diff --git a/.github/workflows/tests+artifacts+pypi.yml b/.github/workflows/tests+artifacts+pypi.yml index 094824944..74ea7e90f 100644 --- a/.github/workflows/tests+artifacts+pypi.yml +++ b/.github/workflows/tests+artifacts+pypi.yml @@ -102,10 +102,11 @@ jobs: pip install -e .[tests] - run: | # TODO #682 - pylint --unsafe-load-any-extension=y --disable=fixme,invalid-name,missing-function-docstring,missing-class-docstring,protected-access,duplicate-code $(git ls-files '*.py' | grep -v ^examples) + pylint --unsafe-load-any-extension=y --disable=fixme,invalid-name,missing-function-docstring,missing-class-docstring,protected-access,duplicate-code $(git ls-files '*.py' | grep -v -e ^examples -e ^tutorials) - run: | # TODO #682 pylint --max-module-lines=550 --unsafe-load-any-extension=y --disable=fixme,too-many-function-args,unsubscriptable-object,consider-using-with,protected-access,too-many-statements,too-many-public-methods,too-many-branches,duplicate-code,invalid-name,missing-function-docstring,missing-module-docstring,missing-class-docstring,too-many-locals,too-many-instance-attributes,too-few-public-methods,too-many-arguments,c-extension-no-member $(git ls-files '*.py' | grep ^examples) + pylint --max-module-lines=550 --unsafe-load-any-extension=y --disable=fixme,too-many-function-args,unsubscriptable-object,consider-using-with,protected-access,too-many-statements,too-many-public-methods,too-many-branches,duplicate-code,invalid-name,missing-function-docstring,missing-module-docstring,missing-class-docstring,too-many-locals,too-many-instance-attributes,too-few-public-methods,too-many-arguments,c-extension-no-member $(git ls-files '*.py' | grep ^tutorials) - run: | # TODO #682 nbqa pylint --unsafe-load-any-extension=y --disable=fixme,duplicate-code,invalid-name,trailing-whitespace,line-too-long,missing-function-docstring,wrong-import-position,missing-module-docstring,wrong-import-order,ungrouped-imports,no-member,too-many-locals,unnecessary-lambda-assignment $(git ls-files '*.ipynb') @@ -125,7 +126,7 @@ jobs: matrix: platform: [ubuntu-latest, macos-12, windows-latest] python-version: ["3.8", "3.10"] - test-suite: ["unit_tests", "smoke_tests/no_env", "smoke_tests/box", "smoke_tests/parcel", "smoke_tests/kinematic_1d", "smoke_tests/kinematic_2d"] + test-suite: ["unit_tests", "smoke_tests/no_env", "smoke_tests/box", "smoke_tests/parcel", "smoke_tests/kinematic_1d", "smoke_tests/kinematic_2d", "tutorials_tests"] exclude: - test-suite: "devops_tests" python-version: "3.8" @@ -160,6 +161,10 @@ jobs: - if: startsWith(matrix.platform, 'ubuntu-') run: echo NUMBA_THREADING_LAYER=omp >> $GITHUB_ENV + # install devops_tests for tutorials_tests + - if: matrix.test-suite == 'tutorials_tests' + run: pip install -r tests/devops_tests/requirements.txt + - env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: pytest --durations=10 --timeout=900 --timeout_method=thread -p no:unraisableexception -We tests/${{ matrix.test-suite }} diff --git a/README.md b/README.md index 58ebafef2..9762bbcbe 100644 --- a/README.md +++ b/README.md @@ -37,10 +37,17 @@ The package features a Pythonic high-performance implementation of the There is a growing set of example Jupyter notebooks exemplifying how to perform various types of calculations and simulations using PySDM. -Most of the example notebooks reproduce resutls and plot from literature, see below for +Most of the example notebooks reproduce results and plot from literature, see below for a list of examples and links to the notebooks (which can be either executed or viewed "in the cloud"). +There are also a growing set of tutorials, also in the form of Jupyter notebooks. +These tutorials are intended for teaching purposes and include short explanations of cloud microphysical + concepts paired with widgets for running interactive simulations using PySDM. +Each tutorial also comes with a set of questions at the end that can be used as homework problems. +Like the examples, these tutorials can be executed or viewed "in the cloud" making it an especially + easy way for students to get started. + PySDM has two alternative parallel number-crunching backends available: multi-threaded CPU backend based on [Numba](http://numba.pydata.org/) and GPU-resident backend built on top of [ThrustRTC](https://pypi.org/project/ThrustRTC/). diff --git a/examples/PySDM_examples/Pyrcel/profile_plotter.py b/examples/PySDM_examples/Pyrcel/profile_plotter.py new file mode 100644 index 000000000..1be4a431f --- /dev/null +++ b/examples/PySDM_examples/Pyrcel/profile_plotter.py @@ -0,0 +1,67 @@ +import numpy as np +from matplotlib import pyplot +from open_atmos_jupyter_utils import show_plot + +from PySDM.physics.constants import si + + +class ProfilePlotter: + def __init__(self, settings, legend=True, log_base=10): + self.settings = settings + self.format = "pdf" + self.legend = legend + self.log_base = log_base + self.ax = pyplot + self.fig = pyplot + + def show(self): + pyplot.tight_layout() + show_plot() + + def save(self, file): + # self.finish() + pyplot.savefig(file, format=self.format) + + def plot(self, output): + self.plot_data(self.settings, output) + + def plot_data(self, settings, output): + _, axs = pyplot.subplots(1, 2, sharey=True, figsize=(10, 5)) + axS = axs[0] + axS.plot( + np.asarray(output["products"]["S_max"]) - 100, + output["products"]["z"], + color="black", + ) + axS.set_ylabel("Displacement [m]") + axS.set_xlabel("Supersaturation [%]") + axS.set_xlim(0, 0.7) + axS.set_ylim(0, 250) + axS.text(0.3, 52, f"max S = {np.nanmax(output['products']['S_max'])-100:.2f}%") + axS.grid() + + axT = axS.twiny() + axT.xaxis.label.set_color("red") + axT.tick_params(axis="x", colors="red") + axT.plot(output["products"]["T"], output["products"]["z"], color="red") + rng = (272, 274) + axT.set_xlim(*rng) + axT.set_xticks(np.linspace(*rng, num=5)) + axT.set_xlabel("Temperature [K]") + + axR = axs[1] + axR.set_xscale("log") + axR.set_xlim(1e-2, 1e2) + for drop_id, volume in enumerate(output["attributes"]["volume"]): + axR.plot( + settings.formulae.trivia.radius(volume=np.asarray(volume)) / si.um, + output["products"]["z"], + color="magenta" if drop_id < settings.n_sd_per_mode[0] else "blue", + label="mode 1" + if drop_id == 0 + else "mode 2" + if drop_id == settings.n_sd_per_mode[0] + else "", + ) + axR.legend(loc="upper right") + axR.set_xlabel("Droplet radius [μm]") diff --git a/examples/PySDM_examples/Pyrcel/tutorial_settings.py b/examples/PySDM_examples/Pyrcel/tutorial_settings.py new file mode 100644 index 000000000..12838a2e3 --- /dev/null +++ b/examples/PySDM_examples/Pyrcel/tutorial_settings.py @@ -0,0 +1,68 @@ +from typing import Dict + +import numpy as np +from pystrict import strict + +from PySDM import Formulae +from PySDM.initialisation.impl.spectrum import Spectrum + + +@strict +class Settings: + def __init__( + self, + dz: float, + n_sd_per_mode: tuple, + aerosol_modes_by_kappa: Dict[float, Spectrum], + vertical_velocity: float, + initial_temperature: float, + initial_pressure: float, + initial_relative_humidity: float, + displacement: float, + formulae: Formulae, + ): + self.formulae = formulae + self.n_sd_per_mode = n_sd_per_mode + self.aerosol_modes_by_kappa = aerosol_modes_by_kappa + + const = self.formulae.constants + self.vertical_velocity = vertical_velocity + self.initial_pressure = initial_pressure + self.initial_temperature = initial_temperature + pv0 = ( + initial_relative_humidity + * formulae.saturation_vapour_pressure.pvs_Celsius( + initial_temperature - const.T0 + ) + ) + self.initial_vapour_mixing_ratio = const.eps * pv0 / (initial_pressure - pv0) + self.t_max = displacement / vertical_velocity + self.timestep = dz / vertical_velocity + self.output_interval = self.timestep + + @property + def initial_air_density(self): + const = self.formulae.constants + dry_air_density = ( + self.formulae.trivia.p_d( + self.initial_pressure, self.initial_vapour_mixing_ratio + ) + / self.initial_temperature + / const.Rd + ) + return dry_air_density * (1 + self.initial_vapour_mixing_ratio) + + @property + def nt(self) -> int: + nt = self.t_max / self.timestep + nt_int = round(nt) + np.testing.assert_almost_equal(nt, nt_int) + return nt_int + + @property + def steps_per_output_interval(self) -> int: + return int(self.output_interval / self.timestep) + + @property + def output_steps(self) -> np.ndarray: + return np.arange(0, self.nt + 1, self.steps_per_output_interval) diff --git a/examples/PySDM_examples/Pyrcel/tutorial_simulation.py b/examples/PySDM_examples/Pyrcel/tutorial_simulation.py new file mode 100644 index 000000000..feb17c72b --- /dev/null +++ b/examples/PySDM_examples/Pyrcel/tutorial_simulation.py @@ -0,0 +1,90 @@ +import numpy as np +from PySDM_examples.utils import BasicSimulation + +from PySDM import Builder +from PySDM.backends import CPU +from PySDM.backends.impl_numba.test_helpers import scipy_ode_condensation_solver +from PySDM.dynamics import AmbientThermodynamics, Condensation +from PySDM.environments import Parcel +from PySDM.initialisation import equilibrate_wet_radii +from PySDM.initialisation.sampling.spectral_sampling import ConstantMultiplicity +from PySDM.physics import si + + +class Simulation(BasicSimulation): + def __init__( + self, settings, products=None, scipy_solver=False, rtol_thd=1e-10, rtol_x=1e-10 + ): + env = Parcel( + dt=settings.timestep, + p0=settings.initial_pressure, + initial_water_vapour_mixing_ratio=settings.initial_vapour_mixing_ratio, + T0=settings.initial_temperature, + w=settings.vertical_velocity, + mass_of_dry_air=44 * si.kg, + ) + n_sd = sum(settings.n_sd_per_mode) + builder = Builder(n_sd=n_sd, backend=CPU(formulae=settings.formulae)) + builder.set_environment(env) + builder.add_dynamic(AmbientThermodynamics()) + builder.add_dynamic(Condensation(rtol_thd=rtol_thd, rtol_x=rtol_x)) + + volume = env.mass_of_dry_air / settings.initial_air_density + attributes = { + k: np.empty(0) for k in ("dry volume", "kappa times dry volume", "n") + } + for i, (kappa, spectrum) in enumerate(settings.aerosol_modes_by_kappa.items()): + sampling = ConstantMultiplicity(spectrum) + r_dry, n_per_volume = sampling.sample(settings.n_sd_per_mode[i]) + v_dry = settings.formulae.trivia.volume(radius=r_dry) + attributes["n"] = np.append(attributes["n"], n_per_volume * volume) + attributes["dry volume"] = np.append(attributes["dry volume"], v_dry) + attributes["kappa times dry volume"] = np.append( + attributes["kappa times dry volume"], v_dry * kappa + ) + r_wet = equilibrate_wet_radii( + r_dry=settings.formulae.trivia.radius(volume=attributes["dry volume"]), + environment=env, + kappa_times_dry_volume=attributes["kappa times dry volume"], + ) + attributes["volume"] = settings.formulae.trivia.volume(radius=r_wet) + + super().__init__( + particulator=builder.build(attributes=attributes, products=products) + ) + if scipy_solver: + scipy_ode_condensation_solver.patch_particulator(self.particulator) + + self.output_attributes = { + "volume": tuple([] for _ in range(self.particulator.n_sd)) + } + self.settings = settings + + self.__sanity_checks(attributes, volume) + + def __sanity_checks(self, attributes, volume): + for attribute in attributes.values(): + assert attribute.shape[0] == self.particulator.n_sd + np.testing.assert_approx_equal( + sum(attributes["multiplicity"]) / volume, + sum( + mode.norm_factor + for mode in self.settings.aerosol_modes_by_kappa.values() + ), + significant=4, + ) + + def _save(self, output): + for key, attr in self.output_attributes.items(): + attr_data = self.particulator.attributes[key].to_ndarray() + for drop_id in range(self.particulator.n_sd): + attr[drop_id].append(attr_data[drop_id]) + super()._save(output) + + def run(self, observers=()): + for observer in observers: + self.particulator.observers.append(observer) + output_products = super()._run( + self.settings.nt, self.settings.steps_per_output_interval + ) + return {"products": output_products, "attributes": self.output_attributes} diff --git a/examples/PySDM_examples/Shima_et_al_2009/tutorial_example.py b/examples/PySDM_examples/Shima_et_al_2009/tutorial_example.py new file mode 100644 index 000000000..123cc676b --- /dev/null +++ b/examples/PySDM_examples/Shima_et_al_2009/tutorial_example.py @@ -0,0 +1,42 @@ +from PySDM.backends import CPU +from PySDM.builder import Builder +from PySDM.dynamics import Coalescence +from PySDM.environments import Box +from PySDM.initialisation.sampling.spectral_sampling import ConstantMultiplicity +from PySDM.products import ParticleVolumeVersusRadiusLogarithmSpectrum, WallTime + + +def run(settings, observers=()): + builder = Builder(n_sd=settings.n_sd, backend=CPU(formulae=settings.formulae)) + builder.set_environment(Box(dv=settings.dv, dt=settings.dt)) + attributes = {} + sampling = ConstantMultiplicity(settings.spectrum) + attributes["volume"], attributes["n"] = sampling.sample(settings.n_sd) + coalescence = Coalescence( + collision_kernel=settings.kernel, adaptive=settings.adaptive + ) + builder.add_dynamic(coalescence) + products = ( + ParticleVolumeVersusRadiusLogarithmSpectrum( + settings.radius_bins_edges, name="dv/dlnr" + ), + WallTime(), + ) + particulator = builder.build(attributes, products) + if hasattr(settings, "u_term") and "terminal velocity" in particulator.attributes: + particulator.attributes["terminal velocity"].approximation = settings.u_term( + particulator + ) + + for observer in observers: + particulator.observers.append(observer) + + vals = {} + particulator.products["wall time"].reset() + for step in settings.output_steps: + particulator.run(step - particulator.n_steps) + vals[step] = particulator.products["dv/dlnr"].get()[0] + vals[step][:] *= settings.rho + + exec_time = particulator.products["wall time"].get() + return vals, exec_time diff --git a/examples/PySDM_examples/Shima_et_al_2009/tutorial_plotter.py b/examples/PySDM_examples/Shima_et_al_2009/tutorial_plotter.py new file mode 100644 index 000000000..ec4f239ab --- /dev/null +++ b/examples/PySDM_examples/Shima_et_al_2009/tutorial_plotter.py @@ -0,0 +1,129 @@ +import numpy as np +from matplotlib import pyplot +from open_atmos_jupyter_utils import show_plot + +from PySDM.physics.constants import si + + +class SpectrumColors: + def __init__(self, begining="#2cbdfe", end="#b317b1"): + self.b = begining + self.e = end + + def __call__(self, value: float): + bR, bG, bB = int(self.b[1:3], 16), int(self.b[3:5], 16), int(self.b[5:7], 16) + eR, eG, eB = int(self.e[1:3], 16), int(self.e[3:5], 16), int(self.e[5:7], 16) + R = bR + int((eR - bR) * value) + G = bG + int((eG - bG) * value) + B = bB + int((eB - bB) * value) + result = f"#{hex(R)[2:4]}{hex(G)[2:4]}{hex(B)[2:4]}" + return result + + +class SpectrumPlotter: + def __init__(self, settings, grid=True, legend=True, log_base=10): + self.settings = settings + self.format = "pdf" + self.colors = SpectrumColors() + self.smooth = False + self.smooth_scope = 2 + self.legend = legend + self.grid = grid + self.xlabel = "particle radius [µm]" + self.ylabel = "dm/dlnr [g/m$^3$]" + self.log_base = log_base + self.ax = pyplot + self.fig = pyplot + self.finished = False + + def finish(self): + if self.finished: + return + self.finished = True + if self.grid: + self.ax.grid() + + self.ax.xscale("log") + self.ax.xlabel(self.xlabel) + self.ax.ylabel(self.ylabel) + if self.legend: + self.ax.legend() + + def show(self): + self.finish() + pyplot.tight_layout() + show_plot() + + def save(self, file): + self.finish() + pyplot.savefig(file, format=self.format) + + def plot(self, spectrum, t): + self.plot_analytic_solution(self.settings, t) + self.plot_data(self.settings, t, spectrum) + + def plot_analytic_solution(self, settings, t): + def analytic_solution(x): + return settings.norm_factor * settings.kernel.analytic_solution( + x=x, t=t, x_0=settings.X0, N_0=settings.n_part + ) + + if t == 0: + analytic_solution = settings.spectrum.size_distribution + + volume_bins_edges = self.settings.formulae.trivia.volume( + settings.radius_bins_edges + ) + dm = np.diff(volume_bins_edges) + dr = np.diff(settings.radius_bins_edges) + + pdf_m_x = volume_bins_edges[:-1] + dm / 2 + pdf_m_y = analytic_solution(pdf_m_x) + + pdf_r_x = settings.radius_bins_edges[:-1] + dr / 2 + pdf_r_y = pdf_m_y * dm / dr * pdf_r_x + + x = pdf_r_x * si.metres / si.micrometres + y_true = ( + pdf_r_y + * self.settings.formulae.trivia.volume(radius=pdf_r_x) + * settings.rho + / settings.dv + * si.kilograms + / si.grams + ) + + self.ax.plot(x, y_true, color="black") + + def plot_data(self, settings, t, spectrum): + if self.smooth: + scope = self.smooth_scope + if t != 0: + new = np.copy(spectrum) + for _ in range(2): + for i in range(scope, len(spectrum) - scope): + new[i] = np.mean(spectrum[i - scope : i + scope + 1]) + scope = 1 + for i in range(scope, len(spectrum) - scope): + spectrum[i] = np.mean(new[i - scope : i + scope + 1]) + + x = settings.radius_bins_edges[:-scope] + dx = np.diff(x) + self.ax.plot( + (x[:-1] + dx / 2) * si.metres / si.micrometres, + spectrum[:-scope] * si.kilograms / si.grams, + label=f"t = {t}s", + color=self.colors( + t / (self.settings.output_steps[-1] * self.settings.dt) + ), + ) + else: + self.ax.step( + settings.radius_bins_edges[:-1] * si.metres / si.micrometres, + spectrum * si.kilograms / si.grams, + where="post", + label=f"t = {t}s", + color=self.colors( + t / (self.settings.output_steps[-1] * self.settings.dt) + ), + ) diff --git a/examples/PySDM_examples/Shima_et_al_2009/tutorial_settings.py b/examples/PySDM_examples/Shima_et_al_2009/tutorial_settings.py new file mode 100644 index 000000000..6343e518d --- /dev/null +++ b/examples/PySDM_examples/Shima_et_al_2009/tutorial_settings.py @@ -0,0 +1,35 @@ +from typing import Optional + +import numpy as np +from pystrict import strict + +from PySDM import Formulae +from PySDM.dynamics.collisions.collision_kernels import Golovin +from PySDM.initialisation import spectra +from PySDM.physics import si + + +@strict +class Settings: + def __init__(self, steps: Optional[list] = None): + steps = steps or [0, 1200, 2400, 3600] + self.formulae = Formulae() + self.n_sd = 2**13 + self.n_part = 2**23 / si.metre**3 + self.X0 = self.formulae.trivia.volume(radius=30.531 * si.micrometres) + self.dv = 1e6 * si.metres**3 + self.norm_factor = self.n_part * self.dv + self.rho = 1000 * si.kilogram / si.metre**3 + self.dt = 1 * si.seconds + self.adaptive = False + self.seed = 44 + self.steps = steps + self.kernel = Golovin(b=1.5e3 / si.second) + self.spectrum = spectra.Exponential(norm_factor=self.norm_factor, scale=self.X0) + self.radius_bins_edges = np.logspace( + np.log10(10 * si.um), np.log10(5e4 * si.um), num=256, endpoint=True + ) + + @property + def output_steps(self): + return [int(step / self.dt) for step in self.steps] diff --git a/tests/tutorials_tests/__init__.py b/tests/tutorials_tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/tutorials_tests/conftest.py b/tests/tutorials_tests/conftest.py new file mode 100644 index 000000000..f6c0d7a1c --- /dev/null +++ b/tests/tutorials_tests/conftest.py @@ -0,0 +1,17 @@ +# pylint: disable=missing-module-docstring +import pathlib + +import pytest + +from ..examples_tests.conftest import findfiles + +PYSDM_TUTORIALS_ABS_PATH = ( + pathlib.Path(__file__).parent.parent.parent.absolute().joinpath("tutorials") +) + + +@pytest.fixture( + params=(path for path in findfiles(PYSDM_TUTORIALS_ABS_PATH, r".*\.(ipynb)$")), +) +def notebook_filename(request): + return request.param diff --git a/tests/tutorials_tests/test_run_notebooks.py b/tests/tutorials_tests/test_run_notebooks.py new file mode 100644 index 000000000..026173cff --- /dev/null +++ b/tests/tutorials_tests/test_run_notebooks.py @@ -0,0 +1,6 @@ +# pylint: disable=missing-module-docstring +from ..devops_tests.test_notebooks import test_run_notebooks as _impl + + +def test_run_notebooks(notebook_filename, tmp_path): + _impl(notebook_filename, tmp_path) diff --git a/tutorials/collisions/__init__.py b/tutorials/collisions/__init__.py new file mode 100644 index 000000000..44b071d42 --- /dev/null +++ b/tutorials/collisions/__init__.py @@ -0,0 +1,5 @@ +# pylint: disable=invalid-name +""" +collision-coalescence tutorial following setup from +[Shima et al. 2009](https://doi.org/10.1002/qj.441) +""" diff --git a/tutorials/collisions/collection_droplet.svg b/tutorials/collisions/collection_droplet.svg new file mode 100644 index 000000000..d8bac55fc --- /dev/null +++ b/tutorials/collisions/collection_droplet.svg @@ -0,0 +1,169 @@ + diff --git a/tutorials/collisions/collisions_playground.ipynb b/tutorials/collisions/collisions_playground.ipynb new file mode 100644 index 000000000..177b09f45 --- /dev/null +++ b/tutorials/collisions/collisions_playground.ipynb @@ -0,0 +1,241 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "[![View notebook](https://img.shields.io/static/v1?label=render%20on&logo=github&color=87ce3e&message=GitHub)](https://github.com/open-atmos/PySDM/tree/main/tutorials/collisions/collisions_playground.ipynb) \n", + "[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/open-atmos/PySDM/tree/main/tutorials/collisions/collisions_playground.ipynb) \n", + "[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/open-atmos/PySDM/tree/main/tutorials/collisions/collisions_playground.ipynb)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Cloud Microphysics: Part 2\n", + "- Collisions and coalescence of cloud droplets\n", + "\n", + "Based on Fig. 2 from Shima et al. 2009 (Q. J. R. Meteorol. Soc. 135) \"_The super‐droplet method for the numerical simulation of clouds and precipitation: a particle‐based and probabilistic microphysics model coupled with a non‐hydrostatic model_.\" \n", + "https://doi.org/10.1002/qj.441" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Collision/coalescence \n", + "\n", + "The process of droplets colliding and coalescence, together refered to as collection, is the mechanism by which cloud droplets grow and eventually grow large enough to precipitate.\n", + "The collection process depends on these two processes, first two droplets colliding, and second that collision resulting in the coalescence of a new larger droplet.\n", + "\n", + "In models we parameterize this collection process stochastically by solving what is known as the SCE: Stochastic Collection Equation.\n", + "And we write the probability that two droplets collide (collision rate) in terms of a \"kernel\": $K(x,y)$, where $x$ and $y$ are the sizes of the two droplets.\n", + "\n", + "In this example, we consider the most basic kernel called the Golovin kernel, which is a linear kernel of the form $K(x,y) = b(x+y)$.\n", + "\n", + "Below is a drawing from Lamb and Verlinde's \"_The Physics and Chemistry of Clouds_\" illustrating the geometry of droplet collisions.\n", + "\n", + "" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## PySDM box model widget\n", + "\n", + "In this homework assignment, and with this `PySDM` example notebook, you have the chance to explore how particle size, collision kernel, and spectral resolution (number of superdroplets used to represent the droplet size distribution) influence the growth of a population of cloud droplets as they undergo collision and coalescence. \n", + "\n", + "In this box setup, we can focus on only the collision/coalescence process while ignoring the hygroscopic growth of particles and activation of aerosols considered in Part 1, as well as fluid flow and mixing from a 2D or 3D simulation." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2023-10-16T10:50:29.614596281Z", + "start_time": "2023-10-16T10:50:29.474658084Z" + } + }, + "outputs": [], + "source": [ + "import sys\n", + "if 'google.colab' in sys.modules:\n", + " !pip --quiet install \"open_atmos_jupyter_utils\"\n", + " from open_atmos_jupyter_utils import pip_install_on_colab\n", + " pip_install_on_colab('PySDM')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2023-10-16T10:50:34.479445450Z", + "start_time": "2023-10-16T10:50:29.475070307Z" + } + }, + "outputs": [], + "source": [ + "from numpy import errstate\n", + "import os\n", + "\n", + "from PySDM import Formulae\n", + "from PySDM.dynamics.collisions.collision_kernels import Golovin\n", + "from PySDM.initialisation import spectra\n", + "from PySDM.physics import si\n", + "\n", + "from PySDM_examples.utils import widgets\n", + "\n", + "from PySDM_examples.Shima_et_al_2009.tutorial_plotter import SpectrumPlotter\n", + "from PySDM_examples.Shima_et_al_2009.tutorial_settings import Settings\n", + "from PySDM_examples.Shima_et_al_2009.tutorial_example import run" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2023-10-16T10:50:34.608979949Z", + "start_time": "2023-10-16T10:50:34.508186707Z" + } + }, + "outputs": [], + "source": [ + "def demo(*, _freezer, _n, _b, _r, _smooth):\n", + " frm = Formulae()\n", + " with _freezer:\n", + " with errstate(all='raise'):\n", + " n_step = 3600\n", + " n_plot = 3\n", + " settings = Settings(steps=[i * (n_step // n_plot) for i in range(n_plot + 1)])\n", + " settings.n_sd = 2 ** _n\n", + " settings.adaptive = True\n", + " settings.dt = 10\n", + " settings.kernel = Golovin(b=_b / si.second)\n", + " settings.X0 = frm.trivia.volume(radius=_r * si.micrometres)\n", + " settings.spectrum = spectra.Exponential(\n", + " norm_factor=settings.norm_factor, scale=settings.X0\n", + " )\n", + " states, _ = run(settings, (widgets.ProgbarUpdater(progbar, settings.output_steps[-1]),))\n", + "\n", + " with errstate(invalid='ignore'):\n", + " plotter = SpectrumPlotter(settings)\n", + " plotter.smooth = _smooth\n", + " for step, state in states.items():\n", + " plotter.plot(state, step * settings.dt)\n", + " plotter.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Widget\n", + "\n", + "Play around with the widget and change the number of superdroplets ($n_{SD} = 2^X$), slope parameter ($b$) in the Golovin collision kernel, and the scale parameter in the droplet size distribution ($r$).\n", + "\n", + "