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,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)