You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 via particle.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 via activity_strategy, surface_strategy, vapor_pressure_strategy parameters.
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(), and mass_transfer_rate() with rtol=1e-10
Energy parity:last_latent_heat_energy is 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 == 0 particles have zero mass change; division by zero is guarded
Data-only requires strategies:TypeError raised when activity_strategy or surface_strategy is None for data-only inputs (existing behavior preserved)
Mixed input rejection:TypeError raised when mixing ParticleRepresentation with GasData or vice versa
All existing tests pass without regression
ruff check and ruff format pass cleanly
Technical Notes
Data-only path per-particle mass division (from isothermal lines 1011–1026):
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) using par.gas and par.particles builders
Convert to ParticleData + GasData using from_representation() and from_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() and gas_legacy.get_concentration()
Data: particle_data.masses[0] and gas_data.concentration[0]
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:
latent_heat_strategy: the LatentHeatStrategy instance
_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 — Create ParticleData + GasData + strategy with explicit strategies. Run step(). Verify it returns (ParticleData, GasData) without error.
test_step_legacy_vs_data_parity — Build identical physical setups using legacy and data-only paths. Run step() on both with CondensationLatentHeat(latent_heat=2.26e6). Assert all output arrays match with rtol=1e-10.
test_step_legacy_vs_data_energy_parity — Same setup as above. Assert last_latent_heat_energy matches between paths to < 1e-14 relative.
test_step_zero_particles_no_crash — Create setup with 0 particles. Run step(). Verify inputs returned unchanged and last_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 — Pass ParticleData without activity_strategy on the condensation strategy. Assert TypeError.
test_data_only_missing_vapor_pressure_strategy_raises — Pass GasData without vapor_pressure_strategy. Assert TypeError.
test_rate_data_only_parity — Parity test for rate() method between legacy and data-only paths.
test_mass_transfer_rate_data_only_parity — Parity test for mass_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 that from_species() may handle n_boxes differently — 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. If volume is not 1.0, this produces different raw concentrations than the legacy path. The parity rtol=1e-10 (not 1e-15) accounts for this numerical difference.
Zero particles with non-zero gas: When n_particles=0 and 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_num sanitization 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_strategy is a Sequence[VaporPressureStrategy], the _pure_vapor_pressure_from_strategy and _partial_pressure_from_strategy helpers iterate per-species. Verify this works correctly for the _get_vapor_pressure_surface() extraction in the latent heat path.
Example Usage
importcopyimportnumpyasnpimportparticulaasparfromparticula.dynamics.condensation.condensation_strategiesimport (
CondensationLatentHeat,
)
fromparticula.gas.latent_heat_strategiesimportConstantLatentHeatfromparticula.gas.gas_dataimportfrom_speciesfromparticula.particles.particle_dataimportfrom_representation# Build legacy objectsaerosol= (
par.particles.AerosolBuilder()
.set_molar_mass(0.018)
# ... (full builder chain)
.build()
)
particle_legacy=aerosol.particlegas_legacy=aerosol.gas_species# Convert to data-only containersparticle_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 pathsresult_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,
)
Dependency diagram:
Description
Add comprehensive parity tests verifying that the
ParticleData+GasData(data-only) input path produces identical results to theParticleRepresentation+GasSpecies(legacy) path for allCondensationLatentHeatmethods:mass_transfer_rate(),rate(), andstep().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 (dividingmass_transferby volume-normalized concentration with division-by-zero guards). Mirror the existingTestCondensationIsothermalparity 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:
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.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 dividesmass_transferby 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
TestCondensationIsothermalclass has tests for both paths (e.g.,test_isothermal_step_with_particle_data_gas_dataat line 373,test_isothermal_step_numerical_parityat line 414). This phase mirrors those tests forCondensationLatentHeat.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 resultsNo 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
step(),rate(), andmass_transfer_rate()withrtol=1e-10last_latent_heat_energyis identical (< 1e-14 relative) for both paths given the same physical setupstep()with zero particles returns inputs unchanged without crash;last_latent_heat_energy == 0.0norm_conc == 0particles have zero mass change; division by zero is guardedTypeErrorraised whenactivity_strategyorsurface_strategyisNonefor data-only inputs (existing behavior preserved)TypeErrorraised when mixingParticleRepresentationwithGasDataor vice versaruff checkandruff formatpass cleanlyTechnical Notes
Data-only path per-particle mass division (from isothermal lines 1011–1026):
This must be identical in the latent heat
step(). Thenp.divide(..., where=...)guard prevents division by zero for zero-concentration particles.Parity test fixture pattern (from
test_isothermal_step_numerical_parityat line 414):ParticleRepresentation+GasSpecies(legacy) usingpar.gasandpar.particlesbuildersParticleData+GasDatausingfrom_representation()andfrom_species()step()on both and compare:particle_legacy.get_species_mass()andgas_legacy.get_concentration()particle_data.masses[0]andgas_data.concentration[0]Important: The legacy path returns
ParticleRepresentationwhich exposes mass viaget_species_mass()(not.data.masses[0]). The data path returnsParticleDatawhich 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
CondensationLatentHeatconstructor 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(extendTestCondensationLatentHeat):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.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.n_particles=0and gas has non-zero concentration, the step should return gas unchanged (no mass transfer occurs).nan_to_numsanitization should handle this — verify output is zero (no condensation for sub-molecular particles).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
References
adw-docs/dev-plans/features/E5-F3-condensation-latent-heat-strategy.md(E5-F3-P5)adw-docs/dev-plans/epics/E5-non-isothermal-condensation.md(E5-F3-P5 section)condensation_strategies.pycondensation_strategies.pycondensation_strategies.pycondensation_strategies.pyparticula/particles/particle_data.pyparticula/gas/gas_data.pytest_isothermal_step_numerical_parityat line 414,test_isothermal_step_with_particle_data_gas_dataat line 373particula/dynamics/condensation/tests/condensation_strategies_test.py(1655 lines)