Skip to content

Units & Normalization

Normalization systems for PIC and MHD plasma simulations. Provides round-trip conversion between code units and SI via frozen dataclasses.

units

Normalization systems for PIC and MHD plasma simulations.

UnitSystem

Bases: StrEnum

How a Normalization's eight references were anchored.

The vocabulary of [units].anchor in simulation.toml (Schema § 2). It names a derivation, not a code type — which code produced the data is [model].type, a separate field. Normalization.system is None when no [units] section declared an anchor at all; see Normalization.undeclared.

FROM_SPECIES derives the length unit from a reference species' plasma frequency; EXPLICIT takes it as given; SI is the identity anchor of data already in SI.

Examples:

>>> UnitSystem.FROM_SPECIES == "from_species"
True
Source code in src/pypic/units.py
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
class UnitSystem(StrEnum):
    """How a `Normalization`'s eight references were anchored.

    The vocabulary of ``[units].anchor`` in ``simulation.toml``
    (Schema § 2).  It names a *derivation*, not a code type — which
    code produced the data is ``[model].type``, a separate field.
    `Normalization.system` is ``None`` when no ``[units]`` section
    declared an anchor at all; see `Normalization.undeclared`.

    ``FROM_SPECIES`` derives the length unit from a reference species'
    plasma frequency; ``EXPLICIT`` takes it as given; ``SI`` is the
    identity anchor of data already in SI.

    Examples
    --------
    >>> UnitSystem.FROM_SPECIES == "from_species"
    True
    """

    FROM_SPECIES = "from_species"
    EXPLICIT = "explicit"
    SI = "si"

Normalization dataclass

Map between simulation (code) units and SI.

Every simulation uses a set of reference quantities to non-dimensionalize the equations. This class stores those reference values (all in SI) and provides normalize / to_si methods for each physical quantity.

Use the classmethods to construct standard normalizations:

  • pic_electron / pic_standard for PIC codes
  • mhd_standard for MHD codes
  • identity for data already in SI or dimensionless tests

Parameters:

Name Type Description Default
length_ref float

Reference length in meters (\(d_e\) for PIC, \(l_0\) for MHD).

required
time_ref float

Reference time in seconds (\(1/\omega_{ref}\) for PIC, \(l_0/v_A\) for MHD).

required
velocity_ref float

Reference velocity in m/s (\(c\) for PIC, \(v_A\) for MHD).

required
b_field_ref float

Reference magnetic field in Tesla.

required
e_field_ref float

Reference electric field in V/m.

required
density_ref float

Reference number density in m\(^{-3}\).

required
mass_ref float

Reference particle mass in kg.

required
charge_ref float

Reference charge in Coulombs.

required
system UnitSystem or None

Which normalization system these references came from, or None when nothing declared one. Defaults to UnitSystem.EXPLICIT, the honest reading of eight hand-supplied references. None makes every dimensional si_factor raise rather than silently return 1.0 — see undeclared.

EXPLICIT

Examples:

>>> norm = Normalization.identity()
>>> norm.normalize("length", 3.0)
3.0
Source code in src/pypic/units.py
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
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
291
292
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
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
@dataclass(frozen=True, slots=True)
class Normalization:
    r"""Map between simulation (code) units and SI.

    Every simulation uses a set of reference quantities to non-dimensionalize
    the equations. This class stores those reference values (all in SI) and
    provides ``normalize`` / ``to_si`` methods for each physical quantity.

    Use the classmethods to construct standard normalizations:

    - `pic_electron` / `pic_standard` for PIC codes
    - `mhd_standard` for MHD codes
    - `identity` for data already in SI or dimensionless tests

    Parameters
    ----------
    length_ref : float
        Reference length in meters ($d_e$ for PIC, $l_0$ for MHD).
    time_ref : float
        Reference time in seconds ($1/\omega_{ref}$ for PIC, $l_0/v_A$ for MHD).
    velocity_ref : float
        Reference velocity in m/s ($c$ for PIC, $v_A$ for MHD).
    b_field_ref : float
        Reference magnetic field in Tesla.
    e_field_ref : float
        Reference electric field in V/m.
    density_ref : float
        Reference number density in m$^{-3}$.
    mass_ref : float
        Reference particle mass in kg.
    charge_ref : float
        Reference charge in Coulombs.
    system : UnitSystem or None
        Which normalization system these references came from, or
        ``None`` when nothing declared one. Defaults to
        ``UnitSystem.EXPLICIT``, the honest reading of eight
        hand-supplied references. ``None`` makes every dimensional
        `si_factor` raise rather than silently return 1.0 — see
        `undeclared`.

    Examples
    --------
    >>> norm = Normalization.identity()
    >>> norm.normalize("length", 3.0)
    3.0
    """

    length_ref: float
    time_ref: float
    velocity_ref: float
    b_field_ref: float
    e_field_ref: float
    density_ref: float
    mass_ref: float
    charge_ref: float
    system: UnitSystem | None = UnitSystem.EXPLICIT

    def __post_init__(self) -> None:
        for attr in (
            "length_ref",
            "time_ref",
            "velocity_ref",
            "b_field_ref",
            "e_field_ref",
            "density_ref",
            "mass_ref",
            "charge_ref",
        ):
            val = getattr(self, attr)
            if val <= 0:
                msg = f"{attr} must be positive, got {val}"
                raise ValueError(msg)

    @classmethod
    def pic_standard(
        cls,
        reference_density: float,
        reference_mass: float,
        reference_charge: float,
        c: float = constants.c,
        reference_velocity: float | None = None,
    ) -> Normalization:
        r"""Construct PIC normalization for an arbitrary reference species.

        Derives all reference quantities from the plasma frequency of the
        reference species:

        $$\omega_{ref} = \sqrt{\frac{n_{ref} \, q_{ref}^2}{\varepsilon_0 \, m_{ref}}}$$

        The skin depth $l_{ref} = c / \omega_{ref}$ anchors length, and
        the remaining references follow from the velocity unit:
        $t_{ref} = l_{ref} / v_{ref}$,
        $B_{ref} = v_{ref}\sqrt{\mu_0 n_{ref} m_{ref}}$, and
        $E_{ref} = v_{ref} B_{ref}$.  At the default $v_{ref} = c$ these
        reduce to $1/\omega_{ref}$ and $m_{ref}\omega_{ref}/q_{ref}$.

        Parameters
        ----------
        reference_density : float
            Number density of the reference species in m$^{-3}$.
        reference_mass : float
            Mass of the reference species in kg.
        reference_charge : float
            Charge of the reference species in Coulombs.
        c : float, optional
            Speed of light in m/s. Defaults to ``scipy.constants.c``.
        reference_velocity : float or None, optional
            Velocity unit in m/s. ``None`` (the default) uses *c*, the
            PIC convention.  Hybrid codes normalize to the Alfvén speed
            instead; passing it makes $B_{ref}$ the field at which $v_A$
            equals that speed, which lands $t_{ref}$ on the inverse ion
            cyclotron frequency — the hybrid time unit.

        Returns
        -------
        Normalization
            PIC normalization instance with all fields in SI.

        Examples
        --------
        >>> from scipy.constants import m_e, e, c
        >>> norm = Normalization.pic_standard(1e18, m_e, e, c)
        >>> bool(np.isclose(norm.normalize("velocity", c), 1.0, rtol=1e-12))
        True
        """
        omega_ref = np.sqrt(
            reference_density
            * reference_charge**2
            / (constants.epsilon_0 * reference_mass)
        )
        length_ref = c / omega_ref
        if reference_velocity is None:
            velocity_ref = c
            # Algebraically equal to the general forms below at v = c;
            # kept verbatim so the default path stays bit-identical.
            time_ref = 1.0 / omega_ref
            b_field_ref = reference_mass * omega_ref / reference_charge
        else:
            velocity_ref = reference_velocity
            time_ref = length_ref / velocity_ref
            b_field_ref = velocity_ref * np.sqrt(
                constants.mu_0 * reference_density * reference_mass
            )
        # From E = -v x B.
        e_field_ref = velocity_ref * b_field_ref

        return cls(
            length_ref=float(length_ref),
            time_ref=float(time_ref),
            velocity_ref=float(velocity_ref),
            b_field_ref=float(b_field_ref),
            e_field_ref=float(e_field_ref),
            density_ref=float(reference_density),
            mass_ref=float(reference_mass),
            charge_ref=float(reference_charge),
            system=UnitSystem.FROM_SPECIES,
        )

    @classmethod
    def pic_electron(cls, n_e: float) -> Normalization:
        r"""PIC normalization using electron parameters.

        Convenience wrapper around `pic_standard` with $m_e$, $e$, $c$ from
        ``scipy.constants``.

        Parameters
        ----------
        n_e : float
            Electron number density in m$^{-3}$.

        Returns
        -------
        Normalization
            Electron-scale PIC normalization.

        Examples
        --------
        >>> norm = Normalization.pic_electron(1e18)
        >>> bool(np.isclose(norm.length_ref, constants.c / np.sqrt(
        ...     1e18 * constants.e**2 / (constants.epsilon_0 * constants.m_e)
        ... ), rtol=1e-12))
        True
        """
        return cls.pic_standard(n_e, constants.m_e, constants.e)

    @classmethod
    def mhd_standard(
        cls,
        reference_length: float,
        reference_density: float,
        reference_b_field: float,
    ) -> Normalization:
        r"""MHD normalization from macroscopic reference quantities.

        Derives the Alfvén speed:

        $$v_A = \frac{B_0}{\sqrt{\mu_0 \, \rho_0}}$$

        Parameters
        ----------
        reference_length : float
            Reference length $l_0$ in meters.
        reference_density : float
            Reference **mass** density $\rho_0$ in kg/m$^3$. The TOML
            spelling is ``reference_mass_density``, which names its unit;
            this parameter keeps the older name for callers that already
            pass it positionally.
        reference_b_field : float
            Reference magnetic field $B_0$ in Tesla.

        Returns
        -------
        Normalization
            MHD normalization instance.

        Examples
        --------
        >>> norm = Normalization.mhd_standard(1e6, 1e-12, 1e-9)
        >>> norm.length_ref
        1000000.0
        """
        v_a = reference_b_field / np.sqrt(constants.mu_0 * reference_density)

        return cls(
            length_ref=float(reference_length),
            time_ref=float(reference_length / v_a),
            velocity_ref=float(v_a),
            b_field_ref=float(reference_b_field),
            e_field_ref=float(v_a * reference_b_field),
            # Convert mass density → number density (reference species: proton)
            density_ref=float(reference_density / constants.m_p),
            mass_ref=float(constants.m_p),
            charge_ref=float(constants.e),
            system=UnitSystem.EXPLICIT,
        )

    @classmethod
    def identity(cls) -> Normalization:
        r"""Return normalization where all reference values are unity.

        Declares the data to be **already SI** — the runtime form of
        ``[units] system = "SI"``. Use `undeclared` instead when no
        unit system is known; the two carry the same eight references
        and differ only in `system`.

        Returns
        -------
        Normalization
            Identity normalization (all refs = 1.0, ``system = SI``).

        Examples
        --------
        >>> norm = Normalization.identity()
        >>> norm.length_ref
        1.0
        >>> norm.si_factor("b_field")
        1.0
        """
        return cls(
            length_ref=1.0,
            time_ref=1.0,
            velocity_ref=1.0,
            b_field_ref=1.0,
            e_field_ref=1.0,
            density_ref=1.0,
            mass_ref=1.0,
            charge_ref=1.0,
            system=UnitSystem.SI,
        )

    @classmethod
    def undeclared(cls) -> Normalization:
        r"""Return the normalization of data whose unit system is unknown.

        What a reader falls back to when no ``simulation.toml``
        accompanies the output. The eight references are unity so the
        arrays are left alone, but `system` is ``None``, so asking for
        a dimensional SI conversion raises
        `UndeclaredNormalizationError` instead of returning code units
        labelled tesla. Dimensionless quantities still convert.

        The absent reference is not recoverable: a PIC deck fixes only
        dimensionless ratios ($\omega_{pe}/\omega_{ce}$, $m_i/m_e$,
        $c/v_A$), so the SI anchor is the modeller's interpretation
        and belongs in ``[units]``.

        Returns
        -------
        Normalization
            All refs = 1.0, ``system = None``.

        Examples
        --------
        >>> norm = Normalization.undeclared()
        >>> norm.si_factor("dimensionless")  # correct under any anchor
        1.0
        >>> norm.si_factor("b_field")
        Traceback (most recent call last):
        pypic.exceptions.UndeclaredNormalizationError: ...
        """
        return cls(
            length_ref=1.0,
            time_ref=1.0,
            velocity_ref=1.0,
            b_field_ref=1.0,
            e_field_ref=1.0,
            density_ref=1.0,
            mass_ref=1.0,
            charge_ref=1.0,
            system=None,
        )

    @property
    def is_identity(self) -> bool:
        """True if all reference values are 1.0 (no unit conversion).

        Examples
        --------
        >>> Normalization.identity().is_identity
        True
        """
        return all(
            getattr(self, f"{q}_ref") == 1.0
            for q in (
                "length",
                "time",
                "velocity",
                "b_field",
                "e_field",
                "density",
                "mass",
                "charge",
            )
        )

    @property
    def rationalization_ratio(self) -> float:
        r"""How far these references sit from SI-rationalized code units.

        $$\frac{B_{ref}^2}{\mu_0 \, n_{ref} \, m_{ref} \, v_{ref}^2}$$

        `pypic.derived` computes in SI-rationalized units throughout —
        $e_B = B^2/2$, $\nabla\cdot\mathbf{E} = \rho_c$ — which holds
        exactly when this ratio is 1.  Every EM quantity is wrong by a
        power of it when it is not, so its **value names the
        convention** rather than grading it:

        ============================  ===================
        Ratio                          Convention
        ============================  ===================
        1                              SI-rationalized
        $1/\mu_0$                      data already in SI
        $(c_{SI}/c_{ref})^2$           a reduced speed of light
        $2/\beta$                      gyrokinetic (gyro-Bohm)
        ============================  ===================

        A diagnostic, not a gate: the last two are deliberate physics.

        Examples
        --------
        >>> from scipy import constants
        >>> n = Normalization.pic_electron(1e18)
        >>> bool(np.isclose(n.rationalization_ratio, 1.0, rtol=1e-9))
        True
        >>> bool(np.isclose(
        ...     Normalization.identity().rationalization_ratio,
        ...     1.0 / constants.mu_0,
        ... ))
        True
        """
        return self.b_field_ref**2 / (
            constants.mu_0 * self.density_ref * self.mass_ref * self.velocity_ref**2
        )

    def summary(self) -> str:
        r"""One-line description of the unit system and its SI anchor.

        Shared by ``Simulation.describe()`` and ``pypic info`` so the
        two report a dataset's units identically. The undeclared case
        is spelled out rather than shown as unit references, since
        those read as SI when nothing established the anchor.

        The ``si`` anchor gets the rationalization annotation too, and
        needs it most: all eight references are 1.0, so its ratio is
        $1/\mu_0$ and every quantity carrying a vacuum constant is wrong
        by a power of it. Naming ``data_in_si`` there points at the fix
        rather than only at the symptom.

        Examples
        --------
        >>> Normalization.undeclared().summary()
        'undeclared (code units; no [units] section)'
        >>> Normalization.identity().summary()[:14]
        'SI (identity) '
        """
        if self.system is None:
            return "undeclared (code units; no [units] section)"
        if self.system is UnitSystem.SI and self.is_identity:
            return (
                "SI (identity) [not SI-rationalized: "
                f"B^2/(mu_0 n m v^2) = {self.rationalization_ratio:.4g}; "
                "derived quantities assume mu_0 = 1, so declare a real anchor "
                "with [units].data_in_si to compute on this data]"
            )
        line = (
            f"{self.system}: l={self.length_ref:.4g} m, "
            f"v={self.velocity_ref:.4g} m/s, "
            f"B={self.b_field_ref:.4g} T, n={self.density_ref:.4g} m^-3"
        )
        # The one property of a reference set that its numbers do not show.
        ratio = self.rationalization_ratio
        if not math.isclose(ratio, 1.0, rel_tol=1e-2):
            line += f" [not SI-rationalized: B^2/(mu_0 n m v^2) = {ratio:.4g}]"
        return line

    def _reference_value(self, quantity: str) -> float:
        """Look up the reference value for *quantity*, or raise ValueError."""
        if quantity not in _QUANTITIES:
            msg = f"Unknown quantity {quantity!r}. Valid: {sorted(_QUANTITIES)}"
            raise ValueError(msg)
        ref: float = getattr(self, f"{quantity}_ref")
        return ref

    def si_factor(self, quantity: str) -> float:
        r"""Return the SI conversion factor for a physical quantity.

        Handles both base quantities (``"length"``, ``"b_field"``, etc.) and
        compound quantities (``"pressure"``, ``"frequency"``, etc.) that are
        products of base reference values.

        Parameters
        ----------
        quantity : str
            Physical quantity name — base or compound.

        Returns
        -------
        float
            Multiplicative factor: ``value_si = value_code * si_factor``.

        Raises
        ------
        UndeclaredNormalizationError
            When `system` is ``None`` and *quantity* is dimensional.
            This is the single chokepoint under `FieldDataset.in_si`,
            `FieldDataset.in_units` and
            [`field_si_factor`][pypic.field_si_factor].
        ValueError
            When *quantity* names neither a base nor a compound
            quantity.

        Examples
        --------
        >>> Normalization.identity().si_factor("velocity")
        1.0
        >>> Normalization.identity().si_factor("dimensionless")
        1.0
        """
        if self.system is None and quantity != _DIMENSIONLESS:
            msg = (
                f"Cannot convert {quantity!r} to SI: no unit system was declared "
                "for this data, so the SI anchor is unknown and code units would "
                "be returned labelled as SI. Ship a simulation.toml with a "
                "[units] section, pass normalization= when opening the data, or "
                'ask for code units (units="code").'
            )
            raise UndeclaredNormalizationError(msg)
        try:
            spec = _SI_FACTORS[quantity]
        except KeyError:
            msg = f"Unknown quantity {quantity!r}. Valid: {sorted(_SI_FACTORS)}"
            raise ValueError(msg) from None
        factor = math.prod(
            getattr(self, f"{name}_ref") ** power
            for name, power in zip(_REFERENCE_ORDER, spec.exponents, strict=True)
            if power
        )
        if spec.mu_0_power:
            factor *= constants.mu_0**spec.mu_0_power
        return float(factor)

    def normalize(self, quantity: str, x: Numeric) -> Numeric:
        r"""Convert a physical quantity from SI to code units.

        $$\hat{x} = x / x_{ref}$$

        Parameters
        ----------
        quantity : str
            One of the eight storage primitives: ``"length"``, ``"time"``,
            ``"velocity"``, ``"b_field"``, ``"e_field"``, ``"density"``,
            ``"mass"``, ``"charge"``.
        x : Numeric
            Value in SI units.

        Returns
        -------
        Numeric
            Value in code units.

        Examples
        --------
        >>> Normalization.identity().normalize("length", 5.0)
        5.0
        """
        return x / self._reference_value(quantity)

    def to_si(self, quantity: str, x: Numeric) -> Numeric:
        r"""Convert a physical quantity from code units to SI.

        $$x = \hat{x} \cdot x_{ref}$$

        Parameters
        ----------
        quantity : str
            One of the eight storage primitives: ``"length"``, ``"time"``,
            ``"velocity"``, ``"b_field"``, ``"e_field"``, ``"density"``,
            ``"mass"``, ``"charge"``.
        x : Numeric
            Value in code units.

        Returns
        -------
        Numeric
            Value in SI units.

        Examples
        --------
        >>> Normalization.identity().to_si("length", 5.0)
        5.0
        """
        return x * self._reference_value(quantity)

is_identity property

True if all reference values are 1.0 (no unit conversion).

Examples:

>>> Normalization.identity().is_identity
True

rationalization_ratio property

How far these references sit from SI-rationalized code units.

\[\frac{B_{ref}^2}{\mu_0 \, n_{ref} \, m_{ref} \, v_{ref}^2}\]

pypic.derived computes in SI-rationalized units throughout — \(e_B = B^2/2\), \(\nabla\cdot\mathbf{E} = \rho_c\) — which holds exactly when this ratio is 1. Every EM quantity is wrong by a power of it when it is not, so its value names the convention rather than grading it:

============================ =================== Ratio Convention ============================ =================== 1 SI-rationalized \(1/\mu_0\) data already in SI \((c_{SI}/c_{ref})^2\) a reduced speed of light \(2/\beta\) gyrokinetic (gyro-Bohm) ============================ ===================

A diagnostic, not a gate: the last two are deliberate physics.

Examples:

>>> from scipy import constants
>>> n = Normalization.pic_electron(1e18)
>>> bool(np.isclose(n.rationalization_ratio, 1.0, rtol=1e-9))
True
>>> bool(np.isclose(
...     Normalization.identity().rationalization_ratio,
...     1.0 / constants.mu_0,
... ))
True

pic_standard(reference_density, reference_mass, reference_charge, c=constants.c, reference_velocity=None) classmethod

Construct PIC normalization for an arbitrary reference species.

Derives all reference quantities from the plasma frequency of the reference species:

\[\omega_{ref} = \sqrt{\frac{n_{ref} \, q_{ref}^2}{\varepsilon_0 \, m_{ref}}}\]

The skin depth \(l_{ref} = c / \omega_{ref}\) anchors length, and the remaining references follow from the velocity unit: \(t_{ref} = l_{ref} / v_{ref}\), \(B_{ref} = v_{ref}\sqrt{\mu_0 n_{ref} m_{ref}}\), and \(E_{ref} = v_{ref} B_{ref}\). At the default \(v_{ref} = c\) these reduce to \(1/\omega_{ref}\) and \(m_{ref}\omega_{ref}/q_{ref}\).

Parameters:

Name Type Description Default
reference_density float

Number density of the reference species in m\(^{-3}\).

required
reference_mass float

Mass of the reference species in kg.

required
reference_charge float

Charge of the reference species in Coulombs.

required
c float

Speed of light in m/s. Defaults to scipy.constants.c.

c
reference_velocity float or None

Velocity unit in m/s. None (the default) uses c, the PIC convention. Hybrid codes normalize to the Alfvén speed instead; passing it makes \(B_{ref}\) the field at which \(v_A\) equals that speed, which lands \(t_{ref}\) on the inverse ion cyclotron frequency — the hybrid time unit.

None

Returns:

Type Description
Normalization

PIC normalization instance with all fields in SI.

Examples:

>>> from scipy.constants import m_e, e, c
>>> norm = Normalization.pic_standard(1e18, m_e, e, c)
>>> bool(np.isclose(norm.normalize("velocity", c), 1.0, rtol=1e-12))
True
Source code in src/pypic/units.py
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
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
@classmethod
def pic_standard(
    cls,
    reference_density: float,
    reference_mass: float,
    reference_charge: float,
    c: float = constants.c,
    reference_velocity: float | None = None,
) -> Normalization:
    r"""Construct PIC normalization for an arbitrary reference species.

    Derives all reference quantities from the plasma frequency of the
    reference species:

    $$\omega_{ref} = \sqrt{\frac{n_{ref} \, q_{ref}^2}{\varepsilon_0 \, m_{ref}}}$$

    The skin depth $l_{ref} = c / \omega_{ref}$ anchors length, and
    the remaining references follow from the velocity unit:
    $t_{ref} = l_{ref} / v_{ref}$,
    $B_{ref} = v_{ref}\sqrt{\mu_0 n_{ref} m_{ref}}$, and
    $E_{ref} = v_{ref} B_{ref}$.  At the default $v_{ref} = c$ these
    reduce to $1/\omega_{ref}$ and $m_{ref}\omega_{ref}/q_{ref}$.

    Parameters
    ----------
    reference_density : float
        Number density of the reference species in m$^{-3}$.
    reference_mass : float
        Mass of the reference species in kg.
    reference_charge : float
        Charge of the reference species in Coulombs.
    c : float, optional
        Speed of light in m/s. Defaults to ``scipy.constants.c``.
    reference_velocity : float or None, optional
        Velocity unit in m/s. ``None`` (the default) uses *c*, the
        PIC convention.  Hybrid codes normalize to the Alfvén speed
        instead; passing it makes $B_{ref}$ the field at which $v_A$
        equals that speed, which lands $t_{ref}$ on the inverse ion
        cyclotron frequency — the hybrid time unit.

    Returns
    -------
    Normalization
        PIC normalization instance with all fields in SI.

    Examples
    --------
    >>> from scipy.constants import m_e, e, c
    >>> norm = Normalization.pic_standard(1e18, m_e, e, c)
    >>> bool(np.isclose(norm.normalize("velocity", c), 1.0, rtol=1e-12))
    True
    """
    omega_ref = np.sqrt(
        reference_density
        * reference_charge**2
        / (constants.epsilon_0 * reference_mass)
    )
    length_ref = c / omega_ref
    if reference_velocity is None:
        velocity_ref = c
        # Algebraically equal to the general forms below at v = c;
        # kept verbatim so the default path stays bit-identical.
        time_ref = 1.0 / omega_ref
        b_field_ref = reference_mass * omega_ref / reference_charge
    else:
        velocity_ref = reference_velocity
        time_ref = length_ref / velocity_ref
        b_field_ref = velocity_ref * np.sqrt(
            constants.mu_0 * reference_density * reference_mass
        )
    # From E = -v x B.
    e_field_ref = velocity_ref * b_field_ref

    return cls(
        length_ref=float(length_ref),
        time_ref=float(time_ref),
        velocity_ref=float(velocity_ref),
        b_field_ref=float(b_field_ref),
        e_field_ref=float(e_field_ref),
        density_ref=float(reference_density),
        mass_ref=float(reference_mass),
        charge_ref=float(reference_charge),
        system=UnitSystem.FROM_SPECIES,
    )

pic_electron(n_e) classmethod

PIC normalization using electron parameters.

Convenience wrapper around pic_standard with \(m_e\), \(e\), \(c\) from scipy.constants.

Parameters:

Name Type Description Default
n_e float

Electron number density in m\(^{-3}\).

required

Returns:

Type Description
Normalization

Electron-scale PIC normalization.

Examples:

>>> norm = Normalization.pic_electron(1e18)
>>> bool(np.isclose(norm.length_ref, constants.c / np.sqrt(
...     1e18 * constants.e**2 / (constants.epsilon_0 * constants.m_e)
... ), rtol=1e-12))
True
Source code in src/pypic/units.py
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
@classmethod
def pic_electron(cls, n_e: float) -> Normalization:
    r"""PIC normalization using electron parameters.

    Convenience wrapper around `pic_standard` with $m_e$, $e$, $c$ from
    ``scipy.constants``.

    Parameters
    ----------
    n_e : float
        Electron number density in m$^{-3}$.

    Returns
    -------
    Normalization
        Electron-scale PIC normalization.

    Examples
    --------
    >>> norm = Normalization.pic_electron(1e18)
    >>> bool(np.isclose(norm.length_ref, constants.c / np.sqrt(
    ...     1e18 * constants.e**2 / (constants.epsilon_0 * constants.m_e)
    ... ), rtol=1e-12))
    True
    """
    return cls.pic_standard(n_e, constants.m_e, constants.e)

mhd_standard(reference_length, reference_density, reference_b_field) classmethod

MHD normalization from macroscopic reference quantities.

Derives the Alfvén speed:

\[v_A = \frac{B_0}{\sqrt{\mu_0 \, \rho_0}}\]

Parameters:

Name Type Description Default
reference_length float

Reference length \(l_0\) in meters.

required
reference_density float

Reference mass density \(\rho_0\) in kg/m\(^3\). The TOML spelling is reference_mass_density, which names its unit; this parameter keeps the older name for callers that already pass it positionally.

required
reference_b_field float

Reference magnetic field \(B_0\) in Tesla.

required

Returns:

Type Description
Normalization

MHD normalization instance.

Examples:

>>> norm = Normalization.mhd_standard(1e6, 1e-12, 1e-9)
>>> norm.length_ref
1000000.0
Source code in src/pypic/units.py
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
354
355
356
357
358
359
360
361
@classmethod
def mhd_standard(
    cls,
    reference_length: float,
    reference_density: float,
    reference_b_field: float,
) -> Normalization:
    r"""MHD normalization from macroscopic reference quantities.

    Derives the Alfvén speed:

    $$v_A = \frac{B_0}{\sqrt{\mu_0 \, \rho_0}}$$

    Parameters
    ----------
    reference_length : float
        Reference length $l_0$ in meters.
    reference_density : float
        Reference **mass** density $\rho_0$ in kg/m$^3$. The TOML
        spelling is ``reference_mass_density``, which names its unit;
        this parameter keeps the older name for callers that already
        pass it positionally.
    reference_b_field : float
        Reference magnetic field $B_0$ in Tesla.

    Returns
    -------
    Normalization
        MHD normalization instance.

    Examples
    --------
    >>> norm = Normalization.mhd_standard(1e6, 1e-12, 1e-9)
    >>> norm.length_ref
    1000000.0
    """
    v_a = reference_b_field / np.sqrt(constants.mu_0 * reference_density)

    return cls(
        length_ref=float(reference_length),
        time_ref=float(reference_length / v_a),
        velocity_ref=float(v_a),
        b_field_ref=float(reference_b_field),
        e_field_ref=float(v_a * reference_b_field),
        # Convert mass density → number density (reference species: proton)
        density_ref=float(reference_density / constants.m_p),
        mass_ref=float(constants.m_p),
        charge_ref=float(constants.e),
        system=UnitSystem.EXPLICIT,
    )

identity() classmethod

Return normalization where all reference values are unity.

Declares the data to be already SI — the runtime form of [units] system = "SI". Use undeclared instead when no unit system is known; the two carry the same eight references and differ only in system.

Returns:

Type Description
Normalization

Identity normalization (all refs = 1.0, system = SI).

Examples:

>>> norm = Normalization.identity()
>>> norm.length_ref
1.0
>>> norm.si_factor("b_field")
1.0
Source code in src/pypic/units.py
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
@classmethod
def identity(cls) -> Normalization:
    r"""Return normalization where all reference values are unity.

    Declares the data to be **already SI** — the runtime form of
    ``[units] system = "SI"``. Use `undeclared` instead when no
    unit system is known; the two carry the same eight references
    and differ only in `system`.

    Returns
    -------
    Normalization
        Identity normalization (all refs = 1.0, ``system = SI``).

    Examples
    --------
    >>> norm = Normalization.identity()
    >>> norm.length_ref
    1.0
    >>> norm.si_factor("b_field")
    1.0
    """
    return cls(
        length_ref=1.0,
        time_ref=1.0,
        velocity_ref=1.0,
        b_field_ref=1.0,
        e_field_ref=1.0,
        density_ref=1.0,
        mass_ref=1.0,
        charge_ref=1.0,
        system=UnitSystem.SI,
    )

undeclared() classmethod

Return the normalization of data whose unit system is unknown.

What a reader falls back to when no simulation.toml accompanies the output. The eight references are unity so the arrays are left alone, but system is None, so asking for a dimensional SI conversion raises UndeclaredNormalizationError instead of returning code units labelled tesla. Dimensionless quantities still convert.

The absent reference is not recoverable: a PIC deck fixes only dimensionless ratios (\(\omega_{pe}/\omega_{ce}\), \(m_i/m_e\), \(c/v_A\)), so the SI anchor is the modeller's interpretation and belongs in [units].

Returns:

Type Description
Normalization

All refs = 1.0, system = None.

Examples:

>>> norm = Normalization.undeclared()
>>> norm.si_factor("dimensionless")  # correct under any anchor
1.0
>>> norm.si_factor("b_field")
Traceback (most recent call last):
pypic.exceptions.UndeclaredNormalizationError: ...
Source code in src/pypic/units.py
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
@classmethod
def undeclared(cls) -> Normalization:
    r"""Return the normalization of data whose unit system is unknown.

    What a reader falls back to when no ``simulation.toml``
    accompanies the output. The eight references are unity so the
    arrays are left alone, but `system` is ``None``, so asking for
    a dimensional SI conversion raises
    `UndeclaredNormalizationError` instead of returning code units
    labelled tesla. Dimensionless quantities still convert.

    The absent reference is not recoverable: a PIC deck fixes only
    dimensionless ratios ($\omega_{pe}/\omega_{ce}$, $m_i/m_e$,
    $c/v_A$), so the SI anchor is the modeller's interpretation
    and belongs in ``[units]``.

    Returns
    -------
    Normalization
        All refs = 1.0, ``system = None``.

    Examples
    --------
    >>> norm = Normalization.undeclared()
    >>> norm.si_factor("dimensionless")  # correct under any anchor
    1.0
    >>> norm.si_factor("b_field")
    Traceback (most recent call last):
    pypic.exceptions.UndeclaredNormalizationError: ...
    """
    return cls(
        length_ref=1.0,
        time_ref=1.0,
        velocity_ref=1.0,
        b_field_ref=1.0,
        e_field_ref=1.0,
        density_ref=1.0,
        mass_ref=1.0,
        charge_ref=1.0,
        system=None,
    )

summary()

One-line description of the unit system and its SI anchor.

Shared by Simulation.describe() and pypic info so the two report a dataset's units identically. The undeclared case is spelled out rather than shown as unit references, since those read as SI when nothing established the anchor.

The si anchor gets the rationalization annotation too, and needs it most: all eight references are 1.0, so its ratio is \(1/\mu_0\) and every quantity carrying a vacuum constant is wrong by a power of it. Naming data_in_si there points at the fix rather than only at the symptom.

Examples:

>>> Normalization.undeclared().summary()
'undeclared (code units; no [units] section)'
>>> Normalization.identity().summary()[:14]
'SI (identity) '
Source code in src/pypic/units.py
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
def summary(self) -> str:
    r"""One-line description of the unit system and its SI anchor.

    Shared by ``Simulation.describe()`` and ``pypic info`` so the
    two report a dataset's units identically. The undeclared case
    is spelled out rather than shown as unit references, since
    those read as SI when nothing established the anchor.

    The ``si`` anchor gets the rationalization annotation too, and
    needs it most: all eight references are 1.0, so its ratio is
    $1/\mu_0$ and every quantity carrying a vacuum constant is wrong
    by a power of it. Naming ``data_in_si`` there points at the fix
    rather than only at the symptom.

    Examples
    --------
    >>> Normalization.undeclared().summary()
    'undeclared (code units; no [units] section)'
    >>> Normalization.identity().summary()[:14]
    'SI (identity) '
    """
    if self.system is None:
        return "undeclared (code units; no [units] section)"
    if self.system is UnitSystem.SI and self.is_identity:
        return (
            "SI (identity) [not SI-rationalized: "
            f"B^2/(mu_0 n m v^2) = {self.rationalization_ratio:.4g}; "
            "derived quantities assume mu_0 = 1, so declare a real anchor "
            "with [units].data_in_si to compute on this data]"
        )
    line = (
        f"{self.system}: l={self.length_ref:.4g} m, "
        f"v={self.velocity_ref:.4g} m/s, "
        f"B={self.b_field_ref:.4g} T, n={self.density_ref:.4g} m^-3"
    )
    # The one property of a reference set that its numbers do not show.
    ratio = self.rationalization_ratio
    if not math.isclose(ratio, 1.0, rel_tol=1e-2):
        line += f" [not SI-rationalized: B^2/(mu_0 n m v^2) = {ratio:.4g}]"
    return line

si_factor(quantity)

Return the SI conversion factor for a physical quantity.

Handles both base quantities ("length", "b_field", etc.) and compound quantities ("pressure", "frequency", etc.) that are products of base reference values.

Parameters:

Name Type Description Default
quantity str

Physical quantity name — base or compound.

required

Returns:

Type Description
float

Multiplicative factor: value_si = value_code * si_factor.

Raises:

Type Description
UndeclaredNormalizationError

When system is None and quantity is dimensional. This is the single chokepoint under FieldDataset.in_si, FieldDataset.in_units and field_si_factor.

ValueError

When quantity names neither a base nor a compound quantity.

Examples:

>>> Normalization.identity().si_factor("velocity")
1.0
>>> Normalization.identity().si_factor("dimensionless")
1.0
Source code in src/pypic/units.py
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
def si_factor(self, quantity: str) -> float:
    r"""Return the SI conversion factor for a physical quantity.

    Handles both base quantities (``"length"``, ``"b_field"``, etc.) and
    compound quantities (``"pressure"``, ``"frequency"``, etc.) that are
    products of base reference values.

    Parameters
    ----------
    quantity : str
        Physical quantity name — base or compound.

    Returns
    -------
    float
        Multiplicative factor: ``value_si = value_code * si_factor``.

    Raises
    ------
    UndeclaredNormalizationError
        When `system` is ``None`` and *quantity* is dimensional.
        This is the single chokepoint under `FieldDataset.in_si`,
        `FieldDataset.in_units` and
        [`field_si_factor`][pypic.field_si_factor].
    ValueError
        When *quantity* names neither a base nor a compound
        quantity.

    Examples
    --------
    >>> Normalization.identity().si_factor("velocity")
    1.0
    >>> Normalization.identity().si_factor("dimensionless")
    1.0
    """
    if self.system is None and quantity != _DIMENSIONLESS:
        msg = (
            f"Cannot convert {quantity!r} to SI: no unit system was declared "
            "for this data, so the SI anchor is unknown and code units would "
            "be returned labelled as SI. Ship a simulation.toml with a "
            "[units] section, pass normalization= when opening the data, or "
            'ask for code units (units="code").'
        )
        raise UndeclaredNormalizationError(msg)
    try:
        spec = _SI_FACTORS[quantity]
    except KeyError:
        msg = f"Unknown quantity {quantity!r}. Valid: {sorted(_SI_FACTORS)}"
        raise ValueError(msg) from None
    factor = math.prod(
        getattr(self, f"{name}_ref") ** power
        for name, power in zip(_REFERENCE_ORDER, spec.exponents, strict=True)
        if power
    )
    if spec.mu_0_power:
        factor *= constants.mu_0**spec.mu_0_power
    return float(factor)

normalize(quantity, x)

Convert a physical quantity from SI to code units.

\[\hat{x} = x / x_{ref}\]

Parameters:

Name Type Description Default
quantity str

One of the eight storage primitives: "length", "time", "velocity", "b_field", "e_field", "density", "mass", "charge".

required
x Numeric

Value in SI units.

required

Returns:

Type Description
Numeric

Value in code units.

Examples:

>>> Normalization.identity().normalize("length", 5.0)
5.0
Source code in src/pypic/units.py
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
def normalize(self, quantity: str, x: Numeric) -> Numeric:
    r"""Convert a physical quantity from SI to code units.

    $$\hat{x} = x / x_{ref}$$

    Parameters
    ----------
    quantity : str
        One of the eight storage primitives: ``"length"``, ``"time"``,
        ``"velocity"``, ``"b_field"``, ``"e_field"``, ``"density"``,
        ``"mass"``, ``"charge"``.
    x : Numeric
        Value in SI units.

    Returns
    -------
    Numeric
        Value in code units.

    Examples
    --------
    >>> Normalization.identity().normalize("length", 5.0)
    5.0
    """
    return x / self._reference_value(quantity)

to_si(quantity, x)

Convert a physical quantity from code units to SI.

\[x = \hat{x} \cdot x_{ref}\]

Parameters:

Name Type Description Default
quantity str

One of the eight storage primitives: "length", "time", "velocity", "b_field", "e_field", "density", "mass", "charge".

required
x Numeric

Value in code units.

required

Returns:

Type Description
Numeric

Value in SI units.

Examples:

>>> Normalization.identity().to_si("length", 5.0)
5.0
Source code in src/pypic/units.py
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
def to_si(self, quantity: str, x: Numeric) -> Numeric:
    r"""Convert a physical quantity from code units to SI.

    $$x = \hat{x} \cdot x_{ref}$$

    Parameters
    ----------
    quantity : str
        One of the eight storage primitives: ``"length"``, ``"time"``,
        ``"velocity"``, ``"b_field"``, ``"e_field"``, ``"density"``,
        ``"mass"``, ``"charge"``.
    x : Numeric
        Value in code units.

    Returns
    -------
    Numeric
        Value in SI units.

    Examples
    --------
    >>> Normalization.identity().to_si("length", 5.0)
    5.0
    """
    return x * self._reference_value(quantity)

PhysicsConstants dataclass

Simulation-frame physical constants in code units.

Stores the speed of light, vacuum permittivity, and vacuum permeability as used inside the simulation. PIC codes typically normalize all three to unity; MHD codes set \(c = \infty\) to eliminate displacement current.

Parameters:

Name Type Description Default
c float

Speed of light in code units.

required
epsilon_0 float

Vacuum permittivity in code units.

required
mu_0 float

Vacuum permeability in code units.

required

Examples:

>>> PhysicsConstants.pic_normalized().c
1.0
>>> PhysicsConstants.mhd_normalized().inv_c_squared()
0.0
Source code in src/pypic/units.py
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
@dataclass(frozen=True, slots=True)
class PhysicsConstants:
    r"""Simulation-frame physical constants in code units.

    Stores the speed of light, vacuum permittivity, and vacuum permeability
    as used inside the simulation. PIC codes typically normalize all three
    to unity; MHD codes set $c = \infty$ to eliminate displacement current.

    Parameters
    ----------
    c : float
        Speed of light in code units.
    epsilon_0 : float
        Vacuum permittivity in code units.
    mu_0 : float
        Vacuum permeability in code units.

    Examples
    --------
    >>> PhysicsConstants.pic_normalized().c
    1.0
    >>> PhysicsConstants.mhd_normalized().inv_c_squared()
    0.0
    """

    c: float
    epsilon_0: float
    mu_0: float

    @classmethod
    def pic_normalized(cls) -> PhysicsConstants:
        r"""Return PIC normalization where $c = \varepsilon_0 = \mu_0 = 1$.

        Returns
        -------
        PhysicsConstants
            PIC-normalized constants.

        Examples
        --------
        >>> pc = PhysicsConstants.pic_normalized()
        >>> pc.c, pc.epsilon_0, pc.mu_0
        (1.0, 1.0, 1.0)
        """
        return cls(c=1.0, epsilon_0=1.0, mu_0=1.0)

    @classmethod
    def mhd_normalized(cls) -> PhysicsConstants:
        r"""MHD normalization where $c = \infty$, eliminating displacement current.

        Returns
        -------
        PhysicsConstants
            MHD-normalized constants.

        Examples
        --------
        >>> pc = PhysicsConstants.mhd_normalized()
        >>> math.isinf(pc.c)
        True
        """
        return cls(c=math.inf, epsilon_0=1.0, mu_0=1.0)

    def inv_c_squared(self) -> float:
        r"""Return $1/c^2$, guarded against infinite $c$.

        Returns ``0.0`` when $c = \infty$ (MHD limit) to avoid
        ``inf * 0 = nan`` in displacement-current terms.

        Returns
        -------
        float
            $1/c^2$, or ``0.0`` if $c$ is infinite.

        Examples
        --------
        >>> PhysicsConstants.pic_normalized().inv_c_squared()
        1.0
        >>> PhysicsConstants(c=10.0, epsilon_0=1.0, mu_0=1.0).inv_c_squared()
        0.01
        """
        if math.isinf(self.c):
            return 0.0
        return 1.0 / self.c**2

pic_normalized() classmethod

Return PIC normalization where \(c = \varepsilon_0 = \mu_0 = 1\).

Returns:

Type Description
PhysicsConstants

PIC-normalized constants.

Examples:

>>> pc = PhysicsConstants.pic_normalized()
>>> pc.c, pc.epsilon_0, pc.mu_0
(1.0, 1.0, 1.0)
Source code in src/pypic/units.py
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
@classmethod
def pic_normalized(cls) -> PhysicsConstants:
    r"""Return PIC normalization where $c = \varepsilon_0 = \mu_0 = 1$.

    Returns
    -------
    PhysicsConstants
        PIC-normalized constants.

    Examples
    --------
    >>> pc = PhysicsConstants.pic_normalized()
    >>> pc.c, pc.epsilon_0, pc.mu_0
    (1.0, 1.0, 1.0)
    """
    return cls(c=1.0, epsilon_0=1.0, mu_0=1.0)

mhd_normalized() classmethod

MHD normalization where \(c = \infty\), eliminating displacement current.

Returns:

Type Description
PhysicsConstants

MHD-normalized constants.

Examples:

>>> pc = PhysicsConstants.mhd_normalized()
>>> math.isinf(pc.c)
True
Source code in src/pypic/units.py
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
@classmethod
def mhd_normalized(cls) -> PhysicsConstants:
    r"""MHD normalization where $c = \infty$, eliminating displacement current.

    Returns
    -------
    PhysicsConstants
        MHD-normalized constants.

    Examples
    --------
    >>> pc = PhysicsConstants.mhd_normalized()
    >>> math.isinf(pc.c)
    True
    """
    return cls(c=math.inf, epsilon_0=1.0, mu_0=1.0)

inv_c_squared()

Return \(1/c^2\), guarded against infinite \(c\).

Returns 0.0 when \(c = \infty\) (MHD limit) to avoid inf * 0 = nan in displacement-current terms.

Returns:

Type Description
float

\(1/c^2\), or 0.0 if \(c\) is infinite.

Examples:

>>> PhysicsConstants.pic_normalized().inv_c_squared()
1.0
>>> PhysicsConstants(c=10.0, epsilon_0=1.0, mu_0=1.0).inv_c_squared()
0.01
Source code in src/pypic/units.py
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
def inv_c_squared(self) -> float:
    r"""Return $1/c^2$, guarded against infinite $c$.

    Returns ``0.0`` when $c = \infty$ (MHD limit) to avoid
    ``inf * 0 = nan`` in displacement-current terms.

    Returns
    -------
    float
        $1/c^2$, or ``0.0`` if $c$ is infinite.

    Examples
    --------
    >>> PhysicsConstants.pic_normalized().inv_c_squared()
    1.0
    >>> PhysicsConstants(c=10.0, epsilon_0=1.0, mu_0=1.0).inv_c_squared()
    0.01
    """
    if math.isinf(self.c):
        return 0.0
    return 1.0 / self.c**2

SpeciesInfo dataclass

Per-species metadata for a plasma simulation.

At minimum, provide either charge + mass or charge_to_mass. The missing quantities are inferred automatically:

  • From charge + mass: \(q/m\) is computed directly.
  • From \(q/m\) alone: convention is \(|q| = 1\), \(m = 1/|q/m|\).
  • If all three are given, consistency is validated.

Parameters:

Name Type Description Default
name str

Species label (e.g. "e", "ion").

required
charge float | None

Charge in code units.

None
mass float | None

Mass in code units.

None
charge_to_mass float | None

Charge-to-mass ratio in code units.

None
temperature float | None

Temperature in code units.

None
thermal_velocity float | Vector3 | None

Scalar (isotropic) or per-component thermal velocity.

None
drift_velocity Vector3 | None

Bulk drift velocity \((v_x, v_y, v_z)\).

None
density float | None

Number density in code units.

None
particles_per_cell int | tuple[int, int, int] | None

Particles per cell, uniform or per-direction.

None

Examples:

>>> e = SpeciesInfo(name="e", charge=-1.0, mass=1/256)
>>> e.charge_to_mass
-256.0
>>> ion = SpeciesInfo(name="ion", charge_to_mass=1.0)
>>> ion.charge, ion.mass
(1.0, 1.0)
Source code in src/pypic/units.py
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
@dataclass(frozen=True, slots=True)
class SpeciesInfo:
    r"""Per-species metadata for a plasma simulation.

    At minimum, provide either ``charge`` + ``mass`` or ``charge_to_mass``.
    The missing quantities are inferred automatically:

    - From charge + mass: $q/m$ is computed directly.
    - From $q/m$ alone: convention is $|q| = 1$, $m = 1/|q/m|$.
    - If all three are given, consistency is validated.

    Parameters
    ----------
    name : str
        Species label (e.g. ``"e"``, ``"ion"``).
    charge : float | None
        Charge in code units.
    mass : float | None
        Mass in code units.
    charge_to_mass : float | None
        Charge-to-mass ratio in code units.
    temperature : float | None
        Temperature in code units.
    thermal_velocity : float | Vector3 | None
        Scalar (isotropic) or per-component thermal velocity.
    drift_velocity : Vector3 | None
        Bulk drift velocity $(v_x, v_y, v_z)$.
    density : float | None
        Number density in code units.
    particles_per_cell : int | tuple[int, int, int] | None
        Particles per cell, uniform or per-direction.

    Examples
    --------
    >>> e = SpeciesInfo(name="e", charge=-1.0, mass=1/256)
    >>> e.charge_to_mass
    -256.0
    >>> ion = SpeciesInfo(name="ion", charge_to_mass=1.0)
    >>> ion.charge, ion.mass
    (1.0, 1.0)
    """

    name: str
    charge: float | None = None
    mass: float | None = None
    charge_to_mass: float | None = None
    temperature: float | None = None
    thermal_velocity: float | Vector3 | None = None
    drift_velocity: Vector3 | None = None
    density: float | None = None
    particles_per_cell: int | tuple[int, int, int] | None = None

    def __post_init__(self) -> None:
        if self.mass is not None and self.mass < 0:
            msg = f"mass must not be negative, got {self.mass}"
            raise ValueError(msg)

        if (
            self.charge is not None
            and self.mass is not None
            and self.charge_to_mass is None
        ):
            # A massless fluid species has no finite charge-to-mass ratio.
            # Left unset rather than infinite: consumers already handle the
            # absent case, and an inf would propagate silently into moments.
            if self.mass != 0.0:
                object.__setattr__(self, "charge_to_mass", self.charge / self.mass)
        elif (
            self.charge_to_mass is not None
            and self.charge is None
            and self.mass is None
        ):
            if self.charge_to_mass == 0.0:
                msg = (
                    "Cannot decompose charge_to_mass=0 into charge and mass. "
                    "Specify charge=0 and mass explicitly."
                )
                raise ValueError(msg)
            # Convention: |q| = 1, sign from q/m, mass = 1/|q/m|
            object.__setattr__(self, "charge", math.copysign(1.0, self.charge_to_mass))
            object.__setattr__(self, "mass", 1.0 / abs(self.charge_to_mass))
        elif (
            self.charge is not None
            and self.mass is not None
            and self.charge_to_mass is not None
        ):
            if self.mass == 0.0:
                msg = (
                    "A massless species has no charge_to_mass; provide "
                    "charge and mass alone."
                )
                raise ValueError(msg)
            expected = self.charge / self.mass
            if not math.isclose(expected, self.charge_to_mass, rel_tol=1e-12):
                msg = (
                    f"Inconsistent species parameters: "
                    f"charge/mass={expected} != charge_to_mass={self.charge_to_mass}"
                )
                raise ValueError(msg)
        elif self.charge is None and self.mass is None and self.charge_to_mass is None:
            msg = "Must provide charge+mass or charge_to_mass (or all three)."
            raise ValueError(msg)
        else:
            provided = [
                name
                for name, val in [
                    ("charge", self.charge),
                    ("mass", self.mass),
                    ("charge_to_mass", self.charge_to_mass),
                ]
                if val is not None
            ]
            msg = (
                f"Incomplete species parameters: got {', '.join(provided)}. "
                f"Provide charge+mass, charge_to_mass alone, or all three."
            )
            raise ValueError(msg)

PhysicsParams dataclass

Typed physics parameters consumed by the compute pipeline.

Known fields have defaults matching the standard non-relativistic PIC/MHD conventions. Reader-specific parameters (iPIC3D theta, BATSRUS divb_method, etc.) go in extra.

Parameters:

Name Type Description Default
gamma float

Adiabatic index (\(\gamma = c_p / c_v\)).

5.0 / 3.0
c float

Speed of light in normalized units.

1.0
relativistic bool

Use relativistic formulas for derived quantities.

False
extra dict[str, Any]

Open-ended reader-specific parameters.

dict()

Examples:

>>> p = PhysicsParams()
>>> p.gamma
1.6666666666666667
>>> p.c
1.0
>>> p = PhysicsParams(gamma=1.4, extra={"eta": 0.01})
>>> p.extra["eta"]
0.01
Source code in src/pypic/units.py
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
@dataclass(frozen=True, slots=True)
class PhysicsParams:
    r"""Typed physics parameters consumed by the compute pipeline.

    Known fields have defaults matching the standard non-relativistic
    PIC/MHD conventions.  Reader-specific parameters (iPIC3D theta,
    BATSRUS divb_method, etc.) go in *extra*.

    Parameters
    ----------
    gamma : float
        Adiabatic index ($\gamma = c_p / c_v$).
    c : float
        Speed of light in normalized units.
    relativistic : bool
        Use relativistic formulas for derived quantities.
    extra : dict[str, Any]
        Open-ended reader-specific parameters.

    Examples
    --------
    >>> p = PhysicsParams()
    >>> p.gamma
    1.6666666666666667
    >>> p.c
    1.0
    >>> p = PhysicsParams(gamma=1.4, extra={"eta": 0.01})
    >>> p.extra["eta"]
    0.01
    """

    gamma: float = 5.0 / 3.0
    c: float = 1.0
    relativistic: bool = False
    extra: Mapping[str, Any] = field(default_factory=dict)

    def __post_init__(self) -> None:
        if not isinstance(self.extra, MappingProxyType):
            object.__setattr__(self, "extra", MappingProxyType(dict(self.extra)))