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
specmod/staged.py
ADDED
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
"""The two-stage event fit, and the channel selection that feeds it.
|
|
2
|
+
|
|
3
|
+
Fitting a source model to one spectrum is not a unique inversion. The source
|
|
4
|
+
corner and the path attenuation trade off against each other on the falling
|
|
5
|
+
limb, and two minimisers can reach the same reduced chi-squared at corner
|
|
6
|
+
frequencies differing by tens of percent — a factor of several in stress drop,
|
|
7
|
+
which scales as ``fc**3``. On the 28 PNR windows, Powell and ``leastsq`` land
|
|
8
|
+
at 21.26 Hz and 14.75 Hz on one station at redchi 0.0259 against 0.0254.
|
|
9
|
+
|
|
10
|
+
That is not resolvable from one spectrum by any minimiser, and the published
|
|
11
|
+
workflow does not try. ``f_c`` belongs to the **source**: every station sees
|
|
12
|
+
the same rupture, so there is one value of it for the event. ``t*`` belongs to
|
|
13
|
+
the **path**, and every station has a different one. So a station whose ``t*``
|
|
14
|
+
came out too high returns a corner that is too high, and the next station's
|
|
15
|
+
error does not point the same way. Averaging over the ensemble is not
|
|
16
|
+
cosmetic smoothing — it uses the fact that the quantity being averaged is
|
|
17
|
+
common to all of them while the contaminating one is not.
|
|
18
|
+
|
|
19
|
+
The two stages
|
|
20
|
+
--------------
|
|
21
|
+
1. ``Omega``, ``f_c`` and ``t*`` free at every station independently. The
|
|
22
|
+
output is not the answer; it is N noisy estimates of one number plus N
|
|
23
|
+
estimates of N different numbers.
|
|
24
|
+
2. The event ``f_c`` — a weighted mean over the stations that survived
|
|
25
|
+
selection — is held fixed, and every station refits ``Omega`` and ``t*``
|
|
26
|
+
against a corner it can no longer trade against.
|
|
27
|
+
|
|
28
|
+
Measured on the same 28 windows, with the same 28 channels contributing to
|
|
29
|
+
both: the two minimisers differ by a factor 1.44 in ``f_c`` at the worst
|
|
30
|
+
station and by **0.4%** on the event value. After stage two they agree to
|
|
31
|
+
0.23% on ``t*`` and 1.7e-3 log10 units on ``Omega``.
|
|
32
|
+
|
|
33
|
+
"With the same channels contributing" is load-bearing, and is the trap in this
|
|
34
|
+
module. See :attr:`ChannelSelection.require_pass` — comparing two minimisers
|
|
35
|
+
under the default selection compares two different ensembles, and gives 125%
|
|
36
|
+
rather than 0.4%.
|
|
37
|
+
|
|
38
|
+
Be clear about what that last number is not. Fixing ``f_c`` removes the
|
|
39
|
+
parameter the minimisers were disagreeing about, so of course they then agree.
|
|
40
|
+
What it shows is that the residual two-parameter problem is well conditioned:
|
|
41
|
+
once the corner is pinned, ``Omega`` and ``t*`` are determined by the spectrum
|
|
42
|
+
rather than negotiable. The judgement is concentrated into one number for the
|
|
43
|
+
whole event, and that number came from the ensemble.
|
|
44
|
+
|
|
45
|
+
Choosing which channels contribute
|
|
46
|
+
----------------------------------
|
|
47
|
+
Selection is the part that cannot be automated away, because it is where
|
|
48
|
+
quality control enters. A station with a bad instrument response, a clipped
|
|
49
|
+
record or a pick on the wrong phase produces a corner frequency that is
|
|
50
|
+
confidently wrong, and averaging it in moves the event value for every other
|
|
51
|
+
station.
|
|
52
|
+
|
|
53
|
+
So the ensemble is chosen by :class:`ChannelSelection`, which reads
|
|
54
|
+
``[fitting]`` and can be overridden per call. The order is fixed and each step
|
|
55
|
+
is recorded with a reason, so ``StagedFit.excluded`` says why any given
|
|
56
|
+
channel is not contributing:
|
|
57
|
+
|
|
58
|
+
1. anything not matching ``include`` (when ``include`` is non-empty),
|
|
59
|
+
2. anything matching ``exclude``,
|
|
60
|
+
3. anything whose stage-1 fit failed its bounds, when ``require_pass``,
|
|
61
|
+
4. anything left with no fit at all.
|
|
62
|
+
|
|
63
|
+
Patterns are shell globs, and they match at whichever level you write them.
|
|
64
|
+
A trace id is ``NET.STA.LOC.CHA``, and a pattern is tried against the whole
|
|
65
|
+
id, against ``NET.STA``, and against each component on its own — so all of
|
|
66
|
+
these do what they look like they do::
|
|
67
|
+
|
|
68
|
+
"AQ07" every channel of that station
|
|
69
|
+
"UR" every station of that network
|
|
70
|
+
"UR.AQ07" that station, spelled unambiguously
|
|
71
|
+
"HHE" every east component, at every station
|
|
72
|
+
"HH?" every high-gain broadband channel
|
|
73
|
+
"UR.AQ07.00.HHE" exactly that channel
|
|
74
|
+
"LV.L00[123]..HH?" a glob over the full id
|
|
75
|
+
|
|
76
|
+
Writing the station code alone is the common case after quality control —
|
|
77
|
+
a clipped record or a bad response is a property of the instrument, not of one
|
|
78
|
+
component — and needing ``"UR.AQ07.*"`` for it is the kind of detail that gets
|
|
79
|
+
mistyped as ``"AQ07"`` and silently matches nothing. Station and channel codes
|
|
80
|
+
do not collide in practice; where a pattern could match at two levels it
|
|
81
|
+
matches, and :attr:`StagedFit.excluded` records which level it hit.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
from __future__ import annotations
|
|
85
|
+
|
|
86
|
+
import contextlib
|
|
87
|
+
import fnmatch
|
|
88
|
+
import io
|
|
89
|
+
from dataclasses import dataclass, field
|
|
90
|
+
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
|
91
|
+
|
|
92
|
+
import numpy as np
|
|
93
|
+
|
|
94
|
+
from .config import load_config
|
|
95
|
+
from .distance import DistanceMeasure, resolve_distance_measure
|
|
96
|
+
from .fitting import FitSpectra
|
|
97
|
+
|
|
98
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
99
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
100
|
+
|
|
101
|
+
from numpy.typing import NDArray
|
|
102
|
+
|
|
103
|
+
__all__ = [
|
|
104
|
+
"WEIGHT_MODELS",
|
|
105
|
+
"ChannelSelection",
|
|
106
|
+
"StagedFit",
|
|
107
|
+
"WeightModel",
|
|
108
|
+
"fit_event",
|
|
109
|
+
"get_weight_model",
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@runtime_checkable
|
|
114
|
+
class WeightModel(Protocol):
|
|
115
|
+
"""How much each station's stage-1 estimate counts toward the event value.
|
|
116
|
+
|
|
117
|
+
Given the stage-1 table and the spectra it was fitted to, return one weight
|
|
118
|
+
per row. Weights need not sum to anything; they are normalised on use.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
name: str
|
|
122
|
+
|
|
123
|
+
def weights(
|
|
124
|
+
self, table: Any, spectra: Any, ids: Sequence[str]
|
|
125
|
+
) -> NDArray[np.float64]: ... # pragma: no cover
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True, slots=True)
|
|
129
|
+
class InverseDistance:
|
|
130
|
+
"""Weight by ``1 / distance``, the published choice.
|
|
131
|
+
|
|
132
|
+
The nearer station has less path between the source and the sensor, so
|
|
133
|
+
less of its high-frequency falloff can be attenuation and its corner is the
|
|
134
|
+
better constrained of the two. That is the argument for it; it is a
|
|
135
|
+
modelling choice rather than a derivation, which is why this is a registry
|
|
136
|
+
and not a hardcoded expression.
|
|
137
|
+
|
|
138
|
+
**Which distance is itself a choice**, and at short range not a small one:
|
|
139
|
+
see :mod:`specmod.distance`. ``measure=None`` takes the project-wide
|
|
140
|
+
setting, so a study that has decided on epicentral does not have to say so
|
|
141
|
+
again here.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
#: ``None`` means "whatever the configuration says". Distance is needed by
|
|
145
|
+
#: geometric spreading as well as by weighting, so the choice belongs in
|
|
146
|
+
#: one place rather than being restated per consumer.
|
|
147
|
+
measure: str | DistanceMeasure | None = None
|
|
148
|
+
name: str = "inverse_distance"
|
|
149
|
+
|
|
150
|
+
def weights(
|
|
151
|
+
self, table: Any, spectra: Any, ids: Sequence[str]
|
|
152
|
+
) -> NDArray[np.float64]:
|
|
153
|
+
distances = resolve_distance_measure(self.measure).distances(spectra, ids)
|
|
154
|
+
weights: NDArray[np.float64] = 1.0 / distances
|
|
155
|
+
return weights
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(frozen=True, slots=True)
|
|
159
|
+
class Uniform:
|
|
160
|
+
"""Every contributing station counts the same.
|
|
161
|
+
|
|
162
|
+
The honest default when the geometry is unknown, and the right one when
|
|
163
|
+
the stations are at comparable distances — where inverse-distance weighting
|
|
164
|
+
adds variance without adding information.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
name: str = "uniform"
|
|
168
|
+
|
|
169
|
+
def weights(
|
|
170
|
+
self, table: Any, spectra: Any, ids: Sequence[str]
|
|
171
|
+
) -> NDArray[np.float64]:
|
|
172
|
+
return np.ones(len(ids), dtype=np.float64)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass(frozen=True, slots=True)
|
|
176
|
+
class InverseVariance:
|
|
177
|
+
"""Weight by ``1 / stderr**2`` on the aggregated parameter.
|
|
178
|
+
|
|
179
|
+
Statistically the right answer when the uncertainties are real, and
|
|
180
|
+
unavailable under the shipped minimiser: Powell estimates no covariance
|
|
181
|
+
matrix, so lmfit reports ``stderr`` as ``None`` for every parameter. This
|
|
182
|
+
raises rather than falling back, because silently becoming uniform is the
|
|
183
|
+
kind of substitution that ends up in a paper.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
name: str = "inverse_variance"
|
|
187
|
+
|
|
188
|
+
def weights(
|
|
189
|
+
self, table: Any, spectra: Any, ids: Sequence[str]
|
|
190
|
+
) -> NDArray[np.float64]:
|
|
191
|
+
indexed = table.set_index("id")
|
|
192
|
+
column = f"{_aggregated_parameter()}-stderr"
|
|
193
|
+
if column not in indexed:
|
|
194
|
+
raise ValueError(f"the fit table has no {column!r} column")
|
|
195
|
+
errors = indexed.loc[list(ids), column].to_numpy(dtype=np.float64)
|
|
196
|
+
if not np.isfinite(errors).all():
|
|
197
|
+
raise ValueError(
|
|
198
|
+
"inverse-variance weighting needs an uncertainty on every "
|
|
199
|
+
"contributing station, and some are missing. The configured "
|
|
200
|
+
"minimiser is likely 'powell', which estimates no covariance "
|
|
201
|
+
"matrix — use 'leastsq', or weight another way."
|
|
202
|
+
)
|
|
203
|
+
weights: NDArray[np.float64] = 1.0 / errors**2
|
|
204
|
+
return weights
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
#: Registered weightings, resolved by name from ``[fitting] event_weighting``.
|
|
208
|
+
WEIGHT_MODELS: dict[str, Any] = {
|
|
209
|
+
# Follows the configured distance measure, so a project-wide choice is
|
|
210
|
+
# honoured in one place. The shipped default.
|
|
211
|
+
"inverse_distance": InverseDistance,
|
|
212
|
+
# And explicit spellings, for a study that wants to say which it used
|
|
213
|
+
# regardless of what the rest of the configuration says.
|
|
214
|
+
"inverse_hypocentral_distance": lambda: InverseDistance(measure="rhyp"),
|
|
215
|
+
"inverse_epicentral_distance": lambda: InverseDistance(measure="repi"),
|
|
216
|
+
"uniform": Uniform,
|
|
217
|
+
"inverse_variance": InverseVariance,
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def get_weight_model(name: str) -> WeightModel:
|
|
222
|
+
"""Resolve a registered weighting by name, with its defaults."""
|
|
223
|
+
try:
|
|
224
|
+
factory = WEIGHT_MODELS[name]
|
|
225
|
+
except KeyError:
|
|
226
|
+
raise ValueError(
|
|
227
|
+
f"Unknown weighting {name!r}. Available: {sorted(WEIGHT_MODELS)}."
|
|
228
|
+
) from None
|
|
229
|
+
model: WeightModel = factory()
|
|
230
|
+
return model
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _aggregated_parameter() -> str:
|
|
234
|
+
return str(load_config().config.fitting.event_parameter)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@dataclass(frozen=True, slots=True)
|
|
238
|
+
class ChannelSelection:
|
|
239
|
+
"""Which channels contribute to the event value.
|
|
240
|
+
|
|
241
|
+
Defaults come from ``[fitting]``; pass one of these to override per call.
|
|
242
|
+
The point of it being a value rather than four arguments is that a
|
|
243
|
+
selection can be written down, compared and stored — a run that dropped
|
|
244
|
+
three stations should be able to say so afterwards.
|
|
245
|
+
"""
|
|
246
|
+
|
|
247
|
+
include: tuple[str, ...] = ()
|
|
248
|
+
exclude: tuple[str, ...] = ()
|
|
249
|
+
#: Drop a station whose stage-1 fit ended with a parameter against a bound.
|
|
250
|
+
#:
|
|
251
|
+
#: **This interacts with the minimiser, and not symmetrically.**
|
|
252
|
+
#: ``pass_fitting`` asks whether ``value +/- stderr`` reaches a bound.
|
|
253
|
+
#: Powell — the shipped default — estimates no covariance matrix, so
|
|
254
|
+
#: ``stderr`` is ``None``, the spread is zero, and the test degenerates to
|
|
255
|
+
#: "is the value exactly on the bound", which essentially never fires. On
|
|
256
|
+
#: these 28 windows it drops **0** stations under Powell and **6** under
|
|
257
|
+
#: ``leastsq``.
|
|
258
|
+
#:
|
|
259
|
+
#: So changing the minimiser changes *which stations vote*, not just how
|
|
260
|
+
#: each one is fitted. Comparing two minimisers under the default
|
|
261
|
+
#: selection therefore compares two different ensembles: 12.081 Hz from 28
|
|
262
|
+
#: channels against 5.376 Hz from 22, a 125% difference that says almost
|
|
263
|
+
#: nothing about the minimisers. Holding the ensemble fixed with
|
|
264
|
+
#: ``require_pass=False`` gives 12.081 against 12.029 — 0.4%, which is the
|
|
265
|
+
#: honest comparison and the number quoted in the module docstring.
|
|
266
|
+
#:
|
|
267
|
+
#: Left ``True`` because the flag is doing the right thing when it fires:
|
|
268
|
+
#: a pinned parameter reports its bound rather than a measurement, and
|
|
269
|
+
#: averaging that in averages in a constant. But a study comparing
|
|
270
|
+
#: minimisers, or reporting a corner frequency alongside one fitted
|
|
271
|
+
#: another way, has to set this ``False`` or account for it.
|
|
272
|
+
require_pass: bool = True
|
|
273
|
+
|
|
274
|
+
@classmethod
|
|
275
|
+
def from_config(cls) -> ChannelSelection:
|
|
276
|
+
fitting = load_config().config.fitting
|
|
277
|
+
return cls(
|
|
278
|
+
include=tuple(fitting.include),
|
|
279
|
+
exclude=tuple(fitting.exclude),
|
|
280
|
+
require_pass=bool(fitting.require_pass),
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
def choose(self, fit: FitSpectra) -> tuple[list[str], dict[str, str]]:
|
|
284
|
+
"""Split ``fit``'s stations into contributors and reasoned exclusions."""
|
|
285
|
+
contributing: list[str] = []
|
|
286
|
+
excluded: dict[str, str] = {}
|
|
287
|
+
|
|
288
|
+
table = fit.table.set_index("id") if len(fit.table) else None
|
|
289
|
+
# `ids()` on a SpectrumSet, plain iteration on anything else — the same
|
|
290
|
+
# duck-typed contract the fitter itself accepts.
|
|
291
|
+
spectra = fit.spectra
|
|
292
|
+
ids = spectra.ids() if hasattr(spectra, "ids") else list(spectra)
|
|
293
|
+
for id in ids:
|
|
294
|
+
if id not in fit.models:
|
|
295
|
+
excluded[id] = "no fit: the station did not pass the band gate"
|
|
296
|
+
continue
|
|
297
|
+
if self.include and _match(id, self.include) is None:
|
|
298
|
+
excluded[id] = f"not matched by include={list(self.include)}"
|
|
299
|
+
continue
|
|
300
|
+
hit = _match(id, self.exclude)
|
|
301
|
+
if hit is not None:
|
|
302
|
+
level, pattern = hit
|
|
303
|
+
excluded[id] = f"matched exclude={pattern!r} at {level}"
|
|
304
|
+
continue
|
|
305
|
+
if table is None or id not in table.index:
|
|
306
|
+
excluded[id] = "no row in the stage-1 table: the fit did not run"
|
|
307
|
+
continue
|
|
308
|
+
if self.require_pass and not bool(table.loc[id, "pass_fitting"]):
|
|
309
|
+
excluded[id] = (
|
|
310
|
+
"stage-1 fit failed: a parameter is pinned against a bound, "
|
|
311
|
+
"so the value reported is the bound rather than a measurement"
|
|
312
|
+
)
|
|
313
|
+
continue
|
|
314
|
+
contributing.append(id)
|
|
315
|
+
|
|
316
|
+
return contributing, excluded
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
#: The parts of ``NET.STA.LOC.CHA`` a pattern may be written against, in the
|
|
320
|
+
#: order a match is reported. Full id first so the most specific spelling wins
|
|
321
|
+
#: the explanation.
|
|
322
|
+
def _levels(id: str) -> list[tuple[str, str]]:
|
|
323
|
+
parts = id.split(".")
|
|
324
|
+
if len(parts) != 4:
|
|
325
|
+
# Not a SEED id — match it whole and say nothing more about it.
|
|
326
|
+
return [("id", id)]
|
|
327
|
+
net, sta, loc, cha = parts
|
|
328
|
+
return [
|
|
329
|
+
("id", id),
|
|
330
|
+
("station", f"{net}.{sta}"),
|
|
331
|
+
("network", net),
|
|
332
|
+
("station", sta),
|
|
333
|
+
("location", loc),
|
|
334
|
+
("channel", cha),
|
|
335
|
+
]
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _match(id: str, patterns: Iterable[str]) -> tuple[str, str] | None:
|
|
339
|
+
"""The (level, pattern) a channel matched at, or ``None``.
|
|
340
|
+
|
|
341
|
+
Returned rather than a bool so an exclusion can say *why*: "matched
|
|
342
|
+
exclude='AQ07' at station" is actionable where "excluded" is not.
|
|
343
|
+
"""
|
|
344
|
+
for pattern in patterns:
|
|
345
|
+
for level, value in _levels(id):
|
|
346
|
+
if fnmatch.fnmatch(value, pattern):
|
|
347
|
+
return level, pattern
|
|
348
|
+
return None
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
@dataclass(frozen=True, slots=True)
|
|
352
|
+
class StagedFit:
|
|
353
|
+
"""The result of :func:`fit_event`: both stages and how they were joined."""
|
|
354
|
+
|
|
355
|
+
#: Every station fitted independently. Kept because the spread across it is
|
|
356
|
+
#: the evidence for how well constrained the event value is, and discarding
|
|
357
|
+
#: it would leave only a number with no error on it.
|
|
358
|
+
stage1: FitSpectra
|
|
359
|
+
#: Every station refitted with the event parameter held fixed. ``None`` when
|
|
360
|
+
#: no station survived selection, in which case there is nothing to fix it
|
|
361
|
+
#: to and the honest result is stage one alone.
|
|
362
|
+
stage2: FitSpectra | None
|
|
363
|
+
#: The aggregated value and what produced it.
|
|
364
|
+
parameter: str
|
|
365
|
+
value: float
|
|
366
|
+
weighting: str
|
|
367
|
+
contributing: tuple[str, ...]
|
|
368
|
+
excluded: Mapping[str, str] = field(default_factory=dict)
|
|
369
|
+
|
|
370
|
+
@property
|
|
371
|
+
def table(self) -> Any:
|
|
372
|
+
"""The stage-2 table where there is one, else stage one's."""
|
|
373
|
+
return self.stage1.table if self.stage2 is None else self.stage2.table
|
|
374
|
+
|
|
375
|
+
def spread(self) -> dict[str, float]:
|
|
376
|
+
"""How much the contributing stations disagreed, before aggregation.
|
|
377
|
+
|
|
378
|
+
The number worth reporting beside the event value. A 2% spread and a
|
|
379
|
+
60% spread give the same weighted mean and mean very different things.
|
|
380
|
+
"""
|
|
381
|
+
if not self.contributing:
|
|
382
|
+
return {}
|
|
383
|
+
values = (
|
|
384
|
+
self.stage1.table.set_index("id")
|
|
385
|
+
.loc[list(self.contributing), self.parameter]
|
|
386
|
+
.to_numpy(dtype=np.float64)
|
|
387
|
+
)
|
|
388
|
+
return {
|
|
389
|
+
"n": float(values.size),
|
|
390
|
+
"min": float(values.min()),
|
|
391
|
+
"max": float(values.max()),
|
|
392
|
+
"median": float(np.median(values)),
|
|
393
|
+
"weighted_mean": self.value,
|
|
394
|
+
"relative_spread": float((values.max() - values.min()) / self.value)
|
|
395
|
+
if self.value
|
|
396
|
+
else float("nan"),
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
def describe(self) -> str:
|
|
400
|
+
"""One paragraph a caller can print or paste into a log."""
|
|
401
|
+
lines = [
|
|
402
|
+
f"{self.parameter} = {self.value:.4g} "
|
|
403
|
+
f"from {len(self.contributing)} channels, weighted by "
|
|
404
|
+
f"{self.weighting}",
|
|
405
|
+
]
|
|
406
|
+
spread = self.spread()
|
|
407
|
+
if spread:
|
|
408
|
+
lines.append(
|
|
409
|
+
f" stage-1 range {spread['min']:.4g} to {spread['max']:.4g} "
|
|
410
|
+
f"({100 * spread['relative_spread']:.0f}% of the event value)"
|
|
411
|
+
)
|
|
412
|
+
if self.excluded:
|
|
413
|
+
lines.append(f" {len(self.excluded)} excluded:")
|
|
414
|
+
lines += [f" {id}: {why}" for id, why in sorted(self.excluded.items())]
|
|
415
|
+
return "\n".join(lines)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def fit_event(
|
|
419
|
+
spectra: Any,
|
|
420
|
+
*,
|
|
421
|
+
model: Any = None,
|
|
422
|
+
guess: Mapping[str, Mapping[str, float]] | None = None,
|
|
423
|
+
fit_bins: bool | None = None,
|
|
424
|
+
parameter: str | None = None,
|
|
425
|
+
weighting: str | WeightModel | None = None,
|
|
426
|
+
selection: ChannelSelection | None = None,
|
|
427
|
+
quiet: bool = True,
|
|
428
|
+
**fit_kwargs: Any,
|
|
429
|
+
) -> StagedFit:
|
|
430
|
+
"""Fit an event in two stages, with the ensemble deciding the source term.
|
|
431
|
+
|
|
432
|
+
Every argument has a configured default, so ``fit_event(spectra)`` is the
|
|
433
|
+
published workflow and the rest is there for when the default is wrong.
|
|
434
|
+
|
|
435
|
+
Parameters
|
|
436
|
+
----------
|
|
437
|
+
spectra
|
|
438
|
+
A :class:`~specmod.core.SpectrumSet`, or anything mapping trace ids to
|
|
439
|
+
paired spectra.
|
|
440
|
+
model, guess, fit_bins
|
|
441
|
+
Passed to :class:`~specmod.fitting.FitSpectra` for both stages, so the
|
|
442
|
+
two are fitting the same thing.
|
|
443
|
+
parameter
|
|
444
|
+
Which parameter the ensemble determines and stage two holds fixed.
|
|
445
|
+
``[fitting] event_parameter``, default ``"fc"`` — the corner frequency
|
|
446
|
+
is the term that belongs to the source. ``"ts"`` is the meaningful
|
|
447
|
+
alternative, for a study with an independent handle on ``Q``.
|
|
448
|
+
weighting
|
|
449
|
+
A registered name or a :class:`WeightModel`. ``[fitting]
|
|
450
|
+
event_weighting``, default inverse hypocentral distance.
|
|
451
|
+
selection
|
|
452
|
+
Which channels contribute. Defaults to :meth:`ChannelSelection.from_config`.
|
|
453
|
+
quiet
|
|
454
|
+
Suppress the fitter's per-station chatter, which is two full passes of
|
|
455
|
+
it here. The failures are on :attr:`StagedFit.excluded` either way.
|
|
456
|
+
**fit_kwargs
|
|
457
|
+
Passed to :meth:`~specmod.fitting.FitSpectra.fit_spectra` for both
|
|
458
|
+
stages — ``method=`` most usefully.
|
|
459
|
+
|
|
460
|
+
Notes
|
|
461
|
+
-----
|
|
462
|
+
Stage two is skipped, with ``stage2=None``, when selection leaves nothing.
|
|
463
|
+
Fixing the parameter to a mean of no stations would be inventing the very
|
|
464
|
+
number the second stage exists to constrain, and a caller who asked for two
|
|
465
|
+
stages and got one should be able to see that rather than read a value.
|
|
466
|
+
"""
|
|
467
|
+
fitting = load_config().config.fitting
|
|
468
|
+
parameter = parameter or str(fitting.event_parameter)
|
|
469
|
+
if selection is None:
|
|
470
|
+
selection = ChannelSelection.from_config()
|
|
471
|
+
if weighting is None:
|
|
472
|
+
weighting = str(fitting.event_weighting)
|
|
473
|
+
model_ = get_weight_model(weighting) if isinstance(weighting, str) else weighting
|
|
474
|
+
|
|
475
|
+
def run(constant: tuple[str, float] | None = None) -> FitSpectra:
|
|
476
|
+
with contextlib.redirect_stdout(io.StringIO() if quiet else None):
|
|
477
|
+
fit = FitSpectra(spectra, model=model, guess=guess, fit_bins=fit_bins)
|
|
478
|
+
if constant is not None:
|
|
479
|
+
fit.set_const(*constant)
|
|
480
|
+
fit.fit_spectra(**fit_kwargs)
|
|
481
|
+
return fit
|
|
482
|
+
|
|
483
|
+
stage1 = run()
|
|
484
|
+
contributing, excluded = selection.choose(stage1)
|
|
485
|
+
|
|
486
|
+
if not contributing:
|
|
487
|
+
return StagedFit(
|
|
488
|
+
stage1=stage1,
|
|
489
|
+
stage2=None,
|
|
490
|
+
parameter=parameter,
|
|
491
|
+
value=float("nan"),
|
|
492
|
+
weighting=getattr(model_, "name", str(weighting)),
|
|
493
|
+
contributing=(),
|
|
494
|
+
excluded=excluded,
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
table = stage1.table.set_index("id")
|
|
498
|
+
values = table.loc[contributing, parameter].to_numpy(dtype=np.float64)
|
|
499
|
+
weights = np.asarray(
|
|
500
|
+
model_.weights(stage1.table, spectra, contributing), dtype=np.float64
|
|
501
|
+
)
|
|
502
|
+
if weights.shape != values.shape:
|
|
503
|
+
raise ValueError(
|
|
504
|
+
f"{getattr(model_, 'name', weighting)} returned {weights.size} "
|
|
505
|
+
f"weights for {values.size} channels"
|
|
506
|
+
)
|
|
507
|
+
total = float(weights.sum())
|
|
508
|
+
if total <= 0:
|
|
509
|
+
raise ValueError(
|
|
510
|
+
f"{getattr(model_, 'name', weighting)} gave every contributing "
|
|
511
|
+
"channel zero weight, so there is no event value to compute"
|
|
512
|
+
)
|
|
513
|
+
value = float((values * weights).sum() / total)
|
|
514
|
+
|
|
515
|
+
return StagedFit(
|
|
516
|
+
stage1=stage1,
|
|
517
|
+
stage2=run(constant=(parameter, value)),
|
|
518
|
+
parameter=parameter,
|
|
519
|
+
value=value,
|
|
520
|
+
weighting=getattr(model_, "name", str(weighting)),
|
|
521
|
+
contributing=tuple(contributing),
|
|
522
|
+
excluded=excluded,
|
|
523
|
+
)
|
specmod/tables.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Fit results as a table, on disk.
|
|
2
|
+
|
|
3
|
+
The other half of ``REFACTOR_PLAN`` §4.6. Spectra are arrays asked about one
|
|
4
|
+
event at a time and live in HDF5 (:mod:`specmod.io`); fit results are a
|
|
5
|
+
columnar scan over every event ever fitted — *"give me f_c and Omega for all
|
|
6
|
+
635 events and regress them"* — and live here. The published Magna run produced
|
|
7
|
+
**11,226 rows** of exactly that, and a multi-event catalogue is larger.
|
|
8
|
+
|
|
9
|
+
**Parquet as the primary, CSV as an export.** This is not fashion. CSV loses
|
|
10
|
+
dtypes, so a column of floats comes back as floats only if pandas guesses
|
|
11
|
+
right and as strings if one cell says ``None``; it round-trips every float
|
|
12
|
+
through decimal text; and it has to be read in full to read any of it. Parquet
|
|
13
|
+
is typed, compressed, and queryable with DuckDB or polars *without loading the
|
|
14
|
+
file*. CSV stays because journal supplements want it, and because a human with
|
|
15
|
+
a text editor is a legitimate reader.
|
|
16
|
+
|
|
17
|
+
The format follows from the suffix, so the choice is visible at the call site
|
|
18
|
+
rather than buried in a keyword.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import TYPE_CHECKING, Any
|
|
25
|
+
|
|
26
|
+
import pandas as pd
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
29
|
+
from collections.abc import Mapping
|
|
30
|
+
|
|
31
|
+
__all__ = ["read_table", "write_table"]
|
|
32
|
+
|
|
33
|
+
#: Suffix to format. ``.parquet`` is the default because it is what the
|
|
34
|
+
#: analysis actually wants; the others exist because other tools do.
|
|
35
|
+
_FORMATS = {".parquet": "parquet", ".pq": "parquet", ".csv": "csv"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _resolve(path: str | Path) -> tuple[Path, str]:
|
|
39
|
+
path = Path(path)
|
|
40
|
+
try:
|
|
41
|
+
return path, _FORMATS[path.suffix.lower()]
|
|
42
|
+
except KeyError:
|
|
43
|
+
raise ValueError(
|
|
44
|
+
f"cannot tell what format {path.name!r} should be from its suffix. "
|
|
45
|
+
f"Use one of {', '.join(sorted(_FORMATS))}."
|
|
46
|
+
) from None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _require_pyarrow() -> None:
|
|
50
|
+
try:
|
|
51
|
+
import pyarrow # noqa: F401, PLC0415
|
|
52
|
+
except ImportError as exc: # pragma: no cover - depends on the environment
|
|
53
|
+
raise ImportError(
|
|
54
|
+
"writing Parquet needs pyarrow. Install it with "
|
|
55
|
+
"`pip install specmod[io]`, or use a .csv path."
|
|
56
|
+
) from exc
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def write_table(
|
|
60
|
+
path: str | Path,
|
|
61
|
+
table: pd.DataFrame,
|
|
62
|
+
*,
|
|
63
|
+
meta: Mapping[str, Any] | None = None,
|
|
64
|
+
) -> Path:
|
|
65
|
+
"""Write a fit table, choosing the format from ``path``'s suffix.
|
|
66
|
+
|
|
67
|
+
``meta`` is stored in the file's own metadata for Parquet — where it
|
|
68
|
+
survives as key-value pairs a reader can get at without parsing the data —
|
|
69
|
+
and dropped for CSV, which has nowhere to put it. That asymmetry is stated
|
|
70
|
+
rather than hidden: a CSV export is lossy about provenance, and pretending
|
|
71
|
+
otherwise is how a run stops being reproducible.
|
|
72
|
+
|
|
73
|
+
The parent directory is created if absent.
|
|
74
|
+
"""
|
|
75
|
+
path, format = _resolve(path)
|
|
76
|
+
parent = path.parent
|
|
77
|
+
if parent != Path():
|
|
78
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
|
|
80
|
+
if format == "csv":
|
|
81
|
+
table.to_csv(path, index=False)
|
|
82
|
+
return path
|
|
83
|
+
|
|
84
|
+
_require_pyarrow()
|
|
85
|
+
import pyarrow as pa # noqa: PLC0415
|
|
86
|
+
import pyarrow.parquet as pq # noqa: PLC0415
|
|
87
|
+
|
|
88
|
+
arrow = pa.Table.from_pandas(table, preserve_index=False)
|
|
89
|
+
if meta:
|
|
90
|
+
# Parquet key-value metadata is bytes-to-bytes, and the existing schema
|
|
91
|
+
# metadata carries pandas' own type information — dropping it would
|
|
92
|
+
# lose the dtypes this format exists to preserve.
|
|
93
|
+
existing = arrow.schema.metadata or {}
|
|
94
|
+
arrow = arrow.replace_schema_metadata(
|
|
95
|
+
{
|
|
96
|
+
**existing,
|
|
97
|
+
**{str(k).encode(): str(v).encode() for k, v in meta.items()},
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
pq.write_table(arrow, path, compression="zstd")
|
|
101
|
+
return path
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def read_table(path: str | Path) -> pd.DataFrame:
|
|
105
|
+
"""Read a fit table back, choosing the format from ``path``'s suffix."""
|
|
106
|
+
path, format = _resolve(path)
|
|
107
|
+
if format == "csv":
|
|
108
|
+
return pd.read_csv(path)
|
|
109
|
+
_require_pyarrow()
|
|
110
|
+
return pd.read_parquet(path)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Pluggable time-to-frequency estimators.
|
|
2
|
+
|
|
3
|
+
All backends satisfy the contract in :mod:`specmod.transforms.base`, so one
|
|
4
|
+
test suite pins every one of them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .base import SpectralEstimator, TaperCorrection
|
|
10
|
+
from .cwt import CWTEstimator
|
|
11
|
+
from .fft import FFTEstimator, WelchEstimator
|
|
12
|
+
from .multitaper import MultitaperEstimator
|
|
13
|
+
from .prieto import PrietoMultitaperEstimator
|
|
14
|
+
from .quadratic import QuadraticMultitaperEstimator
|
|
15
|
+
|
|
16
|
+
#: Name -> estimator, for resolving `TransformConfig.estimator`.
|
|
17
|
+
ESTIMATORS: dict[str, type[SpectralEstimator]] = {
|
|
18
|
+
"fft": FFTEstimator,
|
|
19
|
+
"welch": WelchEstimator,
|
|
20
|
+
"multitaper": MultitaperEstimator,
|
|
21
|
+
"prieto": PrietoMultitaperEstimator,
|
|
22
|
+
"cwt": CWTEstimator,
|
|
23
|
+
"quadratic": QuadraticMultitaperEstimator,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_estimator(name: str, **kwargs: object) -> SpectralEstimator:
|
|
28
|
+
"""Construct an estimator by name."""
|
|
29
|
+
try:
|
|
30
|
+
cls = ESTIMATORS[name]
|
|
31
|
+
except KeyError:
|
|
32
|
+
raise ValueError(
|
|
33
|
+
f"Unknown estimator {name!r}. Available: {sorted(ESTIMATORS)}."
|
|
34
|
+
) from None
|
|
35
|
+
estimator: SpectralEstimator = cls(**kwargs)
|
|
36
|
+
return estimator
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"ESTIMATORS",
|
|
41
|
+
"CWTEstimator",
|
|
42
|
+
"FFTEstimator",
|
|
43
|
+
"MultitaperEstimator",
|
|
44
|
+
"PrietoMultitaperEstimator",
|
|
45
|
+
"QuadraticMultitaperEstimator",
|
|
46
|
+
"SpectralEstimator",
|
|
47
|
+
"TaperCorrection",
|
|
48
|
+
"WelchEstimator",
|
|
49
|
+
"get_estimator",
|
|
50
|
+
]
|