specmod 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- specmod/__init__.py +17 -0
- specmod/_vendor/__init__.py +21 -0
- specmod/_vendor/qiinv.py +243 -0
- specmod/acquire.py +358 -0
- specmod/api.py +480 -0
- specmod/cli.py +139 -0
- specmod/config/__init__.py +44 -0
- specmod/config/layers.py +168 -0
- specmod/config/provenance.py +77 -0
- specmod/config/sections.py +385 -0
- specmod/config/serialize.py +58 -0
- specmod/core/__init__.py +41 -0
- specmod/core/bandwidth.py +187 -0
- specmod/core/collection.py +549 -0
- specmod/core/noise.py +478 -0
- specmod/core/scalogram.py +234 -0
- specmod/core/spectrum.py +326 -0
- specmod/core/units.py +116 -0
- specmod/datasets.py +316 -0
- specmod/distance.py +190 -0
- specmod/exceptions.py +58 -0
- specmod/fitting/__init__.py +58 -0
- specmod/fitting/base.py +50 -0
- specmod/fitting/event.py +284 -0
- specmod/fitting/guess.py +170 -0
- specmod/fitting/spectrum.py +330 -0
- specmod/io.py +241 -0
- specmod/magnitude.py +312 -0
- specmod/picks/__init__.py +182 -0
- specmod/picks/base.py +250 -0
- specmod/picks/delimited.py +224 -0
- specmod/picks/events.py +157 -0
- specmod/picks/resolution.py +149 -0
- specmod/picks/snuffler.py +92 -0
- specmod/pipeline.py +280 -0
- specmod/plotting.py +203 -0
- specmod/preprocess.py +554 -0
- specmod/smoothing/__init__.py +50 -0
- specmod/smoothing/base.py +56 -0
- specmod/smoothing/konno_ohmachi.py +83 -0
- specmod/smoothing/log_bins.py +171 -0
- specmod/sources/__init__.py +65 -0
- specmod/sources/attenuation.py +110 -0
- specmod/sources/composite.py +135 -0
- specmod/sources/motion.py +40 -0
- specmod/sources/source.py +147 -0
- specmod/spreading.py +209 -0
- specmod/staged.py +523 -0
- specmod/tables.py +110 -0
- specmod/transforms/__init__.py +50 -0
- specmod/transforms/base.py +242 -0
- specmod/transforms/cwt.py +219 -0
- specmod/transforms/fft.py +157 -0
- specmod/transforms/multitaper.py +357 -0
- specmod/transforms/prieto.py +272 -0
- specmod/transforms/quadratic.py +221 -0
- specmod/utils.py +305 -0
- specmod-0.2.0.dist-info/METADATA +294 -0
- specmod-0.2.0.dist-info/RECORD +62 -0
- specmod-0.2.0.dist-info/WHEEL +4 -0
- specmod-0.2.0.dist-info/entry_points.txt +2 -0
- specmod-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""The time-frequency surface a CWT produces, and the QC it makes possible.
|
|
2
|
+
|
|
3
|
+
A :class:`Scalogram` is deliberately *not* an amplitude spectrum. Its ``power``
|
|
4
|
+
is ``|W(a,b)|**2`` in the L2-Morlet convention, which carries units of
|
|
5
|
+
``[signal]**2 * time`` — documented, but not comparable to a Fourier amplitude
|
|
6
|
+
spectrum and not something to fit a source model to.
|
|
7
|
+
|
|
8
|
+
The conversion happens in exactly one place, :meth:`Scalogram.time_average`,
|
|
9
|
+
which applies the ``C_delta`` and ``dj*dt`` bridge and returns an ordinary
|
|
10
|
+
:class:`~specmod.core.spectrum.Spectrum`. One normalisation path, one test. A
|
|
11
|
+
second "already normalised" surface would be a second thing to get wrong.
|
|
12
|
+
|
|
13
|
+
References
|
|
14
|
+
----------
|
|
15
|
+
Torrence, C. and Compo, G.P. (1998). A practical guide to wavelet analysis.
|
|
16
|
+
*Bulletin of the American Meteorological Society* 79(1), 61-78.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from types import MappingProxyType
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
from numpy.typing import NDArray
|
|
27
|
+
|
|
28
|
+
from .spectrum import Spectrum
|
|
29
|
+
from .units import AmplitudeKind, Motion
|
|
30
|
+
|
|
31
|
+
__all__ = ["Scalogram", "ScalogramQC"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class ScalogramQC:
|
|
36
|
+
"""Quality checks a time-frequency surface makes possible.
|
|
37
|
+
|
|
38
|
+
An amplitude-only signal-to-noise test cannot see any of these: it collapses
|
|
39
|
+
the time axis before looking. Every field is computed and recorded rather
|
|
40
|
+
than acted on — a trace is never silently dropped, the numbers travel with
|
|
41
|
+
the result so they can be filtered downstream.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
#: Lowest frequency with usable coverage outside the cone of influence.
|
|
45
|
+
#: Window length imposes this limit and nothing else in the pipeline
|
|
46
|
+
#: enforces it, so a short window can otherwise report usable bandwidth
|
|
47
|
+
#: where the transform has no support.
|
|
48
|
+
lowest_resolved_frequency: float
|
|
49
|
+
|
|
50
|
+
#: Fraction of the window free of edge effects, per frequency, summarised
|
|
51
|
+
#: as the median across the band.
|
|
52
|
+
median_coi_coverage: float
|
|
53
|
+
|
|
54
|
+
#: Normalised Gini coefficient of energy over time, in ``[0, 1]``. Near 0
|
|
55
|
+
#: is stationary; near 1 means essentially all the energy is in a handful
|
|
56
|
+
#: of samples, which is a glitch rather than an arrival.
|
|
57
|
+
temporal_concentration: float
|
|
58
|
+
|
|
59
|
+
#: Ratio of spectral energy in the first half of the window to the second.
|
|
60
|
+
#: Far from 1 suggests coda contamination, a second arrival, or a window
|
|
61
|
+
#: that started late.
|
|
62
|
+
half_window_ratio: float
|
|
63
|
+
|
|
64
|
+
def to_dict(self) -> dict[str, float]:
|
|
65
|
+
"""Flat mapping, for landing in a results table as columns."""
|
|
66
|
+
return {
|
|
67
|
+
"qc_lowest_resolved_frequency": self.lowest_resolved_frequency,
|
|
68
|
+
"qc_median_coi_coverage": self.median_coi_coverage,
|
|
69
|
+
"qc_temporal_concentration": self.temporal_concentration,
|
|
70
|
+
"qc_half_window_ratio": self.half_window_ratio,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class Scalogram:
|
|
76
|
+
"""Full time-frequency surface from a continuous wavelet transform.
|
|
77
|
+
|
|
78
|
+
Parameters
|
|
79
|
+
----------
|
|
80
|
+
time
|
|
81
|
+
Sample times, shape ``(n_times,)``.
|
|
82
|
+
freq
|
|
83
|
+
Fourier-equivalent frequencies, shape ``(n_scales,)``. These are true
|
|
84
|
+
Fourier frequencies via the analytic Morlet relation, not scales, so the
|
|
85
|
+
axis means the same thing as an FFT's.
|
|
86
|
+
power
|
|
87
|
+
``|W(a,b)|**2``, shape ``(n_scales, n_times)``.
|
|
88
|
+
scales
|
|
89
|
+
Wavelet scales in seconds, shape ``(n_scales,)``. Needed by the
|
|
90
|
+
normalisation bridge, which divides by scale.
|
|
91
|
+
coi
|
|
92
|
+
Longest resolvable period at each time, shape ``(n_times,)``. A
|
|
93
|
+
frequency is inside the cone of influence where ``1/freq > coi``.
|
|
94
|
+
c_delta
|
|
95
|
+
Reconstruction constant for the wavelet actually used. Computed rather
|
|
96
|
+
than tabulated, so a non-default ``omega0`` stays correct.
|
|
97
|
+
dj
|
|
98
|
+
Spacing of the log-scale grid, in octaves.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
time: NDArray[np.float64]
|
|
102
|
+
freq: NDArray[np.float64]
|
|
103
|
+
power: NDArray[np.float64]
|
|
104
|
+
scales: NDArray[np.float64]
|
|
105
|
+
coi: NDArray[np.float64]
|
|
106
|
+
c_delta: float
|
|
107
|
+
dj: float
|
|
108
|
+
dt: float
|
|
109
|
+
motion: Motion
|
|
110
|
+
meta: MappingProxyType[str, Any] = field(
|
|
111
|
+
default_factory=lambda: MappingProxyType({})
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def __post_init__(self) -> None:
|
|
115
|
+
for name in ("time", "freq", "power", "scales", "coi"):
|
|
116
|
+
array = getattr(self, name)
|
|
117
|
+
array.setflags(write=False)
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def duration(self) -> float:
|
|
121
|
+
return float(self.time.size * self.dt)
|
|
122
|
+
|
|
123
|
+
def coi_mask(self) -> NDArray[np.bool_]:
|
|
124
|
+
"""``True`` where a coefficient is free of edge effects."""
|
|
125
|
+
period = 1.0 / self.freq
|
|
126
|
+
return period[:, None] <= self.coi[None, :]
|
|
127
|
+
|
|
128
|
+
def coi_coverage(self) -> NDArray[np.float64]:
|
|
129
|
+
"""Fraction of the window free of edge effects, per frequency.
|
|
130
|
+
|
|
131
|
+
This is the number that says whether a window is long enough to
|
|
132
|
+
constrain the low-frequency plateau, which is what sets ``Omega``.
|
|
133
|
+
"""
|
|
134
|
+
coverage: NDArray[np.float64] = self.coi_mask().mean(axis=1)
|
|
135
|
+
return coverage
|
|
136
|
+
|
|
137
|
+
def time_average(self, *, mask_coi: bool = True) -> Spectrum:
|
|
138
|
+
"""Collapse to an ordinary amplitude spectrum.
|
|
139
|
+
|
|
140
|
+
Applies the Torrence & Compo normalisation so that the result satisfies
|
|
141
|
+
the same Parseval contract as every other estimator: summing the
|
|
142
|
+
wavelet power over scales, weighted by ``dj*dt/C_delta`` and divided by
|
|
143
|
+
scale, returns the record's energy.
|
|
144
|
+
|
|
145
|
+
Parameters
|
|
146
|
+
----------
|
|
147
|
+
mask_coi
|
|
148
|
+
Exclude coefficients inside the cone of influence and rescale by
|
|
149
|
+
the surviving fraction. Without this a short window reads low at
|
|
150
|
+
low frequency — precisely the band that constrains ``Omega``.
|
|
151
|
+
"""
|
|
152
|
+
freq, scales = self.freq, self.scales
|
|
153
|
+
if mask_coi:
|
|
154
|
+
valid = self.coi_mask()
|
|
155
|
+
counts = valid.sum(axis=1)
|
|
156
|
+
# A scale with no edge-free sample is not measured by this record.
|
|
157
|
+
# Dropping it is the honest answer: emitting zero would read as "no
|
|
158
|
+
# energy here" rather than "no measurement here", and would take a
|
|
159
|
+
# log-space fit to -inf. The axis therefore depends on record
|
|
160
|
+
# length, exactly as the cone of influence says it must.
|
|
161
|
+
usable = counts > 0
|
|
162
|
+
if not usable.any():
|
|
163
|
+
raise ValueError(
|
|
164
|
+
f"No frequency in {freq.min():.3g}-{freq.max():.3g} Hz is "
|
|
165
|
+
f"free of edge effects over a {self.duration:.3g} s record. "
|
|
166
|
+
f"The window is too short for this scale range; raise "
|
|
167
|
+
f"f_min, lengthen the window, or pass mask_coi=False to "
|
|
168
|
+
f"accept edge-contaminated coefficients."
|
|
169
|
+
)
|
|
170
|
+
freq, scales = freq[usable], scales[usable]
|
|
171
|
+
counts = counts[usable]
|
|
172
|
+
summed = (self.power * valid).sum(axis=1)[usable]
|
|
173
|
+
# Rescale the survivors up to the full window length, so masking
|
|
174
|
+
# changes the variance of the estimate rather than its level.
|
|
175
|
+
summed = summed * self.time.size / counts
|
|
176
|
+
else:
|
|
177
|
+
summed = self.power.sum(axis=1)
|
|
178
|
+
|
|
179
|
+
# Torrence & Compo eq. 14: the scale sum of |W|**2 / s, times
|
|
180
|
+
# dj*dt/C_delta, recovers the variance. Multiplying by dt again turns
|
|
181
|
+
# variance into energy, matching sum(x**2)*dt.
|
|
182
|
+
energy_per_scale = summed / scales * (self.dj * self.dt / self.c_delta)
|
|
183
|
+
energy_per_scale = energy_per_scale * self.dt
|
|
184
|
+
|
|
185
|
+
# The scale grid is logarithmic, so each frequency bin subtends
|
|
186
|
+
# df = f * ln(2) * dj. Spreading each scale's energy over its own bin
|
|
187
|
+
# gives a density on a frequency axis.
|
|
188
|
+
bin_width = freq * np.log(2.0) * self.dj
|
|
189
|
+
psd = energy_per_scale / (bin_width * self.duration)
|
|
190
|
+
|
|
191
|
+
# Ascending frequency, matching every other estimator's axis.
|
|
192
|
+
order = np.argsort(freq)
|
|
193
|
+
spectrum = Spectrum(
|
|
194
|
+
freq=np.ascontiguousarray(freq[order]),
|
|
195
|
+
amp=np.ascontiguousarray(psd[order]),
|
|
196
|
+
motion=self.motion,
|
|
197
|
+
kind=AmplitudeKind.PSD,
|
|
198
|
+
duration=self.duration,
|
|
199
|
+
sampling_rate=1.0 / self.dt,
|
|
200
|
+
meta=MappingProxyType({**dict(self.meta), "coi_masked": mask_coi}),
|
|
201
|
+
)
|
|
202
|
+
return spectrum.to_kind(AmplitudeKind.FAS)
|
|
203
|
+
|
|
204
|
+
def qc(self) -> ScalogramQC:
|
|
205
|
+
"""Compute the §4.4.2 checks."""
|
|
206
|
+
coverage = self.coi_coverage()
|
|
207
|
+
resolved = self.freq[coverage > 0.5]
|
|
208
|
+
lowest = float(resolved.min()) if resolved.size else float(self.freq.max())
|
|
209
|
+
|
|
210
|
+
# Energy over time, summed across the band, as a Gini coefficient.
|
|
211
|
+
over_time = self.power.sum(axis=0)
|
|
212
|
+
total = float(over_time.sum())
|
|
213
|
+
if total > 0:
|
|
214
|
+
sorted_energy = np.sort(over_time)
|
|
215
|
+
n = sorted_energy.size
|
|
216
|
+
index = np.arange(1, n + 1)
|
|
217
|
+
gini = float(
|
|
218
|
+
(2.0 * (index * sorted_energy).sum()) / (n * sorted_energy.sum())
|
|
219
|
+
- (n + 1.0) / n
|
|
220
|
+
)
|
|
221
|
+
else:
|
|
222
|
+
gini = 0.0
|
|
223
|
+
|
|
224
|
+
half = self.time.size // 2
|
|
225
|
+
first = float(self.power[:, :half].sum())
|
|
226
|
+
second = float(self.power[:, half:].sum())
|
|
227
|
+
ratio = first / second if second > 0 else np.inf
|
|
228
|
+
|
|
229
|
+
return ScalogramQC(
|
|
230
|
+
lowest_resolved_frequency=lowest,
|
|
231
|
+
median_coi_coverage=float(np.median(coverage)),
|
|
232
|
+
temporal_concentration=gini,
|
|
233
|
+
half_window_ratio=ratio,
|
|
234
|
+
)
|
specmod/core/spectrum.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""The :class:`Spectrum` container.
|
|
2
|
+
|
|
3
|
+
Immutable, self-describing, and normalisation-aware. Every operation returns a
|
|
4
|
+
new object rather than mutating in place, so a spectrum cannot be silently
|
|
5
|
+
integrated twice — the pre-refactor ``Spectrum.integrate()`` mutated, and its
|
|
6
|
+
only inverse was ``differentiate()``, which is neither exact nor recorded.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from dataclasses import dataclass, field, replace
|
|
13
|
+
from types import MappingProxyType
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
from numpy.typing import NDArray
|
|
18
|
+
|
|
19
|
+
from .units import AmplitudeKind, Motion
|
|
20
|
+
|
|
21
|
+
__all__ = ["Spectrum"]
|
|
22
|
+
|
|
23
|
+
#: Tolerance on the derived sample count. ``duration`` is ``n * dt`` and
|
|
24
|
+
#: ``sampling_rate`` is ``1 / dt``, so their product is ``n`` exactly up to
|
|
25
|
+
#: floating-point representation — anything further out is a real mismatch,
|
|
26
|
+
#: not rounding.
|
|
27
|
+
_SAMPLE_COUNT_TOL = 1e-6
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _validate_record_geometry(
|
|
31
|
+
freq: NDArray[np.float64], duration: float, sampling_rate: float
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Check the three quantities every correction is built on agree.
|
|
34
|
+
|
|
35
|
+
Sample count, duration and sampling rate are not independent: ``duration =
|
|
36
|
+
n * dt`` and ``sampling_rate = 1 / dt``, so any two determine the third and
|
|
37
|
+
the frequency axis they imply. Every normalisation in this package — the
|
|
38
|
+
``2T`` between amplitude and power, the fold at DC and Nyquist, the taper
|
|
39
|
+
corrections, the wavelet scale grid — is a function of them.
|
|
40
|
+
|
|
41
|
+
That makes an inconsistent triple the most dangerous thing a caller can
|
|
42
|
+
construct: it produces a spectrum that is wrong by a clean factor
|
|
43
|
+
everywhere, which looks like a plausible spectrum and survives every check
|
|
44
|
+
that inspects shape rather than scale. Catching it here is cheap; catching
|
|
45
|
+
it downstream has historically meant noticing that a magnitude looks odd.
|
|
46
|
+
"""
|
|
47
|
+
implied = duration * sampling_rate
|
|
48
|
+
if abs(implied - round(implied)) > _SAMPLE_COUNT_TOL * max(1.0, implied):
|
|
49
|
+
raise ValueError(
|
|
50
|
+
f"duration={duration} s at {sampling_rate} Hz implies "
|
|
51
|
+
f"{implied} samples, which is not a whole number. These are not "
|
|
52
|
+
f"independent: duration = n_samples / sampling_rate. One of them "
|
|
53
|
+
f"is wrong, and every amplitude conversion depends on both."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if freq.size == 0:
|
|
57
|
+
return
|
|
58
|
+
if freq[0] < 0.0:
|
|
59
|
+
raise ValueError(f"frequencies must be non-negative, got {freq.min()}")
|
|
60
|
+
if freq.size > 1 and not np.all(np.diff(freq) > 0):
|
|
61
|
+
raise ValueError(
|
|
62
|
+
"freq must be strictly increasing; band() and the smoothers both "
|
|
63
|
+
"assume it, and an unsorted axis integrates to nonsense"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
nyquist = sampling_rate / 2.0
|
|
67
|
+
if freq[-1] > nyquist * (1.0 + 1e-9):
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"frequency axis reaches {freq[-1]} Hz but the Nyquist frequency "
|
|
70
|
+
f"for {sampling_rate} Hz sampling is {nyquist} Hz. Either "
|
|
71
|
+
f"sampling_rate is wrong or the axis does not belong to this "
|
|
72
|
+
f"record; energy() would silently integrate over a band the "
|
|
73
|
+
f"record cannot represent."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class Spectrum:
|
|
79
|
+
"""A one-sided spectrum that knows its own units.
|
|
80
|
+
|
|
81
|
+
Parameters
|
|
82
|
+
----------
|
|
83
|
+
freq
|
|
84
|
+
Frequency axis in Hz, strictly increasing, excluding DC by default.
|
|
85
|
+
amp
|
|
86
|
+
Amplitude in whatever :attr:`kind` declares.
|
|
87
|
+
motion
|
|
88
|
+
Ground-motion domain.
|
|
89
|
+
kind
|
|
90
|
+
What ``amp`` represents.
|
|
91
|
+
duration
|
|
92
|
+
**Physical** record duration in seconds, ``n_samples * dt``. Carried
|
|
93
|
+
explicitly because every conversion between kinds needs it and it
|
|
94
|
+
cannot be recovered from ``len(freq)`` once padding is involved.
|
|
95
|
+
sampling_rate
|
|
96
|
+
Samples per second of the source record, in Hz.
|
|
97
|
+
meta
|
|
98
|
+
Arbitrary trace metadata. Stored read-only so a shared mapping cannot be
|
|
99
|
+
mutated through one spectrum and observed through another.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
freq: NDArray[np.float64]
|
|
103
|
+
amp: NDArray[np.float64]
|
|
104
|
+
motion: Motion
|
|
105
|
+
kind: AmplitudeKind
|
|
106
|
+
duration: float
|
|
107
|
+
sampling_rate: float
|
|
108
|
+
meta: Mapping[str, Any] = field(default_factory=dict)
|
|
109
|
+
|
|
110
|
+
def __post_init__(self) -> None:
|
|
111
|
+
freq = np.ascontiguousarray(self.freq, dtype=np.float64)
|
|
112
|
+
amp = np.ascontiguousarray(self.amp, dtype=np.float64)
|
|
113
|
+
if freq.ndim != 1 or amp.ndim != 1:
|
|
114
|
+
raise ValueError("freq and amp must be one-dimensional")
|
|
115
|
+
if freq.shape != amp.shape:
|
|
116
|
+
raise ValueError(
|
|
117
|
+
f"freq and amp must be the same length, got "
|
|
118
|
+
f"{freq.shape[0]} and {amp.shape[0]}"
|
|
119
|
+
)
|
|
120
|
+
if self.duration <= 0:
|
|
121
|
+
raise ValueError(f"duration must be positive, got {self.duration}")
|
|
122
|
+
if self.sampling_rate <= 0:
|
|
123
|
+
raise ValueError(
|
|
124
|
+
f"sampling_rate must be positive, got {self.sampling_rate}"
|
|
125
|
+
)
|
|
126
|
+
_validate_record_geometry(freq, self.duration, self.sampling_rate)
|
|
127
|
+
freq.setflags(write=False)
|
|
128
|
+
amp.setflags(write=False)
|
|
129
|
+
object.__setattr__(self, "freq", freq)
|
|
130
|
+
object.__setattr__(self, "amp", amp)
|
|
131
|
+
object.__setattr__(self, "motion", Motion(self.motion))
|
|
132
|
+
object.__setattr__(self, "kind", AmplitudeKind(self.kind))
|
|
133
|
+
object.__setattr__(self, "meta", MappingProxyType(dict(self.meta)))
|
|
134
|
+
|
|
135
|
+
def __reduce__(self) -> tuple[Any, tuple[Any, ...]]:
|
|
136
|
+
"""Rebuild through ``__init__`` rather than by restoring ``__dict__``.
|
|
137
|
+
|
|
138
|
+
``meta`` is a :class:`~types.MappingProxyType`, which is the right
|
|
139
|
+
thing for an immutable spectrum and **is not picklable**. Without this
|
|
140
|
+
the whole container tree — a ``SpectrumPair``, a ``SpectrumSet``, an
|
|
141
|
+
event — could not be written to disk at all, which was found the moment
|
|
142
|
+
the legacy pickle I/O was removed and there was nothing left to
|
|
143
|
+
persist a result with.
|
|
144
|
+
|
|
145
|
+
Reconstructing through the constructor also re-freezes the arrays.
|
|
146
|
+
``numpy`` restores a pickled array as writeable, so a spectrum
|
|
147
|
+
round-tripped by any other route would come back mutable and quietly
|
|
148
|
+
lose the guarantee it exists to make.
|
|
149
|
+
"""
|
|
150
|
+
return (
|
|
151
|
+
self.__class__,
|
|
152
|
+
(
|
|
153
|
+
self.freq,
|
|
154
|
+
self.amp,
|
|
155
|
+
self.motion,
|
|
156
|
+
self.kind,
|
|
157
|
+
self.duration,
|
|
158
|
+
self.sampling_rate,
|
|
159
|
+
dict(self.meta),
|
|
160
|
+
),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# ------------------------------------------------------------------ units
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def unit(self) -> str:
|
|
167
|
+
"""Unit string, e.g. ``m/s*s`` for a velocity FAS."""
|
|
168
|
+
return self.kind.unit(self.motion)
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def n_samples(self) -> int:
|
|
172
|
+
"""Samples in the source record, ``duration * sampling_rate``.
|
|
173
|
+
|
|
174
|
+
The third of the triple, derived rather than stored so it cannot
|
|
175
|
+
disagree with the other two. Validated on construction — see
|
|
176
|
+
:func:`_validate_record_geometry` for why that matters.
|
|
177
|
+
|
|
178
|
+
Note this is the *record* length, not ``len(freq)``. Zero-padding
|
|
179
|
+
changes the second and not the first, and confusing them is the §2.2
|
|
180
|
+
bug.
|
|
181
|
+
"""
|
|
182
|
+
return round(self.duration * self.sampling_rate)
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def nyquist(self) -> float:
|
|
186
|
+
return self.sampling_rate / 2.0
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def frequency_resolution(self) -> float:
|
|
190
|
+
"""``1/T`` — the narrowest frequency difference the record can resolve.
|
|
191
|
+
|
|
192
|
+
This is the low-frequency floor the SNR bandwidth search must respect;
|
|
193
|
+
nothing enforced it before, so a short window could report usable
|
|
194
|
+
bandwidth below what it could physically resolve.
|
|
195
|
+
"""
|
|
196
|
+
return 1.0 / self.duration
|
|
197
|
+
|
|
198
|
+
def to_kind(self, kind: AmplitudeKind | str) -> Spectrum:
|
|
199
|
+
"""Convert between FAS, MAGNITUDE, PSD and ASD.
|
|
200
|
+
|
|
201
|
+
Conversions go via FAS rather than being enumerated pairwise, so the
|
|
202
|
+
factors of ``2T`` and of the fold each live in exactly one place.
|
|
203
|
+
|
|
204
|
+
``MAGNITUDE`` is the conversion to reach for when reading a long-period
|
|
205
|
+
level: ``Omega`` is defined on ``|X|``, not on the folded ``FAS``, and
|
|
206
|
+
the two differ by two. Asking for it by name is the point — the factor
|
|
207
|
+
is easy to apply by hand and easy to apply twice, or not at all.
|
|
208
|
+
"""
|
|
209
|
+
target = AmplitudeKind(kind)
|
|
210
|
+
if target is self.kind:
|
|
211
|
+
return self
|
|
212
|
+
fas = self._to_fas()
|
|
213
|
+
if target is AmplitudeKind.FAS:
|
|
214
|
+
return fas
|
|
215
|
+
if target is AmplitudeKind.MAGNITUDE:
|
|
216
|
+
# Undo the fold: FAS carries the negative-frequency half, |X| does not.
|
|
217
|
+
return replace(fas, amp=fas.amp / self._fold_factor(), kind=target)
|
|
218
|
+
two_t = 2.0 * self.duration
|
|
219
|
+
if target is AmplitudeKind.PSD:
|
|
220
|
+
amp = fas.amp**2 / two_t
|
|
221
|
+
else: # ASD
|
|
222
|
+
amp = fas.amp / np.sqrt(two_t)
|
|
223
|
+
return replace(fas, amp=amp, kind=target)
|
|
224
|
+
|
|
225
|
+
def _fold_factor(self) -> NDArray[np.float64]:
|
|
226
|
+
"""Per-bin ratio between the folded ``FAS`` and the unfolded ``|X|``.
|
|
227
|
+
|
|
228
|
+
Two everywhere except DC and Nyquist, which have no negative-frequency
|
|
229
|
+
twin to fold in — a real signal's transform is conjugate-symmetric, and
|
|
230
|
+
those two bins are their own mirror image. A blanket factor of two is
|
|
231
|
+
therefore wrong at both ends, by exactly two.
|
|
232
|
+
|
|
233
|
+
**Parity matters here.** An ``rfft`` of an even-length record ends
|
|
234
|
+
exactly on Nyquist; an odd-length one ends half a bin below it, at
|
|
235
|
+
``fs/2 * (n-1)/n``, and that bin *does* have a twin and *is* folded. So
|
|
236
|
+
which bins are special depends on the record length as well as on
|
|
237
|
+
``drop_dc``, and is read off the axis rather than assumed.
|
|
238
|
+
|
|
239
|
+
The tolerance is derived from the axis's own bin spacing rather than
|
|
240
|
+
being a fixed relative one. For an odd-length record the top bin sits
|
|
241
|
+
``df/2`` below Nyquist, and ``df`` shrinks as the record lengthens — so
|
|
242
|
+
a fixed ``rtol`` eventually swallows the gap and folds that bin wrongly.
|
|
243
|
+
With ``numpy``'s default it does so from about 200000 samples, which at
|
|
244
|
+
1000 Hz is a 200 s record. Scaling with ``df`` keeps the two cases
|
|
245
|
+
separated at any length.
|
|
246
|
+
"""
|
|
247
|
+
factor = np.full(self.freq.shape, 2.0)
|
|
248
|
+
if self.freq.size == 0:
|
|
249
|
+
return factor
|
|
250
|
+
# A hundredth of the narrowest spacing: far tighter than the df/2 gap
|
|
251
|
+
# that separates an odd-length top bin from Nyquist, and far looser
|
|
252
|
+
# than floating-point error on an even-length one, which lands exactly.
|
|
253
|
+
spacing = float(np.min(np.diff(self.freq))) if self.freq.size > 1 else 0.0
|
|
254
|
+
tol = 0.01 * spacing if spacing > 0 else 1e-12
|
|
255
|
+
factor[self.freq < tol] = 1.0
|
|
256
|
+
factor[np.abs(self.freq - self.nyquist) < tol] = 1.0
|
|
257
|
+
return factor
|
|
258
|
+
|
|
259
|
+
def _to_fas(self) -> Spectrum:
|
|
260
|
+
if self.kind is AmplitudeKind.FAS:
|
|
261
|
+
return self
|
|
262
|
+
if self.kind is AmplitudeKind.MAGNITUDE:
|
|
263
|
+
return replace(
|
|
264
|
+
self, amp=self.amp * self._fold_factor(), kind=AmplitudeKind.FAS
|
|
265
|
+
)
|
|
266
|
+
two_t = 2.0 * self.duration
|
|
267
|
+
if self.kind is AmplitudeKind.PSD:
|
|
268
|
+
amp = np.sqrt(self.amp * two_t)
|
|
269
|
+
else: # ASD
|
|
270
|
+
amp = self.amp * np.sqrt(two_t)
|
|
271
|
+
return replace(self, amp=amp, kind=AmplitudeKind.FAS)
|
|
272
|
+
|
|
273
|
+
def to_motion(self, motion: Motion | str) -> Spectrum:
|
|
274
|
+
"""Integrate or differentiate to another ground-motion domain.
|
|
275
|
+
|
|
276
|
+
Multiplies by ``(2*pi*f)`` per order of differentiation. Only valid on
|
|
277
|
+
an amplitude-like kind, so a PSD is converted to FAS, transformed, and
|
|
278
|
+
converted back — squaring the frequency factor would otherwise be
|
|
279
|
+
silently wrong.
|
|
280
|
+
"""
|
|
281
|
+
target = Motion(motion)
|
|
282
|
+
if target is self.motion:
|
|
283
|
+
return self
|
|
284
|
+
if self.kind is not AmplitudeKind.FAS:
|
|
285
|
+
return self.to_kind(AmplitudeKind.FAS).to_motion(target).to_kind(self.kind)
|
|
286
|
+
order = target.derivative_order - self.motion.derivative_order
|
|
287
|
+
factor = (2.0 * np.pi * self.freq) ** order
|
|
288
|
+
return replace(self, amp=self.amp * factor, motion=target)
|
|
289
|
+
|
|
290
|
+
# ------------------------------------------------------------- operations
|
|
291
|
+
|
|
292
|
+
def band(self, fmin: float | None = None, fmax: float | None = None) -> Spectrum:
|
|
293
|
+
"""Restrict to a frequency band, inclusive of both bounds."""
|
|
294
|
+
mask = np.ones(self.freq.shape, dtype=bool)
|
|
295
|
+
if fmin is not None:
|
|
296
|
+
mask &= self.freq >= fmin
|
|
297
|
+
if fmax is not None:
|
|
298
|
+
mask &= self.freq <= fmax
|
|
299
|
+
if not mask.any():
|
|
300
|
+
raise ValueError(
|
|
301
|
+
f"No samples in band [{fmin}, {fmax}] Hz; spectrum spans "
|
|
302
|
+
f"[{self.freq[0]:.4g}, {self.freq[-1]:.4g}] Hz."
|
|
303
|
+
)
|
|
304
|
+
return replace(self, freq=self.freq[mask], amp=self.amp[mask])
|
|
305
|
+
|
|
306
|
+
def energy(self) -> float:
|
|
307
|
+
"""Total signal energy, ``sum(x^2) * dt``, recovered from the spectrum.
|
|
308
|
+
|
|
309
|
+
This is the quantity Parseval's theorem ties to the time domain, and it
|
|
310
|
+
is what the cross-estimator normalisation test asserts. For a one-sided
|
|
311
|
+
FAS the two-sided integral folds to ``integral of A^2 / 2 df``.
|
|
312
|
+
"""
|
|
313
|
+
fas = self.to_kind(AmplitudeKind.FAS)
|
|
314
|
+
return float(np.trapezoid(fas.amp**2 / 2.0, fas.freq))
|
|
315
|
+
|
|
316
|
+
def __len__(self) -> int:
|
|
317
|
+
return int(self.freq.size)
|
|
318
|
+
|
|
319
|
+
def __repr__(self) -> str:
|
|
320
|
+
sid = self.meta.get("id", "")
|
|
321
|
+
where = f" {sid}" if sid else ""
|
|
322
|
+
return (
|
|
323
|
+
f"Spectrum({self.kind.value}, {self.motion.value},{where} "
|
|
324
|
+
f"n={len(self)}, {self.freq[0]:.3g}-{self.freq[-1]:.3g} Hz, "
|
|
325
|
+
f"T={self.duration:.4g} s, [{self.unit}])"
|
|
326
|
+
)
|
specmod/core/units.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Physical units and domains, as types rather than conventions.
|
|
2
|
+
|
|
3
|
+
The pre-refactor code tracked none of this. A ``Spectrum`` did not know whether
|
|
4
|
+
it held power or amplitude, nor whether it was in displacement, velocity or
|
|
5
|
+
acceleration; that lived in ``Models.MOTION``, a module global read at import
|
|
6
|
+
time which the user had to keep in sync by hand with however many times they
|
|
7
|
+
had called ``.inte()`` or ``.diff()``. Getting it wrong returned a wrong seismic
|
|
8
|
+
moment with no error anywhere.
|
|
9
|
+
|
|
10
|
+
Making both a typed attribute turns those silent factor errors into exceptions.
|
|
11
|
+
|
|
12
|
+
Conventions
|
|
13
|
+
-----------
|
|
14
|
+
The canonical amplitude kind is the **folded** one-sided Fourier amplitude
|
|
15
|
+
spectrum (:attr:`AmplitudeKind.FAS`), in units of ``[signal] * s``. "Folded"
|
|
16
|
+
means the negative-frequency half has been added in, so ``FAS = 2|X|`` where
|
|
17
|
+
``X`` is the Fourier transform. That is what makes energy recoverable by
|
|
18
|
+
integrating over non-negative frequencies alone, and it is why every estimator
|
|
19
|
+
here can be held to one Parseval check.
|
|
20
|
+
|
|
21
|
+
**It is not the quantity the source model is written in.** Omega, the
|
|
22
|
+
long-period spectral level, is the plateau of ``|X|`` — at zero frequency
|
|
23
|
+
``|X(0)| = |integral u dt|``, which is what ``M0`` is proportional to. Reading
|
|
24
|
+
``FAS`` as Omega puts ``M0`` out by two, which is 0.2 magnitude units. Use
|
|
25
|
+
:attr:`AmplitudeKind.MAGNITUDE` for that, and let :meth:`Spectrum.to_kind`
|
|
26
|
+
apply the factor rather than doing it by hand.
|
|
27
|
+
|
|
28
|
+
Relationships between the kinds, for a record of duration ``T``:
|
|
29
|
+
|
|
30
|
+
============= ======================== ==================================
|
|
31
|
+
Kind Units From FAS ``A``
|
|
32
|
+
============= ======================== ==================================
|
|
33
|
+
``FAS`` ``[x] * s`` --
|
|
34
|
+
``MAGNITUDE`` ``[x] * s`` ``|X| = A / 2``
|
|
35
|
+
``PSD`` ``[x]^2 / Hz`` ``P = A^2 / (2 T)``
|
|
36
|
+
``ASD`` ``[x] / sqrt(Hz)`` ``D = A / sqrt(2 T)``
|
|
37
|
+
============= ======================== ==================================
|
|
38
|
+
|
|
39
|
+
Parseval takes a different form in each amplitude convention, which is the
|
|
40
|
+
whole reason both are named here rather than left to the caller::
|
|
41
|
+
|
|
42
|
+
E = integral A**2 / 2 df (FAS, folded)
|
|
43
|
+
E = 2 * integral |X|**2 df (MAGNITUDE, unfolded)
|
|
44
|
+
|
|
45
|
+
``T`` is the **physical record duration**, ``n_samples * dt``. It is never
|
|
46
|
+
inferred from the length of the frequency axis: zero-padding changes that length
|
|
47
|
+
while leaving the duration alone, which is precisely how the old
|
|
48
|
+
``psd_to_amp`` acquired a padding-dependent error.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
from __future__ import annotations
|
|
52
|
+
|
|
53
|
+
from enum import StrEnum
|
|
54
|
+
|
|
55
|
+
__all__ = ["AmplitudeKind", "Motion"]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Motion(StrEnum):
|
|
59
|
+
"""Ground-motion domain of a time series or spectrum."""
|
|
60
|
+
|
|
61
|
+
DISPLACEMENT = "displacement"
|
|
62
|
+
VELOCITY = "velocity"
|
|
63
|
+
ACCELERATION = "acceleration"
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def derivative_order(self) -> int:
|
|
67
|
+
"""Order of time differentiation relative to displacement.
|
|
68
|
+
|
|
69
|
+
Converting between domains multiplies the spectrum by ``(2*pi*f)`` per
|
|
70
|
+
order, so the difference of two orders gives the exponent directly.
|
|
71
|
+
"""
|
|
72
|
+
return {"displacement": 0, "velocity": 1, "acceleration": 2}[self.value]
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def unit(self) -> str:
|
|
76
|
+
"""SI unit of the time-domain signal."""
|
|
77
|
+
return {"displacement": "m", "velocity": "m/s", "acceleration": "m/s^2"}[
|
|
78
|
+
self.value
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class AmplitudeKind(StrEnum):
|
|
83
|
+
"""What the amplitude axis of a spectrum represents."""
|
|
84
|
+
|
|
85
|
+
#: Folded one-sided Fourier amplitude spectrum, ``2|X|``, in ``[x] * s``.
|
|
86
|
+
#: Energy is ``integral(FAS**2 / 2) df``. The default, because it is the
|
|
87
|
+
#: convention in which one Parseval check covers every estimator.
|
|
88
|
+
FAS = "fas"
|
|
89
|
+
#: Unfolded Fourier transform magnitude, ``|X| = |rfft(x)| * dt``, in
|
|
90
|
+
#: ``[x] * s``. **This is the one Omega is defined in**, and the one to
|
|
91
|
+
#: read a long-period spectral level off. Energy is
|
|
92
|
+
#: ``2 * integral(|X|**2) df``.
|
|
93
|
+
MAGNITUDE = "magnitude"
|
|
94
|
+
#: One-sided power spectral density, ``[x]^2 / Hz``.
|
|
95
|
+
PSD = "psd"
|
|
96
|
+
#: One-sided amplitude spectral density, ``[x] / sqrt(Hz)``.
|
|
97
|
+
ASD = "asd"
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def is_amplitude(self) -> bool:
|
|
101
|
+
"""Whether this kind scales linearly with the record.
|
|
102
|
+
|
|
103
|
+
The distinction that matters for :meth:`Spectrum.to_motion`: applying
|
|
104
|
+
a ``2*pi*f`` factor to a squared quantity is wrong by ``2*pi*f`` again.
|
|
105
|
+
"""
|
|
106
|
+
return self in (AmplitudeKind.FAS, AmplitudeKind.MAGNITUDE, AmplitudeKind.ASD)
|
|
107
|
+
|
|
108
|
+
def unit(self, motion: Motion) -> str:
|
|
109
|
+
"""Full unit string for this kind in a given motion domain."""
|
|
110
|
+
base = motion.unit
|
|
111
|
+
return {
|
|
112
|
+
"fas": f"{base}*s",
|
|
113
|
+
"magnitude": f"{base}*s",
|
|
114
|
+
"psd": f"({base})^2/Hz",
|
|
115
|
+
"asd": f"{base}/sqrt(Hz)",
|
|
116
|
+
}[self.value]
|