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/__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
|
specmod/_vendor/qiinv.py
ADDED
|
@@ -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
|