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,242 @@
1
+ """The estimator interface and the one place normalisation is defined.
2
+
3
+ Every backend — FFT, Welch, multitaper, and later the CWT — returns a
4
+ :class:`~specmod.core.spectrum.Spectrum` obeying the same contract, so a single
5
+ test suite pins all of them. That is the point of the abstraction: the
6
+ pre-refactor code called ``mtspec`` directly from ``Spectrum.__init__`` and
7
+ threaded backend keyword arguments through three layers of public API, so there
8
+ was nowhere for a shared contract to live.
9
+
10
+ The contract
11
+ ------------
12
+ Given a real record ``x`` of ``N`` samples at interval ``dt``:
13
+
14
+ - The returned spectrum is **one-sided**, spanning ``(0, f_Nyquist]``.
15
+ - Its default kind is :attr:`~specmod.core.units.AmplitudeKind.FAS`, in
16
+ ``[x] * s``.
17
+ - ``duration`` is ``N * dt``, the **physical** record length. Normalisation is
18
+ keyed off it and never off ``len(freq)``, so zero-padding changes resolution
19
+ without changing amplitude.
20
+ - Energy is preserved: ``spectrum.energy()`` recovers ``sum(x^2) * dt`` to
21
+ within the accuracy of the estimator.
22
+
23
+ Taper correction
24
+ ----------------
25
+ A taper attenuates the record, and the correction depends on what you are
26
+ measuring. The two are not interchangeable:
27
+
28
+ ``energy`` (default)
29
+ Divide by ``sqrt(mean(w^2))``, preserving total power. Parseval then holds
30
+ exactly, which is what the shared test asserts, and it is the right choice
31
+ for a transient — which is what a seismic arrival is.
32
+
33
+ ``amplitude``
34
+ Divide by ``mean(w)``, preserving the peak of a coherent sinusoid so that
35
+ it reads ``A0 * T``. Right when measuring a monochromatic line.
36
+
37
+ For a Tukey taper with ``alpha=0.05`` the two differ by well under a percent,
38
+ but the choice is explicit rather than implied.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ from typing import Any, Literal, Protocol, runtime_checkable
44
+
45
+ import numpy as np
46
+ from numpy.typing import ArrayLike, NDArray
47
+ from scipy.fft import next_fast_len
48
+ from scipy.signal import get_window
49
+
50
+ from ..core.spectrum import Spectrum
51
+ from ..core.units import AmplitudeKind, Motion
52
+
53
+ __all__ = [
54
+ "SpectralEstimator",
55
+ "TaperCorrection",
56
+ "make_window",
57
+ "prepare_record",
58
+ "window_correction",
59
+ ]
60
+
61
+ TaperCorrection = Literal["energy", "amplitude"]
62
+
63
+
64
+ @runtime_checkable
65
+ class SpectralEstimator(Protocol):
66
+ """Anything that turns a real record into a :class:`Spectrum`."""
67
+
68
+ @property
69
+ def name(self) -> str:
70
+ """Short identifier, recorded in the spectrum's metadata.
71
+
72
+ Declared read-only so the frozen dataclasses implementing this protocol
73
+ satisfy it; a plain mutable attribute would not.
74
+ """
75
+ ...
76
+
77
+ def estimate(
78
+ self,
79
+ data: ArrayLike,
80
+ dt: float,
81
+ *,
82
+ motion: Motion | str = Motion.VELOCITY,
83
+ meta: dict[str, Any] | None = None,
84
+ ) -> Spectrum:
85
+ """Estimate the spectrum of ``data`` sampled at interval ``dt``."""
86
+ ...
87
+
88
+
89
+ def prepare_record(
90
+ data: ArrayLike, dt: float
91
+ ) -> tuple[NDArray[np.float64], int, float]:
92
+ """Validate and demean a record, returning ``(x, n_samples, duration)``.
93
+
94
+ Demeaning is unconditional: a non-zero mean puts all of its energy in the
95
+ DC bin, which is then discarded, so leaving it in loses energy the Parseval
96
+ check would report as a failure.
97
+ """
98
+ x = np.ascontiguousarray(np.asarray(data, dtype=np.float64).squeeze())
99
+ if x.ndim != 1:
100
+ raise ValueError(f"Expected a 1-D record, got shape {x.shape}")
101
+ if x.size < 2:
102
+ raise ValueError(f"Record too short: {x.size} sample(s)")
103
+ if not np.all(np.isfinite(x)):
104
+ raise ValueError("Record contains NaN or Inf")
105
+ if dt <= 0:
106
+ raise ValueError(f"dt must be positive, got {dt}")
107
+ return x - x.mean(), int(x.size), float(x.size * dt)
108
+
109
+
110
+ def make_window(kind: str, n: int, alpha: float = 0.05) -> NDArray[np.float64]:
111
+ """Build a taper of length ``n``."""
112
+ if kind == "boxcar":
113
+ return np.ones(n, dtype=np.float64)
114
+ spec: str | tuple[str, float] = ("tukey", alpha) if kind == "tukey" else kind
115
+ return np.asarray(get_window(spec, n, fftbins=False), dtype=np.float64)
116
+
117
+
118
+ def window_correction(
119
+ window: NDArray[np.float64], correction: TaperCorrection
120
+ ) -> float:
121
+ """Scale factor that undoes a taper's attenuation.
122
+
123
+ See the module docstring for why there are two.
124
+ """
125
+ if correction == "energy":
126
+ return float(np.sqrt(np.mean(window**2)))
127
+ if correction == "amplitude":
128
+ return float(np.mean(window))
129
+ raise ValueError(
130
+ f"Unknown taper correction {correction!r}; expected 'energy' or 'amplitude'."
131
+ )
132
+
133
+
134
+ def resolve_n_fft(n_fft: int | str | None, n_samples: int) -> int:
135
+ """Turn an ``n_fft`` setting into a transform length.
136
+
137
+ Accepts ``None`` for no padding, an explicit integer, or a strategy:
138
+
139
+ ``"fast"``
140
+ The next length ``scipy.fft`` factorises efficiently — a 5-smooth
141
+ composite. **The one to use.**
142
+ ``"pow2"``
143
+ The next power of two. Familiar, and the right answer for a
144
+ radix-2 implementation, but numpy's pocketfft is not one.
145
+
146
+ Both exist because padding to a fast length is purely a speed knob here:
147
+ the normalisation is keyed off the record duration, so padding changes the
148
+ frequency sampling and nothing else. Cut windows have arbitrary lengths and
149
+ a prime one is slow — measured across the 28 PNR S-windows, 1.77x.
150
+
151
+ ``"fast"`` is preferred over ``"pow2"`` on measurement, not taste. A power
152
+ of two overshoots: for a 65537-sample record it pads to 131072, doubling
153
+ the transform, where the next 5-smooth length is 65610. Over a range of
154
+ real and awkward lengths ``"fast"`` totalled 0.76 ms against 1.49 ms for
155
+ ``"pow2"`` and 11.0 ms unpadded.
156
+ """
157
+ if n_fft is None:
158
+ return n_samples
159
+ if isinstance(n_fft, str):
160
+ strategy = n_fft.lower()
161
+ if strategy == "fast":
162
+ return int(next_fast_len(n_samples))
163
+ if strategy == "pow2":
164
+ return 1 << int(np.ceil(np.log2(n_samples)))
165
+ raise ValueError(
166
+ f"n_fft must be an integer, None, 'fast' or 'pow2', got {n_fft!r}"
167
+ )
168
+ nfft = int(n_fft)
169
+ if nfft < n_samples:
170
+ raise ValueError(
171
+ f"n_fft ({nfft}) is shorter than the record ({n_samples} samples)"
172
+ )
173
+ return nfft
174
+
175
+
176
+ def one_sided_fas(
177
+ x: NDArray[np.float64],
178
+ dt: float,
179
+ duration: float,
180
+ *,
181
+ window: NDArray[np.float64],
182
+ correction: TaperCorrection,
183
+ n_fft: int | str | None,
184
+ drop_dc: bool,
185
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
186
+ """Core FFT path: taper, transform, fold, normalise.
187
+
188
+ Returns ``(freq, fas)`` with ``fas`` in ``[x] * s``.
189
+
190
+ The normalisation is ``dt`` per sample, doubled to fold the negative
191
+ frequencies, and divided by the taper correction. ``dt`` is used rather
192
+ than anything derived from the transform length, which is what makes
193
+ padding change only the frequency sampling.
194
+ """
195
+ n = x.size
196
+ nfft = resolve_n_fft(n_fft, n)
197
+
198
+ spec = np.fft.rfft(x * window, n=nfft)
199
+ freq: NDArray[np.float64] = np.fft.rfftfreq(nfft, d=dt).astype(np.float64)
200
+
201
+ fas = np.abs(spec) * dt / window_correction(window, correction)
202
+ # Fold: every bin except DC and, for even nfft, Nyquist has a negative twin.
203
+ fas[1:] *= 2.0
204
+ if nfft % 2 == 0:
205
+ fas[-1] /= 2.0
206
+
207
+ # Deliberately no rescaling for zero-padding. The DFT sums over the N
208
+ # non-zero samples whatever nfft is, so it approximates the same continuous
209
+ # transform `dt * sum(x_n exp(-2 pi i f t_n))` and merely evaluates it on a
210
+ # finer grid. Normalising by `dt` alone is what makes that true; scaling by
211
+ # any length ratio would reintroduce exactly the padding sensitivity that
212
+ # made the pre-refactor `psd_to_amp` wrong (§2.2).
213
+ del duration, n # normalisation depends on neither; kept for the signature
214
+
215
+ if drop_dc:
216
+ return freq[1:], fas[1:]
217
+ return freq, fas
218
+
219
+
220
+ def build_spectrum(
221
+ freq: NDArray[np.float64],
222
+ amp: NDArray[np.float64],
223
+ *,
224
+ kind: AmplitudeKind,
225
+ motion: Motion | str,
226
+ duration: float,
227
+ sampling_rate: float,
228
+ meta: dict[str, Any] | None,
229
+ estimator: str,
230
+ ) -> Spectrum:
231
+ """Assemble a Spectrum, recording which estimator produced it."""
232
+ info = dict(meta or {})
233
+ info.setdefault("estimator", estimator)
234
+ return Spectrum(
235
+ freq=freq,
236
+ amp=amp,
237
+ motion=Motion(motion),
238
+ kind=kind,
239
+ duration=duration,
240
+ sampling_rate=sampling_rate,
241
+ meta=info,
242
+ )
@@ -0,0 +1,219 @@
1
+ """Continuous wavelet transform on an L2-normalised Morlet.
2
+
3
+ The reason this is written here rather than delegated to PyWavelets: the
4
+ normalisation is the entire difficulty. CWT coefficients carry units of
5
+ ``[signal] * sqrt(time)`` under L2 and ``[signal]`` under L1, and neither is the
6
+ ``[signal] * s`` of a Fourier amplitude spectrum. A naive ``|W|`` plotted
7
+ against scale-derived frequency looks entirely reasonable and gives the right
8
+ corner frequency with the wrong ``Omega`` — so the wrong seismic moment, with
9
+ nothing anywhere to catch it. ``pywt.cwt``'s conventions are not documented to
10
+ the precision that requires, so using it would mean reverse-engineering them.
11
+
12
+ The bridge back to amplitude lives in
13
+ :meth:`specmod.core.scalogram.Scalogram.time_average`, and
14
+ ``tests/test_cwt.py`` is its specification: the CWT has to agree with the FFT
15
+ and multitaper estimators on a sinusoid of known amplitude and on the energy of
16
+ a record. The derivation is not trusted; the test is.
17
+
18
+ References
19
+ ----------
20
+ Torrence, C. and Compo, G.P. (1998). A practical guide to wavelet analysis.
21
+ *Bulletin of the American Meteorological Society* 79(1), 61-78.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass
27
+ from types import MappingProxyType
28
+ from typing import Any
29
+
30
+ import numpy as np
31
+ from numpy.typing import ArrayLike, NDArray
32
+
33
+ from ..core.scalogram import Scalogram
34
+ from ..core.spectrum import Spectrum
35
+ from ..core.units import Motion
36
+ from .base import prepare_record
37
+
38
+ __all__ = ["CWTEstimator"]
39
+
40
+
41
+ def _morlet_fourier_factor(omega0: float) -> float:
42
+ """Scale-to-Fourier-period conversion, T&C Table 1.
43
+
44
+ ``period = factor * scale``. This is what lets the output frequency axis
45
+ mean the same thing as an FFT's rather than being "scale, roughly".
46
+ """
47
+ return float(4.0 * np.pi / (omega0 + np.sqrt(2.0 + omega0**2)))
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class CWTEstimator:
52
+ """Continuous wavelet transform, time-averaged to an amplitude spectrum.
53
+
54
+ Produces both outputs from one transform: :meth:`scalogram` returns the
55
+ full time-frequency surface, and :meth:`estimate` returns its time average
56
+ as an ordinary :class:`~specmod.core.spectrum.Spectrum`, so the fitting
57
+ pipeline does not know the difference. The surface is what you look at when
58
+ a fit comes out wrong.
59
+
60
+ Parameters
61
+ ----------
62
+ omega0
63
+ Morlet central frequency, dimensionless. Trades time resolution against
64
+ frequency resolution; 6 is the conventional choice and the value for
65
+ which the analytic scale-frequency relation is usually quoted.
66
+ dj
67
+ Scale spacing in octaves. Smaller resolves the frequency axis more
68
+ finely at proportionally more cost. The normalisation carries an
69
+ explicit ``dj`` factor, so the recovered energy does not depend on it.
70
+ mask_coi
71
+ Exclude the cone of influence from the time average. On by default:
72
+ without it a short window reads low at low frequency, which is the band
73
+ that constrains ``Omega``.
74
+ f_min, f_max
75
+ Frequency range to cover. Defaults span ``1/T`` — the longest period
76
+ the record can represent — up to the Nyquist frequency.
77
+ """
78
+
79
+ omega0: float = 6.0
80
+ dj: float = 0.125
81
+ mask_coi: bool = True
82
+ f_min: float | None = None
83
+ f_max: float | None = None
84
+ name: str = "cwt"
85
+
86
+ def __post_init__(self) -> None:
87
+ if self.omega0 <= 0:
88
+ raise ValueError(f"omega0 must be positive, got {self.omega0}")
89
+ if self.dj <= 0:
90
+ raise ValueError(f"dj must be positive, got {self.dj}")
91
+ if (
92
+ self.f_min is not None
93
+ and self.f_max is not None
94
+ and self.f_min >= self.f_max
95
+ ):
96
+ raise ValueError(f"f_min={self.f_min} must be below f_max={self.f_max}")
97
+
98
+ def _scales(self, n: int, dt: float) -> NDArray[np.float64]:
99
+ factor = _morlet_fourier_factor(self.omega0)
100
+ f_max = self.f_max if self.f_max is not None else 0.5 / dt
101
+ f_min = self.f_min if self.f_min is not None else 1.0 / (n * dt)
102
+ # scale = period / factor, and period = 1/f.
103
+ s_min = 1.0 / (f_max * factor)
104
+ s_max = 1.0 / (f_min * factor)
105
+ n_scales = int(np.floor(np.log2(s_max / s_min) / self.dj)) + 1
106
+ return np.asarray(s_min * 2.0 ** (np.arange(n_scales) * self.dj), dtype=float)
107
+
108
+ def _c_delta(self, dt: float) -> float:
109
+ """Reconstruction constant, computed against *this* scale grid.
110
+
111
+ Two reasons not to use the tabulated value, and the second is the one
112
+ that matters.
113
+
114
+ First, T&C tabulate 0.776 for ``omega0=6`` only, and this class lets
115
+ ``omega0`` vary. The constant moves with it — 1.05 at 4, 0.55 at 8 —
116
+ so a hardcoded 0.776 silently mis-normalises every non-default choice.
117
+
118
+ Second, and less obvious: the value derived here is deliberately *not*
119
+ 0.776 even at ``omega0=6``. It comes out near 0.72 and drifts with
120
+ ``dj``, because the reconstruction sum is a discrete approximation to a
121
+ continuous integral over scale, and this measures the approximation
122
+ actually being used rather than its limit. That is what keeps the
123
+ Parseval contract: with the computed constant, recovered energy is
124
+ 0.97-1.07 of the truth; with 0.776 it is systematically ~7% low, and
125
+ the error grows as ``dj`` coarsens.
126
+
127
+ So the ``dj`` dependence is the point, not a defect — do not "fix" it
128
+ by substituting the published number. ``tests/test_cwt.py`` pins this.
129
+ """
130
+ n = 1024
131
+ delta = np.zeros(n)
132
+ delta[n // 2] = 1.0
133
+ scales = self._scales(n, dt)
134
+ coefficients = self._transform(delta, dt, scales)
135
+ # T&C eq. 11 rearranged: the delta reconstructs to 1 at its own sample.
136
+ summed = float(np.sum(np.real(coefficients[:, n // 2]) / np.sqrt(scales)))
137
+ psi0_at_zero = np.pi**-0.25
138
+ return float(self.dj * np.sqrt(dt) / psi0_at_zero * summed)
139
+
140
+ def _transform(
141
+ self, x: NDArray[np.float64], dt: float, scales: NDArray[np.float64]
142
+ ) -> NDArray[np.complex128]:
143
+ """Wavelet coefficients, L2-normalised, computed in the frequency domain."""
144
+ n = x.size
145
+ x_hat = np.fft.fft(x)
146
+ omega = 2.0 * np.pi * np.fft.fftfreq(n, d=dt)
147
+
148
+ # Morlet is analytic: it has no support at negative frequency, which is
149
+ # what makes |W| an envelope rather than an oscillation.
150
+ heaviside = (omega > 0).astype(float)
151
+ scaled = scales[:, None] * omega[None, :]
152
+ psi_hat = (
153
+ np.pi**-0.25
154
+ * heaviside[None, :]
155
+ * np.exp(-((scaled - self.omega0) ** 2) / 2.0)
156
+ )
157
+ # T&C eq. 6: the sqrt(2*pi*s/dt) factor is the L2 normalisation, and is
158
+ # what makes the coefficients comparable across scales.
159
+ psi_hat = psi_hat * np.sqrt(2.0 * np.pi * scales[:, None] / dt)
160
+
161
+ coefficients: NDArray[np.complex128] = np.fft.ifft(
162
+ x_hat[None, :] * np.conjugate(psi_hat), axis=-1
163
+ )
164
+ return coefficients
165
+
166
+ def scalogram(
167
+ self,
168
+ data: ArrayLike,
169
+ dt: float,
170
+ *,
171
+ motion: Motion | str = Motion.VELOCITY,
172
+ meta: dict[str, Any] | None = None,
173
+ ) -> Scalogram:
174
+ """The full time-frequency surface."""
175
+ x, n, duration = prepare_record(data, dt)
176
+ scales = self._scales(n, dt)
177
+ coefficients = self._transform(x, dt, scales)
178
+
179
+ factor = _morlet_fourier_factor(self.omega0)
180
+ freq = 1.0 / (factor * scales)
181
+
182
+ # T&C section 3g: the e-folding time for Morlet is sqrt(2)*s, so the
183
+ # longest resolvable period at a given sample grows with its distance
184
+ # from the nearest edge.
185
+ edge_distance = np.minimum(np.arange(n), n - 1 - np.arange(n)) * dt
186
+ coi = factor * np.sqrt(2.0) * edge_distance
187
+
188
+ return Scalogram(
189
+ time=(np.arange(n) * dt).astype(np.float64),
190
+ freq=freq,
191
+ power=np.abs(coefficients) ** 2,
192
+ scales=scales,
193
+ coi=coi,
194
+ c_delta=self._c_delta(dt),
195
+ dj=self.dj,
196
+ dt=dt,
197
+ motion=Motion(motion),
198
+ meta=MappingProxyType(
199
+ {
200
+ **(meta or {}),
201
+ "estimator": self.name,
202
+ "omega0": self.omega0,
203
+ "dj": self.dj,
204
+ "duration": duration,
205
+ }
206
+ ),
207
+ )
208
+
209
+ def estimate(
210
+ self,
211
+ data: ArrayLike,
212
+ dt: float,
213
+ *,
214
+ motion: Motion | str = Motion.VELOCITY,
215
+ meta: dict[str, Any] | None = None,
216
+ ) -> Spectrum:
217
+ """Time-averaged amplitude spectrum, interchangeable with the others."""
218
+ surface = self.scalogram(data, dt, motion=motion, meta=meta)
219
+ return surface.time_average(mask_coi=self.mask_coi)
@@ -0,0 +1,157 @@
1
+ """Plain FFT and Welch estimators.
2
+
3
+ ``FFTEstimator`` is the direct route: taper, transform, fold, normalise. Paired
4
+ with a smoother (:mod:`specmod.smoothing`) it is the conventional
5
+ engineering-seismology approach and the fastest option here.
6
+
7
+ ``WelchEstimator`` averages over overlapping segments, trading frequency
8
+ resolution for variance. That makes it a good default for *noise* windows,
9
+ where a stable level matters more than resolving structure.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+ from typing import Any
16
+
17
+ import numpy as np
18
+ from numpy.typing import ArrayLike
19
+ from scipy.signal import welch
20
+
21
+ from ..core.spectrum import Spectrum
22
+ from ..core.units import AmplitudeKind, Motion
23
+ from .base import (
24
+ TaperCorrection,
25
+ build_spectrum,
26
+ make_window,
27
+ one_sided_fas,
28
+ prepare_record,
29
+ )
30
+
31
+ __all__ = ["FFTEstimator", "WelchEstimator"]
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class FFTEstimator:
36
+ """One-sided Fourier amplitude spectrum via :func:`numpy.fft.rfft`.
37
+
38
+ Parameters
39
+ ----------
40
+ taper, taper_alpha
41
+ Window applied before transforming. ``tukey`` with a small ``alpha``
42
+ suppresses edge discontinuities while leaving the body of the record
43
+ untouched.
44
+ taper_correction
45
+ ``"energy"`` preserves Parseval, ``"amplitude"`` preserves the peak of a
46
+ coherent sinusoid. See :mod:`specmod.transforms.base`.
47
+ n_fft
48
+ Transform length: ``None`` for no padding, an integer, or a strategy —
49
+ ``"fast"`` for the next efficiently-factorised length, ``"pow2"`` for
50
+ the next power of two. See
51
+ :func:`~specmod.transforms.base.resolve_n_fft`.
52
+
53
+ Padding refines the frequency grid without changing amplitude — the
54
+ property the pre-refactor normalisation got wrong by keying off
55
+ ``len(freq)``. So it buys two things and neither is leakage
56
+ suppression, which is the taper's job: it removes scalloping loss
57
+ (36% worst case on a line falling between bins, unpadded), and it
58
+ avoids the slow path for an awkward record length.
59
+
60
+ ``"fast"`` is the one to reach for. Cut windows are not round numbers —
61
+ of the 28 PNR S-windows, 17 are odd and several are prime — and a prime
62
+ length costs 1.77x across those. ``"pow2"`` is offered because it is
63
+ what people expect, but it overshoots: numpy's pocketfft handles
64
+ 5-smooth lengths, so padding 65537 to 131072 does twice the work of
65
+ padding it to 65610.
66
+ drop_dc
67
+ Discard the zero-frequency bin.
68
+ """
69
+
70
+ taper: str = "tukey"
71
+ taper_alpha: float = 0.05
72
+ taper_correction: TaperCorrection = "energy"
73
+ n_fft: int | str | None = None
74
+ drop_dc: bool = True
75
+ name: str = "fft"
76
+
77
+ def estimate(
78
+ self,
79
+ data: ArrayLike,
80
+ dt: float,
81
+ *,
82
+ motion: Motion | str = Motion.VELOCITY,
83
+ meta: dict[str, Any] | None = None,
84
+ ) -> Spectrum:
85
+ x, _n, duration = prepare_record(data, dt)
86
+ window = make_window(self.taper, x.size, self.taper_alpha)
87
+ freq, fas = one_sided_fas(
88
+ x,
89
+ dt,
90
+ duration,
91
+ window=window,
92
+ correction=self.taper_correction,
93
+ n_fft=self.n_fft,
94
+ drop_dc=self.drop_dc,
95
+ )
96
+ return build_spectrum(
97
+ freq,
98
+ fas,
99
+ kind=AmplitudeKind.FAS,
100
+ motion=motion,
101
+ duration=duration,
102
+ sampling_rate=1.0 / dt,
103
+ meta=meta,
104
+ estimator=self.name,
105
+ )
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class WelchEstimator:
110
+ """Segment-averaged PSD via :func:`scipy.signal.welch`, returned as FAS.
111
+
112
+ Averaging reduces variance at the cost of frequency resolution, which is
113
+ the right trade for a noise window. Note that ``duration`` remains the full
114
+ record length: it is the physical property of the record, not of the
115
+ segments, and every kind conversion depends on it.
116
+ """
117
+
118
+ segment_length: int | None = None
119
+ overlap: float = 0.5
120
+ taper: str = "hann"
121
+ drop_dc: bool = True
122
+ name: str = "welch"
123
+
124
+ def estimate(
125
+ self,
126
+ data: ArrayLike,
127
+ dt: float,
128
+ *,
129
+ motion: Motion | str = Motion.VELOCITY,
130
+ meta: dict[str, Any] | None = None,
131
+ ) -> Spectrum:
132
+ x, n, duration = prepare_record(data, dt)
133
+ nperseg = min(self.segment_length or n, n)
134
+ freq, psd = welch(
135
+ x,
136
+ fs=1.0 / dt,
137
+ window=self.taper,
138
+ nperseg=nperseg,
139
+ noverlap=int(nperseg * self.overlap),
140
+ detrend=False,
141
+ return_onesided=True,
142
+ scaling="density",
143
+ )
144
+ # scipy's density is one-sided [x]^2/Hz, matching AmplitudeKind.PSD.
145
+ if self.drop_dc:
146
+ freq, psd = freq[1:], psd[1:]
147
+ spectrum = build_spectrum(
148
+ np.asarray(freq, dtype=np.float64),
149
+ np.asarray(psd, dtype=np.float64),
150
+ kind=AmplitudeKind.PSD,
151
+ motion=motion,
152
+ duration=duration,
153
+ sampling_rate=1.0 / dt,
154
+ meta=meta,
155
+ estimator=self.name,
156
+ )
157
+ return spectrum.to_kind(AmplitudeKind.FAS)