From d511cb44fccbe0f671efc766954cef9fb269a199 Mon Sep 17 00:00:00 2001 From: Alicja Date: Wed, 18 Mar 2026 14:59:18 +0100 Subject: [PATCH 1/6] fixing Lognormal ctor parameter name (median vs. mode mismatch). Closes #1808 --- PySDM/initialisation/spectra/lognormal.py | 10 +++---- .../initialisation/test_spectra_lognormal.py | 30 +++++++++++++------ 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/PySDM/initialisation/spectra/lognormal.py b/PySDM/initialisation/spectra/lognormal.py index 1a318dee90..56cfaf416b 100644 --- a/PySDM/initialisation/spectra/lognormal.py +++ b/PySDM/initialisation/spectra/lognormal.py @@ -11,11 +11,11 @@ class Lognormal(Spectrum): - def __init__(self, norm_factor: float, m_mode: float, s_geom: float): + def __init__(self, *, norm_factor: float, median: float, s_geom: float): """`norm_factor=1` corresponds to standard normalised probability density, other settings allow to express, e.g., size or mass distributions; `m_mode` is the median value, `s_geom` is the geometric standard deviation""" - super().__init__(lognorm, (math.log(s_geom), 0, m_mode), norm_factor) + super().__init__(lognorm, (math.log(s_geom), 0, median), norm_factor) @property def s_geom(self): @@ -23,11 +23,11 @@ def s_geom(self): @property def m_mode(self): - return self.distribution_params[2] + raise NotImplementedError() @property def median(self): - return self.m_mode + return self.median @property def geometric_mean(self): @@ -37,6 +37,6 @@ def __str__(self): return ( f"{self.__class__.__name__}:" f" (N={self.norm_factor:.3g}," - f" m_mode={self.m_mode:.3g}," + f" median={self.median:.3g}," f" s_geom={self.s_geom:.3g})" ) diff --git a/tests/unit_tests/initialisation/test_spectra_lognormal.py b/tests/unit_tests/initialisation/test_spectra_lognormal.py index 7ca5a1d64e..8b2a4a1876 100644 --- a/tests/unit_tests/initialisation/test_spectra_lognormal.py +++ b/tests/unit_tests/initialisation/test_spectra_lognormal.py @@ -10,43 +10,55 @@ class TestSpectraLognormal: @staticmethod @pytest.mark.parametrize( - "m_mode, s_geom", + "median, s_geom", ( (0.01, 1.5), (0.1, 1.1), (1, 1.01), ), ) - def test_median(m_mode, s_geom): + def test_median(median, s_geom): # arrange - sut = Lognormal(m_mode=m_mode, norm_factor=1, s_geom=s_geom) + sut = Lognormal(median=median, norm_factor=1, s_geom=s_geom) # act median = sut.percentiles(0.5) # assert - assert median == m_mode + assert median == median @staticmethod @pytest.mark.parametrize( - "m_mode, s_geom", + "median, s_geom", ( (0.01, 3.5), (0.1, 2.1), (1, 1.5), ), ) - def test_mean(m_mode, s_geom): + def test_mean(median, s_geom): # arrange - sut = Lognormal(m_mode=m_mode, norm_factor=1, s_geom=s_geom) - x = np.linspace(m_mode / 1000, m_mode * 1000, num=10000) + sut = Lognormal(median=median, norm_factor=1, s_geom=s_geom) + x = np.linspace(median / 1000, median * 1000, num=10000) # act mean = np.sum(sut.pdf(x) * x) / np.sum(sut.pdf(x)) # assert np.testing.assert_approx_equal( - actual=np.log(m_mode) + 0.5 * np.log(s_geom) ** 2, + actual=np.log(median) + 0.5 * np.log(s_geom) ** 2, desired=np.log(mean), significant=3, ) + + @staticmethod + def test_mode(): + # TODO + spectrum = Lognormal(median=...) + assert False + + @staticmethod + def test_instantiation_passing_mode(): + # TODO + spectrum = Lognormal(mode=...) + assert False \ No newline at end of file From 34544f182f2bbad40edcecf8ef34a71cdc1a74d1 Mon Sep 17 00:00:00 2001 From: Alicja Date: Mon, 13 Apr 2026 16:03:49 +0200 Subject: [PATCH 2/6] draft of test + ctor accepting either median or mode --- PySDM/initialisation/spectra/lognormal.py | 8 +++----- tests/unit_tests/initialisation/test_spectra_lognormal.py | 8 +++++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/PySDM/initialisation/spectra/lognormal.py b/PySDM/initialisation/spectra/lognormal.py index 56cfaf416b..9c560acd2c 100644 --- a/PySDM/initialisation/spectra/lognormal.py +++ b/PySDM/initialisation/spectra/lognormal.py @@ -11,10 +11,12 @@ class Lognormal(Spectrum): - def __init__(self, *, norm_factor: float, median: float, s_geom: float): + def __init__(self, *, norm_factor: float, median = None, s_geom: float, mode = None): """`norm_factor=1` corresponds to standard normalised probability density, other settings allow to express, e.g., size or mass distributions; `m_mode` is the median value, `s_geom` is the geometric standard deviation""" + assert (median is None) ^ (mode is None) + self.median = median or mode * math.exp(s_geom**2) super().__init__(lognorm, (math.log(s_geom), 0, median), norm_factor) @property @@ -25,10 +27,6 @@ def s_geom(self): def m_mode(self): raise NotImplementedError() - @property - def median(self): - return self.median - @property def geometric_mean(self): return self.s_geom diff --git a/tests/unit_tests/initialisation/test_spectra_lognormal.py b/tests/unit_tests/initialisation/test_spectra_lognormal.py index 8b2a4a1876..186b7becc1 100644 --- a/tests/unit_tests/initialisation/test_spectra_lognormal.py +++ b/tests/unit_tests/initialisation/test_spectra_lognormal.py @@ -59,6 +59,8 @@ def test_mode(): @staticmethod def test_instantiation_passing_mode(): - # TODO - spectrum = Lognormal(mode=...) - assert False \ No newline at end of file + # arrange & act + spectrum = Lognormal(mode=1, s_geom=1, norm_factor=1) + + # act + np.testing.assert_approx_equal(actual=spectrum.median, desired=1) From edb00a6d63a1b964bb764dc5d9740ed4b18414aa Mon Sep 17 00:00:00 2001 From: Alicja Date: Mon, 13 Apr 2026 16:13:07 +0200 Subject: [PATCH 3/6] whitespace changes by black using pre-commit --- PySDM/initialisation/spectra/lognormal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PySDM/initialisation/spectra/lognormal.py b/PySDM/initialisation/spectra/lognormal.py index 9c560acd2c..bb48687ff8 100644 --- a/PySDM/initialisation/spectra/lognormal.py +++ b/PySDM/initialisation/spectra/lognormal.py @@ -11,7 +11,7 @@ class Lognormal(Spectrum): - def __init__(self, *, norm_factor: float, median = None, s_geom: float, mode = None): + def __init__(self, *, norm_factor: float, median=None, s_geom: float, mode=None): """`norm_factor=1` corresponds to standard normalised probability density, other settings allow to express, e.g., size or mass distributions; `m_mode` is the median value, `s_geom` is the geometric standard deviation""" From 04709c3936afe790a3988095786263835ed8f46a Mon Sep 17 00:00:00 2001 From: Alicja Date: Mon, 13 Apr 2026 16:31:52 +0200 Subject: [PATCH 4/6] unit test improvement for the new Lognormal API --- PySDM/initialisation/spectra/lognormal.py | 4 ++-- .../initialisation/test_spectra_lognormal.py | 12 +++++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/PySDM/initialisation/spectra/lognormal.py b/PySDM/initialisation/spectra/lognormal.py index bb48687ff8..c2767e147b 100644 --- a/PySDM/initialisation/spectra/lognormal.py +++ b/PySDM/initialisation/spectra/lognormal.py @@ -24,8 +24,8 @@ def s_geom(self): return math.exp(self.distribution_params[0]) @property - def m_mode(self): - raise NotImplementedError() + def mode(self): + return self.median / math.exp(self.s_geom**2) @property def geometric_mean(self): diff --git a/tests/unit_tests/initialisation/test_spectra_lognormal.py b/tests/unit_tests/initialisation/test_spectra_lognormal.py index 186b7becc1..d4203e88bb 100644 --- a/tests/unit_tests/initialisation/test_spectra_lognormal.py +++ b/tests/unit_tests/initialisation/test_spectra_lognormal.py @@ -53,14 +53,16 @@ def test_mean(median, s_geom): @staticmethod def test_mode(): - # TODO - spectrum = Lognormal(median=...) - assert False + # arrange + spectrum = Lognormal(mode=44, s_geom=1.2, norm_factor=666) + + # assert + assert spectrum.mode == 44 @staticmethod def test_instantiation_passing_mode(): # arrange & act spectrum = Lognormal(mode=1, s_geom=1, norm_factor=1) - # act - np.testing.assert_approx_equal(actual=spectrum.median, desired=1) + # assert + np.testing.assert_approx_equal(actual=spectrum.median, desired=np.e) From 96034db65d2eced54f262a6965c0260ccac2893e Mon Sep 17 00:00:00 2001 From: Alicja Date: Mon, 13 Apr 2026 16:40:31 +0200 Subject: [PATCH 5/6] fix Lognormal ctor argument in tutorial code (P+M+J) --- docs/markdown/pysdm_landing.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/markdown/pysdm_landing.md b/docs/markdown/pysdm_landing.md index d7f154dcc9..7d0da536d4 100644 --- a/docs/markdown/pysdm_landing.md +++ b/docs/markdown/pysdm_landing.md @@ -402,7 +402,7 @@ env = Parcel( T0=300 * si.K, w= 2.5 * si.m / si.s ) -spectrum = Lognormal(norm_factor=1e4/si.mg, m_mode=50*si.nm, s_geom=1.4) +spectrum = Lognormal(norm_factor=1e4/si.mg, median=50*si.nm, s_geom=1.4) kappa = .5 * si.dimensionless cloud_range = (.5 * si.um, 25 * si.um) output_interval = 4 @@ -483,7 +483,7 @@ env = Parcel(pyargs( ... 'T0', 300 * si.K, ... 'w', 2.5 * si.m / si.s ... )); -spectrum = Lognormal(pyargs('norm_factor', 1e4/si.mg, 'm_mode', 50 * si.nm, 's_geom', 1.4)); +spectrum = Lognormal(pyargs('norm_factor', 1e4/si.mg, 'median', 50 * si.nm, 's_geom', 1.4)); kappa = .5; cloud_range = py.tuple({.5 * si.um, 25 * si.um}); output_interval = 4; @@ -584,7 +584,7 @@ env = Parcel( T0=300 * si.K, w=2.5 * si.m / si.s ) -spectrum = Lognormal(norm_factor=1e4 / si.mg, m_mode=50 * si.nm, s_geom=1.5) +spectrum = Lognormal(norm_factor=1e4 / si.mg, median=50 * si.nm, s_geom=1.5) kappa = .5 * si.dimensionless cloud_range = (.5 * si.um, 25 * si.um) output_interval = 4 From de5cae7f20c5b83ae76046918810dfffed427d01 Mon Sep 17 00:00:00 2001 From: Alicja Date: Mon, 25 May 2026 16:46:52 +0200 Subject: [PATCH 6/6] Median/mode mismatch clean-up in tests --- PySDM/initialisation/spectra/lognormal.py | 2 +- tests/smoke_tests/parcel_a/pyrcel/test_parcel_example.py | 5 +++-- .../test_single_supersaturation_peak.py | 2 +- tests/unit_tests/dynamics/collisions/test_croupiers.py | 4 ++-- .../dynamics/condensation/test_parcel_sanity_checks.py | 2 +- tests/unit_tests/impl/test_moments.py | 8 ++++---- tests/unit_tests/initialisation/test_spectra_lognormal.py | 4 ++-- .../initialisation/test_spectral_discretisation.py | 7 ++++--- .../initialisation/test_spectro_glacial_discretisation.py | 2 +- tests/unit_tests/physics/test_spectra.py | 6 +++--- tests/unit_tests/products/test_activation_criteria.py | 2 +- 11 files changed, 23 insertions(+), 21 deletions(-) diff --git a/PySDM/initialisation/spectra/lognormal.py b/PySDM/initialisation/spectra/lognormal.py index c2767e147b..3d799a7a83 100644 --- a/PySDM/initialisation/spectra/lognormal.py +++ b/PySDM/initialisation/spectra/lognormal.py @@ -17,7 +17,7 @@ def __init__(self, *, norm_factor: float, median=None, s_geom: float, mode=None) `m_mode` is the median value, `s_geom` is the geometric standard deviation""" assert (median is None) ^ (mode is None) self.median = median or mode * math.exp(s_geom**2) - super().__init__(lognorm, (math.log(s_geom), 0, median), norm_factor) + super().__init__(lognorm, (math.log(s_geom), 0, self.median), norm_factor) @property def s_geom(self): diff --git a/tests/smoke_tests/parcel_a/pyrcel/test_parcel_example.py b/tests/smoke_tests/parcel_a/pyrcel/test_parcel_example.py index 0f2f9398c9..3157013cf1 100644 --- a/tests/smoke_tests/parcel_a/pyrcel/test_parcel_example.py +++ b/tests/smoke_tests/parcel_a/pyrcel/test_parcel_example.py @@ -25,12 +25,13 @@ def test_humidity_and_temperature_profile(s_max, s_250m, T_250m, scipy_solver): settings = Settings( dz=1 * si.m, n_sd_per_mode=(5, 5), + # see also https://github.com/darothen/pyrcel/pull/26 aerosol_modes_by_kappa={ 0.54: Lognormal( - norm_factor=850 / si.cm**3, m_mode=15 * si.nm, s_geom=1.6 + norm_factor=850 / si.cm**3, median=15 * si.nm, s_geom=1.6 ), 1.2: Lognormal( - norm_factor=10 / si.cm**3, m_mode=850 * si.nm, s_geom=1.2 + norm_factor=10 / si.cm**3, median=850 * si.nm, s_geom=1.2 ), }, vertical_velocity=1.0 * si.m / si.s, diff --git a/tests/smoke_tests/parcel_c/abdul_razzak_ghan_2000/test_single_supersaturation_peak.py b/tests/smoke_tests/parcel_c/abdul_razzak_ghan_2000/test_single_supersaturation_peak.py index 9f2b64bcbf..1aa970478c 100644 --- a/tests/smoke_tests/parcel_c/abdul_razzak_ghan_2000/test_single_supersaturation_peak.py +++ b/tests/smoke_tests/parcel_c/abdul_razzak_ghan_2000/test_single_supersaturation_peak.py @@ -42,7 +42,7 @@ def test_single_saturation_peak( n_steps = 70 n_sd = 2 kappa = 0.4 - spectrum = Lognormal(norm_factor=5000 / si.cm**3, m_mode=50.0 * si.nm, s_geom=2.0) + spectrum = Lognormal(norm_factor=5000 / si.cm**3, median=50.0 * si.nm, s_geom=2.0) formulae = Formulae(constants=CONSTANTS_ARG) builder = Builder( backend=CPU(formulae), diff --git a/tests/unit_tests/dynamics/collisions/test_croupiers.py b/tests/unit_tests/dynamics/collisions/test_croupiers.py index 421ada1eae..b461746d3a 100644 --- a/tests/unit_tests/dynamics/collisions/test_croupiers.py +++ b/tests/unit_tests/dynamics/collisions/test_croupiers.py @@ -16,14 +16,14 @@ def test_final_state(croupier, backend_class): # Arrange n_part = 100000 - v_mean = 2e-6 + v_median = 2e-6 d = 1.2 n_sd = 32 x = 4 y = 4 attributes = {} - spectrum = Lognormal(n_part, v_mean, d) + spectrum = Lognormal(norm_factor=n_part, median=v_median, s_geom=d) attributes["volume"], attributes["multiplicity"] = Linear( spectrum ).sample_deterministic(n_sd) diff --git a/tests/unit_tests/dynamics/condensation/test_parcel_sanity_checks.py b/tests/unit_tests/dynamics/condensation/test_parcel_sanity_checks.py index ce99960e77..a13cf4fefc 100644 --- a/tests/unit_tests/dynamics/condensation/test_parcel_sanity_checks.py +++ b/tests/unit_tests/dynamics/condensation/test_parcel_sanity_checks.py @@ -17,7 +17,7 @@ from PySDM.physics import si FORMULAE = Formulae() -SPECTRUM = Lognormal(norm_factor=1e4 / si.mg, m_mode=50 * si.nm, s_geom=1.5) +SPECTRUM = Lognormal(norm_factor=1e4 / si.mg, median=50 * si.nm, s_geom=1.5) N_SD = 64 R_DRY, specific_concentration = spectral_sampling.Logarithmic( SPECTRUM diff --git a/tests/unit_tests/impl/test_moments.py b/tests/unit_tests/impl/test_moments.py index b6ad286f99..a08cf148b2 100644 --- a/tests/unit_tests/impl/test_moments.py +++ b/tests/unit_tests/impl/test_moments.py @@ -14,11 +14,11 @@ class TestMaths: def test_moment_0d(backend_class): # Arrange n_part = 100000 - v_mean = 2e-6 + v_median = 2e-6 d = 1.2 n_sd = 32 - spectrum = Lognormal(n_part, v_mean, d) + spectrum = Lognormal(norm_factor=n_part, median=v_median, s_geom=d) v, n = Linear(spectrum).sample_deterministic(n_sd) T = 300.0 n = discretise_multiplicities(n) @@ -75,11 +75,11 @@ def test_moment_0d(backend_class): def test_spectrum_moment_0d(backend_class): # Arrange n_part = 100000 - v_mean = 2e-6 + v_median = 2e-6 d = 1.2 n_sd = 32 - spectrum = Lognormal(n_part, v_mean, d) + spectrum = Lognormal(norm_factor=n_part, median=v_median, s_geom=d) v, n = Linear(spectrum).sample_deterministic(n_sd) T = 300.0 n = discretise_multiplicities(n) diff --git a/tests/unit_tests/initialisation/test_spectra_lognormal.py b/tests/unit_tests/initialisation/test_spectra_lognormal.py index d4203e88bb..cd3568f616 100644 --- a/tests/unit_tests/initialisation/test_spectra_lognormal.py +++ b/tests/unit_tests/initialisation/test_spectra_lognormal.py @@ -22,10 +22,10 @@ def test_median(median, s_geom): sut = Lognormal(median=median, norm_factor=1, s_geom=s_geom) # act - median = sut.percentiles(0.5) + actual = sut.percentiles(0.5) # assert - assert median == median + assert median == actual @staticmethod @pytest.mark.parametrize( diff --git a/tests/unit_tests/initialisation/test_spectral_discretisation.py b/tests/unit_tests/initialisation/test_spectral_discretisation.py index 91b98d8935..398cc48917 100644 --- a/tests/unit_tests/initialisation/test_spectral_discretisation.py +++ b/tests/unit_tests/initialisation/test_spectral_discretisation.py @@ -7,10 +7,10 @@ from PySDM.initialisation import spectra from PySDM.physics import si -m_mode = 0.5e-5 +median = 0.5e-5 n_part = 256 * 16 s_geom = 1.5 -spectrum = spectra.Lognormal(n_part, m_mode, s_geom) +spectrum = spectra.Lognormal(norm_factor=n_part, median=median, s_geom=s_geom) m_range = (0.1 * si.um, 100 * si.um) formulae = Formulae() @@ -91,5 +91,6 @@ def test_error_threshold_with_deterministic_sampling( sampling_class, error_threshold ): sampling_class( - spectra.Lognormal(n_part, m_mode, s_geom), error_threshold=error_threshold + spectra.Lognormal(norm_factor=n_part, median=median, s_geom=s_geom), + error_threshold=error_threshold, ).sample_deterministic(n_sd=10) diff --git a/tests/unit_tests/initialisation/test_spectro_glacial_discretisation.py b/tests/unit_tests/initialisation/test_spectro_glacial_discretisation.py index 00a72cc85f..0f6080e7ad 100644 --- a/tests/unit_tests/initialisation/test_spectro_glacial_discretisation.py +++ b/tests/unit_tests/initialisation/test_spectro_glacial_discretisation.py @@ -13,7 +13,7 @@ ) spectrum = Lognormal( norm_factor=1, - m_mode=formulae.trivia.sphere_surface(diameter=0.74 * si.um), + median=formulae.trivia.sphere_surface(diameter=0.74 * si.um), s_geom=np.exp(0.25), ) m_range = ( diff --git a/tests/unit_tests/physics/test_spectra.py b/tests/unit_tests/physics/test_spectra.py index 2321f55406..4cc3c0d30d 100644 --- a/tests/unit_tests/physics/test_spectra.py +++ b/tests/unit_tests/physics/test_spectra.py @@ -13,7 +13,7 @@ def test_size_distribution_n_part(): # Arrange s = 1.5 n_part = 256 - sut = Lognormal(n_part, 0.5e-5, s) + sut = Lognormal(norm_factor=n_part, median=0.5e-5, s_geom=s) # Act m, dm = np.linspace(0.1e-6, 100e-6, 100, retstep=True) @@ -27,7 +27,7 @@ def test_size_distribution_r_mode(): # Arrange s = 1.001 r_mode = 1e-6 - sut = Lognormal(norm_factor=1, m_mode=r_mode, s_geom=s) + sut = Lognormal(norm_factor=1, mode=r_mode, s_geom=s) # Act m = np.linspace(start=0.01e-6, stop=100e-6, num=1000) @@ -69,7 +69,7 @@ class TestSum: s = 1.001 r_mode = 1e-6 - lognormal = Lognormal(1, r_mode, s) + lognormal = Lognormal(norm_factor=1, mode=r_mode, s_geom=s) @staticmethod def test_size_distribution(): diff --git a/tests/unit_tests/products/test_activation_criteria.py b/tests/unit_tests/products/test_activation_criteria.py index 37b4dde0f9..970bdb4214 100644 --- a/tests/unit_tests/products/test_activation_criteria.py +++ b/tests/unit_tests/products/test_activation_criteria.py @@ -44,7 +44,7 @@ def test_activation_criteria(backend, plot=False): builder.add_dynamic(Condensation()) r_dry, specific_concentration = spectral_sampling.ConstantMultiplicity( - Lognormal(norm_factor=1e4 / si.mg, m_mode=50 * si.nm, s_geom=1.5) + Lognormal(norm_factor=1e4 / si.mg, median=50 * si.nm, s_geom=1.5) ).sample_deterministic(builder.particulator.n_sd) particulator = builder.build(