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/__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..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,9 +249,88 @@ 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) +@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 non-isothermal mass transfer rate with latent heat. + + 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 [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.asarray(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..1b3d224d9 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,232 @@ 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 + 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, + ) + + +@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]) 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