-
Notifications
You must be signed in to change notification settings - Fork 10
[E5-F3-P5] Add data-only path support and parity tests #1143
Description
Dependency diagram:
#1139 [E5-F3-P1] ──┐
│
#1140 [E5-F3-P2] ──┤
├──► #THIS [E5-F3-P5]
#1141 [E5-F3-P3] ──┤
│
#1142 [E5-F3-P4] ──┘
Description
Add comprehensive parity tests verifying that the ParticleData + GasData (data-only) input path produces identical results to the ParticleRepresentation + GasSpecies (legacy) path for all CondensationLatentHeat methods: mass_transfer_rate(), rate(), and step().
Create test cases covering edge conditions: zero particles, single species, very small particles (below MIN_PARTICLE_RADIUS_M), and zero-concentration particles. Fix any discrepancies found in the data-only path's per-particle mass division logic (dividing mass_transfer by volume-normalized concentration with division-by-zero guards). Mirror the existing TestCondensationIsothermal parity tests (test_isothermal_step_with_particle_data_gas_data, test_isothermal_step_numerical_parity) for the latent heat variant.
Context
The particula framework supports two input styles:
- Legacy path:
ParticleRepresentation+GasSpecies— facade objects that carry activity/surface/vapor_pressure strategies internally. The condensation strategy extracts strategies viaparticle.activity,particle.surface,gas_species.pure_vapor_pressure_strategy. - Data-only path:
ParticleData+GasData— plain data containers. The strategies must be provided on the condensation strategy constructor viaactivity_strategy,surface_strategy,vapor_pressure_strategyparameters.
Both paths are validated via _unwrap_particle() / _unwrap_gas() (lines 63–95) and _resolve_strategies() (lines 257–294). The _require_matching_types() helper (lines 98–106) ensures you can't mix legacy particle with data-only gas.
For the data-only path, the step() method handles per-particle mass division differently (lines 1011–1026): it divides mass_transfer by volume-normalized concentration to get per-particle mass changes, guarding against division by zero. This logic must be replicated exactly in the latent heat variant (already done in P3 if following the template).
The existing TestCondensationIsothermal class has tests for both paths (e.g., test_isothermal_step_with_particle_data_gas_data at line 373, test_isothermal_step_numerical_parity at line 414). This phase mirrors those tests for CondensationLatentHeat.
Scope
Estimated Lines of Code: ~50 LOC (excluding tests)
Complexity: Small
Files to Modify:
particula/dynamics/condensation/condensation_strategies.py(~10-20 LOC) — any fixes for data-only path edge cases; ensure_get_vapor_pressure_surface()(from P2) handles data-only inputs correctly via_resolve_strategies()
Test Files:
particula/dynamics/condensation/tests/condensation_strategies_test.py(+~120 LOC) — add data-only path tests and parity tests comparing legacy vs data-only results
No New Files Created.
The code delta is expected to be small — this is primarily a testing and validation phase. Most edge case fixes are minor guards (e.g., empty array handling, zero-particle early return).
Acceptance Criteria
- Path parity: Legacy path and data-only path produce identical results for
step(),rate(), andmass_transfer_rate()withrtol=1e-10 - Energy parity:
last_latent_heat_energyis identical (< 1e-14 relative) for both paths given the same physical setup - Zero particles:
step()with zero particles returns inputs unchanged without crash;last_latent_heat_energy == 0.0 - Single species: Both paths work correctly with single-species setups
- Very small particles (< MIN_PARTICLE_RADIUS_M): Radii are clipped; no NaN/inf in output
- Zero concentration particles:
norm_conc == 0particles have zero mass change; division by zero is guarded - Data-only requires strategies:
TypeErrorraised whenactivity_strategyorsurface_strategyisNonefor data-only inputs (existing behavior preserved) - Mixed input rejection:
TypeErrorraised when mixingParticleRepresentationwithGasDataor vice versa - All existing tests pass without regression
ruff checkandruff formatpass cleanly
Technical Notes
Data-only path per-particle mass division (from isothermal lines 1011–1026):
if particle_is_legacy:
particle.add_mass(added_mass=mass_transfer)
else:
volume = particle_data.volume[0]
norm_conc = particle_data.concentration[0] / volume
per_particle = np.divide(
mass_transfer,
norm_conc[:, np.newaxis]
if mass_transfer.ndim == 2
else norm_conc,
out=np.zeros_like(mass_transfer),
where=norm_conc[:, np.newaxis] != 0
if mass_transfer.ndim == 2
else norm_conc != 0,
)
particle_data.masses[0] = np.maximum(
particle_data.masses[0] + per_particle, 0.0
)This must be identical in the latent heat step(). The np.divide(..., where=...) guard prevents division by zero for zero-concentration particles.
Parity test fixture pattern (from test_isothermal_step_numerical_parity at line 414):
- Build a
ParticleRepresentation+GasSpecies(legacy) usingpar.gasandpar.particlesbuilders - Convert to
ParticleData+GasDatausingfrom_representation()andfrom_species() - Create a single strategy instance with all explicit strategies set (activity, surface, vapor_pressure), and use it for both paths — or create two identically-configured instances
- Run
step()on both and compare:- Legacy:
particle_legacy.get_species_mass()andgas_legacy.get_concentration() - Data:
particle_data.masses[0]andgas_data.concentration[0]
- Legacy:
Important: The legacy path returns ParticleRepresentation which exposes mass via get_species_mass() (not .data.masses[0]). The data path returns ParticleData which uses .masses[0]. The existing parity test at line 430-443 demonstrates this accessor pattern.
Data-only strategy configuration: For the data-only path, the CondensationLatentHeat constructor needs:
activity_strategy: e.g.,par.particles.ActivityIdealMass()surface_strategy: e.g.,par.particles.SurfaceStrategyVolume(...)vapor_pressure_strategy: e.g.,par.gas.VaporPressureFactory().get_strategy("water_buck")latent_heat_strategy: theLatentHeatStrategyinstance
_get_vapor_pressure_surface() for data-only: Must call _resolve_strategies() to get the activity and vapor pressure strategies before computing the surface vapor pressure. The resolution already handles both paths.
Testing Strategy
Tests are co-located in particula/dynamics/condensation/tests/condensation_strategies_test.py (extend TestCondensationLatentHeat):
test_step_data_only_path_runs— CreateParticleData+GasData+ strategy with explicit strategies. Runstep(). Verify it returns(ParticleData, GasData)without error.test_step_legacy_vs_data_parity— Build identical physical setups using legacy and data-only paths. Runstep()on both withCondensationLatentHeat(latent_heat=2.26e6). Assert all output arrays match withrtol=1e-10.test_step_legacy_vs_data_energy_parity— Same setup as above. Assertlast_latent_heat_energymatches between paths to < 1e-14 relative.test_step_zero_particles_no_crash— Create setup with 0 particles. Runstep(). Verify inputs returned unchanged andlast_latent_heat_energy == 0.0.test_step_single_species_data_only— Single species with data-only path. Verify mass conservation and correct output shapes.test_step_very_small_particles— Particles with radii <1e-10 m. Verify radii are clipped, no NaN/inf in output, and step completes.test_step_zero_concentration_particles— Particles with concentration=0. Verify zero mass change for those particles and no division-by-zero errors.test_data_only_missing_activity_strategy_raises— PassParticleDatawithoutactivity_strategyon the condensation strategy. AssertTypeError.test_data_only_missing_vapor_pressure_strategy_raises— PassGasDatawithoutvapor_pressure_strategy. AssertTypeError.test_mixed_legacy_data_raises— PassParticleRepresentation+GasData. AssertTypeError.test_rate_data_only_parity— Parity test forrate()method between legacy and data-only paths.test_mass_transfer_rate_data_only_parity— Parity test formass_transfer_rate()method.
All tests pass before merge. Run: pytest particula/dynamics/condensation/tests/condensation_strategies_test.py -v -k "LatentHeat and (data or parity or zero or small or mixed)"
Edge Cases and Considerations
from_representation()/from_species()conversion: These utilities convert legacy objects to data containers. Ensure the converted data produces identical numerical results when passed through the data-only path. Note thatfrom_species()may handlen_boxesdifferently — the existing test at line 414 accounts for this.- Volume normalization mismatch: The data path uses
particle_data.concentration[0] / particle_data.volume[0]for normalization. Ifvolumeis not 1.0, this produces different raw concentrations than the legacy path. The parityrtol=1e-10(not 1e-15) accounts for this numerical difference. - Zero particles with non-zero gas: When
n_particles=0and gas has non-zero concentration, the step should return gas unchanged (no mass transfer occurs). - All particles below MIN_PARTICLE_RADIUS_M: All radii clipped to 1e-10 m. Combined with very high Kelvin effect, pressure deltas may be NaN/inf. The
nan_to_numsanitization should handle this — verify output is zero (no condensation for sub-molecular particles). - Single particle with zero mass: A particle with mass=0 and concentration > 0. The activity strategy may return unusual partial pressures. Verify the strategy handles this without error.
- Data-only with per-species vapor pressure strategies: When
vapor_pressure_strategyis aSequence[VaporPressureStrategy], the_pure_vapor_pressure_from_strategyand_partial_pressure_from_strategyhelpers iterate per-species. Verify this works correctly for the_get_vapor_pressure_surface()extraction in the latent heat path.
Example Usage
import copy
import numpy as np
import particula as par
from particula.dynamics.condensation.condensation_strategies import (
CondensationLatentHeat,
)
from particula.gas.latent_heat_strategies import ConstantLatentHeat
from particula.gas.gas_data import from_species
from particula.particles.particle_data import from_representation
# Build legacy objects
aerosol = (
par.particles.AerosolBuilder()
.set_molar_mass(0.018)
# ... (full builder chain)
.build()
)
particle_legacy = aerosol.particle
gas_legacy = aerosol.gas_species
# Convert to data-only containers
particle_data = from_representation(particle_legacy)
gas_data = from_species(gas_legacy)
# Create strategy for data-only path (explicit strategies)
strategy_data = CondensationLatentHeat(
molar_mass=0.018,
latent_heat_strategy=ConstantLatentHeat(latent_heat_ref=2.26e6),
activity_strategy=particle_legacy.activity,
surface_strategy=particle_legacy.surface,
vapor_pressure_strategy=gas_legacy.pure_vapor_pressure_strategy,
)
# Create strategy for legacy path (uses same explicit strategies
# for parity — both strategy instances must be configured identically)
strategy_legacy = CondensationLatentHeat(
molar_mass=0.018,
latent_heat_strategy=ConstantLatentHeat(latent_heat_ref=2.26e6),
activity_strategy=particle_legacy.activity,
surface_strategy=particle_legacy.surface,
vapor_pressure_strategy=gas_legacy.pure_vapor_pressure_strategy,
)
# Deep copy to avoid mutation during step()
particle_legacy_copy = copy.deepcopy(particle_legacy)
gas_legacy_copy = copy.deepcopy(gas_legacy)
# Run both paths
result_legacy_p, result_legacy_g = strategy_legacy.step(
particle_legacy_copy, gas_legacy_copy, 293.0, 101325.0, 1.0
)
result_data_p, result_data_g = strategy_data.step(
particle_data, gas_data, 293.0, 101325.0, 1.0
)
# Compare: legacy returns ParticleRepresentation (use get_species_mass),
# data returns ParticleData (use .masses[0])
legacy_mass = result_legacy_p.get_species_mass()
data_mass = result_data_p.masses[0]
np.testing.assert_allclose(
data_mass,
legacy_mass,
rtol=1e-10,
)References
- Feature plan:
adw-docs/dev-plans/features/E5-F3-condensation-latent-heat-strategy.md(E5-F3-P5) - Epic doc:
adw-docs/dev-plans/epics/E5-non-isothermal-condensation.md(E5-F3-P5 section) - Parent issue: [E5-F3] Generate implementation issues for CondensationLatentHeat Strategy Class #1117 (E5-F3: CondensationLatentHeat Strategy Class)
- CondensationIsothermal.step() data-only path: lines 1011–1040 of
condensation_strategies.py - _unwrap_particle() / _unwrap_gas(): lines 63–95 of
condensation_strategies.py - _resolve_strategies(): lines 257–294 of
condensation_strategies.py - _require_matching_types(): lines 98–106 of
condensation_strategies.py - from_representation():
particula/particles/particle_data.py - from_species():
particula/gas/gas_data.py - Existing parity tests:
test_isothermal_step_numerical_parityat line 414,test_isothermal_step_with_particle_data_gas_dataat line 373 - Test file:
particula/dynamics/condensation/tests/condensation_strategies_test.py(1655 lines)