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,357 @@
|
|
|
1
|
+
"""Thomson multitaper estimation on DPSS tapers.
|
|
2
|
+
|
|
3
|
+
This replaces ``mtspec``, which is a ctypes wrapper around Fortran, ships as
|
|
4
|
+
source with no wheels, has had no release since 2016, and does not build
|
|
5
|
+
without a Fortran compiler — so it made the whole package uninstallable.
|
|
6
|
+
|
|
7
|
+
The implementation here uses :func:`scipy.signal.windows.dpss`, in SciPy since
|
|
8
|
+
1.1, and adds Thomson's adaptive weighting. That is a few dozen lines and no
|
|
9
|
+
new dependency, and it puts the normalisation under our control, which is what
|
|
10
|
+
lets one contract cover every backend.
|
|
11
|
+
|
|
12
|
+
Prieto's pure-Python ``multitaper`` package remains available behind the
|
|
13
|
+
``specmod[multitaper]`` extra for the things it does that this does not:
|
|
14
|
+
jackknife confidence intervals, the F-test for spectral lines, and coherence.
|
|
15
|
+
|
|
16
|
+
.. warning::
|
|
17
|
+
|
|
18
|
+
**Multitaper assumes stationarity, and estimated energy depends on where in
|
|
19
|
+
the window a transient sits.** This is about the analysis window's own
|
|
20
|
+
contents, not about signal leaking into a noise window.
|
|
21
|
+
|
|
22
|
+
Measured through this class on an identical 10%-wide burst moved across a
|
|
23
|
+
2000-sample window (estimated energy / true energy):
|
|
24
|
+
|
|
25
|
+
======== ============ ======== ============== ==============
|
|
26
|
+
Position ``adaptive`` flat sum of taper^2 FFT, 5% Tukey
|
|
27
|
+
======== ============ ======== ============== ==============
|
|
28
|
+
10% 0.956 0.973 0.948 1.031
|
|
29
|
+
25% 1.079 1.075 1.071 1.031
|
|
30
|
+
50% 1.151 1.145 1.148 1.031
|
|
31
|
+
75% 1.070 1.066 1.072 1.031
|
|
32
|
+
90% 0.898 0.916 0.951 1.031
|
|
33
|
+
uniform 1.010 1.009 -- 0.995
|
|
34
|
+
======== ============ ======== ============== ==============
|
|
35
|
+
|
|
36
|
+
The bias is modest, +/-15%, it is the *same* under either weighting, and it
|
|
37
|
+
tracks the summed taper envelope almost exactly — compare against the
|
|
38
|
+
``taper^2`` column. That is simply the taper weighting the middle of the
|
|
39
|
+
record more than the ends, and it applied equally to ``mtspec``, which uses
|
|
40
|
+
the same tapers. It is therefore present in pre-refactor results rather than
|
|
41
|
+
introduced here, and nothing is silently corrected — see
|
|
42
|
+
``docs/REFACTOR_PLAN.md`` §5.2.6.
|
|
43
|
+
|
|
44
|
+
It matters for the published workflow specifically: refining the window to
|
|
45
|
+
the 1st-99th percentiles of cumulative energy trims the quiet lead-in while
|
|
46
|
+
the coda tail remains, which pushes the arrival away from centre.
|
|
47
|
+
|
|
48
|
+
Two ways out, both opt-in. Set ``center=True`` to remove the position
|
|
49
|
+
dependence entirely — a circular shift leaves ``|FFT|`` unchanged, so it is
|
|
50
|
+
free. Or prefer :class:`~specmod.transforms.fft.FFTEstimator` with a light
|
|
51
|
+
taper, which holds 1.03x regardless of position, when absolute energy
|
|
52
|
+
fidelity matters more than variance reduction.
|
|
53
|
+
|
|
54
|
+
.. note::
|
|
55
|
+
|
|
56
|
+
**Validated against Prieto's** ``multitaper``. With
|
|
57
|
+
``normalize_to_variance=True`` putting both on the same absolute scale, the
|
|
58
|
+
two agree to within 0.3% across the band, under adaptive and flat weighting
|
|
59
|
+
alike, for stationary noise and for bursts at 10%, 50% and 90%.
|
|
60
|
+
|
|
61
|
+
The check that earns that is the transient one. A Parseval test on
|
|
62
|
+
stationary noise passes for a whole family of weighting errors — the signal
|
|
63
|
+
term is large at every frequency, so the regularisation in Eq. 5.1b never
|
|
64
|
+
gets a chance to dominate. See :func:`_adaptive_weights` for the units trap
|
|
65
|
+
that hides there.
|
|
66
|
+
|
|
67
|
+
References
|
|
68
|
+
----------
|
|
69
|
+
Thomson, D.J. (1982). Spectrum estimation and harmonic analysis.
|
|
70
|
+
*Proc. IEEE* 70(9), 1055-1096.
|
|
71
|
+
|
|
72
|
+
Prieto, G.A. (2022). The multitaper spectrum analysis package in Python.
|
|
73
|
+
*Seismological Research Letters* 93(3), 1922-1929.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
from __future__ import annotations
|
|
77
|
+
|
|
78
|
+
from dataclasses import dataclass
|
|
79
|
+
from typing import Any
|
|
80
|
+
|
|
81
|
+
import numpy as np
|
|
82
|
+
from numpy.typing import ArrayLike, NDArray
|
|
83
|
+
from scipy.signal.windows import dpss
|
|
84
|
+
|
|
85
|
+
from ..core.spectrum import Spectrum
|
|
86
|
+
from ..core.units import AmplitudeKind, Motion
|
|
87
|
+
from .base import build_spectrum, prepare_record
|
|
88
|
+
|
|
89
|
+
__all__ = ["MultitaperEstimator"]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _broadband_power(eigenspectra: NDArray[np.float64], n: int) -> float:
|
|
93
|
+
"""Mean eigenspectral power per two-sided bin — Thomson's ``sigma^2``.
|
|
94
|
+
|
|
95
|
+
This is the white level that a taper's out-of-band leakage would deliver,
|
|
96
|
+
and it is what Thomson's Eq. 5.1b multiplies by ``1 - lambda_k``. It must
|
|
97
|
+
be expressed in the *same units as the eigenspectra*, which is the whole
|
|
98
|
+
reason it is computed from them here rather than taken from the record.
|
|
99
|
+
|
|
100
|
+
Accepts either layout. Given the full ``n`` two-sided bins the mean is
|
|
101
|
+
direct; given the ``n // 2 + 1`` non-negative bins of an ``rfft`` the
|
|
102
|
+
two-sided sum is recovered by doubling and removing the double-counted DC
|
|
103
|
+
and (for even ``n``) Nyquist terms. The two cannot be confused, since
|
|
104
|
+
``n // 2 + 1 != n`` for any ``n >= 2``.
|
|
105
|
+
"""
|
|
106
|
+
if eigenspectra.shape[-1] == n:
|
|
107
|
+
return float(eigenspectra.mean())
|
|
108
|
+
total = 2.0 * eigenspectra.sum(axis=-1) - eigenspectra[..., 0]
|
|
109
|
+
if n % 2 == 0:
|
|
110
|
+
total -= eigenspectra[..., -1]
|
|
111
|
+
return float((total / n).mean())
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def center_on_energy_centroid(
|
|
115
|
+
x: NDArray[np.float64], edge_tolerance: float
|
|
116
|
+
) -> NDArray[np.float64]:
|
|
117
|
+
"""Circularly shift ``x`` so its energy centroid sits mid-window.
|
|
118
|
+
|
|
119
|
+
Legitimate because ``|FFT|`` is invariant under a circular shift: the
|
|
120
|
+
quantity being estimated does not change, only its alignment with the
|
|
121
|
+
tapers. Shared by every estimator that offers ``center``, so the wrap
|
|
122
|
+
check cannot drift between them.
|
|
123
|
+
|
|
124
|
+
Raises when the window edges are not quiet enough to roll without
|
|
125
|
+
splicing a discontinuity into the record.
|
|
126
|
+
"""
|
|
127
|
+
energy = x**2
|
|
128
|
+
centroid = int((np.arange(x.size) * energy).sum() / energy.sum())
|
|
129
|
+
rolled: NDArray[np.float64] = np.roll(x, x.size // 2 - centroid)
|
|
130
|
+
peak = float(np.abs(rolled).max())
|
|
131
|
+
if peak > 0:
|
|
132
|
+
step = abs(float(rolled[0]) - float(rolled[-1])) / peak
|
|
133
|
+
if step > edge_tolerance:
|
|
134
|
+
raise ValueError(
|
|
135
|
+
f"Centring would introduce a discontinuity of {step:.3f} of "
|
|
136
|
+
f"the record's peak at the wrap point, above "
|
|
137
|
+
f"center_edge_tolerance={edge_tolerance}. The "
|
|
138
|
+
f"window edges are not quiet enough to roll; taper first, "
|
|
139
|
+
f"widen the window, or use FFTEstimator, which is "
|
|
140
|
+
f"position-stable without centring."
|
|
141
|
+
)
|
|
142
|
+
return rolled
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _adaptive_weights(
|
|
146
|
+
eigenspectra: NDArray[np.float64],
|
|
147
|
+
eigenvalues: NDArray[np.float64],
|
|
148
|
+
n: int,
|
|
149
|
+
*,
|
|
150
|
+
max_iter: int = 1000,
|
|
151
|
+
tol: float = 9.5e-7,
|
|
152
|
+
) -> NDArray[np.float64]:
|
|
153
|
+
"""Thomson's adaptive weights.
|
|
154
|
+
|
|
155
|
+
Higher-order tapers leak more, so weighting them equally lets out-of-band
|
|
156
|
+
power contaminate the estimate. The weights downweight a taper wherever its
|
|
157
|
+
broadband leakage would dominate the local signal, which matters here: a
|
|
158
|
+
seismic spectrum spans orders of magnitude in amplitude, so leakage from the
|
|
159
|
+
peak can swamp the high-frequency tail entirely.
|
|
160
|
+
|
|
161
|
+
Iterates ``w_k = sqrt(lambda_k) * S / (lambda_k * S + b_k)`` to convergence,
|
|
162
|
+
with ``b_k = (1 - lambda_k) * sigma^2`` from Thomson Eq. 5.1b, and clips the
|
|
163
|
+
weights at unity so that an unbiased taper is weighted exactly once.
|
|
164
|
+
|
|
165
|
+
``sigma^2`` is derived from the eigenspectra rather than supplied, which
|
|
166
|
+
makes the routine invariant to how the caller has scaled them. That is not
|
|
167
|
+
a stylistic choice: passing the record's *time-domain* variance against
|
|
168
|
+
eigenspectra already scaled to PSD overstates ``b_k`` by a factor of
|
|
169
|
+
``1/dt``, which drives every weight towards zero for any record whose
|
|
170
|
+
energy is concentrated away from the taper centre. See the module docstring.
|
|
171
|
+
"""
|
|
172
|
+
sigma2 = _broadband_power(eigenspectra, n)
|
|
173
|
+
b_k = (1.0 - eigenvalues)[:, None] * sigma2
|
|
174
|
+
sqrt_lambda = np.sqrt(eigenvalues)[:, None]
|
|
175
|
+
lam = eigenvalues[:, None]
|
|
176
|
+
|
|
177
|
+
# Start from the two least-leaky tapers, as Thomson recommends.
|
|
178
|
+
spectrum = eigenspectra[:2].mean(axis=0)
|
|
179
|
+
weights = np.ones_like(eigenspectra)
|
|
180
|
+
for _ in range(max_iter):
|
|
181
|
+
denom = np.maximum(lam * spectrum + b_k, 1e-300)
|
|
182
|
+
weights = np.minimum(sqrt_lambda * spectrum / denom, 1.0)
|
|
183
|
+
w2 = weights**2
|
|
184
|
+
updated = (w2 * eigenspectra).sum(axis=0) / np.maximum(w2.sum(axis=0), 1e-300)
|
|
185
|
+
change = np.abs(updated - spectrum) / np.maximum(updated + spectrum, 1e-300)
|
|
186
|
+
spectrum = updated
|
|
187
|
+
if change.max() < tol:
|
|
188
|
+
break
|
|
189
|
+
return weights
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@dataclass(frozen=True)
|
|
193
|
+
class MultitaperEstimator:
|
|
194
|
+
"""Multitaper spectrum estimate.
|
|
195
|
+
|
|
196
|
+
Parameters
|
|
197
|
+
----------
|
|
198
|
+
time_bandwidth
|
|
199
|
+
The time-bandwidth product ``NW``. Larger values reduce variance and
|
|
200
|
+
leakage at the cost of frequency resolution. In the pre-refactor code
|
|
201
|
+
this was the literal ``3`` passed positionally to ``mtspec``, with no
|
|
202
|
+
way to configure it.
|
|
203
|
+
n_tapers
|
|
204
|
+
Number of DPSS tapers. Must not exceed ``2*NW - 1``, beyond which the
|
|
205
|
+
tapers are poorly concentrated and add leakage rather than reducing
|
|
206
|
+
variance; exceeding it raises rather than silently degrading.
|
|
207
|
+
adaptive
|
|
208
|
+
Apply Thomson's adaptive weighting. When ``False``, tapers are averaged
|
|
209
|
+
with equal weight.
|
|
210
|
+
|
|
211
|
+
On by default, because leakage suppression is the reason to reach for
|
|
212
|
+
multitaper at all and flat weighting does not provide it. Measured on a
|
|
213
|
+
2 Hz line 10^6 times stronger than the background — a mild version of
|
|
214
|
+
what a seismic spectrum does across its band — the recovered noise
|
|
215
|
+
floor between 20 and 49 Hz sits **287x** above the truth with flat
|
|
216
|
+
weighting and **1.1x** with adaptive. A Brune fit reads ``t*`` and
|
|
217
|
+
``f_c`` off exactly that high-frequency decay, so an inflated floor is
|
|
218
|
+
not a cosmetic problem.
|
|
219
|
+
|
|
220
|
+
The cost is resolution: adaptive weighting downweights the higher-order
|
|
221
|
+
tapers wherever leakage would dominate, so it uses fewer effective
|
|
222
|
+
degrees of freedom and gives a noisier estimate in bands where the
|
|
223
|
+
signal is strong. Turn it off for a well-conditioned record with little
|
|
224
|
+
dynamic range, where the extra averaging is worth more than the leakage
|
|
225
|
+
rejection.
|
|
226
|
+
center
|
|
227
|
+
Circularly shift the record so its energy centroid sits mid-window
|
|
228
|
+
before estimating.
|
|
229
|
+
|
|
230
|
+
This removes the position dependence **entirely** rather than reducing
|
|
231
|
+
it: measured across start positions from 2% to 78%, the recovered
|
|
232
|
+
energy ratio is identical to three decimal places once centred, and the
|
|
233
|
+
spectrum matches the naturally-centred case exactly. It is legitimate
|
|
234
|
+
because ``|FFT|`` is invariant under a circular shift, so the quantity
|
|
235
|
+
being estimated does not change.
|
|
236
|
+
|
|
237
|
+
What remains after centring is the taper concentration itself — a
|
|
238
|
+
compact centred transient still reads about 1.16x high with flat
|
|
239
|
+
weighting. That bias is *consistent* rather than position-dependent,
|
|
240
|
+
which matters: a consistent multiplicative bias cancels in any ratio
|
|
241
|
+
(signal-to-noise, spectral ratios, relative amplitudes between stations)
|
|
242
|
+
and can be calibrated, where a position-dependent one cannot.
|
|
243
|
+
|
|
244
|
+
Off by default because a circular shift wraps. It is safe when the
|
|
245
|
+
window edges are quiet, and refuses when they are not — see
|
|
246
|
+
``center_edge_tolerance``.
|
|
247
|
+
center_edge_tolerance
|
|
248
|
+
Maximum amplitude at the wrap point, as a fraction of the record's
|
|
249
|
+
peak, before centring raises rather than introducing a discontinuity.
|
|
250
|
+
A window whose coda is still strong at the end cannot be safely rolled.
|
|
251
|
+
normalize_to_variance
|
|
252
|
+
Rescale the whole spectrum so it integrates to the record's variance,
|
|
253
|
+
as Prieto's ``multitaper`` package does (``mtspec.py``: ``sscal =
|
|
254
|
+
xvar / (sum(spec)*df)``). ``mtspec`` wrapped the same lineage, so
|
|
255
|
+
**enable this when reproducing pre-refactor results.**
|
|
256
|
+
|
|
257
|
+
Off by default because it costs you a diagnostic. With it on,
|
|
258
|
+
``Spectrum.energy()`` returns the input energy whatever else is wrong,
|
|
259
|
+
so it can no longer tell you whether the normalisation is sound — the
|
|
260
|
+
one check that would catch a mis-scaled spectrum always passes. Left
|
|
261
|
+
off, ``energy()`` is a live measurement of your own record.
|
|
262
|
+
|
|
263
|
+
It is a calibration, not a derivation: a single scalar chosen to force
|
|
264
|
+
the integral, which is why it cannot change spectral *shape*. Total
|
|
265
|
+
power comes out right and the shape absorbs whatever error remains, so
|
|
266
|
+
it does not make the long-period level position-independent — see the
|
|
267
|
+
warning above, and ``center`` for something that does.
|
|
268
|
+
"""
|
|
269
|
+
|
|
270
|
+
time_bandwidth: float = 3.0
|
|
271
|
+
n_tapers: int = 5
|
|
272
|
+
adaptive: bool = True
|
|
273
|
+
center: bool = False
|
|
274
|
+
center_edge_tolerance: float = 0.05
|
|
275
|
+
normalize_to_variance: bool = False
|
|
276
|
+
drop_dc: bool = True
|
|
277
|
+
name: str = "multitaper"
|
|
278
|
+
|
|
279
|
+
def __post_init__(self) -> None:
|
|
280
|
+
if self.time_bandwidth <= 0:
|
|
281
|
+
raise ValueError(
|
|
282
|
+
f"time_bandwidth must be positive, got {self.time_bandwidth}"
|
|
283
|
+
)
|
|
284
|
+
limit = int(2 * self.time_bandwidth - 1)
|
|
285
|
+
if self.n_tapers < 1:
|
|
286
|
+
raise ValueError(f"n_tapers must be at least 1, got {self.n_tapers}")
|
|
287
|
+
if self.n_tapers > limit:
|
|
288
|
+
raise ValueError(
|
|
289
|
+
f"n_tapers={self.n_tapers} exceeds 2*NW-1={limit} for "
|
|
290
|
+
f"time_bandwidth={self.time_bandwidth}. Tapers beyond that are "
|
|
291
|
+
f"poorly concentrated and add leakage rather than reducing "
|
|
292
|
+
f"variance; raise time_bandwidth or lower n_tapers."
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
def _centered(self, x: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
296
|
+
return center_on_energy_centroid(x, self.center_edge_tolerance)
|
|
297
|
+
|
|
298
|
+
def estimate(
|
|
299
|
+
self,
|
|
300
|
+
data: ArrayLike,
|
|
301
|
+
dt: float,
|
|
302
|
+
*,
|
|
303
|
+
motion: Motion | str = Motion.VELOCITY,
|
|
304
|
+
meta: dict[str, Any] | None = None,
|
|
305
|
+
) -> Spectrum:
|
|
306
|
+
x, n, duration = prepare_record(data, dt)
|
|
307
|
+
if self.center:
|
|
308
|
+
x = self._centered(x)
|
|
309
|
+
|
|
310
|
+
tapers, eigenvalues = dpss(
|
|
311
|
+
n, self.time_bandwidth, self.n_tapers, sym=False, return_ratios=True
|
|
312
|
+
)
|
|
313
|
+
# DPSS tapers are unit-norm; scale so each eigenspectrum is a PSD in
|
|
314
|
+
# [x]^2/Hz, then fold to one-sided.
|
|
315
|
+
spectra = np.fft.rfft(tapers * x, axis=-1)
|
|
316
|
+
eigenspectra = (np.abs(spectra) ** 2) * dt
|
|
317
|
+
|
|
318
|
+
if self.adaptive and self.n_tapers > 1:
|
|
319
|
+
weights = _adaptive_weights(eigenspectra, eigenvalues, n)
|
|
320
|
+
w2 = weights**2
|
|
321
|
+
psd = (w2 * eigenspectra).sum(axis=0) / np.maximum(w2.sum(axis=0), 1e-300)
|
|
322
|
+
else:
|
|
323
|
+
psd = eigenspectra.mean(axis=0)
|
|
324
|
+
|
|
325
|
+
freq: NDArray[np.float64] = np.fft.rfftfreq(n, d=dt).astype(np.float64)
|
|
326
|
+
psd[1:] *= 2.0 # fold negative frequencies
|
|
327
|
+
if n % 2 == 0:
|
|
328
|
+
psd[-1] /= 2.0
|
|
329
|
+
|
|
330
|
+
if self.normalize_to_variance:
|
|
331
|
+
# Prieto's convention: pin the integral to the record variance.
|
|
332
|
+
df = float(freq[1] - freq[0])
|
|
333
|
+
total = float(psd.sum() * df)
|
|
334
|
+
if total > 0:
|
|
335
|
+
psd = psd * (float(x.var()) / total)
|
|
336
|
+
|
|
337
|
+
if self.drop_dc:
|
|
338
|
+
freq, psd = freq[1:], psd[1:]
|
|
339
|
+
|
|
340
|
+
spectrum = build_spectrum(
|
|
341
|
+
freq,
|
|
342
|
+
psd,
|
|
343
|
+
kind=AmplitudeKind.PSD,
|
|
344
|
+
motion=motion,
|
|
345
|
+
duration=duration,
|
|
346
|
+
sampling_rate=1.0 / dt,
|
|
347
|
+
meta={
|
|
348
|
+
**(meta or {}),
|
|
349
|
+
"time_bandwidth": self.time_bandwidth,
|
|
350
|
+
"n_tapers": self.n_tapers,
|
|
351
|
+
"adaptive": self.adaptive,
|
|
352
|
+
"centered": self.center,
|
|
353
|
+
"normalize_to_variance": self.normalize_to_variance,
|
|
354
|
+
},
|
|
355
|
+
estimator=self.name,
|
|
356
|
+
)
|
|
357
|
+
return spectrum.to_kind(AmplitudeKind.FAS)
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Backend for Prieto's ``multitaper`` package.
|
|
2
|
+
|
|
3
|
+
The direct successor to the Fortran library that ``mtspec`` wrapped, by the same
|
|
4
|
+
author and pure Python. It is worth having alongside
|
|
5
|
+
:class:`~specmod.transforms.multitaper.MultitaperEstimator` for three reasons:
|
|
6
|
+
|
|
7
|
+
1. **It is the closest available proxy for pre-refactor behaviour.** ``mtspec``
|
|
8
|
+
wrapped the same lineage, so when the phase-0 comparison asks "did the old
|
|
9
|
+
code do this", this is the cheapest way to ask.
|
|
10
|
+
2. **Jackknife confidence intervals** and the **F-test for spectral lines**,
|
|
11
|
+
which the native implementation does not provide.
|
|
12
|
+
3. It is an independent implementation, so disagreement between the two is
|
|
13
|
+
informative rather than invisible.
|
|
14
|
+
|
|
15
|
+
Installed with ``pip install specmod[multitaper]``.
|
|
16
|
+
|
|
17
|
+
What actually works
|
|
18
|
+
-------------------
|
|
19
|
+
Verified against ``multitaper`` 1.2 on numpy 2. Several of the package's less
|
|
20
|
+
travelled paths carry shape bugs that only surface under current numpy, so the
|
|
21
|
+
usable surface is narrower than the documentation implies:
|
|
22
|
+
|
|
23
|
+
============= ========= ========== ==============
|
|
24
|
+
Method adaptive constant eigenvalue
|
|
25
|
+
============= ========= ========== ==============
|
|
26
|
+
``spec`` works works works
|
|
27
|
+
``ftest`` works works works
|
|
28
|
+
``jackspec`` works **fails** works
|
|
29
|
+
``qiinv`` **fails** **fails** **fails**
|
|
30
|
+
============= ========= ========== ==============
|
|
31
|
+
|
|
32
|
+
``qiinv`` is the quadratic multitaper of Prieto *et al.* (2007) — the
|
|
33
|
+
higher-resolution estimate the 2022 paper recommends over adaptive weighting
|
|
34
|
+
for resolving peaks, and therefore the one that would most interest a corner
|
|
35
|
+
frequency measurement. It raises ``ValueError`` in
|
|
36
|
+
``multitaper.utils.qiinv`` for every weighting, so it is **not exposed here**.
|
|
37
|
+
Both failures are upstream and neither is worked around; they are reported with
|
|
38
|
+
their cause so the fix is obvious if upstream lands one.
|
|
39
|
+
|
|
40
|
+
Normalisation
|
|
41
|
+
-------------
|
|
42
|
+
**This backend always rescales the spectrum to integrate to the record
|
|
43
|
+
variance** (``mtspec.py``: ``sscal = xvar / (sum(spec)*df)``). That is baked
|
|
44
|
+
into ``MTSpec`` and cannot be switched off.
|
|
45
|
+
|
|
46
|
+
The consequence matters: ``Spectrum.energy()`` on the result recovers the input
|
|
47
|
+
energy *by construction*, whatever the taper weighting did. It is therefore not
|
|
48
|
+
an independent check that the estimate is sound, and it means this backend is
|
|
49
|
+
immune to the position-dependent energy bias described in
|
|
50
|
+
:mod:`specmod.transforms.multitaper` — but only for total energy, not for the
|
|
51
|
+
shape of the spectrum. See ``docs/choosing-a-transform.md``.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
from __future__ import annotations
|
|
55
|
+
|
|
56
|
+
from dataclasses import dataclass
|
|
57
|
+
from typing import Any, Literal
|
|
58
|
+
|
|
59
|
+
import numpy as np
|
|
60
|
+
from numpy.typing import ArrayLike, NDArray
|
|
61
|
+
|
|
62
|
+
from ..core.spectrum import Spectrum
|
|
63
|
+
from ..core.units import AmplitudeKind, Motion
|
|
64
|
+
from .base import build_spectrum, prepare_record
|
|
65
|
+
|
|
66
|
+
__all__ = ["PrietoMultitaperEstimator"]
|
|
67
|
+
|
|
68
|
+
Weighting = Literal["adaptive", "constant", "eigenvalue"]
|
|
69
|
+
|
|
70
|
+
#: Prieto's ``iadapt`` codes.
|
|
71
|
+
_IADAPT: dict[str, int] = {"adaptive": 0, "constant": 1, "eigenvalue": 2}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _require_multitaper() -> Any:
|
|
75
|
+
try:
|
|
76
|
+
# Deferred so the package stays importable without the extra: this is
|
|
77
|
+
# an optional backend, and a missing one should be a clear error at
|
|
78
|
+
# call time rather than an ImportError from `import specmod`.
|
|
79
|
+
from multitaper import MTSpec # noqa: PLC0415
|
|
80
|
+
except ImportError as exc: # pragma: no cover - depends on the environment
|
|
81
|
+
raise ImportError(
|
|
82
|
+
"The 'multitaper' package is not installed. It is an optional "
|
|
83
|
+
"backend; install it with `pip install specmod[multitaper]`."
|
|
84
|
+
) from exc
|
|
85
|
+
return MTSpec
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _one_sided(
|
|
89
|
+
freq: NDArray[np.float64], spec: NDArray[np.float64]
|
|
90
|
+
) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
|
|
91
|
+
"""Fold ``MTSpec``'s output into SpecMod's one-sided PSD convention.
|
|
92
|
+
|
|
93
|
+
Two conversions, both easy to get wrong silently:
|
|
94
|
+
|
|
95
|
+
- ``MTSpec.freq`` comes back in **FFT order** (0, +f..., -f...), not sorted.
|
|
96
|
+
Integrating over it without sorting runs backwards through the negative
|
|
97
|
+
half and returns a partly-cancelled, sometimes negative, result.
|
|
98
|
+
- The spectrum is **two-sided**, normalised so the full integral equals the
|
|
99
|
+
variance. SpecMod's PSD is one-sided with the same total, so folding
|
|
100
|
+
doubles every non-DC bin.
|
|
101
|
+
"""
|
|
102
|
+
order = np.argsort(freq)
|
|
103
|
+
freq, spec = freq[order], spec[order]
|
|
104
|
+
positive = freq > 0
|
|
105
|
+
return freq[positive], spec[positive] * 2.0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class PrietoMultitaperEstimator:
|
|
110
|
+
"""Multitaper estimate via Prieto's ``multitaper`` package.
|
|
111
|
+
|
|
112
|
+
Parameters
|
|
113
|
+
----------
|
|
114
|
+
time_bandwidth, n_tapers
|
|
115
|
+
As for :class:`~specmod.transforms.multitaper.MultitaperEstimator`.
|
|
116
|
+
weighting
|
|
117
|
+
``adaptive`` is Prieto's default. ``constant`` weights the tapers
|
|
118
|
+
equally; ``eigenvalue`` weights by the concentration ratios.
|
|
119
|
+
n_fft
|
|
120
|
+
Transform length. ``None`` leaves ``MTSpec``'s own default, which pads.
|
|
121
|
+
Padding refines the frequency grid; ``duration`` stays the physical
|
|
122
|
+
record length either way.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
time_bandwidth: float = 3.0
|
|
126
|
+
n_tapers: int = 5
|
|
127
|
+
weighting: Weighting = "adaptive"
|
|
128
|
+
n_fft: int | None = None
|
|
129
|
+
drop_dc: bool = True
|
|
130
|
+
name: str = "prieto"
|
|
131
|
+
|
|
132
|
+
def __post_init__(self) -> None:
|
|
133
|
+
if self.weighting not in _IADAPT:
|
|
134
|
+
raise ValueError(
|
|
135
|
+
f"Unknown weighting {self.weighting!r}; "
|
|
136
|
+
f"expected one of {sorted(_IADAPT)}."
|
|
137
|
+
)
|
|
138
|
+
if self.time_bandwidth <= 0:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"time_bandwidth must be positive, got {self.time_bandwidth}"
|
|
141
|
+
)
|
|
142
|
+
limit = int(2 * self.time_bandwidth - 1)
|
|
143
|
+
if not 1 <= self.n_tapers <= limit:
|
|
144
|
+
raise ValueError(
|
|
145
|
+
f"n_tapers={self.n_tapers} must be between 1 and 2*NW-1={limit} "
|
|
146
|
+
f"for time_bandwidth={self.time_bandwidth}."
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def _mtspec(self, x: NDArray[np.float64], dt: float) -> Any:
|
|
150
|
+
MTSpec = _require_multitaper()
|
|
151
|
+
return MTSpec(
|
|
152
|
+
x,
|
|
153
|
+
nw=self.time_bandwidth,
|
|
154
|
+
kspec=self.n_tapers,
|
|
155
|
+
dt=dt,
|
|
156
|
+
nfft=0 if self.n_fft is None else int(self.n_fft),
|
|
157
|
+
iadapt=_IADAPT[self.weighting],
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def estimate(
|
|
161
|
+
self,
|
|
162
|
+
data: ArrayLike,
|
|
163
|
+
dt: float,
|
|
164
|
+
*,
|
|
165
|
+
motion: Motion | str = Motion.VELOCITY,
|
|
166
|
+
meta: dict[str, Any] | None = None,
|
|
167
|
+
) -> Spectrum:
|
|
168
|
+
x, _n, duration = prepare_record(data, dt)
|
|
169
|
+
mt = self._mtspec(x, dt)
|
|
170
|
+
freq, psd = _one_sided(
|
|
171
|
+
np.asarray(mt.freq, dtype=np.float64).ravel(),
|
|
172
|
+
np.asarray(mt.spec, dtype=np.float64).ravel(),
|
|
173
|
+
)
|
|
174
|
+
if not self.drop_dc: # DC was dropped by the f > 0 mask
|
|
175
|
+
freq = np.concatenate([[0.0], freq])
|
|
176
|
+
psd = np.concatenate([[0.0], psd])
|
|
177
|
+
|
|
178
|
+
spectrum = build_spectrum(
|
|
179
|
+
freq,
|
|
180
|
+
psd,
|
|
181
|
+
kind=AmplitudeKind.PSD,
|
|
182
|
+
motion=motion,
|
|
183
|
+
duration=duration,
|
|
184
|
+
sampling_rate=1.0 / dt,
|
|
185
|
+
meta={
|
|
186
|
+
**(meta or {}),
|
|
187
|
+
"time_bandwidth": self.time_bandwidth,
|
|
188
|
+
"n_tapers": self.n_tapers,
|
|
189
|
+
"weighting": self.weighting,
|
|
190
|
+
# Not optional in this backend — see the module docstring.
|
|
191
|
+
"normalize_to_variance": True,
|
|
192
|
+
},
|
|
193
|
+
estimator=self.name,
|
|
194
|
+
)
|
|
195
|
+
return spectrum.to_kind(AmplitudeKind.FAS)
|
|
196
|
+
|
|
197
|
+
def confidence_interval(
|
|
198
|
+
self, data: ArrayLike, dt: float, *, motion: Motion | str = Motion.VELOCITY
|
|
199
|
+
) -> tuple[Spectrum, Spectrum]:
|
|
200
|
+
"""Jackknife 95% confidence bounds, as two FAS spectra.
|
|
201
|
+
|
|
202
|
+
The main reason to reach for this backend rather than the native one.
|
|
203
|
+
The interval is log-symmetric about the estimate — measured at roughly
|
|
204
|
+
2.5x either way for ``nw=3, kspec=5``.
|
|
205
|
+
|
|
206
|
+
.. note::
|
|
207
|
+
|
|
208
|
+
Upstream inverts the two bounds for a small fraction of bins (~2%
|
|
209
|
+
in testing), so ``low <= high`` does not hold everywhere. Sort the
|
|
210
|
+
pair per-bin if you need that guarantee.
|
|
211
|
+
|
|
212
|
+
Raises
|
|
213
|
+
------
|
|
214
|
+
NotImplementedError
|
|
215
|
+
For ``weighting="constant"``. This is an upstream bug, not a
|
|
216
|
+
limitation: in ``multitaper.utils.jackspec`` the degrees-of-freedom
|
|
217
|
+
array ``se`` keeps shape ``(nfft, 1)`` under constant weighting, so
|
|
218
|
+
``t.ppf(...) * sqrt(var[:, 0])`` broadcasts to ``(nfft, nfft)``
|
|
219
|
+
instead of elementwise and raises. ``adaptive`` and ``eigenvalue``
|
|
220
|
+
are unaffected.
|
|
221
|
+
"""
|
|
222
|
+
if self.weighting == "constant":
|
|
223
|
+
raise NotImplementedError(
|
|
224
|
+
"Jackknife intervals are unavailable for weighting='constant' "
|
|
225
|
+
"due to a shape bug in multitaper.utils.jackspec. Use "
|
|
226
|
+
"weighting='adaptive' or 'eigenvalue'."
|
|
227
|
+
)
|
|
228
|
+
x, _n, duration = prepare_record(data, dt)
|
|
229
|
+
mt = self._mtspec(x, dt)
|
|
230
|
+
ci = np.asarray(mt.jackspec(), dtype=np.float64)
|
|
231
|
+
raw_freq = np.asarray(mt.freq, dtype=np.float64).ravel()
|
|
232
|
+
|
|
233
|
+
bounds = []
|
|
234
|
+
for column in (0, 1):
|
|
235
|
+
freq, psd = _one_sided(raw_freq, ci[:, column].ravel())
|
|
236
|
+
bounds.append(
|
|
237
|
+
build_spectrum(
|
|
238
|
+
freq,
|
|
239
|
+
psd,
|
|
240
|
+
kind=AmplitudeKind.PSD,
|
|
241
|
+
motion=motion,
|
|
242
|
+
duration=duration,
|
|
243
|
+
sampling_rate=1.0 / dt,
|
|
244
|
+
meta={
|
|
245
|
+
"weighting": self.weighting,
|
|
246
|
+
"bound": ("low", "high")[column],
|
|
247
|
+
},
|
|
248
|
+
estimator=self.name,
|
|
249
|
+
).to_kind(AmplitudeKind.FAS)
|
|
250
|
+
)
|
|
251
|
+
return bounds[0], bounds[1]
|
|
252
|
+
|
|
253
|
+
def f_test(
|
|
254
|
+
self, data: ArrayLike, dt: float
|
|
255
|
+
) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]:
|
|
256
|
+
"""Thomson's F-test for periodic (line) components.
|
|
257
|
+
|
|
258
|
+
Returns ``(freq, F, p)`` on the one-sided axis. Useful for spotting
|
|
259
|
+
instrumental or cultural tones that would otherwise be mistaken for
|
|
260
|
+
source structure.
|
|
261
|
+
"""
|
|
262
|
+
x, _n, _duration = prepare_record(data, dt)
|
|
263
|
+
mt = self._mtspec(x, dt)
|
|
264
|
+
f_statistic, p_value = mt.ftest()
|
|
265
|
+
raw_freq = np.asarray(mt.freq, dtype=np.float64).ravel()
|
|
266
|
+
order = np.argsort(raw_freq)
|
|
267
|
+
positive = raw_freq[order] > 0
|
|
268
|
+
return (
|
|
269
|
+
raw_freq[order][positive],
|
|
270
|
+
np.asarray(f_statistic, dtype=np.float64).ravel()[order][positive],
|
|
271
|
+
np.asarray(p_value, dtype=np.float64).ravel()[order][positive],
|
|
272
|
+
)
|