specmod 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- specmod/__init__.py +17 -0
- specmod/_vendor/__init__.py +21 -0
- specmod/_vendor/qiinv.py +243 -0
- specmod/acquire.py +358 -0
- specmod/api.py +480 -0
- specmod/cli.py +139 -0
- specmod/config/__init__.py +44 -0
- specmod/config/layers.py +168 -0
- specmod/config/provenance.py +77 -0
- specmod/config/sections.py +385 -0
- specmod/config/serialize.py +58 -0
- specmod/core/__init__.py +41 -0
- specmod/core/bandwidth.py +187 -0
- specmod/core/collection.py +549 -0
- specmod/core/noise.py +478 -0
- specmod/core/scalogram.py +234 -0
- specmod/core/spectrum.py +326 -0
- specmod/core/units.py +116 -0
- specmod/datasets.py +316 -0
- specmod/distance.py +190 -0
- specmod/exceptions.py +58 -0
- specmod/fitting/__init__.py +58 -0
- specmod/fitting/base.py +50 -0
- specmod/fitting/event.py +284 -0
- specmod/fitting/guess.py +170 -0
- specmod/fitting/spectrum.py +330 -0
- specmod/io.py +241 -0
- specmod/magnitude.py +312 -0
- specmod/picks/__init__.py +182 -0
- specmod/picks/base.py +250 -0
- specmod/picks/delimited.py +224 -0
- specmod/picks/events.py +157 -0
- specmod/picks/resolution.py +149 -0
- specmod/picks/snuffler.py +92 -0
- specmod/pipeline.py +280 -0
- specmod/plotting.py +203 -0
- specmod/preprocess.py +554 -0
- specmod/smoothing/__init__.py +50 -0
- specmod/smoothing/base.py +56 -0
- specmod/smoothing/konno_ohmachi.py +83 -0
- specmod/smoothing/log_bins.py +171 -0
- specmod/sources/__init__.py +65 -0
- specmod/sources/attenuation.py +110 -0
- specmod/sources/composite.py +135 -0
- specmod/sources/motion.py +40 -0
- specmod/sources/source.py +147 -0
- specmod/spreading.py +209 -0
- specmod/staged.py +523 -0
- specmod/tables.py +110 -0
- specmod/transforms/__init__.py +50 -0
- specmod/transforms/base.py +242 -0
- specmod/transforms/cwt.py +219 -0
- specmod/transforms/fft.py +157 -0
- specmod/transforms/multitaper.py +357 -0
- specmod/transforms/prieto.py +272 -0
- specmod/transforms/quadratic.py +221 -0
- specmod/utils.py +305 -0
- specmod-0.2.0.dist-info/METADATA +294 -0
- specmod-0.2.0.dist-info/RECORD +62 -0
- specmod-0.2.0.dist-info/WHEEL +4 -0
- specmod-0.2.0.dist-info/entry_points.txt +2 -0
- specmod-0.2.0.dist-info/licenses/LICENSE +21 -0
specmod/fitting/event.py
ADDED
|
@@ -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)
|
specmod/fitting/guess.py
ADDED
|
@@ -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]))
|