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
48 changes: 47 additions & 1 deletion docs/Features/condensation_strategy_system.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,20 @@ into runnable pipelines. You can choose simultaneous isothermal updates or
staggered two-pass Gauss-Seidel sweeps with theta-controlled first-pass
fractions, batch partitioning, and gas-field updates for stability.

CondensationLatentHeat mirrors the isothermal workflow for particle-resolved
runs while adding latent-heat-aware mass transfer and per-step energy
diagnostics. The step now supports a `dynamic_viscosity` override and records
`last_latent_heat_energy` for parity with the isothermal API plus energy
tracking.

This feature is built around user-facing APIs exposed via `particula.dynamics`:

- `CondensationStrategy` – abstract base defining `rate` / `step`.
- `CondensationIsothermal` – simultaneous isothermal mass transfer.
- `CondensationIsothermalStaggered` – two-pass staggered (Gauss-Seidel) update
with theta modes (`"half"`, `"random"`, `"batch"`) and batching.
- `CondensationLatentHeat` – latent-heat-corrected rate with per-step energy
diagnostics.
- `CondensationIsothermalBuilder`, `CondensationIsothermalStaggeredBuilder` –
fluent builders with validation and unit handling.
- `CondensationFactory` – factory selecting a condensation strategy by name.
Expand All @@ -38,6 +46,8 @@ This feature is built around user-facing APIs exposed via `particula.dynamics`:
fields are depleted.
- **Pipeline-ready**: Use `MassCondensation` with `sub_steps` for tight coupling
to other runnables in a single pipeline.
- **Latent heat diagnostics**: Track per-step energy release for
particle-resolved runs with `CondensationLatentHeat`.

## Who It's For

Expand Down Expand Up @@ -65,6 +75,7 @@ par.dynamics.CondensationStrategy
# Concrete implementations
par.dynamics.CondensationIsothermal
par.dynamics.CondensationIsothermalStaggered
par.dynamics.CondensationLatentHeat

# Builders and factory
par.dynamics.CondensationIsothermalBuilder
Expand Down Expand Up @@ -131,6 +142,39 @@ particle, gas = iso.step(
)
```

### CondensationLatentHeat (energy diagnostics)

`CondensationLatentHeat` mirrors the isothermal step but applies a latent-heat
correction when a latent heat strategy (or scalar fallback) is provided. It
records `last_latent_heat_energy` each step (positive for condensation,
negative for evaporation) and accepts a `dynamic_viscosity` override for
particle-resolved workflows.

```python
latent = par.dynamics.CondensationLatentHeat(
molar_mass=0.018,
diffusion_coefficient=2e-5,
accommodation_coefficient=1.0,
latent_heat=2.4e6, # J/kg fallback
)
particle, gas = latent.step(
particle=particle,
gas_species=gas,
temperature=298.15,
pressure=101325.0,
time_step=1.0,
dynamic_viscosity=1.8e-5,
)
energy_released = latent.last_latent_heat_energy # total energy [J]
```
Comment on lines +145 to +169
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs/example use last_latent_heat_energy without stating units. Given the condensation workflow uses mass concentrations (kg/m^3), this diagnostic is currently an energy density (J/m^3) unless multiplied by the parcel/box volume. Please clarify the units in this section so users don’t interpret it as total energy [J].

Copilot uses AI. Check for mistakes.

When no latent heat strategy is configured (or a nonpositive scalar is
provided), the step follows the isothermal path and reports
`last_latent_heat_energy = 0.0`.

`last_latent_heat_energy` records the total latent heat released per step
(sum of dm × L), not an energy density.

### CondensationIsothermalStaggered (two-pass Gauss-Seidel)

`CondensationIsothermalStaggered` splits each timestep into two passes. Theta
Expand Down Expand Up @@ -476,6 +520,8 @@ aerosol, gas = selective.step(
| `accommodation_coefficient` | Mass accommodation coefficient (unitless). | `1.0` |
| `update_gases` | Whether to deplete gas concentrations during step. | `True` |
| `skip_partitioning_indices` | Species indices to exclude from partitioning. | `None` |
| `latent_heat_strategy` | Optional latent heat strategy. | `None` |
| `latent_heat` | Scalar fallback latent heat [J/kg]. | `0.0` |
| `theta_mode` | Staggered theta selection: `"half"`, `"random"`, `"batch"`. | `"half"` (staggered) |
| `num_batches` | Gauss-Seidel batch count (clipped to particle count). | `1` |
| `shuffle_each_step` | Shuffle particle order each step (staggered). | `True` |
Expand All @@ -500,7 +546,7 @@ aerosol, gas = selective.step(

- Staggered solver is Gauss-Seidel only; other solvers are not exposed.
- Factory supports `"isothermal"` and `"isothermal_staggered"` only.
- No latent-heat or temperature feedback; condensation is isothermal.
- No temperature feedback; latent heat is diagnostic only.
- Minimum-radius clamp (1e-10 m) enforces continuum validity; sub-continuum
physics is out of scope.

Expand Down
4 changes: 2 additions & 2 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ Whether you’re a researcher, educator, or industry expert, Particula is design
- **Supporting non-isothermal condensation** with thermal resistance,
latent-heat mass transfer rate utilities, latent-heat energy release
bookkeeping, and the `CondensationLatentHeat` strategy with
latent-heat-corrected `mass_transfer_rate()`/`rate()` (step integration
pending).
latent-heat-corrected `mass_transfer_rate()`/`rate()` and an implemented
`step()` that tracks per-step latent heat energy.
- **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
227 changes: 213 additions & 14 deletions particula/dynamics/condensation/condensation_strategies.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@

from particula.dynamics.condensation.mass_transfer import (
get_first_order_mass_transport_k,
get_latent_heat_energy_released,
get_mass_transfer,
get_mass_transfer_rate,
get_mass_transfer_rate_latent_heat,
Expand Down Expand Up @@ -1761,7 +1762,9 @@ class CondensationLatentHeat(CondensationStrategy):
Attributes:
latent_heat_strategy_input: Strategy input provided at initialization.
latent_heat_input: Raw latent heat value provided at initialization.
last_latent_heat_energy: Diagnostic latent heat energy tracker.
last_latent_heat_energy: Total latent heat energy released in the most
recent step [J]. Positive values indicate condensation and
negative values indicate evaporation. Overwritten each call.
"""

# pylint: disable=R0913, R0917
Expand Down Expand Up @@ -1952,11 +1955,9 @@ def mass_transfer_rate(
pressure_delta = self.calculate_pressure_delta(
particle, gas_species, temperature, radius_with_fill
)
if not np.all(np.isfinite(pressure_delta)):
raise ValueError(
"Non-finite pressure_delta computed for latent-heat "
"condensation."
)
pressure_delta = np.nan_to_num(
pressure_delta, posinf=0.0, neginf=0.0, nan=0.0
)

if self._latent_heat_strategy is None:
return get_mass_transfer_rate(
Expand Down Expand Up @@ -2036,6 +2037,144 @@ def rate(
rates = self._apply_skip_partitioning(rates)
return rates

def _update_latent_heat_energy(
self, mass_transfer: NDArray[np.float64], temperature: float
) -> None:
"""Store latent heat energy released for the current step.

Args:
mass_transfer: Per-particle mass transfer in kg for this step.
temperature: System temperature in Kelvin.
"""
if self._latent_heat_strategy is None:
self.last_latent_heat_energy = 0.0
return

latent_heat = self._latent_heat_strategy.latent_heat(temperature)
self.last_latent_heat_energy = float(
np.sum(
get_latent_heat_energy_released(
mass_transfer,
Comment on lines +2043 to +2057
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstrings describe mass_transfer as kg and last_latent_heat_energy as [J], but in step() the mass transfer returned by get_mass_transfer() is scaled by particle concentration (#/m^3), matching the existing isothermal step’s kg/m^3 semantics. That makes last_latent_heat_energy an energy density (J/m^3) unless it’s multiplied by the box volume. Please either update the docs/attribute text to reflect J/m^3, or multiply by particle_data.volume[0] if the intended diagnostic is total energy [J].

Copilot uses AI. Check for mistakes.
latent_heat,
)
)
)

def _calculate_norm_conc(
self, volume: float, concentration: NDArray[np.float64]
) -> NDArray[np.float64]:
"""Validate inputs and compute normalized concentration.

Args:
volume: Particle volume for the single box [m^3].
concentration: Particle concentration array.

Returns:
Normalized concentration (concentration / volume).

Raises:
ValueError: If volume or concentration inputs are invalid.
"""
if not np.isfinite(volume) or volume <= 0.0:
raise ValueError("volume must be finite and positive.")
if not np.all(np.isfinite(concentration)) or np.any(
concentration < 0.0
):
raise ValueError(
"concentration must be finite and nonnegative for norm_conc."
)
return concentration / volume

def _normalize_mass_transfer_shape(
self,
mass_transfer: NDArray[np.float64],
species_mass: NDArray[np.float64],
) -> NDArray[np.float64]:
"""Normalize mass transfer shape to match species count.

Args:
mass_transfer: Per-particle mass transfer array.
species_mass: Particle mass array used to infer species count.

Returns:
Mass transfer array with consistent species dimension.
"""
species_count = 1 if species_mass.ndim == 1 else species_mass.shape[1]
if mass_transfer.ndim == 1:
return mass_transfer.reshape(-1, species_count)
if mass_transfer.shape[1] > species_count:
return mass_transfer[:, :species_count]
if mass_transfer.shape[1] < species_count:
return np.broadcast_to(
mass_transfer, (mass_transfer.shape[0], species_count)
)
return mass_transfer

def _apply_mass_transfer_to_particles(
self,
particle_data: ParticleData,
mass_transfer: NDArray[np.float64],
norm_conc: NDArray[np.float64],
) -> None:
"""Apply mass transfer to particle data in place."""
if mass_transfer.ndim == 2:
nonzero_mask = norm_conc != 0
denom = norm_conc[:, np.newaxis]
where_mask = nonzero_mask[:, np.newaxis]
else:
denom = norm_conc
where_mask = norm_conc != 0
per_particle = np.divide(
mass_transfer,
denom,
out=np.zeros_like(mass_transfer),
where=where_mask,
)
np.add(
particle_data.masses[0],
per_particle,
out=particle_data.masses[0],
)
np.maximum(particle_data.masses[0], 0.0, out=particle_data.masses[0])

def _apply_mass_transfer_to_gas(
self, gas_data: GasData, mass_transfer: NDArray[np.float64]
) -> None:
"""Apply mass transfer to gas concentrations in place."""
np.subtract(
gas_data.concentration[0],
mass_transfer.sum(axis=0),
out=gas_data.concentration[0],
)
np.maximum(
gas_data.concentration[0],
0.0,
out=gas_data.concentration[0],
)

# pylint: disable=too-many-positional-arguments, too-many-arguments
@overload # type: ignore[override]
def step(
self,
particle: ParticleRepresentation,
gas_species: GasSpecies,
temperature: float,
pressure: float,
time_step: float,
dynamic_viscosity: Optional[float] = None,
) -> Tuple[ParticleRepresentation, GasSpecies]: ...

@overload
def step(
self,
particle: ParticleData,
gas_species: GasData,
temperature: float,
pressure: float,
time_step: float,
dynamic_viscosity: Optional[float] = None,
) -> Tuple[ParticleData, GasData]: ...

def step(
self,
particle: ParticleRepresentation | ParticleData,
Expand All @@ -2047,23 +2186,83 @@ def step(
) -> (
Tuple[ParticleRepresentation, GasSpecies] | Tuple[ParticleData, GasData]
):
"""Advance one condensation step (stub).
"""Advance one condensation step with latent-heat diagnostics.

The mass transfer rate is computed, optional skip-partitioning applied,
and both the particle and gas states are updated while respecting
inventory limits. The per-step latent heat release is stored in
``last_latent_heat_energy`` [J] (positive for condensation, negative
for evaporation) and overwritten on every call.

Args:
particle: Particle representation to update.
gas_species: Gas species object providing vapor properties.
temperature: System temperature in Kelvin.
pressure: System pressure in Pascals.
time_step: Integration timestep in seconds.
dynamic_viscosity: Optional dynamic viscosity override.
dynamic_viscosity: Optional dynamic viscosity override used in the
mass-transfer calculation.

Returns:
Tuple containing updated particle and gas species objects.

Raises:
NotImplementedError: Step is not implemented for latent-heat
condensation.
"""
raise NotImplementedError(
"CondensationLatentHeat.step is not implemented."
particle_data, particle_is_legacy = _unwrap_particle(particle)
gas_data, gas_is_legacy = _unwrap_gas(gas_species)
_require_matching_types(particle_is_legacy, gas_is_legacy)
_require_single_box(particle_data.n_boxes, "ParticleData")
_require_single_box(gas_data.n_boxes, "GasData")

mass_rate = self.mass_transfer_rate(
particle=particle,
gas_species=gas_species,
temperature=temperature,
pressure=pressure,
dynamic_viscosity=dynamic_viscosity,
)

mass_rate_array = np.atleast_1d(np.asarray(mass_rate, dtype=np.float64))

mass_rate_array = self._apply_skip_partitioning(mass_rate_array)

gas_mass_array: NDArray[np.float64] = np.atleast_1d(
np.asarray(gas_data.concentration[0], dtype=np.float64)
)
volume = particle_data.volume[0]
concentration = particle_data.concentration[0]
norm_conc = self._calculate_norm_conc(volume, concentration)
mass_transfer = get_mass_transfer(
mass_rate=mass_rate_array,
time_step=time_step,
gas_mass=gas_mass_array,
particle_mass=particle_data.masses[0],
particle_concentration=norm_conc,
)

mass_transfer = self._normalize_mass_transfer_shape(
mass_transfer,
particle_data.masses[0],
)

self._update_latent_heat_energy(mass_transfer, temperature)

if particle_is_legacy:
particle.add_mass(added_mass=mass_transfer) # type: ignore[union-attr]
else:
self._apply_mass_transfer_to_particles(
particle_data,
mass_transfer,
norm_conc,
)
if self.update_gases:
if gas_is_legacy:
gas_species.add_concentration( # type: ignore[union-attr]
added_concentration=-mass_transfer.sum(axis=0)
)
else:
self._apply_mass_transfer_to_gas(gas_data, mass_transfer)
if particle_is_legacy:
return cast(
Tuple[ParticleRepresentation, GasSpecies],
(particle, gas_species),
)
return cast(Tuple[ParticleData, GasData], (particle_data, gas_data))
Loading