diff --git a/PySDM/products/size_spectral/__init__.py b/PySDM/products/size_spectral/__init__.py
index 6cd8d4fa3..ab3d1c267 100644
--- a/PySDM/products/size_spectral/__init__.py
+++ b/PySDM/products/size_spectral/__init__.py
@@ -19,7 +19,7 @@
ActivatedParticleSpecificConcentration,
)
from .particle_size_spectrum import (
- ParticleSizeSpectrumPerMass,
+ ParticleSizeSpectrumPerMassOfDryAir,
ParticleSizeSpectrumPerVolume,
)
from .particle_volume_versus_radius_logarithm_spectrum import (
diff --git a/PySDM/products/size_spectral/particle_size_spectrum.py b/PySDM/products/size_spectral/particle_size_spectrum.py
index 6eb402df1..5e81e8034 100644
--- a/PySDM/products/size_spectral/particle_size_spectrum.py
+++ b/PySDM/products/size_spectral/particle_size_spectrum.py
@@ -1,5 +1,5 @@
"""
-wet radius-binned particle size spectra (per mass of dry air or per volume of air)
+wet- or dry-radius binned particle size spectra (per mass of dry air or per volume of air)
"""
from abc import ABC
@@ -10,19 +10,17 @@
class ParticleSizeSpectrum(SpectrumMomentProduct, ABC):
- def __init__(
- self, *, radius_bins_edges, name, unit, dry=False, normalise_by_dv=False
- ):
+ def __init__(self, *, radius_bins_edges, name, unit, dry=False, specific=False):
self.volume_attr = "dry volume" if dry else "volume"
self.radius_bins_edges = radius_bins_edges
- self.normalise_by_dv = normalise_by_dv
+ self.specific = specific
super().__init__(name=name, unit=unit, attr_unit="m^3")
def register(self, builder):
builder.request_attribute(self.volume_attr)
volume_bins_edges = builder.particulator.formulae.trivia.volume(
- self.radius_bins_edges
+ np.asarray(self.radius_bins_edges)
)
self.attr_bins_edges = builder.particulator.backend.Storage.from_ndarray(
volume_bins_edges
@@ -42,26 +40,28 @@ def _impl(self, **kwargs):
self._download_spectrum_moment_to_buffer(rank=0, bin_number=i)
vals[:, i] = self.buffer.ravel()
- if self.normalise_by_dv:
- vals[:] /= self.particulator.mesh.dv
+ vals[:] /= self.particulator.mesh.dv
+
+ if self.specific:
+ self._download_to_buffer(self.particulator.environment["rhod"])
- self._download_to_buffer(self.particulator.environment["rhod"])
- rhod = self.buffer.ravel()
for i in range(len(self.attr_bins_edges) - 1):
dr = self.formulae.trivia.radius(
volume=self.attr_bins_edges[i + 1]
) - self.formulae.trivia.radius(volume=self.attr_bins_edges[i])
- vals[:, i] /= rhod * dr
+ vals[:, i] /= dr
+ if self.specific:
+ vals[:, i] /= self.buffer.ravel()
return np.squeeze(vals.reshape(self.shape))
-class ParticleSizeSpectrumPerMass(ParticleSizeSpectrum):
+class ParticleSizeSpectrumPerMassOfDryAir(ParticleSizeSpectrum):
def __init__(self, radius_bins_edges, dry=False, name=None, unit="kg^-1 m^-1"):
super().__init__(
radius_bins_edges=radius_bins_edges,
dry=dry,
- normalise_by_dv=True,
+ specific=True,
name=name,
unit=unit,
)
@@ -72,7 +72,7 @@ def __init__(self, radius_bins_edges, dry=False, name=None, unit="m^-3 m^-1"):
super().__init__(
radius_bins_edges=radius_bins_edges,
dry=dry,
- normalise_by_dv=False,
+ specific=False,
name=name,
unit=unit,
)
diff --git a/examples/PySDM_examples/Lowe_et_al_2019/fig_2.ipynb b/examples/PySDM_examples/Lowe_et_al_2019/fig_2.ipynb
index eb8dade7e..3a3ec7f03 100644
--- a/examples/PySDM_examples/Lowe_et_al_2019/fig_2.ipynb
+++ b/examples/PySDM_examples/Lowe_et_al_2019/fig_2.ipynb
@@ -109,20 +109,2122 @@
"outputs": [
{
"data": {
- "image/svg+xml": "\n\n\n",
+ "image/svg+xml": [
+ "\n",
+ "\n",
+ "\n"
+ ],
"text/plain": [
- ""
+ ""
]
},
- "metadata": {
- "needs_background": "light"
- },
+ "metadata": {},
"output_type": "display_data"
},
{
"data": {
"application/vnd.jupyter.widget-view+json": {
- "model_id": "e1729c1f6a5146f7a4cc8edd03643252",
+ "model_id": "1308691ee03242aabea1b23f5074e625",
"version_major": 2,
"version_minor": 0
},
@@ -187,20 +2289,1500 @@
"outputs": [
{
"data": {
- "image/svg+xml": "\n\n\n",
+ "image/svg+xml": [
+ "\n",
+ "\n",
+ "\n"
+ ],
"text/plain": [
- ""
+ ""
]
},
- "metadata": {
- "needs_background": "light"
- },
+ "metadata": {},
"output_type": "display_data"
},
{
"data": {
"application/vnd.jupyter.widget-view+json": {
- "model_id": "16e7f8cc7d94433aadb4b1a085f0118f",
+ "model_id": "c289f4ee3fb443b1bbc8bc6fde5cdc2e",
"version_major": 2,
"version_minor": 0
},
@@ -223,7 +3805,7 @@
"bins = settings.wet_radius_bins_edges / si.um\n",
"binwidths = np.diff(bins)\n",
"for key, out_item in output.items():\n",
- " spectra = out_item['spectrum'] * si.mg * si.um\n",
+ " spectra = out_item['spectrum'] * si.cm**3 * si.um\n",
" pyplot.step(\n",
" bins[:-1],\n",
" uniform_filter1d(spectra * binwidths, size=5),\n",
@@ -235,7 +3817,7 @@
"pyplot.xscale('log')\n",
"pyplot.grid()\n",
"pyplot.xlabel(\"Hydrometeor radius [μm]\")\n",
- "pyplot.ylabel(\"Hydrometeor number concentration\\n size distribution [cm$^{-3}$]\")\n",
+ "pyplot.ylabel(\"Hydrometeor number concentration\\n size distribution [cm$^{-3}$ μm$^{-1}$]\")\n",
"xticks = (4,5,6,7,8,9,10,11,12)\n",
"axs.set_xticks(xticks)\n",
"axs.set_xlim(xticks[0], xticks[-1])\n",
@@ -253,7 +3835,7 @@
],
"metadata": {
"kernelspec": {
- "display_name": "Python 3.10.9 ('pysdm')",
+ "display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
@@ -267,7 +3849,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.10.9"
+ "version": "3.9.2"
},
"vscode": {
"interpreter": {
diff --git a/examples/PySDM_examples/Pyrcel/example_basic_run.ipynb b/examples/PySDM_examples/Pyrcel/example_basic_run.ipynb
index ecc4efbed..604230337 100644
--- a/examples/PySDM_examples/Pyrcel/example_basic_run.ipynb
+++ b/examples/PySDM_examples/Pyrcel/example_basic_run.ipynb
@@ -18,7 +18,7 @@
},
{
"cell_type": "code",
- "execution_count": 28,
+ "execution_count": 1,
"metadata": {
"ExecuteTime": {
"end_time": "2024-02-01T18:50:44.487515Z",
@@ -36,7 +36,7 @@
},
{
"cell_type": "code",
- "execution_count": 29,
+ "execution_count": 2,
"metadata": {
"ExecuteTime": {
"end_time": "2024-02-01T18:50:44.500015Z",
@@ -63,7 +63,7 @@
},
{
"cell_type": "code",
- "execution_count": 30,
+ "execution_count": 3,
"metadata": {
"ExecuteTime": {
"end_time": "2024-02-01T18:50:46.351409Z",
@@ -117,7 +117,7 @@
},
{
"cell_type": "code",
- "execution_count": 31,
+ "execution_count": 4,
"metadata": {
"ExecuteTime": {
"end_time": "2024-02-01T18:50:47.104078Z",
@@ -131,7 +131,7 @@
},
{
"cell_type": "code",
- "execution_count": 32,
+ "execution_count": 5,
"metadata": {
"ExecuteTime": {
"end_time": "2024-02-01T18:50:47.495883Z",
@@ -141,20 +141,2084 @@
"outputs": [
{
"data": {
- "text/plain": "",
- "image/svg+xml": "\n\n\n"
+ "image/svg+xml": [
+ "\n",
+ "\n",
+ "\n"
+ ],
+ "text/plain": [
+ ""
+ ]
},
"metadata": {},
"output_type": "display_data"
},
{
"data": {
- "text/plain": "HTML(value=\"./supersaturation.pdf
\")",
"application/vnd.jupyter.widget-view+json": {
+ "model_id": "d7edbfd94195449a83f1c5d767e5610c",
"version_major": 2,
- "version_minor": 0,
- "model_id": "2e9bd13f8fe743dab4bcd4591f75dedc"
- }
+ "version_minor": 0
+ },
+ "text/plain": [
+ "HTML(value=\"./supersaturation.pdf
\")"
+ ]
},
"metadata": {},
"output_type": "display_data"
@@ -199,7 +2263,7 @@
},
{
"cell_type": "code",
- "execution_count": 33,
+ "execution_count": 6,
"metadata": {
"ExecuteTime": {
"end_time": "2024-02-01T18:50:48.282682Z",
@@ -209,20 +2273,1845 @@
"outputs": [
{
"data": {
- "text/plain": "",
- "image/svg+xml": "\n\n\n"
+ "image/svg+xml": [
+ "\n",
+ "\n",
+ "\n"
+ ],
+ "text/plain": [
+ ""
+ ]
},
"metadata": {},
"output_type": "display_data"
},
{
"data": {
- "text/plain": "HTML(value=\"./size_dist.pdf
\")",
"application/vnd.jupyter.widget-view+json": {
+ "model_id": "86be8c039f3c41409ee42fcc1aaf5210",
"version_major": 2,
- "version_minor": 0,
- "model_id": "62569a1bfc574aa6b66dd2357cb2e517"
- }
+ "version_minor": 0
+ },
+ "text/plain": [
+ "HTML(value=\"./size_dist.pdf
\")"
+ ]
},
"metadata": {},
"output_type": "display_data"
@@ -253,7 +4142,7 @@
],
"metadata": {
"kernelspec": {
- "display_name": "Python 3",
+ "display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
@@ -267,7 +4156,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.8.7"
+ "version": "3.9.2"
}
},
"nbformat": 4,
diff --git a/examples/PySDM_examples/Pyrcel/simulation.py b/examples/PySDM_examples/Pyrcel/simulation.py
index df4e53604..4dee4a685 100644
--- a/examples/PySDM_examples/Pyrcel/simulation.py
+++ b/examples/PySDM_examples/Pyrcel/simulation.py
@@ -25,7 +25,11 @@ def __init__(
)
n_sd = sum(settings.n_sd_per_mode)
builder = Builder(
- n_sd=n_sd, backend=CPU(formulae=settings.formulae), environment=env
+ n_sd=n_sd,
+ backend=CPU(
+ formulae=settings.formulae, override_jit_flags={"parallel": False}
+ ),
+ environment=env,
)
builder.add_dynamic(AmbientThermodynamics())
builder.add_dynamic(Condensation(rtol_thd=rtol_thd, rtol_x=rtol_x))
diff --git a/examples/PySDM_examples/Szumowski_et_al_1998/make_default_product_collection.py b/examples/PySDM_examples/Szumowski_et_al_1998/make_default_product_collection.py
index bde1d04b8..458f2b4d6 100644
--- a/examples/PySDM_examples/Szumowski_et_al_1998/make_default_product_collection.py
+++ b/examples/PySDM_examples/Szumowski_et_al_1998/make_default_product_collection.py
@@ -7,12 +7,12 @@ def make_default_product_collection(settings):
cloud_range = (settings.aerosol_radius_threshold, settings.drizzle_radius_threshold)
products = [
# Note: consider better radius_bins_edges
- PySDM_products.ParticleSizeSpectrumPerMass(
+ PySDM_products.ParticleSizeSpectrumPerMassOfDryAir(
name="Particles Wet Size Spectrum",
unit="mg^-1 um^-1",
radius_bins_edges=settings.r_bins_edges,
),
- PySDM_products.ParticleSizeSpectrumPerMass(
+ PySDM_products.ParticleSizeSpectrumPerMassOfDryAir(
name="Particles Dry Size Spectrum",
unit="mg^-1 um^-1",
radius_bins_edges=settings.r_bins_edges,
diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py
index 523474ada..9700f2eea 100644
--- a/tests/unit_tests/conftest.py
+++ b/tests/unit_tests/conftest.py
@@ -9,6 +9,9 @@ def backend_class(request):
return request.param
-@pytest.fixture(params=(CPU(), GPU()), scope="session")
+@pytest.fixture(
+ params=(pytest.param(CPU(), id="CPU"), pytest.param(GPU(), id="GPU")),
+ scope="session",
+)
def backend_instance(request):
return request.param
diff --git a/tests/unit_tests/products/test_impl.py b/tests/unit_tests/products/test_impl.py
index 81f2f21db..8254d57cb 100644
--- a/tests/unit_tests/products/test_impl.py
+++ b/tests/unit_tests/products/test_impl.py
@@ -27,7 +27,7 @@
MeanVolumeRadius,
NumberSizeSpectrum,
ParcelLiquidWaterPath,
- ParticleSizeSpectrumPerMass,
+ ParticleSizeSpectrumPerMassOfDryAir,
ParticleSizeSpectrumPerVolume,
ParticleVolumeVersusRadiusLogarithmSpectrum,
RadiusBinnedNumberAveragedTerminalVelocity,
@@ -42,7 +42,7 @@
AqueousMassSpectrum: {"key": "S_VI", "dry_radius_bins_edges": (0, np.inf)},
AqueousMoleFraction: {"key": "S_VI"},
TotalDryMassMixingRatio: {"density": 1},
- ParticleSizeSpectrumPerMass: {"radius_bins_edges": (0, np.inf)},
+ ParticleSizeSpectrumPerMassOfDryAir: {"radius_bins_edges": (0, np.inf)},
GaseousMoleFraction: {"key": "O3"},
FreezableSpecificConcentration: {"temperature_bins_edges": (0, 300)},
DynamicWallTime: {"dynamic": "Condensation"},
diff --git a/tests/unit_tests/products/test_particle_size_spectrum.py b/tests/unit_tests/products/test_particle_size_spectrum.py
new file mode 100644
index 000000000..e5f5e0ecb
--- /dev/null
+++ b/tests/unit_tests/products/test_particle_size_spectrum.py
@@ -0,0 +1,105 @@
+"""
+tests the ParticleSizeSpectrum product against per-mass/per-volume and dry-/wet-size choices
+"""
+
+import pytest
+import numpy as np
+from PySDM import Builder
+from PySDM.physics import si
+from PySDM.environments import Box
+from PySDM.products import (
+ ParticleSizeSpectrumPerMassOfDryAir,
+ ParticleSizeSpectrumPerVolume,
+)
+
+
+class TestParticleSizeSpectrum:
+ @staticmethod
+ @pytest.mark.parametrize(
+ "product_class, specific, unit",
+ (
+ (ParticleSizeSpectrumPerVolume, False, "1.0 / meter ** 4"),
+ (ParticleSizeSpectrumPerMassOfDryAir, True, "1.0 / kilogram / meter"),
+ ),
+ )
+ def test_specific_flag(product_class, specific, unit, backend_instance):
+ """checks if the reported concentration is correctly normalised per
+ volume or mass of air, and per bin size"""
+ # arrange
+ dv = 666 * si.m**3
+ multiplicity = 1000
+ rhod = 44 * si.kg / si.m**3
+ min_size = 0
+ max_size = 1 * si.mm
+
+ n_sd = 1
+ box = Box(dt=np.nan, dv=dv)
+ builder = Builder(n_sd=n_sd, backend=backend_instance, environment=box)
+ sut = product_class(radius_bins_edges=(min_size, max_size))
+ builder.build(
+ products=(sut,),
+ attributes={
+ "multiplicity": np.ones(n_sd) * multiplicity,
+ "water mass": np.ones(n_sd) * si.ug,
+ },
+ )
+ box["rhod"] = rhod
+
+ # act
+ actual = sut.get()
+
+ # assert
+ assert sut.unit == unit
+ assert sut.specific == specific
+ np.testing.assert_approx_equal(
+ actual=actual,
+ desired=multiplicity
+ / dv
+ / (max_size - min_size)
+ / (rhod if specific else 1),
+ significant=10,
+ )
+
+ @staticmethod
+ @pytest.mark.parametrize(
+ "product_class",
+ (ParticleSizeSpectrumPerVolume, ParticleSizeSpectrumPerMassOfDryAir),
+ )
+ @pytest.mark.parametrize("dry", (True, False))
+ def test_dry_flag(product_class, dry, backend_instance):
+ """checks if dry or wet size attribute is correctly picked for moment calculation"""
+ # arrange
+ n_sd = 1
+ dv = 666 * si.m**3
+ min_size = 0
+ max_size = 1 * si.mm
+ multiplicity = 100
+ rhod = 44 * si.kg / si.m**3
+ wet_volume = 1 * si.um**3
+ dry_volume = 0.01 * si.um**3
+
+ box = Box(dt=np.nan, dv=dv)
+ builder = Builder(n_sd=n_sd, backend=backend_instance, environment=box)
+ sut = product_class(radius_bins_edges=(min_size, max_size), dry=dry)
+ builder.build(
+ products=(sut,),
+ attributes={
+ "multiplicity": np.ones(n_sd) * multiplicity,
+ "volume": np.ones(n_sd) * (np.nan if dry else wet_volume),
+ "dry volume": np.ones(n_sd) * (dry_volume if dry else np.nan),
+ },
+ )
+ box["rhod"] = rhod
+
+ # act
+ actual = sut.get()
+
+ # assert
+ np.testing.assert_approx_equal(
+ actual=actual,
+ desired=multiplicity
+ / dv
+ / (max_size - min_size)
+ / (rhod if sut.specific else 1),
+ significant=10,
+ )