Units¶
Simple unit tools following the openPMD standard: a unit is defined by a conversion factor to SI (unitSI), a 7-component dimension tuple (unitDimension), and a presentational symbol (unitSymbol).
from beamphysics.units import (
NAMED_UNITS,
dimension_name,
pmd_unit,
sqrt_unit,
)
from beamphysics import particle_paths
from h5py import File
This is the basic class:
help(pmd_unit)
Help on class pmd_unit in module beamphysics.units:
class pmd_unit(builtins.object)
| pmd_unit(
| unitSymbol: str = '',
| unitSI: int | float = 0,
| unitDimension: str | Sequence[int | float] = (0, 0, 0, 0, 0, 0, 0)
| )
|
| OpenPMD representation of a unit.
|
| Parameters
| ----------
| unitSymbol : str
| Native units name. Can be a simple symbol (e.g., 'eV', 'm') or a
| compound expression using * and / operators (e.g., 'eV/c', 'kg*m/s').
| unitSI : float, optional
| Conversion factor to the corresponding SI unit. Defaults to 0.
| If unspecified, `unitSymbol` must be a recognized symbol name.
| unitDimension : str, or list of int, optional
| Common name of dimensions or list of 7 SI Base Exponents.
| Valid names include:
| * "1"
| * "length"
| * "mass"
| * "time"
| * "current"
| * "temperature"
| * "mol"
| * "luminous"
| * "charge"
| * "electric_field"
| * "electric_potential"
| * "magnetic_field"
| * "velocity"
| * "energy"
| * "momentum"
|
| For a full list, see `beamphysics.units.DIMENSION`.
|
| Notes
| -----
|
| Base unit dimensions are defined as:
|
| Base dimension | exponents. | SI unit
| ---------------- ----------------- -------
| length : (1,0,0,0,0,0,0) m
| mass : (0,1,0,0,0,0,0) kg
| time : (0,0,1,0,0,0,0) s
| current : (0,0,0,1,0,0,0) A
| temperature : (0,0,0,0,1,0,0) K
| mol : (0,0,0,0,0,1,0) mol
| luminous : (0,0,0,0,0,0,1) cd
|
| Examples
| --------
|
| Define that an eV is 1.602176634e-19 of base units m^2 kg/s^2, which is a Joule (J):
|
| >>> pmd_unit('eV', 1.602176634e-19, (2, 1, -2, 0, 0, 0, 0))
| pmd_unit('eV', 1.602176634e-19, (2.0, 1.0, -2.0, 0.0, 0.0, 0.0, 0.0))
|
| If unitSI=0 (default), `pmd_unit` may be initialized with a known symbol:
|
| >>> pmd_unit('T')
| pmd_unit('T', 1.0, (0.0, 1.0, -2.0, -1.0, 0.0, 0.0, 0.0))
|
| Compound units can be created using * and / operators in the symbol string:
|
| >>> pmd_unit('eV/c')
| pmd_unit('eV/c', 5.344285992678308e-28, (1.0, 1.0, -1.0, 0.0, 0.0, 0.0, 0.0))
|
| >>> pmd_unit('kg*m/s')
| pmd_unit('kg*m/s', 1.0, (1.0, 1.0, -1.0, 0.0, 0.0, 0.0, 0.0))
|
| Parsing is left-associative ('V/eV/c' means '(V/eV)/c'); parentheses
| group a sub-expression into a single operand:
|
| >>> pmd_unit('V/(eV/c)')
| pmd_unit('V/(eV/c)', 1.8711573470618972e+27, (1.0, 0.0, -2.0, -1.0, 0.0, 0.0, 0.0))
|
| Alternatively, use the `from_symbol` class method for explicit symbol lookup:
|
| >>> pmd_unit.from_symbol('A*s')
| pmd_unit('A*s', 1.0, (0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0))
|
| Equality follows the openPMD standard: the symbol is only a display
| label, and two units are equal when they share the same ``unitDimension``
| and ``unitSI`` (compared with a small relative tolerance):
|
| >>> pmd_unit("T") == pmd_unit("T")
| True
| >>> pmd_unit("eV") == pmd_unit("T")
| False
| >>> pmd_unit("1/s") == pmd_unit("Hz")
| True
| >>> pmd_unit("J/m") == pmd_unit("eV/m") # different SI scales, unequal
| False
|
| Methods defined here:
|
| __eq__(self, other) -> bool
| Return self==value.
|
| __hash__(self) -> int
| Return hash(self).
|
| __init__(
| self,
| unitSymbol: str = '',
| unitSI: int | float = 0,
| unitDimension: str | Sequence[int | float] = (0, 0, 0, 0, 0, 0, 0)
| )
| Initialize self. See help(type(self)) for accurate signature.
|
| __mul__(self, other) -> pmd_unit
|
| __pow__(self, power: float) -> pmd_unit
|
| __repr__(self) -> str
| Return repr(self).
|
| __str__(self) -> str
| Return str(self).
|
| __truediv__(self, other) -> pmd_unit
|
| compatible_with(self, other: pmd_unit) -> bool
| Return ``True`` when ``other`` has the same dimension as ``self``.
|
| Compatibility is a pure-dimension check: two units are compatible
| iff a value expressed in one can be converted to the other by a
| single multiplicative SI factor (see :meth:`conversion_factor_to`).
| It does *not* require equal :attr:`unitSI`, so e.g. ``eV`` and
| ``J`` are compatible.
|
| Parameters
| ----------
| other : pmd_unit
| The unit to compare against.
|
| Returns
| -------
| bool
| ``True`` iff ``self.unitDimension == other.unitDimension``.
|
| Examples
| --------
| >>> pmd_unit("eV").compatible_with(pmd_unit("J"))
| True
| >>> pmd_unit("Hz").compatible_with(pmd_unit("1/s"))
| True
| >>> pmd_unit("m").compatible_with(pmd_unit("s"))
| False
|
| conversion_factor_to(self, other: pmd_unit) -> float
| Return the factor that converts a value in ``self`` to ``other``.
|
| For a value ``x`` measured in ``self``, the value in ``other`` is
| ``x * self.conversion_factor_to(other)``. Equivalent to
| ``self.unitSI / other.unitSI`` after a dimension check.
|
| Parameters
| ----------
| other : pmd_unit
| The target unit. Must have the same :attr:`unitDimension` as
| ``self``.
|
| Returns
| -------
| float
| Multiplicative factor mapping values in ``self`` to ``other``.
|
| Raises
| ------
| ValueError
| If ``other`` is not dimensionally :meth:`compatible_with` ``self``.
|
| Examples
| --------
| >>> pmd_unit("eV").conversion_factor_to(pmd_unit("J"))
| 1.602176634e-19
| >>> pmd_unit("km").conversion_factor_to(pmd_unit("m"))
| 1000.0
|
| scaled_symbol(self, factor: float) -> str
| Display symbol for ``factor`` times this unit.
|
| The spelling is kept whenever an SI prefix can express the factor
| directly on it: ``pmd_unit("eV/c").scaled_symbol(1e-3)`` is
| ``'meV/c'``. When it cannot, the scaled value is matched against
| the named units (``1e-6 * mA*s`` scales to ``'nC'``, ``1e3 * 1/s``
| to ``'kHz'``), and failing that the factor is written out:
| ``pmd_unit("m^2").scaled_symbol(1e-3)`` is ``'1e-3 m^2'`` (a glued
| ``'mm^2'`` would read as ``(mm)^2``, the wrong value).
|
| ``factor`` is typically a power of ten from :func:`nice_array`,
| :func:`nice_scale_prefix`, or :func:`plottable_array`.
|
| simplify(self, named_units=None) -> pmd_unit
| Search for a simpler equivalent symbol with the same dimension and
| SI value as this unit.
|
| The search proceeds in two passes:
|
| 1. **Atomic match.** Look for a single entry in ``named_units`` whose
| dimension matches ``self.unitDimension`` and whose ``unitSI``
| equals ``self.unitSI`` (optionally up to a single SI prefix
| factor from :data:`SHORT_PREFIX_FACTOR`). Comparisons use
| ``math.isclose`` so factors built from arithmetic are matched
| reliably.
| 2. **Compound match.** If no atomic match is found, look for an
| ``a*b`` or ``a/b`` combination of two named units (both with
| nonzero dimension) whose product/quotient matches ``self`` exactly.
|
| Among candidates, the one with the simplest symbol is returned
| (no operators preferred; then shortest; then lexicographic).
| If nothing matches, ``self`` is returned unchanged.
|
| Parameters
| ----------
| named_units : sequence of pmd_unit, optional
| Pool of named units to search. Defaults to :data:`NAMED_UNITS`.
|
| Returns
| -------
| pmd_unit
| A simplified unit, or ``self`` if no simplification was found.
|
| to_tex(self) -> str
| Render the stored unit symbol as a matplotlib-mathtext string.
|
| The translation is purely structural — no algebraic simplification
| or reordering. Each atomic unit name is wrapped in ``\mathrm{...}``
| (so it renders upright), ``*`` becomes ``{\cdot}``, ``/`` is kept
| as ``/``, ``^N`` becomes ``^{N}``, ``sqrt(X)`` becomes
| ``\sqrt{...}``, and a parenthesized group ``(X)`` keeps its parens
| (recursing on the inner expression in both cases).
|
| The result is intended to be embedded inside an ``$...$`` mathtext
| block by the caller. The identity / empty symbol returns ``""``.
|
| Returns
| -------
| str
| A TeX fragment equivalent to :attr:`unitSymbol`.
|
| Examples
| --------
| >>> pmd_unit("eV/c").to_tex()
| '\\mathrm{eV}/\\mathrm{c}'
|
| >>> pmd_unit("kg*m/s").to_tex()
| '\\mathrm{kg}{\\cdot}\\mathrm{m}/\\mathrm{s}'
|
| >>> pmd_unit("m^2").to_tex()
| '\\mathrm{m}^{2}'
|
| >>> pmd_unit("sqrt(m)").to_tex()
| '\\sqrt{\\mathrm{m}}'
|
| ----------------------------------------------------------------------
| Class methods defined here:
|
| from_symbol(unitSymbol: str) -> pmd_unit
| Create a pmd_unit from a symbol string, with automatic lookup and parsing.
|
| This is the single, recursive parser. It handles:
|
| - Known units ('eV', 'm', 'T') and aliases ('Ohm', 'deg')
| - Compound expressions ('eV/c', 'kg*m/s') — each token recurses here
| - Parenthesized grouping ('V/(eV/c)', '(eV*s)^2')
| - Power notation ('m^2', 's^-1', 'm^(-3/2)')
| - Square root notation ('sqrt(m)', 'sqrt(kg*m)')
| - SI-prefixed known units ('keV', 'mm', 'mrad')
|
| Every recursive call parses a strictly shorter string (a token, a
| sqrt/group inner expression, or a power base) — never the unchanged
| input — so parsing always terminates.
|
| Parameters
| ----------
| unitSymbol : str
| The unit symbol to look up or parse.
|
| Returns
| -------
| pmd_unit
| The resulting unit object.
|
| Raises
| ------
| ValueError
| If the symbol is not found in known_unit dict or cannot be parsed.
|
| ----------------------------------------------------------------------
| Readonly properties defined here:
|
| unitDimension
|
| unitSI
|
| unitSymbol
|
| ----------------------------------------------------------------------
| Data descriptors defined here:
|
| __dict__
| dictionary for instance variables
|
| __weakref__
| list of weak references to the object
|
| ----------------------------------------------------------------------
| Data and other attributes defined here:
|
| __slotnames__ = []
Unit arithmetic¶
Named units can be multiplied, divided, and raised to a power (*, /, **):
u1 = pmd_unit("J")
u2 = pmd_unit("m")
u1 * u2, u1 / u2, u1**2
(pmd_unit('J*m', 1.0, (3.0, 1.0, -2.0, 0.0, 0.0, 0.0, 0.0)),
pmd_unit('J/m', 1.0, (1.0, 1.0, -2.0, 0.0, 0.0, 0.0, 0.0)),
pmd_unit('J^2', 1.0, (4.0, 2.0, -4.0, 0.0, 0.0, 0.0, 0.0)))
Power notation¶
^ accepts any rational exponent — integer, decimal, parenthesised fraction, or the equivalent sqrt(...) form. Fractional exponents are carried in the dimension tuple:
# All of these spellings parse; the last two are equivalent.
{s: pmd_unit(s) for s in ["m^2", "m^-1", "m^(3/2)", "sqrt(m)", "m^(1/2)"]}
{'m^2': pmd_unit('m^2', 1.0, (2.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)),
'm^-1': pmd_unit('m^-1', 1.0, (-1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)),
'm^(3/2)': pmd_unit('m^(3/2)', 1.0, (1.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)),
'sqrt(m)': pmd_unit('sqrt(m)', 1.0, (0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)),
'm^(1/2)': pmd_unit('m^(1/2)', 1.0, (0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0))}
The ** operator (or power_unit(u, n)) raises a unit to a power. For compound symbols the exponent is distributed across each token, so pmd_unit("eV*s") ** 2 yields eV^2*s^2 (the equivalent grouped spelling (eV*s)^2 is also accepted as input — see the next section):
pmd_unit("eV*s") ** 2, pmd_unit("m^2*s^-1") ** 3
(pmd_unit('eV^2*s^2', 2.5669699665355694e-38, (4.0, 2.0, -2.0, 0.0, 0.0, 0.0, 0.0)),
pmd_unit('m^6*s^-3', 1.0, (6.0, 0.0, -3.0, 0.0, 0.0, 0.0, 0.0)))
Parenthesized grouping¶
Parsing is left-associative: V/eV/c means (V/eV)/c. Parentheses group a sub-expression into a single operand, so V/(eV/c) divides by eV/c as a whole and (eV*s)^2 exponentiates the whole product. Groups nest, and dividing two pmd_unit objects parenthesizes a compound divisor automatically so the resulting symbol re-parses to the same unit:
(
pmd_unit("V/(eV/c)"),
pmd_unit("V/eV/c"), # different grouping -> different unit
pmd_unit("(eV*s)^2"),
pmd_unit("V/m") / pmd_unit("eV/c"), # division emits the parenthesized form
)
(pmd_unit('V/(eV/c)', 1.8711573470618972e+27, (1.0, 0.0, -2.0, -1.0, 0.0, 0.0, 0.0)),
pmd_unit('V/eV/c', 20819433270.9356, (-1.0, 0.0, 0.0, -1.0, 0.0, 0.0, 0.0)),
pmd_unit('(eV*s)^2', 2.5669699665355694e-38, (4.0, 2.0, -2.0, 0.0, 0.0, 0.0, 0.0)),
pmd_unit('V/m/(eV/c)', 1.8711573470618972e+27, (0.0, 0.0, -2.0, -1.0, 0.0, 0.0, 0.0)))
# Whitespace normalizes, and redundant parens around an atomic unit are dropped:
pmd_unit("V / ( eV / c )").unitSymbol, pmd_unit("V/(m)").unitSymbol
('V/(eV/c)', 'V/m')
Named units¶
The package knows the following named units:
NAMED_UNITS
[pmd_unit('', 1.0, (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)),
pmd_unit('degree', 0.017453292519943295, (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)),
pmd_unit('rad', 1.0, (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)),
pmd_unit('m', 1.0, (1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)),
pmd_unit('kg', 1.0, (0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0)),
pmd_unit('g', 0.001, (0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0)),
pmd_unit('s', 1.0, (0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0)),
pmd_unit('A', 1.0, (0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0)),
pmd_unit('K', 1.0, (0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0)),
pmd_unit('mol', 1.0, (0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0)),
pmd_unit('cd', 1.0, (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0)),
pmd_unit('C', 1.0, (0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0)),
pmd_unit('charge #', 1.0, (0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0)),
pmd_unit('V/m', 1.0, (1.0, 1.0, -3.0, -1.0, 0.0, 0.0, 0.0)),
pmd_unit('V', 1.0, (2.0, 1.0, -3.0, -1.0, 0.0, 0.0, 0.0)),
pmd_unit('c', 299792458.0, (1.0, 0.0, -1.0, 0.0, 0.0, 0.0, 0.0)),
pmd_unit('m/s', 1.0, (1.0, 0.0, -1.0, 0.0, 0.0, 0.0, 0.0)),
pmd_unit('eV', 1.602176634e-19, (2.0, 1.0, -2.0, 0.0, 0.0, 0.0, 0.0)),
pmd_unit('J', 1.0, (2.0, 1.0, -2.0, 0.0, 0.0, 0.0, 0.0)),
pmd_unit('eV/c', 5.344285992678308e-28, (1.0, 1.0, -1.0, 0.0, 0.0, 0.0, 0.0)),
pmd_unit('eV/m', 1.602176634e-19, (1.0, 1.0, -2.0, 0.0, 0.0, 0.0, 0.0)),
pmd_unit('J/m', 1.0, (1.0, 1.0, -2.0, 0.0, 0.0, 0.0, 0.0)),
pmd_unit('W', 1.0, (2.0, 1.0, -3.0, 0.0, 0.0, 0.0, 0.0)),
pmd_unit('W/rad^2', 1.0, (2.0, 1.0, -3.0, 0.0, 0.0, 0.0, 0.0)),
pmd_unit('W/m^2', 1.0, (0.0, 1.0, -3.0, 0.0, 0.0, 0.0, 0.0)),
pmd_unit('T', 1.0, (0.0, 1.0, -2.0, -1.0, 0.0, 0.0, 0.0)),
pmd_unit('Hz', 1.0, (0.0, 0.0, -1.0, 0.0, 0.0, 0.0, 0.0)),
pmd_unit('Ω', 1.0, (2.0, 1.0, -3.0, -2.0, 0.0, 0.0, 0.0))]
A few common ASCII aliases are accepted as alternate spellings of the named units (the canonical symbol is preserved in the stored unitSymbol, so the alias and the canonical form compare equal):
pmd_unit("Ohm"), pmd_unit("Ω"), pmd_unit("deg"), pmd_unit("degree")
(pmd_unit('Ohm', 1.0, (2.0, 1.0, -3.0, -2.0, 0.0, 0.0, 0.0)),
pmd_unit('Ω', 1.0, (2.0, 1.0, -3.0, -2.0, 0.0, 0.0, 0.0)),
pmd_unit('deg', 0.017453292519943295, (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)),
pmd_unit('degree', 0.017453292519943295, (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)))
SI prefixes¶
Any known unit symbol may carry a standard SI prefix (k, M, G, T, m, µ, n, ...). The prefix scales unitSI and leaves the dimension unchanged:
pmd_unit("keV"), pmd_unit("mm"), pmd_unit("GHz")
(pmd_unit('keV', 1.602176634e-16, (2.0, 1.0, -2.0, 0.0, 0.0, 0.0, 0.0)),
pmd_unit('mm', 0.001, (1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)),
pmd_unit('GHz', 1000000000.0, (0.0, 0.0, -1.0, 0.0, 0.0, 0.0, 0.0)))
Prefixes also apply to the dimensionless angle units rad and degree (e.g. milliradians):
pmd_unit("mrad"), pmd_unit("µrad")
(pmd_unit('mrad', 0.001, (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)),
pmd_unit('µrad', 1e-06, (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)))
A prefix is only accepted when the remainder is itself a known unit, and a bare prefix is not a unit. Symbols that are both a prefix letter and a unit keep their unit meaning — c is the speed of light (not centi) and T is tesla (not tera):
pmd_unit("c"), pmd_unit("T") # the units, not centi-/tera-
(pmd_unit('c', 299792458.0, (1.0, 0.0, -1.0, 0.0, 0.0, 0.0, 0.0)),
pmd_unit('T', 1.0, (0.0, 1.0, -2.0, -1.0, 0.0, 0.0, 0.0)))
A bare prefix, or a prefixed dimensionless identity, is rejected:
for s in ["k", "M", "k1"]:
try:
pmd_unit(s)
except ValueError as ex:
print(f"{s!r} -> {type(ex).__name__}: {ex}")
'k' -> ValueError: Unknown unitSymbol: k 'M' -> ValueError: Unknown unitSymbol: M 'k1' -> ValueError: Unknown unitSymbol: k1
.simplify()¶
The .simplify() method searches the internal NAMED_UNITS list for an equivalent unit with a simpler symbol. It can:
- collapse a product or quotient to a single named unit (
W*s→J,V*A→W,J/C→V); - reconstruct an
a*b/a/bcompound when no single name exists (e.g.T*m,T/m); - attach an SI prefix when the value is an exact power-of-ten multiple of a named unit (
1000 J→kJ).
Matching is float-tolerant and deterministic (the simplest symbol wins), and the resulting symbol always re-parses back to the same unit. .simplify() is not called automatically.
u = pmd_unit("W*s")
u.simplify()
pmd_unit('J', 1.0, (2.0, 1.0, -2.0, 0.0, 0.0, 0.0, 0.0))
A product or quotient of named units, or a reconstructed compound:
(
(pmd_unit("V") * pmd_unit("A")).simplify(), # -> W
(pmd_unit("J") / pmd_unit("C")).simplify(), # -> V
(pmd_unit("T") * pmd_unit("m")).simplify(), # -> T*m
)
(pmd_unit('W', 1.0, (2.0, 1.0, -3.0, 0.0, 0.0, 0.0, 0.0)),
pmd_unit('V', 1.0, (2.0, 1.0, -3.0, -1.0, 0.0, 0.0, 0.0)),
pmd_unit('T*m', 1.0, (1.0, 1.0, -2.0, -1.0, 0.0, 0.0, 0.0)))
u = pmd_unit("this should be kJ", 1e3, (2, 1, -2, 0, 0, 0, 0))
simplified = u.simplify()
# The simplified symbol re-parses back to the same unit:
simplified, pmd_unit(simplified.unitSymbol)
(pmd_unit('kJ', 1000.0, (2.0, 1.0, -2.0, 0.0, 0.0, 0.0, 0.0)),
pmd_unit('kJ', 1000.0, (2.0, 1.0, -2.0, 0.0, 0.0, 0.0, 0.0)))
A purely dimensionless quantity has no meaningful named form, so .simplify() leaves it unchanged rather than inventing a same-dimension ratio. For example, mrad/µrad is just the number 1000, and it is not rewritten as kg/g:
pmd_unit("mrad/µrad").simplify() # 1000, dimensionless -> unchanged
pmd_unit('mrad/µrad', 1000.0000000000001, (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0))
Canonical symbols, equality, and validation¶
The parser normalizes whitespace around operators (and inside sqrt(...)) on the way in, so all the spellings below produce the same stored symbol — and therefore compare equal and hash identically:
spellings = ["eV/c", "eV / c", " eV/c ", "eV /c"]
[pmd_unit(s).unitSymbol for s in spellings]
['eV/c', 'eV/c', 'eV/c', 'eV/c']
a, b = pmd_unit("eV / c"), pmd_unit("eV/c")
a == b, hash(a) == hash(b)
(True, True)
Whitespace inside sqrt(...) is also normalized:
pmd_unit("sqrt( m )").unitSymbol, pmd_unit("sqrt( m )") == pmd_unit("sqrt(m)")
('sqrt(m)', True)
Malformed inputs — empty operands around an operator, or repeated operators — are rejected rather than silently parsing via the identity:
for s in ["m*", "/m", "m//s", "m**s", r"\sqrt{m}"]:
try:
pmd_unit(s)
except ValueError as ex:
print(f"{s!r:>14} -> ValueError: {ex}")
'm*' -> ValueError: Malformed unit symbol 'm*': empty operand around an operator.
'/m' -> ValueError: Malformed unit symbol '/m': empty operand around an operator.
'm//s' -> ValueError: Malformed unit symbol 'm//s': empty operand around an operator.
'm**s' -> ValueError: Malformed unit symbol 'm**s': empty operand around an operator.
'\\sqrt{m}' -> ValueError: Unknown unitSymbol: \sqrt{m}
The openPMD standard defines unitSI as a non-negative conversion factor to SI, and the constructor enforces that:
try:
pmd_unit("bogus", unitSI=-1.0, unitDimension="1")
except ValueError as ex:
print(f"ValueError: {ex}")
ValueError: unitSI must be non-negative (got -1.0). The openPMD standard defines unitSI as a conversion factor to SI.
Value-based equality¶
The openPMD standard defines a unit by its unitSI and unitDimension — the symbol is purely presentational. pmd_unit.__eq__ follows that convention: two units compare equal when they share the same dimension and (within a small relative tolerance) the same SI factor, regardless of how their symbols were spelled.
So different syntactic spellings of the same physical unit are equal:
pmd_unit("1/s") == pmd_unit("Hz"), pmd_unit("s^-1") == pmd_unit("Hz")
(True, True)
Arithmetic round-trips survive floating-point drift — (eV/c) * c reconstructs eV even though the intermediate float math doesn't reproduce e_charge bit-exactly:
roundtrip = pmd_unit("eV/c") * pmd_unit("c")
roundtrip, roundtrip == pmd_unit("eV")
(pmd_unit('eV/c*c', 1.602176634e-19, (2.0, 1.0, -2.0, 0.0, 0.0, 0.0, 0.0)),
True)
Same dimension but different SI scale stays unequal (J and eV are both energies, but 1 J ≠ 1 eV):
pmd_unit("J") == pmd_unit("eV"), pmd_unit("J/m") == pmd_unit("eV/m")
(False, False)
The hash/eq invariant is preserved — equal units share a hash bucket, so they de-duplicate correctly in sets and dicts:
hash(pmd_unit("1/s")) == hash(pmd_unit("Hz")), len({pmd_unit("1/s"), pmd_unit("Hz")})
(True, 1)
Dimension compatibility and conversion factors¶
compatible_with is a pure-dimension check (SI scale ignored). conversion_factor_to returns the multiplicative SI factor between two compatible units, and raises ValueError if the dimensions don't match:
(
pmd_unit("eV").compatible_with(pmd_unit("J")), # True (energy)
pmd_unit("m").compatible_with(pmd_unit("s")), # False (length vs time)
pmd_unit("eV").conversion_factor_to(pmd_unit("J")), # e_charge
pmd_unit("km").conversion_factor_to(pmd_unit("m")), # 1000.0
)
(True, False, 1.602176634e-19, 1000.0)
try:
pmd_unit("m").conversion_factor_to(pmd_unit("s"))
except ValueError as ex:
print(f"ValueError: {ex}")
ValueError: Incompatible units: 'm' (dimension (1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)) cannot be converted to 's' (dimension (0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0)).
openPMD HDF5 units¶
Open a file, find the particle paths from the root attributes
# Pick one:
# H5FILE = 'data/bmad_particles.h5'
H5FILE = "data/distgen_particles.h5"
# H5FILE = 'data/astra_particles.h5'
h5 = File(H5FILE, "r")
ppaths = particle_paths(h5)
print(ppaths)
['//']
This points to a single particle group:
ph5 = h5[ppaths[0]]
list(ph5)
['momentum', 'particleStatus', 'position', 'time', 'weight']
Each component should have a dimension and a conversion factor to SI:
d = dict(ph5["momentum/x"].attrs)
d
{'unitDimension': array([ 1, 1, -1, 0, 0, 0, 0]),
'unitSI': np.float64(5.344285992678308e-28),
'unitSymbol': 'eV/c'}
tuple(d["unitDimension"])
(np.int64(1), np.int64(1), np.int64(-1), np.int64(0), np.int64(0), np.int64(0), np.int64(0))
This will extract the name of this dimension:
dimension_name(d["unitDimension"])
'momentum'
Nice arrays¶
from beamphysics.units import nice_array
This will scale the array, and return the appropriate SI prefix:
x = 1e-4
nice_array(x)
(100.00000000000001, 1e-06, 'µ')
nice_array([-0.01, 0.01])
(array([-10., 10.]), 0.001, 'm')
from beamphysics.units import nice_scale_prefix
nice_scale_prefix(0.009)
(0.001, 'm')
TeX rendering for plot labels¶
pmd_unit.to_tex() translates the stored symbol into a matplotlib-mathtext fragment. The translation is purely structural — no algebraic simplification, no reordering: each atomic name is wrapped in \mathrm{...}, * becomes {\cdot}, / stays, ^N becomes ^{N}, sqrt(X) becomes \sqrt{...}, and a parenthesized group keeps its parens (recursing on the inner expression in both cases).
for s in [
"eV/c",
"kg*m/s",
"m^2",
"s^-1",
"m^(1/2)",
"sqrt(m)",
"sqrt(kg*m)",
"V/(eV/c)",
]:
print(f"{s:>12} -> {pmd_unit(s).to_tex()}")
eV/c -> \mathrm{eV}/\mathrm{c}
kg*m/s -> \mathrm{kg}{\cdot}\mathrm{m}/\mathrm{s}
m^2 -> \mathrm{m}^{2}
s^-1 -> \mathrm{s}^{-1}
m^(1/2) -> \mathrm{m}^{1/2}
sqrt(m) -> \sqrt{\mathrm{m}}
sqrt(kg*m) -> \sqrt{\mathrm{kg}{\cdot}\mathrm{m}}
V/(eV/c) -> \mathrm{V}/(\mathrm{eV}/\mathrm{c})
Because the translation is structural, to_tex() reflects the stored symbol — not the simplified form. A redundant compound stays compound:
u = pmd_unit("m*s/m") # dimension reduces, but the stored symbol is preserved
u.unitSymbol, u.to_tex(), u.simplify().unitSymbol
('m*s/m', '\\mathrm{m}{\\cdot}\\mathrm{s}/\\mathrm{m}', 's')
beamphysics.labels.mathlabel consumes this when building axis labels, so any quantity with a pmd_unit will render correctly as a mathtext string:
from beamphysics.labels import mathlabel
mathlabel("x_bar", units=sqrt_unit(pmd_unit("m")))
'$\\overline{x}~(\\sqrt{\\mathrm{m}})$'
Limitations¶
This is a simple class for use with this package. So even simple things like the example below will fail.
For more advanced units, use a package like Pint: https://pint.readthedocs.io/
try:
u1 / 1
except Exception as ex:
print(f"You can't do this. It will raise {type(ex).__name__}: {ex}")
You can't do this. It will raise ValueError: u2 is not a pmd_unit instance