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", + " \n", + " \n", + " \n", + " \n", + " 2025-06-09T13:51:58.962757\n", + " image/svg+xml\n", + " \n", + " \n", + " Matplotlib v3.8.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" + ], + "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]")