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/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """SpecMod — a toolbox for processing and modelling seismic spectra.
2
+
3
+ The public API is re-exported here. Submodules may be imported directly for
4
+ anything not listed in ``__all__``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from importlib.metadata import PackageNotFoundError
10
+ from importlib.metadata import version as _version
11
+
12
+ try:
13
+ __version__ = _version("specmod")
14
+ except PackageNotFoundError: # pragma: no cover - only when running from source
15
+ __version__ = "0.0.0.dev0"
16
+
17
+ __all__ = ["__version__"]
@@ -0,0 +1,21 @@
1
+ """Third-party code vendored into SpecMod.
2
+
3
+ Everything here was written by someone else and is redistributed under its own
4
+ licence, which is reproduced in the module that carries it. The rules for this
5
+ package:
6
+
7
+ 1. **Nothing in here is public API.** Callers go through the wrapper in
8
+ :mod:`specmod.transforms`, so a vendored implementation can be replaced by a
9
+ native one without anybody noticing.
10
+ 2. **Changes are recorded.** Each module lists what was altered from upstream
11
+ and why, so the diff against the original stays legible.
12
+ 3. **Our tests own it.** Vendored code is held to the same contracts as
13
+ everything else — a vendored function that cannot satisfy the Parseval check
14
+ in :mod:`specmod.core.spectrum` is a bug on our side of the fence now.
15
+
16
+ The point of the quarantine is that upstream code arrives with upstream
17
+ conventions, and mixing those into ``specmod.transforms`` is how a codebase
18
+ ends up with two of everything.
19
+ """
20
+
21
+ from __future__ import annotations
@@ -0,0 +1,243 @@
1
+ """Quadratic inverse spectrum estimation, vendored from Prieto's ``multitaper``.
2
+
3
+ Upstream: https://github.com/gaprieto/multitaper — ``multitaper/utils.py``,
4
+ functions ``qiinv`` and ``sft``, version 1.2.0.
5
+
6
+ MIT License
7
+ Copyright (c) 2022 Germán A. Prieto
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a
10
+ copy of this software and associated documentation files (the "Software"),
11
+ to deal in the Software without restriction, including without limitation
12
+ the rights to use, copy, modify, merge, publish, distribute, sublicense,
13
+ and/or sell copies of the Software, and to permit persons to whom the
14
+ Software is furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in
17
+ all copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
24
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
25
+ DEALINGS IN THE SOFTWARE.
26
+
27
+ Why this is vendored rather than imported
28
+ -----------------------------------------
29
+ ``multitaper`` remains an optional extra and is still the better route to
30
+ jackknife intervals and the F-test. This one function is carried here because
31
+ it does not run at all on the version of numpy SpecMod requires — see change 1
32
+ — so importing it would make the quadratic estimator unavailable by default.
33
+ Vendoring keeps it a core capability with no optional dependency.
34
+
35
+ Changes from upstream
36
+ ---------------------
37
+ 1. **numpy 2 compatibility (the reason this is here).** ``scipy.optimize.nnls``
38
+ and ``scipy.linalg.lstsq`` return shape-``(1,)`` arrays for a single-column
39
+ system, and upstream assigns them into scalar slots of a 1-D buffer. That
40
+ was deprecated in numpy 1.25 and raises in 2.0, so ``qiinv`` fails for every
41
+ weighting scheme. Four lines now index the scalar out explicitly: ``cte2``,
42
+ ``cte``, ``slope``, ``quad``.
43
+
44
+ 2. **``sft`` replaced by a vectorised equivalent, dropping numba.** Upstream's
45
+ ``sft`` is a Goertzel recursion decorated with ``@njit``; it is called
46
+ ``kspec * nxi`` times, so without numba it would be unusably slow. It
47
+ computes a single-frequency DFT, ``sum_j x[j] exp(-i w j)``, and the whole
48
+ ``Vj`` block collapses to one complex matmul. Verified against upstream to
49
+ 1e-12 absolute over 300 randomised cases — the residual is the recursion's
50
+ own accumulation error, so the form here is the more accurate of the two.
51
+ This removes numba from the dependency graph entirely.
52
+
53
+ 3. **The ``nfft``-long solve loop is unchanged.** It is the slow part and it
54
+ vectorises, but leaving it identical keeps the diff against upstream
55
+ readable. Optimise it only with the cross-validation test in place.
56
+
57
+ 4. The ``Cjk``/``Pjk`` construction is vectorised out of its double loop,
58
+ ``spec`` is dropped from the signature (upstream accepts it and never reads
59
+ it), the eigenvalue warning goes through ``warnings`` rather than ``print``,
60
+ and the unused ``cte``/``sigma2``/``cte_var``/``slope_var`` buffers are gone.
61
+
62
+ 5. Formatting and type annotations.
63
+
64
+ References
65
+ ----------
66
+ Prieto, G.A., Parker, R.L., Thomson, D.J., Vernon, F.L., Graham, R.L. (2007).
67
+ Reducing the bias of multitaper spectrum estimates.
68
+ *Geophysical Journal International* 171(3), 1269-1281.
69
+
70
+ Thomson, D.J. (1990). Quadratic-inverse spectrum estimates: applications to
71
+ palaeoclimatology. *Phil. Trans. R. Soc. Lond. A* 332, 539-597.
72
+ """
73
+
74
+ from __future__ import annotations
75
+
76
+ import warnings
77
+
78
+ import numpy as np
79
+ import scipy.linalg
80
+ import scipy.optimize
81
+ from numpy.typing import NDArray
82
+
83
+ __all__ = ["qiinv"]
84
+
85
+
86
+ def _single_frequency_dft(
87
+ tapers: NDArray[np.float64], omega: NDArray[np.float64]
88
+ ) -> NDArray[np.complex128]:
89
+ """``sum_j tapers[j, k] exp(-i omega_i j)`` for every ``(i, k)``.
90
+
91
+ Replaces upstream's ``sft`` Goertzel recursion (see change 2 above). The
92
+ frequencies here are a handful of points inside the inner band, not an FFT
93
+ grid, so there is nothing to gain from an FFT and a direct sum is both
94
+ exact and fast enough as one matmul.
95
+ """
96
+ phase = np.exp(-1j * np.outer(omega, np.arange(tapers.shape[0])))
97
+ result: NDArray[np.complex128] = phase @ tapers
98
+ return result
99
+
100
+
101
+ def qiinv(
102
+ yk: NDArray[np.complex128],
103
+ wt: NDArray[np.float64],
104
+ vn: NDArray[np.float64],
105
+ lamb: NDArray[np.float64],
106
+ nw: float,
107
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]:
108
+ """Quadratic inverse spectrum estimate, after Prieto et al. (2007).
109
+
110
+ Estimates the spectrum's first two derivatives inside the inner band and
111
+ subtracts the bias that curvature — the second derivative — introduces into
112
+ an ordinary multitaper estimate. That bias is largest exactly where the
113
+ spectrum bends most sharply, which for a Brune source is the corner.
114
+
115
+ Parameters
116
+ ----------
117
+ yk
118
+ Eigencoefficients, shape ``(nfft, kspec)``: the **two-sided** FFT of
119
+ each tapered copy of the record, in numpy's FFT ordering.
120
+ wt
121
+ Per-taper, per-frequency weights, shape ``(nfft, kspec)``. Pass ones
122
+ for flat weighting.
123
+ vn
124
+ DPSS tapers, shape ``(npts, kspec)``. Note the orientation: this is the
125
+ transpose of what :func:`scipy.signal.windows.dpss` returns.
126
+ lamb
127
+ Taper eigenvalues (concentration ratios), shape ``(kspec,)``.
128
+ nw
129
+ The time-bandwidth product.
130
+
131
+ Returns
132
+ -------
133
+ qispec
134
+ The quadratic estimate, shape ``(nfft,)``, in the units of
135
+ ``|yk|**2``.
136
+ slope, quad
137
+ First and second derivative of the spectrum with respect to frequency.
138
+ Returned because they are diagnostics in their own right — ``quad`` is
139
+ what the correction is built from, so a caller can see how much work it
140
+ did.
141
+ """
142
+ npts, kspec = np.shape(vn)
143
+ nfft = np.shape(yk)[0]
144
+ nxi = 79
145
+ n_cross = kspec * kspec
146
+
147
+ if np.min(lamb) < 0.9:
148
+ # Upstream prints; a caller of ours has no console to watch.
149
+ warnings.warn(
150
+ f"Poorest taper eigenvalue is {np.min(lamb):.4f} (< 0.9), so the "
151
+ f"higher-order tapers leak badly and the quadratic estimate will "
152
+ f"inherit that. Reduce n_tapers or raise time_bandwidth.",
153
+ RuntimeWarning,
154
+ stacklevel=2,
155
+ )
156
+
157
+ # ---------------------------------------------- inner-band frequencies
158
+ bp = nw / npts # half-bandwidth W
159
+ xi = np.linspace(-bp, bp, num=nxi)
160
+ dxi = xi[2] - xi[1]
161
+
162
+ xk = wt * yk
163
+ vj = _single_frequency_dft(vn, 2.0 * np.pi * xi) / np.sqrt(lamb)
164
+
165
+ # ------------------------- vectorised Cjk (data) and Pjk = {Vj Vk*}
166
+ i_idx, k_idx = np.divmod(np.arange(n_cross), kspec)
167
+ cross = np.conjugate(xk[:, i_idx]) * xk[:, k_idx] # (nfft, L)
168
+ proj = np.conjugate(vj[:, i_idx]) * vj[:, k_idx] # (nxi, L)
169
+
170
+ cross = cross.T # (L, nfft), as upstream orders it
171
+ proj = proj.T # (L, nxi)
172
+ proj[:, 0] *= 0.5 # trapezoid end weights
173
+ proj[:, nxi - 1] *= 0.5
174
+
175
+ # ------------------ Chebyshev basis: constant, slope, curvature
176
+ hcte = np.ones(nxi)
177
+ hslope = xi / bp
178
+ hquad = 2.0 * (xi / bp) ** 2 - 1.0
179
+
180
+ h1 = (proj @ hcte) * dxi
181
+ hk = np.empty((n_cross, 3), dtype=complex)
182
+ hk[:, 0] = h1
183
+ hk[:, 1] = (proj @ hslope) * dxi
184
+ hk[:, 2] = (proj @ hquad) * dxi
185
+ nh = hk.shape[1]
186
+
187
+ # --------------------------- least squares via QR, factored once
188
+ q_mat, r_mat = scipy.linalg.qr(hk)
189
+ qt = np.transpose(q_mat)
190
+ ri = scipy.linalg.lstsq(r_mat, np.eye(n_cross))[0]
191
+ covb = np.real(ri @ np.transpose(ri))
192
+
193
+ cte2 = np.zeros(nfft)
194
+ slope = np.zeros(nfft)
195
+ quad = np.zeros(nfft)
196
+ quad_var = np.zeros(nfft)
197
+
198
+ h1_real = np.real(h1)[:, None]
199
+ for i in range(nfft):
200
+ cjk = cross[:, i : i + 1]
201
+
202
+ # Constrain the constant term to be non-negative: a power spectrum
203
+ # cannot go below zero, and the unconstrained fit will happily do so.
204
+ cte2[i] = np.real(scipy.optimize.nnls(h1_real, np.real(cjk[:, 0]))[0])[0]
205
+
206
+ # Solve the derivatives against what the constant term left behind.
207
+ residual = cjk - h1_real * cte2[i]
208
+ hmodel = scipy.linalg.lstsq(r_mat, qt @ residual)[0]
209
+ slope[i] = -np.real(hmodel[1])[0]
210
+ quad[i] = np.real(hmodel[2])[0]
211
+
212
+ pred = hk @ np.real(hmodel)
213
+ sigma2 = np.sum(np.abs(residual - pred) ** 2) / (n_cross - nh)
214
+ quad_var[i] = sigma2 * covb[2, 2]
215
+
216
+ slope = slope / bp
217
+ quad = quad / bp**2
218
+ quad_var = quad_var / bp**4
219
+
220
+ # Damp the correction where the curvature estimate is itself noisy, so a
221
+ # poorly-determined second derivative cannot drag the spectrum around.
222
+ #
223
+ # Guarded against a zero or non-finite denominator, which upstream divides
224
+ # through blindly and so returns NaN for the whole spectrum. The case that
225
+ # matters is 0/0: a dead channel or a zero-filled gap demeans to all zeros,
226
+ # every cross-spectrum is zero, and NaN from here propagates silently into
227
+ # a fit. Weight zero is the right answer — no usable curvature information
228
+ # means apply no correction.
229
+ #
230
+ # The non-finite arm only triggers for amplitudes around 1e150, where the
231
+ # squares approach the top of float64. It is cheap insurance rather than a
232
+ # real case: above roughly 1e155 ``scipy.optimize.nnls`` rejects the input
233
+ # outright, so the failure is loud either way.
234
+ denominator = quad**2 + quad_var
235
+ weight = np.divide(
236
+ quad**2,
237
+ denominator,
238
+ out=np.zeros_like(denominator),
239
+ where=np.isfinite(denominator) & (denominator > 0.0),
240
+ )
241
+ qispec = cte2 - weight * (1.0 / 6.0) * bp**2 * quad
242
+
243
+ return qispec, slope, quad
specmod/acquire.py ADDED
@@ -0,0 +1,358 @@
1
+ """Fetch an event from an FDSN data centre into the layout tests and users read.
2
+
3
+ The request is declared in TOML and the response is written as an
4
+ :class:`specmod.datasets.EventDirectory`, beside a manifest recording what was
5
+ asked for and what came back::
6
+
7
+ from specmod.acquire import fetch
8
+ fetch("datasets/pnr_2019.toml", out="build/pnr_2019")
9
+
10
+ or ``specmod fetch datasets/pnr_2019.toml -o build/pnr_2019``.
11
+
12
+ **Waveforms are stored raw.** Counts and the response, never a deconvolved
13
+ trace: baking ``remove_response`` into the artefact takes it out of test
14
+ coverage and freezes one ObsPy version's behaviour into the fixture.
15
+
16
+ **A config makes the request reproducible, not the response.** FDSN is not
17
+ content-addressed — responses are corrected retroactively, archives are
18
+ backfilled, catalogue solutions revised. That is what :func:`verify` and the
19
+ manifest are for, and why published artefacts are pinned by hash rather than
20
+ re-fetched. See §5.2.2 of ``docs/REFACTOR_PLAN.md``.
21
+
22
+ Every network call goes through the ``client`` argument, which defaults to an
23
+ ObsPy FDSN client and is injected in tests. Nothing here calls the network on
24
+ import.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import hashlib
30
+ import json
31
+ import tomllib
32
+ from dataclasses import dataclass, field, replace
33
+ from datetime import UTC, datetime
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+ from . import __version__
38
+ from .datasets import Event, EventDirectory
39
+
40
+ __all__ = [
41
+ "AcquisitionConfig",
42
+ "EventSpec",
43
+ "StationSpec",
44
+ "WindowSpec",
45
+ "fetch",
46
+ "read_config",
47
+ "verify",
48
+ ]
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class EventSpec:
53
+ """Which earthquake, and where its parameters come from.
54
+
55
+ ``eventid`` resolves the hypocentre from the data centre's catalogue, which
56
+ is preferable to retyping it: a retyped origin is a second source of truth
57
+ that can disagree with the catalogue silently. The explicit fields are for
58
+ events the catalogue does not carry — induced sequences monitored privately,
59
+ most often — and one of the two must be given.
60
+ """
61
+
62
+ eventid: str | None = None
63
+ #: FDSN service to resolve ``eventid`` against, when it is not the one
64
+ #: serving the waveforms. Event ids are issued per catalogue — a USGS
65
+ #: ComCat id means nothing to IRIS — so the two are genuinely separable
66
+ #: and the config has to be able to say so.
67
+ catalogue: str | None = None
68
+ origin: str | None = None
69
+ latitude: float | None = None
70
+ longitude: float | None = None
71
+ depth_km: float | None = None
72
+ catalogue_magnitude: float | None = None
73
+ catalogue_magnitude_type: str | None = None
74
+
75
+ def __post_init__(self) -> None:
76
+ explicit = (self.origin, self.latitude, self.longitude, self.depth_km)
77
+ if self.eventid is None and any(v is None for v in explicit):
78
+ raise ValueError(
79
+ "an event needs either `eventid`, to resolve from the "
80
+ "catalogue, or all of `origin`, `latitude`, `longitude` and "
81
+ "`depth_km`"
82
+ )
83
+
84
+ def resolved(self) -> Event:
85
+ """The :class:`~specmod.datasets.Event` these fields describe."""
86
+ if self.origin is None:
87
+ raise ValueError(
88
+ "this event is declared by eventid and has not been resolved "
89
+ "against a catalogue yet"
90
+ )
91
+ assert self.latitude is not None
92
+ assert self.longitude is not None
93
+ assert self.depth_km is not None
94
+ return Event(
95
+ origin=self.origin,
96
+ latitude=self.latitude,
97
+ longitude=self.longitude,
98
+ depth_km=self.depth_km,
99
+ catalogue_magnitude=self.catalogue_magnitude,
100
+ catalogue_magnitude_type=self.catalogue_magnitude_type,
101
+ )
102
+
103
+
104
+ @dataclass(frozen=True)
105
+ class StationSpec:
106
+ """Which channels to ask for.
107
+
108
+ The patterns are FDSN wildcards, so the config alone does not say what you
109
+ got — which is why the manifest records the channel list after expansion.
110
+ """
111
+
112
+ network: str = "*"
113
+ station: str = "*"
114
+ location: str = "*"
115
+ channel: str = "*"
116
+ #: Kilometres from the epicentre. ``None`` means no limit.
117
+ max_radius_km: float | None = None
118
+ min_radius_km: float | None = None
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class WindowSpec:
123
+ """How much record to take, relative to the origin time."""
124
+
125
+ before_origin_s: float = 10.0
126
+ after_origin_s: float = 120.0
127
+
128
+ def __post_init__(self) -> None:
129
+ if self.before_origin_s + self.after_origin_s <= 0:
130
+ raise ValueError("the window must have positive length")
131
+
132
+
133
+ @dataclass(frozen=True)
134
+ class AcquisitionConfig:
135
+ """A complete, declarative description of one fetch."""
136
+
137
+ name: str
138
+ #: FDSN data centre, by short name (``"IRIS"``) or base URL. Recorded in
139
+ #: the manifest because different centres serve different holdings for the
140
+ #: same event.
141
+ data_centre: str = "IRIS"
142
+ event: EventSpec = field(default_factory=EventSpec)
143
+ stations: StationSpec = field(default_factory=StationSpec)
144
+ window: WindowSpec = field(default_factory=WindowSpec)
145
+ #: The TOML this was parsed from, kept verbatim for the manifest.
146
+ source_toml: str = ""
147
+
148
+ @classmethod
149
+ def from_dict(
150
+ cls, data: dict[str, Any], *, source_toml: str = ""
151
+ ) -> AcquisitionConfig:
152
+ known = {"name", "data_centre", "event", "stations", "window"}
153
+ unknown = set(data) - known
154
+ if unknown:
155
+ raise ValueError(
156
+ f"unknown key(s) in acquisition config: {sorted(unknown)}. "
157
+ f"Known: {sorted(known)}"
158
+ )
159
+ if "name" not in data:
160
+ raise ValueError("an acquisition config needs a `name`")
161
+ return cls(
162
+ name=str(data["name"]),
163
+ data_centre=str(data.get("data_centre", "IRIS")),
164
+ event=EventSpec(**data.get("event", {})),
165
+ stations=StationSpec(**data.get("stations", {})),
166
+ window=WindowSpec(**data.get("window", {})),
167
+ source_toml=source_toml,
168
+ )
169
+
170
+
171
+ def read_config(path: str | Path) -> AcquisitionConfig:
172
+ """Parse an acquisition config, keeping the text for the manifest."""
173
+ text = Path(path).read_text()
174
+ return AcquisitionConfig.from_dict(tomllib.loads(text), source_toml=text)
175
+
176
+
177
+ def _sha256(path: Path) -> str:
178
+ digest = hashlib.sha256()
179
+ with path.open("rb") as handle:
180
+ for block in iter(lambda: handle.read(1 << 20), b""):
181
+ digest.update(block)
182
+ return digest.hexdigest()
183
+
184
+
185
+ def _default_client(data_centre: str) -> Any:
186
+ from obspy.clients.fdsn import Client # noqa: PLC0415
187
+
188
+ return Client(data_centre)
189
+
190
+
191
+ def _resolve_event(
192
+ config: AcquisitionConfig, client: Any, event_client: Any = None
193
+ ) -> tuple[EventSpec, Any]:
194
+ """Fill in the hypocentre from the catalogue when only an id was given.
195
+
196
+ Returns the resolved spec **and the catalogue it came from**, so the caller
197
+ can write the QuakeML rather than keeping only the handful of numbers this
198
+ reads off it.
199
+ """
200
+ if config.event.eventid is None:
201
+ return config.event, None
202
+
203
+ if event_client is None:
204
+ event_client = (
205
+ client
206
+ if config.event.catalogue in (None, config.data_centre)
207
+ else _default_client(config.event.catalogue)
208
+ )
209
+ catalogue = event_client.get_events(eventid=config.event.eventid)
210
+ if len(catalogue) != 1:
211
+ raise ValueError(
212
+ f"eventid {config.event.eventid!r} matched {len(catalogue)} events; "
213
+ f"it must identify exactly one"
214
+ )
215
+ origin = catalogue[0].preferred_origin() or catalogue[0].origins[0]
216
+ magnitude = catalogue[0].preferred_magnitude()
217
+ magnitudes = catalogue[0].magnitudes
218
+ if magnitude is None and magnitudes:
219
+ magnitude = magnitudes[0]
220
+
221
+ resolved = replace(
222
+ config.event,
223
+ origin=str(origin.time),
224
+ latitude=float(origin.latitude),
225
+ longitude=float(origin.longitude),
226
+ depth_km=float(origin.depth) / 1000.0,
227
+ catalogue_magnitude=None if magnitude is None else float(magnitude.mag),
228
+ catalogue_magnitude_type=(
229
+ None if magnitude is None else str(magnitude.magnitude_type)
230
+ ),
231
+ )
232
+ return resolved, catalogue
233
+
234
+
235
+ def fetch(
236
+ config: str | Path | AcquisitionConfig,
237
+ out: str | Path,
238
+ *,
239
+ client: Any = None,
240
+ event_client: Any = None,
241
+ ) -> dict[str, Any]:
242
+ """Fetch one event and write it as an :class:`EventDirectory`.
243
+
244
+ Returns the manifest, which is also written to ``manifest.json`` beside the
245
+ data. ``client`` accepts anything with the ObsPy FDSN client's
246
+ ``get_events``, ``get_stations`` and ``get_waveforms`` methods; tests pass a
247
+ fake so that no test touches the network.
248
+ """
249
+ import obspy # noqa: PLC0415
250
+
251
+ if not isinstance(config, AcquisitionConfig):
252
+ config = read_config(config)
253
+ if client is None:
254
+ client = _default_client(config.data_centre)
255
+
256
+ spec, catalogue = _resolve_event(config, client, event_client)
257
+ event = spec.resolved()
258
+ origin_time = obspy.UTCDateTime(event.origin)
259
+ start = origin_time - config.window.before_origin_s
260
+ end = origin_time + config.window.after_origin_s
261
+
262
+ station_kwargs: dict[str, Any] = {
263
+ "network": config.stations.network,
264
+ "station": config.stations.station,
265
+ "location": config.stations.location,
266
+ "channel": config.stations.channel,
267
+ "starttime": start,
268
+ "endtime": end,
269
+ "level": "response",
270
+ }
271
+ if config.stations.max_radius_km is not None:
272
+ station_kwargs["latitude"] = event.latitude
273
+ station_kwargs["longitude"] = event.longitude
274
+ station_kwargs["maxradius"] = config.stations.max_radius_km / 111.195
275
+ if config.stations.min_radius_km is not None:
276
+ station_kwargs["minradius"] = config.stations.min_radius_km / 111.195
277
+
278
+ inventory = client.get_stations(**station_kwargs)
279
+ stream = client.get_waveforms(
280
+ network=config.stations.network,
281
+ station=config.stations.station,
282
+ location=config.stations.location,
283
+ channel=config.stations.channel,
284
+ starttime=start,
285
+ endtime=end,
286
+ )
287
+
288
+ paths = EventDirectory(Path(out) / event.origin)
289
+ paths.waveforms.mkdir(parents=True, exist_ok=True)
290
+ paths.stations.mkdir(parents=True, exist_ok=True)
291
+ paths.picks.mkdir(parents=True, exist_ok=True)
292
+
293
+ written: dict[str, str] = {}
294
+ for trace in stream:
295
+ # One file per channel, named as the shipped data is, so a fetched
296
+ # event and a committed one are read by the same code.
297
+ name = f"{trace.id}_{trace.stats.starttime}"
298
+ target = paths.waveforms / name
299
+ trace.write(str(target), format="MSEED")
300
+ written[str(target.relative_to(Path(out)))] = _sha256(target)
301
+
302
+ inventory.write(str(paths.inventory), format="STATIONXML")
303
+ written[str(paths.inventory.relative_to(Path(out)))] = _sha256(paths.inventory)
304
+
305
+ # The catalogue in full, not just the six numbers read off it above.
306
+ # Origin uncertainties, every magnitude rather than the preferred one,
307
+ # agency and evaluation status are all in here and nowhere else.
308
+ if catalogue is not None:
309
+ catalogue.write(str(paths.quakeml), format="QUAKEML")
310
+ written[str(paths.quakeml.relative_to(Path(out)))] = _sha256(paths.quakeml)
311
+
312
+ manifest = {
313
+ "name": config.name,
314
+ "fetched_at": datetime.now(UTC).isoformat(),
315
+ "data_centre": config.data_centre,
316
+ "event_catalogue": config.event.catalogue or config.data_centre,
317
+ "specmod_version": __version__,
318
+ "obspy_version": obspy.__version__,
319
+ "config": config.source_toml,
320
+ "quakeml": catalogue is not None,
321
+ "resolved": {
322
+ "origin": event.origin,
323
+ "latitude": event.latitude,
324
+ "longitude": event.longitude,
325
+ "depth_km": event.depth_km,
326
+ "catalogue_magnitude": event.catalogue_magnitude,
327
+ "catalogue_magnitude_type": event.catalogue_magnitude_type,
328
+ "eventid": spec.eventid,
329
+ # After wildcard expansion: the config alone does not say what the
330
+ # request actually returned.
331
+ "channels": sorted({trace.id for trace in stream}),
332
+ "window": {"start": str(start), "end": str(end)},
333
+ },
334
+ "files": dict(sorted(written.items())),
335
+ }
336
+ (Path(out) / "manifest.json").write_text(json.dumps(manifest, indent=1) + "\n")
337
+ return manifest
338
+
339
+
340
+ def verify(out: str | Path) -> list[str]:
341
+ """Re-hash what is on disk and report anything that no longer matches.
342
+
343
+ Integrity only: it says whether the files changed since they were written,
344
+ not whether the data centre has revised its holdings. That needs a re-fetch
345
+ and a diff against the manifest, which is the fuller ``--verify`` §5.2.2
346
+ describes and which needs the network.
347
+ """
348
+ root = Path(out)
349
+ manifest = json.loads((root / "manifest.json").read_text())
350
+
351
+ problems = []
352
+ for name, digest in manifest["files"].items():
353
+ path = root / name
354
+ if not path.is_file():
355
+ problems.append(f"missing: {name}")
356
+ elif _sha256(path) != digest:
357
+ problems.append(f"changed: {name}")
358
+ return problems