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