diff --git a/PySDM/backends/impl_common/freezing_attributes.py b/PySDM/backends/impl_common/freezing_attributes.py
index a58718c9e7..a9f788ff47 100644
--- a/PySDM/backends/impl_common/freezing_attributes.py
+++ b/PySDM/backends/impl_common/freezing_attributes.py
@@ -25,3 +25,14 @@ class TimeDependentAttributes(
"""groups attributes required in time-dependent regime"""
__slots__ = ()
+
+
+class TimeDependentHomogeneousAttributes(
+ namedtuple(
+ typename="TimeDependentHomogeneousAttributes",
+ field_names=("volume", "signed_water_mass"),
+ )
+):
+ """groups attributes required in time-dependent regime for homogeneous freezing"""
+
+ __slots__ = ()
diff --git a/PySDM/backends/impl_numba/methods/freezing_methods.py b/PySDM/backends/impl_numba/methods/freezing_methods.py
index 6799e3b982..ef4bc210cc 100644
--- a/PySDM/backends/impl_numba/methods/freezing_methods.py
+++ b/PySDM/backends/impl_numba/methods/freezing_methods.py
@@ -1,5 +1,6 @@
"""
-CPU implementation of backend methods for freezing (singular and time-dependent immersion freezing)
+CPU implementation of backend methods for homogeneous freezing and
+heterogeneous freezing (singular and time-dependent immersion freezing)
"""
from functools import cached_property
@@ -12,31 +13,40 @@
from ...impl_common.freezing_attributes import (
SingularAttributes,
TimeDependentAttributes,
+ TimeDependentHomogeneousAttributes,
)
class FreezingMethods(BackendMethods):
- def __init__(self):
- BackendMethods.__init__(self)
- unfrozen_and_saturated = self.formulae.trivia.unfrozen_and_saturated
- frozen_and_above_freezing_point = (
- self.formulae.trivia.frozen_and_above_freezing_point
- )
-
- @numba.njit(**{**self.default_jit_flags, "parallel": False})
- def _freeze(water_mass, i):
- water_mass[i] = -1 * water_mass[i]
+ @cached_property
+ def _freeze(self):
+ @numba.njit(**{**self.default_jit_flags, **{"parallel": False}})
+ def body(signed_water_mass, i):
+ signed_water_mass[i] = -1 * signed_water_mass[i]
# TODO #599: change thd (latent heat)!
- @numba.njit(**{**self.default_jit_flags, "parallel": False})
- def _thaw(water_mass, i):
- water_mass[i] = -1 * water_mass[i]
+ return body
+
+ @cached_property
+ def _thaw(self):
+ @numba.njit(**{**self.default_jit_flags, **{"parallel": False}})
+ def body(signed_water_mass, i):
+ signed_water_mass[i] = -1 * signed_water_mass[i]
# TODO #599: change thd (latent heat)!
+ return body
+
+ @cached_property
+ def _freeze_singular_body(self):
+ _thaw = self._thaw
+ _freeze = self._freeze
+ frozen_and_above_freezing_point = (
+ self.formulae.trivia.frozen_and_above_freezing_point
+ )
+ unfrozen_and_saturated = self.formulae.trivia.unfrozen_and_saturated
+
@numba.njit(**self.default_jit_flags)
- def freeze_singular_body(
- attributes, temperature, relative_humidity, cell, thaw
- ):
+ def body(attributes, temperature, relative_humidity, cell, thaw):
n_sd = len(attributes.freezing_temperature)
for i in numba.prange(n_sd): # pylint: disable=not-an-iterable
if attributes.freezing_temperature[i] == 0:
@@ -53,13 +63,21 @@ def freeze_singular_body(
):
_freeze(attributes.signed_water_mass, i)
- self.freeze_singular_body = freeze_singular_body
+ return body
+ @cached_property
+ def _freeze_time_dependent_body(self):
+ _thaw = self._thaw
+ _freeze = self._freeze
+ frozen_and_above_freezing_point = (
+ self.formulae.trivia.frozen_and_above_freezing_point
+ )
+ unfrozen_and_saturated = self.formulae.trivia.unfrozen_and_saturated
j_het = self.formulae.heterogeneous_ice_nucleation_rate.j_het
prob_zero_events = self.formulae.trivia.poissonian_avoidance_function
@numba.njit(**self.default_jit_flags)
- def freeze_time_dependent_body( # pylint: disable=too-many-arguments
+ def body( # pylint: disable=too-many-arguments
rand,
attributes,
timestep,
@@ -90,12 +108,69 @@ def freeze_time_dependent_body( # pylint: disable=too-many-arguments
if rand[i] < prob:
_freeze(attributes.signed_water_mass, i)
- self.freeze_time_dependent_body = freeze_time_dependent_body
+ return body
+
+ @cached_property
+ def _freeze_time_dependent_homogeneous_body(self):
+ _thaw = self._thaw
+ _freeze = self._freeze
+ frozen_and_above_freezing_point = (
+ self.formulae.trivia.frozen_and_above_freezing_point
+ )
+ unfrozen_and_ice_saturated = self.formulae.trivia.unfrozen_and_ice_saturated
+ j_hom = self.formulae.homogeneous_ice_nucleation_rate.j_hom
+ prob_zero_events = self.formulae.trivia.poissonian_avoidance_function
+ d_a_w_ice_within_range = (
+ self.formulae.homogeneous_ice_nucleation_rate.d_a_w_ice_within_range
+ )
+ d_a_w_ice_maximum = (
+ self.formulae.homogeneous_ice_nucleation_rate.d_a_w_ice_maximum
+ )
+
+ @numba.njit(**self.default_jit_flags)
+ def body( # pylint: disable=unused-argument,too-many-arguments
+ rand,
+ attributes,
+ timestep,
+ cell,
+ a_w_ice,
+ temperature,
+ relative_humidity_ice,
+ thaw,
+ ):
+
+ n_sd = len(attributes.signed_water_mass)
+ for i in numba.prange(n_sd): # pylint: disable=not-an-iterable
+ cell_id = cell[i]
+ if thaw and frozen_and_above_freezing_point(
+ attributes.signed_water_mass[i], temperature[cell_id]
+ ):
+ _thaw(attributes.signed_water_mass, i)
+ elif unfrozen_and_ice_saturated(
+ attributes.signed_water_mass[i], relative_humidity_ice[cell_id]
+ ):
+ d_a_w_ice = (relative_humidity_ice[cell_id] - 1.0) * a_w_ice[
+ cell_id
+ ]
+
+ if d_a_w_ice_within_range(d_a_w_ice):
+ d_a_w_ice = d_a_w_ice_maximum(d_a_w_ice)
+ rate_assuming_constant_temperature_within_dt = (
+ j_hom(temperature[cell_id], d_a_w_ice)
+ * attributes.volume[i]
+ )
+ prob = 1 - prob_zero_events(
+ r=rate_assuming_constant_temperature_within_dt, dt=timestep
+ )
+ if rand[i] < prob:
+ _freeze(attributes.signed_water_mass, i)
+
+ return body
def freeze_singular(
self, *, attributes, temperature, relative_humidity, cell, thaw: bool
):
- self.freeze_singular_body(
+ self._freeze_singular_body(
SingularAttributes(
freezing_temperature=attributes.freezing_temperature.data,
signed_water_mass=attributes.signed_water_mass.data,
@@ -118,7 +193,7 @@ def freeze_time_dependent(
relative_humidity,
thaw: bool,
):
- self.freeze_time_dependent_body(
+ self._freeze_time_dependent_body(
rand.data,
TimeDependentAttributes(
immersed_surface_area=attributes.immersed_surface_area.data,
@@ -132,6 +207,32 @@ def freeze_time_dependent(
thaw=thaw,
)
+ def freeze_time_dependent_homogeneous(
+ self,
+ *,
+ rand,
+ attributes,
+ timestep,
+ cell,
+ a_w_ice,
+ temperature,
+ relative_humidity_ice,
+ thaw: bool,
+ ):
+ self._freeze_time_dependent_homogeneous_body(
+ rand.data,
+ TimeDependentHomogeneousAttributes(
+ volume=attributes.volume.data,
+ signed_water_mass=attributes.signed_water_mass.data,
+ ),
+ timestep,
+ cell.data,
+ a_w_ice.data,
+ temperature.data,
+ relative_humidity_ice.data,
+ thaw=thaw,
+ )
+
@cached_property
def _record_freezing_temperatures_body(self):
ff = self.formulae_flattened
diff --git a/PySDM/dynamics/freezing.py b/PySDM/dynamics/freezing.py
index 50eb8dbcbe..94084e0c5f 100644
--- a/PySDM/dynamics/freezing.py
+++ b/PySDM/dynamics/freezing.py
@@ -1,14 +1,25 @@
"""
-immersion freezing using either singular or time-dependent formulation
+droplet freezing using either singular or
+time-dependent formulation for immersion freezing
+and homogeneous freezing and thaw
"""
from PySDM.dynamics.impl import register_dynamic
@register_dynamic()
-class Freezing:
- def __init__(self, *, singular=True, thaw=False):
+class Freezing: # pylint: disable=too-many-instance-attributes
+ def __init__(
+ self,
+ *,
+ singular=True,
+ homogeneous_freezing=False,
+ immersion_freezing=True,
+ thaw=False,
+ ):
self.singular = singular
+ self.homogeneous_freezing = homogeneous_freezing
+ self.immersion_freezing = immersion_freezing
self.thaw = thaw
self.enable = True
self.rand = None
@@ -26,12 +37,21 @@ def register(self, builder):
if self.singular:
builder.request_attribute("freezing temperature")
- if not self.singular:
+ if not self.singular and self.immersion_freezing:
assert (
self.particulator.formulae.heterogeneous_ice_nucleation_rate.__name__
!= "Null"
)
builder.request_attribute("immersed surface area")
+
+ if self.homogeneous_freezing:
+ assert (
+ self.particulator.formulae.homogeneous_ice_nucleation_rate.__name__
+ != "Null"
+ )
+ builder.request_attribute("volume")
+
+ if self.homogeneous_freezing or not self.singular:
self.rand = self.particulator.Storage.empty(
self.particulator.n_sd, dtype=float
)
@@ -49,11 +69,19 @@ def __call__(self):
if not self.enable:
return
- if self.singular:
- self.particulator.immersion_freezing_singular(thaw=self.thaw)
- else:
+ if self.immersion_freezing:
+ if self.singular:
+ self.particulator.immersion_freezing_singular(thaw=self.thaw)
+ else:
+ self.rand.urand(self.rng)
+ self.particulator.immersion_freezing_time_dependent(
+ rand=self.rand,
+ thaw=self.thaw,
+ )
+
+ if self.homogeneous_freezing:
self.rand.urand(self.rng)
- self.particulator.immersion_freezing_time_dependent(
+ self.particulator.homogeneous_freezing_time_dependent(
rand=self.rand,
thaw=self.thaw,
)
diff --git a/PySDM/formulae.py b/PySDM/formulae.py
index 30d9fb43a9..4b796f5784 100644
--- a/PySDM/formulae.py
+++ b/PySDM/formulae.py
@@ -47,6 +47,7 @@ def __init__( # pylint: disable=too-many-locals
hydrostatics: str = "ConstantGVapourMixingRatioAndThetaStd",
freezing_temperature_spectrum: str = "Null",
heterogeneous_ice_nucleation_rate: str = "Null",
+ homogeneous_ice_nucleation_rate: str = "Null",
fragmentation_function: str = "AlwaysN",
isotope_equilibrium_fractionation_factors: str = "Null",
isotope_kinetic_fractionation_factors: str = "Null",
@@ -86,6 +87,7 @@ def __init__( # pylint: disable=too-many-locals
self.hydrostatics = hydrostatics
self.freezing_temperature_spectrum = freezing_temperature_spectrum
self.heterogeneous_ice_nucleation_rate = heterogeneous_ice_nucleation_rate
+ self.homogeneous_ice_nucleation_rate = homogeneous_ice_nucleation_rate
self.fragmentation_function = fragmentation_function
self.isotope_equilibrium_fractionation_factors = (
isotope_equilibrium_fractionation_factors
diff --git a/PySDM/particulator.py b/PySDM/particulator.py
index 4f7606ff72..32ae0697d5 100644
--- a/PySDM/particulator.py
+++ b/PySDM/particulator.py
@@ -8,6 +8,7 @@
from PySDM.backends.impl_common.freezing_attributes import (
SingularAttributes,
TimeDependentAttributes,
+ TimeDependentHomogeneousAttributes,
)
from PySDM.backends.impl_common.index import make_Index
from PySDM.backends.impl_common.indexed_storage import make_IndexedStorage
@@ -537,3 +538,18 @@ def immersion_freezing_singular(self, *, thaw: bool):
thaw=thaw,
)
self.attributes.mark_updated("signed water mass")
+
+ def homogeneous_freezing_time_dependent(self, *, thaw: bool, rand: Storage):
+ self.backend.freeze_time_dependent_homogeneous(
+ rand=rand,
+ attributes=TimeDependentHomogeneousAttributes(
+ volume=self.attributes["volume"],
+ signed_water_mass=self.attributes["signed water mass"],
+ ),
+ timestep=self.dt,
+ cell=self.attributes["cell id"],
+ a_w_ice=self.environment["a_w_ice"],
+ temperature=self.environment["T"],
+ relative_humidity_ice=self.environment["RH_ice"],
+ thaw=thaw,
+ )
diff --git a/PySDM/physics/__init__.py b/PySDM/physics/__init__.py
index 7437944de6..f2e2bfb43f 100644
--- a/PySDM/physics/__init__.py
+++ b/PySDM/physics/__init__.py
@@ -27,6 +27,7 @@
fragmentation_function,
freezing_temperature_spectrum,
heterogeneous_ice_nucleation_rate,
+ homogeneous_ice_nucleation_rate,
hydrostatics,
hygroscopicity,
impl,
diff --git a/PySDM/physics/constants_defaults.py b/PySDM/physics/constants_defaults.py
index 39d89938dc..097ff04f8d 100644
--- a/PySDM/physics/constants_defaults.py
+++ b/PySDM/physics/constants_defaults.py
@@ -106,13 +106,6 @@
""" thermal accommodation coefficient for vapour deposition as recommended in
[Pruppacher & Klett](https://doi.org/10.1007/978-0-306-48100-0) """
-p1000 = 1000 * si.hectopascals
-c_pd = 1005 * si.joule / si.kilogram / si.kelvin
-c_pv = 1850 * si.joule / si.kilogram / si.kelvin
-g_std = sci.g * si.metre / si.second**2
-
-c_pw = 4218 * si.joule / si.kilogram / si.kelvin
-
ARM_C1 = 6.1094 * si.hectopascal
""" [August](https://doi.org/10.1002/andp.18280890511) Roche Magnus formula coefficients
(values from [Alduchov & Eskridge 1996](https://doi.org/10.1175%2F1520-0450%281996%29035%3C0601%3AIMFAOS%3E2.0.CO%3B2))
@@ -322,8 +315,44 @@
ABIFM_C = np.inf
""" 〃 """
+KOOP_2000_C1 = -906.7
+""" homogeneous ice nucleation rate
+([Koop et al. 2000](https://doi.org/10.1038/35020537)) """
+KOOP_2000_C2 = 8502
+""" 〃 """
+KOOP_2000_C3 = -26924
+""" 〃 """
+KOOP_2000_C4 = 29180
+""" 〃 """
+KOOP_UNIT = 1 / si.cm**3 / si.s
+""" 〃 """
+KOOP_MIN_DA_W_ICE = 0.26
+""" 〃 """
+KOOP_MAX_DA_W_ICE = 0.34
+
+KOOP_CORR = -1.522
+""" homogeneous ice nucleation rate correction factor
+([Spichtinger et al. 2023](https://doi.org/10.5194/acp-23-2035-2023)) """
+
+KOOP_MURRAY_C0 = -3020.684
+""" homogeneous ice nucleation rate for pure water droplets
+([Koop & Murray 20016](https://doi.org/10.1063/1.4962355)) """
+KOOP_MURRAY_C1 = -425.921 / si.K
+""" 〃 """
+KOOP_MURRAY_C2 = -25.9779 / si.K**2
+""" 〃 """
+KOOP_MURRAY_C3 = -0.868451 / si.K**3
+""" 〃 """
+KOOP_MURRAY_C4 = -1.66203e-2 / si.K**4
+""" 〃 """
+KOOP_MURRAY_C5 = -1.71736e-4 / si.K**5
+""" 〃 """
+KOOP_MURRAY_C6 = -7.46953e-7 / si.K**6
+""" 〃 """
+
J_HET = np.nan
-""" constant ice nucleation rate """
+J_HOM = np.nan
+""" constant ice nucleation rates """
STRAUB_E_D1 = 0.04 * si.cm
""" [Straub et al. 2010](https://doi.org/10.1175/2009JAS3175.1) """
diff --git a/PySDM/physics/homogeneous_ice_nucleation_rate/__init__.py b/PySDM/physics/homogeneous_ice_nucleation_rate/__init__.py
new file mode 100644
index 0000000000..95fe7ed622
--- /dev/null
+++ b/PySDM/physics/homogeneous_ice_nucleation_rate/__init__.py
@@ -0,0 +1,9 @@
+"""
+homogeneous-freezing rate (aka J_hom) formulations
+"""
+
+from .constant import Constant
+from .null import Null
+from .koop import Koop2000
+from .koop_corr import Koop_Correction
+from .koop_murray import KoopMurray2016
diff --git a/PySDM/physics/homogeneous_ice_nucleation_rate/constant.py b/PySDM/physics/homogeneous_ice_nucleation_rate/constant.py
new file mode 100644
index 0000000000..9d4a79ef51
--- /dev/null
+++ b/PySDM/physics/homogeneous_ice_nucleation_rate/constant.py
@@ -0,0 +1,22 @@
+"""
+constant rate formulation (for tests)
+"""
+
+import numpy as np
+
+
+class Constant: # pylint: disable=too-few-public-methods
+ def __init__(self, const):
+ assert np.isfinite(const.J_HOM)
+
+ @staticmethod
+ def d_a_w_ice_within_range(const, da_w_ice): # pylint: disable=unused-argument
+ return True
+
+ @staticmethod
+ def d_a_w_ice_maximum(const, da_w_ice): # pylint: disable=unused-argument
+ return da_w_ice
+
+ @staticmethod
+ def j_hom(const, T, a_w_ice): # pylint: disable=unused-argument
+ return const.J_HOM
diff --git a/PySDM/physics/homogeneous_ice_nucleation_rate/koop.py b/PySDM/physics/homogeneous_ice_nucleation_rate/koop.py
new file mode 100644
index 0000000000..8a1526f4c2
--- /dev/null
+++ b/PySDM/physics/homogeneous_ice_nucleation_rate/koop.py
@@ -0,0 +1,35 @@
+"""
+Koop homogeneous nucleation rate parameterization for solution droplets
+valid for 0.26 < da_w_ice < 0.34
+ ([Koop et al. 2000](https://doi.org/10.1038/35020537))
+"""
+
+import numpy as np
+
+
+class Koop2000: # pylint: disable=too-few-public-methods
+ def __init__(self, const):
+ pass
+
+ @staticmethod
+ def d_a_w_ice_within_range(const, da_w_ice):
+ return da_w_ice >= const.KOOP_MIN_DA_W_ICE
+
+ @staticmethod
+ def d_a_w_ice_maximum(const, da_w_ice):
+ return np.where(
+ da_w_ice > const.KOOP_MAX_DA_W_ICE, const.KOOP_MAX_DA_W_ICE, da_w_ice
+ )
+
+ @staticmethod
+ def j_hom(const, T, da_w_ice): # pylint: disable=unused-argument
+ return (
+ 10
+ ** (
+ const.KOOP_2000_C1
+ + const.KOOP_2000_C2 * da_w_ice
+ + const.KOOP_2000_C3 * da_w_ice**2.0
+ + const.KOOP_2000_C4 * da_w_ice**3.0
+ )
+ * const.KOOP_UNIT
+ )
diff --git a/PySDM/physics/homogeneous_ice_nucleation_rate/koop_corr.py b/PySDM/physics/homogeneous_ice_nucleation_rate/koop_corr.py
new file mode 100644
index 0000000000..a8a3f04380
--- /dev/null
+++ b/PySDM/physics/homogeneous_ice_nucleation_rate/koop_corr.py
@@ -0,0 +1,37 @@
+"""
+Koop homogeneous nucleation rate parameterization for solution droplets [Koop et al. 2000] corrected
+such that it coincides with homogeneous nucleation rate parameterization for pure water droplets
+[Koop and Murray 2016] at water saturation between 235K < T < 240K
+ ([Spichtinger et al. 2023](https://doi.org/10.5194/acp-23-2035-2023))
+"""
+
+import numpy as np
+
+
+class Koop_Correction: # pylint: disable=too-few-public-methods
+ def __init__(self, const):
+ pass
+
+ @staticmethod
+ def d_a_w_ice_within_range(const, da_w_ice):
+ return da_w_ice >= const.KOOP_MIN_DA_W_ICE
+
+ @staticmethod
+ def d_a_w_ice_maximum(const, da_w_ice):
+ return np.where(
+ da_w_ice > const.KOOP_MAX_DA_W_ICE, const.KOOP_MAX_DA_W_ICE, da_w_ice
+ )
+
+ @staticmethod
+ def j_hom(const, T, da_w_ice): # pylint: disable=unused-argument
+ return (
+ 10
+ ** (
+ const.KOOP_2000_C1
+ + const.KOOP_2000_C2 * da_w_ice
+ + const.KOOP_2000_C3 * da_w_ice**2.0
+ + const.KOOP_2000_C4 * da_w_ice**3.0
+ + const.KOOP_CORR
+ )
+ * const.KOOP_UNIT
+ )
diff --git a/PySDM/physics/homogeneous_ice_nucleation_rate/koop_murray.py b/PySDM/physics/homogeneous_ice_nucleation_rate/koop_murray.py
new file mode 100644
index 0000000000..d3e41816f5
--- /dev/null
+++ b/PySDM/physics/homogeneous_ice_nucleation_rate/koop_murray.py
@@ -0,0 +1,38 @@
+"""
+Koop and Murray homogeneous nucleation rate parameterization for pure water droplets
+at water saturation
+([eq. A9, Tab VII in Koop and Murray 2016](https://doi.org/10.1063/1.4962355))
+"""
+
+import numpy as np
+
+
+class KoopMurray2016: # pylint: disable=too-few-public-methods
+ def __init__(self, const):
+ pass
+
+ @staticmethod
+ def d_a_w_ice_within_range(const, da_w_ice):
+ return da_w_ice >= const.KOOP_MIN_DA_W_ICE
+
+ @staticmethod
+ def d_a_w_ice_maximum(const, da_w_ice):
+ return np.where(
+ da_w_ice > const.KOOP_MAX_DA_W_ICE, const.KOOP_MAX_DA_W_ICE, da_w_ice
+ )
+
+ @staticmethod
+ def j_hom(const, T, da_w_ice): # pylint: disable=unused-argument
+ return (
+ 10
+ ** (
+ const.KOOP_MURRAY_C0
+ + const.KOOP_MURRAY_C1 * (T - const.T0)
+ + const.KOOP_MURRAY_C2 * (T - const.T0) ** 2.0
+ + const.KOOP_MURRAY_C3 * (T - const.T0) ** 3.0
+ + const.KOOP_MURRAY_C4 * (T - const.T0) ** 4.0
+ + const.KOOP_MURRAY_C5 * (T - const.T0) ** 5.0
+ + const.KOOP_MURRAY_C6 * (T - const.T0) ** 6.0
+ )
+ * const.KOOP_UNIT
+ )
diff --git a/PySDM/physics/homogeneous_ice_nucleation_rate/null.py b/PySDM/physics/homogeneous_ice_nucleation_rate/null.py
new file mode 100644
index 0000000000..739bdf72cf
--- /dev/null
+++ b/PySDM/physics/homogeneous_ice_nucleation_rate/null.py
@@ -0,0 +1,23 @@
+"""
+do-nothing null formulation (needed as other formulations require parameters
+ to be set before instantiation of Formulae)
+"""
+
+import numpy as np
+
+
+class Null: # pylint: disable=too-few-public-methods,unused-argument
+ def __init__(self, _):
+ pass
+
+ @staticmethod
+ def d_a_w_ice_within_range(const, da_w_ice): # pylint: disable=unused-argument
+ return True
+
+ @staticmethod
+ def d_a_w_ice_maximum(const, da_w_ice): # pylint: disable=unused-argument
+ return da_w_ice
+
+ @staticmethod
+ def j_hom(const, T, d_a_w_ice): # pylint: disable=unused-argument
+ return np.nan
diff --git a/PySDM/physics/trivia.py b/PySDM/physics/trivia.py
index 59be2d2a49..93f53ce6d8 100644
--- a/PySDM/physics/trivia.py
+++ b/PySDM/physics/trivia.py
@@ -83,6 +83,10 @@ def unfrozen(signed_water_mass):
def unfrozen_and_saturated(signed_water_mass, relative_humidity):
return signed_water_mass > 0 and relative_humidity > 1
+ @staticmethod
+ def unfrozen_and_ice_saturated(signed_water_mass, relative_humidity_ice):
+ return signed_water_mass > 0 and relative_humidity_ice > 1
+
@staticmethod
def frozen_and_above_freezing_point(const, signed_water_mass, temperature):
return signed_water_mass < 0 and temperature > const.T0
diff --git a/docs/bibliography.json b/docs/bibliography.json
index 01546a2c61..44b4be2186 100644
--- a/docs/bibliography.json
+++ b/docs/bibliography.json
@@ -897,5 +897,33 @@
],
"title": "Remarks on the deuterium excess in precipitation in cold regions",
"label": "Fisher 1991 (Tellus B)"
+ },
+ "https://doi.org/10.5194/acp-23-2035-2023": {
+ "usages": [
+ "PySDM/physics/constants_defaults.py",
+ "PySDM/physics/homogeneous_ice_nucleation_rate/koop_corr.py",
+ "examples/PySDM_examples/Spichtinger_et_al_2023/__init__.py",
+ "examples/PySDM_examples/Spichtinger_et_al_2023/data/reference_bulk.py",
+ "examples/PySDM_examples/Spichtinger_et_al_2023/fig_B1.ipynb",
+ "tests/unit_tests/physics/test_homogeneous_nucleation_rates.py"
+ ],
+ "title": "Impact of formulations of the homogeneous nucleation rate on ice nucleation events in cirrus",
+ "label": "Spichtinger et al. 2023 (Atmos. Chem. Phys. 23)"
+ },
+ "https://doi.org/10.1038/35020537": {
+ "usages": [
+ "PySDM/physics/constants_defaults.py",
+ "PySDM/physics/homogeneous_ice_nucleation_rate/koop.py"
+ ],
+ "title": "Water activity as the determinant for homogeneous ice nucleation in aqueous solutions",
+ "label": "Koop et al. 2000 (Nature 406)"
+ },
+ "https://doi.org/10.1063/1.4962355": {
+ "usages": [
+ "PySDM/physics/constants_defaults.py",
+ "PySDM/physics/homogeneous_ice_nucleation_rate/koop_murray.py"
+ ],
+ "title": "A physically constrained classical description of the homogeneous nucleation of ice in water",
+ "label": "Koop and Murray 2016 (J. Chem. Phys. 145)"
}
}
diff --git a/examples/PySDM_examples/Spichtinger_et_al_2023/__init__.py b/examples/PySDM_examples/Spichtinger_et_al_2023/__init__.py
new file mode 100644
index 0000000000..baa2774fa3
--- /dev/null
+++ b/examples/PySDM_examples/Spichtinger_et_al_2023/__init__.py
@@ -0,0 +1,7 @@
+# pylint: disable=invalid-name
+"""
+homogeneous nucleation event example based on Fig. B1. in
+[Spichtinger et al. 2023](https://doi.org/10.5194/acp-23-2035-2023)
+"""
+from .simulation import Simulation
+from .settings import Settings
diff --git a/examples/PySDM_examples/Spichtinger_et_al_2023/data/__init__.py b/examples/PySDM_examples/Spichtinger_et_al_2023/data/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/examples/PySDM_examples/Spichtinger_et_al_2023/data/reference_bulk.py b/examples/PySDM_examples/Spichtinger_et_al_2023/data/reference_bulk.py
new file mode 100644
index 0000000000..6aec8eb507
--- /dev/null
+++ b/examples/PySDM_examples/Spichtinger_et_al_2023/data/reference_bulk.py
@@ -0,0 +1,47 @@
+"""
+reference results for bulk scheme in Fig B1. in
+[Spichtinger et al. 2023](https://doi.org/10.5194/acp-23-2035-2023)
+"""
+
+import numpy as np
+
+
+def bulk_model_reference_array():
+
+ initial_temperatures = np.array([196.0, 216.0, 236.0])
+ updrafts = np.array([0.05, 0.1, 0.3, 0.5, 1.0, 3.0, 5.0, 10.0])
+
+ dim_size = (np.shape(initial_temperatures)[0], np.shape(updrafts)[0])
+ ni_bulk_ref = np.zeros(dim_size)
+
+ # T = 196
+ ni_bulk_ref[0, 0] = 643686.1316903427
+ ni_bulk_ref[0, 1] = 2368481.0609527444
+ ni_bulk_ref[0, 2] = 20160966.984670535
+ ni_bulk_ref[0, 3] = 49475281.81718969
+ ni_bulk_ref[0, 4] = 131080662.23620115
+ ni_bulk_ref[0, 5] = 401046528.70428866
+ ni_bulk_ref[0, 6] = 627442148.3402529
+ ni_bulk_ref[0, 7] = 1151707310.2210448
+
+ # T = 216
+ ni_bulk_ref[1, 0] = 60955.84292640147
+ ni_bulk_ref[1, 1] = 189002.0792186534
+ ni_bulk_ref[1, 2] = 1200751.6897658105
+ ni_bulk_ref[1, 3] = 2942110.815055958
+ ni_bulk_ref[1, 4] = 10475282.894692907
+ ni_bulk_ref[1, 5] = 90871045.40856971
+ ni_bulk_ref[1, 6] = 252175505.460412
+ ni_bulk_ref[1, 7] = 860335156.4717773
+
+ # T = 236
+ ni_bulk_ref[2, 0] = 13049.108886452004
+ ni_bulk_ref[2, 1] = 40422.244759544985
+ ni_bulk_ref[2, 2] = 237862.49854786208
+ ni_bulk_ref[2, 3] = 545315.7805748513
+ ni_bulk_ref[2, 4] = 1707801.469906006
+ ni_bulk_ref[2, 5] = 11128055.66932415
+ ni_bulk_ref[2, 6] = 27739585.111447476
+ ni_bulk_ref[2, 7] = 101799566.47225031
+
+ return initial_temperatures, updrafts, ni_bulk_ref
diff --git a/examples/PySDM_examples/Spichtinger_et_al_2023/data/simulation_data.py b/examples/PySDM_examples/Spichtinger_et_al_2023/data/simulation_data.py
new file mode 100644
index 0000000000..3b7f6cb597
--- /dev/null
+++ b/examples/PySDM_examples/Spichtinger_et_al_2023/data/simulation_data.py
@@ -0,0 +1,22 @@
+import numpy as np
+
+
+def saved_simulation_ensemble_mean():
+
+ ni_ens_mean = np.array(
+ [
+ [0.00000000e00, 0.00000000e00, 0.00000000e00],
+ [0.00000000e00, 0.00000000e00, 0.00000000e00],
+ [1.62069821e03, 0.00000000e00, 0.00000000e00],
+ [5.25377025e06, 9.75512904e05, 4.51431097e05],
+ [3.67137290e07, 5.45240337e06, 1.53884530e06],
+ [7.96514420e07, 1.13878118e07, 3.26386880e06],
+ [2.19385480e08, 3.62240242e07, 9.00657591e06],
+ [1.00631095e09, 2.34443408e08, 4.90577208e07],
+ [1.73457062e09, 5.20774276e08, 1.16040316e08],
+ ]
+ )
+ T = np.array([196.0, 216.0, 236.0])
+ w = np.array([0.01, 0.03, 0.05, 0.1, 0.3, 0.5, 1.0, 3.0, 5.0])
+
+ return T, w, ni_ens_mean
diff --git a/examples/PySDM_examples/Spichtinger_et_al_2023/fig_B1.ipynb b/examples/PySDM_examples/Spichtinger_et_al_2023/fig_B1.ipynb
new file mode 100644
index 0000000000..3dad57dcce
--- /dev/null
+++ b/examples/PySDM_examples/Spichtinger_et_al_2023/fig_B1.ipynb
@@ -0,0 +1,2004 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "a6b09eaef75333df",
+ "metadata": {},
+ "source": [
+ "\n",
+ "#### based on Fig. B1 from Spichtinger et al. 2023 (ACP) \"_Impact of formulations of the homogeneous nucleation rate on ice nucleation events in cirrus_\"\n",
+ "\n",
+ "(work in progress)\n",
+ "\n",
+ "https://doi.org/10.5194/acp-23-2035-2023"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "initial_id",
+ "metadata": {
+ "ExecuteTime": {
+ "end_time": "2025-05-15T14:20:31.231964Z",
+ "start_time": "2025-05-15T14:20:31.221457Z"
+ }
+ },
+ "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-examples')"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "69ce798ec8b87121",
+ "metadata": {
+ "ExecuteTime": {
+ "end_time": "2025-05-15T14:32:48.496132Z",
+ "start_time": "2025-05-15T14:32:47.161494Z"
+ }
+ },
+ "outputs": [],
+ "source": [
+ "import json\n",
+ "from PySDM_examples.Spichtinger_et_al_2023 import Simulation, Settings\n",
+ "from PySDM_examples.Spichtinger_et_al_2023.data import simulation_data, reference_bulk\n",
+ "import numpy as np\n",
+ "from matplotlib import pyplot\n",
+ "from open_atmos_jupyter_utils import show_plot"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "fabd7ea8e8a11996",
+ "metadata": {
+ "ExecuteTime": {
+ "end_time": "2025-05-15T14:20:46.119340Z",
+ "start_time": "2025-05-15T14:20:46.116341Z"
+ }
+ },
+ "outputs": [],
+ "source": [
+ "calculate_data = False\n",
+ "save_to_file = False\n",
+ "read_from_json = False"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "1acd9d93e2af385c",
+ "metadata": {
+ "ExecuteTime": {
+ "end_time": "2025-05-19T10:08:33.165481Z",
+ "start_time": "2025-05-19T10:08:32.903294Z"
+ }
+ },
+ "outputs": [],
+ "source": [
+ "if calculate_data:\n",
+ "\n",
+ " initial_temperatures = np.array([196.0, 216.0, 236.0])\n",
+ " updrafts = np.array([0.01, 0.03, 0.05, 0.1, 0.3, 0.5, 1.0, 3.0, 5.0])\n",
+ " number_of_ensemble_runs = 5\n",
+ " seeds = [124670285330, 439785398735, 9782539783258, 12874192127481, 12741731272]\n",
+ "\n",
+ " dim_updrafts = len(updrafts)\n",
+ " dim_initial_temperatures = len(initial_temperatures)\n",
+ "\n",
+ " number_concentration_ice = np.zeros(\n",
+ " [dim_updrafts, dim_initial_temperatures, number_of_ensemble_runs]\n",
+ " )\n",
+ "\n",
+ " for i in range(dim_updrafts):\n",
+ " for j in range(dim_initial_temperatures):\n",
+ " for k in range(number_of_ensemble_runs):\n",
+ " setting = Settings(n_sd=50000,\n",
+ " w_updraft=updrafts[i],\n",
+ " T0=initial_temperatures[j],\n",
+ " seed=seeds[k],\n",
+ " dt=0.1)\n",
+ " model = Simulation(setting)\n",
+ " number_concentration_ice[i, j, k] = model.run()\n",
+ "\n",
+ " if save_to_file:\n",
+ " file_name = \"data/ni_w_T_ens_\" + str(number_of_ensemble_runs) + \".json\"\n",
+ " data_file = {\n",
+ " \"ni\": number_concentration_ice.tolist(),\n",
+ " \"T\": initial_temperatures.tolist(),\n",
+ " \"w\": updrafts.tolist(),\n",
+ " }\n",
+ " with open(file_name, \"w\", encoding=\"utf-8\") as file:\n",
+ " json.dump(data_file, file)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "3821d0f892f4af29",
+ "metadata": {
+ "ExecuteTime": {
+ "end_time": "2025-05-15T14:32:54.180974Z",
+ "start_time": "2025-05-15T14:32:51.733640Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "image/svg+xml": [
+ "\n",
+ "\n",
+ "\n"
+ ],
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "application/vnd.jupyter.widget-view+json": {
+ "model_id": "c73995000eb34665bb23298a60ad0134",
+ "version_major": 2,
+ "version_minor": 0
+ },
+ "text/plain": [
+ "HBox(children=(HTML(value=\"./fig_B1.pdf
\"), HTML(value=\" 0.0 and RHi < 130.0:
+ print("break")
+ break
+ RHi_old = RHi
+
+ return output["ni"][-1]
diff --git a/examples/docs/pysdm_examples_landing.md b/examples/docs/pysdm_examples_landing.md
index cb7633ad91..8479131c91 100644
--- a/examples/docs/pysdm_examples_landing.md
+++ b/examples/docs/pysdm_examples_landing.md
@@ -92,6 +92,8 @@ Example notebooks include:
- condensation and aqueous-chemistry
- `PySDM_examples.Kreidenweis_et_al_2003`: Hoppel gap simulation setup (i.e. depiction of evolution of aerosol mass spectrum from a monomodal to bimodal due to aqueous‐phase SO2 oxidation)
- `PySDM_examples.Jaruga_and_Pawlowska_2018`: exploration of numerical convergence using the above Hoppel-gap simulation setup
+- freezing
+ - `PySDM_examples.Spichtinger_et_al_2023`: homogeneous freezing and ice growth (Wegener-Bergeron-Findeisen process)
The parcel environment is also featured in the PySDM tutorials.
diff --git a/tests/examples_tests/conftest.py b/tests/examples_tests/conftest.py
index 74cafe3e12..2b57a0daa5 100644
--- a/tests/examples_tests/conftest.py
+++ b/tests/examples_tests/conftest.py
@@ -25,6 +25,7 @@ def findfiles(path, regex):
"Alpert_and_Knopf_2016",
"Ervens_and_Feingold_2012",
"Niedermeier_et_al_2014",
+ "Spichtinger_et_al_2023",
],
"isotopes": [
"Bolot_et_al_2013",
@@ -46,6 +47,7 @@ def findfiles(path, regex):
"condensation_a": [
"Lowe_et_al_2019",
"Singer_Ward",
+ "Rogers_1975",
],
"condensation_b": [
"Abdul_Razzak_Ghan_2000",
@@ -57,7 +59,6 @@ def findfiles(path, regex):
"Grabowski_and_Pawlowska_2023",
"Jensen_and_Nugent_2017",
"Abade_and_Albuquerque_2024",
- "Rogers_1975",
],
"coagulation": ["Berry_1967", "Shima_et_al_2009"],
"breakup": ["Bieli_et_al_2022", "deJong_Mackay_et_al_2023", "Srivastava_1982"],
diff --git a/tests/unit_tests/dynamics/test_immersion_freezing.py b/tests/unit_tests/dynamics/test_freezing.py
similarity index 77%
rename from tests/unit_tests/dynamics/test_immersion_freezing.py
rename to tests/unit_tests/dynamics/test_freezing.py
index d9f8486cec..ba46790dd7 100644
--- a/tests/unit_tests/dynamics/test_immersion_freezing.py
+++ b/tests/unit_tests/dynamics/test_freezing.py
@@ -14,7 +14,7 @@
EPSILON_RH = 1e-3
-class TestImmersionFreezing:
+class TestDropletFreezing:
@staticmethod
@pytest.mark.parametrize(
"record_freezing_temperature",
@@ -86,27 +86,49 @@ def test_no_subsaturated_freezing(self):
pass
@staticmethod
- @pytest.mark.parametrize("singular", (True, False))
+ @pytest.mark.parametrize(
+ "freezing_type", ("het_singular", "het_time_dependent", "hom_time_dependent")
+ )
@pytest.mark.parametrize("thaw", (True, False))
@pytest.mark.parametrize("epsilon", (0, 1e-5))
- def test_thaw(backend_class, singular, thaw, epsilon):
+ def test_thaw(backend_class, freezing_type, thaw, epsilon):
# arrange
+ singular = False
+ immersion_freezing = True
+ homogeneous_freezing = False
+ if freezing_type == "het_singular":
+ freezing_parameter = {}
+ singular = True
+ elif freezing_type == "het_time_dependent":
+ freezing_parameter = {
+ "heterogeneous_ice_nucleation_rate": "Constant",
+ "constants": {"J_HET": 0},
+ }
+ elif freezing_type == "hom_time_dependent":
+ freezing_parameter = {
+ "homogeneous_ice_nucleation_rate": "Constant",
+ "constants": {"J_HOM": 0},
+ }
+ immersion_freezing = False
+ homogeneous_freezing = True
+ if backend_class.__name__ == "ThrustRTC":
+ pytest.skip()
formulae = Formulae(
particle_shape_and_density="MixedPhaseSpheres",
- **(
- {}
- if singular
- else {
- "heterogeneous_ice_nucleation_rate": "Constant",
- "constants": {"J_HET": 0},
- }
- ),
+ **(freezing_parameter),
)
env = Box(dt=1 * si.s, dv=1 * si.m**3)
builder = Builder(
n_sd=1, backend=backend_class(formulae=formulae), environment=env
)
- builder.add_dynamic(Freezing(singular=singular, thaw=thaw))
+ builder.add_dynamic(
+ Freezing(
+ singular=singular,
+ homogeneous_freezing=homogeneous_freezing,
+ immersion_freezing=immersion_freezing,
+ thaw=thaw,
+ )
+ )
particulator = builder.build(
products=(IceWaterContent(),),
attributes={
@@ -123,6 +145,7 @@ def test_thaw(backend_class, singular, thaw, epsilon):
)
particulator.environment["T"] = formulae.constants.T0 + epsilon
particulator.environment["RH"] = np.nan
+ particulator.environment["RH_ice"] = np.nan
if not singular:
particulator.environment["a_w_ice"] = np.nan
assert particulator.products["ice water content"].get() > 0
@@ -137,7 +160,7 @@ def test_thaw(backend_class, singular, thaw, epsilon):
assert particulator.products["ice water content"].get() > 0
@staticmethod
- def test_freeze_singular(backend_class):
+ def test_immersion_freezing_singular(backend_class):
# arrange
n_sd = 44
dt = 1 * si.s
@@ -176,8 +199,13 @@ def test_freeze_singular(backend_class):
@staticmethod
@pytest.mark.parametrize("double_precision", (True, False))
- # pylint: disable=too-many-locals
- def test_freeze_time_dependent(backend_class, double_precision, plot=False):
+ @pytest.mark.parametrize(
+ "freezing_type", ("het_time_dependent", "hom_time_dependent")
+ )
+ # pylint: disable=too-many-locals,too-many-statements
+ def test_freezing_time_dependent(
+ backend_class, freezing_type, double_precision, plot=False
+ ):
if backend_class.__name__ == "Numba" and not double_precision:
pytest.skip()
@@ -193,6 +221,7 @@ def test_freeze_time_dependent(backend_class, double_precision, plot=False):
)
rate = 1e-9
immersed_surface_area = 1
+ droplet_volume = 1
number_of_real_droplets = 1024
total_time = (
@@ -200,9 +229,7 @@ def test_freeze_time_dependent(backend_class, double_precision, plot=False):
)
# dummy (but must-be-set) values
- initial_water_mass = (
- 44 # for sign flip (ice water has negative volumes), value does not matter
- )
+ initial_water_mass = 1000 # for sign flip (ice water has negative volumes)
d_v = 666 # products use conc., dividing there, multiplying here, value does not matter
def hgh(t):
@@ -211,15 +238,32 @@ def hgh(t):
def low(t):
return np.exp(-1.25 * rate * (t + total_time / 4))
+ immersion_freezing = True
+ homogeneous_freezing = False
+ if freezing_type == "het_time_dependent":
+ freezing_parameter = {
+ "heterogeneous_ice_nucleation_rate": "Constant",
+ "constants": {"J_HET": rate / immersed_surface_area},
+ }
+ elif freezing_type == "hom_time_dependent":
+ freezing_parameter = {
+ "homogeneous_ice_nucleation_rate": "Constant",
+ "constants": {"J_HOM": rate / droplet_volume},
+ }
+ immersion_freezing = False
+ homogeneous_freezing = True
+ if backend_class.__name__ == "ThrustRTC":
+ pytest.skip()
+
# Act
output = {}
formulae = Formulae(
particle_shape_and_density="MixedPhaseSpheres",
- heterogeneous_ice_nucleation_rate="Constant",
- constants={"J_HET": rate / immersed_surface_area},
+ **(freezing_parameter),
seed=seed,
)
+
products = (IceWaterContent(name="qi"),)
for case in cases:
@@ -238,7 +282,13 @@ def low(t):
),
environment=env,
)
- builder.add_dynamic(Freezing(singular=False))
+ builder.add_dynamic(
+ Freezing(
+ singular=False,
+ immersion_freezing=immersion_freezing,
+ homogeneous_freezing=homogeneous_freezing,
+ )
+ )
attributes = {
"multiplicity": np.full(n_sd, int(case["N"])),
"immersed surface area": np.full(n_sd, immersed_surface_area),
@@ -246,7 +296,8 @@ def low(t):
}
particulator = builder.build(attributes=attributes, products=products)
particulator.environment["RH"] = 1.0001
- particulator.environment["a_w_ice"] = np.nan
+ particulator.environment["RH_ice"] = 1.5
+ particulator.environment["a_w_ice"] = 0.6
particulator.environment["T"] = np.nan
cell_id = 0
diff --git a/tests/unit_tests/physics/test_homogeneous_nucleation_rates.py b/tests/unit_tests/physics/test_homogeneous_nucleation_rates.py
new file mode 100644
index 0000000000..f3b51a01ec
--- /dev/null
+++ b/tests/unit_tests/physics/test_homogeneous_nucleation_rates.py
@@ -0,0 +1,119 @@
+"""
+test for homogeneous nucleation rate parameterisations
+"""
+
+from contextlib import nullcontext
+import re
+import pytest
+from matplotlib import pyplot
+import numpy as np
+from PySDM.formulae import Formulae, _choices
+from PySDM.physics import homogeneous_ice_nucleation_rate
+from PySDM import physics
+from PySDM.physics.dimensional_analysis import DimensionalAnalysis
+
+SPICHTINGER_ET_AL_2023_FIG2_DATA = {
+ "da_w_ice": [0.27, 0.29, 0.31, 0.33],
+ "jhom_log10": [5, 11, 15, 20],
+}
+
+
+class TestHomogeneousIceNucleationRate:
+ @staticmethod
+ @pytest.mark.parametrize(
+ "index", range(len(SPICHTINGER_ET_AL_2023_FIG2_DATA["da_w_ice"]))
+ )
+ @pytest.mark.parametrize(
+ "parametrisation, context",
+ (
+ ("Koop_Correction", nullcontext()),
+ (
+ "Koop2000",
+ pytest.raises(
+ AssertionError, match="Items are not equal to 2 significant digits"
+ ),
+ ),
+ (
+ "KoopMurray2016",
+ pytest.raises(
+ ValueError,
+ match=re.escape(
+ "x and y must have same first dimension, but have shapes (4,) and (1,)"
+ ),
+ ),
+ ),
+ ),
+ )
+ def test_fig_2_in_spichtinger_et_al_2023(
+ index, parametrisation, context, plot=False
+ ):
+ """Fig. 2 in [Spichtinger et al. 2023](https://doi.org/10.5194/acp-23-2035-2023)"""
+ # arrange
+ formulae = Formulae(
+ homogeneous_ice_nucleation_rate=parametrisation,
+ )
+
+ # act
+ with context:
+ jhom_log10 = np.log10(
+ formulae.homogeneous_ice_nucleation_rate.j_hom(
+ np.nan, np.asarray(SPICHTINGER_ET_AL_2023_FIG2_DATA["da_w_ice"])
+ )
+ )
+
+ # plot
+ pyplot.scatter(
+ x=[SPICHTINGER_ET_AL_2023_FIG2_DATA["da_w_ice"][index]],
+ y=[SPICHTINGER_ET_AL_2023_FIG2_DATA["jhom_log10"][index]],
+ color="red",
+ marker="x",
+ )
+ pyplot.plot(
+ SPICHTINGER_ET_AL_2023_FIG2_DATA["da_w_ice"],
+ jhom_log10,
+ marker=".",
+ )
+ pyplot.gca().set(
+ xlabel=r"water activity difference $\Delta a_w$",
+ ylabel="log$_{10}(J)$",
+ title=parametrisation,
+ xlim=(0.26, 0.34),
+ ylim=(0, 25),
+ )
+ pyplot.grid()
+ if plot:
+ pyplot.show()
+ else:
+ pyplot.clf()
+
+ # assert
+ np.testing.assert_approx_equal(
+ actual=jhom_log10[index],
+ desired=SPICHTINGER_ET_AL_2023_FIG2_DATA["jhom_log10"][index],
+ significant=2,
+ )
+
+ @staticmethod
+ @pytest.mark.parametrize("variant", _choices(homogeneous_ice_nucleation_rate))
+ def test_units(variant):
+ if variant == "Null":
+ pytest.skip()
+
+ with DimensionalAnalysis():
+ # arrange
+ si = physics.si
+ formulae = Formulae(
+ homogeneous_ice_nucleation_rate=variant,
+ constants=(
+ {} if variant != "Constant" else {"J_HOM": 1 / si.m**3 / si.s}
+ ),
+ )
+ sut = formulae.homogeneous_ice_nucleation_rate
+ temperature = 250 * si.K
+ da_w_ice = 0.3 * si.dimensionless
+
+ # act
+ value = sut.j_hom(temperature, da_w_ice)
+
+ # assert
+ assert value.check("1/[volume]/[time]")