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/picks/base.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Record types and the reader contract.
|
|
2
|
+
|
|
3
|
+
A :class:`Pick` names an arrival on a :class:`SensorID`, which may be partial —
|
|
4
|
+
``None`` for a field the source did not state. :class:`PickSet` groups the picks
|
|
5
|
+
of one event; :class:`Resolution` is what :func:`specmod.picks.resolve` returns.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Literal, Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
14
|
+
from collections.abc import Iterable, Sequence
|
|
15
|
+
from os import PathLike
|
|
16
|
+
|
|
17
|
+
from obspy import UTCDateTime
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"EMPTY_LOCATION",
|
|
21
|
+
"AmbiguousPolicy",
|
|
22
|
+
"DuplicatePolicy",
|
|
23
|
+
"Pick",
|
|
24
|
+
"PickReader",
|
|
25
|
+
"PickSet",
|
|
26
|
+
"Resolution",
|
|
27
|
+
"SensorID",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
#: How an unset location code is spelled in a sensor key. Snuffler marker files
|
|
31
|
+
#: use it, and it keeps the key's field count fixed.
|
|
32
|
+
EMPTY_LOCATION = "--"
|
|
33
|
+
|
|
34
|
+
DuplicatePolicy = Literal["prefer_reviewed", "earliest", "highest_weight", "error"]
|
|
35
|
+
AmbiguousPolicy = Literal["error", "broadcast", "skip"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, order=True)
|
|
39
|
+
class SensorID:
|
|
40
|
+
"""A sensor identity, possibly partial.
|
|
41
|
+
|
|
42
|
+
``None`` means the source did not state the field, and matches anything.
|
|
43
|
+
``""`` means stated and empty, and matches only an empty field.
|
|
44
|
+
|
|
45
|
+
Readers apply that distinction asymmetrically: an empty network code is not
|
|
46
|
+
a valid SEED network and becomes ``None``, while an empty location code is
|
|
47
|
+
the ordinary single-sensor case and is kept as ``""``.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
network: str | None
|
|
51
|
+
station: str
|
|
52
|
+
location: str | None
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def parse(cls, text: str) -> SensorID:
|
|
56
|
+
"""Build from ``NET.STA.LOC``, reading ``--`` as an empty location."""
|
|
57
|
+
parts = text.split(".")
|
|
58
|
+
if len(parts) != 3:
|
|
59
|
+
raise ValueError(
|
|
60
|
+
f"sensor id {text!r} is not NET.STA.LOC (got {len(parts)} fields)"
|
|
61
|
+
)
|
|
62
|
+
network, station, location = parts
|
|
63
|
+
return cls(
|
|
64
|
+
network=network or None,
|
|
65
|
+
station=station,
|
|
66
|
+
location="" if location == EMPTY_LOCATION else location,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def __str__(self) -> str:
|
|
70
|
+
network = self.network if self.network is not None else "*"
|
|
71
|
+
location = "*" if self.location is None else self.location or EMPTY_LOCATION
|
|
72
|
+
return f"{network}.{self.station}.{location}"
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def is_complete(self) -> bool:
|
|
76
|
+
"""Whether every field is stated, so this names exactly one sensor."""
|
|
77
|
+
return self.network is not None and self.location is not None
|
|
78
|
+
|
|
79
|
+
def matches(self, other: SensorID) -> bool:
|
|
80
|
+
"""Whether this identity is consistent with ``other``.
|
|
81
|
+
|
|
82
|
+
Every field this one specifies must agree. Fields left ``None`` do not
|
|
83
|
+
constrain the match, so a partial identity can match several sensors —
|
|
84
|
+
which is a condition :func:`resolve` reports rather than resolves.
|
|
85
|
+
"""
|
|
86
|
+
return all(
|
|
87
|
+
mine is None or theirs is None or mine == theirs
|
|
88
|
+
for mine, theirs in (
|
|
89
|
+
(self.station, other.station),
|
|
90
|
+
(self.network, other.network),
|
|
91
|
+
(self.location, other.location),
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class Pick:
|
|
98
|
+
"""One phase arrival, with whatever provenance its format carried.
|
|
99
|
+
|
|
100
|
+
Only ``sensor``, ``phase`` and ``time`` are always present. The rest is
|
|
101
|
+
absent wherever the source format has no field for it, and is what a
|
|
102
|
+
:class:`DuplicatePolicy` uses to choose between competing picks.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
sensor: SensorID
|
|
106
|
+
phase: str
|
|
107
|
+
time: UTCDateTime
|
|
108
|
+
raw_phase: str | None = None
|
|
109
|
+
uncertainty: float | None = None
|
|
110
|
+
polarity: str | None = None
|
|
111
|
+
weight: float | None = None
|
|
112
|
+
automatic: bool | None = None
|
|
113
|
+
reviewed: bool | None = None
|
|
114
|
+
channel: str | None = None
|
|
115
|
+
author: str | None = None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True)
|
|
119
|
+
class PickSet:
|
|
120
|
+
"""The picks of a single event."""
|
|
121
|
+
|
|
122
|
+
picks: tuple[Pick, ...] = ()
|
|
123
|
+
event_id: str | None = None
|
|
124
|
+
origin: UTCDateTime | None = None
|
|
125
|
+
|
|
126
|
+
def __len__(self) -> int:
|
|
127
|
+
return len(self.picks)
|
|
128
|
+
|
|
129
|
+
def __iter__(self) -> Any:
|
|
130
|
+
return iter(self.picks)
|
|
131
|
+
|
|
132
|
+
def sensors(self) -> tuple[SensorID, ...]:
|
|
133
|
+
"""The distinct sensor identities picked, in sorted order."""
|
|
134
|
+
return tuple(sorted({pick.sensor for pick in self.picks}))
|
|
135
|
+
|
|
136
|
+
def mapping(
|
|
137
|
+
self, *, duplicates: DuplicatePolicy = "prefer_reviewed"
|
|
138
|
+
) -> dict[str, dict[str, UTCDateTime]]:
|
|
139
|
+
"""The picks as ``{"NET.STA.LOC": {"P": UTCDateTime}}``.
|
|
140
|
+
|
|
141
|
+
Keyed on each pick's own identity, so an unstated field appears as
|
|
142
|
+
``*`` and matches no trace. Use :func:`resolve` to match against a
|
|
143
|
+
stream.
|
|
144
|
+
"""
|
|
145
|
+
out: dict[str, dict[str, UTCDateTime]] = {}
|
|
146
|
+
for (sensor, phase), picks in _grouped(self.picks).items():
|
|
147
|
+
chosen = _choose(picks, duplicates, sensor, phase)
|
|
148
|
+
out.setdefault(str(sensor), {})[phase] = chosen.time
|
|
149
|
+
return out
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass(frozen=True)
|
|
153
|
+
class Resolution:
|
|
154
|
+
"""The outcome of matching a :class:`PickSet` against a set of sensors."""
|
|
155
|
+
|
|
156
|
+
#: Attached picks, keyed by the *sensor's* complete id, then by phase.
|
|
157
|
+
attached: dict[str, dict[str, Pick]] = field(default_factory=dict)
|
|
158
|
+
#: Picks that matched no sensor.
|
|
159
|
+
unused: tuple[Pick, ...] = ()
|
|
160
|
+
#: Picks skipped because they matched several sensors.
|
|
161
|
+
ambiguous: tuple[Pick, ...] = ()
|
|
162
|
+
#: ``(sensor, phase)`` where a policy chose between competing picks.
|
|
163
|
+
duplicated: tuple[tuple[str, str], ...] = ()
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def n_attached(self) -> int:
|
|
167
|
+
return sum(len(phases) for phases in self.attached.values())
|
|
168
|
+
|
|
169
|
+
def summary(self) -> str:
|
|
170
|
+
"""One line, suitable for printing or asserting on."""
|
|
171
|
+
return (
|
|
172
|
+
f"{self.n_attached} attached to {len(self.attached)} sensors, "
|
|
173
|
+
f"{len(self.unused)} unused, {len(self.ambiguous)} ambiguous, "
|
|
174
|
+
f"{len(self.duplicated)} resolved by policy"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _grouped(picks: Iterable[Pick]) -> dict[tuple[SensorID, str], list[Pick]]:
|
|
179
|
+
grouped: dict[tuple[SensorID, str], list[Pick]] = {}
|
|
180
|
+
for pick in picks:
|
|
181
|
+
grouped.setdefault((pick.sensor, pick.phase), []).append(pick)
|
|
182
|
+
return grouped
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _choose(
|
|
186
|
+
picks: Sequence[Pick], policy: DuplicatePolicy, sensor: Any, phase: str
|
|
187
|
+
) -> Pick:
|
|
188
|
+
"""Reduce competing picks for one sensor and phase to one."""
|
|
189
|
+
if len(picks) == 1:
|
|
190
|
+
return picks[0]
|
|
191
|
+
|
|
192
|
+
if policy == "error":
|
|
193
|
+
times = ", ".join(str(pick.time) for pick in picks)
|
|
194
|
+
raise ValueError(
|
|
195
|
+
f"{sensor} has {len(picks)} {phase} picks ({times}) and "
|
|
196
|
+
f"duplicates='error'. Choose a policy, or drop the extras."
|
|
197
|
+
)
|
|
198
|
+
if policy == "earliest":
|
|
199
|
+
return min(picks, key=lambda pick: pick.time)
|
|
200
|
+
if policy == "highest_weight":
|
|
201
|
+
# An absent weight loses to any stated one, and ties break on time so
|
|
202
|
+
# the choice does not depend on file order.
|
|
203
|
+
return max(
|
|
204
|
+
picks,
|
|
205
|
+
key=lambda pick: (
|
|
206
|
+
pick.weight if pick.weight is not None else float("-inf"),
|
|
207
|
+
-float(pick.time.timestamp),
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
# prefer_reviewed: an analyst's pick beats an automatic one, then earliest.
|
|
211
|
+
return min(
|
|
212
|
+
picks,
|
|
213
|
+
key=lambda pick: (
|
|
214
|
+
0 if pick.reviewed else 1,
|
|
215
|
+
0 if pick.automatic is False else 1,
|
|
216
|
+
pick.time,
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@runtime_checkable
|
|
222
|
+
class PickReader(Protocol):
|
|
223
|
+
"""One pick format.
|
|
224
|
+
|
|
225
|
+
``suffixes`` is a hint for error messages and nothing else: a suffix does
|
|
226
|
+
not identify a format, so :func:`specmod.picks.detect_reader` selects on
|
|
227
|
+
:meth:`can_read` alone.
|
|
228
|
+
|
|
229
|
+
``name`` and ``suffixes`` are read-only properties so that a frozen
|
|
230
|
+
dataclass satisfies the protocol; mutable attributes would not.
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def name(self) -> str: ...
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def suffixes(self) -> tuple[str, ...]: ...
|
|
238
|
+
|
|
239
|
+
def can_read(self, source: str | PathLike[str]) -> bool:
|
|
240
|
+
"""Whether this reader recognises ``source``.
|
|
241
|
+
|
|
242
|
+
Must not raise — on a missing, empty, truncated or binary file, and on
|
|
243
|
+
every other format's files, it returns ``False``. Must be cheap: read a
|
|
244
|
+
header, not the whole file.
|
|
245
|
+
"""
|
|
246
|
+
...
|
|
247
|
+
|
|
248
|
+
def read(self, source: str | PathLike[str]) -> list[PickSet]:
|
|
249
|
+
"""Every event in ``source``, in file order."""
|
|
250
|
+
...
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Delimited pick tables: one row per arrival, columns named by the caller.
|
|
2
|
+
|
|
3
|
+
:class:`DelimitedPickReader` is the general case, configured with a delimiter
|
|
4
|
+
and a column mapping. :class:`CSVPickReader`, :class:`TSVPickReader` and
|
|
5
|
+
:class:`WhitespacePickReader` are it with the delimiter and the plausible file
|
|
6
|
+
suffixes already set.
|
|
7
|
+
|
|
8
|
+
No vendor presets ship. PhaseNet, EQTransformer and SeisBench each write
|
|
9
|
+
several column layouts across versions, and a preset written from
|
|
10
|
+
documentation rather than from a real output file is a guess with a name on it.
|
|
11
|
+
Supply the mapping yourself — see ``docs/pick-formats.md``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import csv as _csv
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import TYPE_CHECKING
|
|
19
|
+
|
|
20
|
+
import obspy
|
|
21
|
+
|
|
22
|
+
from .base import Pick, PickSet, SensorID
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
25
|
+
from collections.abc import Mapping
|
|
26
|
+
from os import PathLike
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"CSVPickReader",
|
|
30
|
+
"DelimitedPickReader",
|
|
31
|
+
"TSVPickReader",
|
|
32
|
+
"WhitespacePickReader",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
#: Fields a mapping may name. ``station``, ``phase`` and ``time`` are required;
|
|
36
|
+
#: the rest populate the matching :class:`~specmod.picks.Pick` attributes.
|
|
37
|
+
FIELDS = (
|
|
38
|
+
"station",
|
|
39
|
+
"phase",
|
|
40
|
+
"time",
|
|
41
|
+
"network",
|
|
42
|
+
"location",
|
|
43
|
+
"channel",
|
|
44
|
+
"weight",
|
|
45
|
+
"uncertainty",
|
|
46
|
+
"polarity",
|
|
47
|
+
"author",
|
|
48
|
+
)
|
|
49
|
+
REQUIRED = ("station", "phase", "time")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class DelimitedPickReader:
|
|
54
|
+
"""A delimited table of picks, one row per arrival.
|
|
55
|
+
|
|
56
|
+
``columns`` maps this reader's field names to the column headings in the
|
|
57
|
+
file: ``{"station": "sta", "phase": "phase_type", "time": "arrival_time"}``.
|
|
58
|
+
``station``, ``phase`` and ``time`` are required; :data:`FIELDS` lists what
|
|
59
|
+
else may be mapped.
|
|
60
|
+
|
|
61
|
+
``delimiter`` is a single character, or ``None`` to split on runs of
|
|
62
|
+
whitespace — the latter takes a separate parse path, since quoting has no
|
|
63
|
+
meaning in a whitespace-aligned table.
|
|
64
|
+
|
|
65
|
+
``reader_name`` is what :func:`specmod.picks.read` refers to it by and must
|
|
66
|
+
be unique once registered. ``file_suffixes`` is a hint for error messages
|
|
67
|
+
and nothing else; detection is by header, so a reader claims a file only
|
|
68
|
+
when every mapped column is present. Two readers configured for different
|
|
69
|
+
schemas therefore do not collide, and one whose columns are a subset of
|
|
70
|
+
another's collides visibly — the ambiguity
|
|
71
|
+
:func:`specmod.picks.detect_reader` reports.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
columns: Mapping[str, str]
|
|
75
|
+
reader_name: str = "delimited"
|
|
76
|
+
delimiter: str | None = ","
|
|
77
|
+
file_suffixes: tuple[str, ...] = (".csv", ".tsv", ".txt", ".dat")
|
|
78
|
+
#: Rows whose phase does not fold to P or S are skipped rather than raising.
|
|
79
|
+
skip_unknown_phases: bool = True
|
|
80
|
+
|
|
81
|
+
def __post_init__(self) -> None:
|
|
82
|
+
unknown = sorted(set(self.columns) - set(FIELDS))
|
|
83
|
+
if unknown:
|
|
84
|
+
raise ValueError(
|
|
85
|
+
f"unknown pick fields {unknown}. Available: {list(FIELDS)}."
|
|
86
|
+
)
|
|
87
|
+
missing = [name for name in REQUIRED if name not in self.columns]
|
|
88
|
+
if missing:
|
|
89
|
+
raise ValueError(f"columns must map {missing}; got {sorted(self.columns)}.")
|
|
90
|
+
if self.delimiter is not None and len(self.delimiter) != 1:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"delimiter must be one character or None for whitespace; "
|
|
93
|
+
f"got {self.delimiter!r}."
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def name(self) -> str:
|
|
98
|
+
return self.reader_name
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def suffixes(self) -> tuple[str, ...]:
|
|
102
|
+
return self.file_suffixes
|
|
103
|
+
|
|
104
|
+
def _rows(self, source: str | PathLike[str]) -> list[list[str]] | None:
|
|
105
|
+
"""Every line as a list of cells, or ``None`` if this is not a table."""
|
|
106
|
+
try:
|
|
107
|
+
with open(source, newline="") as handle:
|
|
108
|
+
text = handle.read()
|
|
109
|
+
except (OSError, UnicodeDecodeError):
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
if self.delimiter is None:
|
|
113
|
+
return [line.split() for line in text.splitlines() if line.strip()]
|
|
114
|
+
try:
|
|
115
|
+
return [
|
|
116
|
+
[cell.strip() for cell in row]
|
|
117
|
+
for row in _csv.reader(text.splitlines(), delimiter=self.delimiter)
|
|
118
|
+
if row
|
|
119
|
+
]
|
|
120
|
+
except _csv.Error:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
def can_read(self, source: str | PathLike[str]) -> bool:
|
|
124
|
+
rows = self._rows(source)
|
|
125
|
+
if not rows:
|
|
126
|
+
return False
|
|
127
|
+
return set(self.columns.values()) <= set(rows[0])
|
|
128
|
+
|
|
129
|
+
def read(self, source: str | PathLike[str]) -> list[PickSet]:
|
|
130
|
+
rows = self._rows(source)
|
|
131
|
+
if not rows:
|
|
132
|
+
return [PickSet()]
|
|
133
|
+
|
|
134
|
+
headings, body = rows[0], rows[1:]
|
|
135
|
+
picks = []
|
|
136
|
+
for cells in body:
|
|
137
|
+
row = dict(zip(headings, cells, strict=False))
|
|
138
|
+
pick = self._pick(row)
|
|
139
|
+
if pick is not None:
|
|
140
|
+
picks.append(pick)
|
|
141
|
+
return [PickSet(picks=tuple(picks))]
|
|
142
|
+
|
|
143
|
+
def _value(self, row: Mapping[str, str], field_name: str) -> str | None:
|
|
144
|
+
heading = self.columns.get(field_name)
|
|
145
|
+
if heading is None:
|
|
146
|
+
return None
|
|
147
|
+
value = (row.get(heading) or "").strip()
|
|
148
|
+
return value or None
|
|
149
|
+
|
|
150
|
+
def _pick(self, row: Mapping[str, str]) -> Pick | None:
|
|
151
|
+
raw_phase = self._value(row, "phase")
|
|
152
|
+
phase = (raw_phase or "")[:1].upper()
|
|
153
|
+
if phase not in ("P", "S"):
|
|
154
|
+
if self.skip_unknown_phases:
|
|
155
|
+
return None
|
|
156
|
+
raise ValueError(f"phase {raw_phase!r} is neither P nor S")
|
|
157
|
+
|
|
158
|
+
station = self._value(row, "station")
|
|
159
|
+
time = self._value(row, "time")
|
|
160
|
+
if station is None or time is None:
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
location = self._value(row, "location")
|
|
164
|
+
return Pick(
|
|
165
|
+
sensor=SensorID(
|
|
166
|
+
network=self._value(row, "network"),
|
|
167
|
+
station=station,
|
|
168
|
+
# A column that is present but blank states an empty location;
|
|
169
|
+
# a column that is absent states nothing.
|
|
170
|
+
location=(location or "") if "location" in self.columns else None,
|
|
171
|
+
),
|
|
172
|
+
phase=phase,
|
|
173
|
+
time=obspy.UTCDateTime(time),
|
|
174
|
+
raw_phase=raw_phase,
|
|
175
|
+
channel=self._value(row, "channel"),
|
|
176
|
+
weight=_as_float(self._value(row, "weight")),
|
|
177
|
+
uncertainty=_as_float(self._value(row, "uncertainty")),
|
|
178
|
+
polarity=self._value(row, "polarity"),
|
|
179
|
+
author=self._value(row, "author"),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass(frozen=True)
|
|
184
|
+
class CSVPickReader(DelimitedPickReader):
|
|
185
|
+
"""Comma-separated, with the quoting rules of :mod:`csv`."""
|
|
186
|
+
|
|
187
|
+
reader_name: str = "csv"
|
|
188
|
+
delimiter: str | None = ","
|
|
189
|
+
file_suffixes: tuple[str, ...] = (".csv",)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@dataclass(frozen=True)
|
|
193
|
+
class TSVPickReader(DelimitedPickReader):
|
|
194
|
+
"""Tab-separated."""
|
|
195
|
+
|
|
196
|
+
reader_name: str = "tsv"
|
|
197
|
+
delimiter: str | None = "\t"
|
|
198
|
+
file_suffixes: tuple[str, ...] = (".tsv", ".tab")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass(frozen=True)
|
|
202
|
+
class WhitespacePickReader(DelimitedPickReader):
|
|
203
|
+
"""Columns separated by runs of spaces or tabs.
|
|
204
|
+
|
|
205
|
+
The shape most hand-written and Fortran-era arrival tables come in. Cells
|
|
206
|
+
cannot contain spaces, and quoting is not honoured.
|
|
207
|
+
|
|
208
|
+
This **subsumes** :class:`TSVPickReader` — splitting on whitespace splits
|
|
209
|
+
on tabs — so registering both against the same column names makes every
|
|
210
|
+
tab-separated file ambiguous. Register one, or pass ``format=``.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
reader_name: str = "whitespace"
|
|
214
|
+
delimiter: str | None = None
|
|
215
|
+
file_suffixes: tuple[str, ...] = (".txt", ".dat", ".lst")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _as_float(value: str | None) -> float | None:
|
|
219
|
+
if value is None:
|
|
220
|
+
return None
|
|
221
|
+
try:
|
|
222
|
+
return float(value)
|
|
223
|
+
except ValueError:
|
|
224
|
+
return None
|
specmod/picks/events.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Every event format ObsPy parses, through one reader.
|
|
2
|
+
|
|
3
|
+
``obspy.read_events`` sniffs and parses nineteen formats, ten of which carry
|
|
4
|
+
arrivals, so this delegate is the whole standard roster. A format registered
|
|
5
|
+
with ObsPy's own ``obspy.plugin.event`` entry points is readable here without
|
|
6
|
+
being registered with specmod.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
import obspy
|
|
15
|
+
from obspy.core.event import Catalog
|
|
16
|
+
|
|
17
|
+
from .base import Pick, PickSet, SensorID
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
20
|
+
from os import PathLike
|
|
21
|
+
|
|
22
|
+
from obspy.core.event import Event as ObsPyEvent
|
|
23
|
+
|
|
24
|
+
__all__ = ["ObsPyEventsReader", "from_catalog"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _obspy_claims(source: str | PathLike[str]) -> bool:
|
|
28
|
+
"""Whether any ObsPy event plugin recognises this file.
|
|
29
|
+
|
|
30
|
+
Uses the same per-format ``isFormat`` functions ``read_events`` uses to
|
|
31
|
+
auto-detect, so this agrees with what a subsequent read would do without
|
|
32
|
+
parsing the file.
|
|
33
|
+
"""
|
|
34
|
+
from obspy.core.util.base import ( # noqa: PLC0415
|
|
35
|
+
ENTRY_POINTS,
|
|
36
|
+
buffered_load_entry_point,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
for entry_point in ENTRY_POINTS["event"].values():
|
|
40
|
+
if entry_point.dist is None:
|
|
41
|
+
continue
|
|
42
|
+
try:
|
|
43
|
+
is_format = buffered_load_entry_point(
|
|
44
|
+
entry_point.dist.name,
|
|
45
|
+
f"obspy.plugin.event.{entry_point.name}",
|
|
46
|
+
"isFormat",
|
|
47
|
+
)
|
|
48
|
+
if is_format(str(source)):
|
|
49
|
+
return True
|
|
50
|
+
except Exception:
|
|
51
|
+
# A detector that raises has not claimed the file. They are
|
|
52
|
+
# third-party code reading untrusted bytes, and one of them
|
|
53
|
+
# throwing must not stop the others being asked.
|
|
54
|
+
continue
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
#: Phase hints folded to their first letter. ``Pg``/``Pn``/``Pb`` and their S
|
|
59
|
+
#: counterparts are all direct-arrival branches this pipeline does not
|
|
60
|
+
#: distinguish; the original is kept on the pick as ``raw_phase``.
|
|
61
|
+
def _fold(hint: str | None) -> str | None:
|
|
62
|
+
if not hint:
|
|
63
|
+
return None
|
|
64
|
+
first = hint.strip()[:1].upper()
|
|
65
|
+
return first if first in ("P", "S") else None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _sensor_from_waveform_id(waveform_id: Any) -> SensorID | None:
|
|
69
|
+
station = (getattr(waveform_id, "station_code", None) or "").strip()
|
|
70
|
+
if not station:
|
|
71
|
+
return None
|
|
72
|
+
network = (getattr(waveform_id, "network_code", None) or "").strip()
|
|
73
|
+
location = getattr(waveform_id, "location_code", None)
|
|
74
|
+
return SensorID(
|
|
75
|
+
# An empty network code is not a valid SEED network, so it states
|
|
76
|
+
# nothing; an empty location code is the normal single-sensor case.
|
|
77
|
+
network=network or None,
|
|
78
|
+
station=station,
|
|
79
|
+
location=location if location is not None else None,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _pick_from_obspy(source: Any) -> Pick | None:
|
|
84
|
+
phase = _fold(getattr(source, "phase_hint", None))
|
|
85
|
+
if phase is None:
|
|
86
|
+
return None
|
|
87
|
+
if getattr(source, "evaluation_status", None) == "rejected":
|
|
88
|
+
return None
|
|
89
|
+
sensor = _sensor_from_waveform_id(getattr(source, "waveform_id", None))
|
|
90
|
+
if sensor is None:
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
mode = getattr(source, "evaluation_mode", None)
|
|
94
|
+
status = getattr(source, "evaluation_status", None)
|
|
95
|
+
creation = getattr(source, "creation_info", None)
|
|
96
|
+
return Pick(
|
|
97
|
+
sensor=sensor,
|
|
98
|
+
phase=phase,
|
|
99
|
+
time=source.time,
|
|
100
|
+
raw_phase=(source.phase_hint or "").strip() or None,
|
|
101
|
+
uncertainty=getattr(source, "time_errors", None)
|
|
102
|
+
and getattr(source.time_errors, "uncertainty", None),
|
|
103
|
+
polarity=getattr(source, "polarity", None),
|
|
104
|
+
automatic=None if mode is None else mode == "automatic",
|
|
105
|
+
reviewed=None if status is None else status in ("reviewed", "confirmed"),
|
|
106
|
+
channel=getattr(getattr(source, "waveform_id", None), "channel_code", None),
|
|
107
|
+
author=getattr(creation, "author", None) if creation is not None else None,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def from_catalog(catalog: Catalog | ObsPyEvent) -> list[PickSet]:
|
|
112
|
+
"""Every event in an ObsPy catalogue, as one :class:`PickSet` each.
|
|
113
|
+
|
|
114
|
+
Phase hints are folded to ``P`` or ``S`` on their first letter, with the
|
|
115
|
+
original kept as ``raw_phase``. Picks with no ``P``/``S`` hint, with an
|
|
116
|
+
``evaluation_status`` of ``rejected``, or with no station code are dropped.
|
|
117
|
+
"""
|
|
118
|
+
events = list(catalog) if isinstance(catalog, Catalog) else [catalog]
|
|
119
|
+
|
|
120
|
+
sets = []
|
|
121
|
+
for event in events:
|
|
122
|
+
picks = tuple(
|
|
123
|
+
pick
|
|
124
|
+
for pick in (_pick_from_obspy(raw) for raw in event.picks)
|
|
125
|
+
if pick is not None
|
|
126
|
+
)
|
|
127
|
+
origin = event.preferred_origin() or (
|
|
128
|
+
event.origins[0] if event.origins else None
|
|
129
|
+
)
|
|
130
|
+
resource_id = getattr(event, "resource_id", None)
|
|
131
|
+
sets.append(
|
|
132
|
+
PickSet(
|
|
133
|
+
picks=picks,
|
|
134
|
+
event_id=str(resource_id) if resource_id is not None else None,
|
|
135
|
+
origin=origin.time if origin is not None else None,
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
return sets
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass(frozen=True)
|
|
142
|
+
class ObsPyEventsReader:
|
|
143
|
+
"""Anything :func:`obspy.read_events` parses."""
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def name(self) -> str:
|
|
147
|
+
return "obspy_events"
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def suffixes(self) -> tuple[str, ...]:
|
|
151
|
+
return (".xml", ".quakeml", ".scml", ".pha", ".hyp", ".nordic", ".out")
|
|
152
|
+
|
|
153
|
+
def can_read(self, source: str | PathLike[str]) -> bool:
|
|
154
|
+
return _obspy_claims(source)
|
|
155
|
+
|
|
156
|
+
def read(self, source: str | PathLike[str]) -> list[PickSet]:
|
|
157
|
+
return from_catalog(obspy.read_events(str(source)))
|