diff --git a/docs/index.md b/docs/index.md index 6870ff09c..a2c82def9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -28,8 +28,9 @@ 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. +- **Supporting non-isothermal condensation** with thermal resistance, + latent-heat mass transfer rate utilities, and latent-heat energy release + bookkeeping. - **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 7bf11d960..a0f1b8e08 100644 --- a/particula/dynamics/__init__.py +++ b/particula/dynamics/__init__.py @@ -65,6 +65,7 @@ from particula.dynamics.condensation.mass_transfer import ( get_mass_transfer_rate, get_mass_transfer_rate_latent_heat, + get_latent_heat_energy_released, 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 5174aae06..320d67e33 100644 --- a/particula/dynamics/condensation/mass_transfer.py +++ b/particula/dynamics/condensation/mass_transfer.py @@ -331,6 +331,72 @@ def get_mass_transfer_rate_latent_heat( return np.asarray(isothermal_rate / correction, dtype=np.float64) +@validate_inputs( + { + "mass_transfer": "finite", + "latent_heat": "nonnegative", + } +) +def get_latent_heat_energy_released( + mass_transfer: Union[float, NDArray[np.float64]], + latent_heat: Union[float, NDArray[np.float64]], +) -> Union[float, NDArray[np.float64]]: + """Calculate latent heat energy released during phase change. + + This diagnostic function converts the mass transferred in a time step into + energy released to (or absorbed from) the gas phase: + + - Q = dm × L + - Q : Latent heat energy [J]. + - dm : Mass transferred per step [kg]. + - L : Latent heat of vaporization [J/kg]. + + Positive mass transfer (condensation) yields Q > 0, while negative mass + transfer (evaporation) yields Q < 0. + + Args: + mass_transfer: Mass transferred per step [kg]. + latent_heat: Latent heat of vaporization [J/kg]. + + Returns: + Latent heat energy released or absorbed [J]. + + Examples: + ```py title="Condensation releases heat" + import particula as par + + par.dynamics.get_latent_heat_energy_released( + mass_transfer=1.0e-15, + latent_heat=2.454e6, + ) + # Output: 2.454e-09 + ``` + + ```py title="Evaporation absorbs heat" + import particula as par + + par.dynamics.get_latent_heat_energy_released( + mass_transfer=-1.0e-15, + latent_heat=2.454e6, + ) + # Output: -2.454e-09 + ``` + + Raises: + ValueError: If mass_transfer is non-finite or latent_heat is negative. + + Notes: + Large magnitudes may overflow following NumPy's float64 behavior. + """ + mass_transfer = np.asarray(mass_transfer, dtype=np.float64) + latent_heat = np.asarray(latent_heat, dtype=np.float64) + if not np.all(np.isfinite(latent_heat)): + raise ValueError( + "Argument 'latent_heat' must be finite (no inf or NaN)." + ) + return np.asarray(mass_transfer * latent_heat, 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 1b3d224d9..5724acf4e 100644 --- a/particula/dynamics/condensation/tests/mass_transfer_test.py +++ b/particula/dynamics/condensation/tests/mass_transfer_test.py @@ -1,9 +1,11 @@ """Test the Condensation module.""" import numpy as np +import particula as par import pytest from particula.dynamics.condensation.mass_transfer import ( get_first_order_mass_transport_k, + get_latent_heat_energy_released, get_mass_transfer, get_mass_transfer_of_multiple_species, get_mass_transfer_of_single_species, @@ -453,6 +455,128 @@ def test_mass_transfer_rate_latent_heat_validation_additional_inputs( get_mass_transfer_rate_latent_heat(**kwargs) +def test_latent_heat_energy_released_precision(): + """Check latent heat energy calculation at high precision.""" + mass_transfer = 1.5e-15 + latent_heat = 2.454e6 + expected = 3.681e-9 + result = get_latent_heat_energy_released( + mass_transfer=mass_transfer, + latent_heat=latent_heat, + ) + np.testing.assert_allclose(result, expected, rtol=1e-14) + + +def test_latent_heat_energy_released_sign_convention(): + """Positive mass transfer releases heat; negative absorbs heat.""" + latent_heat = 2.454e6 + positive_result = get_latent_heat_energy_released( + mass_transfer=1.0e-15, + latent_heat=latent_heat, + ) + negative_result = get_latent_heat_energy_released( + mass_transfer=-1.0e-15, + latent_heat=latent_heat, + ) + assert positive_result > 0.0 + assert negative_result < 0.0 + + +def test_latent_heat_energy_released_zero_latent_heat(): + """Zero latent heat returns exact zero.""" + result = get_latent_heat_energy_released( + mass_transfer=1.0e-15, + latent_heat=0.0, + ) + assert result == 0.0 + + +def test_latent_heat_energy_released_zero_mass_transfer(): + """Zero mass transfer returns exact zero.""" + result = get_latent_heat_energy_released( + mass_transfer=0.0, + latent_heat=2.454e6, + ) + assert result == 0.0 + + +def test_latent_heat_energy_released_array_shapes(): + """Broadcast scalar and array inputs and preserve shapes.""" + mass_transfer_1d = np.array([1.0e-15, -2.0e-15]) + latent_heat_scalar = 2.454e6 + result_1d = get_latent_heat_energy_released( + mass_transfer=mass_transfer_1d, + latent_heat=latent_heat_scalar, + ) + expected_1d = mass_transfer_1d * latent_heat_scalar + assert np.asarray(result_1d).shape == mass_transfer_1d.shape + np.testing.assert_allclose(result_1d, expected_1d, rtol=1e-14) + + mass_transfer_2d = np.array([[1.0e-15, -1.0e-15], [2.0e-15, 3.0e-15]]) + latent_heat_1d = np.array([2.454e6, 1.8e6]) + result_2d = get_latent_heat_energy_released( + mass_transfer=mass_transfer_2d, + latent_heat=latent_heat_1d, + ) + expected_2d = mass_transfer_2d * latent_heat_1d + assert np.asarray(result_2d).shape == mass_transfer_2d.shape + np.testing.assert_allclose(result_2d, expected_2d, rtol=1e-14) + + +def test_latent_heat_energy_released_list_inputs(): + """List inputs should coerce to arrays before multiplication.""" + mass_transfer = [1.0e-15, -2.0e-15] + latent_heat = [2.454e6, 1.8e6] + result = get_latent_heat_energy_released( + mass_transfer=mass_transfer, + latent_heat=latent_heat, + ) + expected = np.asarray(mass_transfer) * np.asarray(latent_heat) + np.testing.assert_allclose(result, expected, rtol=1e-14) + + +def test_latent_heat_energy_released_validation_negative_latent_heat(): + """Negative latent heat should raise validation error.""" + with pytest.raises(ValueError, match="latent_heat"): + get_latent_heat_energy_released( + mass_transfer=1.0e-15, + latent_heat=-1.0, + ) + + +@pytest.mark.parametrize("latent_heat", [np.nan, np.inf]) +def test_latent_heat_energy_released_validation_nonfinite_latent_heat( + latent_heat, +): + """Non-finite latent heat should raise validation error.""" + with pytest.raises(ValueError, match="latent_heat"): + get_latent_heat_energy_released( + mass_transfer=1.0e-15, + latent_heat=latent_heat, + ) + + +@pytest.mark.parametrize("mass_transfer", [np.nan, np.inf]) +def test_latent_heat_energy_released_validation_nonfinite_mass_transfer( + mass_transfer, +): + """Non-finite mass transfer should raise validation error.""" + with pytest.raises(ValueError, match="mass_transfer"): + get_latent_heat_energy_released( + mass_transfer=mass_transfer, + latent_heat=2.454e6, + ) + + +def test_latent_heat_energy_released_public_reexport(): + """Public API should re-export the latent heat helper.""" + result = par.dynamics.get_latent_heat_energy_released( + mass_transfer=1.0e-15, + latent_heat=2.454e6, + ) + assert result == pytest.approx(2.454e-9) + + 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 66844bfdb..93fc5ed58 100644 --- a/readme.md +++ b/readme.md @@ -94,8 +94,9 @@ particula/ - **Builder Pattern** — Clean, validated object construction with unit conversion - **Composable Processes** — Chain runnables with `|` operator - **Condensation Utilities** — Non-isothermal helpers via - `particula.dynamics.get_thermal_resistance_factor` and - `particula.dynamics.get_mass_transfer_rate_latent_heat` + `particula.dynamics.get_thermal_resistance_factor`, + `particula.dynamics.get_mass_transfer_rate_latent_heat`, and + `particula.dynamics.get_latent_heat_energy_released` - **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