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
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
+ ]