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/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}")
|
specmod/fitting/base.py
ADDED
|
@@ -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")
|