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,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)
|