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
@@ -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(".")))