Skip to content

[E5-F3-P5] Add data-only path support and parity tests #1143

@Gorkowski

Description

@Gorkowski

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:

  1. 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.
  2. 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

  1. Path parity: Legacy path and data-only path produce identical results for step(), rate(), and mass_transfer_rate() with rtol=1e-10
  2. Energy parity: last_latent_heat_energy is identical (< 1e-14 relative) for both paths given the same physical setup
  3. Zero particles: step() with zero particles returns inputs unchanged without crash; last_latent_heat_energy == 0.0
  4. Single species: Both paths work correctly with single-species setups
  5. Very small particles (< MIN_PARTICLE_RADIUS_M): Radii are clipped; no NaN/inf in output
  6. Zero concentration particles: norm_conc == 0 particles have zero mass change; division by zero is guarded
  7. Data-only requires strategies: TypeError raised when activity_strategy or surface_strategy is None for data-only inputs (existing behavior preserved)
  8. Mixed input rejection: TypeError raised when mixing ParticleRepresentation with GasData or vice versa
  9. All existing tests pass without regression
  10. ruff check and ruff format pass 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):

  1. Build a ParticleRepresentation + GasSpecies (legacy) using par.gas and par.particles builders
  2. Convert to ParticleData + GasData using from_representation() and from_species()
  3. 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
  4. 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:

  • 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: 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):

  1. test_step_data_only_path_runs — Create ParticleData + GasData + strategy with explicit strategies. Run step(). Verify it returns (ParticleData, GasData) without error.
  2. 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.
  3. test_step_legacy_vs_data_energy_parity — Same setup as above. Assert last_latent_heat_energy matches between paths to < 1e-14 relative.
  4. test_step_zero_particles_no_crash — Create setup with 0 particles. Run step(). Verify inputs returned unchanged and last_latent_heat_energy == 0.0.
  5. test_step_single_species_data_only — Single species with data-only path. Verify mass conservation and correct output shapes.
  6. test_step_very_small_particles — Particles with radii < 1e-10 m. Verify radii are clipped, no NaN/inf in output, and step completes.
  7. test_step_zero_concentration_particles — Particles with concentration=0. Verify zero mass change for those particles and no division-by-zero errors.
  8. test_data_only_missing_activity_strategy_raises — Pass ParticleData without activity_strategy on the condensation strategy. Assert TypeError.
  9. test_data_only_missing_vapor_pressure_strategy_raises — Pass GasData without vapor_pressure_strategy. Assert TypeError.
  10. test_mixed_legacy_data_raises — Pass ParticleRepresentation + GasData. Assert TypeError.
  11. test_rate_data_only_parity — Parity test for rate() method between legacy and data-only paths.
  12. 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

  1. 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.
  2. 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.
  3. 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).
  4. 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).
  5. 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.
  6. 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

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_parity at line 414, test_isothermal_step_with_particle_data_gas_data at line 373
  • Test file: particula/dynamics/condensation/tests/condensation_strategies_test.py (1655 lines)

Metadata

Metadata

Assignees

No one assigned

    Labels

    adw:in-progressADW workflow actively processing - remove to re-triggeragentCreated or managed by ADW automationmodel:defaultUse base/sonnet tier (workflow default)type:completeFull complete workflow (plan → build → test → review → document → ship)

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions