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