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.
Files changed (62) hide show
  1. specmod/__init__.py +17 -0
  2. specmod/_vendor/__init__.py +21 -0
  3. specmod/_vendor/qiinv.py +243 -0
  4. specmod/acquire.py +358 -0
  5. specmod/api.py +480 -0
  6. specmod/cli.py +139 -0
  7. specmod/config/__init__.py +44 -0
  8. specmod/config/layers.py +168 -0
  9. specmod/config/provenance.py +77 -0
  10. specmod/config/sections.py +385 -0
  11. specmod/config/serialize.py +58 -0
  12. specmod/core/__init__.py +41 -0
  13. specmod/core/bandwidth.py +187 -0
  14. specmod/core/collection.py +549 -0
  15. specmod/core/noise.py +478 -0
  16. specmod/core/scalogram.py +234 -0
  17. specmod/core/spectrum.py +326 -0
  18. specmod/core/units.py +116 -0
  19. specmod/datasets.py +316 -0
  20. specmod/distance.py +190 -0
  21. specmod/exceptions.py +58 -0
  22. specmod/fitting/__init__.py +58 -0
  23. specmod/fitting/base.py +50 -0
  24. specmod/fitting/event.py +284 -0
  25. specmod/fitting/guess.py +170 -0
  26. specmod/fitting/spectrum.py +330 -0
  27. specmod/io.py +241 -0
  28. specmod/magnitude.py +312 -0
  29. specmod/picks/__init__.py +182 -0
  30. specmod/picks/base.py +250 -0
  31. specmod/picks/delimited.py +224 -0
  32. specmod/picks/events.py +157 -0
  33. specmod/picks/resolution.py +149 -0
  34. specmod/picks/snuffler.py +92 -0
  35. specmod/pipeline.py +280 -0
  36. specmod/plotting.py +203 -0
  37. specmod/preprocess.py +554 -0
  38. specmod/smoothing/__init__.py +50 -0
  39. specmod/smoothing/base.py +56 -0
  40. specmod/smoothing/konno_ohmachi.py +83 -0
  41. specmod/smoothing/log_bins.py +171 -0
  42. specmod/sources/__init__.py +65 -0
  43. specmod/sources/attenuation.py +110 -0
  44. specmod/sources/composite.py +135 -0
  45. specmod/sources/motion.py +40 -0
  46. specmod/sources/source.py +147 -0
  47. specmod/spreading.py +209 -0
  48. specmod/staged.py +523 -0
  49. specmod/tables.py +110 -0
  50. specmod/transforms/__init__.py +50 -0
  51. specmod/transforms/base.py +242 -0
  52. specmod/transforms/cwt.py +219 -0
  53. specmod/transforms/fft.py +157 -0
  54. specmod/transforms/multitaper.py +357 -0
  55. specmod/transforms/prieto.py +272 -0
  56. specmod/transforms/quadratic.py +221 -0
  57. specmod/utils.py +305 -0
  58. specmod-0.2.0.dist-info/METADATA +294 -0
  59. specmod-0.2.0.dist-info/RECORD +62 -0
  60. specmod-0.2.0.dist-info/WHEEL +4 -0
  61. specmod-0.2.0.dist-info/entry_points.txt +2 -0
  62. 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
+ )