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,149 @@
|
|
|
1
|
+
"""The three rules: event selection, sensor matching, duplicate resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from .base import (
|
|
9
|
+
AmbiguousPolicy,
|
|
10
|
+
DuplicatePolicy,
|
|
11
|
+
Pick,
|
|
12
|
+
PickSet,
|
|
13
|
+
Resolution,
|
|
14
|
+
SensorID,
|
|
15
|
+
_choose,
|
|
16
|
+
_grouped,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
20
|
+
from collections.abc import Iterable, Sequence
|
|
21
|
+
|
|
22
|
+
from obspy import UTCDateTime
|
|
23
|
+
|
|
24
|
+
__all__ = ["resolve", "select_event"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def select_event(
|
|
28
|
+
sets: Sequence[PickSet],
|
|
29
|
+
*,
|
|
30
|
+
event_id: str | None = None,
|
|
31
|
+
near: UTCDateTime | None = None,
|
|
32
|
+
tolerance_s: float = 60.0,
|
|
33
|
+
) -> PickSet:
|
|
34
|
+
"""Choose one :class:`PickSet` from a source that may hold several.
|
|
35
|
+
|
|
36
|
+
Selects by ``event_id``, or by origin time within ``tolerance_s`` of
|
|
37
|
+
``near``. With neither, the source must hold exactly one event. Raises
|
|
38
|
+
unless exactly one event is selected, naming those available.
|
|
39
|
+
"""
|
|
40
|
+
if not sets:
|
|
41
|
+
raise ValueError("no events in this source")
|
|
42
|
+
|
|
43
|
+
if event_id is not None:
|
|
44
|
+
matched = [s for s in sets if s.event_id == event_id]
|
|
45
|
+
if len(matched) != 1:
|
|
46
|
+
known = ", ".join(str(s.event_id) for s in sets)
|
|
47
|
+
raise ValueError(
|
|
48
|
+
f"event_id={event_id!r} matched {len(matched)} events. "
|
|
49
|
+
f"Available: {known}."
|
|
50
|
+
)
|
|
51
|
+
return matched[0]
|
|
52
|
+
|
|
53
|
+
if near is not None:
|
|
54
|
+
timed = [s for s in sets if s.origin is not None]
|
|
55
|
+
matched = [s for s in timed if abs(s.origin - near) <= tolerance_s] # type: ignore[operator]
|
|
56
|
+
if len(matched) != 1:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f"{len(matched)} of {len(sets)} events lie within "
|
|
59
|
+
f"{tolerance_s} s of {near} ({len(sets) - len(timed)} carry no "
|
|
60
|
+
f"origin time). Widen or narrow the tolerance, or use event_id."
|
|
61
|
+
)
|
|
62
|
+
return matched[0]
|
|
63
|
+
|
|
64
|
+
if len(sets) > 1:
|
|
65
|
+
known = ", ".join(f"{s.event_id or '<no id>'}@{s.origin}" for s in sets)
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"this source holds {len(sets)} events and no selector was given: "
|
|
68
|
+
f"{known}. Pass event_id= or near=."
|
|
69
|
+
)
|
|
70
|
+
return sets[0]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def resolve(
|
|
74
|
+
picks: PickSet,
|
|
75
|
+
sensors: Iterable[SensorID],
|
|
76
|
+
*,
|
|
77
|
+
on_ambiguous: AmbiguousPolicy = "error",
|
|
78
|
+
duplicates: DuplicatePolicy = "prefer_reviewed",
|
|
79
|
+
) -> Resolution:
|
|
80
|
+
"""Match a set of picks against the sensors actually present.
|
|
81
|
+
|
|
82
|
+
``sensors`` are complete identities, as built from a stream. A pick reaches
|
|
83
|
+
exactly one of them, none — in which case it is unused — or several, which
|
|
84
|
+
is ``on_ambiguous``:
|
|
85
|
+
|
|
86
|
+
``error``
|
|
87
|
+
Raise, naming the candidates and the fields the pick left unstated.
|
|
88
|
+
The default.
|
|
89
|
+
``skip``
|
|
90
|
+
Leave the pick unattached, counted in :attr:`Resolution.ambiguous`.
|
|
91
|
+
``broadcast``
|
|
92
|
+
Attach to every match.
|
|
93
|
+
|
|
94
|
+
``duplicates`` chooses between several picks for one sensor and phase:
|
|
95
|
+
``prefer_reviewed`` (then earliest), ``earliest``, ``highest_weight``, or
|
|
96
|
+
``error``.
|
|
97
|
+
"""
|
|
98
|
+
# Deduplicated: a stream carries one trace per component, so the same
|
|
99
|
+
# sensor arrives two or three times. Ambiguity is about distinct sensors —
|
|
100
|
+
# counting components would make every ordinary stream ambiguous.
|
|
101
|
+
targets = list(dict.fromkeys(sensors))
|
|
102
|
+
attached: dict[str, dict[str, Pick]] = {}
|
|
103
|
+
unused: list[Pick] = []
|
|
104
|
+
ambiguous: list[Pick] = []
|
|
105
|
+
duplicated: list[tuple[str, str]] = []
|
|
106
|
+
|
|
107
|
+
for (sensor, phase), competing in _grouped(picks.picks).items():
|
|
108
|
+
matched = [target for target in targets if sensor.matches(target)]
|
|
109
|
+
|
|
110
|
+
if not matched:
|
|
111
|
+
unused.extend(competing)
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
if len(matched) > 1:
|
|
115
|
+
if on_ambiguous == "error":
|
|
116
|
+
names = ", ".join(str(target) for target in matched)
|
|
117
|
+
missing = [
|
|
118
|
+
name
|
|
119
|
+
for name, value in (
|
|
120
|
+
("network", sensor.network),
|
|
121
|
+
("location code", sensor.location),
|
|
122
|
+
)
|
|
123
|
+
if value is None
|
|
124
|
+
]
|
|
125
|
+
raise ValueError(
|
|
126
|
+
f"the {phase} pick for {sensor} matches {len(matched)} "
|
|
127
|
+
f"sensors ({names}). It states no "
|
|
128
|
+
f"{' and no '.join(missing)}, which is what would "
|
|
129
|
+
f"distinguish them. Pass on_ambiguous='broadcast' if the "
|
|
130
|
+
f"sensors are co-located, or 'skip' to drop it."
|
|
131
|
+
)
|
|
132
|
+
if on_ambiguous == "skip":
|
|
133
|
+
ambiguous.extend(competing)
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
chosen = _choose(competing, duplicates, sensor, phase)
|
|
137
|
+
if len(competing) > 1:
|
|
138
|
+
duplicated.append((str(sensor), phase))
|
|
139
|
+
|
|
140
|
+
for target in matched:
|
|
141
|
+
key = str(target)
|
|
142
|
+
attached.setdefault(key, {})[phase] = replace(chosen, sensor=target)
|
|
143
|
+
|
|
144
|
+
return Resolution(
|
|
145
|
+
attached=attached,
|
|
146
|
+
unused=tuple(unused),
|
|
147
|
+
ambiguous=tuple(ambiguous),
|
|
148
|
+
duplicated=tuple(duplicated),
|
|
149
|
+
)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Snuffler (Pyrocko) marker files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import obspy
|
|
9
|
+
|
|
10
|
+
from .base import Pick, PickSet, SensorID
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
13
|
+
from os import PathLike
|
|
14
|
+
|
|
15
|
+
__all__ = ["SnufflerReader"]
|
|
16
|
+
|
|
17
|
+
#: First line of every marker file Snuffler writes.
|
|
18
|
+
MAGIC = "# Snuffler Markers File Version"
|
|
19
|
+
|
|
20
|
+
#: Marker symbol to ``(phase, branch, first motion)``.
|
|
21
|
+
PHASES = {
|
|
22
|
+
"^": ("P", "Pg", "u"),
|
|
23
|
+
"v": ("P", "Pg", "d"),
|
|
24
|
+
"P": ("P", "Pg", None),
|
|
25
|
+
"S": ("S", "Sg", None),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
#: Weights above this are how the format spells a rejected arrival.
|
|
29
|
+
MAX_WEIGHT = 3
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class SnufflerReader:
|
|
34
|
+
"""Marker files, as saved from Snuffler.
|
|
35
|
+
|
|
36
|
+
Not an ObsPy format: markers are an editor's working notes rather than an
|
|
37
|
+
event, and carry no origin.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def name(self) -> str:
|
|
42
|
+
return "snuffler"
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def suffixes(self) -> tuple[str, ...]:
|
|
46
|
+
return (".picks", ".markers")
|
|
47
|
+
|
|
48
|
+
def can_read(self, source: str | PathLike[str]) -> bool:
|
|
49
|
+
try:
|
|
50
|
+
with open(source) as handle:
|
|
51
|
+
return handle.readline().startswith(MAGIC)
|
|
52
|
+
except (OSError, UnicodeDecodeError):
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
def read(self, source: str | PathLike[str]) -> list[PickSet]:
|
|
56
|
+
with open(source) as handle:
|
|
57
|
+
lines = handle.readlines()[1:]
|
|
58
|
+
|
|
59
|
+
picks = []
|
|
60
|
+
for line in lines:
|
|
61
|
+
fields = line.split()
|
|
62
|
+
if not fields or fields[0] != "phase:":
|
|
63
|
+
# Snuffler also writes plain time markers, which name no phase.
|
|
64
|
+
continue
|
|
65
|
+
identity = fields[4].replace("..", ".--.").split(".")
|
|
66
|
+
phase, branch, motion = PHASES[fields[8]]
|
|
67
|
+
weight = int(fields[3])
|
|
68
|
+
if weight > MAX_WEIGHT:
|
|
69
|
+
continue
|
|
70
|
+
picks.append(
|
|
71
|
+
Pick(
|
|
72
|
+
sensor=SensorID.parse(
|
|
73
|
+
".".join(
|
|
74
|
+
(
|
|
75
|
+
identity[0],
|
|
76
|
+
identity[1],
|
|
77
|
+
identity[2] if len(identity) > 2 else "--",
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
),
|
|
81
|
+
phase=phase,
|
|
82
|
+
time=obspy.UTCDateTime("T".join(fields[1:3])),
|
|
83
|
+
raw_phase=branch,
|
|
84
|
+
polarity=motion,
|
|
85
|
+
weight=float(weight),
|
|
86
|
+
channel=identity[3] if len(identity) > 3 else None,
|
|
87
|
+
# A marker file is what an analyst saved out of Snuffler.
|
|
88
|
+
automatic=False,
|
|
89
|
+
reviewed=True,
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
return [PickSet(picks=tuple(picks))]
|
specmod/pipeline.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Waveforms in, :class:`~specmod.core.SpectrumSet` out.
|
|
2
|
+
|
|
3
|
+
The replacement for ``spectral.Spectra.from_streams``, and the seam that
|
|
4
|
+
``spectral`` becomes a shell over. Given a signal stream and the noise cut to
|
|
5
|
+
match it, this produces the immutable containers directly — trace to
|
|
6
|
+
:class:`~specmod.core.Spectrum` to :class:`~specmod.core.SpectrumPair` — with
|
|
7
|
+
none of ``spectral``'s mutable classes in between.
|
|
8
|
+
|
|
9
|
+
**Why it lives here rather than in ``core``.** ``core`` and ``transforms``
|
|
10
|
+
know nothing about ObsPy: they operate on arrays, a duration and a sampling
|
|
11
|
+
rate. That is worth keeping, because it is what makes them testable without
|
|
12
|
+
constructing a Stream and usable on data that never came from one. This module
|
|
13
|
+
is the single place that knows what a ``Trace`` is, so the dependency stops at
|
|
14
|
+
one file rather than spreading through the containers.
|
|
15
|
+
|
|
16
|
+
**The route is shorter than the legacy one, and identical in value.**
|
|
17
|
+
``spectral.Spectrum`` converts the estimator's output to a PSD, copies the
|
|
18
|
+
arrays out, converts back to magnitude, then ``SNP`` wraps them in a
|
|
19
|
+
``core.Spectrum`` again — a round trip through two mutable objects that exists
|
|
20
|
+
only because the legacy call sequence had it. Here the estimator's spectrum is
|
|
21
|
+
converted once, to the unfolded magnitude convention the pipeline reads
|
|
22
|
+
``Omega`` in, and handed straight to :meth:`SpectrumPair.compare`.
|
|
23
|
+
|
|
24
|
+
That the two agree is not asserted, it is measured:
|
|
25
|
+
``tests/test_pipeline.py`` runs both over the same 28 windows and all five
|
|
26
|
+
estimators and requires the numbers to match to 1 part in 1e12.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import dataclasses
|
|
32
|
+
import inspect
|
|
33
|
+
from typing import TYPE_CHECKING, Any
|
|
34
|
+
|
|
35
|
+
import numpy as np
|
|
36
|
+
|
|
37
|
+
from .config import load_config
|
|
38
|
+
from .core import AmplitudeKind, Motion, Spectrum, SpectrumPair, SpectrumSet
|
|
39
|
+
from .transforms import ESTIMATORS
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
42
|
+
from collections.abc import Iterable, Mapping
|
|
43
|
+
|
|
44
|
+
from numpy.typing import NDArray
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"estimate_spectrum",
|
|
48
|
+
"pair_from_traces",
|
|
49
|
+
"spectrum_from_trace",
|
|
50
|
+
"spectrum_set_from_streams",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def estimate_spectrum(
|
|
55
|
+
data: NDArray[np.float64],
|
|
56
|
+
delta: float,
|
|
57
|
+
*,
|
|
58
|
+
motion: Motion | str = Motion.VELOCITY,
|
|
59
|
+
**kwargs: Any,
|
|
60
|
+
) -> Spectrum:
|
|
61
|
+
"""Transform a record with whichever estimator the configuration names.
|
|
62
|
+
|
|
63
|
+
The bridge to :mod:`specmod.transforms`. Every estimator — FFT, Welch,
|
|
64
|
+
multitaper, Prieto, quadratic, CWT — becomes available through
|
|
65
|
+
:class:`specmod.config.TransformConfig`, where before there was a hardcoded
|
|
66
|
+
``mtspec(data, delta, 3)``.
|
|
67
|
+
|
|
68
|
+
Returns a :class:`specmod.core.Spectrum`, which carries its own units, so
|
|
69
|
+
the caller no longer has to track how many times the record has been
|
|
70
|
+
integrated or what amplitude convention is in force. **The convention is
|
|
71
|
+
the folded one**, ``FAS``; :func:`spectrum_from_trace` is what converts to
|
|
72
|
+
the unfolded magnitude the rest of the pipeline reads ``Omega`` in.
|
|
73
|
+
|
|
74
|
+
Keyword arguments override the configured estimator's parameters, which is
|
|
75
|
+
how the ``**kwargs`` passthrough from the stream entry points works.
|
|
76
|
+
|
|
77
|
+
Lived in ``spectral`` until the direct pipeline existed, which put the one
|
|
78
|
+
typed bridge to ``transforms`` inside the untyped legacy shell and made
|
|
79
|
+
every caller of it untyped too.
|
|
80
|
+
"""
|
|
81
|
+
transform = load_config().config.transform
|
|
82
|
+
name = kwargs.pop("estimator", transform.estimator)
|
|
83
|
+
if name == "mtspec":
|
|
84
|
+
raise ValueError(
|
|
85
|
+
"estimator='mtspec' is the pre-refactor Fortran backend and is not "
|
|
86
|
+
"wired into the pipeline. Use 'prieto' for the same lineage with "
|
|
87
|
+
"no compiler, or 'multitaper' for the native implementation."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
cls = ESTIMATORS[name]
|
|
91
|
+
# By constructor signature, not by `dataclasses.fields`. The registry is
|
|
92
|
+
# typed over the `SpectralEstimator` protocol, which promises a `name` and
|
|
93
|
+
# an `estimate` and says nothing about being a dataclass — so asking for
|
|
94
|
+
# its fields is a claim the type does not support, and an estimator that
|
|
95
|
+
# was a plain class would break it. The signature is what "accepts this
|
|
96
|
+
# setting" actually means.
|
|
97
|
+
accepted = set(inspect.signature(cls).parameters)
|
|
98
|
+
settings = {
|
|
99
|
+
key: value
|
|
100
|
+
for key, value in dataclasses.asdict(transform).items()
|
|
101
|
+
if key in accepted
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# Filtering the *configuration* to what this estimator accepts is right:
|
|
105
|
+
# `[transform]` holds every estimator's settings at once, and a CWT
|
|
106
|
+
# parameter is not an error when the FFT is selected. Filtering the
|
|
107
|
+
# caller's own keywords the same way is not. It silently discarded
|
|
108
|
+
# anything the estimator did not recognise, so a typo, or an argument
|
|
109
|
+
# meant for a different stage, simply did nothing.
|
|
110
|
+
#
|
|
111
|
+
# This is not hypothetical: `spectrum_set_from_streams(rotate_noise=False)`
|
|
112
|
+
# looks exactly like it works. `rotate_noise` belongs to `compare`, not to
|
|
113
|
+
# an estimator, so it was dropped here and the run silently kept the
|
|
114
|
+
# configured value — which cost three wrong measurements before the
|
|
115
|
+
# recorded settings gave it away.
|
|
116
|
+
unknown = sorted(set(kwargs) - accepted)
|
|
117
|
+
if unknown:
|
|
118
|
+
compare_only = sorted(set(unknown) & set(_compare_settings()))
|
|
119
|
+
hint = ""
|
|
120
|
+
if compare_only:
|
|
121
|
+
verb = "configures" if len(compare_only) == 1 else "configure"
|
|
122
|
+
this = "it" if len(compare_only) == 1 else "them"
|
|
123
|
+
hint = (
|
|
124
|
+
f" {', '.join(compare_only)} {verb} the signal-to-noise "
|
|
125
|
+
f"comparison, not the transform — pass {this} as "
|
|
126
|
+
f"compare={{{', '.join(f'{k!r}: ...' for k in compare_only)}}}."
|
|
127
|
+
)
|
|
128
|
+
raise TypeError(
|
|
129
|
+
f"{name} does not accept {', '.join(unknown)}. It takes "
|
|
130
|
+
f"{', '.join(sorted(accepted - {'self'}))}.{hint}"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
settings.update(kwargs)
|
|
134
|
+
result: Spectrum = cls(**settings).estimate(data, delta, motion=motion)
|
|
135
|
+
return result
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
#: Trace stats that are not data and do not survive a round trip through a
|
|
139
|
+
#: plain mapping. ``processing`` is an ever-growing list of ObsPy call strings,
|
|
140
|
+
#: ``sac``/``calib``/``__format`` are format-specific, and all of them break
|
|
141
|
+
#: equality comparison between two runs of the same pipeline.
|
|
142
|
+
_DROPPED_STATS = frozenset({"processing", "sac", "calib", "__format"})
|
|
143
|
+
|
|
144
|
+
_SCALAR = (float, int, str, np.floating, np.integer)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _trace_meta(trace: Any) -> dict[str, Any]:
|
|
148
|
+
"""Trace stats as a plain, comparable mapping.
|
|
149
|
+
|
|
150
|
+
Non-scalars — ``UTCDateTime`` for the picks and window edges, mostly — are
|
|
151
|
+
stringified rather than dropped, because the window times are the record of
|
|
152
|
+
what was cut and a spectrum that cannot say where it came from is not
|
|
153
|
+
reproducible. Same rule the legacy ``__sanitise_trace_meta`` used, stated
|
|
154
|
+
as a set rather than as a chain of ``if``\\s.
|
|
155
|
+
"""
|
|
156
|
+
return {
|
|
157
|
+
key: (value if isinstance(value, _SCALAR) else str(value))
|
|
158
|
+
for key, value in dict(trace.stats).items()
|
|
159
|
+
if key not in _DROPPED_STATS
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def spectrum_from_trace(trace: Any, **kwargs: Any) -> Spectrum:
|
|
164
|
+
"""Transform one trace into a spectrum on the pipeline's convention.
|
|
165
|
+
|
|
166
|
+
The convention is ``MAGNITUDE``, the *unfolded* transform magnitude
|
|
167
|
+
``|X(f)|``, not the folded ``2|X|`` the estimators return. This is not a
|
|
168
|
+
preference: ``Omega`` is defined on the unfolded spectrum — the
|
|
169
|
+
long-period displacement plateau is ``|X(f -> 0)| = |int u dt|`` and
|
|
170
|
+
``M0`` is proportional to it — so reading it off a folded spectrum puts
|
|
171
|
+
every moment out by two, which is 0.2 magnitude units. See
|
|
172
|
+
:meth:`specmod.core.Spectrum.to_kind` for the conversion.
|
|
173
|
+
|
|
174
|
+
``kwargs`` override the configured estimator's parameters, including
|
|
175
|
+
``estimator`` itself.
|
|
176
|
+
"""
|
|
177
|
+
spectrum = estimate_spectrum(
|
|
178
|
+
np.asarray(trace.data, dtype=float),
|
|
179
|
+
float(trace.stats.delta),
|
|
180
|
+
**kwargs,
|
|
181
|
+
)
|
|
182
|
+
meta = _trace_meta(trace)
|
|
183
|
+
meta["id"] = trace.id
|
|
184
|
+
# The lowest frequency this window supports, captured here because
|
|
185
|
+
# interpolation onto a shared axis destroys it. For an FFT or multitaper it
|
|
186
|
+
# is 1/T; for the CWT the cone-of-influence floor, about 1.4x stricter on
|
|
187
|
+
# these windows because a wavelet needs several cycles in the record rather
|
|
188
|
+
# than one. Taking it from the axis rather than from a formula makes the
|
|
189
|
+
# right rule apply to each without a special case.
|
|
190
|
+
meta["resolution_floor"] = float(spectrum.freq.min())
|
|
191
|
+
meta["estimator"] = spectrum.meta.get("estimator")
|
|
192
|
+
|
|
193
|
+
return Spectrum(
|
|
194
|
+
freq=spectrum.freq,
|
|
195
|
+
amp=spectrum.to_kind(AmplitudeKind.MAGNITUDE).amp,
|
|
196
|
+
motion=spectrum.motion,
|
|
197
|
+
kind=AmplitudeKind.MAGNITUDE,
|
|
198
|
+
duration=spectrum.duration,
|
|
199
|
+
sampling_rate=spectrum.sampling_rate,
|
|
200
|
+
meta=meta,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _compare_settings(overrides: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
|
205
|
+
"""Configured arguments for :meth:`SpectrumPair.compare`.
|
|
206
|
+
|
|
207
|
+
One place, so the per-pair and per-stream entry points cannot disagree
|
|
208
|
+
about what the configuration said.
|
|
209
|
+
"""
|
|
210
|
+
config = load_config().config
|
|
211
|
+
settings: dict[str, Any] = {
|
|
212
|
+
"threshold": config.snr.tolerance,
|
|
213
|
+
"f_min": config.smoothing.f_min,
|
|
214
|
+
"f_max": config.smoothing.f_max,
|
|
215
|
+
"n_bins": config.smoothing.n_bins,
|
|
216
|
+
"scale_parseval": config.snr.scale_parseval,
|
|
217
|
+
"rotate_noise": config.snr.rotate_noise,
|
|
218
|
+
"noise_model": config.snr.rotation_method,
|
|
219
|
+
"resolution_floor": config.snr.resolution_floor,
|
|
220
|
+
"bandwidth": config.snr.bandwidth_method,
|
|
221
|
+
"rotation_space": config.snr.rotation_space,
|
|
222
|
+
}
|
|
223
|
+
settings.update(overrides or {})
|
|
224
|
+
return settings
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def pair_from_traces(
|
|
228
|
+
signal_trace: Any,
|
|
229
|
+
noise_trace: Any,
|
|
230
|
+
*,
|
|
231
|
+
compare: Mapping[str, Any] | None = None,
|
|
232
|
+
**kwargs: Any,
|
|
233
|
+
) -> SpectrumPair:
|
|
234
|
+
"""Transform a signal and its noise, and pair them.
|
|
235
|
+
|
|
236
|
+
``kwargs`` go to the estimator; ``compare`` overrides individual arguments
|
|
237
|
+
to :meth:`SpectrumPair.compare`, which otherwise come from configuration.
|
|
238
|
+
"""
|
|
239
|
+
signal = spectrum_from_trace(signal_trace, **kwargs)
|
|
240
|
+
noise = spectrum_from_trace(noise_trace, **kwargs)
|
|
241
|
+
return SpectrumPair.compare(signal, noise, **_compare_settings(compare))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def spectrum_set_from_streams(
|
|
245
|
+
signal: Iterable[Any],
|
|
246
|
+
noise: Iterable[Any],
|
|
247
|
+
*,
|
|
248
|
+
event: str = "",
|
|
249
|
+
compare: Mapping[str, Any] | None = None,
|
|
250
|
+
**kwargs: Any,
|
|
251
|
+
) -> SpectrumSet:
|
|
252
|
+
"""Pair two streams trace by trace.
|
|
253
|
+
|
|
254
|
+
The two are matched by **position**, as the legacy path matched them, since
|
|
255
|
+
that is how ``preprocess.get_noise_p`` builds the noise: one trace per
|
|
256
|
+
signal trace, in order. Mismatched ids are an error rather than a warning —
|
|
257
|
+
a signal compared against another station's noise is not a degraded
|
|
258
|
+
measurement, it is a meaningless one.
|
|
259
|
+
|
|
260
|
+
``event`` defaults to the origin time carried on the first trace, which is
|
|
261
|
+
what the legacy container used to name the group.
|
|
262
|
+
"""
|
|
263
|
+
settings = _compare_settings(compare)
|
|
264
|
+
pairs: dict[str, SpectrumPair] = {}
|
|
265
|
+
|
|
266
|
+
for signal_trace, noise_trace in zip(signal, noise, strict=True):
|
|
267
|
+
if signal_trace.id != noise_trace.id:
|
|
268
|
+
raise ValueError(
|
|
269
|
+
f"signal {signal_trace.id} paired with noise {noise_trace.id}; "
|
|
270
|
+
f"the streams are not in the same order"
|
|
271
|
+
)
|
|
272
|
+
signal_spectrum = spectrum_from_trace(signal_trace, **kwargs)
|
|
273
|
+
noise_spectrum = spectrum_from_trace(noise_trace, **kwargs)
|
|
274
|
+
pairs[signal_trace.id] = SpectrumPair.compare(
|
|
275
|
+
signal_spectrum, noise_spectrum, **settings
|
|
276
|
+
)
|
|
277
|
+
if not event:
|
|
278
|
+
event = str(signal_trace.stats.get("otime", ""))
|
|
279
|
+
|
|
280
|
+
return SpectrumSet(pairs=pairs, event=event)
|