Skip to content

Commit 3c8b036

Browse files
committed
fix(validate): address validation gaps for #1148
Successfully fixed: - Refactored CondensationLatentHeat.step into helpers to reduce complexity and keep behavior intact. - Sanitized non-finite pressure_delta values to mirror isothermal behavior. - Validated volume/concentration inputs for norm_conc and raise clear errors. - Normalized mass_transfer shape before latent heat energy accounting. - Clamped gas concentration updates to nonnegative values. - Documented latent heat energy units and expanded latent heat step tests. Remaining issues: none. Closes #1148 ADW-ID: 31535132
1 parent e02951b commit 3c8b036

File tree

7 files changed

+410
-59
lines changed

7 files changed

+410
-59
lines changed

adw-docs/dev-plans/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ and rollout.
7777
- Scope: Thermal resistance factor and non-isothermal mass transfer rate
7878
pure functions with energy tracking.
7979
- [E5-F3: CondensationLatentHeat Strategy Class][e5-f3] — Status: In Progress
80-
(P3, #1141)
80+
(P1, #1139)
8181
- Scope: New condensation strategy with latent heat correction and energy
8282
diagnostics.
8383
- [E5-F4: Builder, Factory, and Exports][e5-f4] — Status: Planning

adw-docs/dev-plans/epics/E5-non-isothermal-condensation.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ L -> 0 as T -> T_c. Used in engineering thermodynamics and EOS-based models.
284284
relative), reduced rate when L > 0 (thermal resistance always slows
285285
condensation), array shapes for single and multi-species
286286

287-
- [x] **E5-F3-P3**: Implement `step()` for particle-resolved with energy
287+
- [ ] **E5-F3-P3**: Implement `step()` for particle-resolved with energy
288288
tracking and tests
289289
- `step()` follows `CondensationIsothermal.step()` flow (lines 922-1040)
290290
with two additions:
@@ -623,4 +623,3 @@ class CondensationLatentHeat(CondensationStrategy):
623623
| 2026-03-02 | Initial epic creation | ADW |
624624
| 2026-03-02 | Split E5-F1-P3 into P3 (builders) + P4 (factory+exports); split E5-F3-P3 into P3 (particle-resolved step) + P4 (discrete+continuous) + P5 (data-only parity); added missing details: function signatures, file references, thermal conductivity source, vapor_pressure_surface parameter, test tolerances, literature targets | ADW |
625625
| 2026-03-04 | Noted E5-F3-P1 issue #1139 and logging expectations | ADW |
626-
| 2026-03-04 | Marked E5-F3-P3 complete for issue #1141 | ADW |

adw-docs/dev-plans/features/E5-F3-condensation-latent-heat-strategy.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -146,9 +146,9 @@ additions:
146146
- Tests: numerical parity with `CondensationIsothermal` when L=0 (< 1e-15
147147
relative), reduced rate when L > 0, array shapes for single/multi-species
148148

149-
- [x] **E5-F3-P3**: Implement `step()` for particle-resolved with energy
149+
- [ ] **E5-F3-P3**: Implement `step()` for particle-resolved with energy
150150
tracking and tests
151-
- Issue: #1141 | Size: M (~100 LOC) | Status: Complete
151+
- Issue: TBD | Size: M (~100 LOC) | Status: Not Started
152152
- `step()` follows `CondensationIsothermal.step()` flow (lines 922-1040)
153153
with two additions:
154154
1. Uses `mass_transfer_rate` from P2 (includes thermal correction)
@@ -241,4 +241,3 @@ additions:
241241
|------|--------|--------|
242242
| 2026-03-02 | Initial feature document created from E5 epic | ADW |
243243
| 2026-03-04 | Marked P1 in progress for issue #1139 | ADW |
244-
| 2026-03-04 | Completed P3 step() implementation and tests (issue #1141) | ADW |

adw-docs/dev-plans/features/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ work, typically ~100 LOC per phase, that deliver user-facing functionality.
2828
|----|------|--------|----------|--------|
2929
| E5-F1 | [Latent Heat Strategy Pattern](E5-F1-latent-heat-strategy.md) | In Progress | P1 | 4 |
3030
| E5-F2 | [Non-Isothermal Mass Transfer Functions](E5-F2-non-isothermal-mass-transfer.md) | Planning | P1 | 3 |
31-
| E5-F3 | [CondensationLatentHeat Strategy Class](E5-F3-condensation-latent-heat-strategy.md) | In Progress (P3) | P1 | 5 |
31+
| E5-F3 | [CondensationLatentHeat Strategy Class](E5-F3-condensation-latent-heat-strategy.md) | In Progress | P1 | 5 |
3232
| E5-F4 | [Builder, Factory, and Exports](E5-F4-builder-factory-exports.md) | Planning | P1 | 2 |
3333
| E5-F5 | [Validation and Integration Tests](E5-F5-validation-integration-tests.md) | Planning | P1 | 2 |
3434
| E5-F6 | [Documentation and Examples](E5-F6-documentation-examples.md) | Planning | P2 | 3 |

docs/Features/condensation_strategy_system.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,13 +165,16 @@ particle, gas = latent.step(
165165
time_step=1.0,
166166
dynamic_viscosity=1.8e-5,
167167
)
168-
energy_released = latent.last_latent_heat_energy
168+
energy_released = latent.last_latent_heat_energy # total energy [J]
169169
```
170170

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

175+
`last_latent_heat_energy` records the total latent heat released per step
176+
(sum of dm × L), not an energy density.
177+
175178
### CondensationIsothermalStaggered (two-pass Gauss-Seidel)
176179

177180
`CondensationIsothermalStaggered` splits each timestep into two passes. Theta

particula/dynamics/condensation/condensation_strategies.py

Lines changed: 110 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1762,7 +1762,7 @@ class CondensationLatentHeat(CondensationStrategy):
17621762
Attributes:
17631763
latent_heat_strategy_input: Strategy input provided at initialization.
17641764
latent_heat_input: Raw latent heat value provided at initialization.
1765-
last_latent_heat_energy: Net latent heat energy released in the most
1765+
last_latent_heat_energy: Total latent heat energy released in the most
17661766
recent step [J]. Positive values indicate condensation and
17671767
negative values indicate evaporation. Overwritten each call.
17681768
"""
@@ -1955,11 +1955,9 @@ def mass_transfer_rate(
19551955
pressure_delta = self.calculate_pressure_delta(
19561956
particle, gas_species, temperature, radius_with_fill
19571957
)
1958-
if not np.all(np.isfinite(pressure_delta)):
1959-
raise ValueError(
1960-
"Non-finite pressure_delta computed for latent-heat "
1961-
"condensation."
1962-
)
1958+
pressure_delta = np.nan_to_num(
1959+
pressure_delta, posinf=0.0, neginf=0.0, nan=0.0
1960+
)
19631961

19641962
if self._latent_heat_strategy is None:
19651963
return get_mass_transfer_rate(
@@ -2062,6 +2060,98 @@ def _update_latent_heat_energy(
20622060
)
20632061
)
20642062

2063+
def _calculate_norm_conc(
2064+
self, volume: float, concentration: NDArray[np.float64]
2065+
) -> NDArray[np.float64]:
2066+
"""Validate inputs and compute normalized concentration.
2067+
2068+
Args:
2069+
volume: Particle volume for the single box [m^3].
2070+
concentration: Particle concentration array.
2071+
2072+
Returns:
2073+
Normalized concentration (concentration / volume).
2074+
2075+
Raises:
2076+
ValueError: If volume or concentration inputs are invalid.
2077+
"""
2078+
if not np.isfinite(volume) or volume <= 0.0:
2079+
raise ValueError("volume must be finite and positive.")
2080+
if not np.all(np.isfinite(concentration)) or np.any(
2081+
concentration < 0.0
2082+
):
2083+
raise ValueError(
2084+
"concentration must be finite and nonnegative for norm_conc."
2085+
)
2086+
return concentration / volume
2087+
2088+
def _normalize_mass_transfer_shape(
2089+
self,
2090+
mass_transfer: NDArray[np.float64],
2091+
species_mass: NDArray[np.float64],
2092+
) -> NDArray[np.float64]:
2093+
"""Normalize mass transfer shape to match species count.
2094+
2095+
Args:
2096+
mass_transfer: Per-particle mass transfer array.
2097+
species_mass: Particle mass array used to infer species count.
2098+
2099+
Returns:
2100+
Mass transfer array with consistent species dimension.
2101+
"""
2102+
species_count = 1 if species_mass.ndim == 1 else species_mass.shape[1]
2103+
if mass_transfer.ndim == 1:
2104+
return mass_transfer.reshape(-1, species_count)
2105+
if mass_transfer.shape[1] > species_count:
2106+
return mass_transfer[:, :species_count]
2107+
if mass_transfer.shape[1] < species_count:
2108+
return np.broadcast_to(
2109+
mass_transfer, (mass_transfer.shape[0], species_count)
2110+
)
2111+
return mass_transfer
2112+
2113+
def _apply_mass_transfer_to_particles(
2114+
self,
2115+
particle_data: ParticleData,
2116+
mass_transfer: NDArray[np.float64],
2117+
norm_conc: NDArray[np.float64],
2118+
) -> None:
2119+
"""Apply mass transfer to particle data in place."""
2120+
if mass_transfer.ndim == 2:
2121+
nonzero_mask = norm_conc != 0
2122+
denom = norm_conc[:, np.newaxis]
2123+
where_mask = nonzero_mask[:, np.newaxis]
2124+
else:
2125+
denom = norm_conc
2126+
where_mask = norm_conc != 0
2127+
per_particle = np.divide(
2128+
mass_transfer,
2129+
denom,
2130+
out=np.zeros_like(mass_transfer),
2131+
where=where_mask,
2132+
)
2133+
np.add(
2134+
particle_data.masses[0],
2135+
per_particle,
2136+
out=particle_data.masses[0],
2137+
)
2138+
np.maximum(particle_data.masses[0], 0.0, out=particle_data.masses[0])
2139+
2140+
def _apply_mass_transfer_to_gas(
2141+
self, gas_data: GasData, mass_transfer: NDArray[np.float64]
2142+
) -> None:
2143+
"""Apply mass transfer to gas concentrations in place."""
2144+
np.subtract(
2145+
gas_data.concentration[0],
2146+
mass_transfer.sum(axis=0),
2147+
out=gas_data.concentration[0],
2148+
)
2149+
np.maximum(
2150+
gas_data.concentration[0],
2151+
0.0,
2152+
out=gas_data.concentration[0],
2153+
)
2154+
20652155
# pylint: disable=too-many-positional-arguments, too-many-arguments
20662156
@overload # type: ignore[override]
20672157
def step(
@@ -2101,8 +2191,8 @@ def step(
21012191
The mass transfer rate is computed, optional skip-partitioning applied,
21022192
and both the particle and gas states are updated while respecting
21032193
inventory limits. The per-step latent heat release is stored in
2104-
``last_latent_heat_energy`` (positive for condensation, negative for
2105-
evaporation) and overwritten on every call.
2194+
``last_latent_heat_energy`` [J] (positive for condensation, negative
2195+
for evaporation) and overwritten on every call.
21062196
21072197
Args:
21082198
particle: Particle representation to update.
@@ -2130,18 +2220,16 @@ def step(
21302220
dynamic_viscosity=dynamic_viscosity,
21312221
)
21322222

2133-
if isinstance(mass_rate, (int, float)):
2134-
mass_rate_array = np.array([mass_rate])
2135-
else:
2136-
mass_rate_array = mass_rate
2223+
mass_rate_array = np.atleast_1d(np.asarray(mass_rate, dtype=np.float64))
21372224

21382225
mass_rate_array = self._apply_skip_partitioning(mass_rate_array)
21392226

21402227
gas_mass_array: NDArray[np.float64] = np.atleast_1d(
21412228
np.asarray(gas_data.concentration[0], dtype=np.float64)
21422229
)
21432230
volume = particle_data.volume[0]
2144-
norm_conc = particle_data.concentration[0] / volume
2231+
concentration = particle_data.concentration[0]
2232+
norm_conc = self._calculate_norm_conc(volume, concentration)
21452233
mass_transfer = get_mass_transfer(
21462234
mass_rate=mass_rate_array,
21472235
time_step=time_step,
@@ -2150,45 +2238,28 @@ def step(
21502238
particle_concentration=norm_conc,
21512239
)
21522240

2153-
self._update_latent_heat_energy(mass_transfer, temperature)
2241+
mass_transfer = self._normalize_mass_transfer_shape(
2242+
mass_transfer,
2243+
particle_data.masses[0],
2244+
)
21542245

2155-
species_mass = particle_data.masses[0]
2156-
if species_mass.ndim == 1:
2157-
species_count = 1
2158-
else:
2159-
species_count = species_mass.shape[1]
2160-
if mass_transfer.ndim == 1:
2161-
mass_transfer = mass_transfer.reshape(-1, species_count)
2162-
elif mass_transfer.shape[1] > species_count:
2163-
mass_transfer = mass_transfer[:, :species_count]
2164-
elif mass_transfer.shape[1] < species_count:
2165-
mass_transfer = np.broadcast_to(
2166-
mass_transfer, (mass_transfer.shape[0], species_count)
2167-
)
2246+
self._update_latent_heat_energy(mass_transfer, temperature)
21682247

21692248
if particle_is_legacy:
21702249
particle.add_mass(added_mass=mass_transfer) # type: ignore[union-attr]
21712250
else:
2172-
per_particle = np.divide(
2251+
self._apply_mass_transfer_to_particles(
2252+
particle_data,
21732253
mass_transfer,
2174-
norm_conc[:, np.newaxis]
2175-
if mass_transfer.ndim == 2
2176-
else norm_conc,
2177-
out=np.zeros_like(mass_transfer),
2178-
where=norm_conc[:, np.newaxis] != 0
2179-
if mass_transfer.ndim == 2
2180-
else norm_conc != 0,
2181-
)
2182-
particle_data.masses[0] = np.maximum(
2183-
particle_data.masses[0] + per_particle, 0.0
2254+
norm_conc,
21842255
)
21852256
if self.update_gases:
21862257
if gas_is_legacy:
21872258
gas_species.add_concentration( # type: ignore[union-attr]
21882259
added_concentration=-mass_transfer.sum(axis=0)
21892260
)
21902261
else:
2191-
gas_data.concentration[0] -= mass_transfer.sum(axis=0)
2262+
self._apply_mass_transfer_to_gas(gas_data, mass_transfer)
21922263
if particle_is_legacy:
21932264
return cast(
21942265
Tuple[ParticleRepresentation, GasSpecies],

0 commit comments

Comments
 (0)