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,284 @@
1
+ """Fitting every passing station in an event, and the table that comes out."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import warnings
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ import matplotlib.pyplot as plt
10
+ import pandas as pd
11
+
12
+ from .. import config as cfg
13
+ from ..tables import read_table, write_table
14
+ from .base import SpectraLike, plot_columns
15
+ from .guess import fittable_signal, initial_guess
16
+ from .spectrum import FitSpectrum
17
+
18
+ if TYPE_CHECKING: # pragma: no cover
19
+ from collections.abc import Mapping
20
+ from pathlib import Path
21
+
22
+ __all__ = ["FitSpectra"]
23
+
24
+
25
+ #: Per-station progress goes here rather than to stdout. A library must not
26
+ #: configure logging for its host, so there is no `basicConfig` anywhere in
27
+ #: this package: a caller that wants to see these calls `logging.basicConfig`
28
+ #: itself, and one that does not is not written to by surprise.
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ class FitSpectra:
33
+ """Fit every passing station in an event."""
34
+
35
+ #: Declarations, as on :class:`FitSpectrum`. `models = {}` at class level
36
+ #: was one dictionary shared by every `FitSpectra` ever built; `__init__`
37
+ #: rebinds it, so nothing reached the shared copy, but nothing prevented it
38
+ #: either. `guess = {}` was never assigned anywhere at all — a class
39
+ #: attribute recording a constructor argument that is not kept.
40
+ spectra: SpectraLike
41
+ models: dict[str, FitSpectrum]
42
+ table: pd.DataFrame
43
+
44
+ def __init__(
45
+ self,
46
+ spectra: SpectraLike,
47
+ model: Any = None,
48
+ guess: Mapping[str, Mapping[str, float]] | None = None,
49
+ fit_bins: bool | None = None,
50
+ ) -> None:
51
+ """``guess=None`` derives one, rather than fitting nothing.
52
+
53
+ It used to skip `init_fitting` entirely, so `FitSpectra(spectra)` built
54
+ an object with no models and `fit_spectra()` silently did nothing and
55
+ produced an empty table. There is a sensible guess available — see
56
+ :func:`initial_guess` — so that is now the default and an explicit
57
+ ``guess={}`` is how you say "none".
58
+ """
59
+ self.models = {}
60
+ self.table = pd.DataFrame([])
61
+ self.set_spectra(spectra)
62
+ if fit_bins is None:
63
+ fit_bins = cfg.load_config().config.fitting.fit_bins
64
+ if guess is None:
65
+ guess = initial_guess(spectra, model)
66
+ self.init_fitting(model, guess, fit_bins)
67
+
68
+ def __len__(self) -> int:
69
+ return len(self.models)
70
+
71
+ def set_spectra(self, spectra: SpectraLike) -> None:
72
+ if self.__check_spectra(spectra):
73
+ self.spectra = spectra
74
+
75
+ def get_spectra(self) -> SpectraLike:
76
+ return self.spectra
77
+
78
+ def get_fit(self, id: str) -> FitSpectrum | None:
79
+ if id.upper() in self.models:
80
+ return self.models[id.upper()]
81
+ warnings.warn(
82
+ f"{id.upper()} is not among the fitted stations "
83
+ f"({', '.join(sorted(self.models)) or 'none'}); returning None.",
84
+ stacklevel=2,
85
+ )
86
+ return None
87
+
88
+ def fit_spectra(self, weight_method: str | None = None, **kwargs: Any) -> None:
89
+ """Fit every station, with the configured minimiser unless told otherwise.
90
+
91
+ ``method`` and ``weight_method`` both come from ``[fitting]`` when not
92
+ given. Neither used to: `fit_spectra()` fell through to lmfit's default
93
+ minimiser, so a study file saying ``method = "powell"`` was ignored and
94
+ the caller had to remember ``fit_spectra(method="powell")`` — which the
95
+ tutorial does and nothing enforced.
96
+
97
+ It matters. On the 28 PNR windows lmfit's default returns a **negative
98
+ corner frequency** on one station where Powell does not; a corner
99
+ frequency below zero is not a degraded measurement but a meaningless
100
+ one, and nothing downstream rejects it.
101
+ """
102
+ fitting = cfg.load_config().config.fitting
103
+ if weight_method is None:
104
+ weight_method = fitting.weight_method
105
+ kwargs.setdefault("method", fitting.method)
106
+ wm = self.__check_wm(weight_method)
107
+ for name, mod in self.models.items():
108
+ try:
109
+ if wm == "log":
110
+ mod.fit_mod(weights=1 / mod.mod_freq, **kwargs)
111
+ else:
112
+ mod.fit_mod(**kwargs)
113
+ except ValueError as error:
114
+ # `logging`, not `warnings`, and the difference matters here:
115
+ # warnings are deduplicated per code location by default, so a
116
+ # run that skipped twenty stations would report one. Each skip
117
+ # is a station missing from the results and has to be visible.
118
+ logger.warning("skipping %s: %s", name, error)
119
+
120
+ self.__set_fit_models_to_spectrum()
121
+ self.__generate_group_fit_table()
122
+
123
+ def init_fitting(
124
+ self,
125
+ model: Any,
126
+ guess: Mapping[str, Mapping[str, float]],
127
+ fit_bins: bool,
128
+ ) -> None:
129
+ """Build a fit per passing station.
130
+
131
+ ``model=None`` resolves through the configuration once per station,
132
+ which is cheap and keeps every fit in a run agreeing on what it is
133
+ fitting.
134
+ """
135
+ # Iterate the container rather than reaching into `.group`. `Spectra`
136
+ # and `core.SpectrumSet` both present this interface, which is what
137
+ # lets the container be swapped underneath without touching the fitter.
138
+ # A station is fitted when it passed the gate *and* has a guess.
139
+ # Indexing `guess[id]` unconditionally made a partial guess dict a
140
+ # `KeyError` naming a station, rather than a way to fit a subset —
141
+ # and made `guess={}` a crash instead of "fit nothing".
142
+ tmp: dict[str, FitSpectrum] = {}
143
+ for id in self.spectra:
144
+ signal = fittable_signal(self.spectra[id], id)
145
+ if signal is None or id not in guess:
146
+ continue
147
+ tmp[id] = FitSpectrum(signal, model, **guess[id], fit_bins=fit_bins)
148
+ self.models = tmp
149
+
150
+ def set_const(self, pname: str, value: float, id: str | None = None) -> None:
151
+ if id is None:
152
+ for mod in self.models.values():
153
+ mod.set_const(pname, value)
154
+ elif id in self.models:
155
+ self.models[id].set_const(pname, value)
156
+
157
+ def set_bounds(
158
+ self, pname: str, min: float | None = None, max: float | None = None
159
+ ) -> None:
160
+ for mod in self.models.values():
161
+ mod.set_bounds(pname, min, max)
162
+
163
+ def reset(self, name: str = "all") -> None:
164
+ """Unbind every parameter, on one station or all of them.
165
+
166
+ The lookup tested ``name.upper()`` for membership and then indexed with
167
+ ``name``, so any id not already upper-case passed the check and raised
168
+ ``KeyError`` on the next line. Station ids are upper-case in practice,
169
+ which is why it never fired.
170
+ """
171
+ if name.upper() == "ALL":
172
+ for mod in self.models.values():
173
+ mod.reset()
174
+ return
175
+
176
+ id = name.upper()
177
+ if id in self.models:
178
+ self.models[id].reset()
179
+ else:
180
+ warnings.warn(
181
+ f"{id} is not among the fitted stations; nothing was reset.",
182
+ stacklevel=2,
183
+ )
184
+
185
+ def quick_vis(self, save: str | None = None) -> None:
186
+ rows = self.__num_rows()
187
+ fig, axes = plt.subplots(rows, plot_columns(), figsize=(17, int(rows * 5)))
188
+ # `strict=False`: the grid is rounded up to whole rows, so there are
189
+ # more axes than models by construction.
190
+ for ax, mod in zip(axes.flatten(), self.models.values(), strict=False):
191
+ if mod.result is None or not mod.pass_fitting:
192
+ ax.set_title(f"Fitting Failed for {mod.sig.id}")
193
+ else:
194
+ mod.quick_vis(ax)
195
+
196
+ if save is not None:
197
+ if type(save) is str:
198
+ fig.savefig(save)
199
+ else:
200
+ raise ValueError("Must provide valid path as str.")
201
+
202
+ @staticmethod
203
+ def write_flatfile(path: str | Path, fits: FitSpectra) -> Path:
204
+ """Write the group fit table, in the format ``path``'s suffix names.
205
+
206
+ ``.parquet`` is typed, compressed and queryable without loading;
207
+ ``.csv`` is what journal supplements want. See :mod:`specmod.tables`.
208
+
209
+ The previous implementation was ``os.makedirs(os.path.join(
210
+ *path.split("/")[:-1]))``, which raised ``TypeError: join() missing 1
211
+ required positional argument`` for any path without a directory
212
+ component — ``write_flatfile("out.csv", fits)`` could not work. It also
213
+ split on ``/`` literally, so it did nothing useful on Windows.
214
+ """
215
+ return write_table(path, fits.table)
216
+
217
+ @staticmethod
218
+ def read_flatfile(path: str | Path) -> pd.DataFrame:
219
+ """Read a fit table back. Format follows the suffix."""
220
+ return read_table(path)
221
+
222
+ def __check_wm(self, wm: str) -> str:
223
+ if wm not in ["log", "none"]:
224
+ warnings.warn(
225
+ f"Unknown weight method {wm!r}; expected 'log' or 'none'. "
226
+ "Falling back to 'none'.",
227
+ stacklevel=3,
228
+ )
229
+ wm = "none"
230
+ return wm
231
+
232
+ def __generate_group_fit_table(self) -> None:
233
+ ds = [m.meta for m in self.models.values()]
234
+ df1 = pd.DataFrame([])
235
+ for i, d in enumerate(ds):
236
+ df1 = pd.concat(
237
+ [df1, pd.DataFrame(d, index=[i])], ignore_index=True, sort=False
238
+ )
239
+ self.table = df1
240
+
241
+ def __set_fit_models_to_spectrum(self) -> None:
242
+ """Hand each fit back to the spectrum it came from, where that is possible.
243
+
244
+ The legacy `Signal` carries its own fit so that plotting and
245
+ serialisation can reach it from the spectrum. `core.SpectrumPair` is
246
+ frozen and cannot, by design — a result writing itself back into its
247
+ own input is how a container stops being trustworthy.
248
+
249
+ Nothing is lost by skipping it: `self.models` is the source of truth
250
+ either way, and the write-back was only ever a convenience. So this
251
+ writes where the container accepts it and moves on where it does not,
252
+ rather than requiring every container to be mutable.
253
+ """
254
+ for id, mod in self.models.items():
255
+ spectrum = self.spectra[id]
256
+ signal = getattr(spectrum, "signal", spectrum)
257
+ setter = getattr(signal, "set_model", None)
258
+ if setter is not None:
259
+ setter(mod)
260
+
261
+ def __check_spectra(self, spectra: SpectraLike) -> bool:
262
+ """Accept anything that maps trace ids to paired spectra.
263
+
264
+ Was ``isinstance(spectra, spectral.Spectra)``, which is why the fitter
265
+ could not be handed a :class:`~specmod.core.SpectrumSet` even though it
266
+ only ever iterates and indexes. Requiring one concrete class was the
267
+ last thing tying the fitter to the legacy module.
268
+ """
269
+ required = ("__iter__", "__getitem__", "__len__")
270
+ missing = [name for name in required if not hasattr(spectra, name)]
271
+ if missing:
272
+ raise ValueError(
273
+ f"{type(spectra).__name__} cannot be fitted: it must map trace "
274
+ f"ids to paired spectra, and is missing {', '.join(missing)}. "
275
+ f"Use specmod.pipeline.spectrum_set_from_streams."
276
+ )
277
+ return True
278
+
279
+ def __num_rows(self) -> int:
280
+ count = len(self)
281
+ cols = plot_columns()
282
+ if count % cols > 0:
283
+ return int((cols * (int(count / cols) + 1)) / cols)
284
+ return int(count / cols)
@@ -0,0 +1,170 @@
1
+ """Choosing what to fit, and where the fit starts from."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import warnings
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+
11
+ from .. import config as cfg
12
+ from .. import sources
13
+ from ..core.units import Motion
14
+ from .base import SpectraLike, Spectrumish
15
+
16
+ __all__ = ["fittable_signal", "initial_guess", "selected_band"]
17
+
18
+
19
+ def fittable_signal(pair: Any, id: str = "") -> Spectrumish | None:
20
+ """The signal to fit from a paired spectrum, or ``None`` to skip it.
21
+
22
+ Skipping is a decision the container should not have to spell out at every
23
+ call site: a pair is unfittable when the signal-to-noise gate rejected it.
24
+
25
+ What comes back for a :class:`~specmod.core.SpectrumPair` is its
26
+ :class:`~specmod.core.collection.FittableView`, not its ``signal``. The
27
+ pair keeps the unbinned and binned spectra as separate objects, which is
28
+ right for the comparison and wrong for a fitter that wants ``freq``,
29
+ ``amp``, ``bfreq`` and ``bamp`` side by side; the view is what puts them
30
+ there. ``id`` names the station on it, since a frozen pair does not carry
31
+ one of its own.
32
+
33
+ The ``getattr`` fallback below is what a spectrum-like object that is not
34
+ a pair takes — a bare view, or anything else presenting the same
35
+ attributes. It is not a legacy shim; it is what lets the fitter be given
36
+ something constructed by hand.
37
+ """
38
+ view = getattr(pair, "for_fitting", None)
39
+ if view is not None:
40
+ return pair.for_fitting(id) if pair.passes else None
41
+
42
+ signal = getattr(pair, "signal", pair)
43
+ passes = getattr(pair, "passes", None)
44
+ if passes is None:
45
+ passes = getattr(signal, "pass_snr", True)
46
+ return signal if passes else None
47
+
48
+
49
+ def _warn_if_peak_is_meaningless(signal: Spectrumish, id: str) -> None:
50
+ """Warn when the peak-as-``fc`` guess is being read off the wrong domain.
51
+
52
+ A no-op for velocity, and for a spectrum that does not say what motion it
53
+ carries — something assembled by hand is the caller's business.
54
+ """
55
+ motion = getattr(signal, "motion", None)
56
+ if motion is None or Motion(motion) is Motion.VELOCITY:
57
+ return
58
+ warnings.warn(
59
+ f"{id or 'this spectrum'} is in {Motion(motion).value}, and the "
60
+ f"initial guess for fc is the frequency of the spectral peak — which "
61
+ f"is the corner only in velocity. A {Motion(motion).value} spectrum "
62
+ f"falls monotonically across the band, so the guess will be a band "
63
+ f"edge and the fit will settle near it. Fit the velocity spectrum; "
64
+ f"`llpsp` is the displacement plateau either way.",
65
+ stacklevel=3,
66
+ )
67
+
68
+
69
+ def initial_guess(
70
+ spectra: SpectraLike, model: Any = None
71
+ ) -> dict[str, dict[str, float]]:
72
+ """Starting parameters for every fittable spectrum in ``spectra``.
73
+
74
+ Replaces ``model_guess.create_simple_guess`` and its ``_fdep`` twin, which
75
+ were two near-identical functions differing only in whether they added an
76
+ ``a`` for frequency-dependent Q — so adding a third model meant writing a
77
+ third guess function, and picking the wrong one gave lmfit a parameter the
78
+ model did not take.
79
+
80
+ **Which parameters are needed is asked of the model, not assumed.** The
81
+ fitted callable declares them in its signature, so a model gets exactly the
82
+ guesses it takes and nothing else. Values that cannot be read off the
83
+ spectrum come from ``[fitting]`` in the configuration.
84
+
85
+ The two that *are* read off the spectrum:
86
+
87
+ ``llpsp``
88
+ ``log10`` of the largest amplitude inside the selected band — the
89
+ long-period plateau, which is what ``Omega`` is.
90
+ ``fc``
91
+ the frequency at which that maximum falls.
92
+
93
+ Both assume a **velocity** spectrum, which is where a fit belongs anyway:
94
+ the model carries a motion factor, so ``llpsp`` is the displacement plateau
95
+ whichever domain is fitted, but converting first is not a neutral change of
96
+ view — integrating implicitly low-passes and differentiating amplifies
97
+ high-frequency noise, so the record to fit is the one the sensor recorded.
98
+
99
+ In velocity the peak is not merely near the corner, it *is* the corner, for
100
+ any omega-squared source: the stationary point of
101
+ ``f * [1 + (f/fc)**(gamma*n)]**(-1/gamma)`` sits at ``f = fc`` whenever
102
+ ``n == 2``, whatever the corner sharpness. In displacement and acceleration
103
+ the spectrum is monotonic across the band, so the peak is whichever band
104
+ edge it was handed and the guess is meaningless. Handed one of those, this
105
+ warns rather than proceeding quietly.
106
+
107
+ Stations with no band are omitted rather than given ``None`` guesses. The
108
+ old version emitted ``{"llpsp": None, "fc": None, "ts": None}`` on
109
+ ``IndexError``, which lmfit cannot use — the failure simply moved to the
110
+ fit call.
111
+ """
112
+ if model is None:
113
+ model = sources.from_config()
114
+ callable_ = (
115
+ model.as_callable() if isinstance(model, sources.SpectralModel) else model
116
+ )
117
+ wanted = set(inspect.signature(callable_).parameters) - {"f"}
118
+
119
+ fitting = cfg.load_config().config.fitting
120
+ #: Parameters no spectrum can suggest a value for.
121
+ defaults = {
122
+ "ts": fitting.initial_t_star,
123
+ "a": fitting.initial_alpha,
124
+ }
125
+
126
+ guesses: dict[str, dict[str, float]] = {}
127
+ for id in spectra:
128
+ signal = fittable_signal(spectra[id], id)
129
+ if signal is None:
130
+ continue
131
+ band = selected_band(signal)
132
+ if band is None:
133
+ continue
134
+ inside = (signal.freq >= band[0]) & (signal.freq <= band[1])
135
+ if not inside.any():
136
+ continue
137
+
138
+ _warn_if_peak_is_meaningless(signal, id)
139
+
140
+ amp, freq = signal.amp[inside], signal.freq[inside]
141
+ peak = int(amp.argmax())
142
+ available = {
143
+ "llpsp": float(np.log10(amp[peak])),
144
+ "fc": float(freq[peak]),
145
+ **defaults,
146
+ }
147
+ missing = wanted - set(available)
148
+ if missing:
149
+ raise ValueError(
150
+ f"no initial guess is defined for {sorted(missing)}, which "
151
+ f"{getattr(model, 'describe', lambda: callable_.__name__)()} "
152
+ f"takes. Add it to specmod.config.FittingConfig and to "
153
+ f"`initial_guess`, or pass explicit guesses."
154
+ )
155
+ guesses[id] = {k: v for k, v in available.items() if k in wanted}
156
+
157
+ return guesses
158
+
159
+
160
+ def selected_band(spectrum: Any) -> tuple[float, float] | None:
161
+ """The band to fit over, or ``None`` to fit everything available.
162
+
163
+ ``None`` rather than an empty array, because "no band survived" and "a band
164
+ from 0 to 0" are different claims and the legacy spelling — an empty
165
+ ``ubfreqs`` — could be read as either.
166
+ """
167
+ band = getattr(spectrum, "band", None)
168
+ if band is None:
169
+ return None
170
+ return (float(band[0]), float(band[1]))