From 68ea35aa275f8379c6c4c88ab394a4238a11931e Mon Sep 17 00:00:00 2001 From: Kyle Gorkowski Date: Tue, 3 Mar 2026 14:04:21 -0700 Subject: [PATCH 1/3] feat(condensation): add latent heat mass transfer rate Add get_mass_transfer_rate_latent_heat using the thermal resistance factor and preserve isothermal parity when latent heat is zero. Export the function through particula.dynamics and expand mass_transfer tests to cover parity, rate reduction, broadcasting, and validation errors. Closes #1135 ADW-ID: 12b5f7df --- particula/dynamics/__init__.py | 1 + .../dynamics/condensation/mass_transfer.py | 76 +++++++ .../condensation/tests/mass_transfer_test.py | 186 ++++++++++++++++++ 3 files changed, 263 insertions(+) diff --git a/particula/dynamics/__init__.py b/particula/dynamics/__init__.py index f05476eec..7bf11d960 100644 --- a/particula/dynamics/__init__.py +++ b/particula/dynamics/__init__.py @@ -64,6 +64,7 @@ ) from particula.dynamics.condensation.mass_transfer import ( get_mass_transfer_rate, + get_mass_transfer_rate_latent_heat, get_first_order_mass_transport_k, get_thermal_resistance_factor, get_radius_transfer_rate, diff --git a/particula/dynamics/condensation/mass_transfer.py b/particula/dynamics/condensation/mass_transfer.py index 085be8810..34aafaef8 100644 --- a/particula/dynamics/condensation/mass_transfer.py +++ b/particula/dynamics/condensation/mass_transfer.py @@ -251,6 +251,82 @@ def get_thermal_resistance_factor( return np.asarray(thermal_factor, dtype=np.float64) +@validate_inputs( + { + "pressure_delta": "finite", + "first_order_mass_transport": "finite", + "temperature": "positive", + "molar_mass": "positive", + "latent_heat": "nonnegative", + "thermal_conductivity": "positive", + "vapor_pressure_surface": "nonnegative", + "diffusion_coefficient": "nonnegative", + } +) +def get_mass_transfer_rate_latent_heat( + pressure_delta: Union[float, NDArray[np.float64]], + first_order_mass_transport: Union[float, NDArray[np.float64]], + temperature: Union[float, NDArray[np.float64]], + molar_mass: Union[float, NDArray[np.float64]], + latent_heat: Union[float, NDArray[np.float64]], + thermal_conductivity: Union[float, NDArray[np.float64]], + vapor_pressure_surface: Union[float, NDArray[np.float64]], + diffusion_coefficient: Union[float, NDArray[np.float64]], +) -> Union[float, NDArray[np.float64]]: + """Calculate the non-isothermal mass transfer rate. + + This function applies the thermal resistance correction from + get_thermal_resistance_factor to the isothermal mass transfer rate. When + latent_heat is zero, the thermal correction reduces to unity and the + result matches get_mass_transfer_rate. + + Arguments: + - pressure_delta : Difference in partial pressure [Pa]. + - first_order_mass_transport : Mass transport coefficient K [m³/s]. + - temperature : Temperature T [K]. + - molar_mass : Molar mass M [kg/mol]. + - latent_heat : Latent heat of vaporization L [J/kg]. + - thermal_conductivity : Gas thermal conductivity kappa [W/(m·K)]. + - vapor_pressure_surface : Equilibrium vapor pressure at the surface + [Pa]. + - diffusion_coefficient : Vapor diffusion coefficient D [m²/s]. + + Returns: + Non-isothermal mass transfer rate [kg/s], matching the broadcasted + input shape. + + Raises: + ValueError: If any validated inputs violate positive/nonnegative + constraints. + """ + pressure_delta = np.asarray(pressure_delta) + first_order_mass_transport = np.asarray(first_order_mass_transport) + temperature = np.asarray(temperature) + molar_mass = np.asarray(molar_mass) + latent_heat = np.asarray(latent_heat) + thermal_conductivity = np.asarray(thermal_conductivity) + vapor_pressure_surface = np.asarray(vapor_pressure_surface) + diffusion_coefficient = np.asarray(diffusion_coefficient) + + thermal_factor = get_thermal_resistance_factor( + diffusion_coefficient=diffusion_coefficient, + latent_heat=latent_heat, + vapor_pressure_surface=vapor_pressure_surface, + thermal_conductivity=thermal_conductivity, + temperature=temperature, + molar_mass=molar_mass, + ) + r_specific = GAS_CONSTANT / molar_mass + correction = thermal_factor / (r_specific * temperature) + isothermal_rate = get_mass_transfer_rate( + pressure_delta=pressure_delta, + first_order_mass_transport=first_order_mass_transport, + temperature=temperature, + molar_mass=molar_mass, + ) + return np.array(isothermal_rate / correction, dtype=np.float64) + + @validate_inputs( { "mass_rate": "finite", diff --git a/particula/dynamics/condensation/tests/mass_transfer_test.py b/particula/dynamics/condensation/tests/mass_transfer_test.py index 176b2543c..c64b94c97 100644 --- a/particula/dynamics/condensation/tests/mass_transfer_test.py +++ b/particula/dynamics/condensation/tests/mass_transfer_test.py @@ -8,6 +8,7 @@ get_mass_transfer_of_multiple_species, get_mass_transfer_of_single_species, get_mass_transfer_rate, + get_mass_transfer_rate_latent_heat, get_radius_transfer_rate, get_thermal_resistance_factor, ) @@ -226,6 +227,191 @@ def test_thermal_resistance_factor_rejects_invalid_inputs(field, value): get_thermal_resistance_factor(**kwargs) +def test_mass_transfer_rate_latent_heat_isothermal_parity(): + """Latent heat of zero should match the isothermal rate (scalar).""" + pressure_delta = 23.39 + first_order_mass_transport = 1.5e-14 + temperature = 293.0 + molar_mass = 0.018015 + result = get_mass_transfer_rate_latent_heat( + pressure_delta=pressure_delta, + first_order_mass_transport=first_order_mass_transport, + temperature=temperature, + molar_mass=molar_mass, + latent_heat=0.0, + thermal_conductivity=0.0257, + vapor_pressure_surface=2339.0, + diffusion_coefficient=2.5e-5, + ) + expected = get_mass_transfer_rate( + pressure_delta=pressure_delta, + first_order_mass_transport=first_order_mass_transport, + temperature=temperature, + molar_mass=molar_mass, + ) + np.testing.assert_allclose(result, expected, rtol=1e-15) + + +def test_mass_transfer_rate_latent_heat_isothermal_parity_array(): + """Latent heat of zero should match isothermal rate (arrays).""" + pressure_delta = np.array([10.0, 15.0]) + first_order_mass_transport = np.array([1e-17, 2e-17]) + temperature = np.array([300.0, 310.0]) + molar_mass = np.array([0.02897, 0.018015]) + result = get_mass_transfer_rate_latent_heat( + pressure_delta=pressure_delta, + first_order_mass_transport=first_order_mass_transport, + temperature=temperature, + molar_mass=molar_mass, + latent_heat=0.0, + thermal_conductivity=0.0257, + vapor_pressure_surface=2339.0, + diffusion_coefficient=2.5e-5, + ) + expected = get_mass_transfer_rate( + pressure_delta=pressure_delta, + first_order_mass_transport=first_order_mass_transport, + temperature=temperature, + molar_mass=molar_mass, + ) + np.testing.assert_allclose(result, expected, rtol=1e-15) + + +def test_mass_transfer_rate_latent_heat_rate_reduction(): + """Non-isothermal rate magnitude should be reduced when latent heat > 0.""" + pressure_delta = np.array([23.39, -23.39]) + first_order_mass_transport = 1.5e-14 + temperature = 293.0 + molar_mass = 0.018015 + latent_heat = 2.454e6 + thermal_conductivity = 0.0257 + vapor_pressure_surface = 2339.0 + diffusion_coefficient = 2.5e-5 + non_isothermal = get_mass_transfer_rate_latent_heat( + pressure_delta=pressure_delta, + first_order_mass_transport=first_order_mass_transport, + temperature=temperature, + molar_mass=molar_mass, + latent_heat=latent_heat, + thermal_conductivity=thermal_conductivity, + vapor_pressure_surface=vapor_pressure_surface, + diffusion_coefficient=diffusion_coefficient, + ) + isothermal = get_mass_transfer_rate( + pressure_delta=pressure_delta, + first_order_mass_transport=first_order_mass_transport, + temperature=temperature, + molar_mass=molar_mass, + ) + assert np.all(np.abs(non_isothermal) < np.abs(isothermal)) + + +def test_mass_transfer_rate_latent_heat_water_293k(): + """Check water-in-air values at 293 K with latent heat correction.""" + pressure_delta = 23.39 + first_order_mass_transport = 1.5e-14 + temperature = 293.0 + molar_mass = 0.018015 + latent_heat = 2.454e6 + thermal_conductivity = 0.0257 + vapor_pressure_surface = 2339.0 + diffusion_coefficient = 2.5e-5 + thermal_factor = get_thermal_resistance_factor( + diffusion_coefficient=diffusion_coefficient, + latent_heat=latent_heat, + vapor_pressure_surface=vapor_pressure_surface, + thermal_conductivity=thermal_conductivity, + temperature=temperature, + molar_mass=molar_mass, + ) + expected = first_order_mass_transport * pressure_delta / thermal_factor + result = get_mass_transfer_rate_latent_heat( + pressure_delta=pressure_delta, + first_order_mass_transport=first_order_mass_transport, + temperature=temperature, + molar_mass=molar_mass, + latent_heat=latent_heat, + thermal_conductivity=thermal_conductivity, + vapor_pressure_surface=vapor_pressure_surface, + diffusion_coefficient=diffusion_coefficient, + ) + isothermal_rate = get_mass_transfer_rate( + pressure_delta=pressure_delta, + first_order_mass_transport=first_order_mass_transport, + temperature=temperature, + molar_mass=molar_mass, + ) + assert result > 0.0 + assert expected < isothermal_rate + np.testing.assert_allclose(result, expected, rtol=1e-12) + + +def test_mass_transfer_rate_latent_heat_array_shapes(): + """Mix scalar and array inputs to confirm broadcasting.""" + pressure_delta = np.array([[10.0], [15.0]]) + first_order_mass_transport = np.array( + [[1e-17, 2e-17, 3e-17], [1.1e-17, 2.1e-17, 3.1e-17]] + ) + temperature = np.array([[290.0], [300.0]]) + result = get_mass_transfer_rate_latent_heat( + pressure_delta=pressure_delta, + first_order_mass_transport=first_order_mass_transport, + temperature=temperature, + molar_mass=0.02897, + latent_heat=2.454e6, + thermal_conductivity=0.0257, + vapor_pressure_surface=2339.0, + diffusion_coefficient=2.5e-5, + ) + assert np.asarray(result).shape == (2, 3) + assert np.all(np.isfinite(result)) + + +def test_mass_transfer_rate_latent_heat_validation_negative_latent_heat(): + """Negative latent heat should raise a validation error.""" + with pytest.raises(ValueError, match="latent_heat"): + get_mass_transfer_rate_latent_heat( + pressure_delta=10.0, + first_order_mass_transport=1e-17, + temperature=293.0, + molar_mass=0.018015, + latent_heat=-1.0, + thermal_conductivity=0.0257, + vapor_pressure_surface=2339.0, + diffusion_coefficient=2.5e-5, + ) + + +def test_mass_transfer_rate_latent_heat_validation_zero_temperature(): + """Zero temperature should raise a validation error.""" + with pytest.raises(ValueError, match="temperature"): + get_mass_transfer_rate_latent_heat( + pressure_delta=10.0, + first_order_mass_transport=1e-17, + temperature=0.0, + molar_mass=0.018015, + latent_heat=2.454e6, + thermal_conductivity=0.0257, + vapor_pressure_surface=2339.0, + diffusion_coefficient=2.5e-5, + ) + + +def test_mass_transfer_rate_latent_heat_validation_negative_molar_mass(): + """Negative molar mass should raise a validation error.""" + with pytest.raises(ValueError, match="molar_mass"): + get_mass_transfer_rate_latent_heat( + pressure_delta=10.0, + first_order_mass_transport=1e-17, + temperature=293.0, + molar_mass=-0.01, + latent_heat=2.454e6, + thermal_conductivity=0.0257, + vapor_pressure_surface=2339.0, + diffusion_coefficient=2.5e-5, + ) + + def test_multi_species_mass_transfer_rate(): """Test the mass_transfer_rate function for multiple species.""" pressure_delta = np.array([10.0, 15.0]) From 34a5d31c8ca5ecd554175303589e719f3b7b0630 Mon Sep 17 00:00:00 2001 From: Kyle Gorkowski Date: Tue, 3 Mar 2026 14:21:27 -0700 Subject: [PATCH 2/3] docs(plans): mark non-isothermal mass transfer in progress Update E5-F2 planning docs and epic metadata to reflect the active P2 phase. Refresh public-facing docs and README highlights for latent-heat mass transfer utilities, and refine the mass-transfer docstring for consistency. Closes #1135 ADW-ID: 12b5f7df --- adw-docs/dev-plans/README.md | 2 +- .../epics/E5-non-isothermal-condensation.md | 3 ++- .../E5-F2-non-isothermal-mass-transfer.md | 11 +++++--- adw-docs/dev-plans/features/index.md | 2 +- docs/index.md | 2 ++ .../dynamics/condensation/mass_transfer.py | 25 +++++++++---------- readme.md | 5 ++-- 7 files changed, 28 insertions(+), 22 deletions(-) diff --git a/adw-docs/dev-plans/README.md b/adw-docs/dev-plans/README.md index b943c4b8a..45784ecdb 100644 --- a/adw-docs/dev-plans/README.md +++ b/adw-docs/dev-plans/README.md @@ -73,7 +73,7 @@ and rollout. - [E5-F1: Latent Heat Strategy Pattern][e5-f1] — Status: Completed (P2, #1123) - Scope: `LatentHeatStrategy` ABC with constant, linear, and power-law implementations plus builder/factory integration. -- [E5-F2: Non-Isothermal Mass Transfer Functions][e5-f2] — Status: Planning +- [E5-F2: Non-Isothermal Mass Transfer Functions][e5-f2] — Status: In Progress - Scope: Thermal resistance factor and non-isothermal mass transfer rate pure functions with energy tracking. - [E5-F3: CondensationLatentHeat Strategy Class][e5-f3] — Status: Planning diff --git a/adw-docs/dev-plans/epics/E5-non-isothermal-condensation.md b/adw-docs/dev-plans/epics/E5-non-isothermal-condensation.md index b18887c6c..5e3861c78 100644 --- a/adw-docs/dev-plans/epics/E5-non-isothermal-condensation.md +++ b/adw-docs/dev-plans/epics/E5-non-isothermal-condensation.md @@ -5,7 +5,7 @@ **Owners**: @Gorkowski **Start Date**: 2026-03-02 **Target Date**: TBD -**Last Updated**: 2026-03-02 +**Last Updated**: 2026-03-03 **Size**: Medium (7 features, ~22 phases) ## Vision @@ -207,6 +207,7 @@ L -> 0 as T -> T_c. Used in engineering thermodynamics and EOS-based models. - Note: needs `vapor_pressure_surface` to pass through to `get_thermal_resistance_factor()`. This is the equilibrium vapor pressure at the droplet surface (activity × pure_vapor_pressure × kelvin_term). + - Issue: #1135 | Size: S (~50 LOC) | Status: In Progress - When `latent_heat = 0`, the thermal_factor reduces to `R_specific * T` and the full expression reduces exactly to `get_mass_transfer_rate()` (isothermal limit). This MUST be tested to machine precision. diff --git a/adw-docs/dev-plans/features/E5-F2-non-isothermal-mass-transfer.md b/adw-docs/dev-plans/features/E5-F2-non-isothermal-mass-transfer.md index 302813b6d..9c6d6ecda 100644 --- a/adw-docs/dev-plans/features/E5-F2-non-isothermal-mass-transfer.md +++ b/adw-docs/dev-plans/features/E5-F2-non-isothermal-mass-transfer.md @@ -1,12 +1,12 @@ # Feature E5-F2: Non-Isothermal Mass Transfer Functions **Parent Epic**: [E5: Non-Isothermal Condensation with Latent Heat](../epics/E5-non-isothermal-condensation.md) -**Status**: Planning +**Status**: In Progress (P2) **Priority**: P1 **Owners**: @Gorkowski -**Start Date**: TBD +**Start Date**: 2026-03-03 **Target Date**: TBD -**Last Updated**: 2026-03-02 +**Last Updated**: 2026-03-03 **Size**: Small (3 phases) ## Summary @@ -113,7 +113,7 @@ negative dm (evaporation) = heat absorbed (Q < 0). `R_specific * T`), dimensional consistency, array broadcasting - [ ] **E5-F2-P2**: Add non-isothermal mass transfer rate function with tests - - Issue: TBD | Size: S (~50 LOC) | Status: Not Started + - Issue: #1135 | Size: S (~50 LOC) | Status: In Progress - File: `particula/dynamics/condensation/mass_transfer.py` (extend) - Function: `get_mass_transfer_rate_latent_heat(pressure_delta, first_order_mass_transport, temperature, molar_mass, latent_heat, @@ -128,6 +128,8 @@ negative dm (evaporation) = heat absorbed (Q < 0). - Tests: isothermal parity (L=0 matches `get_mass_transfer_rate` to < 1e-15 relative), known cloud droplet growth rate for water at S=1.01 (1% supersaturation), multi-species array shapes (1D and 2D) + - Exports: re-export `get_mass_transfer_rate_latent_heat` from + `particula.dynamics` - [ ] **E5-F2-P3**: Add latent heat energy release tracking function with tests - Issue: TBD | Size: XS (~30 LOC) | Status: Not Started @@ -194,3 +196,4 @@ negative dm (evaporation) = heat absorbed (Q < 0). | Date | Change | Author | |------|--------|--------| | 2026-03-02 | Initial feature document created from E5 epic | ADW | +| 2026-03-03 | Start E5-F2-P2 for latent heat mass transfer rate | ADW | diff --git a/adw-docs/dev-plans/features/index.md b/adw-docs/dev-plans/features/index.md index 039db6309..5fb6cab4b 100644 --- a/adw-docs/dev-plans/features/index.md +++ b/adw-docs/dev-plans/features/index.md @@ -27,7 +27,7 @@ work, typically ~100 LOC per phase, that deliver user-facing functionality. | ID | Name | Status | Priority | Phases | |----|------|--------|----------|--------| | E5-F1 | [Latent Heat Strategy Pattern](E5-F1-latent-heat-strategy.md) | In Progress | P1 | 4 | -| E5-F2 | [Non-Isothermal Mass Transfer Functions](E5-F2-non-isothermal-mass-transfer.md) | Planning | P1 | 3 | +| E5-F2 | [Non-Isothermal Mass Transfer Functions](E5-F2-non-isothermal-mass-transfer.md) | In Progress | P1 | 3 | | E5-F3 | [CondensationLatentHeat Strategy Class](E5-F3-condensation-latent-heat-strategy.md) | Planning | P1 | 5 | | E5-F4 | [Builder, Factory, and Exports](E5-F4-builder-factory-exports.md) | Planning | P1 | 2 | | E5-F5 | [Validation and Integration Tests](E5-F5-validation-integration-tests.md) | Planning | P1 | 2 | diff --git a/docs/index.md b/docs/index.md index f8d94c867..6870ff09c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -28,6 +28,8 @@ Whether you’re a researcher, educator, or industry expert, Particula is design - **Providing a Python-based API** for reproducible and modular simulations. - **Building gas-phase properties** with builder/factory patterns (vapor pressure and latent heat) that support unit-aware setters and exports. +- **Supporting non-isothermal condensation** with thermal resistance and + latent-heat mass transfer rate utilities. - **Interrogating your experimental data** to validate and expand your impact. - **Fostering open-source collaboration** to share ideas and build on each other’s work. diff --git a/particula/dynamics/condensation/mass_transfer.py b/particula/dynamics/condensation/mass_transfer.py index 34aafaef8..8ae8d069e 100644 --- a/particula/dynamics/condensation/mass_transfer.py +++ b/particula/dynamics/condensation/mass_transfer.py @@ -273,23 +273,22 @@ def get_mass_transfer_rate_latent_heat( vapor_pressure_surface: Union[float, NDArray[np.float64]], diffusion_coefficient: Union[float, NDArray[np.float64]], ) -> Union[float, NDArray[np.float64]]: - """Calculate the non-isothermal mass transfer rate. + """Calculate non-isothermal mass transfer rate with latent heat. - This function applies the thermal resistance correction from - get_thermal_resistance_factor to the isothermal mass transfer rate. When - latent_heat is zero, the thermal correction reduces to unity and the + Applies the thermal resistance factor to the isothermal mass transfer + rate. When latent heat is zero, the correction equals unity and the result matches get_mass_transfer_rate. - Arguments: - - pressure_delta : Difference in partial pressure [Pa]. - - first_order_mass_transport : Mass transport coefficient K [m³/s]. - - temperature : Temperature T [K]. - - molar_mass : Molar mass M [kg/mol]. - - latent_heat : Latent heat of vaporization L [J/kg]. - - thermal_conductivity : Gas thermal conductivity kappa [W/(m·K)]. - - vapor_pressure_surface : Equilibrium vapor pressure at the surface + Args: + pressure_delta: Difference in partial pressure [Pa]. + first_order_mass_transport: Mass transport coefficient K [m³/s]. + temperature: Temperature T [K]. + molar_mass: Molar mass M [kg/mol]. + latent_heat: Latent heat of vaporization L [J/kg]. + thermal_conductivity: Gas thermal conductivity kappa [W/(m·K)]. + vapor_pressure_surface: Equilibrium vapor pressure at the surface [Pa]. - - diffusion_coefficient : Vapor diffusion coefficient D [m²/s]. + diffusion_coefficient: Vapor diffusion coefficient D [m²/s]. Returns: Non-isothermal mass transfer rate [kg/s], matching the broadcasted diff --git a/readme.md b/readme.md index 57d1f4012..66844bfdb 100644 --- a/readme.md +++ b/readme.md @@ -93,8 +93,9 @@ particula/ - **Flexible Representations** — Discrete bins, continuous PDF, particle-resolved - **Builder Pattern** — Clean, validated object construction with unit conversion - **Composable Processes** — Chain runnables with `|` operator -- **Condensation Utilities** — Non-isothermal thermal resistance helper via - `particula.dynamics.get_thermal_resistance_factor` +- **Condensation Utilities** — Non-isothermal helpers via + `particula.dynamics.get_thermal_resistance_factor` and + `particula.dynamics.get_mass_transfer_rate_latent_heat` - **Latent Heat Factories** — Build constant, linear, and power-law latent heat strategies via `particula.gas.LatentHeatFactory` with unit-aware builders and gas-phase exports for upcoming non-isothermal workflows From 847896df63105a1c19d7e51eecfb5b24158d85b3 Mon Sep 17 00:00:00 2001 From: Kyle Gorkowski Date: Tue, 3 Mar 2026 17:27:14 -0700 Subject: [PATCH 3/3] fix(condensation): guard thermal resistance factor Add a non-positive thermal factor check to prevent invalid non-isothermal corrections and update mass transfer helpers for consistent docstrings and array handling. Add validation coverage for diffusion, conductivity, and surface pressure inputs, and refresh dev-plan status notes. Closes #1144 ADW-ID: 86e769a4 --- adw-docs/dev-plans/README.md | 2 +- .../epics/E5-non-isothermal-condensation.md | 3 +- .../E5-F2-non-isothermal-mass-transfer.md | 11 ++-- adw-docs/dev-plans/features/index.md | 2 +- .../dynamics/condensation/mass_transfer.py | 55 ++++++++++--------- .../condensation/tests/mass_transfer_test.py | 41 ++++++++++++++ 6 files changed, 78 insertions(+), 36 deletions(-) diff --git a/adw-docs/dev-plans/README.md b/adw-docs/dev-plans/README.md index 45784ecdb..b943c4b8a 100644 --- a/adw-docs/dev-plans/README.md +++ b/adw-docs/dev-plans/README.md @@ -73,7 +73,7 @@ and rollout. - [E5-F1: Latent Heat Strategy Pattern][e5-f1] — Status: Completed (P2, #1123) - Scope: `LatentHeatStrategy` ABC with constant, linear, and power-law implementations plus builder/factory integration. -- [E5-F2: Non-Isothermal Mass Transfer Functions][e5-f2] — Status: In Progress +- [E5-F2: Non-Isothermal Mass Transfer Functions][e5-f2] — Status: Planning - Scope: Thermal resistance factor and non-isothermal mass transfer rate pure functions with energy tracking. - [E5-F3: CondensationLatentHeat Strategy Class][e5-f3] — Status: Planning diff --git a/adw-docs/dev-plans/epics/E5-non-isothermal-condensation.md b/adw-docs/dev-plans/epics/E5-non-isothermal-condensation.md index 5e3861c78..b18887c6c 100644 --- a/adw-docs/dev-plans/epics/E5-non-isothermal-condensation.md +++ b/adw-docs/dev-plans/epics/E5-non-isothermal-condensation.md @@ -5,7 +5,7 @@ **Owners**: @Gorkowski **Start Date**: 2026-03-02 **Target Date**: TBD -**Last Updated**: 2026-03-03 +**Last Updated**: 2026-03-02 **Size**: Medium (7 features, ~22 phases) ## Vision @@ -207,7 +207,6 @@ L -> 0 as T -> T_c. Used in engineering thermodynamics and EOS-based models. - Note: needs `vapor_pressure_surface` to pass through to `get_thermal_resistance_factor()`. This is the equilibrium vapor pressure at the droplet surface (activity × pure_vapor_pressure × kelvin_term). - - Issue: #1135 | Size: S (~50 LOC) | Status: In Progress - When `latent_heat = 0`, the thermal_factor reduces to `R_specific * T` and the full expression reduces exactly to `get_mass_transfer_rate()` (isothermal limit). This MUST be tested to machine precision. diff --git a/adw-docs/dev-plans/features/E5-F2-non-isothermal-mass-transfer.md b/adw-docs/dev-plans/features/E5-F2-non-isothermal-mass-transfer.md index 9c6d6ecda..302813b6d 100644 --- a/adw-docs/dev-plans/features/E5-F2-non-isothermal-mass-transfer.md +++ b/adw-docs/dev-plans/features/E5-F2-non-isothermal-mass-transfer.md @@ -1,12 +1,12 @@ # Feature E5-F2: Non-Isothermal Mass Transfer Functions **Parent Epic**: [E5: Non-Isothermal Condensation with Latent Heat](../epics/E5-non-isothermal-condensation.md) -**Status**: In Progress (P2) +**Status**: Planning **Priority**: P1 **Owners**: @Gorkowski -**Start Date**: 2026-03-03 +**Start Date**: TBD **Target Date**: TBD -**Last Updated**: 2026-03-03 +**Last Updated**: 2026-03-02 **Size**: Small (3 phases) ## Summary @@ -113,7 +113,7 @@ negative dm (evaporation) = heat absorbed (Q < 0). `R_specific * T`), dimensional consistency, array broadcasting - [ ] **E5-F2-P2**: Add non-isothermal mass transfer rate function with tests - - Issue: #1135 | Size: S (~50 LOC) | Status: In Progress + - Issue: TBD | Size: S (~50 LOC) | Status: Not Started - File: `particula/dynamics/condensation/mass_transfer.py` (extend) - Function: `get_mass_transfer_rate_latent_heat(pressure_delta, first_order_mass_transport, temperature, molar_mass, latent_heat, @@ -128,8 +128,6 @@ negative dm (evaporation) = heat absorbed (Q < 0). - Tests: isothermal parity (L=0 matches `get_mass_transfer_rate` to < 1e-15 relative), known cloud droplet growth rate for water at S=1.01 (1% supersaturation), multi-species array shapes (1D and 2D) - - Exports: re-export `get_mass_transfer_rate_latent_heat` from - `particula.dynamics` - [ ] **E5-F2-P3**: Add latent heat energy release tracking function with tests - Issue: TBD | Size: XS (~30 LOC) | Status: Not Started @@ -196,4 +194,3 @@ negative dm (evaporation) = heat absorbed (Q < 0). | Date | Change | Author | |------|--------|--------| | 2026-03-02 | Initial feature document created from E5 epic | ADW | -| 2026-03-03 | Start E5-F2-P2 for latent heat mass transfer rate | ADW | diff --git a/adw-docs/dev-plans/features/index.md b/adw-docs/dev-plans/features/index.md index 5fb6cab4b..039db6309 100644 --- a/adw-docs/dev-plans/features/index.md +++ b/adw-docs/dev-plans/features/index.md @@ -27,7 +27,7 @@ work, typically ~100 LOC per phase, that deliver user-facing functionality. | ID | Name | Status | Priority | Phases | |----|------|--------|----------|--------| | E5-F1 | [Latent Heat Strategy Pattern](E5-F1-latent-heat-strategy.md) | In Progress | P1 | 4 | -| E5-F2 | [Non-Isothermal Mass Transfer Functions](E5-F2-non-isothermal-mass-transfer.md) | In Progress | P1 | 3 | +| E5-F2 | [Non-Isothermal Mass Transfer Functions](E5-F2-non-isothermal-mass-transfer.md) | Planning | P1 | 3 | | E5-F3 | [CondensationLatentHeat Strategy Class](E5-F3-condensation-latent-heat-strategy.md) | Planning | P1 | 5 | | E5-F4 | [Builder, Factory, and Exports](E5-F4-builder-factory-exports.md) | Planning | P1 | 2 | | E5-F5 | [Validation and Integration Tests](E5-F5-validation-integration-tests.md) | Planning | P1 | 2 | diff --git a/particula/dynamics/condensation/mass_transfer.py b/particula/dynamics/condensation/mass_transfer.py index 8ae8d069e..5174aae06 100644 --- a/particula/dynamics/condensation/mass_transfer.py +++ b/particula/dynamics/condensation/mass_transfer.py @@ -207,22 +207,23 @@ def get_thermal_resistance_factor( molar mass M [kg/mol]. When latent_heat is zero, the factor reduces to r_specific * temperature. - Arguments: - - diffusion_coefficient : The vapor diffusion coefficient D [m²/s]. - - latent_heat : Latent heat of vaporization L [J/kg]. - - vapor_pressure_surface : Equilibrium vapor pressure at the - surface [Pa]. - - thermal_conductivity : Gas thermal conductivity kappa [W/(m·K)]. - - temperature : Temperature T [K]. - - molar_mass : Molar mass M [kg/mol]. + Args: + diffusion_coefficient: Vapor diffusion coefficient D [m²/s]. + latent_heat: Latent heat of vaporization L [J/kg]. + vapor_pressure_surface: Equilibrium vapor pressure at the surface + [Pa]. + thermal_conductivity: Gas thermal conductivity kappa [W/(m·K)]. + temperature: Temperature T [K]. + molar_mass: Molar mass M [kg/mol]. Returns: - Thermal resistance factor for non-isothermal mass transfer - [J/kg], matching the broadcasted input shape. + Thermal resistance factor for non-isothermal mass transfer [J/kg], + matching the broadcasted input shape. Raises: ValueError: If any validated inputs violate positive/nonnegative constraints. + ValueError: If the thermal resistance factor is non-positive. References: - Topping, D., & Bane, M. (2022). Introduction to Aerosol @@ -248,6 +249,10 @@ def get_thermal_resistance_factor( thermal_factor = diffusion_term * correction_term + ( r_specific * temperature ) + if np.any(thermal_factor <= 0): + raise ValueError( + "Thermal resistance factor must be positive; check inputs." + ) return np.asarray(thermal_factor, dtype=np.float64) @@ -279,24 +284,24 @@ def get_mass_transfer_rate_latent_heat( rate. When latent heat is zero, the correction equals unity and the result matches get_mass_transfer_rate. - Args: - pressure_delta: Difference in partial pressure [Pa]. - first_order_mass_transport: Mass transport coefficient K [m³/s]. - temperature: Temperature T [K]. - molar_mass: Molar mass M [kg/mol]. - latent_heat: Latent heat of vaporization L [J/kg]. - thermal_conductivity: Gas thermal conductivity kappa [W/(m·K)]. - vapor_pressure_surface: Equilibrium vapor pressure at the surface - [Pa]. - diffusion_coefficient: Vapor diffusion coefficient D [m²/s]. + Arguments: + - pressure_delta : Difference in partial pressure [Pa]. + - first_order_mass_transport : Mass transport coefficient K [m³/s]. + - temperature : Temperature T [K]. + - molar_mass : Molar mass M [kg/mol]. + - latent_heat : Latent heat of vaporization L [J/kg]. + - thermal_conductivity : Gas thermal conductivity kappa [W/(m·K)]. + - vapor_pressure_surface : Equilibrium vapor pressure at the + surface [Pa]. + - diffusion_coefficient : Vapor diffusion coefficient D [m²/s]. Returns: - Non-isothermal mass transfer rate [kg/s], matching the broadcasted - input shape. + - Non-isothermal mass transfer rate [kg/s], matching the broadcasted + input shape. Raises: - ValueError: If any validated inputs violate positive/nonnegative - constraints. + - ValueError : If any validated inputs violate positive/nonnegative + constraints. """ pressure_delta = np.asarray(pressure_delta) first_order_mass_transport = np.asarray(first_order_mass_transport) @@ -323,7 +328,7 @@ def get_mass_transfer_rate_latent_heat( temperature=temperature, molar_mass=molar_mass, ) - return np.array(isothermal_rate / correction, dtype=np.float64) + return np.asarray(isothermal_rate / correction, dtype=np.float64) @validate_inputs( diff --git a/particula/dynamics/condensation/tests/mass_transfer_test.py b/particula/dynamics/condensation/tests/mass_transfer_test.py index c64b94c97..1b3d224d9 100644 --- a/particula/dynamics/condensation/tests/mass_transfer_test.py +++ b/particula/dynamics/condensation/tests/mass_transfer_test.py @@ -227,6 +227,19 @@ def test_thermal_resistance_factor_rejects_invalid_inputs(field, value): get_thermal_resistance_factor(**kwargs) +def test_thermal_resistance_factor_rejects_nonpositive_result(): + """Non-positive thermal factors should raise a ValueError.""" + with pytest.raises(ValueError, match="Thermal resistance factor"): + get_thermal_resistance_factor( + diffusion_coefficient=1.0e6, + latent_heat=1.0, + vapor_pressure_surface=1.0e6, + thermal_conductivity=1.0, + temperature=1.0, + molar_mass=0.08314, + ) + + def test_mass_transfer_rate_latent_heat_isothermal_parity(): """Latent heat of zero should match the isothermal rate (scalar).""" pressure_delta = 23.39 @@ -412,6 +425,34 @@ def test_mass_transfer_rate_latent_heat_validation_negative_molar_mass(): ) +@pytest.mark.parametrize( + ("field", "value"), + [ + ("diffusion_coefficient", -1.0), + ("thermal_conductivity", 0.0), + ("thermal_conductivity", -1.0), + ("vapor_pressure_surface", -1.0), + ], +) +def test_mass_transfer_rate_latent_heat_validation_additional_inputs( + field, value +): + """Additional validated inputs should reject invalid values.""" + kwargs = { + "pressure_delta": 10.0, + "first_order_mass_transport": 1e-17, + "temperature": 293.0, + "molar_mass": 0.018015, + "latent_heat": 2.454e6, + "thermal_conductivity": 0.0257, + "vapor_pressure_surface": 2339.0, + "diffusion_coefficient": 2.5e-5, + } + kwargs[field] = value + with pytest.raises(ValueError, match=field): + get_mass_transfer_rate_latent_heat(**kwargs) + + def test_multi_species_mass_transfer_rate(): """Test the mass_transfer_rate function for multiple species.""" pressure_delta = np.array([10.0, 15.0])