Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions particula/dynamics/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
100 changes: 90 additions & 10 deletions particula/dynamics/condensation/mass_transfer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand Down
227 changes: 227 additions & 0 deletions particula/dynamics/condensation/tests/mass_transfer_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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])
Expand Down
5 changes: 3 additions & 2 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down