specmod 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. specmod/__init__.py +17 -0
  2. specmod/_vendor/__init__.py +21 -0
  3. specmod/_vendor/qiinv.py +243 -0
  4. specmod/acquire.py +358 -0
  5. specmod/api.py +480 -0
  6. specmod/cli.py +139 -0
  7. specmod/config/__init__.py +44 -0
  8. specmod/config/layers.py +168 -0
  9. specmod/config/provenance.py +77 -0
  10. specmod/config/sections.py +385 -0
  11. specmod/config/serialize.py +58 -0
  12. specmod/core/__init__.py +41 -0
  13. specmod/core/bandwidth.py +187 -0
  14. specmod/core/collection.py +549 -0
  15. specmod/core/noise.py +478 -0
  16. specmod/core/scalogram.py +234 -0
  17. specmod/core/spectrum.py +326 -0
  18. specmod/core/units.py +116 -0
  19. specmod/datasets.py +316 -0
  20. specmod/distance.py +190 -0
  21. specmod/exceptions.py +58 -0
  22. specmod/fitting/__init__.py +58 -0
  23. specmod/fitting/base.py +50 -0
  24. specmod/fitting/event.py +284 -0
  25. specmod/fitting/guess.py +170 -0
  26. specmod/fitting/spectrum.py +330 -0
  27. specmod/io.py +241 -0
  28. specmod/magnitude.py +312 -0
  29. specmod/picks/__init__.py +182 -0
  30. specmod/picks/base.py +250 -0
  31. specmod/picks/delimited.py +224 -0
  32. specmod/picks/events.py +157 -0
  33. specmod/picks/resolution.py +149 -0
  34. specmod/picks/snuffler.py +92 -0
  35. specmod/pipeline.py +280 -0
  36. specmod/plotting.py +203 -0
  37. specmod/preprocess.py +554 -0
  38. specmod/smoothing/__init__.py +50 -0
  39. specmod/smoothing/base.py +56 -0
  40. specmod/smoothing/konno_ohmachi.py +83 -0
  41. specmod/smoothing/log_bins.py +171 -0
  42. specmod/sources/__init__.py +65 -0
  43. specmod/sources/attenuation.py +110 -0
  44. specmod/sources/composite.py +135 -0
  45. specmod/sources/motion.py +40 -0
  46. specmod/sources/source.py +147 -0
  47. specmod/spreading.py +209 -0
  48. specmod/staged.py +523 -0
  49. specmod/tables.py +110 -0
  50. specmod/transforms/__init__.py +50 -0
  51. specmod/transforms/base.py +242 -0
  52. specmod/transforms/cwt.py +219 -0
  53. specmod/transforms/fft.py +157 -0
  54. specmod/transforms/multitaper.py +357 -0
  55. specmod/transforms/prieto.py +272 -0
  56. specmod/transforms/quadratic.py +221 -0
  57. specmod/utils.py +305 -0
  58. specmod-0.2.0.dist-info/METADATA +294 -0
  59. specmod-0.2.0.dist-info/RECORD +62 -0
  60. specmod-0.2.0.dist-info/WHEEL +4 -0
  61. specmod-0.2.0.dist-info/entry_points.txt +2 -0
  62. specmod-0.2.0.dist-info/licenses/LICENSE +21 -0
specmod/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
@@ -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)))