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,549 @@
|
|
|
1
|
+
"""Pairing a signal against its noise, and the band that survives the comparison.
|
|
2
|
+
|
|
3
|
+
This is the typed replacement for ``spectral.SNP`` and ``spectral.Spectra``.
|
|
4
|
+
The numerics are identical — ``tests/test_golden_reference.py`` holds both
|
|
5
|
+
paths to the same 140 window-estimator results — but three structural
|
|
6
|
+
properties change, and they are the reason the rewrite is worth doing.
|
|
7
|
+
|
|
8
|
+
**Configuration is an argument, not an import-time global.** ``spectral.py``
|
|
9
|
+
binds every setting at module import (``BW_METHOD``, ``ROT_METHOD`` and
|
|
10
|
+
eight more). That is why a Brune and a Boatwright model cannot be fitted in one
|
|
11
|
+
session, why tests cannot vary configuration without reimporting, and why they
|
|
12
|
+
cannot run in parallel. Everything here takes its settings as parameters.
|
|
13
|
+
|
|
14
|
+
**Nothing mutates.** The legacy classes rescale, rotate, interpolate and
|
|
15
|
+
integrate in place, which is what made ``core.Spectrum``'s read-only arrays
|
|
16
|
+
break the pipeline when the estimators were rewired: the containers were
|
|
17
|
+
mutating arrays they did not own. Each step here returns a new object, so a
|
|
18
|
+
spectrum cannot change under a reference someone else is holding.
|
|
19
|
+
|
|
20
|
+
**The pieces are separable.** The binning, the Parseval rescale, the
|
|
21
|
+
interpolation and the band search are module-level functions over arrays. They
|
|
22
|
+
were private methods reachable only by constructing a full pair from two obspy
|
|
23
|
+
traces, so the only way to test the band search was to run the whole pipeline.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from typing import Any, ClassVar
|
|
31
|
+
|
|
32
|
+
import numpy as np
|
|
33
|
+
from numpy.typing import NDArray
|
|
34
|
+
|
|
35
|
+
from .bandwidth import get_bandwidth_selector
|
|
36
|
+
from .noise import BoostNoise, NoiseModel, get_noise_model
|
|
37
|
+
from .spectrum import Spectrum
|
|
38
|
+
from .units import Motion
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"BinnedSpectrum",
|
|
42
|
+
"FittableView",
|
|
43
|
+
"SpectrumPair",
|
|
44
|
+
"SpectrumSet",
|
|
45
|
+
"find_bandwidth",
|
|
46
|
+
"interpolate_onto",
|
|
47
|
+
"log_bin",
|
|
48
|
+
"parseval_scale",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class BinnedSpectrum:
|
|
54
|
+
"""A spectrum averaged into log-spaced bins.
|
|
55
|
+
|
|
56
|
+
Separate from :class:`~specmod.core.spectrum.Spectrum` because it is not
|
|
57
|
+
one: the bin centres are geometric midpoints of the edges rather than
|
|
58
|
+
Fourier frequencies, so record geometry (``duration``, ``sampling_rate``)
|
|
59
|
+
no longer determines the axis and the Parseval contract does not hold on
|
|
60
|
+
it. Conflating the two is how a binned spectrum ends up being handed to
|
|
61
|
+
something that assumes an FFT grid.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
freq: NDArray[np.float64]
|
|
65
|
+
amp: NDArray[np.float64]
|
|
66
|
+
|
|
67
|
+
def __post_init__(self) -> None:
|
|
68
|
+
if self.freq.shape != self.amp.shape:
|
|
69
|
+
raise ValueError(
|
|
70
|
+
f"freq {self.freq.shape} and amp {self.amp.shape} must match"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def __len__(self) -> int:
|
|
74
|
+
return int(self.freq.size)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def log_bin(
|
|
78
|
+
freq: NDArray[np.float64],
|
|
79
|
+
amp: NDArray[np.float64],
|
|
80
|
+
*,
|
|
81
|
+
f_min: float = 0.001,
|
|
82
|
+
f_max: float = 200.0,
|
|
83
|
+
n_bins: int = 101,
|
|
84
|
+
) -> BinnedSpectrum:
|
|
85
|
+
"""Average ``amp`` into ``n_bins`` log-spaced bins, dropping empty ones.
|
|
86
|
+
|
|
87
|
+
The requested range is clamped to the record's own, which is what makes
|
|
88
|
+
the requested bin count the count you get. Unclamped, the shipped defaults
|
|
89
|
+
(0.001 Hz to 200 Hz) sit far outside any real record — on the PNR data
|
|
90
|
+
roughly a third of the bins fall below the lowest frequency present and a
|
|
91
|
+
third above the highest, all of them empty — which is why the surviving
|
|
92
|
+
axis was always far shorter than ``n_bins``.
|
|
93
|
+
|
|
94
|
+
The average is geometric (the mean of ``log10(amp)``), matching the log
|
|
95
|
+
scale the bins themselves are spaced on. Empty bins are expected rather
|
|
96
|
+
than exceptional — log bins over a linear grid are inevitably sparse at the
|
|
97
|
+
low end — so they are dropped silently rather than warned about per bin.
|
|
98
|
+
|
|
99
|
+
**Membership is computed, not tested.** The bin index comes from the
|
|
100
|
+
position of ``log10(f)`` along the range, which puts every sample in
|
|
101
|
+
exactly one bin. The previous version tested ``f >= left and f <= right``
|
|
102
|
+
against each edge in turn: both ends closed, so a sample landing on an
|
|
103
|
+
interior edge belonged to *two* bins, and which of the two comparisons
|
|
104
|
+
succeeded depended on the last bit of ``np.logspace``. That is one of the
|
|
105
|
+
three places where a last-bit difference changed a result — it moved the
|
|
106
|
+
surviving bin count by one, and with it the length of ``bsnr``. Computing
|
|
107
|
+
the index removes the double membership and the edge comparison together.
|
|
108
|
+
"""
|
|
109
|
+
lo = max(f_min, float(freq.min()))
|
|
110
|
+
hi = min(f_max, float(freq.max()))
|
|
111
|
+
n_intervals = n_bins - 1
|
|
112
|
+
|
|
113
|
+
log_lo, log_hi = np.log10(lo), np.log10(hi)
|
|
114
|
+
width = (log_hi - log_lo) / n_intervals
|
|
115
|
+
|
|
116
|
+
# Index by position rather than by comparison against edges. The clip puts
|
|
117
|
+
# the sample sitting exactly at `hi` into the last bin rather than one past
|
|
118
|
+
# it, which is the only place the half-open rule needs an exception.
|
|
119
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
120
|
+
index = np.floor((np.log10(freq) - log_lo) / width).astype(int)
|
|
121
|
+
inside = (freq >= lo) & (freq <= hi)
|
|
122
|
+
index = np.clip(index, 0, n_intervals - 1)
|
|
123
|
+
|
|
124
|
+
amps = np.full(n_intervals, np.nan, dtype=np.float64)
|
|
125
|
+
log_amp = np.log10(amp)
|
|
126
|
+
for i in range(n_intervals):
|
|
127
|
+
selected = log_amp[inside & (index == i)]
|
|
128
|
+
if selected.size:
|
|
129
|
+
amps[i] = 10 ** selected.mean()
|
|
130
|
+
|
|
131
|
+
edges = np.logspace(log_lo, log_hi, n_bins)
|
|
132
|
+
centres = 10 ** (0.5 * (np.log10(edges[:-1]) + np.log10(edges[1:])))
|
|
133
|
+
|
|
134
|
+
keep = ~np.isnan(amps)
|
|
135
|
+
return BinnedSpectrum(freq=centres[keep], amp=amps[keep])
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _resolution_floor(spectrum: Spectrum) -> float:
|
|
139
|
+
"""The lowest frequency ``spectrum`` can actually resolve.
|
|
140
|
+
|
|
141
|
+
Read from ``meta`` when it is there, and only derived from the axis when it
|
|
142
|
+
is not. That order is the whole point. Deriving it works exactly once: the
|
|
143
|
+
noise is interpolated onto the signal's axis before binning, so from then
|
|
144
|
+
on ``noise.freq.min()`` is the *signal's* lowest frequency and the noise's
|
|
145
|
+
own is gone. :func:`specmod.pipeline.spectrum_from_trace` records it on the
|
|
146
|
+
spectrum for that reason, and until now nothing read it.
|
|
147
|
+
|
|
148
|
+
The consequence was that a converted pair had a lower floor than the pair
|
|
149
|
+
it came from — the shorter noise window's limit silently replaced by the
|
|
150
|
+
longer signal window's. `to_motion` therefore let the band open into the
|
|
151
|
+
region below the noise's resolution, where :func:`interpolate_onto` is
|
|
152
|
+
repeating an edge value rather than reporting a measurement, and the
|
|
153
|
+
signal-to-noise ratio has an invented denominator.
|
|
154
|
+
|
|
155
|
+
Falls back to the axis for a spectrum built by hand rather than by the
|
|
156
|
+
pipeline, which is the only case where the axis is still the truth.
|
|
157
|
+
"""
|
|
158
|
+
recorded = spectrum.meta.get("resolution_floor")
|
|
159
|
+
if recorded is not None:
|
|
160
|
+
return float(recorded)
|
|
161
|
+
return float(spectrum.freq.min()) if spectrum.freq.size else 0.0
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def parseval_scale(n_signal: int, n_noise: int) -> float:
|
|
165
|
+
"""Factor putting a noise spectrum on the signal's energy footing.
|
|
166
|
+
|
|
167
|
+
The two windows are rarely the same length — 1.2 to 1.6 s of noise against
|
|
168
|
+
1.8 to 3.5 s of signal on the PNR data — and a shorter record spreads the
|
|
169
|
+
same power over fewer bins. Comparing them without this compares spectra
|
|
170
|
+
computed over different durations.
|
|
171
|
+
"""
|
|
172
|
+
return float(np.sqrt(n_signal / n_noise))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def interpolate_onto(
|
|
176
|
+
target_freq: NDArray[np.float64],
|
|
177
|
+
freq: NDArray[np.float64],
|
|
178
|
+
amp: NDArray[np.float64],
|
|
179
|
+
) -> NDArray[np.float64]:
|
|
180
|
+
"""Resample ``amp`` onto ``target_freq``.
|
|
181
|
+
|
|
182
|
+
.. warning::
|
|
183
|
+
|
|
184
|
+
``np.interp`` does not extrapolate — it repeats the edge value. Below
|
|
185
|
+
``freq.min()`` the result is therefore a flat continuation rather than a
|
|
186
|
+
measurement, and a signal-to-noise ratio computed there has an invented
|
|
187
|
+
denominator. :meth:`SpectrumPair.resolution_floor` is what keeps the
|
|
188
|
+
selected band out of that region; this function does not, and must not
|
|
189
|
+
be used without it.
|
|
190
|
+
"""
|
|
191
|
+
return np.interp(target_freq, freq, amp)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def find_bandwidth(
|
|
195
|
+
freq: NDArray[np.float64],
|
|
196
|
+
snr: NDArray[np.float64],
|
|
197
|
+
threshold: float,
|
|
198
|
+
*,
|
|
199
|
+
method: str = "peak",
|
|
200
|
+
) -> tuple[float, float] | None:
|
|
201
|
+
"""Select the usable band with a named strategy.
|
|
202
|
+
|
|
203
|
+
A thin front for :data:`specmod.core.bandwidth.BANDWIDTH_SELECTORS`. The
|
|
204
|
+
default is ``"peak"``, which is what the shipped configuration has always
|
|
205
|
+
used — the legacy ``BW_METHOD = 2``. See that module for what the
|
|
206
|
+
strategies assume and why the choice matters.
|
|
207
|
+
"""
|
|
208
|
+
return get_bandwidth_selector(method).select(freq, snr, threshold)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass(frozen=True)
|
|
212
|
+
class SpectrumPair:
|
|
213
|
+
"""A signal spectrum and the noise it is judged against.
|
|
214
|
+
|
|
215
|
+
Build with :meth:`compare`, which runs the rescale, the interpolation, the
|
|
216
|
+
binning and the band search in the order they depend on each other.
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
#: Where :meth:`compare` records its own arguments inside ``meta``.
|
|
220
|
+
SETTINGS_KEY: ClassVar[str] = "compare_settings"
|
|
221
|
+
|
|
222
|
+
signal: Spectrum
|
|
223
|
+
noise: Spectrum
|
|
224
|
+
binned_signal: BinnedSpectrum
|
|
225
|
+
binned_noise: BinnedSpectrum
|
|
226
|
+
snr: NDArray[np.float64]
|
|
227
|
+
resolution_floor: float
|
|
228
|
+
band: tuple[float, float] | None = None
|
|
229
|
+
meta: Mapping[str, Any] = field(default_factory=dict)
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def passes(self) -> bool:
|
|
233
|
+
"""Whether a usable band survived."""
|
|
234
|
+
return self.band is not None
|
|
235
|
+
|
|
236
|
+
def for_fitting(self, id: str = "") -> FittableView:
|
|
237
|
+
"""This pair as the flat view a fitter reads. See :class:`FittableView`."""
|
|
238
|
+
return FittableView(pair=self, id=id)
|
|
239
|
+
|
|
240
|
+
@classmethod
|
|
241
|
+
def compare(
|
|
242
|
+
cls,
|
|
243
|
+
signal: Spectrum,
|
|
244
|
+
noise: Spectrum,
|
|
245
|
+
*,
|
|
246
|
+
threshold: float = 3.0,
|
|
247
|
+
f_min: float = 0.001,
|
|
248
|
+
f_max: float = 200.0,
|
|
249
|
+
n_bins: int = 101,
|
|
250
|
+
scale_parseval: bool = True,
|
|
251
|
+
resolution_floor: bool = True,
|
|
252
|
+
rotate_noise: bool = True,
|
|
253
|
+
noise_model: str | NoiseModel = "boost",
|
|
254
|
+
bandwidth: str = "peak",
|
|
255
|
+
rotation_inc: float = 0.05,
|
|
256
|
+
rotation_space: tuple[float, float] = (0.001, 1.001),
|
|
257
|
+
meta: Mapping[str, Any] | None = None,
|
|
258
|
+
) -> SpectrumPair:
|
|
259
|
+
"""Pair the two and select the band.
|
|
260
|
+
|
|
261
|
+
The order matters and is not arbitrary. The noise is rescaled and moved
|
|
262
|
+
onto the signal's frequency axis *before* binning, which is what makes
|
|
263
|
+
the two binned arrays share bin edges — the element-wise ratio below is
|
|
264
|
+
only meaningful because of it, and it holds for every estimator
|
|
265
|
+
including those whose native axes differ in length.
|
|
266
|
+
|
|
267
|
+
The floor is captured from the two spectra before the interpolation,
|
|
268
|
+
because afterwards the noise carries the signal's axis and its own
|
|
269
|
+
lowest resolvable frequency is unrecoverable.
|
|
270
|
+
"""
|
|
271
|
+
floor = max(_resolution_floor(signal), _resolution_floor(noise))
|
|
272
|
+
|
|
273
|
+
noise_amp = np.asarray(noise.amp, dtype=np.float64)
|
|
274
|
+
if scale_parseval:
|
|
275
|
+
noise_amp = noise_amp * parseval_scale(signal.amp.size, noise.amp.size)
|
|
276
|
+
noise_amp = interpolate_onto(signal.freq, noise.freq, noise_amp)
|
|
277
|
+
|
|
278
|
+
binned_signal = log_bin(
|
|
279
|
+
signal.freq,
|
|
280
|
+
np.asarray(signal.amp),
|
|
281
|
+
f_min=f_min,
|
|
282
|
+
f_max=f_max,
|
|
283
|
+
n_bins=n_bins,
|
|
284
|
+
)
|
|
285
|
+
binned_noise = log_bin(
|
|
286
|
+
signal.freq, noise_amp, f_min=f_min, f_max=f_max, n_bins=n_bins
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
if rotate_noise:
|
|
290
|
+
# The factor is derived on the binned axis — that is where the
|
|
291
|
+
# method is defined — and applied to the *unbinned* noise, which
|
|
292
|
+
# then becomes the single source the binned noise is derived from.
|
|
293
|
+
#
|
|
294
|
+
# The order matters and used to be the other way round: the lift
|
|
295
|
+
# multiplied the bins directly and, separately, the unbinned array
|
|
296
|
+
# by the factor interpolated up. Those two operations do not agree.
|
|
297
|
+
# A bin holds the geometric mean of `log10(amp)`, so binning the
|
|
298
|
+
# lifted noise gives `mean(log a) + mean(log f)` while lifting the
|
|
299
|
+
# bin gives `mean(log a) + log f(centre)` — equal only where the
|
|
300
|
+
# factor is flat across the bin.
|
|
301
|
+
#
|
|
302
|
+
# The result was that a stored pair's `binned_noise` was not the
|
|
303
|
+
# binning of its own `noise`, by up to 18.8% on the PNR windows.
|
|
304
|
+
# Every pair was born inconsistent; a domain change re-bins, so
|
|
305
|
+
# `to_motion` silently *repaired* it and looked like the culprit.
|
|
306
|
+
model = _resolve_noise_model(noise_model, rotation_space)
|
|
307
|
+
factor = model.factor(
|
|
308
|
+
binned_noise.freq, binned_noise.amp, binned_signal.amp
|
|
309
|
+
)
|
|
310
|
+
noise_amp = noise_amp * interpolate_onto(
|
|
311
|
+
signal.freq, binned_noise.freq, factor
|
|
312
|
+
)
|
|
313
|
+
binned_noise = log_bin(
|
|
314
|
+
signal.freq, noise_amp, f_min=f_min, f_max=f_max, n_bins=n_bins
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
snr = binned_signal.amp / binned_noise.amp
|
|
318
|
+
band = find_bandwidth(binned_signal.freq, snr, threshold, method=bandwidth)
|
|
319
|
+
if band is not None and resolution_floor:
|
|
320
|
+
band = _clamp_to_floor(band, floor)
|
|
321
|
+
|
|
322
|
+
aligned_noise = Spectrum(
|
|
323
|
+
freq=signal.freq,
|
|
324
|
+
amp=noise_amp,
|
|
325
|
+
motion=noise.motion,
|
|
326
|
+
kind=noise.kind,
|
|
327
|
+
duration=noise.duration,
|
|
328
|
+
sampling_rate=noise.sampling_rate,
|
|
329
|
+
meta=dict(noise.meta),
|
|
330
|
+
)
|
|
331
|
+
recorded = dict(meta or {})
|
|
332
|
+
# How this pair was made, kept so it can be remade. `to_motion` needs
|
|
333
|
+
# to replay the binning and the band search on converted amplitudes,
|
|
334
|
+
# and a pair that cannot say what settings produced it could only do
|
|
335
|
+
# that by being told again — which is how the settings of a stored
|
|
336
|
+
# result drift from the settings it was actually computed with.
|
|
337
|
+
recorded[cls.SETTINGS_KEY] = {
|
|
338
|
+
"threshold": threshold,
|
|
339
|
+
"f_min": f_min,
|
|
340
|
+
"f_max": f_max,
|
|
341
|
+
"n_bins": n_bins,
|
|
342
|
+
"scale_parseval": scale_parseval,
|
|
343
|
+
"resolution_floor": resolution_floor,
|
|
344
|
+
"rotate_noise": rotate_noise,
|
|
345
|
+
"noise_model": noise_model,
|
|
346
|
+
"bandwidth": bandwidth,
|
|
347
|
+
"rotation_inc": rotation_inc,
|
|
348
|
+
"rotation_space": rotation_space,
|
|
349
|
+
}
|
|
350
|
+
return cls(
|
|
351
|
+
signal=signal,
|
|
352
|
+
noise=aligned_noise,
|
|
353
|
+
binned_signal=binned_signal,
|
|
354
|
+
binned_noise=binned_noise,
|
|
355
|
+
snr=snr,
|
|
356
|
+
resolution_floor=floor,
|
|
357
|
+
band=band,
|
|
358
|
+
meta=recorded,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
def to_motion(self, motion: Motion | str) -> SpectrumPair:
|
|
362
|
+
"""This pair in another ground-motion domain, re-binned and re-banded.
|
|
363
|
+
|
|
364
|
+
Replaces ``spectral.SNP.integrate``/``differentiate``, which mutated
|
|
365
|
+
in place and had no way to express "the same event, as displacement"
|
|
366
|
+
other than destroying the velocity one. This returns a new pair.
|
|
367
|
+
|
|
368
|
+
**The noise is not lifted again.** ``self.noise`` already carries the
|
|
369
|
+
lift from the comparison that built this pair, and applying it a second
|
|
370
|
+
time would compound on every conversion — narrowing the band each time.
|
|
371
|
+
The pre-refactor code guarded this with a ``ROTATED`` flag; here it
|
|
372
|
+
falls out of the settings being replayed with ``rotate_noise=False``.
|
|
373
|
+
|
|
374
|
+
**The band can move, and not for the reason it first appears.** The
|
|
375
|
+
*unbinned* signal-to-noise ratio is invariant under a domain change —
|
|
376
|
+
both spectra are multiplied by the same power of ``2*pi*f``. The
|
|
377
|
+
*binned* ratio is not, because a bin holds the geometric mean of
|
|
378
|
+
``log10(amp)`` and averaging ``log10(a/f)`` over a bin is not
|
|
379
|
+
``log10(a)`` averaged minus ``log10(f_centre)`` unless the centre is
|
|
380
|
+
the geometric mean of the frequencies in it. Measured on the 28 PNR
|
|
381
|
+
windows: the binned ratio moves by up to 16%, and 3 of the 28 bands
|
|
382
|
+
with it.
|
|
383
|
+
"""
|
|
384
|
+
settings = dict(self.meta.get(self.SETTINGS_KEY, {}))
|
|
385
|
+
settings["rotate_noise"] = False
|
|
386
|
+
meta = {k: v for k, v in self.meta.items() if k != self.SETTINGS_KEY}
|
|
387
|
+
return type(self).compare(
|
|
388
|
+
self.signal.to_motion(motion),
|
|
389
|
+
self.noise.to_motion(motion),
|
|
390
|
+
meta=meta,
|
|
391
|
+
**settings,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _resolve_noise_model(
|
|
396
|
+
noise_model: str | NoiseModel, space: tuple[float, float]
|
|
397
|
+
) -> NoiseModel:
|
|
398
|
+
"""Turn a name or an instance into a model, honouring the legacy ``space``.
|
|
399
|
+
|
|
400
|
+
``space`` is a parameter of the boost method alone, and it arrives here as
|
|
401
|
+
a loose keyword rather than on the model because that is how the legacy
|
|
402
|
+
configuration stored it. Passing an already-constructed model instead is
|
|
403
|
+
the way to say what you mean; then the keyword is ignored, because the
|
|
404
|
+
instance already carries its own.
|
|
405
|
+
"""
|
|
406
|
+
if not isinstance(noise_model, str):
|
|
407
|
+
return noise_model
|
|
408
|
+
model = get_noise_model(noise_model)
|
|
409
|
+
if isinstance(model, BoostNoise) and space != model.space:
|
|
410
|
+
return BoostNoise(space=space)
|
|
411
|
+
return model
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _clamp_to_floor(
|
|
415
|
+
band: tuple[float, float], floor: float
|
|
416
|
+
) -> tuple[float, float] | None:
|
|
417
|
+
"""Refuse the part of a band that rests on an extrapolated noise level.
|
|
418
|
+
|
|
419
|
+
Below the floor the noise is ``np.interp``'s repeated edge value, so the
|
|
420
|
+
ratio there is measured against nothing. Raising the low edge is the
|
|
421
|
+
conservative response; if the floor swallows the band entirely there is no
|
|
422
|
+
usable measurement and the answer is ``None`` rather than a narrower band
|
|
423
|
+
that would look like a result.
|
|
424
|
+
"""
|
|
425
|
+
low, high = band
|
|
426
|
+
if low >= floor:
|
|
427
|
+
return band
|
|
428
|
+
if floor >= high:
|
|
429
|
+
return None
|
|
430
|
+
return floor, high
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
@dataclass(frozen=True)
|
|
434
|
+
class FittableView:
|
|
435
|
+
"""A pair presented as the flat thing a fitter reads.
|
|
436
|
+
|
|
437
|
+
``SpectrumPair`` keeps the unbinned spectrum and its binned form as
|
|
438
|
+
separate objects, which is right for the comparison — they are different
|
|
439
|
+
kinds of thing, and conflating them is how a binned axis ends up somewhere
|
|
440
|
+
that assumes an FFT grid. A fitter wants them side by side, so this is the
|
|
441
|
+
view that puts them there.
|
|
442
|
+
|
|
443
|
+
A view rather than a conversion: it holds the pair and reads through, so
|
|
444
|
+
there is one copy of the arrays and no question of which is authoritative.
|
|
445
|
+
"""
|
|
446
|
+
|
|
447
|
+
pair: SpectrumPair
|
|
448
|
+
id: str = ""
|
|
449
|
+
|
|
450
|
+
@property
|
|
451
|
+
def meta(self) -> dict[str, Any]:
|
|
452
|
+
# A plain dict, not the Spectrum's `MappingProxyType`. The proxy is
|
|
453
|
+
# right for an immutable spectrum but cannot be deepcopied, and the
|
|
454
|
+
# fitter deepcopies metadata so a fit cannot write back into the
|
|
455
|
+
# spectrum it was built from. Converting here is the adapter earning
|
|
456
|
+
# its keep.
|
|
457
|
+
meta = dict(self.pair.signal.meta)
|
|
458
|
+
# The band and the gate belong in a flat fit table: a fitted corner
|
|
459
|
+
# frequency without the band it was read over is not interpretable,
|
|
460
|
+
# and it is the first thing anyone comparing two runs asks for. Under
|
|
461
|
+
# the legacy names, so a flatfile written from either container has the
|
|
462
|
+
# same columns.
|
|
463
|
+
meta["pass_snr"] = self.pair.passes
|
|
464
|
+
if self.pair.band is not None:
|
|
465
|
+
meta["lower-f-bound"] = float(self.pair.band[0])
|
|
466
|
+
meta["upper-f-bound"] = float(self.pair.band[1])
|
|
467
|
+
if self.id:
|
|
468
|
+
meta.setdefault("id", self.id)
|
|
469
|
+
return meta
|
|
470
|
+
|
|
471
|
+
@property
|
|
472
|
+
def freq(self) -> NDArray[np.float64]:
|
|
473
|
+
return self.pair.signal.freq
|
|
474
|
+
|
|
475
|
+
@property
|
|
476
|
+
def amp(self) -> NDArray[np.float64]:
|
|
477
|
+
return self.pair.signal.amp
|
|
478
|
+
|
|
479
|
+
@property
|
|
480
|
+
def bfreq(self) -> NDArray[np.float64]:
|
|
481
|
+
return self.pair.binned_signal.freq
|
|
482
|
+
|
|
483
|
+
@property
|
|
484
|
+
def bamp(self) -> NDArray[np.float64]:
|
|
485
|
+
return self.pair.binned_signal.amp
|
|
486
|
+
|
|
487
|
+
@property
|
|
488
|
+
def band(self) -> tuple[float, float] | None:
|
|
489
|
+
return self.pair.band
|
|
490
|
+
|
|
491
|
+
@property
|
|
492
|
+
def passes(self) -> bool:
|
|
493
|
+
return self.pair.passes
|
|
494
|
+
|
|
495
|
+
@property
|
|
496
|
+
def motion(self) -> Motion:
|
|
497
|
+
"""The ground-motion domain the arrays are in.
|
|
498
|
+
|
|
499
|
+
Read through like the rest, because a fitter has to know it: the
|
|
500
|
+
initial guess for ``fc`` is the frequency of the spectral peak, and
|
|
501
|
+
that is the corner only in velocity.
|
|
502
|
+
"""
|
|
503
|
+
return self.pair.signal.motion
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
@dataclass(frozen=True)
|
|
507
|
+
class SpectrumSet:
|
|
508
|
+
"""The pairs for one event, keyed by trace id.
|
|
509
|
+
|
|
510
|
+
Replaces ``spectral.Spectra``. A mapping rather than a class with a
|
|
511
|
+
``group`` attribute, so the obvious operations — iterate, filter, count —
|
|
512
|
+
are the ones that work.
|
|
513
|
+
"""
|
|
514
|
+
|
|
515
|
+
pairs: Mapping[str, SpectrumPair]
|
|
516
|
+
event: str = ""
|
|
517
|
+
meta: Mapping[str, Any] = field(default_factory=dict)
|
|
518
|
+
|
|
519
|
+
def __getitem__(self, key: str) -> SpectrumPair:
|
|
520
|
+
return self.pairs[key]
|
|
521
|
+
|
|
522
|
+
def __iter__(self) -> Iterator[str]:
|
|
523
|
+
return iter(self.pairs)
|
|
524
|
+
|
|
525
|
+
def __len__(self) -> int:
|
|
526
|
+
return len(self.pairs)
|
|
527
|
+
|
|
528
|
+
def passing(self) -> SpectrumSet:
|
|
529
|
+
"""Only the pairs that yielded a usable band."""
|
|
530
|
+
return SpectrumSet(
|
|
531
|
+
pairs={k: v for k, v in self.pairs.items() if v.passes},
|
|
532
|
+
event=self.event,
|
|
533
|
+
meta=dict(self.meta),
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
def ids(self) -> Sequence[str]:
|
|
537
|
+
return sorted(self.pairs)
|
|
538
|
+
|
|
539
|
+
def to_motion(self, motion: Motion | str) -> SpectrumSet:
|
|
540
|
+
"""The whole event in another ground-motion domain.
|
|
541
|
+
|
|
542
|
+
Replaces ``spectral.Spectra.inte``/``diff``. See
|
|
543
|
+
:meth:`SpectrumPair.to_motion` for what is recomputed and what is not.
|
|
544
|
+
"""
|
|
545
|
+
return SpectrumSet(
|
|
546
|
+
pairs={k: v.to_motion(motion) for k, v in self.pairs.items()},
|
|
547
|
+
event=self.event,
|
|
548
|
+
meta=dict(self.meta),
|
|
549
|
+
)
|