Skip to content

particula.particles.particle_data

particle_data

Provide a batched particle data container for multi-box CFD simulations.

ParticleData isolates per-particle arrays from behavior while embedding the batch dimension required for CFD experiments spanning multiple boxes.

Example

Single-box simulation (n_boxes=1)::

from particula.particles.particle_data import ParticleData
import numpy as np

data = ParticleData(
    masses=np.random.rand(1, 1000, 3) * 1e-18,  # 1000 particles
    concentration=np.ones((1, 1000)),
    charge=np.zeros((1, 1000)),
    density=np.array([1000.0, 1200.0, 800.0]),
    volume=np.array([1e-6]),  # 1 cm^3
)

Multi-box CFD simulation (100 boxes)::

cfd_data = ParticleData(
    masses=np.zeros((100, 10000, 3)),
    concentration=np.ones((100, 10000)),
    charge=np.zeros((100, 10000)),
    density=np.array([1000.0, 1200.0, 800.0]),
    volume=np.ones(100) * 1e-6,
)

ParticleData dataclass

ParticleData(masses: NDArray[float64], concentration: NDArray[float64], charge: NDArray[float64], density: NDArray[float64], volume: NDArray[float64])

Batched particle data container for multi-box simulations.

Simple data container with batch dimension built-in. All per-particle arrays have shape (n_boxes, n_particles, ...) to support multi-box CFD. Single-box simulations use n_boxes=1.

This is NOT a frozen dataclass - arrays can be updated in place for performance in tight simulation loops. Use copy() if immutability needed.

Attributes:

  • masses (NDArray[float64]) –

    Per-species masses in kg. Shape: (n_boxes, n_particles, n_species)

  • concentration (NDArray[float64]) –

    Number concentration per particle. Shape: (n_boxes, n_particles) For particle-resolved: actual count (typically 1). For binned: number per m^3.

  • charge (NDArray[float64]) –

    Particle charges (dimensionless integer counts). Shape: (n_boxes, n_particles)

  • density (NDArray[float64]) –

    Material densities in kg/m^3. Shape: (n_species,) - shared across all boxes

  • volume (NDArray[float64]) –

    Simulation volume per box in m^3. Shape: (n_boxes,)

Raises:

  • ValueError

    If array shapes are inconsistent.

effective_density property

effective_density: NDArray[float64]

Mass-weighted effective density per particle.

Returns:

  • NDArray[float64]

    Effective density in kg/m^3 with shape (n_boxes, n_particles).

mass_fractions property

mass_fractions: NDArray[float64]

Mass fractions per species for each particle.

Returns:

  • NDArray[float64]

    Mass fractions with shape (n_boxes, n_particles, n_species).

n_boxes property

n_boxes: int

Number of simulation boxes.

Returns:

  • int

    The size of the batch dimension (n_boxes).

n_particles property

n_particles: int

Number of particles per box.

Returns:

  • int

    The number of particles (n_particles).

n_species property

n_species: int

Number of chemical species.

Returns:

  • int

    The number of species (n_species).

radii property

radii: NDArray[float64]

Particle radii derived from mass and density.

Returns:

  • NDArray[float64]

    Radii in meters with shape (n_boxes, n_particles).

total_mass property

total_mass: NDArray[float64]

Total mass per particle.

Returns:

  • NDArray[float64]

    Total mass in kilograms with shape (n_boxes, n_particles).

__post_init__

__post_init__() -> None

Validate array shapes are consistent.

Source code in particula/particles/particle_data.py
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
def __post_init__(self) -> None:
    """Validate array shapes are consistent."""
    # Validate masses is 3D
    if self.masses.ndim != 3:
        raise ValueError(
            "masses must be 3D (n_boxes, n_particles, n_species), "
            f"got shape {self.masses.shape}"
        )

    # Extract batch dimensions from masses
    n_boxes = self.masses.shape[0]
    n_particles = self.masses.shape[1]
    n_species = self.masses.shape[2]

    # Validate concentration shape (n_boxes, n_particles)
    expected_2d = (n_boxes, n_particles)
    if self.concentration.shape != expected_2d:
        raise ValueError(
            f"concentration shape {self.concentration.shape} doesn't match "
            f"expected {expected_2d}"
        )

    # Validate charge shape (n_boxes, n_particles)
    if self.charge.shape != expected_2d:
        raise ValueError(
            f"charge shape {self.charge.shape} doesn't match "
            f"expected {expected_2d}"
        )

    # Validate volume shape (n_boxes,)
    expected_1d = (n_boxes,)
    if self.volume.shape != expected_1d:
        raise ValueError(
            f"volume shape {self.volume.shape} doesn't match "
            f"expected {expected_1d}"
        )

    # Validate density is 1D (n_species,)
    if self.density.ndim != 1:
        raise ValueError(
            f"density must be 1D (n_species,), "
            f"got shape {self.density.shape}"
        )

    # Validate n_species matches between masses and density
    if self.density.shape[0] != n_species:
        raise ValueError(
            f"n_species mismatch: masses has {n_species} species, "
            f"but density has {self.density.shape[0]} species"
        )

copy

copy() -> ParticleData

Create a deep copy of this ParticleData.

Returns:

  • ParticleData

    A new ParticleData instance with copied arrays.

Source code in particula/particles/particle_data.py
214
215
216
217
218
219
220
221
222
223
224
225
226
def copy(self) -> "ParticleData":
    """Create a deep copy of this ParticleData.

    Returns:
        A new ParticleData instance with copied arrays.
    """
    return ParticleData(
        masses=np.copy(self.masses),
        concentration=np.copy(self.concentration),
        charge=np.copy(self.charge),
        density=np.copy(self.density),
        volume=np.copy(self.volume),
    )

from_representation

from_representation(representation: ParticleRepresentation, n_boxes: int = 1) -> ParticleData

Convert a ParticleRepresentation to batched ParticleData.

Uses raw concentration and charge arrays (no volume scaling) to avoid double-division for ParticleResolved strategies. Per-species masses are tiled across boxes to match the ParticleData batch dimension.

Example

data = from_representation(rep, n_boxes=2) data.masses.shape (2, rep.get_species_mass().shape[0], rep.get_species_mass().shape[1])

Parameters:

  • representation (ParticleRepresentation) –

    Source representation with per-species mass and concentration/charge arrays.

  • n_boxes (int, default: 1 ) –

    Number of boxes to replicate the representation across.

Returns:

Source code in particula/particles/particle_data.py
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
def from_representation(
    representation: ParticleRepresentation,
    n_boxes: int = 1,
) -> ParticleData:
    """Convert a ParticleRepresentation to batched ParticleData.

    Uses raw concentration and charge arrays (no volume scaling) to avoid
    double-division for ParticleResolved strategies. Per-species masses are
    tiled across boxes to match the ParticleData batch dimension.

    Example:
        >>> data = from_representation(rep, n_boxes=2)
        >>> data.masses.shape
        (2, rep.get_species_mass().shape[0], rep.get_species_mass().shape[1])

    Args:
        representation: Source representation with per-species mass and
            concentration/charge arrays.
        n_boxes: Number of boxes to replicate the representation across.

    Returns:
        ParticleData: Batched masses, concentration, charge, density, and
        volume.
    """
    masses_raw = representation.get_species_mass(clone=True)
    density = np.atleast_1d(representation.density)

    if masses_raw.ndim == 1:
        if density.size == masses_raw.size:
            masses = np.tile(masses_raw, (masses_raw.size, 1))
        else:
            masses = masses_raw[:, np.newaxis]
    elif masses_raw.ndim == 2:
        masses = masses_raw
    else:
        raise ValueError(
            "representation.get_species_mass() must be 1D or 2D; "
            f"got ndim={masses_raw.ndim}"
        )

    concentration = np.asarray(representation.concentration)
    charge = np.asarray(representation.charge)
    volume = np.full((n_boxes,), representation.volume)

    if masses.shape[1] != density.shape[0]:
        raise ValueError(
            "n_species mismatch: representation masses and density must "
            f"align, got masses n_species={masses.shape[1]} and "
            f"density n_species={density.shape[0]}"
        )

    masses = np.tile(masses[np.newaxis, ...], (n_boxes, 1, 1))
    concentration = np.tile(concentration[np.newaxis, ...], (n_boxes, 1))
    charge = np.tile(charge[np.newaxis, ...], (n_boxes, 1))

    return ParticleData(
        masses=masses,
        concentration=concentration,
        charge=charge,
        density=density,
        volume=volume,
    )

to_representation

to_representation(data: ParticleData, strategy: MassBasedMovingBin | RadiiBasedMovingBin | SpeciatedMassMovingBin | ParticleResolvedSpeciatedMass, activity: ActivityStrategy, surface: SurfaceStrategy, box_index: int = 0) -> ParticleRepresentation

Convert ParticleData back to a ParticleRepresentation for one box.

Parameters:

Returns:

Raises:

  • ValueError

    If box_index is out of range.

Source code in particula/particles/particle_data.py
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
def to_representation(
    data: ParticleData,
    strategy: MassBasedMovingBin
    | RadiiBasedMovingBin
    | SpeciatedMassMovingBin
    | ParticleResolvedSpeciatedMass,
    activity: ActivityStrategy,
    surface: SurfaceStrategy,
    box_index: int = 0,
) -> ParticleRepresentation:
    """Convert ParticleData back to a ParticleRepresentation for one box.

    Args:
        data: Batched particle data.
        strategy: Distribution strategy to use for the reconstructed
            representation.
        activity: Activity strategy.
        surface: Surface strategy.
        box_index: Index of the box to extract.

    Returns:
        ParticleRepresentation: Representation for the selected box.

    Raises:
        ValueError: If box_index is out of range.
    """
    if box_index < 0 or box_index >= data.n_boxes:
        raise ValueError(
            f"box_index {box_index} out of range for {data.n_boxes} boxes"
        )

    masses = data.masses[box_index]
    concentration = data.concentration[box_index]
    charge = data.charge[box_index]
    volume = float(data.volume[box_index])
    density = data.density

    if isinstance(strategy, MassBasedMovingBin):
        distribution = masses.sum(axis=1)
    elif isinstance(strategy, RadiiBasedMovingBin):
        distribution = data.radii[box_index]
    elif isinstance(strategy, SpeciatedMassMovingBin):
        distribution = masses
    elif isinstance(strategy, ParticleResolvedSpeciatedMass):
        distribution = masses
    else:
        raise TypeError(
            "Unsupported distribution strategy type: "
            f"{strategy.__class__.__name__}"
        )

    return ParticleRepresentation(
        strategy=strategy,
        activity=activity,
        surface=surface,
        distribution=distribution,
        density=density,
        concentration=concentration,
        charge=charge,
        volume=volume,
    )