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,330 @@
|
|
|
1
|
+
"""Fitting a source model to a single spectrum.
|
|
2
|
+
|
|
3
|
+
The lmfit surface used here is declared in ``stubs/lmfit``; see
|
|
4
|
+
``stubs/README.md``. lmfit ships no annotations, so without those a
|
|
5
|
+
``ModelResult`` is `Any` and nothing checks that ``result.redchi`` exists or
|
|
6
|
+
that ``Parameter.stderr`` can be `None` — which it is under every minimiser
|
|
7
|
+
that estimates no covariance matrix, including the shipped default.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from copy import deepcopy
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
import lmfit as lm
|
|
16
|
+
import matplotlib.pyplot as plt
|
|
17
|
+
import numpy as np
|
|
18
|
+
from matplotlib.ticker import NullFormatter, StrMethodFormatter
|
|
19
|
+
|
|
20
|
+
from .. import config as cfg
|
|
21
|
+
from .. import sources
|
|
22
|
+
from .base import REQUIRED_SPECTRUM_ATTRIBUTES, Spectrumish
|
|
23
|
+
from .guess import selected_band
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
26
|
+
from collections.abc import Mapping
|
|
27
|
+
|
|
28
|
+
from matplotlib.axes import Axes
|
|
29
|
+
from numpy.typing import NDArray
|
|
30
|
+
|
|
31
|
+
__all__ = ["FitSpectrum"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class FitSpectrum:
|
|
35
|
+
"""Fit a source model to one spectrum with lmfit.
|
|
36
|
+
|
|
37
|
+
Takes anything carrying :data:`REQUIRED_SPECTRUM_ATTRIBUTES` — in practice
|
|
38
|
+
a :class:`~specmod.core.collection.FittableView` from
|
|
39
|
+
:func:`fittable_signal`.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
#: Declarations, not defaults. These were class attributes carrying `None`
|
|
43
|
+
#: and `{}`, which meant two things at once: every read had to cope with a
|
|
44
|
+
#: `None` that `__init__` had in fact replaced, and `meta = {}` was one
|
|
45
|
+
#: dictionary shared by every instance ever constructed. `__init__` assigns
|
|
46
|
+
#: all of them, so the type is what it is after construction — and the
|
|
47
|
+
#: shared-mutable-default hazard is gone rather than merely unreached.
|
|
48
|
+
sig: Spectrumish
|
|
49
|
+
mod: lm.Model
|
|
50
|
+
params: lm.Parameters
|
|
51
|
+
#: `None` until :meth:`fit_mod` runs. This one really is optional, and
|
|
52
|
+
#: callers test it — see :func:`specmod.plotting.plot_pair`.
|
|
53
|
+
result: lm.ModelResult | None
|
|
54
|
+
mod_freq: NDArray[np.float64]
|
|
55
|
+
mod_amp: NDArray[np.float64]
|
|
56
|
+
pass_fitting: bool
|
|
57
|
+
fit_bins: bool
|
|
58
|
+
meta: dict[str, Any]
|
|
59
|
+
#: The :class:`specmod.sources.SpectralModel` behind the fit, when there is
|
|
60
|
+
#: one. ``None`` if a bare callable was supplied.
|
|
61
|
+
spectral_model: sources.SpectralModel | None
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
signal: Spectrumish,
|
|
66
|
+
model: Any = None,
|
|
67
|
+
fit_bins: bool = False,
|
|
68
|
+
**params: float,
|
|
69
|
+
) -> None:
|
|
70
|
+
self.result = None
|
|
71
|
+
self.pass_fitting = True
|
|
72
|
+
self.meta = {}
|
|
73
|
+
self.spectral_model = None
|
|
74
|
+
self.mod_freq = np.array([])
|
|
75
|
+
self.mod_amp = np.array([])
|
|
76
|
+
self.fit_bins = fit_bins
|
|
77
|
+
self.set_signal(signal)
|
|
78
|
+
self.set_model(model, **params)
|
|
79
|
+
|
|
80
|
+
def fit_mod(self, **kwargs: Any) -> None:
|
|
81
|
+
"""Fit, judge the result, then record it — in that order.
|
|
82
|
+
|
|
83
|
+
The judgement used to be made *after* the recording, so the
|
|
84
|
+
``pass_fitting`` column of every flat file held the value from before
|
|
85
|
+
the fit ran — ``True``, the class default, on a fresh `FitSpectrum`.
|
|
86
|
+
The attribute and the table disagreed, and the table is what gets
|
|
87
|
+
written out and regressed on.
|
|
88
|
+
"""
|
|
89
|
+
self.result = self.mod.fit(self.mod_amp, self.params, f=self.mod_freq, **kwargs)
|
|
90
|
+
self.__determine_pass_or_fail()
|
|
91
|
+
self.__set_results_to_meta()
|
|
92
|
+
|
|
93
|
+
def set_signal(self, signal: Spectrumish) -> None:
|
|
94
|
+
if self.__check_input(signal):
|
|
95
|
+
self.sig = signal
|
|
96
|
+
self.__set_meta(signal.meta)
|
|
97
|
+
# if setting a new signal - assess and adjust the freq bounds
|
|
98
|
+
self.__set_mod_amp_freq()
|
|
99
|
+
|
|
100
|
+
def set_model(self, model: Any = None, **params: float) -> None:
|
|
101
|
+
"""Set the model to fit.
|
|
102
|
+
|
|
103
|
+
Accepts a :class:`specmod.sources.SpectralModel`, a bare callable, or
|
|
104
|
+
``None`` — in which case the model is whatever ``[model]`` in the
|
|
105
|
+
configuration asks for. That default is the point: before it existed,
|
|
106
|
+
``config.model.source`` was read by nothing and the caller had to pass
|
|
107
|
+
the right function by hand, so a study file saying
|
|
108
|
+
``source = "boatwright"`` silently got Brune.
|
|
109
|
+
|
|
110
|
+
A bare callable still works, because fitting an ad-hoc shape is a
|
|
111
|
+
legitimate thing to want. It simply carries no provenance:
|
|
112
|
+
:attr:`spectral_model` is ``None`` and nothing can report what was fitted.
|
|
113
|
+
"""
|
|
114
|
+
if model is None:
|
|
115
|
+
model = sources.from_config()
|
|
116
|
+
|
|
117
|
+
if isinstance(model, sources.SpectralModel):
|
|
118
|
+
self.spectral_model = model
|
|
119
|
+
model = model.as_callable()
|
|
120
|
+
else:
|
|
121
|
+
self.spectral_model = None
|
|
122
|
+
|
|
123
|
+
self.mod = lm.Model(model)
|
|
124
|
+
# whenever a model is set the inital params must be set also
|
|
125
|
+
self.__init_params(**params)
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def fitted(self) -> lm.ModelResult:
|
|
129
|
+
"""The fit result, or a message saying it has not been fitted.
|
|
130
|
+
|
|
131
|
+
Every private reader below went straight through ``self.result``,
|
|
132
|
+
which is ``None`` until :meth:`fit_mod` runs — so calling
|
|
133
|
+
:meth:`quick_vis` on an unfitted spectrum raised ``AttributeError:
|
|
134
|
+
'NoneType' object has no attribute 'best_fit'``, from a line that
|
|
135
|
+
names neither the station nor the missing step.
|
|
136
|
+
"""
|
|
137
|
+
if self.result is None:
|
|
138
|
+
raise RuntimeError(
|
|
139
|
+
f"{getattr(self.sig, 'id', 'this spectrum')} has not been "
|
|
140
|
+
"fitted yet; call fit_mod() first"
|
|
141
|
+
)
|
|
142
|
+
return self.result
|
|
143
|
+
|
|
144
|
+
def describe_model(self) -> str | None:
|
|
145
|
+
"""What is being fitted, or ``None`` for a bare callable."""
|
|
146
|
+
return None if self.spectral_model is None else self.spectral_model.describe()
|
|
147
|
+
|
|
148
|
+
def set_const(self, pname: str, value: float) -> None:
|
|
149
|
+
self.params[pname].value = value
|
|
150
|
+
self.params[pname].vary = False
|
|
151
|
+
|
|
152
|
+
def set_bounds(
|
|
153
|
+
self, pname: str, min: float | None = None, max: float | None = None
|
|
154
|
+
) -> None:
|
|
155
|
+
if min is not None:
|
|
156
|
+
self.params[pname].min = min
|
|
157
|
+
if max is not None:
|
|
158
|
+
self.params[pname].max = max
|
|
159
|
+
|
|
160
|
+
def __set_meta(self, meta: Mapping[str, Any]) -> None:
|
|
161
|
+
self.meta = deepcopy(dict(meta))
|
|
162
|
+
|
|
163
|
+
def __init_params(self, **params: Any) -> None:
|
|
164
|
+
"""Seed the parameters, and floor ``t*`` where the configuration says.
|
|
165
|
+
|
|
166
|
+
``fitting.t_star_min`` existed and was read by nothing. The tutorial
|
|
167
|
+
did ``fits.set_bounds("ts", min=0.0001)`` by hand and the config value
|
|
168
|
+
is 1e-4 — the same number — so the setting was a written-down record of
|
|
169
|
+
something every caller had to remember. Applied here, forgetting it is
|
|
170
|
+
no longer possible.
|
|
171
|
+
|
|
172
|
+
The same applies to ``fc``, and the legacy code knew it — the line
|
|
173
|
+
``# self.set_bounds('fc', min=0)`` sat commented out here. It is not a
|
|
174
|
+
poor fit but an unphysical one: a negative ``t*`` says the wave gained
|
|
175
|
+
energy travelling, and a corner frequency below zero says nothing at
|
|
176
|
+
all. lmfit returns either if the misfit surface leans that way, and
|
|
177
|
+
with the shipped multitaper default it returned ``fc = -4.45 Hz`` on
|
|
178
|
+
one PNR station while ``pass_fitting`` reported success — because a
|
|
179
|
+
parameter with no bound cannot be *at* its bound.
|
|
180
|
+
"""
|
|
181
|
+
# `**params: Any` rather than `float`, because lmfit's `make_params`
|
|
182
|
+
# takes a leading `verbose` argument: a model with a parameter of that
|
|
183
|
+
# name would have its seed swallowed as a flag. Not a hazard for any
|
|
184
|
+
# source model here, and not one this package can fix.
|
|
185
|
+
self.params = self.mod.make_params(**params)
|
|
186
|
+
fitting = cfg.load_config().config.fitting
|
|
187
|
+
for name, floor in (
|
|
188
|
+
("ts", fitting.t_star_min),
|
|
189
|
+
("fc", fitting.corner_frequency_min),
|
|
190
|
+
):
|
|
191
|
+
if name in self.params and floor is not None:
|
|
192
|
+
self.set_bounds(name, min=floor)
|
|
193
|
+
|
|
194
|
+
def reset(self) -> None:
|
|
195
|
+
for par in self.params.values():
|
|
196
|
+
par.vary = True
|
|
197
|
+
par.min = -np.inf
|
|
198
|
+
par.max = np.inf
|
|
199
|
+
|
|
200
|
+
def __check_input(self, signal: Spectrumish) -> bool:
|
|
201
|
+
"""Accept anything carrying what the fit reads, not one named class.
|
|
202
|
+
|
|
203
|
+
This used to be ``isinstance(signal, spectral.Signal)``, which is the
|
|
204
|
+
coupling that kept the container holding the legacy pair — nothing
|
|
205
|
+
could be handed to the fitter unless it *was* that class. What the fit
|
|
206
|
+
actually needs is the six attributes below, so that is what is checked.
|
|
207
|
+
|
|
208
|
+
Named explicitly rather than left to fail at first use: a missing
|
|
209
|
+
``bamp`` should say so here, not surface as an AttributeError from
|
|
210
|
+
inside a band selection three calls later.
|
|
211
|
+
"""
|
|
212
|
+
missing = [
|
|
213
|
+
name for name in REQUIRED_SPECTRUM_ATTRIBUTES if not hasattr(signal, name)
|
|
214
|
+
]
|
|
215
|
+
if missing:
|
|
216
|
+
raise ValueError(
|
|
217
|
+
f"{type(signal).__name__} cannot be fitted: missing "
|
|
218
|
+
f"{', '.join(missing)}. A fittable spectrum needs "
|
|
219
|
+
f"{', '.join(REQUIRED_SPECTRUM_ATTRIBUTES)}."
|
|
220
|
+
)
|
|
221
|
+
return True
|
|
222
|
+
|
|
223
|
+
def __set_mod_amp_freq(self) -> None:
|
|
224
|
+
"""
|
|
225
|
+
Only fit between signal limits if they are specified.
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
if self.fit_bins:
|
|
229
|
+
freq = self.sig.bfreq
|
|
230
|
+
amp = self.sig.bamp
|
|
231
|
+
else:
|
|
232
|
+
freq = self.sig.freq
|
|
233
|
+
amp = self.sig.amp
|
|
234
|
+
|
|
235
|
+
band = selected_band(self.sig)
|
|
236
|
+
if band is not None:
|
|
237
|
+
inds = np.where((freq >= band[0]) & (freq <= band[1]))
|
|
238
|
+
self.mod_freq = freq[inds]
|
|
239
|
+
self.mod_amp = amp[inds]
|
|
240
|
+
else:
|
|
241
|
+
self.mod_freq = freq
|
|
242
|
+
self.mod_amp = amp
|
|
243
|
+
|
|
244
|
+
self.mod_amp = np.log10(self.mod_amp)
|
|
245
|
+
|
|
246
|
+
def __param_string(self) -> str:
|
|
247
|
+
"""``name: value+/-2sigma`` per parameter, or ``name: value`` alone.
|
|
248
|
+
|
|
249
|
+
The old version computed ``2 * k.stderr`` unconditionally inside a
|
|
250
|
+
bare ``except Exception``. ``stderr`` is ``None`` whenever the
|
|
251
|
+
minimiser estimated no covariance matrix — which Powell, the shipped
|
|
252
|
+
default, never does — so this raised ``TypeError`` on every fit made
|
|
253
|
+
with the default configuration, swallowed it, and titled the plot
|
|
254
|
+
``NaN``. A missing uncertainty is a property of the method, not a
|
|
255
|
+
failed fit, so the value is still worth printing.
|
|
256
|
+
"""
|
|
257
|
+
parts = []
|
|
258
|
+
for k in self.fitted.params.values():
|
|
259
|
+
if k.stderr is None:
|
|
260
|
+
parts.append(f"{k.name}: {k.value:.3f}")
|
|
261
|
+
else:
|
|
262
|
+
parts.append(f"{k.name}: {k.value:.3f}+/-{2 * k.stderr:.3f}")
|
|
263
|
+
return ", ".join(parts)
|
|
264
|
+
|
|
265
|
+
def quick_vis(self, ax: Axes | None = None) -> Axes:
|
|
266
|
+
if ax is None:
|
|
267
|
+
_fig, ax = plt.subplots(1, 1)
|
|
268
|
+
|
|
269
|
+
ax.loglog(self.mod_freq, 10**self.mod_amp, color="grey", label=self.sig.id)
|
|
270
|
+
ax.loglog(self.mod_freq, 10**self.fitted.best_fit, "k--", label="model")
|
|
271
|
+
ax.xaxis.set_major_formatter(StrMethodFormatter("{x:.2f}"))
|
|
272
|
+
ax.xaxis.set_minor_formatter(NullFormatter())
|
|
273
|
+
ax.set_title(self.__param_string())
|
|
274
|
+
ax.set_xlabel("freq [Hz]")
|
|
275
|
+
ax.set_ylabel("spectral amp")
|
|
276
|
+
ax.legend()
|
|
277
|
+
return ax
|
|
278
|
+
|
|
279
|
+
def __get_pars(self) -> dict[str, Any]:
|
|
280
|
+
p: dict[str, Any] = {}
|
|
281
|
+
for k in self.fitted.params.values():
|
|
282
|
+
p.update({k.name: k.value})
|
|
283
|
+
p.update({k.name + "-stderr": k.stderr})
|
|
284
|
+
return p
|
|
285
|
+
|
|
286
|
+
def __get_fit_stats(self) -> dict[str, float]:
|
|
287
|
+
res = self.fitted
|
|
288
|
+
s: dict[str, float] = {}
|
|
289
|
+
s.update({"aic": res.aic})
|
|
290
|
+
s.update({"bic": res.bic})
|
|
291
|
+
s.update({"chisqr": res.chisqr})
|
|
292
|
+
s.update({"redchi": res.redchi})
|
|
293
|
+
return s
|
|
294
|
+
|
|
295
|
+
def __get_test_results(self) -> dict[str, bool]:
|
|
296
|
+
t: dict[str, bool] = {}
|
|
297
|
+
t.update({"pass_fitting": self.pass_fitting})
|
|
298
|
+
return t
|
|
299
|
+
|
|
300
|
+
def __set_results_to_meta(self) -> None:
|
|
301
|
+
self.meta.update(self.__get_pars())
|
|
302
|
+
self.meta.update(self.__get_fit_stats())
|
|
303
|
+
self.meta.update(self.__get_test_results())
|
|
304
|
+
|
|
305
|
+
def __determine_pass_or_fail(self) -> None:
|
|
306
|
+
"""A fit fails when a parameter is pinned against one of its bounds.
|
|
307
|
+
|
|
308
|
+
Which is the useful question: a corner frequency resting on its floor
|
|
309
|
+
is the minimiser saying "lower, if you would let me", and the value it
|
|
310
|
+
reports is the bound rather than a measurement.
|
|
311
|
+
|
|
312
|
+
Reset first. ``pass_fitting`` starts as a class attribute and was only
|
|
313
|
+
ever set *False*, so a `FitSpectrum` that failed once could never pass
|
|
314
|
+
again however many times it was refitted.
|
|
315
|
+
|
|
316
|
+
**Where there is no uncertainty, the value itself is compared.** The
|
|
317
|
+
old version treated a missing ``stderr`` as a failure, which would mark
|
|
318
|
+
every fit failed under the shipped configuration: Powell does not
|
|
319
|
+
estimate a covariance matrix, so lmfit has no uncertainties to report.
|
|
320
|
+
That is a property of the minimiser, not a fault in the fit. Asking
|
|
321
|
+
whether the value sits on the bound is the same question with the
|
|
322
|
+
error bar removed.
|
|
323
|
+
"""
|
|
324
|
+
self.pass_fitting = True
|
|
325
|
+
for _par, vals in self.fitted.params.items():
|
|
326
|
+
if not vals.vary:
|
|
327
|
+
continue
|
|
328
|
+
spread = vals.stderr if vals.stderr is not None else 0.0
|
|
329
|
+
if (vals.value - spread <= vals.min) or (vals.value + spread >= vals.max):
|
|
330
|
+
self.pass_fitting = False
|
specmod/io.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Writing an event's spectra to disk and reading them back.
|
|
2
|
+
|
|
3
|
+
Replaces ``spectral.Spectra.write_spectra``/``read_spectra``, which pickled the
|
|
4
|
+
container. Pickle stores the import path of every class it holds, so a stored
|
|
5
|
+
result stops loading the moment a class is renamed or moved — which is exactly
|
|
6
|
+
what happened to the shipped ``Tutorial/Spectra/*.spec``: unreadable since the
|
|
7
|
+
``Spectral.py`` → ``spectral.py`` rename, years before the classes were
|
|
8
|
+
deleted. **A format that breaks when you refactor is a cache, not a format.**
|
|
9
|
+
|
|
10
|
+
HDF5, per ``REFACTOR_PLAN`` §4.6. The four rules there are lessons from how
|
|
11
|
+
pickle failed rather than general good practice, and each is visible in the
|
|
12
|
+
layout below:
|
|
13
|
+
|
|
14
|
+
*Never store class identity.*
|
|
15
|
+
Plain arrays and a documented layout. Nothing here names a Python type, so
|
|
16
|
+
nothing here can be broken by renaming one.
|
|
17
|
+
|
|
18
|
+
*Every file carries a format version.*
|
|
19
|
+
:data:`FORMAT_VERSION`, on the root group. A reader checks it and fails
|
|
20
|
+
naming both versions. The absence of this is the whole problem with what
|
|
21
|
+
came before.
|
|
22
|
+
|
|
23
|
+
*Self-describing units.*
|
|
24
|
+
``motion``, ``kind``, ``duration`` and ``sampling_rate`` are stored
|
|
25
|
+
attributes, not conventions the reader has to know. This is the typing of
|
|
26
|
+
:class:`~specmod.core.Spectrum` expressed on disk, and it is what stops a
|
|
27
|
+
file being silently misread as displacement.
|
|
28
|
+
|
|
29
|
+
*One file per event, one group per channel.*
|
|
30
|
+
Which matches how the science is done and how it is re-examined, and
|
|
31
|
+
sidesteps HDF5's single-writer limitation if the workflow is ever
|
|
32
|
+
parallelised across events.
|
|
33
|
+
|
|
34
|
+
**Nothing here can pickle.** ``h5py`` has no mechanism to; trace metadata is
|
|
35
|
+
stored as a JSON string attribute rather than as an object array, which is how
|
|
36
|
+
``numpy`` would otherwise smuggle pickling back in.
|
|
37
|
+
|
|
38
|
+
The tables — fit results — go to Parquet instead, in :mod:`specmod.tables`.
|
|
39
|
+
Arrays and tables are used in genuinely different ways (random access into one
|
|
40
|
+
event, versus a columnar scan over every event ever fitted), and one format for
|
|
41
|
+
both would be worse at each.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import json
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
from typing import TYPE_CHECKING, Any
|
|
49
|
+
|
|
50
|
+
import numpy as np
|
|
51
|
+
|
|
52
|
+
from .core import (
|
|
53
|
+
AmplitudeKind,
|
|
54
|
+
BinnedSpectrum,
|
|
55
|
+
Motion,
|
|
56
|
+
Spectrum,
|
|
57
|
+
SpectrumPair,
|
|
58
|
+
SpectrumSet,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
62
|
+
from collections.abc import Mapping
|
|
63
|
+
|
|
64
|
+
__all__ = ["FORMAT_VERSION", "load", "save"]
|
|
65
|
+
|
|
66
|
+
#: Bumped when the layout changes in a way an older reader cannot handle.
|
|
67
|
+
FORMAT_VERSION = 1
|
|
68
|
+
|
|
69
|
+
#: Compress datasets at least this large, in bytes; store smaller ones raw.
|
|
70
|
+
#:
|
|
71
|
+
#: Measured, not guessed. Compression in HDF5 requires *chunked* storage, which
|
|
72
|
+
#: costs a chunk index per dataset. On this event the arrays are tiny — median
|
|
73
|
+
#: 91 float64, 728 bytes — and gzip took the file from 370 KB to **867 KB**
|
|
74
|
+
#: against a raw payload of 250 KB: the indices cost more than the data they
|
|
75
|
+
#: describe. Above the threshold the ratio flips and compression pays, which is
|
|
76
|
+
#: the case the plan's "chunked, compressed" argument was really about (a
|
|
77
|
+
#: scalogram is ~1 MB per trace).
|
|
78
|
+
_COMPRESS_ABOVE_BYTES = 16 * 1024
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _require_h5py() -> Any:
|
|
82
|
+
"""Import ``h5py``, or say what to install.
|
|
83
|
+
|
|
84
|
+
Deferred rather than imported at module scope so that the message names the
|
|
85
|
+
extra. A bare ``ModuleNotFoundError: h5py`` in the middle of a save tells a
|
|
86
|
+
user nothing about how to fix it.
|
|
87
|
+
"""
|
|
88
|
+
try:
|
|
89
|
+
import h5py # noqa: PLC0415
|
|
90
|
+
except ImportError as exc: # pragma: no cover - depends on the environment
|
|
91
|
+
raise ImportError(
|
|
92
|
+
"reading and writing spectra needs h5py. Install it with "
|
|
93
|
+
"`pip install specmod[io]`."
|
|
94
|
+
) from exc
|
|
95
|
+
return h5py
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _dataset(group: Any, name: str, data: Any) -> None:
|
|
99
|
+
"""One array, compressed only where compression pays. See
|
|
100
|
+
:data:`_COMPRESS_ABOVE_BYTES`."""
|
|
101
|
+
data = np.asarray(data)
|
|
102
|
+
if data.nbytes >= _COMPRESS_ABOVE_BYTES:
|
|
103
|
+
group.create_dataset(name, data=data, compression="gzip")
|
|
104
|
+
else:
|
|
105
|
+
group.create_dataset(name, data=data)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _write_spectrum(group: Any, name: str, spectrum: Spectrum) -> None:
|
|
109
|
+
"""One spectrum: two datasets, and the attributes that make them readable.
|
|
110
|
+
|
|
111
|
+
The units go on as attributes rather than being implied, because a
|
|
112
|
+
frequency and an amplitude array alone do not say whether they are a
|
|
113
|
+
velocity magnitude or a displacement power spectral density — and the two
|
|
114
|
+
differ by factors that would pass unnoticed.
|
|
115
|
+
"""
|
|
116
|
+
# Only the amplitude. The frequency axis is written once per group, by
|
|
117
|
+
# `save`: `compare` interpolates the noise onto the signal's axis and bins
|
|
118
|
+
# both against the same edges, so the two spectra *share* an axis by
|
|
119
|
+
# construction. Storing it twice would be waste, and worse, would make a
|
|
120
|
+
# file expressible in which they disagree — a state the containers cannot
|
|
121
|
+
# represent and no reader could act on.
|
|
122
|
+
_dataset(group, f"{name}_amp", spectrum.amp)
|
|
123
|
+
attrs = group.attrs
|
|
124
|
+
attrs[f"{name}_motion"] = str(spectrum.motion)
|
|
125
|
+
attrs[f"{name}_kind"] = str(spectrum.kind)
|
|
126
|
+
attrs[f"{name}_duration"] = float(spectrum.duration)
|
|
127
|
+
attrs[f"{name}_sampling_rate"] = float(spectrum.sampling_rate)
|
|
128
|
+
attrs[f"{name}_meta"] = json.dumps(dict(spectrum.meta))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _read_spectrum(group: Any, name: str) -> Spectrum:
|
|
132
|
+
attrs = group.attrs
|
|
133
|
+
return Spectrum(
|
|
134
|
+
freq=np.asarray(group["freq"]),
|
|
135
|
+
amp=np.asarray(group[f"{name}_amp"]),
|
|
136
|
+
# Through the enums rather than as bare strings: constructing them is
|
|
137
|
+
# what turns a corrupted or hand-edited attribute into an error naming
|
|
138
|
+
# the value, instead of a spectrum that claims a unit nothing else
|
|
139
|
+
# recognises.
|
|
140
|
+
motion=Motion(str(attrs[f"{name}_motion"])),
|
|
141
|
+
kind=AmplitudeKind(str(attrs[f"{name}_kind"])),
|
|
142
|
+
duration=float(attrs[f"{name}_duration"]),
|
|
143
|
+
sampling_rate=float(attrs[f"{name}_sampling_rate"]),
|
|
144
|
+
meta=json.loads(str(attrs[f"{name}_meta"])),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def save(path: str | Path, spectra: SpectrumSet) -> Path:
|
|
149
|
+
"""Write an event's spectra to ``path``.
|
|
150
|
+
|
|
151
|
+
One group per trace id, keyed by the id itself — HDF5 group names may
|
|
152
|
+
contain dots, so ``LV.L001..HHE`` needs no mangling and the file browses
|
|
153
|
+
with the same names the pipeline uses.
|
|
154
|
+
|
|
155
|
+
The parent directory is created if absent. The legacy version raised
|
|
156
|
+
``FileNotFoundError`` from inside ``open``, naming the file rather than the
|
|
157
|
+
directory that did not exist.
|
|
158
|
+
"""
|
|
159
|
+
h5py = _require_h5py()
|
|
160
|
+
|
|
161
|
+
path = Path(path)
|
|
162
|
+
# Appended, never substituted. An event id is an ISO timestamp —
|
|
163
|
+
# `2019-08-26T07:49:24.200000Z` — and `Path.with_suffix` reads `.200000Z`
|
|
164
|
+
# as an extension and replaces it, silently truncating the name to
|
|
165
|
+
# `2019-08-26T07:49:24.h5`. Two events in the same second would then
|
|
166
|
+
# overwrite each other.
|
|
167
|
+
if path.suffix != ".h5":
|
|
168
|
+
path = path.with_name(path.name + ".h5")
|
|
169
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
170
|
+
|
|
171
|
+
with h5py.File(path, "w") as handle:
|
|
172
|
+
handle.attrs["specmod_format_version"] = FORMAT_VERSION
|
|
173
|
+
handle.attrs["event"] = spectra.event
|
|
174
|
+
handle.attrs["meta"] = json.dumps(dict(spectra.meta))
|
|
175
|
+
|
|
176
|
+
for id in spectra.ids():
|
|
177
|
+
pair = spectra[id]
|
|
178
|
+
group = handle.create_group(id)
|
|
179
|
+
# The two shared axes, written once each.
|
|
180
|
+
_dataset(group, "freq", pair.signal.freq)
|
|
181
|
+
_dataset(group, "binned_freq", pair.binned_signal.freq)
|
|
182
|
+
|
|
183
|
+
_write_spectrum(group, "signal", pair.signal)
|
|
184
|
+
_write_spectrum(group, "noise", pair.noise)
|
|
185
|
+
_dataset(group, "binned_signal_amp", pair.binned_signal.amp)
|
|
186
|
+
_dataset(group, "binned_noise_amp", pair.binned_noise.amp)
|
|
187
|
+
_dataset(group, "snr", pair.snr)
|
|
188
|
+
group.attrs["resolution_floor"] = float(pair.resolution_floor)
|
|
189
|
+
group.attrs["meta"] = json.dumps(dict(pair.meta))
|
|
190
|
+
# `band` is absent rather than sentinel-valued when no band
|
|
191
|
+
# survived. A NaN pair would read as a band, and "no usable
|
|
192
|
+
# bandwidth" is a different claim from "a band at nowhere".
|
|
193
|
+
if pair.band is not None:
|
|
194
|
+
group.attrs["band"] = np.asarray(pair.band, dtype=np.float64)
|
|
195
|
+
|
|
196
|
+
return path
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def load(path: str | Path) -> SpectrumSet:
|
|
200
|
+
"""Read back what :func:`save` wrote.
|
|
201
|
+
|
|
202
|
+
The arrays come back read-only, as they went in: a spectrum loaded from
|
|
203
|
+
disk gives the same immutability guarantee as one just computed, which is
|
|
204
|
+
what makes reload-and-refit safe to do in a loop.
|
|
205
|
+
"""
|
|
206
|
+
h5py = _require_h5py()
|
|
207
|
+
path = Path(path)
|
|
208
|
+
|
|
209
|
+
with h5py.File(path, "r") as handle:
|
|
210
|
+
version = handle.attrs.get("specmod_format_version")
|
|
211
|
+
if version != FORMAT_VERSION:
|
|
212
|
+
raise ValueError(
|
|
213
|
+
f"{path} declares format version {version}, and this is "
|
|
214
|
+
f"version {FORMAT_VERSION}. Re-run the pipeline, or read it "
|
|
215
|
+
f"with the version of specmod that wrote it."
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
pairs: dict[str, SpectrumPair] = {}
|
|
219
|
+
for id, group in handle.items():
|
|
220
|
+
band = group.attrs.get("band")
|
|
221
|
+
pairs[id] = SpectrumPair(
|
|
222
|
+
signal=_read_spectrum(group, "signal"),
|
|
223
|
+
noise=_read_spectrum(group, "noise"),
|
|
224
|
+
binned_signal=BinnedSpectrum(
|
|
225
|
+
freq=np.asarray(group["binned_freq"]),
|
|
226
|
+
amp=np.asarray(group["binned_signal_amp"]),
|
|
227
|
+
),
|
|
228
|
+
binned_noise=BinnedSpectrum(
|
|
229
|
+
freq=np.asarray(group["binned_freq"]),
|
|
230
|
+
amp=np.asarray(group["binned_noise_amp"]),
|
|
231
|
+
),
|
|
232
|
+
snr=np.asarray(group["snr"]),
|
|
233
|
+
resolution_floor=float(group.attrs["resolution_floor"]),
|
|
234
|
+
band=None if band is None else (float(band[0]), float(band[1])),
|
|
235
|
+
meta=json.loads(str(group.attrs["meta"])),
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
event = str(handle.attrs.get("event", ""))
|
|
239
|
+
meta: Mapping[str, Any] = json.loads(str(handle.attrs.get("meta", "{}")))
|
|
240
|
+
|
|
241
|
+
return SpectrumSet(pairs=pairs, event=event, meta=dict(meta))
|