diff --git a/PySDM/backends/impl_common/freezing_attributes.py b/PySDM/backends/impl_common/freezing_attributes.py index a58718c9e..a9f788ff4 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 6799e3b98..99f41017a 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,6 +13,7 @@ from ...impl_common.freezing_attributes import ( SingularAttributes, TimeDependentAttributes, + TimeDependentHomogeneousAttributes, ) @@ -19,18 +21,19 @@ class FreezingMethods(BackendMethods): def __init__(self): BackendMethods.__init__(self) unfrozen_and_saturated = self.formulae.trivia.unfrozen_and_saturated + unfrozen_and_ice_saturated = self.formulae.trivia.unfrozen_and_ice_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] + def _freeze(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] + def _thaw(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) @@ -92,6 +95,48 @@ def freeze_time_dependent_body( # pylint: disable=too-many-arguments self.freeze_time_dependent_body = freeze_time_dependent_body + j_hom = self.formulae.homogeneous_ice_nucleation_rate.j_hom + + @numba.njit(**self.default_jit_flags) + def freeze_time_dependent_homogeneous_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 0.23 < d_a_w_ice < 0.34: + 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) + + self.freeze_time_dependent_homogeneous_body = ( + freeze_time_dependent_homogeneous_body + ) + def freeze_singular( self, *, attributes, temperature, relative_humidity, cell, thaw: bool ): @@ -132,6 +177,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 50eb8dbcb..94084e0c5 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 5b28b4666..bb8c7fe1e 100644 --- a/PySDM/formulae.py +++ b/PySDM/formulae.py @@ -23,7 +23,7 @@ from PySDM.dynamics.terminal_velocity.gunn_and_kinzer import TpDependent -class Formulae: # pylint: disable=too-few-public-methods,too-many-instance-attributes +class Formulae: # pylint: disable=too-few-public-methods,too-many-instance-attributes,too-many-statements def __init__( # pylint: disable=too-many-locals self, *, @@ -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", @@ -85,6 +86,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 4f7606ff7..32ae0697d 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 786510501..63c042d15 100644 --- a/PySDM/physics/__init__.py +++ b/PySDM/physics/__init__.py @@ -17,6 +17,7 @@ """ from . import ( + dimensional_analysis, diffusion_coordinate, constants_defaults, diffusion_kinetics, @@ -27,6 +28,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 55cd2c05e..b907d496c 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,41 @@ 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_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 000000000..95fe7ed62 --- /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 000000000..f5f32353d --- /dev/null +++ b/PySDM/physics/homogeneous_ice_nucleation_rate/constant.py @@ -0,0 +1,14 @@ +""" +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 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 000000000..d25742ddd --- /dev/null +++ b/PySDM/physics/homogeneous_ice_nucleation_rate/koop.py @@ -0,0 +1,23 @@ +""" +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)) +""" + + +class Koop2000: # pylint: disable=too-few-public-methods + def __init__(self, const): + pass + + @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 000000000..4de3f3d88 --- /dev/null +++ b/PySDM/physics/homogeneous_ice_nucleation_rate/koop_corr.py @@ -0,0 +1,25 @@ +""" +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)) +""" + + +class Koop_Correction: # pylint: disable=too-few-public-methods + def __init__(self, const): + pass + + @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 000000000..8eb341f01 --- /dev/null +++ b/PySDM/physics/homogeneous_ice_nucleation_rate/koop_murray.py @@ -0,0 +1,27 @@ +""" +Koop and Murray homogeneous nucleation rate parameterization for pure water droplets +at water saturation + ([Koop and Murray 2016](https://doi.org/10.1063/1.4962355)) +""" + + +class KoopMurray2016: # pylint: disable=too-few-public-methods + def __init__(self, const): + pass + + @staticmethod + def j_hom(const, T, da_w_ice): # pylint: disable=unused-argument + T_diff = T - const.T_tri + return ( + 10 + ** ( + const.KOOP_MURRAY_C0 + + const.KOOP_MURRAY_C1 * T_diff + + const.KOOP_MURRAY_C2 * T_diff**2.0 + + const.KOOP_MURRAY_C3 * T_diff**3.0 + + const.KOOP_MURRAY_C4 * T_diff**4.0 + + const.KOOP_MURRAY_C5 * T_diff**5.0 + + const.KOOP_MURRAY_C6 * T_diff**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 000000000..8fd96b4d3 --- /dev/null +++ b/PySDM/physics/homogeneous_ice_nucleation_rate/null.py @@ -0,0 +1,15 @@ +""" +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 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 59be2d2a4..93f53ce6d 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 e0a9dc173..0bbc8946a 100644 --- a/docs/bibliography.json +++ b/docs/bibliography.json @@ -867,5 +867,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 000000000..baa2774fa --- /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 000000000..e69de29bb 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 000000000..6aec8eb50 --- /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 000000000..3b7f6cb59 --- /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 000000000..e0ffc971e --- /dev/null +++ b/examples/PySDM_examples/Spichtinger_et_al_2023/fig_B1.ipynb @@ -0,0 +1,232 @@ +{ + "cells": [ + { + "metadata": {}, + "cell_type": "markdown", + "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" + ], + "id": "a6b09eaef75333df" + }, + { + "cell_type": "code", + "id": "initial_id", + "metadata": { + "collapsed": true, + "ExecuteTime": { + "end_time": "2025-05-15T14:20:31.231964Z", + "start_time": "2025-05-15T14:20:31.221457Z" + } + }, + "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')" + ], + "outputs": [], + "execution_count": 5 + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-05-15T14:32:48.496132Z", + "start_time": "2025-05-15T14:32:47.161494Z" + } + }, + "cell_type": "code", + "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" + ], + "id": "69ce798ec8b87121", + "outputs": [], + "execution_count": 11 + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-05-15T14:20:46.119340Z", + "start_time": "2025-05-15T14:20:46.116341Z" + } + }, + "cell_type": "code", + "source": [ + "calculate_data = False\n", + "save_to_file = False\n", + "read_from_json = False" + ], + "id": "fabd7ea8e8a11996", + "outputs": [], + "execution_count": 7 + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-05-19T10:08:33.165481Z", + "start_time": "2025-05-19T10:08:32.903294Z" + } + }, + "cell_type": "code", + "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)" + ], + "id": "1acd9d93e2af385c", + "outputs": [ + { + "ename": "NameError", + "evalue": "name 'calculate_data' is not defined", + "output_type": "error", + "traceback": [ + "\u001B[31m---------------------------------------------------------------------------\u001B[39m", + "\u001B[31mNameError\u001B[39m Traceback (most recent call last)", + "\u001B[36mCell\u001B[39m\u001B[36m \u001B[39m\u001B[32mIn[1]\u001B[39m\u001B[32m, line 1\u001B[39m\n\u001B[32m----> \u001B[39m\u001B[32m1\u001B[39m \u001B[38;5;28;01mif\u001B[39;00m \u001B[43mcalculate_data\u001B[49m:\n\u001B[32m 2\u001B[39m \n\u001B[32m 3\u001B[39m \u001B[38;5;66;03m# general_settings = {\"n_sd\": 50000, \"dt\": 0.1}\u001B[39;00m\n\u001B[32m 5\u001B[39m initial_temperatures = np.array([\u001B[32m196.0\u001B[39m, \u001B[32m216.0\u001B[39m, \u001B[32m236.0\u001B[39m])\n\u001B[32m 6\u001B[39m updrafts = np.array([\u001B[32m0.01\u001B[39m, \u001B[32m0.03\u001B[39m, \u001B[32m0.05\u001B[39m, \u001B[32m0.1\u001B[39m, \u001B[32m0.3\u001B[39m, \u001B[32m0.5\u001B[39m, \u001B[32m1.0\u001B[39m, \u001B[32m3.0\u001B[39m, \u001B[32m5.0\u001B[39m])\n", + "\u001B[31mNameError\u001B[39m: name 'calculate_data' is not defined" + ] + } + ], + "execution_count": 1 + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-05-15T14:32:54.180974Z", + "start_time": "2025-05-15T14:32:51.733640Z" + } + }, + "cell_type": "code", + "source": [ + "if calculate_data:\n", + " T = initial_temperatures\n", + " w = updrafts\n", + " ni_ens_mean = np.mean(number_concentration_ice, axis=2)\n", + "else:\n", + " if read_from_json:\n", + " file_name = \"data/ni_w_T_ens_5.json\"\n", + " with open(file_name, \"r\", encoding=\"utf-8\") as f:\n", + " data = json.load(f)\n", + "\n", + " ni = data[\"ni\"]\n", + " T = data[\"T\"]\n", + " w = data[\"w\"]\n", + " ni_ens_mean = np.mean(ni, axis=2)\n", + " else:\n", + " T, w, ni_ens_mean = simulation_data.saved_simulation_ensemble_mean()\n", + "\n", + "# bulk reference\n", + "T_bulk_ref, w_bulk_ref, ni_bulk_ref = reference_bulk.bulk_model_reference_array()\n", + "\n", + "# plot\n", + "fig, ax = pyplot.subplots(1, 1, figsize=(5, 5))\n", + "\n", + "for j, Tj in enumerate(T):\n", + " ax.scatter(w, ni_ens_mean[:, j], label=f\"T0={Tj:.0f}K\")\n", + " ax.plot(w_bulk_ref, ni_bulk_ref[j, :], linestyle=\"dashed\")\n", + "\n", + "ax.set_xscale(\"log\")\n", + "ax.set_xlim(0.08, 10.0)\n", + "ax.set_xlabel(r\"vertical updraft [$\\mathrm{m \\, s^{-1}}$]\")\n", + "\n", + "ax.set_yscale(\"log\")\n", + "ax.set_ylim(1.0e2, 1.0e10)\n", + "ax.set_ylabel(r\"ice number concentration [$\\mathrm{m^{-3}}$]\")\n", + "\n", + "ax.legend(loc=\"lower right\")\n", + "\n", + "show_plot('fig_B1.pdf')" + ], + "id": "3821d0f892f4af29", + "outputs": [ + { + "data": { + "text/plain": [ + "
" + ], + "image/svg+xml": "\n\n\n \n \n \n \n 2025-05-15T16:32:54.045692\n image/svg+xml\n \n \n Matplotlib v3.10.1, https://matplotlib.org/\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n" + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "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/tests/examples_tests/conftest.py b/tests/examples_tests/conftest.py index 55e918ce5..70094d91c 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", 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 d9f8486ce..ba46790dd 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 000000000..7d2ad9eba --- /dev/null +++ b/tests/unit_tests/physics/test_homogeneous_nucleation_rates.py @@ -0,0 +1,118 @@ +""" +test for homogeneous nucleation rate parametrisations +""" + +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 + +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 physics.dimensional_analysis.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]")