Skip to content

Condensation Strategy System

Strategy-based condensation and evaporation with unified dynamics APIs, staggered Gauss-Seidel updates for stability, and runnable integration.

Overview

The condensation strategy system models mass transfer between gas and particle phases using the same object-oriented patterns as wall loss and coagulation. Strategies expose rate and step, work with ParticleRepresentation across "discrete", "continuous_pdf", and "particle_resolved" modes, and plug directly 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 consistency with the isothermal API plus energy tracking.

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

  • CondensationStrategy – abstract base defining rate / step, exposed via particula.dynamics.condensation.
  • 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, CondensationLatentHeatBuilder – fluent builders with validation and unit handling, exported via both particula.dynamics and particula.dynamics.condensation.
  • CondensationFactory – factory selecting a condensation strategy by name, exported via both particula.dynamics and particula.dynamics.condensation.
  • MassCondensation runnable – delegates to a condensation strategy, splits time_step across sub_steps, and composes in pipelines.
  • Mass-transfer helpers – get_mass_transfer_rate, get_first_order_mass_ transport_k, get_mass_transfer, get_radius_transfer_rate for reference and lower-level workflows.

Key Benefits

  • Consistent dynamics workflow: Same strategy-based API (rate, step, builders, factory) as wall loss and coagulation.
  • Stability for particle-resolved runs: Staggered two-pass Gauss-Seidel with theta modes and batch clipping reduces lag and preserves mass.
  • Gas-particle coupling control: Toggle update_gases and skip_partitioning_indices to steer which species condense and whether gas 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

This feature is designed for:

  • Chamber and box-model users: Time-dependent condensation combined with wall loss and coagulation.
  • Particle-resolved modelers: Needing Gauss-Seidel batches, theta control, and deterministic or shuffled ordering for stability.
  • Multi-species workflows: Tracking several vapors with selective partitioning and optional gas-field depletion.

Capabilities

Unified condensation API in particula.dynamics

Concrete condensation implementations are exported alongside other dynamics components, while the abstract base interface remains available from the condensation subpackage:

import particula as par

# Abstract interface
par.dynamics.condensation.CondensationStrategy

# Concrete implementations
par.dynamics.CondensationIsothermal
par.dynamics.CondensationIsothermalStaggered
par.dynamics.CondensationLatentHeat

# Builders and factory
par.dynamics.CondensationIsothermalBuilder
par.dynamics.CondensationIsothermalStaggeredBuilder
par.dynamics.CondensationLatentHeatBuilder
par.dynamics.CondensationFactory

All strategies share a common shape: initialize with physical parameters (molar mass, diffusion coefficient, accommodation coefficient) and coupling controls (update_gases, skip_partitioning_indices); call rate(...) for instantaneous transfer; call step(...) to advance.

Runnable entry point: MassCondensation

MassCondensation is a RunnableABC exported as par.dynamics.MassCondensation. It operates on an Aerosol, delegates to the configured strategy, splits time_step across sub_steps, and composes with other runnables.

import particula as par

condensation = par.dynamics.MassCondensation(
    condensation_strategy=par.dynamics.CondensationIsothermal(
        molar_mass=0.018,
        diffusion_coefficient=2e-5,
        accommodation_coefficient=1.0,
    ),
)
wall_loss = par.dynamics.WallLoss(
    wall_loss_strategy=par.dynamics.SphericalWallLossStrategy(
        wall_eddy_diffusivity=1e-3,
        chamber_radius=0.5,
        distribution_type="discrete",
    ),
)
workflow = condensation | wall_loss
updated = workflow.execute(aerosol, time_step=60.0, sub_steps=4)

CondensationIsothermal (simultaneous update)

CondensationIsothermal evaluates the mass-transfer equation in one shot for all particles/bins:

  • dm/dt = 4π × r × D × M × f(Kn, α) × Δp / (R × T)

Radii are filled when zero, clipped to the minimum physical size (1e-10 m), pressure deltas (Δp) are sanitized (NaN/inf → 0) before computing rates, and gas mass is optionally depleted (update_gases=True).

iso = par.dynamics.CondensationIsothermal(
    molar_mass=0.18,
    diffusion_coefficient=2e-5,
    accommodation_coefficient=0.9,
    update_gases=True,
)
particle, gas = iso.step(
    particle=particle,
    gas_species=gas,
    temperature=298.15,
    pressure=101325.0,
    time_step=1.0,
)

CondensationLatentHeat (energy diagnostics)

CondensationLatentHeat mirrors the isothermal step but applies a latent-heat correction when a latent heat strategy is provided. Direct manual CondensationLatentHeat(...) construction is supported, and the constructor also accepts a positive scalar latent_heat compatibility fallback. For most public user workflows, prefer the builder/factory path below when you want validated parameter loading or a strategy object. When both latent_heat_strategy and latent_heat are supplied, the explicit strategy takes precedence. It records last_latent_heat_energy each step (positive for condensation, negative for evaporation) and accepts a dynamic_viscosity override for particle-resolved workflows.

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]

When neither a latent_heat_strategy nor a positive scalar latent_heat fallback is configured, the step follows the isothermal path and reports last_latent_heat_energy = 0.0.

last_latent_heat_energy records per-step latent-heat bookkeeping from the step mass-transfer quantity. For the current single-box public example path, that diagnostic aligns with the mass-concentration contract and should be reported as an energy density.

For the current shipped support boundary, E3-F7 is the executable CPU integration baseline for future Epic D GPU parity work: particula/integration_tests/condensation_latent_heat_conservation_test.py. It verifies only a finite nonzero condensation transfer, particle water gain, gas water loss, total water conservation, and final-step last_latent_heat_energy agreement with the constant-latent-heat bookkeeping path. This baseline is CPU-only and diagnostic/reference only.

The bounded, low-level direct GPU condensation path separately supports an optional per-species latent-rate correction in each of its four equal substeps, with CPU-oracle/Warp parity coverage. Omitting latent_heat, or using a zero entry for a species, retains that species' isothermal rate path. This is diagnostic bookkeeping, not temperature feedback or strategy/runnable thermal coupling. The direct path mutates gas concentration and its P2 finalization conserves particle-plus-gas inventory per box and species. It does not provide strategy/runnable-level latent-heat support; the separate energy_transfer diagnostic is described below.

CondensationIsothermalStaggered (two-pass Gauss-Seidel)

CondensationIsothermalStaggered splits each timestep into two passes. Theta controls the first-pass fraction; batches are clipped to the particle count and optionally shuffled each step. Gas concentration is updated after every batch to reduce lag. Radii are clamped and Δp is sanitized before computing dm/dt.

staggered = par.dynamics.CondensationIsothermalStaggered(
    molar_mass=0.018,
    diffusion_coefficient=2e-5,
    accommodation_coefficient=1.0,
    theta_mode="random",
    num_batches=8,
    shuffle_each_step=True,
    random_state=1234,
)
particle, gas = staggered.step(
    particle=particle,
    gas_species=gas,
    temperature=298.0,
    pressure=101325.0,
    time_step=0.5,
)

Theta modes and batching

  • "half": deterministic θ = 0.5 (symmetric two-pass).
  • "random": θ ~ U[0,1] using the configured RNG (random_state).
  • "batch": θ = 1.0; staggering comes from batch ordering instead of θ.
half = par.dynamics.CondensationIsothermalStaggered(
    molar_mass=0.018,
    theta_mode="half",
    num_batches=1,
)

batch = par.dynamics.CondensationIsothermalStaggered(
    molar_mass=0.018,
    theta_mode="batch",
    num_batches=16,
    shuffle_each_step=False,  # deterministic ordering
)

rand = par.dynamics.CondensationIsothermalStaggered(
    molar_mass=0.018,
    theta_mode="random",
    num_batches=4,
    random_state=2024,
)

Builder and factory workflow

Builders provide validation, units, and consistent naming; the factory selects a strategy by string while reusing builder validation. The public builder and factory entry points are available from both import surfaces:

  • particula.dynamics.CondensationLatentHeatBuilder
  • particula.dynamics.condensation.CondensationLatentHeatBuilder
  • particula.dynamics.CondensationFactory
  • particula.dynamics.condensation.CondensationFactory

The shipped latent-heat factory key is "latent_heat".

import particula as par
from particula.gas.latent_heat_strategies import ConstantLatentHeat

iso = (
    par.dynamics.CondensationIsothermalBuilder()
    .set_molar_mass(0.018, "kg/mol")
    .set_diffusion_coefficient(2.1e-5, "m^2/s")
    .set_accommodation_coefficient(1.0)
    .set_update_gases(True)
    .build()
)

staggered = (
    par.dynamics.CondensationIsothermalStaggeredBuilder()
    .set_molar_mass(0.018, "kg/mol")
    .set_diffusion_coefficient(2e-5, "m^2/s")
    .set_accommodation_coefficient(0.95)
    .set_theta_mode("batch")
    .set_num_batches(12)
    .set_shuffle_each_step(True)
    .set_random_state(7)
    .set_update_gases(False)
    .build()
)

latent_strategy = ConstantLatentHeat(latent_heat_ref=2.4e6)

latent = (
    par.dynamics.CondensationLatentHeatBuilder()
    .set_molar_mass(0.018, "kg/mol")
    .set_diffusion_coefficient(2e-5, "m^2/s")
    .set_accommodation_coefficient(1.0)
    .set_latent_heat_strategy(latent_strategy)
    .set_update_gases(True)
    .build()
)

factory = par.dynamics.CondensationFactory()
latent_factory_with_strategy = factory.get_strategy(
    strategy_type="latent_heat",
    parameters={
        "molar_mass": 0.018,
        "diffusion_coefficient": 2e-5,
        "accommodation_coefficient": 1.0,
        "latent_heat_strategy": latent_strategy,
        "update_gases": True,
    },
)

iso_factory = factory.get_strategy(
    strategy_type="isothermal",
    parameters={
        "molar_mass": 0.018,
        "diffusion_coefficient": 2e-5,
        "accommodation_coefficient": 1.0,
        "update_gases": True,
    },
)
latent_factory = factory.get_strategy(
    strategy_type="latent_heat",
    parameters={
        "molar_mass": 0.018,
        "diffusion_coefficient": 2e-5,
        "accommodation_coefficient": 1.0,
        "latent_heat": 2.4e6,
        "update_gases": True,
    },
)

Use direct CondensationLatentHeat(...) construction when you want to wire the strategy manually. For the supported public workflow, prefer CondensationLatentHeatBuilder or CondensationFactory().get_strategy("latent_heat", parameters=...). The builder/factory path supports both documented latent-heat inputs:

  • latent_heat_strategy: pass an explicit latent heat strategy object.
  • latent_heat: pass a positive scalar fallback that the builder forwards to CondensationLatentHeat.

If both are supplied, the explicit latent_heat_strategy remains active and the scalar value is preserved only as constructor input metadata.

Skip-partitioning indices and gas updates

Use skip_partitioning_indices to zero-out selected species during rate/step without touching others; combine with update_gases to control depletion.

iso_skip = par.dynamics.CondensationIsothermal(
    molar_mass=[0.1, 0.2],
    diffusion_coefficient=[2e-5, 1.5e-5],
    accommodation_coefficient=[1.0, 0.8],
    skip_partitioning_indices=[1],  # second species stays in gas
    update_gases=True,
)
rates = iso_skip.rate(particle, gas, temperature=298.0, pressure=101325.0)

Multi-species and particle-resolved batching

multi = par.dynamics.CondensationIsothermalStaggered(
    molar_mass=[0.018, 0.046],
    diffusion_coefficient=[2e-5, 1.2e-5],
    accommodation_coefficient=[1.0, 0.7],
    theta_mode="batch",
    num_batches=24,
    shuffle_each_step=True,
)
particle, gas = multi.step(
    particle=particle,
    gas_species=gas,
    temperature=296.0,
    pressure=101325.0,
    time_step=0.25,
)

Sub-steps in runnable pipelines

MassCondensation.execute splits time_step by sub_steps, calling the strategy step each sub-iteration. Use this to interleave condensation tightly with other processes.

cond = par.dynamics.MassCondensation(condensation_strategy=iso)
coag = par.dynamics.Coagulation(
    coagulation_strategy=par.dynamics.BrownianCoagulationStrategy(
        distribution_type="discrete",
    ),
)
workflow = cond | coag | wall_loss
updated = workflow.execute(aerosol, time_step=120.0, sub_steps=6)

Mass conservation, stability, and sanitization

  • Two-pass Gauss-Seidel (staggered): theta split plus batch sweeps, with gas updated after each batch.
  • Batch clipping: num_batches is clipped to the particle count to avoid empty batches.
  • Minimum radius clamp: Radii are clipped to 1e-10 m; zeros filled to avoid divide-by-zero.
  • Δp sanitization: Non-finite partial-pressure deltas are zeroed before computing dm/dt.
  • Inventory limits: get_mass_transfer clips condensation/evaporation by gas and particle inventory and per-bin limits.
  • Gas updates optional: update_gases=False leaves gas concentrations unchanged.

Getting Started

Quick start: isothermal condensation on a discrete distribution

import particula as par

particle = par.particles.PresetParticleRadiusBuilder().build()
gas = par.gas.GasSpecies(molar_mass=0.018, concentration=1e-6)
condensation = par.dynamics.CondensationIsothermal(
    molar_mass=0.018,
    diffusion_coefficient=2e-5,
    accommodation_coefficient=1.0,
)
rate = condensation.rate(
    particle, gas, temperature=298.15, pressure=101325.0
)
particle, gas = condensation.step(
    particle=particle,
    gas_species=gas,
    temperature=298.15,
    pressure=101325.0,
    time_step=10.0,
)

Staggered quick start with theta and batches

staggered = par.dynamics.CondensationIsothermalStaggered(
    molar_mass=0.018,
    diffusion_coefficient=2e-5,
    accommodation_coefficient=1.0,
    theta_mode="half",
    num_batches=4,
)
particle, gas = staggered.step(
    particle=particle,
    gas_species=gas,
    temperature=298.15,
    pressure=101325.0,
    time_step=5.0,
)

Prerequisites

  • particula version 0.2.6 or later.
  • A ParticleRepresentation (discrete, continuous PDF, or particle-resolved).
  • Gas species configured via par.gas.GasSpecies with vapor properties.
  • Familiarity with particula dynamics and examples.

Typical Workflows

1. Configure via builder

condensation = (
    par.dynamics.CondensationIsothermalBuilder()
    .set_molar_mass(0.05, "kg/mol")
    .set_diffusion_coefficient(1.5e-5, "m^2/s")
    .set_accommodation_coefficient(0.85)
    .set_update_gases(True)
    .build()
)

2. Run inside a runnable pipeline

cond = par.dynamics.MassCondensation(condensation_strategy=condensation)
wall = par.dynamics.WallLoss(
    wall_loss_strategy=par.dynamics.SphericalWallLossStrategy(
        wall_eddy_diffusivity=1e-3,
        chamber_radius=0.5,
        distribution_type="discrete",
    ),
)
workflow = cond | wall
aerosol = workflow.execute(aerosol, time_step=30.0, sub_steps=3)

3. Particle-resolved batching with deterministic ordering

staggered = (
    par.dynamics.CondensationIsothermalStaggeredBuilder()
    .set_molar_mass(0.018)
    .set_diffusion_coefficient(2e-5)
    .set_accommodation_coefficient(1.0)
    .set_theta_mode("batch")
    .set_num_batches(32)
    .set_shuffle_each_step(False)
    .build()
)
aerosol = par.dynamics.MassCondensation(staggered).execute(
    aerosol,
    time_step=60.0,
    sub_steps=2,
)

Use Cases

Use case 1: Chamber growth with gas depletion

Scenario: Track condensational growth of secondary organic aerosol while reducing gas phase mass.

Solution: Use CondensationIsothermal with update_gases=True and couple to wall loss in one pipeline.

cond = par.dynamics.MassCondensation(
    condensation_strategy=par.dynamics.CondensationIsothermal(
        molar_mass=0.15,
        diffusion_coefficient=1.8e-5,
        accommodation_coefficient=0.95,
        update_gases=True,
    )
)
workflow = cond | wall_loss
updated = workflow.execute(aerosol, time_step=120.0, sub_steps=4)

Use case 2: Particle-resolved stability with batch staggering

Scenario: Particle-resolved simulation oscillates when large particles dominate.

Solution: Switch to CondensationIsothermalStaggered with theta_mode "batch" and multiple batches to spread updates and reduce lag.

stable = par.dynamics.CondensationIsothermalStaggered(
    molar_mass=0.018,
    theta_mode="batch",
    num_batches=24,
    shuffle_each_step=True,
)
aerosol = par.dynamics.MassCondensation(stable).execute(
    aerosol,
    time_step=10.0,
    sub_steps=2,
)

Use case 3: Mixed-species with selective partitioning

Scenario: Only one of two vapors should condense; the other stays in gas.

Solution: Provide vector properties and set skip_partitioning_indices for the species that should remain in gas; keep update_gases enabled for the condensing species.

selective = par.dynamics.CondensationIsothermal(
    molar_mass=[0.018, 0.05],
    diffusion_coefficient=[2e-5, 1.6e-5],
    accommodation_coefficient=[1.0, 0.5],
    skip_partitioning_indices=[1],
    update_gases=True,
)
aerosol, gas = selective.step(
    particle=particle,
    gas_species=gas,
    temperature=300.0,
    pressure=101325.0,
    time_step=5.0,
)

Configuration

Option Description Default
molar_mass Molar mass of condensing species [kg/mol]. Required
diffusion_coefficient Vapor diffusion coefficient [m^2/s]. 2e-5
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
random_state Seed / RNG for theta draws and shuffling. None
sub_steps Runnable-only: split time_step into sub_steps. 1

Best Practices

  1. Match strategy to stability needs: Use simultaneous isothermal for bulk runs; switch to staggered with batches when particle-resolved lag or oscillations appear.
  2. Choose sensible batches: Start with num_batches near √N for particle-resolved cases; never exceed particle count.
  3. Control gas coupling explicitly: Set update_gases=False when gas is externally prescribed; leave it on for conservation.
  4. Use skip indices sparingly: Limit skip_partitioning_indices to species that should remain entirely in gas.
  5. Sub-step when composing processes: Increase sub_steps to reduce operator-splitting error when coupling with wall loss or coagulation.

Low-level Warp GPU condensation

This is a direct low-level Warp path, separate from the CPU strategies and the MassCondensation runnable described above. Its canonical step import is:

from particula.gpu.kernels import condensation_step_gpu

When reusable scratch is needed, import CondensationScratchBuffers only from particula.gpu.kernels.condensation. That concrete-module-only sidecar is not a second step entry point. Its transfer fields have shape (n_boxes, n_particles, n_species) and its property fields have shape (n_boxes,). Each supplied field must be active-device, stable-shape wp.float64; fields may be omitted independently and use fallback allocations. Supplied buffers preserve identity and callers must keep them alive and unmodified through launch completion.

Every successful call executes exactly four time_step / 4.0 substeps. Each substep optionally refreshes composition-weighted surface tension, overwrites gas.vapor_pressure, refreshes environment properties, produces a raw transfer proposal, finalizes that proposal against available inventory, applies the finalized transfer to particle mass, accumulates it, and mutates gas concentration by the matching particle-concentration-weighted amount. Later proposals therefore see gas concentration coupled by earlier substeps. Vapor-pressure refresh is temperature-driven and does not read gas concentration. The resolved total transfer buffer is cleared once after preflight, accumulates P2-finalized transfer, and is returned by identity when supplied. Work storage keeps only the final raw proposal. Production calculations make no hidden data CPU↔Warp transfers. CUDA validation reads small device status flags with .numpy(), which synchronizes execution but does not transfer or mutate caller-owned simulation buffers.

Invalid primary P2 state or supplied P2 sidecars fail during aggregate preflight, before launches or caller-buffer mutation. A non-finite fresh raw proposal fails before P2 finalization for that substep; completed earlier substeps remain applied. The failed cycle may already have written raw work, gas.vapor_pressure, and supplied dynamic-viscosity, mean-free-path, or composition-weighted surface-tension workspaces, but not P2 inventories or energy output. Retain or restore caller-owned snapshots when a failed call must be retried from its original state.

This direct low-level GPU step uses an optional keyword-only caller-owned active-device wp.float64 latent_heat sidecar with shape (n_species,) in each fixed substep. Its shipped rate is dm_i/dt = isothermal_rate_i / correction_i, where isothermal_rate_i = k_i * Delta_p_i * M_i / (R * T), R_specific_i = R / M_i, and correction_i = thermal_factor_i / (R_specific_i * T). The source thermal resistance expression is thermal_factor_i = (D_i * L_i * p_surface_i / (T * k_thermal)) * (L_i / (T * R_specific_i) - 1) + R_specific_i * T, using the activity- and Kelvin-adjusted p_surface_i. Omitting latent heat, or setting a species entry to exactly zero, takes that species through the exact isothermal branch.

Issue #1272 also ships optional keyword-only caller-owned active-device wp.float64 energy_transfer, shape (n_boxes, n_species), as write-only diagnostic output. It requires valid latent heat, is overwritten only after successful preflight, and is not a third return item. Its signed whole-call identity is Q[box, species] = sum_particles(Delta m_finalized) * L[species]: finalized Delta m is kg, L is J/kg, and Q is J, so condensation is positive and evaporation negative (kg * J/kg = J). The #1272 diagnostic uses strict rtol=1e-12, atol=1e-18 evidence. thermal_work is validated but remains deferred and unused.

This is diagnostic bookkeeping, not temperature feedback, gas mutation or gas/full-system conservation. The direct step couples finalized particle and gas transfer through its stated P2-finalized direct coupling, but does not add strategy/ Runnable integration, adaptive stepping, graph capture/replay, autodiff guarantees, or complete E4-F6 cross-device certification.

The GPU condensation parity walkthrough separately reports physics, conservation, and energy evidence for this fixed-four-substep low-level direct-kernel comparison. Its condensation parity walkthrough ownership record keeps deferred capabilities explicit. Warp CPU is the installed-Warp baseline; CUDA is optional additive evidence. The caller-owned, write-only energy_transfer output is a diagnostic, not a return value and not temperature feedback.

Focused fixed-four-substep direct-kernel evidence commands are: python docs/Examples/gpu_condensation_parity_walkthrough.py, pytest particula/gpu/tests/gpu_condensation_parity_walkthrough_test.py -q -Werror, and pytest particula/tests/condensation_parity_walkthrough_docs_test.py -q -Werror.

P1--P4 direct-condensation evidence

Evidence Discoverable wrapper Bounded result and backend
P1 parity particula/gpu/kernels/tests/condensation_test.py An independent NumPy fixed-four-substep oracle separately compares final particle masses and gas concentrations for one/multi-box and multi-species cases. Warp CPU is the normal backend when Warp is installed; CUDA is optional additive evidence.
P2 conservation and contracts particula/gpu/kernels/tests/condensation_test.py Separate concentration-weighted particle-plus-gas inventory checks use rtol=1e-12, distinct from P1 parity. The wrapper also covers in-place mutation and caller-owned output contracts.
P3 capture limit particula/gpu/kernels/tests/condensation_graph_capture_test.py Warp CPU capture is capability-skipped because Warp capture requires CUDA. On CUDA, only the public-step host-validation readback inside capture is strict-xfailed; setup and normal calls must still pass. This records an unsupported capability, not replay support.
P4 raw-rate autodiff particula/gpu/kernels/tests/condensation_autodiff_test.py A one-box fp64 out-of-place condensation_mass_transfer_kernel Tape derivative is compared with centered finite differences only in the interior, where P2 positivity and inventory boundaries are inactive. Warp CPU is the normal backend; CUDA is optional.

This matrix is evidence for condensation_step_gpu (P1--P3) or the raw condensation_mass_transfer_kernel (P4) only. It establishes no CPU strategy or Runnable equivalence, performance target, adaptive stepping, new API, general graph replay, or general differentiability claim. For focused, warning-clean coverage, run:

pytest particula/gpu/kernels/tests/condensation_test.py -q -Werror
pytest particula/gpu/kernels/tests/condensation_graph_capture_test.py -q -Werror
pytest particula/gpu/kernels/tests/condensation_autodiff_test.py -q -Werror

# Optional CUDA selections; unavailable CUDA must skip cleanly.
pytest particula/gpu/kernels/tests/condensation_test.py -q -m "warp and cuda" -Werror
pytest particula/gpu/kernels/tests/condensation_graph_capture_test.py -q -m "warp and cuda" -Werror
pytest particula/gpu/kernels/tests/condensation_autodiff_test.py -q -m "warp and cuda" -Werror

Warp-backed tests may skip when Warp is missing. CUDA evidence is optional when CUDA is unavailable; a skip is not GPU execution. P3's CPU skip and narrowly scoped CUDA strict expected failure document the public-step host-readback limitation rather than a successful capture replay.

Limitations

  • Staggered solver is Gauss-Seidel only; other solvers are not exposed.
  • Factory supports "isothermal", "isothermal_staggered", and "latent_heat" only.
  • Direct low-level GPU latent heat is a per-species rate correction only; it has no temperature feedback or strategy/Runnable integration. Its signed energy_transfer sidecar is finalized-transfer diagnostic bookkeeping only.
  • Minimum-radius clamp (1e-10 m) enforces continuum validity; sub-continuum physics is out of scope.

FAQ

When should I choose staggered over isothermal?

Choose CondensationIsothermalStaggered when particle-resolved runs show numerical oscillations or lag, or when you need reproducible Gauss-Seidel ordering with batches and theta control.

How do theta modes differ?

  • "half" uses θ = 0.5 for symmetric two-pass updates.
  • "random" draws θ per particle with the configured RNG to reduce ordering bias.
  • "batch" uses θ = 1.0 and relies on batch ordering for staggering.

How is mass conserved?

Mass changes are limited by get_mass_transfer to available gas and particle inventory, batches update working gas each pass, radii are clamped, and Δp is sanitized before computing dm/dt.

See Also