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/api.py
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
"""Stable public surface for downstream packages.
|
|
2
|
+
|
|
3
|
+
Anything not exported here is internal and may change without notice. Anything
|
|
4
|
+
exported here follows the deprecation policy in ``CONTRIBUTING.md``: one minor
|
|
5
|
+
version of ``DeprecationWarning`` before a removal or a signature change, even
|
|
6
|
+
while SpecMod is ``0.x``.
|
|
7
|
+
|
|
8
|
+
The point of the module is containment. SpecMod's internals are still being
|
|
9
|
+
refactored and its own documentation warns of breaking changes at every ``0.x``
|
|
10
|
+
release; downstream packages import *this* and nothing else, so an internal
|
|
11
|
+
rename costs a line here instead of a release there.
|
|
12
|
+
|
|
13
|
+
Five properties hold for everything below, and they are what make the surface
|
|
14
|
+
usable from a service that owns its own IO and has to be able to replay a job:
|
|
15
|
+
|
|
16
|
+
1. **Path-free.** Every function takes in-memory data — arrays, or ObsPy
|
|
17
|
+
objects. None of them opens a file. Convenience wrappers that take paths
|
|
18
|
+
live elsewhere in the package.
|
|
19
|
+
2. **Deterministic.** The same inputs and the same explicit arguments produce
|
|
20
|
+
the same outputs. Nothing here reads the working directory or the
|
|
21
|
+
environment, and nothing draws random numbers. See the caveat on
|
|
22
|
+
:func:`fit_spectrum`.
|
|
23
|
+
3. **Non-mutating.** Inputs are left as they were found; results are new
|
|
24
|
+
objects.
|
|
25
|
+
4. **Quiet.** Nothing prints. Diagnostics go through :mod:`logging` and
|
|
26
|
+
:mod:`warnings`.
|
|
27
|
+
5. **Typed errors.** Failures are :class:`~specmod.exceptions.SpecModError`
|
|
28
|
+
subclasses — see :mod:`specmod.exceptions` for which of the three, and why
|
|
29
|
+
the distinction is the useful part.
|
|
30
|
+
|
|
31
|
+
Examples
|
|
32
|
+
--------
|
|
33
|
+
>>> import numpy as np
|
|
34
|
+
>>> from specmod import api
|
|
35
|
+
>>> rng = np.random.default_rng(0)
|
|
36
|
+
>>> signal = api.estimate_spectrum(rng.normal(size=2048), 0.01,
|
|
37
|
+
... estimator="multitaper")
|
|
38
|
+
>>> noise = api.estimate_spectrum(rng.normal(size=1024), 0.01,
|
|
39
|
+
... estimator="multitaper")
|
|
40
|
+
>>> pair = api.compare_spectra(signal, noise)
|
|
41
|
+
>>> pair.snr.shape == pair.binned_signal.freq.shape
|
|
42
|
+
True
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import importlib.util
|
|
48
|
+
from collections.abc import Iterator, Mapping
|
|
49
|
+
from contextlib import contextmanager
|
|
50
|
+
from dataclasses import dataclass
|
|
51
|
+
from typing import Any
|
|
52
|
+
|
|
53
|
+
import numpy as np
|
|
54
|
+
from numpy.typing import ArrayLike, NDArray
|
|
55
|
+
|
|
56
|
+
from . import __version__
|
|
57
|
+
from .config import Config, ResolvedConfig, config_hash, load_config
|
|
58
|
+
from .config.serialize import to_toml as _to_toml
|
|
59
|
+
from .core.collection import SpectrumPair
|
|
60
|
+
from .core.spectrum import Spectrum
|
|
61
|
+
from .core.units import AmplitudeKind, Motion
|
|
62
|
+
from .exceptions import (
|
|
63
|
+
InternalError,
|
|
64
|
+
InvalidInputError,
|
|
65
|
+
MissingBackendError,
|
|
66
|
+
SpecModError,
|
|
67
|
+
)
|
|
68
|
+
from .fitting import FitSpectrum, fittable_signal, initial_guess
|
|
69
|
+
from .transforms import ESTIMATORS, get_estimator
|
|
70
|
+
from .transforms.base import make_window, window_correction
|
|
71
|
+
|
|
72
|
+
__all__ = [
|
|
73
|
+
"AmplitudeKind",
|
|
74
|
+
"Config",
|
|
75
|
+
"InternalError",
|
|
76
|
+
"InvalidInputError",
|
|
77
|
+
"MissingBackendError",
|
|
78
|
+
"Motion",
|
|
79
|
+
"ResolvedConfig",
|
|
80
|
+
"SpecModError",
|
|
81
|
+
"Spectrum",
|
|
82
|
+
"SpectrumFit",
|
|
83
|
+
"SpectrumPair",
|
|
84
|
+
"__version__",
|
|
85
|
+
"available_estimators",
|
|
86
|
+
"compare_spectra",
|
|
87
|
+
"config_hash",
|
|
88
|
+
"config_to_toml",
|
|
89
|
+
"estimate_spectrum",
|
|
90
|
+
"fit_spectrum",
|
|
91
|
+
"load_config",
|
|
92
|
+
"make_window",
|
|
93
|
+
"window_correction",
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
#: Which distribution each estimator needs beyond a default install.
|
|
97
|
+
#:
|
|
98
|
+
#: Measured rather than inferred, by constructing every registered estimator
|
|
99
|
+
#: and running it in an environment with none of the extras present: `cwt` and
|
|
100
|
+
#: `quadratic` are implemented against numpy and scipy and work without their
|
|
101
|
+
#: nominal extras, and only `prieto` actually requires one. Guessing from the
|
|
102
|
+
#: extras table in ``pyproject.toml`` would have marked three unavailable.
|
|
103
|
+
_ESTIMATOR_REQUIRES: Mapping[str, str | None] = {
|
|
104
|
+
"fft": None,
|
|
105
|
+
"welch": None,
|
|
106
|
+
"multitaper": None,
|
|
107
|
+
"quadratic": None,
|
|
108
|
+
"cwt": None,
|
|
109
|
+
"prieto": "multitaper",
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@contextmanager
|
|
114
|
+
def _typed_errors() -> Iterator[None]:
|
|
115
|
+
"""Translate the builtins internals raise into the documented hierarchy.
|
|
116
|
+
|
|
117
|
+
At the boundary rather than inside, because the internals are still moving
|
|
118
|
+
and this module is the thing that is supposed to stay still.
|
|
119
|
+
"""
|
|
120
|
+
try:
|
|
121
|
+
yield
|
|
122
|
+
except SpecModError:
|
|
123
|
+
raise
|
|
124
|
+
except ImportError as error:
|
|
125
|
+
raise MissingBackendError(str(error)) from error
|
|
126
|
+
except (ValueError, TypeError, KeyError) as error:
|
|
127
|
+
raise InvalidInputError(str(error)) from error
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def available_estimators() -> tuple[str, ...]:
|
|
131
|
+
"""The estimators that can actually run in this environment, sorted.
|
|
132
|
+
|
|
133
|
+
SpecMod installs without its optional backends, so the registry is not the
|
|
134
|
+
same question as what will work. Ask this before offering a choice to a
|
|
135
|
+
user, rather than discovering the answer as a failed job.
|
|
136
|
+
|
|
137
|
+
Returns
|
|
138
|
+
-------
|
|
139
|
+
tuple of str
|
|
140
|
+
Names accepted by ``estimator=`` on :func:`estimate_spectrum`.
|
|
141
|
+
|
|
142
|
+
Examples
|
|
143
|
+
--------
|
|
144
|
+
>>> "fft" in available_estimators()
|
|
145
|
+
True
|
|
146
|
+
"""
|
|
147
|
+
available = []
|
|
148
|
+
for name, requires in sorted(_ESTIMATOR_REQUIRES.items()):
|
|
149
|
+
if name not in ESTIMATORS: # pragma: no cover - registry drift
|
|
150
|
+
continue
|
|
151
|
+
if requires is None:
|
|
152
|
+
available.append(name)
|
|
153
|
+
continue
|
|
154
|
+
try:
|
|
155
|
+
found = importlib.util.find_spec(requires) is not None
|
|
156
|
+
except (ImportError, ValueError):
|
|
157
|
+
# A blocked or broken module. `--without-optional-extras` installs
|
|
158
|
+
# a finder that raises ModuleNotFoundError from `find_spec`, which
|
|
159
|
+
# is exactly the "not available" answer.
|
|
160
|
+
found = False
|
|
161
|
+
if found:
|
|
162
|
+
available.append(name)
|
|
163
|
+
return tuple(available)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def estimate_spectrum(
|
|
167
|
+
data: ArrayLike,
|
|
168
|
+
dt: float,
|
|
169
|
+
*,
|
|
170
|
+
estimator: str,
|
|
171
|
+
motion: Motion | str = Motion.VELOCITY,
|
|
172
|
+
meta: Mapping[str, Any] | None = None,
|
|
173
|
+
**options: Any,
|
|
174
|
+
) -> Spectrum:
|
|
175
|
+
"""Estimate the amplitude spectrum of one in-memory record.
|
|
176
|
+
|
|
177
|
+
Parameters
|
|
178
|
+
----------
|
|
179
|
+
data
|
|
180
|
+
The record, as a 1-D array of samples. Not modified.
|
|
181
|
+
dt
|
|
182
|
+
Sample interval in seconds.
|
|
183
|
+
estimator
|
|
184
|
+
Which backend, from :func:`available_estimators`. Required rather than
|
|
185
|
+
defaulted: the configured default is a property of a study, and a
|
|
186
|
+
service that resolves it silently cannot replay a job it did not
|
|
187
|
+
record.
|
|
188
|
+
motion
|
|
189
|
+
The ground-motion domain the record is in. Carried on the result, and
|
|
190
|
+
what makes converting between domains a typed operation later.
|
|
191
|
+
meta
|
|
192
|
+
Extra metadata to attach to the spectrum. Copied, not held.
|
|
193
|
+
**options
|
|
194
|
+
Passed to the estimator's constructor — ``n_tapers``,
|
|
195
|
+
``time_bandwidth`` and so on. Backend-specific.
|
|
196
|
+
|
|
197
|
+
Returns
|
|
198
|
+
-------
|
|
199
|
+
Spectrum
|
|
200
|
+
Frequency axis, amplitude, and the metadata needed to interpret both.
|
|
201
|
+
|
|
202
|
+
Raises
|
|
203
|
+
------
|
|
204
|
+
InvalidInputError
|
|
205
|
+
The record is empty, not 1-D, contains non-finite values, or the
|
|
206
|
+
estimator name is not known.
|
|
207
|
+
MissingBackendError
|
|
208
|
+
The estimator needs an optional extra that is not installed.
|
|
209
|
+
"""
|
|
210
|
+
with _typed_errors():
|
|
211
|
+
backend = get_estimator(estimator, **options)
|
|
212
|
+
return backend.estimate(
|
|
213
|
+
np.asarray(data), dt, motion=motion, meta=dict(meta) if meta else None
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def compare_spectra(
|
|
218
|
+
signal: Spectrum,
|
|
219
|
+
noise: Spectrum,
|
|
220
|
+
**settings: Any,
|
|
221
|
+
) -> SpectrumPair:
|
|
222
|
+
"""Judge a signal spectrum against its noise window.
|
|
223
|
+
|
|
224
|
+
Returns the pair, **including the per-bin signal-to-noise curve** rather
|
|
225
|
+
than only the band derived from it: ``pair.snr`` is an array aligned with
|
|
226
|
+
``pair.binned_signal.freq``, and ``pair.band`` is one summary of it. A
|
|
227
|
+
consumer that needs a different threshold, or that admits data bin by bin
|
|
228
|
+
rather than over a contiguous interval, needs the curve — and a curve
|
|
229
|
+
cannot be recovered from a stored interval.
|
|
230
|
+
|
|
231
|
+
Parameters
|
|
232
|
+
----------
|
|
233
|
+
signal, noise
|
|
234
|
+
Spectra from :func:`estimate_spectrum`. Neither is modified.
|
|
235
|
+
**settings
|
|
236
|
+
``threshold``, ``f_min``, ``f_max``, ``n_bins``, ``noise_model``,
|
|
237
|
+
``bandwidth`` and the rest of
|
|
238
|
+
:meth:`specmod.core.collection.SpectrumPair.compare`. All have explicit
|
|
239
|
+
defaults; none is read from configuration.
|
|
240
|
+
|
|
241
|
+
Returns
|
|
242
|
+
-------
|
|
243
|
+
SpectrumPair
|
|
244
|
+
With ``binned_signal``, ``binned_noise``, ``snr``, ``band`` and
|
|
245
|
+
``resolution_floor``.
|
|
246
|
+
|
|
247
|
+
Raises
|
|
248
|
+
------
|
|
249
|
+
InvalidInputError
|
|
250
|
+
The two spectra do not describe the same record geometry — a frequency
|
|
251
|
+
axis above its own Nyquist, most often from pairing windows that came
|
|
252
|
+
from different sampling rates.
|
|
253
|
+
"""
|
|
254
|
+
with _typed_errors():
|
|
255
|
+
return SpectrumPair.compare(signal, noise, **settings)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@dataclass(frozen=True, slots=True)
|
|
259
|
+
class SpectrumFit:
|
|
260
|
+
"""The result of fitting a source model to one spectrum.
|
|
261
|
+
|
|
262
|
+
Frozen, and holding plain numbers rather than the fitter's own objects, so
|
|
263
|
+
it can be serialised and compared without depending on lmfit's API.
|
|
264
|
+
|
|
265
|
+
Attributes
|
|
266
|
+
----------
|
|
267
|
+
params
|
|
268
|
+
Fitted values, keyed by name — ``llpsp`` (the long-period spectral
|
|
269
|
+
level Ω₀, as its base-10 logarithm), ``fc``, ``ts`` (t\\*).
|
|
270
|
+
stderr
|
|
271
|
+
One standard error per parameter, where the fitter could estimate one.
|
|
272
|
+
**Empty under some minimisers** — see the note on
|
|
273
|
+
:func:`fit_spectrum`. Absent means not measured, and is left absent
|
|
274
|
+
rather than filled with a zero that would read as "certain".
|
|
275
|
+
covariance
|
|
276
|
+
The covariance matrix, with ``names`` giving its row and column order,
|
|
277
|
+
or ``None`` when the minimiser produced none. The ``fc``-``t*``
|
|
278
|
+
correlation lives here, and reporting either parameter without it
|
|
279
|
+
overstates both.
|
|
280
|
+
chisqr, redchi
|
|
281
|
+
Misfit, and misfit per degree of freedom.
|
|
282
|
+
n_points
|
|
283
|
+
How many spectral samples the fit actually used.
|
|
284
|
+
success
|
|
285
|
+
Whether the minimiser reported convergence.
|
|
286
|
+
"""
|
|
287
|
+
|
|
288
|
+
params: Mapping[str, float]
|
|
289
|
+
stderr: Mapping[str, float]
|
|
290
|
+
covariance: NDArray[np.float64] | None
|
|
291
|
+
names: tuple[str, ...]
|
|
292
|
+
chisqr: float
|
|
293
|
+
redchi: float
|
|
294
|
+
n_points: int
|
|
295
|
+
success: bool
|
|
296
|
+
|
|
297
|
+
def correlation(self, a: str, b: str) -> float | None:
|
|
298
|
+
"""Correlation between two fitted parameters, or ``None``.
|
|
299
|
+
|
|
300
|
+
``None`` when there is no covariance matrix, or when either parameter
|
|
301
|
+
has no variance to correlate — not zero, which would read as
|
|
302
|
+
"independent" rather than "not measured".
|
|
303
|
+
"""
|
|
304
|
+
if self.covariance is None or a not in self.names or b not in self.names:
|
|
305
|
+
return None
|
|
306
|
+
i, j = self.names.index(a), self.names.index(b)
|
|
307
|
+
denominator = np.sqrt(self.covariance[i, i] * self.covariance[j, j])
|
|
308
|
+
if not np.isfinite(denominator) or denominator == 0:
|
|
309
|
+
return None
|
|
310
|
+
return float(self.covariance[i, j] / denominator)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def fit_spectrum(
|
|
314
|
+
pair: SpectrumPair,
|
|
315
|
+
*,
|
|
316
|
+
id: str = "",
|
|
317
|
+
model: Any = None,
|
|
318
|
+
guess: Mapping[str, float] | None = None,
|
|
319
|
+
fit_bins: bool = False,
|
|
320
|
+
method: str | None = None,
|
|
321
|
+
weight_method: str | None = None,
|
|
322
|
+
**fit_options: Any,
|
|
323
|
+
) -> SpectrumFit:
|
|
324
|
+
"""Fit a source model to one spectrum, with its uncertainty.
|
|
325
|
+
|
|
326
|
+
This is the **per-spectrum** fit. SpecMod does not do a joint per-event
|
|
327
|
+
inversion: :class:`specmod.fitting.FitSpectra` loops over stations and fits
|
|
328
|
+
each independently, sharing no parameters between them. A joint solver
|
|
329
|
+
belongs to whoever needs one, on top of this.
|
|
330
|
+
|
|
331
|
+
Parameters
|
|
332
|
+
----------
|
|
333
|
+
pair
|
|
334
|
+
From :func:`compare_spectra`. Its selected band is what gets fitted.
|
|
335
|
+
Not modified.
|
|
336
|
+
id
|
|
337
|
+
Label carried into the result's metadata.
|
|
338
|
+
model
|
|
339
|
+
A model object, or ``None`` for the configured default.
|
|
340
|
+
guess
|
|
341
|
+
Starting values for the fitted parameters. ``None`` derives them from
|
|
342
|
+
the spectrum with :func:`specmod.fitting.initial_guess`, which is what
|
|
343
|
+
:class:`specmod.fitting.FitSpectra` does. Do not skip it: without a
|
|
344
|
+
starting corner frequency the minimiser walks ``fc`` to zero and the
|
|
345
|
+
model evaluates to NaN, so an unguessed fit does not merely fit worse,
|
|
346
|
+
it raises.
|
|
347
|
+
fit_bins
|
|
348
|
+
Fit the log-binned spectrum rather than the full-resolution one.
|
|
349
|
+
method
|
|
350
|
+
Minimiser name, passed to lmfit. ``None`` takes ``[fitting] method``
|
|
351
|
+
from configuration, which is what :class:`specmod.fitting.FitSpectra`
|
|
352
|
+
does — so a single-spectrum fit here matches the same station's fit in
|
|
353
|
+
an event run. Naming it explicitly is what makes the call reproducible
|
|
354
|
+
somewhere else, and the default matters: on the 28 PNR windows lmfit's
|
|
355
|
+
own default returns a negative corner frequency on one station where
|
|
356
|
+
the configured ``powell`` does not.
|
|
357
|
+
weight_method
|
|
358
|
+
``"log"`` weights residuals by ``1/f``; ``"none"`` does not. ``None``
|
|
359
|
+
takes ``[fitting] weight_method`` from configuration.
|
|
360
|
+
**fit_options
|
|
361
|
+
Anything else lmfit's ``fit`` accepts.
|
|
362
|
+
|
|
363
|
+
Returns
|
|
364
|
+
-------
|
|
365
|
+
SpectrumFit
|
|
366
|
+
Point estimates *and* their errors and covariance. Frozen.
|
|
367
|
+
|
|
368
|
+
Raises
|
|
369
|
+
------
|
|
370
|
+
InvalidInputError
|
|
371
|
+
The pair has no usable band, or the spectrum is missing an attribute
|
|
372
|
+
the model needs.
|
|
373
|
+
|
|
374
|
+
Notes
|
|
375
|
+
-----
|
|
376
|
+
**Uncertainty depends on the minimiser, and the configured default does
|
|
377
|
+
not provide it.** Only the least-squares family produces a covariance
|
|
378
|
+
matrix. Measured on one synthetic station, all four agree on the corner
|
|
379
|
+
frequency and only two report an error for it:
|
|
380
|
+
|
|
381
|
+
========== ======== ============ ===================
|
|
382
|
+
``method`` ``fc`` ``fc`` error ``fc``-``t*`` corr.
|
|
383
|
+
========== ======== ============ ===================
|
|
384
|
+
powell 7.925 -- --
|
|
385
|
+
nelder 7.925 -- --
|
|
386
|
+
leastsq 7.925 0.129 0.837
|
|
387
|
+
========== ======== ============ ===================
|
|
388
|
+
|
|
389
|
+
``[fitting] method`` ships as ``powell``, so a default fit returns point
|
|
390
|
+
estimates with an empty ``stderr`` and no covariance. Pass
|
|
391
|
+
``method="leastsq"`` when the uncertainty is the point. That correlation is
|
|
392
|
+
not incidental: 0.84 between ``fc`` and ``t*`` is why neither should be
|
|
393
|
+
quoted alone.
|
|
394
|
+
|
|
395
|
+
**One determinism caveat, and it is the only one on this surface.** The
|
|
396
|
+
initial guess and the default minimiser are read from configuration by
|
|
397
|
+
internals, through :func:`specmod.config.load_config`, which resolves
|
|
398
|
+
against the current working directory and the environment. Two runs in the
|
|
399
|
+
same process with the same working directory agree exactly; two runs in
|
|
400
|
+
different directories may not, if a ``specmod.toml`` differs between them.
|
|
401
|
+
|
|
402
|
+
Pass ``model`` and the minimiser options explicitly to close that gap, and
|
|
403
|
+
record :func:`config_hash` alongside any result you intend to replay.
|
|
404
|
+
"""
|
|
405
|
+
with _typed_errors():
|
|
406
|
+
if method is None or weight_method is None:
|
|
407
|
+
fitting = load_config().config.fitting
|
|
408
|
+
method = fitting.method if method is None else method
|
|
409
|
+
weight_method = (
|
|
410
|
+
fitting.weight_method if weight_method is None else weight_method
|
|
411
|
+
)
|
|
412
|
+
if weight_method not in ("log", "none"):
|
|
413
|
+
raise InvalidInputError(
|
|
414
|
+
f"Unknown weight_method {weight_method!r}; expected 'log' or 'none'."
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
signal = fittable_signal(pair, id)
|
|
418
|
+
if signal is None:
|
|
419
|
+
raise InvalidInputError(
|
|
420
|
+
f"{id or 'this pair'} has no usable band, so there is nothing "
|
|
421
|
+
"to fit. `SpectrumPair.passes` reports that before you get here."
|
|
422
|
+
)
|
|
423
|
+
if guess is None:
|
|
424
|
+
guess = initial_guess({id: pair}, model).get(id, {})
|
|
425
|
+
|
|
426
|
+
fitter = FitSpectrum(signal, model, fit_bins, **guess)
|
|
427
|
+
if weight_method == "log":
|
|
428
|
+
fit_options["weights"] = 1 / fitter.mod_freq
|
|
429
|
+
fitter.fit_mod(method=method, **fit_options)
|
|
430
|
+
return _as_fit(fitter)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _as_fit(fitter: FitSpectrum) -> SpectrumFit:
|
|
434
|
+
"""Extract the numbers from lmfit's result object.
|
|
435
|
+
|
|
436
|
+
Nothing is computed here that the fitter did not already produce; this is
|
|
437
|
+
a projection, so that the public type does not have lmfit in it.
|
|
438
|
+
"""
|
|
439
|
+
result = fitter.result
|
|
440
|
+
if result is None: # pragma: no cover - fit_mod always assigns
|
|
441
|
+
raise InternalError("fit produced no result object")
|
|
442
|
+
|
|
443
|
+
names = tuple(result.params)
|
|
444
|
+
params = {name: float(result.params[name].value) for name in names}
|
|
445
|
+
|
|
446
|
+
stderr = {}
|
|
447
|
+
for name in names:
|
|
448
|
+
error = result.params[name].stderr
|
|
449
|
+
if error is not None:
|
|
450
|
+
stderr[name] = float(error)
|
|
451
|
+
|
|
452
|
+
covariance = None
|
|
453
|
+
if result.covar is not None:
|
|
454
|
+
covariance = np.asarray(result.covar, dtype=np.float64)
|
|
455
|
+
# lmfit's covariance covers only the parameters it varied, in their
|
|
456
|
+
# order — not every parameter in `params`, which may include fixed
|
|
457
|
+
# ones. Using `names` for its axes would mislabel the matrix.
|
|
458
|
+
names = tuple(name for name in names if result.params[name].vary)
|
|
459
|
+
|
|
460
|
+
return SpectrumFit(
|
|
461
|
+
params=params,
|
|
462
|
+
stderr=stderr,
|
|
463
|
+
covariance=covariance,
|
|
464
|
+
names=names,
|
|
465
|
+
chisqr=float(result.chisqr),
|
|
466
|
+
redchi=float(result.redchi),
|
|
467
|
+
n_points=int(result.ndata),
|
|
468
|
+
success=bool(result.success),
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def config_to_toml(config: Config, *, header: str | None = None) -> str:
|
|
473
|
+
"""Serialise a configuration to TOML, as ``specmod config freeze`` does.
|
|
474
|
+
|
|
475
|
+
Returns the text rather than writing it, so the caller decides where it
|
|
476
|
+
goes — which for anything but a local filesystem is the only workable
|
|
477
|
+
arrangement.
|
|
478
|
+
"""
|
|
479
|
+
with _typed_errors():
|
|
480
|
+
return _to_toml(config, header=header)
|
specmod/cli.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Command line interface.
|
|
2
|
+
|
|
3
|
+
Covers configuration inspection and dataset acquisition.
|
|
4
|
+
|
|
5
|
+
Built on ``click``: every command SpecMod grows should be a ``click`` command
|
|
6
|
+
so the whole surface stays consistent — one convention for options, one for
|
|
7
|
+
help text, and composable groups rather than nested ``argparse`` subparsers.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from typing import Any, TypeVar
|
|
14
|
+
|
|
15
|
+
import click
|
|
16
|
+
|
|
17
|
+
from . import __version__
|
|
18
|
+
from .acquire import fetch, verify
|
|
19
|
+
from .config import load_config
|
|
20
|
+
from .config.provenance import Provenance
|
|
21
|
+
from .config.serialize import to_toml
|
|
22
|
+
|
|
23
|
+
#: Shared by every command that resolves configuration, so the layering flags
|
|
24
|
+
#: cannot drift apart between them.
|
|
25
|
+
_resolution_options = [
|
|
26
|
+
click.option(
|
|
27
|
+
"-c",
|
|
28
|
+
"--config",
|
|
29
|
+
"config_file",
|
|
30
|
+
type=click.Path(exists=True, dir_okay=False),
|
|
31
|
+
help="Explicit config file to apply.",
|
|
32
|
+
),
|
|
33
|
+
click.option("--no-local", is_flag=True, help="Ignore specmod.local.toml."),
|
|
34
|
+
click.option("--no-env", is_flag=True, help="Ignore SPECMOD_* variables."),
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def resolution_options(fn: F) -> F:
|
|
42
|
+
"""Apply the shared config-resolution options to a command."""
|
|
43
|
+
for option in reversed(_resolution_options):
|
|
44
|
+
fn = option(fn)
|
|
45
|
+
return fn
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
49
|
+
@click.version_option(__version__, prog_name="specmod")
|
|
50
|
+
def main() -> None:
|
|
51
|
+
"""Process and model seismic spectra."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@main.group()
|
|
55
|
+
def config() -> None:
|
|
56
|
+
"""Inspect or export configuration."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@config.command("show")
|
|
60
|
+
@resolution_options
|
|
61
|
+
@click.option(
|
|
62
|
+
"--provenance",
|
|
63
|
+
is_flag=True,
|
|
64
|
+
help="Emit the JSON provenance record instead of the readable form.",
|
|
65
|
+
)
|
|
66
|
+
def config_show(
|
|
67
|
+
config_file: str | None, no_local: bool, no_env: bool, provenance: bool
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Print the resolved configuration and where each value came from."""
|
|
70
|
+
resolved = load_config(
|
|
71
|
+
project_file=config_file, use_local=not no_local, use_env=not no_env
|
|
72
|
+
)
|
|
73
|
+
if provenance:
|
|
74
|
+
click.echo(
|
|
75
|
+
Provenance.capture(resolved.config, sources=resolved.sources).to_json()
|
|
76
|
+
)
|
|
77
|
+
else:
|
|
78
|
+
click.echo(resolved.explain(), nl=False)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@config.command("freeze")
|
|
82
|
+
@resolution_options
|
|
83
|
+
def config_freeze(config_file: str | None, no_local: bool, no_env: bool) -> None:
|
|
84
|
+
"""Write the resolved configuration as TOML, for committing as a study."""
|
|
85
|
+
resolved = load_config(
|
|
86
|
+
project_file=config_file, use_local=not no_local, use_env=not no_env
|
|
87
|
+
)
|
|
88
|
+
click.echo(
|
|
89
|
+
to_toml(
|
|
90
|
+
resolved.config,
|
|
91
|
+
header=(
|
|
92
|
+
f"Frozen by specmod {__version__}.\n"
|
|
93
|
+
"Commit this alongside the results it produced."
|
|
94
|
+
),
|
|
95
|
+
),
|
|
96
|
+
nl=False,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@main.command("fetch")
|
|
101
|
+
@click.argument("config_file", type=click.Path(exists=True, dir_okay=False))
|
|
102
|
+
@click.option(
|
|
103
|
+
"-o",
|
|
104
|
+
"--out",
|
|
105
|
+
required=True,
|
|
106
|
+
type=click.Path(file_okay=False),
|
|
107
|
+
help="Directory to write the event and its manifest into.",
|
|
108
|
+
)
|
|
109
|
+
@click.option(
|
|
110
|
+
"--verify",
|
|
111
|
+
"verify_only",
|
|
112
|
+
is_flag=True,
|
|
113
|
+
help="Re-hash an existing fetch against its manifest instead of fetching.",
|
|
114
|
+
)
|
|
115
|
+
def fetch_command(config_file: str, out: str, verify_only: bool) -> None:
|
|
116
|
+
"""Fetch an event described by an acquisition config.
|
|
117
|
+
|
|
118
|
+
This is the one command that uses the network, which is why it is explicit
|
|
119
|
+
rather than something a test could reach by accident.
|
|
120
|
+
"""
|
|
121
|
+
if verify_only:
|
|
122
|
+
problems = verify(out)
|
|
123
|
+
for problem in problems:
|
|
124
|
+
click.echo(problem, err=True)
|
|
125
|
+
if problems:
|
|
126
|
+
raise SystemExit(1)
|
|
127
|
+
click.echo(f"{out}: matches its manifest")
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
manifest = fetch(config_file, out=out)
|
|
131
|
+
channels = manifest["resolved"]["channels"]
|
|
132
|
+
click.echo(
|
|
133
|
+
f"{manifest['name']}: {len(channels)} channels from "
|
|
134
|
+
f"{manifest['data_centre']} -> {out}"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
if __name__ == "__main__": # pragma: no cover
|
|
139
|
+
main()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Configuration: semantic sections, layered overrides, recorded provenance.
|
|
2
|
+
|
|
3
|
+
See ``docs/REFACTOR_PLAN.md`` §4.7.
|
|
4
|
+
|
|
5
|
+
Defaults reproduce the behaviour shipped before the refactor. A study pins its
|
|
6
|
+
own values in a committed TOML file; personal experimentation goes in
|
|
7
|
+
``specmod.local.toml``, which is gitignored, and is promoted deliberately with
|
|
8
|
+
``specmod config freeze``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from .layers import LAYER_NAMES, ResolvedConfig, load_config
|
|
14
|
+
from .provenance import Provenance, config_hash
|
|
15
|
+
from .sections import (
|
|
16
|
+
AcquireConfig,
|
|
17
|
+
Config,
|
|
18
|
+
FittingConfig,
|
|
19
|
+
GeometryConfig,
|
|
20
|
+
ModelConfig,
|
|
21
|
+
SmoothingConfig,
|
|
22
|
+
SnrConfig,
|
|
23
|
+
TransformConfig,
|
|
24
|
+
VizConfig,
|
|
25
|
+
WindowsConfig,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"LAYER_NAMES",
|
|
30
|
+
"AcquireConfig",
|
|
31
|
+
"Config",
|
|
32
|
+
"FittingConfig",
|
|
33
|
+
"GeometryConfig",
|
|
34
|
+
"ModelConfig",
|
|
35
|
+
"Provenance",
|
|
36
|
+
"ResolvedConfig",
|
|
37
|
+
"SmoothingConfig",
|
|
38
|
+
"SnrConfig",
|
|
39
|
+
"TransformConfig",
|
|
40
|
+
"VizConfig",
|
|
41
|
+
"WindowsConfig",
|
|
42
|
+
"config_hash",
|
|
43
|
+
"load_config",
|
|
44
|
+
]
|