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
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Quadratic multitaper estimation (Prieto et al. 2007).
|
|
2
|
+
|
|
3
|
+
An ordinary multitaper estimate averages over ``K`` tapers, each of which
|
|
4
|
+
smooths the spectrum across the inner band ``[-W, W]``. Where the true spectrum
|
|
5
|
+
is curved, that smoothing does not average out: it pulls peaks down and fills
|
|
6
|
+
troughs in, by an amount proportional to the second derivative. The quadratic
|
|
7
|
+
inverse method estimates that curvature and subtracts the bias it causes.
|
|
8
|
+
|
|
9
|
+
Measured on a pure line, where the true Fourier amplitude ``A*T/2`` is known
|
|
10
|
+
exactly: the ordinary estimate recovers 0.87 of the peak, this one 1.02. It
|
|
11
|
+
also separates two lines just past the resolution limit with about 1.4x the
|
|
12
|
+
peak-to-trough contrast. On white noise — no curvature to correct — it agrees
|
|
13
|
+
with the ordinary estimate to within a few percent, which is the control that
|
|
14
|
+
says it is not simply sharpening everything.
|
|
15
|
+
|
|
16
|
+
.. note::
|
|
17
|
+
|
|
18
|
+
**On a corner frequency it is only a marginal improvement.** Over 40
|
|
19
|
+
realisations of a true 4 Hz Brune corner: ordinary multitaper biases -0.087
|
|
20
|
+
and this +0.077, with the same scatter (IQR 0.133 against 0.139), so the two
|
|
21
|
+
are indistinguishable once both are counted. A lightly-tapered FFT beats
|
|
22
|
+
both (bias -0.020, IQR 0.055) and is around 100x faster. Reach for this
|
|
23
|
+
estimator when the feature of interest is a peak or a line, not to squeeze a
|
|
24
|
+
corner.
|
|
25
|
+
|
|
26
|
+
.. warning::
|
|
27
|
+
|
|
28
|
+
``qiinv`` builds cross-spectra from ``wt * yk`` and never divides by
|
|
29
|
+
``sum(w**2)``, so **adaptive weights must be renormalised before they are
|
|
30
|
+
handed to it** — see :meth:`QuadraticMultitaperEstimator.estimate`. Omitting
|
|
31
|
+
that scales the estimate down by ``sum(w**2)/K`` wherever the weights bite,
|
|
32
|
+
which on a Brune spectrum is 0.57 in the tail and reads convincingly like a
|
|
33
|
+
curvature artefact. The tell is that it vanishes under flat weighting.
|
|
34
|
+
|
|
35
|
+
Upstream omits this and leans on a global variance rescaling that cannot
|
|
36
|
+
compensate, because the deficit varies with frequency — so results taken
|
|
37
|
+
from ``multitaper``'s own ``MTSpec.qiinv`` carry it.
|
|
38
|
+
|
|
39
|
+
The cost is a per-frequency least-squares solve, so this is roughly two orders
|
|
40
|
+
of magnitude slower than :class:`~specmod.transforms.multitaper.MultitaperEstimator`.
|
|
41
|
+
It is not the estimator to reach for on a whole catalogue.
|
|
42
|
+
|
|
43
|
+
Implementation
|
|
44
|
+
--------------
|
|
45
|
+
The numerical core is vendored from Prieto's ``multitaper`` package rather than
|
|
46
|
+
reimplemented — see :mod:`specmod._vendor.qiinv` for the licence, the changes
|
|
47
|
+
made, and why. This module owns the part that faces SpecMod: DPSS tapers,
|
|
48
|
+
Thomson weights, and the units contract that every estimator here satisfies.
|
|
49
|
+
|
|
50
|
+
References
|
|
51
|
+
----------
|
|
52
|
+
Prieto, G.A., Parker, R.L., Thomson, D.J., Vernon, F.L., Graham, R.L. (2007).
|
|
53
|
+
Reducing the bias of multitaper spectrum estimates.
|
|
54
|
+
*Geophysical Journal International* 171(3), 1269-1281.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
from __future__ import annotations
|
|
58
|
+
|
|
59
|
+
from dataclasses import dataclass
|
|
60
|
+
from typing import Any
|
|
61
|
+
|
|
62
|
+
import numpy as np
|
|
63
|
+
from numpy.typing import ArrayLike, NDArray
|
|
64
|
+
from scipy.signal.windows import dpss
|
|
65
|
+
|
|
66
|
+
from .._vendor.qiinv import qiinv
|
|
67
|
+
from ..core.spectrum import Spectrum
|
|
68
|
+
from ..core.units import AmplitudeKind, Motion
|
|
69
|
+
from .base import build_spectrum, prepare_record
|
|
70
|
+
from .multitaper import _adaptive_weights, center_on_energy_centroid
|
|
71
|
+
|
|
72
|
+
__all__ = ["QuadraticMultitaperEstimator"]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class QuadraticMultitaperEstimator:
|
|
77
|
+
"""Curvature-corrected multitaper spectrum estimate.
|
|
78
|
+
|
|
79
|
+
Parameters
|
|
80
|
+
----------
|
|
81
|
+
time_bandwidth, n_tapers
|
|
82
|
+
As :class:`~specmod.transforms.multitaper.MultitaperEstimator`. The
|
|
83
|
+
correction scales with ``W = NW/N``, so a larger time-bandwidth product
|
|
84
|
+
means more smoothing to undo and a correspondingly larger correction.
|
|
85
|
+
adaptive
|
|
86
|
+
Whether the eigencoefficients fed to the curvature fit are weighted by
|
|
87
|
+
Thomson's adaptive scheme. Applies to the input weights only; the
|
|
88
|
+
quadratic step itself is unweighted.
|
|
89
|
+
normalize_to_variance
|
|
90
|
+
Rescale so the spectrum integrates to the record variance, as ``mtspec``
|
|
91
|
+
and Prieto's package do. Off by default, for the reason given in
|
|
92
|
+
:class:`~specmod.transforms.multitaper.MultitaperEstimator`.
|
|
93
|
+
|
|
94
|
+
Note that Prieto's ``MTSpec.qiinv()`` applies this unconditionally, and
|
|
95
|
+
to an already-renormalised input, so reproducing that path needs it on.
|
|
96
|
+
center
|
|
97
|
+
Circularly shift the record so its energy centroid sits mid-window.
|
|
98
|
+
Same rationale and same wrap check as the ordinary estimator.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
time_bandwidth: float = 3.0
|
|
102
|
+
n_tapers: int = 5
|
|
103
|
+
adaptive: bool = True
|
|
104
|
+
center: bool = False
|
|
105
|
+
center_edge_tolerance: float = 0.05
|
|
106
|
+
normalize_to_variance: bool = False
|
|
107
|
+
drop_dc: bool = True
|
|
108
|
+
name: str = "quadratic"
|
|
109
|
+
|
|
110
|
+
def __post_init__(self) -> None:
|
|
111
|
+
if self.time_bandwidth <= 0:
|
|
112
|
+
raise ValueError(
|
|
113
|
+
f"time_bandwidth must be positive, got {self.time_bandwidth}"
|
|
114
|
+
)
|
|
115
|
+
limit = int(2 * self.time_bandwidth - 1)
|
|
116
|
+
if self.n_tapers < 1:
|
|
117
|
+
raise ValueError(f"n_tapers must be at least 1, got {self.n_tapers}")
|
|
118
|
+
if self.n_tapers > limit:
|
|
119
|
+
raise ValueError(
|
|
120
|
+
f"n_tapers={self.n_tapers} exceeds 2*NW-1={limit} for "
|
|
121
|
+
f"time_bandwidth={self.time_bandwidth}. Tapers beyond that are "
|
|
122
|
+
f"poorly concentrated and add leakage rather than reducing "
|
|
123
|
+
f"variance; raise time_bandwidth or lower n_tapers."
|
|
124
|
+
)
|
|
125
|
+
if self.n_tapers < 2:
|
|
126
|
+
raise ValueError(
|
|
127
|
+
"The quadratic estimate fits three basis functions to the "
|
|
128
|
+
"cross-spectra of K tapers, so it needs at least 2 tapers "
|
|
129
|
+
"(K**2 = 4 equations) to be determined at all. Use "
|
|
130
|
+
"MultitaperEstimator for a single-taper estimate."
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def estimate(
|
|
134
|
+
self,
|
|
135
|
+
data: ArrayLike,
|
|
136
|
+
dt: float,
|
|
137
|
+
*,
|
|
138
|
+
motion: Motion | str = Motion.VELOCITY,
|
|
139
|
+
meta: dict[str, Any] | None = None,
|
|
140
|
+
) -> Spectrum:
|
|
141
|
+
x, n, duration = prepare_record(data, dt)
|
|
142
|
+
if self.center:
|
|
143
|
+
x = center_on_energy_centroid(x, self.center_edge_tolerance)
|
|
144
|
+
|
|
145
|
+
tapers, eigenvalues = dpss(
|
|
146
|
+
n, self.time_bandwidth, self.n_tapers, sym=False, return_ratios=True
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# qiinv works on the full two-sided transform: it forms cross-spectra
|
|
150
|
+
# between tapers and needs the negative frequencies to do it.
|
|
151
|
+
yk = np.fft.fft(tapers * x, axis=-1)
|
|
152
|
+
eigenspectra = np.abs(yk) ** 2
|
|
153
|
+
|
|
154
|
+
if self.adaptive:
|
|
155
|
+
weights = _adaptive_weights(eigenspectra, eigenvalues, n)
|
|
156
|
+
# qiinv forms cross-spectra of ``wt * yk`` and never divides by
|
|
157
|
+
# ``sum(w**2)``, so its diagonal averages to ``(1/K) sum(w**2 |y|**2)``
|
|
158
|
+
# where the adaptive estimate is ``sum(w**2 |y|**2) / sum(w**2)``.
|
|
159
|
+
# Renormalising per frequency so ``sum(w**2) = K`` makes the two
|
|
160
|
+
# agree. Without it the estimate is scaled down by ``sum(w**2)/K``
|
|
161
|
+
# wherever the weights bite — 0.57 in the high-frequency tail of a
|
|
162
|
+
# Brune spectrum, which reads exactly like a curvature artefact and
|
|
163
|
+
# is not one. Upstream leaves this out and relies on its global
|
|
164
|
+
# variance renormalisation to hide it, which cannot: the deficit is
|
|
165
|
+
# frequency-dependent.
|
|
166
|
+
scale = self.n_tapers / np.maximum((weights**2).sum(axis=0), 1e-300)
|
|
167
|
+
weights = weights * np.sqrt(scale)
|
|
168
|
+
else:
|
|
169
|
+
weights = np.ones_like(eigenspectra)
|
|
170
|
+
|
|
171
|
+
qispec, _slope, curvature = qiinv(
|
|
172
|
+
yk.T, weights.T, tapers.T, eigenvalues, self.time_bandwidth
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# qispec is in |FFT|**2; the same dt that makes an ordinary
|
|
176
|
+
# eigenspectrum a PSD applies here, and for the same reason.
|
|
177
|
+
psd = qispec * dt
|
|
178
|
+
|
|
179
|
+
# The correction is a subtraction and can in principle overshoot into
|
|
180
|
+
# negative power where curvature is large and poorly determined. The
|
|
181
|
+
# damping inside qiinv makes that rare, but a negative PSD is not a
|
|
182
|
+
# spectrum, so clip and say so rather than emit sqrt of a negative.
|
|
183
|
+
negative = int((psd < 0).sum())
|
|
184
|
+
if negative:
|
|
185
|
+
psd = np.maximum(psd, 0.0)
|
|
186
|
+
|
|
187
|
+
freq: NDArray[np.float64] = np.fft.rfftfreq(n, d=dt).astype(np.float64)
|
|
188
|
+
psd = psd[: freq.size].copy()
|
|
189
|
+
psd[1:] *= 2.0 # fold negative frequencies
|
|
190
|
+
if n % 2 == 0:
|
|
191
|
+
psd[-1] /= 2.0
|
|
192
|
+
|
|
193
|
+
if self.normalize_to_variance:
|
|
194
|
+
df = float(freq[1] - freq[0])
|
|
195
|
+
total = float(psd.sum() * df)
|
|
196
|
+
if total > 0:
|
|
197
|
+
psd = psd * (float(x.var()) / total)
|
|
198
|
+
|
|
199
|
+
if self.drop_dc:
|
|
200
|
+
freq, psd = freq[1:], psd[1:]
|
|
201
|
+
|
|
202
|
+
spectrum = build_spectrum(
|
|
203
|
+
freq,
|
|
204
|
+
psd,
|
|
205
|
+
kind=AmplitudeKind.PSD,
|
|
206
|
+
motion=motion,
|
|
207
|
+
duration=duration,
|
|
208
|
+
sampling_rate=1.0 / dt,
|
|
209
|
+
meta={
|
|
210
|
+
**(meta or {}),
|
|
211
|
+
"time_bandwidth": self.time_bandwidth,
|
|
212
|
+
"n_tapers": self.n_tapers,
|
|
213
|
+
"adaptive": self.adaptive,
|
|
214
|
+
"centered": self.center,
|
|
215
|
+
"normalize_to_variance": self.normalize_to_variance,
|
|
216
|
+
"curvature_rms": float(np.sqrt(np.mean(curvature**2))),
|
|
217
|
+
"clipped_bins": negative,
|
|
218
|
+
},
|
|
219
|
+
estimator=self.name,
|
|
220
|
+
)
|
|
221
|
+
return spectrum.to_kind(AmplitudeKind.FAS)
|
specmod/utils.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Reading picks and catalogues, and looking at waveforms.
|
|
2
|
+
|
|
3
|
+
What is left after the migration. The rotation machinery that used to live here
|
|
4
|
+
— ``find_rotation_angle``, ``find_rotation_angle_v2``, ``rotate``,
|
|
5
|
+
``rotate_noise_full``, ``get_centroid_freq`` and ``non_lin_boost_noise_func`` —
|
|
6
|
+
is now :mod:`specmod.core.noise`, where the two methods are registered models
|
|
7
|
+
rather than an integer flag. The SAC-discovery scaffolding that used to sit
|
|
8
|
+
between them (``DataSet`` and its channel-ranking helpers, ``cps``,
|
|
9
|
+
``path_to_utc``) had no callers anywhere and was hardcoded to another study's
|
|
10
|
+
station codes; it is replaced by the acquisition layer in REFACTOR_PLAN §5.2.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import contextlib
|
|
16
|
+
import logging
|
|
17
|
+
import warnings
|
|
18
|
+
from typing import TYPE_CHECKING, Any
|
|
19
|
+
|
|
20
|
+
import matplotlib
|
|
21
|
+
import matplotlib.pyplot as plt
|
|
22
|
+
import numpy as np
|
|
23
|
+
import obspy
|
|
24
|
+
import pandas as pd
|
|
25
|
+
from matplotlib.dates import num2date
|
|
26
|
+
from obspy.core.event import Catalog, Event, WaveformStreamID
|
|
27
|
+
from obspy.core.event import Pick as ObsPyPick
|
|
28
|
+
|
|
29
|
+
from specmod.picks import PickSet, SnufflerReader, from_catalog, select_event
|
|
30
|
+
|
|
31
|
+
#: See the note in `specmod.fitting.event`: diagnostics go here, and nothing in
|
|
32
|
+
#: this package configures logging on a caller's behalf.
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
36
|
+
from collections.abc import Sequence
|
|
37
|
+
from datetime import datetime
|
|
38
|
+
from os import PathLike
|
|
39
|
+
|
|
40
|
+
from obspy import Stream, UTCDateTime
|
|
41
|
+
|
|
42
|
+
# Type 42 embeds fonts as editable text rather than outlines, so a figure can
|
|
43
|
+
# be relabelled in a vector editor after the fact. Set at import because it is
|
|
44
|
+
# a global, and this module is the one that draws.
|
|
45
|
+
matplotlib.rcParams["ps.fonttype"] = 42
|
|
46
|
+
matplotlib.rcParams["pdf.fonttype"] = 42
|
|
47
|
+
matplotlib.rcParams["svg.fonttype"] = "none"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _to_datetime(value: Any) -> Any:
|
|
51
|
+
"""``matplotlib.dates.num2date`` carries no annotations upstream.
|
|
52
|
+
|
|
53
|
+
Wrapped once rather than suppressed at each of its four call sites, so
|
|
54
|
+
there is one place to delete when matplotlib annotates it.
|
|
55
|
+
"""
|
|
56
|
+
return num2date(value) # type: ignore[no-untyped-call]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def read_pyrocko_picks(path: str | PathLike[str]) -> PickSet:
|
|
60
|
+
"""Snuffler marker file to a :class:`~specmod.picks.PickSet`.
|
|
61
|
+
|
|
62
|
+
Thin wrapper over :class:`specmod.picks.SnufflerReader`, which is where the
|
|
63
|
+
format lives.
|
|
64
|
+
"""
|
|
65
|
+
return SnufflerReader().read(path)[0]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def read_pyrocko(path: str | PathLike[str]) -> dict[str, dict[str, UTCDateTime]]:
|
|
69
|
+
"""Snuffler marker file to ``{"NET.STA.LOC": {"P": UTCDateTime, ...}}``.
|
|
70
|
+
|
|
71
|
+
Keyed **per sensor**, not per channel. An arrival is an observation of one
|
|
72
|
+
sensor, and the component it was picked on is incidental — on the shipped
|
|
73
|
+
PNR file every station has P on ``HHZ`` and S on ``HHN``, so keying by full
|
|
74
|
+
SEED id would leave the horizontals with no S at all.
|
|
75
|
+
|
|
76
|
+
The location code stays in the key, because that is what distinguishes two
|
|
77
|
+
sensors at one site. A surface and a borehole instrument share a station
|
|
78
|
+
name and see genuinely different arrivals; collapsing them would silently
|
|
79
|
+
give one the other's picks.
|
|
80
|
+
|
|
81
|
+
A flat mapping cannot express a partial identity or a second event; prefer
|
|
82
|
+
:func:`read_pyrocko_picks` with :func:`specmod.picks.resolve`.
|
|
83
|
+
"""
|
|
84
|
+
return read_pyrocko_picks(path).mapping()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def read_quakeml_picks(
|
|
88
|
+
source: str | PathLike[str] | Catalog,
|
|
89
|
+
) -> dict[str, dict[str, UTCDateTime]]:
|
|
90
|
+
"""QuakeML picks to ``{"NET.STA.LOC": {"P": UTCDateTime, ...}}``.
|
|
91
|
+
|
|
92
|
+
The same shape :func:`read_pyrocko` returns, so the two are
|
|
93
|
+
interchangeable at the call site. ``source`` is a path or an
|
|
94
|
+
``obspy.core.event.Catalog``.
|
|
95
|
+
|
|
96
|
+
Phase hints are normalised to ``P`` or ``S`` on their first letter, which
|
|
97
|
+
folds ``Pg``/``Pn``/``Pb`` and their S counterparts together. That matches
|
|
98
|
+
what this pipeline does with them — it wants a direct arrival, and the
|
|
99
|
+
branch distinction is not something the windowing uses.
|
|
100
|
+
|
|
101
|
+
Picks with an ``evaluation_status`` of ``rejected`` are dropped. Anything
|
|
102
|
+
else — reviewed, preliminary, unset — is kept, matching how the Snuffler
|
|
103
|
+
reader treats weights.
|
|
104
|
+
|
|
105
|
+
A multi-event source raises rather than merging. A flat mapping cannot
|
|
106
|
+
express a partial identity or a second event; prefer
|
|
107
|
+
:func:`specmod.picks.read` with :func:`specmod.picks.resolve`.
|
|
108
|
+
"""
|
|
109
|
+
catalog = source if isinstance(source, Catalog) else obspy.read_events(str(source))
|
|
110
|
+
return select_event(from_catalog(catalog)).mapping()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def picks_to_quakeml(
|
|
114
|
+
picks: dict[str, dict[str, UTCDateTime]],
|
|
115
|
+
*,
|
|
116
|
+
event_id: str | None = None,
|
|
117
|
+
) -> Catalog:
|
|
118
|
+
"""The inverse: a pick mapping as an ``obspy`` ``Catalog``.
|
|
119
|
+
|
|
120
|
+
Used to convert a Snuffler marker file to the standard format once, rather
|
|
121
|
+
than teaching every consumer both. The location code ``--`` is written back
|
|
122
|
+
as an empty string, which is what StationXML and miniSEED use.
|
|
123
|
+
"""
|
|
124
|
+
event = Event(resource_id=event_id) if event_id else Event()
|
|
125
|
+
for key, phases in sorted(picks.items()):
|
|
126
|
+
net, sta, loc = key.split(".")
|
|
127
|
+
for phase, time in sorted(phases.items()):
|
|
128
|
+
event.picks.append(
|
|
129
|
+
ObsPyPick(
|
|
130
|
+
time=time,
|
|
131
|
+
phase_hint=phase,
|
|
132
|
+
waveform_id=WaveformStreamID(
|
|
133
|
+
network_code=net,
|
|
134
|
+
station_code=sta,
|
|
135
|
+
location_code="" if loc == "--" else loc,
|
|
136
|
+
),
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
return Catalog(events=[event])
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def plot_traces(
|
|
143
|
+
st: Stream,
|
|
144
|
+
*,
|
|
145
|
+
plot_theoreticals: bool = False,
|
|
146
|
+
plot_windows: bool = False,
|
|
147
|
+
conv: float | None = 1e-9,
|
|
148
|
+
bft: float = 1,
|
|
149
|
+
aftt: float = 60,
|
|
150
|
+
sig: Stream | None = None,
|
|
151
|
+
noise: Stream | None = None,
|
|
152
|
+
save: str | None = None,
|
|
153
|
+
) -> None:
|
|
154
|
+
# `plot_windows` reads `sig[i]` and `noise[i]`. Without both it used to
|
|
155
|
+
# raise `TypeError: 'NoneType' object is not subscriptable` from inside the
|
|
156
|
+
# loop, naming neither the argument that was missing nor the option that
|
|
157
|
+
# required it.
|
|
158
|
+
if plot_windows and (sig is None or noise is None):
|
|
159
|
+
raise ValueError("plot_windows=True requires both sig and noise")
|
|
160
|
+
|
|
161
|
+
sharey = False
|
|
162
|
+
stc = stream_distance_sort(st)
|
|
163
|
+
if conv is None:
|
|
164
|
+
conv = 1
|
|
165
|
+
stc.normalize()
|
|
166
|
+
sharey = True
|
|
167
|
+
|
|
168
|
+
if sig is not None and noise is not None:
|
|
169
|
+
sig = stream_distance_sort(sig)
|
|
170
|
+
noise = stream_distance_sort(noise)
|
|
171
|
+
|
|
172
|
+
fig, drawn = plt.subplots(
|
|
173
|
+
len(stc), 1, sharex=True, sharey=sharey, figsize=(14, len(stc) * 3)
|
|
174
|
+
)
|
|
175
|
+
# `subplots` returns a bare `Axes` for a single row and an array otherwise.
|
|
176
|
+
# Kept as a separate name from what `subplots` returned: rebinding one
|
|
177
|
+
# variable to both shapes is what made the function unannotatable.
|
|
178
|
+
ax: Sequence[Any] = drawn.flatten() if len(stc) > 1 else [drawn]
|
|
179
|
+
|
|
180
|
+
for i, tr in enumerate(stc):
|
|
181
|
+
if plot_windows:
|
|
182
|
+
assert sig is not None # both guarded above
|
|
183
|
+
assert noise is not None
|
|
184
|
+
# get window start and end times
|
|
185
|
+
sts = sig[i].stats["wstart"], sig[i].stats["wend"]
|
|
186
|
+
nts = noise[i].stats["wstart"], noise[i].stats["wend"]
|
|
187
|
+
|
|
188
|
+
tr.trim(nts[0] - bft, sts[1] + aftt)
|
|
189
|
+
|
|
190
|
+
for signal_edge, noise_edge in zip(sts, nts, strict=True):
|
|
191
|
+
ts: datetime = _to_datetime(signal_edge.matplotlib_date)
|
|
192
|
+
tn: datetime = _to_datetime(noise_edge.matplotlib_date)
|
|
193
|
+
ax[i].vlines(ts, tr.data.min() * conv, tr.data.max() * conv, color="k")
|
|
194
|
+
ax[i].vlines(
|
|
195
|
+
tn, tr.data.min() * conv, tr.data.max() * conv, color="blue"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
if plot_theoreticals:
|
|
199
|
+
try:
|
|
200
|
+
if not plot_windows:
|
|
201
|
+
# Trim to a window around the P arrival, on the assumption
|
|
202
|
+
# that the pick is trustworthy.
|
|
203
|
+
tr.trim(tr.stats["p_time"] - bft, tr.stats["p_time"] + aftt)
|
|
204
|
+
|
|
205
|
+
p = _to_datetime(tr.stats["p_time"].matplotlib_date)
|
|
206
|
+
s = _to_datetime(tr.stats["s_time"].matplotlib_date)
|
|
207
|
+
ax[i].vlines(
|
|
208
|
+
p,
|
|
209
|
+
tr.data.min() * conv / 1.5,
|
|
210
|
+
tr.data.max() * conv / 1.5,
|
|
211
|
+
linestyles="dashed",
|
|
212
|
+
color="blue",
|
|
213
|
+
label="Pg",
|
|
214
|
+
)
|
|
215
|
+
ax[i].vlines(
|
|
216
|
+
s,
|
|
217
|
+
tr.data.min() * conv / 1.5,
|
|
218
|
+
tr.data.max() * conv / 1.5,
|
|
219
|
+
linestyles="dashed",
|
|
220
|
+
color="red",
|
|
221
|
+
label="Sg",
|
|
222
|
+
)
|
|
223
|
+
except KeyError:
|
|
224
|
+
pass
|
|
225
|
+
|
|
226
|
+
# `sample` rather than `i`: the comprehension used to reuse the name of
|
|
227
|
+
# the enclosing loop variable. Comprehensions have their own scope, so
|
|
228
|
+
# it was never a bug — but it reads like one, and would become one the
|
|
229
|
+
# moment this is turned back into a statement.
|
|
230
|
+
time = _to_datetime(
|
|
231
|
+
np.array(
|
|
232
|
+
[
|
|
233
|
+
(
|
|
234
|
+
tr.stats.starttime + (tr.stats.delta * (sample + 1))
|
|
235
|
+
).matplotlib_date
|
|
236
|
+
for sample in range(len(tr.data))
|
|
237
|
+
]
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
ax[i].plot(time, tr.data * conv, color="grey", label=tr.id, zorder=1)
|
|
242
|
+
# A trace with no geometry set still plots; it just goes untitled.
|
|
243
|
+
with contextlib.suppress(KeyError):
|
|
244
|
+
ax[i].set_title(
|
|
245
|
+
f"Repi: {tr.stats['repi']:.2f} km, Rhyp: {tr.stats['rhyp']:.2f} km"
|
|
246
|
+
)
|
|
247
|
+
ax[i].legend()
|
|
248
|
+
ax[-1].set_xlabel("Time (UTC)")
|
|
249
|
+
fig.suptitle(str(st[0].stats.otime))
|
|
250
|
+
fig.tight_layout()
|
|
251
|
+
if save is not None:
|
|
252
|
+
assert type(save) is str
|
|
253
|
+
fig.savefig(save)
|
|
254
|
+
fig.clear()
|
|
255
|
+
plt.close(fig)
|
|
256
|
+
logger.debug("closed the time-domain figure after saving to %s", save)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def stream_distance_sort(st: Stream, dist_met: str = "repi") -> Stream:
|
|
260
|
+
"""A copy of ``st`` ordered by distance. Not in place.
|
|
261
|
+
|
|
262
|
+
Returns the stream unsorted, with a warning, when the traces carry no
|
|
263
|
+
distance — which is why the copy is taken on the way out rather than only
|
|
264
|
+
on the sorted path.
|
|
265
|
+
"""
|
|
266
|
+
try:
|
|
267
|
+
st = obspy.Stream(sorted(st, key=lambda x: x.stats[dist_met]))
|
|
268
|
+
except KeyError:
|
|
269
|
+
warnings.warn(
|
|
270
|
+
f"No {dist_met!r} on these traces, so the stream is returned "
|
|
271
|
+
"unsorted. Set distances with `preprocess.set_stream_distance`.",
|
|
272
|
+
stacklevel=2,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
return st.copy()
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def read_cat(path: str | PathLike[str]) -> pd.DataFrame:
|
|
279
|
+
return pd.read_csv(path, sep=r"\s+")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def cat2kstyle(row: Any) -> str:
|
|
283
|
+
"""Catalogue date and time as dot-separated whole-number fields.
|
|
284
|
+
|
|
285
|
+
Sub-second precision is dropped, because :func:`keith2utc` parses every
|
|
286
|
+
field with :func:`int`. It used to be dropped with a fixed ``[:-3]`` slice,
|
|
287
|
+
which silently assumed exactly two decimal places on the seconds:
|
|
288
|
+
|
|
289
|
+
* ``"13:09:31.00"`` worked,
|
|
290
|
+
* ``"13:09:31.000"`` left a trailing separator and made ``keith2utc``
|
|
291
|
+
raise ``invalid literal for int()``,
|
|
292
|
+
* ``"13:09:31"`` — no decimals at all — lost the **seconds**, quietly
|
|
293
|
+
returning 13:09 as though it were 13:09:31.
|
|
294
|
+
|
|
295
|
+
The last is the one worth fixing: it is a wrong answer rather than an
|
|
296
|
+
error, and a catalogue written without fractional seconds is not unusual.
|
|
297
|
+
Splitting on the decimal point handles all three.
|
|
298
|
+
"""
|
|
299
|
+
date = row["Date"].replace("/", ".")
|
|
300
|
+
time = row["Time"].split(".")[0].replace(":", ".")
|
|
301
|
+
return ".".join([date, time])
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def keith2utc(row: Any) -> UTCDateTime:
|
|
305
|
+
return obspy.UTCDateTime(*map(int, cat2kstyle(row).split(".")))
|