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/datasets.py ADDED
@@ -0,0 +1,316 @@
1
+ """Where an event's data lives on disk, and the events shipped with the repo.
2
+
3
+ The layout follows ObsPy's ``mass_downloader``, which writes ``waveforms/``
4
+ and ``stations/`` beneath a per-event directory. This adds the three things a
5
+ spectral workflow needs alongside them::
6
+
7
+ tutorial/data/events/<origin>/
8
+ event.xml # QuakeML: origin, magnitudes, uncertainties
9
+ waveforms/ # one miniSEED file per channel
10
+ stations/inventory.xml # StationXML for those channels
11
+ picks/*.xml # QuakeML picks (*.picks: Snuffler markers)
12
+ spectra/*.h5 # computed spectra
13
+ spectra/flatfiles/*.csv # and their tabular export
14
+
15
+ :class:`EventDirectory` resolves those paths and :class:`Event` carries the
16
+ hypocentre needed to set source-station geometry. Tests, ``tools/`` and the
17
+ documentation notebooks all read the layout from here.
18
+
19
+ Datasets that ship with the repository have their own loader —
20
+ :func:`load_pnr_2019` — and need no network. Published ones are fetched by
21
+ :func:`load`, cached by pooch and pinned by hash. :mod:`specmod.acquire`
22
+ produces both.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import glob
28
+ import json
29
+ import os
30
+ from dataclasses import dataclass
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ __all__ = [
35
+ "EVENTS",
36
+ "PNR_2019",
37
+ "REGISTRY",
38
+ "Dataset",
39
+ "DatasetSpec",
40
+ "Event",
41
+ "EventDirectory",
42
+ "data_dir",
43
+ "load",
44
+ "load_pnr_2019",
45
+ ]
46
+
47
+ #: Event directories, relative to the repository root.
48
+ EVENTS = Path("tutorial") / "data" / "events"
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class EventDirectory:
53
+ """The paths beneath one event directory.
54
+
55
+ Reads nothing on construction, so it is safe to build at import time.
56
+ """
57
+
58
+ root: Path
59
+
60
+ @property
61
+ def waveforms(self) -> Path:
62
+ return self.root / "waveforms"
63
+
64
+ @property
65
+ def stations(self) -> Path:
66
+ return self.root / "stations"
67
+
68
+ @property
69
+ def inventory(self) -> Path:
70
+ """The StationXML covering the channels in :attr:`waveforms`."""
71
+ return self.stations / "inventory.xml"
72
+
73
+ @property
74
+ def picks(self) -> Path:
75
+ return self.root / "picks"
76
+
77
+ @property
78
+ def spectra(self) -> Path:
79
+ return self.root / "spectra"
80
+
81
+ @property
82
+ def flatfiles(self) -> Path:
83
+ return self.spectra / "flatfiles"
84
+
85
+ def waveform_glob(self, pattern: str = "*") -> str:
86
+ """A glob over :attr:`waveforms`, as a string for ``obspy.read``."""
87
+ return str(self.waveforms / pattern)
88
+
89
+ @property
90
+ def quakeml(self) -> Path:
91
+ """The event's QuakeML: origin, magnitudes and their uncertainties."""
92
+ return self.root / "event.xml"
93
+
94
+ def picks_file(self) -> Path:
95
+ """The pick file for this event, QuakeML for preference.
96
+
97
+ QuakeML is the standard and carries what a marker file cannot —
98
+ polarity, uncertainty, evaluation status, the full SEED id. Snuffler
99
+ markers are still read where that is all there is.
100
+
101
+ Raises :class:`FileNotFoundError` when neither is present.
102
+ """
103
+ for pattern in ("*.xml", "*.picks"):
104
+ found = sorted(glob.glob(str(self.picks / pattern)))
105
+ if found:
106
+ return Path(found[0])
107
+ raise FileNotFoundError(f"no *.xml or *.picks file under {self.picks}")
108
+
109
+ def is_present(self) -> bool:
110
+ """Whether the waveforms and station metadata are both present.
111
+
112
+ Does not check ``spectra/``, which is generated rather than shipped.
113
+ """
114
+ return self.waveforms.is_dir() and self.inventory.is_file()
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class Event:
119
+ """An earthquake: where its data sits, and the hypocentre it happened at.
120
+
121
+ ``origin``, ``latitude``, ``longitude`` and ``depth_km`` are the four
122
+ values :func:`specmod.preprocess.set_stream_distance` takes.
123
+ """
124
+
125
+ origin: str
126
+ latitude: float
127
+ longitude: float
128
+ depth_km: float
129
+ #: The published magnitude and the scale it is on, e.g. ``2.9`` and
130
+ #: ``"Mw"``. Kept as a pair: ML and Mw diverge below about magnitude 3, so
131
+ #: a bare number cannot be compared against a computed one.
132
+ catalogue_magnitude: float | None = None
133
+ catalogue_magnitude_type: str | None = None
134
+
135
+ def directory(self, project_root: Path | str) -> EventDirectory:
136
+ """Locate this event beneath ``project_root``, the repository root."""
137
+ return EventDirectory(Path(project_root) / EVENTS / self.origin)
138
+
139
+
140
+ #: Preston New Road, 26 August 2019 — the induced event the tutorial and both
141
+ #: golden references are built around, and the largest of the PNR-2 sequence.
142
+ #: The origin time doubles as the directory name.
143
+ #:
144
+ #: ``Mw 2.9`` is from the PNR-2 catalogue published with Cuadrilla's
145
+ #: hydraulic-fracture monitoring (NGDC, `709cbc2f-af5c-4d09-a4ea-6deb5aa8c5d8
146
+ #: <https://www2.bgs.ac.uk/nationalgeosciencedatacentre/citedData/catalogue/709cbc2f-af5c-4d09-a4ea-6deb5aa8c5d8.html>`_),
147
+ #: which gives ``surface_ML``, ``surface_Mw`` and ``corrected_Mw`` all as 2.9.
148
+ #:
149
+ #: The hypocentre is the catalogue's, converted from its British National Grid
150
+ #: easting/northing (336135.0, 432515.0; EPSG:27700) to WGS84. The catalogue
151
+ #: gives depth as an elevation of -2040 m.
152
+ PNR_2019 = Event(
153
+ origin="2019-08-26T07:30:47.000000Z",
154
+ latitude=53.785021,
155
+ longitude=-2.970780,
156
+ depth_km=2.04,
157
+ catalogue_magnitude=2.9,
158
+ catalogue_magnitude_type="Mw",
159
+ )
160
+
161
+
162
+ # --------------------------------------------------------------- consuming
163
+ #
164
+ # Published datasets, fetched once and cached. `specmod.acquire` produces
165
+ # these; this half consumes them, and is offline after the first download.
166
+
167
+
168
+ @dataclass(frozen=True)
169
+ class DatasetSpec:
170
+ """A published dataset: where to get it, and what it should hash to.
171
+
172
+ The hash is what makes a regression test mean anything. A config records
173
+ intent and makes a dataset regenerable, but FDSN is not content-addressed,
174
+ so re-running the config is not guaranteed to return the same bytes — see
175
+ §5.2.2 of ``docs/REFACTOR_PLAN.md``.
176
+
177
+ Versioning is by name. ``magna_2020_v1`` and ``magna_2020_v2`` are separate
178
+ entries, so a result pinned to v1 keeps fetching v1 after v2 exists.
179
+ """
180
+
181
+ name: str
182
+ url: str
183
+ #: ``sha256:...`` of the archive, as pooch expects it.
184
+ sha256: str
185
+ event: Event
186
+ #: Path within the unpacked archive holding the event directory.
187
+ member: str = ""
188
+
189
+
190
+ #: Published datasets by name. Local datasets are not listed: they ship with
191
+ #: the package and need no download.
192
+ REGISTRY: dict[str, DatasetSpec] = {}
193
+
194
+
195
+ def data_dir() -> Path:
196
+ """Where downloaded datasets are cached.
197
+
198
+ ``SPECMOD_DATA_DIR`` overrides the platform cache directory, which matters
199
+ on a cluster where ``$HOME`` is small or not writable from a compute node.
200
+ """
201
+ override = os.environ.get("SPECMOD_DATA_DIR")
202
+ if override:
203
+ return Path(override)
204
+
205
+ import pooch # noqa: PLC0415
206
+
207
+ return Path(pooch.os_cache("specmod"))
208
+
209
+
210
+ @dataclass(frozen=True)
211
+ class Dataset:
212
+ """One event's data, wherever it came from.
213
+
214
+ The readers are methods rather than eager attributes because a dataset is
215
+ often opened for its metadata alone, and reading a stream costs real time.
216
+ """
217
+
218
+ event: Event
219
+ paths: EventDirectory
220
+ #: The acquisition manifest, where the dataset was produced by
221
+ #: :mod:`specmod.acquire`. ``None`` for data that ships with the package.
222
+ manifest: dict[str, Any] | None = None
223
+
224
+ def stream(self, pattern: str = "*") -> Any:
225
+ """Read the waveforms. Raw counts — the response is not removed."""
226
+ import obspy # noqa: PLC0415
227
+
228
+ return obspy.read(self.paths.waveform_glob(pattern))
229
+
230
+ def inventory(self) -> Any:
231
+ """Read the station metadata, including responses."""
232
+ import obspy # noqa: PLC0415
233
+
234
+ return obspy.read_inventory(str(self.paths.inventory))
235
+
236
+ def catalog(self) -> Any:
237
+ """Read the event's QuakeML.
238
+
239
+ Everything the catalogue said, rather than the four numbers
240
+ :class:`Event` keeps: origin uncertainties, every magnitude rather than
241
+ the preferred one, agency and evaluation status.
242
+ """
243
+ import obspy # noqa: PLC0415
244
+
245
+ if not self.paths.quakeml.is_file():
246
+ raise FileNotFoundError(
247
+ f"no QuakeML at {self.paths.quakeml}. It is written by "
248
+ f"specmod.acquire when an event is resolved from a catalogue; "
249
+ f"an event declared explicitly in a config has none."
250
+ )
251
+ return obspy.read_events(str(self.paths.quakeml))
252
+
253
+
254
+ def _repository_root() -> Path:
255
+ """The checkout this package was imported from, if it is one.
256
+
257
+ Data that ships with the repository is not inside the installed package, so
258
+ it is only reachable from a source checkout or an editable install.
259
+ """
260
+ return Path(__file__).resolve().parent.parent.parent
261
+
262
+
263
+ def load_pnr_2019() -> Dataset:
264
+ """The Preston New Road event committed to this repository.
265
+
266
+ Needs no download and no network: the waveforms and inventory are in the
267
+ checkout. Raises when they are not, rather than reaching for a URL, since
268
+ there is no published artefact for this one.
269
+ """
270
+ paths = PNR_2019.directory(_repository_root())
271
+ if not paths.is_present():
272
+ raise FileNotFoundError(
273
+ f"the PNR data is not at {paths.root}. It ships with the "
274
+ f"repository rather than being downloaded, so this needs a source "
275
+ f"checkout or an editable install."
276
+ )
277
+ return Dataset(event=PNR_2019, paths=paths)
278
+
279
+
280
+ def load(name: str, *, downloader: Any = None) -> Dataset:
281
+ """Fetch a published dataset by name, from the cache after the first call.
282
+
283
+ Downloads are hash-checked by pooch: a corrupted or substituted archive
284
+ fails here rather than quietly becoming a new expected answer.
285
+
286
+ ``downloader`` is passed through to :func:`pooch.retrieve`. It exists so
287
+ the caching, hash check and unpacking can be exercised without a network —
288
+ pooch has no ``file://`` support — and so an operator behind an
289
+ authenticating proxy can supply their own.
290
+ """
291
+ try:
292
+ spec = REGISTRY[name]
293
+ except KeyError:
294
+ known = sorted(REGISTRY) or ["<none published yet>"]
295
+ raise ValueError(
296
+ f"Unknown dataset {name!r}. Published: {known}. Data that ships "
297
+ f"with the repository has its own loader, e.g. load_pnr_2019()."
298
+ ) from None
299
+
300
+ import pooch # noqa: PLC0415
301
+
302
+ unpacked = pooch.retrieve(
303
+ url=spec.url,
304
+ known_hash=spec.sha256,
305
+ path=data_dir(),
306
+ processor=pooch.Untar(),
307
+ downloader=downloader,
308
+ )
309
+ root = Path(os.path.commonpath(unpacked)) if unpacked else data_dir()
310
+ paths = EventDirectory(root / spec.member if spec.member else root)
311
+
312
+ manifest_file = paths.root.parent / "manifest.json"
313
+ manifest = (
314
+ json.loads(manifest_file.read_text()) if manifest_file.is_file() else None
315
+ )
316
+ return Dataset(event=spec.event, paths=paths, manifest=manifest)
specmod/distance.py ADDED
@@ -0,0 +1,190 @@
1
+ """Source-to-site distance, as a registry rather than a stat name.
2
+
3
+ Which distance you mean is a modelling choice, and at short range it is not a
4
+ small one. On the PNR data the nearest station is **1.02 km epicentral against
5
+ 2.30 km hypocentral** — a factor of 2.24 — while the farthest agree to 1.004.
6
+ Anything weighted by inverse distance, or corrected for geometric spreading,
7
+ therefore depends on the choice most strongly at exactly the station that
8
+ matters most.
9
+
10
+ Two are implemented here because they are the two a point source supports.
11
+ Both read a value :func:`specmod.preprocess.set_stream_distance` has already
12
+ computed:
13
+
14
+ ``repi``
15
+ Epicentral. Horizontal distance from the epicentre.
16
+ ``rhyp``
17
+ Hypocentral. Slant distance from the hypocentre.
18
+
19
+ **Epicentral is the honest choice when sensor depths are unknown**, and that
20
+ is more often than it sounds. ``rhyp`` is built from the source depth and the
21
+ station *elevation*, which silently assumes every sensor sits at the surface.
22
+ For a borehole deployment that is wrong by the burial depth, and nothing in
23
+ the metadata announces it — the PNR inventory records channel ``depth`` as
24
+ ``123456.0``, a placeholder, so on that dataset ``rhyp`` is an assumption
25
+ wearing a measurement's name.
26
+
27
+ Finite-fault measures
28
+ ---------------------
29
+ ``Rrup`` (closest distance to the rupture surface) and ``Rjb`` (Joyner-Boore,
30
+ closest horizontal distance to the surface projection of the rupture) are the
31
+ measures ground-motion work generally wants, and they are **not implemented**
32
+ — deliberately, rather than by omission.
33
+
34
+ Both need a rupture *surface*: strike, dip, length, width and a hypocentre
35
+ position on it. SpecMod carries a point source, so there is nothing to compute
36
+ them from, and a version that quietly degenerated to ``rhyp`` and ``repi``
37
+ would be worse than an error — those are exactly what `Rrup` and `Rjb` reduce
38
+ to for a point source, so the substitution would be invisible in the output
39
+ and wrong for any event large enough to warrant asking.
40
+
41
+ They are registered all the same, raising with what they would need. A name
42
+ that resolves to a clear failure is a better extension point than a name that
43
+ does not resolve at all, and it puts the requirement where someone adding
44
+ finite-fault support will read it.
45
+
46
+ The registry is the same shape as :data:`specmod.transforms.ESTIMATORS`,
47
+ :data:`specmod.core.noise.NOISE_MODELS` and
48
+ :data:`specmod.staged.WEIGHT_MODELS`, so a study names a distance the way it
49
+ names anything else and the choice travels with the resolved configuration.
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ from dataclasses import dataclass
55
+ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
56
+
57
+ import numpy as np
58
+
59
+ from .config import load_config
60
+
61
+ if TYPE_CHECKING: # pragma: no cover
62
+ from collections.abc import Sequence
63
+
64
+ from numpy.typing import NDArray
65
+
66
+ __all__ = [
67
+ "DISTANCE_MEASURES",
68
+ "DistanceMeasure",
69
+ "Epicentral",
70
+ "FiniteFaultDistance",
71
+ "Hypocentral",
72
+ "get_distance_measure",
73
+ "resolve_distance_measure",
74
+ ]
75
+
76
+
77
+ @runtime_checkable
78
+ class DistanceMeasure(Protocol):
79
+ """One distance per channel, in kilometres."""
80
+
81
+ name: str
82
+
83
+ def distances(
84
+ self, spectra: Any, ids: Sequence[str]
85
+ ) -> NDArray[np.float64]: ... # pragma: no cover
86
+
87
+
88
+ @dataclass(frozen=True, slots=True)
89
+ class _FromMeta:
90
+ """A distance already computed onto the trace metadata."""
91
+
92
+ key: str
93
+ name: str
94
+
95
+ def distances(self, spectra: Any, ids: Sequence[str]) -> NDArray[np.float64]:
96
+ out = np.empty(len(ids), dtype=np.float64)
97
+ for i, id in enumerate(ids):
98
+ meta = spectra[id].signal.meta
99
+ if self.key not in meta:
100
+ raise ValueError(
101
+ f"{id} carries no {self.key!r}, so its {self.name} distance "
102
+ f"is unknown. Set the geometry with "
103
+ f"specmod.preprocess.set_stream_distance."
104
+ )
105
+ value = float(meta[self.key])
106
+ if value <= 0:
107
+ raise ValueError(
108
+ f"{id} has {self.key}={value}, which is not a distance"
109
+ )
110
+ out[i] = value
111
+ return out
112
+
113
+
114
+ @dataclass(frozen=True, slots=True)
115
+ class Epicentral(_FromMeta):
116
+ key: str = "repi"
117
+ name: str = "epicentral"
118
+
119
+
120
+ @dataclass(frozen=True, slots=True)
121
+ class Hypocentral(_FromMeta):
122
+ key: str = "rhyp"
123
+ name: str = "hypocentral"
124
+
125
+
126
+ @dataclass(frozen=True, slots=True)
127
+ class FiniteFaultDistance:
128
+ """``Rrup`` and ``Rjb``: registered, and not implemented.
129
+
130
+ Raising here rather than omitting the name is the point. For a point source
131
+ these degenerate exactly to hypocentral and epicentral, so an
132
+ implementation that silently fell back would produce plausible numbers that
133
+ are wrong for any event big enough to justify asking for them.
134
+ """
135
+
136
+ name: str
137
+ needs: str
138
+
139
+ def distances(self, spectra: Any, ids: Sequence[str]) -> NDArray[np.float64]:
140
+ raise NotImplementedError(
141
+ f"{self.name} is not implemented. It needs {self.needs}, and "
142
+ f"SpecMod carries a point source — there is no rupture surface to "
143
+ f"measure from. For a point source {self.name} degenerates to "
144
+ f"{'hypocentral' if self.name == 'rrup' else 'epicentral'}; name "
145
+ f"that instead if it is what you mean, rather than getting it by "
146
+ f"accident."
147
+ )
148
+
149
+
150
+ #: Registered distance measures, resolved by name from configuration.
151
+ DISTANCE_MEASURES: dict[str, Any] = {
152
+ "repi": Epicentral,
153
+ "rhyp": Hypocentral,
154
+ "rrup": lambda: FiniteFaultDistance(
155
+ name="rrup", needs="a rupture surface — strike, dip, length and width"
156
+ ),
157
+ "rjb": lambda: FiniteFaultDistance(
158
+ name="rjb",
159
+ needs="the surface projection of a rupture — strike, dip, length and width",
160
+ ),
161
+ }
162
+
163
+
164
+ def get_distance_measure(name: str) -> DistanceMeasure:
165
+ """Resolve a registered measure by name."""
166
+ try:
167
+ factory = DISTANCE_MEASURES[name]
168
+ except KeyError:
169
+ raise ValueError(
170
+ f"Unknown distance measure {name!r}. "
171
+ f"Available: {sorted(DISTANCE_MEASURES)}."
172
+ ) from None
173
+ measure: DistanceMeasure = factory()
174
+ return measure
175
+
176
+
177
+ def resolve_distance_measure(
178
+ measure: str | DistanceMeasure | None = None,
179
+ ) -> DistanceMeasure:
180
+ """A measure from a name, an instance, or the configuration.
181
+
182
+ ``None`` takes ``[geometry] distance_measure``, which is the project-wide
183
+ choice. It lived in ``[windows]`` and had no reader at all until this
184
+ module; cutting a window does not depend on how distance is measured.
185
+ """
186
+ if measure is None:
187
+ measure = str(load_config().config.geometry.distance_measure)
188
+ if isinstance(measure, str):
189
+ return get_distance_measure(measure)
190
+ return measure
specmod/exceptions.py ADDED
@@ -0,0 +1,58 @@
1
+ """The exception hierarchy :mod:`specmod.api` raises.
2
+
3
+ Three kinds, because a caller does three different things with them:
4
+
5
+ - :class:`InvalidInputError` — the caller's data or arguments are wrong. A
6
+ program shows a form error; a person fixes the input.
7
+ - :class:`MissingBackendError` — the code is fine and the environment is not:
8
+ an optional extra is not installed. Fixed by installing something, and
9
+ avoidable up front with :func:`specmod.api.available_estimators`.
10
+ - :class:`InternalError` — an invariant inside SpecMod is broken. Nothing the
11
+ caller can do; it is a bug report.
12
+
13
+ Each also inherits the builtin exception the corresponding internal code
14
+ raises today, so ``except ValueError`` keeps working and internals can migrate
15
+ one at a time without a flag day.
16
+
17
+ **Internals still raise the builtins.** :mod:`specmod.api` translates at its
18
+ own boundary, so the guarantee is specific: functions reached *through
19
+ `specmod.api`* raise this hierarchy. Reaching around it gets the builtins.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ __all__ = [
25
+ "InternalError",
26
+ "InvalidInputError",
27
+ "MissingBackendError",
28
+ "SpecModError",
29
+ ]
30
+
31
+
32
+ class SpecModError(Exception):
33
+ """Base class for every error SpecMod raises deliberately."""
34
+
35
+
36
+ class InvalidInputError(SpecModError, ValueError):
37
+ """The data or arguments given to SpecMod are not usable.
38
+
39
+ A record containing NaN, a frequency axis that does not belong to its
40
+ record, an unknown estimator name, a band with no samples in it.
41
+ """
42
+
43
+
44
+ class MissingBackendError(SpecModError, ImportError):
45
+ """An optional backend is not installed.
46
+
47
+ Raised at call time rather than import time, so a default install stays
48
+ importable. :func:`specmod.api.available_estimators` answers the same
49
+ question without provoking the error.
50
+ """
51
+
52
+
53
+ class InternalError(SpecModError, RuntimeError):
54
+ """An invariant inside SpecMod does not hold.
55
+
56
+ Not caused by the caller and not fixable by them. If one of these reaches
57
+ you, it is a bug in SpecMod.
58
+ """
@@ -0,0 +1,58 @@
1
+ """Fitting a source model to one spectrum, and to a whole event.
2
+
3
+ :func:`fittable_signal` decides what to fit, :func:`initial_guess` where to
4
+ start, :class:`FitSpectrum` fits one station and :class:`FitSpectra` an event.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import warnings
10
+
11
+ from .base import (
12
+ REQUIRED_SPECTRUM_ATTRIBUTES,
13
+ SpectraLike,
14
+ Spectrumish,
15
+ plot_columns,
16
+ )
17
+ from .event import FitSpectra
18
+ from .guess import fittable_signal, initial_guess, selected_band
19
+ from .spectrum import FitSpectrum
20
+
21
+ __all__ = [
22
+ "PLOT_COLUMNS",
23
+ "REQUIRED_SPECTRUM_ATTRIBUTES",
24
+ "FitSpectra",
25
+ "FitSpectrum",
26
+ "SpectraLike",
27
+ "Spectrumish",
28
+ "fittable_signal",
29
+ "initial_guess",
30
+ "plot_columns",
31
+ "selected_band",
32
+ ]
33
+
34
+
35
+ def __getattr__(name: str) -> object:
36
+ """Keep ``PLOT_COLUMNS`` importable, resolved at each access.
37
+
38
+ It was a module-level constant evaluated at import time, which froze the
39
+ configuration of whatever directory the process started in. Reading it now
40
+ resolves configuration per access, so the value is at least correct — but
41
+ a name that looks like a constant and performs a lookup is a poor bargain
42
+ either way, hence the warning and :func:`plot_columns`.
43
+
44
+ Deliberately *not* ``from .base import ...``: a from-import would run this
45
+ once at import time and rebind the result, which is the frozen behaviour
46
+ this replaced, reintroduced one level up.
47
+ """
48
+ if name == "PLOT_COLUMNS":
49
+ warnings.warn(
50
+ "specmod.fitting.PLOT_COLUMNS is deprecated and will be removed "
51
+ "in 0.4.0; call specmod.fitting.plot_columns() instead, which "
52
+ "resolves the current configuration rather than the one that "
53
+ "happened to be in effect at import.",
54
+ DeprecationWarning,
55
+ stacklevel=2,
56
+ )
57
+ return plot_columns()
58
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,50 @@
1
+ """What the fitting layer expects of whatever it is handed.
2
+
3
+ Structural rather than nominal: :class:`~specmod.fitting.FitSpectrum` reads
4
+ attributes off its input and does not care what class provides them, which is
5
+ what lets it take a :class:`~specmod.core.collection.FittableView`, a bare
6
+ spectrum, or something assembled by hand in a notebook.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from .. import config as cfg
14
+
15
+ __all__ = [
16
+ "REQUIRED_SPECTRUM_ATTRIBUTES",
17
+ "SpectraLike",
18
+ "Spectrumish",
19
+ "plot_columns",
20
+ ]
21
+
22
+ #: What a spectrum-like object handed to :class:`FitSpectrum` looks like.
23
+ #: Structural rather than nominal on purpose — see :meth:`FitSpectrum.__check_input`.
24
+ Spectrumish = Any
25
+ #: A container mapping trace ids to paired spectra; see
26
+ #: :meth:`FitSpectra.__check_spectra`.
27
+ SpectraLike = Any
28
+
29
+
30
+ def plot_columns() -> int:
31
+ """How many columns a multi-panel figure uses, from ``[viz]``.
32
+
33
+ A function rather than a constant, and that is the whole point. This was
34
+ ``PLOT_COLUMNS = cfg.load_config().config.viz.plot_columns`` evaluated at
35
+ import time, so importing :mod:`specmod.fitting` resolved configuration
36
+ against whatever directory the process happened to start in and froze the
37
+ answer for the life of the interpreter. Measured: importing from a project
38
+ whose ``specmod.toml`` says 5, then moving to one that resolves to 3, left
39
+ the constant at 5 — a worker serving two projects would use the first
40
+ one's layout for both.
41
+
42
+ One home for the setting either way: it used to be defined in *both* the
43
+ SPECTRAL and FITTING dicts, and the two copies could disagree.
44
+ """
45
+ return int(cfg.load_config().config.viz.plot_columns)
46
+
47
+
48
+ #: What :class:`FitSpectrum` reads off whatever it is given. Kept as data so
49
+ #: the requirement is stated once and can be asserted against.
50
+ REQUIRED_SPECTRUM_ATTRIBUTES = ("id", "meta", "freq", "amp", "bfreq", "bamp")