Source code for specmod.core.units
"""Physical units and domains, as types rather than conventions.
The pre-refactor code tracked none of this. A ``Spectrum`` did not know whether
it held power or amplitude, nor whether it was in displacement, velocity or
acceleration; that lived in ``Models.MOTION``, a module global read at import
time which the user had to keep in sync by hand with however many times they
had called ``.inte()`` or ``.diff()``. Getting it wrong returned a wrong seismic
moment with no error anywhere.
Making both a typed attribute turns those silent factor errors into exceptions.
Conventions
-----------
The canonical amplitude kind is the **folded** one-sided Fourier amplitude
spectrum (:attr:`AmplitudeKind.FAS`), in units of ``[signal] * s``. "Folded"
means the negative-frequency half has been added in, so ``FAS = 2|X|`` where
``X`` is the Fourier transform. That is what makes energy recoverable by
integrating over non-negative frequencies alone, and it is why every estimator
here can be held to one Parseval check.
**It is not the quantity the source model is written in.** Omega, the
long-period spectral level, is the plateau of ``|X|`` — at zero frequency
``|X(0)| = |integral u dt|``, which is what ``M0`` is proportional to. Reading
``FAS`` as Omega puts ``M0`` out by two, which is 0.2 magnitude units. Use
:attr:`AmplitudeKind.MAGNITUDE` for that, and let :meth:`Spectrum.to_kind`
apply the factor rather than doing it by hand.
Relationships between the kinds, for a record of duration ``T``:
============= ======================== ==================================
Kind Units From FAS ``A``
============= ======================== ==================================
``FAS`` ``[x] * s`` --
``MAGNITUDE`` ``[x] * s`` ``|X| = A / 2``
``PSD`` ``[x]^2 / Hz`` ``P = A^2 / (2 T)``
``ASD`` ``[x] / sqrt(Hz)`` ``D = A / sqrt(2 T)``
============= ======================== ==================================
Parseval takes a different form in each amplitude convention, which is the
whole reason both are named here rather than left to the caller::
E = integral A**2 / 2 df (FAS, folded)
E = 2 * integral |X|**2 df (MAGNITUDE, unfolded)
``T`` is the **physical record duration**, ``n_samples * dt``. It is never
inferred from the length of the frequency axis: zero-padding changes that length
while leaving the duration alone, which is precisely how the old
``psd_to_amp`` acquired a padding-dependent error.
"""
from __future__ import annotations
from enum import StrEnum
__all__ = ["AmplitudeKind", "Motion"]
[docs]
class Motion(StrEnum):
"""Ground-motion domain of a time series or spectrum."""
DISPLACEMENT = "displacement"
VELOCITY = "velocity"
ACCELERATION = "acceleration"
@property
def derivative_order(self) -> int:
"""Order of time differentiation relative to displacement.
Converting between domains multiplies the spectrum by ``(2*pi*f)`` per
order, so the difference of two orders gives the exponent directly.
"""
return {"displacement": 0, "velocity": 1, "acceleration": 2}[self.value]
@property
def unit(self) -> str:
"""SI unit of the time-domain signal."""
return {"displacement": "m", "velocity": "m/s", "acceleration": "m/s^2"}[
self.value
]
[docs]
class AmplitudeKind(StrEnum):
"""What the amplitude axis of a spectrum represents."""
#: Folded one-sided Fourier amplitude spectrum, ``2|X|``, in ``[x] * s``.
#: Energy is ``integral(FAS**2 / 2) df``. The default, because it is the
#: convention in which one Parseval check covers every estimator.
FAS = "fas"
#: Unfolded Fourier transform magnitude, ``|X| = |rfft(x)| * dt``, in
#: ``[x] * s``. **This is the one Omega is defined in**, and the one to
#: read a long-period spectral level off. Energy is
#: ``2 * integral(|X|**2) df``.
MAGNITUDE = "magnitude"
#: One-sided power spectral density, ``[x]^2 / Hz``.
PSD = "psd"
#: One-sided amplitude spectral density, ``[x] / sqrt(Hz)``.
ASD = "asd"
@property
def is_amplitude(self) -> bool:
"""Whether this kind scales linearly with the record.
The distinction that matters for :meth:`Spectrum.to_motion`: applying
a ``2*pi*f`` factor to a squared quantity is wrong by ``2*pi*f`` again.
"""
return self in (AmplitudeKind.FAS, AmplitudeKind.MAGNITUDE, AmplitudeKind.ASD)
[docs]
def unit(self, motion: Motion) -> str:
"""Full unit string for this kind in a given motion domain."""
base = motion.unit
return {
"fas": f"{base}*s",
"magnitude": f"{base}*s",
"psd": f"({base})^2/Hz",
"asd": f"{base}/sqrt(Hz)",
}[self.value]