Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
9b910d0
matsushima multiplicity parameterization
emmacware Jun 16, 2025
7ec0500
constants: redefine room temperature to be 25C instead of 25.01C (#1646)
slayoo Jun 13, 2025
90b2ed2
Merge branch 'main' into alpha_sampling
slayoo Jun 16, 2025
6813de9
correct alpha function
emmacware Jun 18, 2025
561d492
Homogeneous freezing (as a new option in `Freezing` dynamic, disabled…
tluettm Jun 18, 2025
32c5c52
CI: do not run tests for docs/README-only commits in PRs (#1652)
slayoo Jun 18, 2025
4c0f24b
integrate to pysdm!
emmacware Jun 20, 2025
6618c0a
Merge branch 'main' into alpha_sampling
slayoo Jul 2, 2025
962278a
alpha sampling tests
emmacware Jul 2, 2025
095ba0d
move plottnig logic from .py file to ipynb; drop dependence on seabor…
slayoo Jul 2, 2025
8d0fd8c
add bib items; remove commented-out code; precommit, pylint, etc
slayoo Jul 2, 2025
dbc5ebf
Merge branch 'main' into alpha_sampling
slayoo Jul 6, 2025
ad154f7
shipway match CLEO
emmacware Jul 18, 2025
3beeb0f
add random,pseudorandom, and endpoint dist options
emmacware Jul 21, 2025
59efe5e
example endpoint dists
emmacware Jul 21, 2025
98360c6
much smaller wiggle
emmacware Jul 21, 2025
258b676
Merge pull request #2 from open-atmos/main
emmacware Jul 22, 2025
0732dc4
revert changes to Hill example
slayoo Sep 17, 2025
3064c9f
Merge remote-tracking branch 'upstream/main' into HEAD
slayoo Sep 17, 2025
60f5a90
precommit run; updated notebook
slayoo Sep 17, 2025
93c7b95
Merge branch 'main' into alpha_sampling
slayoo Oct 16, 2025
fdd7515
change x_prime, interp to fix wiggles, allow to convert to radius/log…
emmacware Oct 20, 2025
e412ebe
add pseudorandom and random sampling
emmacware Oct 27, 2025
1f60be5
alpha test comparing endpoints
emmacware Nov 24, 2025
cb8f024
Merge branch 'main' into alpha_sampling
slayoo Nov 24, 2025
d7cd62d
cleanups; smoke test draft
slayoo Nov 26, 2025
b2058aa
add smoke test draft
slayoo Nov 26, 2025
2016916
cleanup and refactor Matsushima fig 1 notebook to enable plot data ac…
slayoo Nov 27, 2025
b24a0ae
drafting new sampling API (with sample_deterministic, sample_quasiran…
slayoo Nov 27, 2025
746fa56
add unit test for Sum of spectra
slayoo Nov 27, 2025
1e10ee0
refactored sampling classes
slayoo Nov 27, 2025
54716b0
make docs snippets compatible with the new API
slayoo Nov 27, 2025
046ade6
bib json fix
slayoo Nov 27, 2025
98aeae7
adapting code to sample_* choices in the new API
slayoo Nov 29, 2025
e300e27
addressing pylint hints
slayoo Nov 29, 2025
004d85d
addressing pylint hints again
slayoo Nov 29, 2025
5383bc7
adapting sample() usaged in examples
slayoo Nov 29, 2025
77c81ca
typo fix
slayoo Nov 29, 2025
59ca89c
typo
slayoo Nov 29, 2025
97838a9
regenerated notebook
slayoo Nov 29, 2025
545ec70
fix Kreidenweis test/example API
slayoo Nov 29, 2025
2e72edb
add new example to conftest
slayoo Nov 29, 2025
0e0fa3a
fix import test
slayoo Nov 29, 2025
053f317
fix Lowe example
slayoo Nov 29, 2025
49c72eb
Merge remote-tracking branch 'upstream/main' into HEAD
slayoo Nov 29, 2025
ed7e22b
further cleanup
slayoo Nov 29, 2025
2876216
further API-change related fixes in example
slayoo Nov 29, 2025
b91ac44
use determinisic sampling in kin_2d env
slayoo Nov 30, 2025
027790e
fix sample() in Graf example
slayoo Nov 30, 2025
70e6b4d
one more instance
slayoo Nov 30, 2025
6b3dc69
smoke test for panels b and c in the new MAtsushima notebook
slayoo Nov 30, 2025
c86d844
spectral discretisation unit test coverage improvement + cleanups
slayoo Nov 30, 2025
f9a0e32
remove unused import
slayoo Nov 30, 2025
a18c137
remove unused convert_to code
slayoo Nov 30, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions PySDM/environments/kinematic_1d.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,12 @@ def init_attributes(
) = self.mesh.cellular_attributes(positions)

if collisions_only:
v_wet, n_per_kg = spectral_discretisation.sample(
v_wet, n_per_kg = spectral_discretisation.sample_deterministic(
backend=self.particulator.backend, n_sd=self.particulator.n_sd
)
attributes["volume"] = v_wet
else:
r_dry, n_per_kg = spectral_discretisation.sample(
r_dry, n_per_kg = spectral_discretisation.sample_deterministic(
backend=self.particulator.backend, n_sd=self.particulator.n_sd
)
attributes["dry volume"] = self.formulae.trivia.volume(radius=r_dry)
Expand Down
6 changes: 3 additions & 3 deletions PySDM/environments/kinematic_2d.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,9 @@ def init_attributes(
attributes["position in cell"],
) = self.mesh.cellular_attributes(positions)

r_dry, n_per_kg = spectral_sampling(spectrum=dry_radius_spectrum).sample(
n_sd=n_sd, backend=self.particulator.backend
)
r_dry, n_per_kg = spectral_sampling(
spectrum=dry_radius_spectrum
).sample_deterministic(n_sd=n_sd, backend=self.particulator.backend)

attributes["dry volume"] = self.formulae.trivia.volume(radius=r_dry)
attributes["kappa times dry volume"] = kappa * attributes["dry volume"]
Expand Down
184 changes: 108 additions & 76 deletions PySDM/initialisation/sampling/spectral_sampling.py
Original file line number Diff line number Diff line change
@@ -1,58 +1,51 @@
"""
spectral sampling logic incl. linear, logarithmic, uniform-random and constant-multiplicity
sampling classes
spectral discretisation logic incl. linear, logarithmic, and constant-multiplicity
layouts with deterministic, pseudorandom and quasirandom sampling
"""

import abc
from typing import Optional, Tuple

import numpy as np
from scipy import optimize
from scipy.interpolate import interp1d

default_cdf_range = (0.00001, 0.99999)


class SpectralSampling: # pylint: disable=too-few-public-methods
def __init__(self, spectrum, size_range: Optional[Tuple[float, float]] = None):
class SpectralSampling:
def __init__(
self,
spectrum,
*,
size_range: Optional[Tuple[float, float]] = None,
error_threshold: Optional[float] = None,
):
self.spectrum = spectrum
self.error_threshold = error_threshold or 0.01

if size_range is None:
if hasattr(spectrum, "percentiles"):
self.size_range = spectrum.percentiles(default_cdf_range)
else:
self.size_range = [np.nan, np.nan]
for i in (0, 1):
result = optimize.root(
lambda x, value=default_cdf_range[i]: spectrum.cdf(x) - value,
x0=spectrum.median(),
)
assert result.success
self.size_range[i] = result.x
self.cdf_range = default_cdf_range
self.size_range = spectrum.percentiles(self.cdf_range)
else:
assert len(size_range) == 2
assert size_range[0] > 0
assert size_range[1] > size_range[0]
self.size_range = size_range
self.cdf_range = (
spectrum.cdf(size_range[0]),
spectrum.cdf(size_range[1]),
)

@abc.abstractmethod
def _sample(self, frac_values):
pass

class DeterministicSpectralSampling(
SpectralSampling
): # pylint: disable=too-few-public-methods
# TODO #1031 - error_threshold will be also used in non-deterministic sampling
def __init__(
self,
spectrum,
size_range: Optional[Tuple[float, float]] = None,
error_threshold: Optional[float] = None,
):
super().__init__(spectrum, size_range)
self.error_threshold = error_threshold or 0.01

def _sample(self, grid, spectrum):
def _sample_with_grid(self, grid):
x = grid[1:-1:2]
cdf = spectrum.cumulative(grid[0::2])
cdf = self.spectrum.cumulative(grid[0::2])
y_float = cdf[1:] - cdf[0:-1]

diff = abs(1 - np.sum(y_float) / spectrum.norm_factor)
diff = abs(1 - np.sum(y_float) / self.spectrum.norm_factor)
if diff > self.error_threshold:
raise ValueError(
f"{diff * 100:.3g}% error in total real-droplet number due to sampling "
Expand All @@ -61,61 +54,100 @@ def _sample(self, grid, spectrum):

return x, y_float

def sample_deterministic(
self, n_sd, *, backend=None
): # pylint: disable=unused-argument
return self._sample(
frac_values=np.linspace(
self.cdf_range[0], self.cdf_range[1], num=2 * n_sd + 1
)
)

class Linear(DeterministicSpectralSampling): # pylint: disable=too-few-public-methods
def sample(self, n_sd, *, backend=None): # pylint: disable=unused-argument
grid = np.linspace(*self.size_range, num=2 * n_sd + 1)
return self._sample(grid, self.spectrum)
def sample_quasirandom(self, n_sd, *, backend):
num_elements = n_sd
storage = backend.Storage.empty(num_elements, dtype=float)
backend.Random(seed=backend.formulae.seed, size=num_elements)(storage)
u01 = storage.to_ndarray()

frac_values = np.linspace(
self.cdf_range[0], self.cdf_range[1], num=2 * n_sd + 1
)

class Logarithmic(
DeterministicSpectralSampling
): # pylint: disable=too-few-public-methods
def __init__(
self,
spectrum,
size_range: [None, Tuple[float, float]] = None,
error_threshold: Optional[float] = None,
):
super().__init__(spectrum, size_range, error_threshold)
self.start = np.log10(self.size_range[0])
self.stop = np.log10(self.size_range[1])
for i in range(1, len(frac_values) - 1, 2):
frac_values[i] = frac_values[i - 1] + u01[i // 2] * (
frac_values[i + 1] - frac_values[i - 1]
)

def sample(self, n_sd, *, backend=None): # pylint: disable=unused-argument
grid = np.logspace(self.start, self.stop, num=2 * n_sd + 1)
return self._sample(grid, self.spectrum)
return self._sample(frac_values=frac_values)

def sample_pseudorandom(self, n_sd, *, backend):
num_elements = 2 * n_sd + 1
storage = backend.Storage.empty(num_elements, dtype=float)
backend.Random(seed=backend.formulae.seed, size=num_elements)(storage)
u01 = storage.to_ndarray()

frac_values = np.sort(
self.cdf_range[0] + u01 * (self.cdf_range[1] - self.cdf_range[0])
)
return self._sample(frac_values=frac_values)

class ConstantMultiplicity(
DeterministicSpectralSampling
): # pylint: disable=too-few-public-methods
def __init__(self, spectrum, size_range=None):
super().__init__(spectrum, size_range)

self.cdf_range = (
spectrum.cumulative(self.size_range[0]),
spectrum.cumulative(self.size_range[1]),
class Logarithmic(SpectralSampling):
def _sample(self, frac_values):
grid = np.exp(
(np.log(self.size_range[1]) - np.log(self.size_range[0])) * frac_values
+ np.log(self.size_range[0])
)
assert 0 < self.cdf_range[0] < self.cdf_range[1]
return self._sample_with_grid(grid)

def sample(self, n_sd, *, backend=None): # pylint: disable=unused-argument
cdf_arg = np.linspace(self.cdf_range[0], self.cdf_range[1], num=2 * n_sd + 1)
cdf_arg /= self.spectrum.norm_factor
percentiles = self.spectrum.percentiles(cdf_arg)

assert np.isfinite(percentiles).all()
class Linear(SpectralSampling):
def _sample(self, frac_values):
grid = self.size_range[0] + frac_values * (
self.size_range[1] - self.size_range[0]
)
return self._sample_with_grid(grid)

return self._sample(percentiles, self.spectrum)

class ConstantMultiplicity(SpectralSampling):
def _sample(self, frac_values):
grid = self.spectrum.percentiles(frac_values)
assert np.isfinite(grid).all()
return self._sample_with_grid(grid)

class UniformRandom(SpectralSampling): # pylint: disable=too-few-public-methods
def sample(self, n_sd, *, backend):
n_elements = n_sd
storage = backend.Storage.empty(n_elements, dtype=float)
backend.Random(seed=backend.formulae.seed, size=n_elements)(storage)
u01 = storage.to_ndarray()

pdf_arg = self.size_range[0] + u01 * (self.size_range[1] - self.size_range[0])
dr = abs(self.size_range[1] - self.size_range[0]) / n_sd
# TODO #1031 - should also handle error_threshold check
return pdf_arg, dr * self.spectrum.size_distribution(pdf_arg)
class AlphaSampling(SpectralSampling):
"""as in [Matsushima et al. 2023](https://doi.org/10.5194/gmd-16-6211-2023)"""

def __init__(
self,
spectrum,
*,
alpha,
dist_1_inv,
interp_points,
size_range=None,
error_threshold: Optional[float] = None,
):
super().__init__(
spectrum, size_range=size_range, error_threshold=error_threshold
)
self.alpha = alpha
self.dist_0_cdf = self.spectrum.cdf
self.dist_1_inv = dist_1_inv
self.x_prime = np.linspace(
self.size_range[0], self.size_range[1], num=interp_points
)

def _sample(self, frac_values):
if self.alpha == 0:
frac_values = self.spectrum.percentiles(frac_values)
elif self.alpha == 1:
frac_values = self.dist_1_inv(frac_values, self.size_range)
else:
sd_cdf = self.dist_0_cdf(self.x_prime)
x_sd_cdf = (1 - self.alpha) * self.x_prime + self.alpha * self.dist_1_inv(
sd_cdf, self.size_range
)
frac_values = interp1d(sd_cdf, x_sd_cdf)(frac_values)
return self._sample_with_grid(frac_values)
8 changes: 7 additions & 1 deletion PySDM/initialisation/spectra/sum.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def __init__(self, spectra: tuple, interpolation_grid=None):
cdf_arg = np.zeros(len(interpolation_grid) * len(self.spectra) + 1)
cdf_arg[1:] = np.concatenate(percentiles)
cdf = self.cumulative(cdf_arg) / self.norm_factor
self.inverse_cdf = interp1d(cdf, cdf_arg)
self.inverse_cdf = interp1d(cdf, cdf_arg, bounds_error=False)

def size_distribution(self, arg):
result = 0.0
Expand All @@ -36,3 +36,9 @@ def cumulative(self, arg):

def percentiles(self, cdf_values):
return self.inverse_cdf(cdf_values)

def pdf(self, arg):
return self.size_distribution(arg) / self.norm_factor

def cdf(self, arg):
return self.cumulative(arg) / self.norm_factor
9 changes: 9 additions & 0 deletions docs/bibliography.json
Original file line number Diff line number Diff line change
Expand Up @@ -951,6 +951,15 @@
"title": "A physically constrained classical description of the homogeneous nucleation of ice in water",
"label": "Koop and Murray 2016 (J. Chem. Phys. 145)"
},
"https://doi.org/10.5194/gmd-16-6211-2023": {
"title": "Overcoming computational challenges to realize meter- to submeter-scale resolution in cloud simulations using the super-droplet method",
"label": "Matsushima et al. 2023 (Geosci. Model Dev. 16)",
"usages": [
"PySDM/initialisation/sampling/spectral_sampling.py",
"examples/PySDM_examples/Matsushima_et_al_2023/figure_1.ipynb",
"examples/PySDM_examples/Matsushima_et_al_2023/__init__.py"
]
},
"https://doi.org/10.48550/arXiv.2509.05536": {
"usages": [
"examples/PySDM_examples/Ware_et_al_2025/__init__.py",
Expand Down
14 changes: 7 additions & 7 deletions docs/markdown/pysdm_landing.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ Exponential = pyimport("PySDM.initialisation.spectra").Exponential
n_sd = 2^15
initial_spectrum = Exponential(norm_factor=8.39e12, scale=1.19e5 * si.um^3)
attributes = Dict()
attributes["volume"], attributes["multiplicity"] = ConstantMultiplicity(spectrum=initial_spectrum).sample(n_sd)
attributes["volume"], attributes["multiplicity"] = ConstantMultiplicity(spectrum=initial_spectrum).sample_deterministic(n_sd)
```
</details>
<details>
Expand All @@ -119,7 +119,7 @@ initial_spectrum = Exponential(pyargs(...
'norm_factor', 8.39e12, ...
'scale', 1.19e5 * si.um ^ 3 ...
));
tmp = ConstantMultiplicity(initial_spectrum).sample(int32(n_sd));
tmp = ConstantMultiplicity(initial_spectrum).sample_deterministic(int32(n_sd));
attributes = py.dict(pyargs('volume', tmp{1}, 'multiplicity', tmp{2}));
```
</details>
Expand All @@ -133,7 +133,7 @@ from PySDM.initialisation.spectra.exponential import Exponential
n_sd = 2 ** 15
initial_spectrum = Exponential(norm_factor=8.39e12, scale=1.19e5 * si.um ** 3)
attributes = {}
attributes['volume'], attributes['multiplicity'] = ConstantMultiplicity(initial_spectrum).sample(n_sd)
attributes['volume'], attributes['multiplicity'] = ConstantMultiplicity(initial_spectrum).sample_deterministic(n_sd)
```
</details>

Expand Down Expand Up @@ -323,7 +323,7 @@ The component submodules used to create this simulation are visualized below:
IS["initial_spectrum :Exponential"] -->|passed as arg to| CM_INIT
CM_INIT(["ConstantMultiplicity.__init__()"]) -->|instantiates| CM_INSTANCE
CM_INSTANCE[":ConstantMultiplicity"] -.-|has a method| SAMPLE
SAMPLE(["ConstantMultiplicity.sample()"]) -->|returns| n
SAMPLE(["ConstantMultiplicity.sample_deterministic()"]) -->|returns| n
SAMPLE -->|returns| volume
n -->|added as element of| ATTRIBUTES
PARTICULATOR_INSTANCE -.-|has a method| PARTICULATOR_RUN(["Particulator.run()"])
Expand Down Expand Up @@ -414,7 +414,7 @@ builder = Builder(backend=CPU(formulae), n_sd=n_sd, environment=env)
builder.add_dynamic(AmbientThermodynamics())
builder.add_dynamic(Condensation())

r_dry, specific_concentration = spectral_sampling.Logarithmic(spectrum).sample(n_sd)
r_dry, specific_concentration = spectral_sampling.Logarithmic(spectrum).sample_deterministic(n_sd)
v_dry = formulae.trivia.volume(radius=r_dry)
r_wet = equilibrate_wet_radii(r_dry=r_dry, environment=builder.particulator.environment, kappa_times_dry_volume=kappa * v_dry)

Expand Down Expand Up @@ -495,7 +495,7 @@ builder = Builder(pyargs('backend', CPU(formulae), 'n_sd', int32(n_sd), 'environ
builder.add_dynamic(AmbientThermodynamics());
builder.add_dynamic(Condensation());

tmp = spectral_sampling.Logarithmic(spectrum).sample(int32(n_sd));
tmp = spectral_sampling.Logarithmic(spectrum).sample_deterministic(int32(n_sd));
r_dry = tmp{1};
v_dry = formulae.trivia.volume(pyargs('radius', r_dry));
specific_concentration = tmp{2};
Expand Down Expand Up @@ -596,7 +596,7 @@ builder = Builder(backend=CPU(formulae), n_sd=n_sd, environment=env)
builder.add_dynamic(AmbientThermodynamics())
builder.add_dynamic(Condensation())

r_dry, specific_concentration = spectral_sampling.Logarithmic(spectrum).sample(n_sd)
r_dry, specific_concentration = spectral_sampling.Logarithmic(spectrum).sample_deterministic(n_sd)
v_dry = formulae.trivia.volume(radius=r_dry)
r_wet = equilibrate_wet_radii(r_dry=r_dry, environment=builder.particulator.environment, kappa_times_dry_volume=kappa * v_dry)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,9 @@ def __init__(self, settings):
if settings.enable_vapour_deposition_on_ice:
builder.add_dynamic(VapourDepositionOnIce(adaptive=True))

r_dry, n_in_dv = ConstantMultiplicity(settings.soluble_aerosol).sample(
n_sd=settings.n_sd
)
r_dry, n_in_dv = ConstantMultiplicity(
settings.soluble_aerosol
).sample_deterministic(n_sd=settings.n_sd)
attributes = builder.particulator.environment.init_attributes(
n_in_dv=n_in_dv, kappa=settings.kappa, r_dry=r_dry
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ def run_parcel(
}
for i, mode in enumerate(aerosol.modes):
kappa, spectrum = mode["kappa"]["CompressedFilmOvadnevaite"], mode["spectrum"]
r_dry, concentration = ConstantMultiplicity(spectrum).sample(n_sd_per_mode)
r_dry, concentration = ConstantMultiplicity(spectrum).sample_deterministic(
n_sd_per_mode
)
v_dry = builder.formulae.trivia.volume(radius=r_dry)
specific_concentration = concentration / builder.formulae.constants.rho_STP
attributes["multiplicity"] = np.append(
Expand Down
4 changes: 3 additions & 1 deletion examples/PySDM_examples/Alpert_and_Knopf_2016/simulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,9 @@ def simulation(
n_sd, multiplicity / volume
)
else:
_isa, _conc = spectral_sampling.ConstantMultiplicity(spectrum).sample(n_sd)
_isa, _conc = spectral_sampling.ConstantMultiplicity(
spectrum
).sample_deterministic(n_sd)
attributes = {
"multiplicity": discretise_multiplicities(_conc * volume),
"immersed surface area": _isa,
Expand Down
2 changes: 1 addition & 1 deletion examples/PySDM_examples/Arabas_et_al_2025/aida.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@
}
},
"source": [
"d_dry, conc = spectral_sampling.ConstantMultiplicity(dNdD).sample(common['n_sd'])\n",
"d_dry, conc = spectral_sampling.ConstantMultiplicity(dNdD).sample_deterministic(common['n_sd'])\n",
"\n",
"ATTRIBUTES = {\n",
" 'n': conc * common['volume'],\n",
Expand Down
Loading
Loading