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,83 @@
1
+ """Konno & Ohmachi (1998) smoothing.
2
+
3
+ A constant-width window in log-frequency: narrow at low frequency, wide at
4
+ high. That is what makes it the convention in engineering seismology — it
5
+ avoids over-smoothing the low-frequency band that constrains the long-period
6
+ level, while still suppressing scatter in the high-frequency tail.
7
+
8
+ The window is ``[sin(b log10(f/fc)) / (b log10(f/fc))]^4``, with ``b`` the
9
+ bandwidth. Smaller ``b`` smooths harder; 40 is the conventional value.
10
+
11
+ ObsPy already implements this and is already a hard dependency, so this is a
12
+ thin adapter rather than a reimplementation. It keeps the frequency axis
13
+ unchanged, unlike :class:`~specmod.smoothing.log_bins.LogBinner`, which is
14
+ often what you want before fitting on the original grid.
15
+
16
+ References
17
+ ----------
18
+ Konno, K. & Ohmachi, T. (1998). Ground-motion characteristics estimated from
19
+ spectral ratio between horizontal and vertical components of microtremor.
20
+ *BSSA* 88(1), 228-241.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass, replace
26
+
27
+ import numpy as np
28
+ from obspy.signal.konnoohmachismoothing import konno_ohmachi_smoothing
29
+
30
+ from ..core.spectrum import Spectrum
31
+ from .base import record_smoothing
32
+
33
+ __all__ = ["KonnoOhmachi"]
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class KonnoOhmachi:
38
+ """Konno-Ohmachi smoothing at fixed log-frequency bandwidth.
39
+
40
+ Parameters
41
+ ----------
42
+ bandwidth
43
+ The ``b`` coefficient. Smaller values smooth more aggressively.
44
+ count
45
+ Number of times to apply the window. More than one is occasionally
46
+ used for very noisy spectra.
47
+ normalize
48
+ Normalise each window to unit area. Without this the smoothed
49
+ amplitudes are biased low where the window is truncated at the edges of
50
+ the frequency axis.
51
+ """
52
+
53
+ bandwidth: float = 40.0
54
+ count: int = 1
55
+ normalize: bool = True
56
+ name: str = "konno_ohmachi"
57
+
58
+ def __post_init__(self) -> None:
59
+ if self.bandwidth <= 0:
60
+ raise ValueError(f"bandwidth must be positive, got {self.bandwidth}")
61
+ if self.count < 1:
62
+ raise ValueError(f"count must be at least 1, got {self.count}")
63
+
64
+ def smooth(self, spectrum: Spectrum) -> Spectrum:
65
+ # ObsPy needs float32 or float64 and rejects anything else outright.
66
+ smoothed = konno_ohmachi_smoothing(
67
+ np.asarray(spectrum.amp, dtype=np.float64),
68
+ np.asarray(spectrum.freq, dtype=np.float64),
69
+ bandwidth=self.bandwidth,
70
+ count=self.count,
71
+ normalize=self.normalize,
72
+ )
73
+ return replace(
74
+ spectrum,
75
+ amp=np.asarray(smoothed, dtype=np.float64),
76
+ meta=record_smoothing(
77
+ spectrum.meta,
78
+ self.name,
79
+ bandwidth=self.bandwidth,
80
+ count=self.count,
81
+ normalize=self.normalize,
82
+ ),
83
+ )
@@ -0,0 +1,171 @@
1
+ """Log-spaced frequency binning.
2
+
3
+ Replaces ``Spectrum.__bin_spectrum``, which had four problems:
4
+
5
+ 1. Bin edges were hardcoded to 0.001-200 Hz regardless of the record. For a
6
+ 100 Hz trace that puts a third of the bins above Nyquist and a third below
7
+ ``1/T``, where there is no data — so they came out empty.
8
+ 2. Empty bins produced ``nan`` and a ``RuntimeWarning`` per bin, then were
9
+ dropped. Dropping means the binned axis has a *different length* for
10
+ different traces, which the SNR code then compares element-wise.
11
+ 3. The bin count actually used came from a config dict the caller could not
12
+ override per-call.
13
+ 4. It ran unconditionally inside ``__init__``.
14
+
15
+ Here the default edges are derived from the record — ``1/T`` to Nyquist, the
16
+ band where data actually exists — and empty bins are handled explicitly.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass, replace
22
+ from typing import Literal
23
+
24
+ import numpy as np
25
+ from numpy.typing import NDArray
26
+
27
+ from ..core.spectrum import Spectrum
28
+ from .base import record_smoothing
29
+
30
+ __all__ = ["LogBinner"]
31
+
32
+ Statistic = Literal["geometric", "mean", "median"]
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class LogBinner:
37
+ """Average a spectrum into log-spaced frequency bins.
38
+
39
+ Parameters
40
+ ----------
41
+ f_min, f_max
42
+ Bin edges in Hz. ``None`` derives them from the record: ``1/T`` for
43
+ ``f_min`` and Nyquist for ``f_max``. Deriving is the sensible default
44
+ because it is exactly the band the record can represent.
45
+ n_bins
46
+ Number of bins.
47
+ statistic
48
+ How to combine samples within a bin. ``geometric`` is the mean of
49
+ ``log10(amp)``, which is what the pre-refactor code computed and the
50
+ right choice for a quantity spanning orders of magnitude — an
51
+ arithmetic mean over a decade of amplitudes is dominated by its largest
52
+ member.
53
+ min_count
54
+ Minimum samples for a bin to be kept when ``drop_empty`` is set.
55
+ drop_empty
56
+ Drop bins holding fewer than ``min_count`` samples. Log bins over a
57
+ linearly-spaced frequency axis are inevitably sparse at the low end —
58
+ a bin is only reliably populated above roughly ``1 / (2.3 * dlog10f * T)``
59
+ — so dropping is usually what you want for plotting or fitting.
60
+
61
+ Set ``False`` to keep a **fixed-length** axis with ``nan`` in the empty
62
+ bins. Combined with explicit ``f_min``/``f_max`` that guarantees two
63
+ spectra bin onto identical axes, which is what an element-wise
64
+ signal-to-noise ratio requires.
65
+ """
66
+
67
+ f_min: float | None = None
68
+ f_max: float | None = None
69
+ n_bins: int = 151
70
+ statistic: Statistic = "geometric"
71
+ min_count: int = 1
72
+ drop_empty: bool = True
73
+ name: str = "log_bins"
74
+
75
+ def __post_init__(self) -> None:
76
+ if self.n_bins < 1:
77
+ raise ValueError(f"n_bins must be at least 1, got {self.n_bins}")
78
+ if self.min_count < 1:
79
+ raise ValueError(f"min_count must be at least 1, got {self.min_count}")
80
+ if self.f_min is not None and self.f_min <= 0:
81
+ raise ValueError(
82
+ f"f_min must be positive for log spacing, got {self.f_min}"
83
+ )
84
+ if (
85
+ self.f_min is not None
86
+ and self.f_max is not None
87
+ and self.f_min >= self.f_max
88
+ ):
89
+ raise ValueError(f"f_min ({self.f_min}) must be below f_max ({self.f_max})")
90
+
91
+ def edges_for(self, spectrum: Spectrum) -> NDArray[np.float64]:
92
+ """Bin edges for a given spectrum.
93
+
94
+ Explicit ``f_min``/``f_max`` are honoured **exactly** and never clamped
95
+ to the spectrum's own range. That matters more than it looks: signal and
96
+ noise windows have different durations, so clamping would bin them onto
97
+ different axes, and the SNR ratio compares them element-wise. Pinning
98
+ both edges is how a caller guarantees a shared axis.
99
+
100
+ Only derived bounds are clamped, since deriving already means "whatever
101
+ this record supports".
102
+ """
103
+ if self.f_min is not None:
104
+ lo = self.f_min
105
+ else:
106
+ lo = max(spectrum.frequency_resolution, float(spectrum.freq[0]))
107
+ if self.f_max is not None:
108
+ hi = self.f_max
109
+ else:
110
+ hi = min(spectrum.nyquist, float(spectrum.freq[-1]))
111
+ if lo >= hi:
112
+ raise ValueError(
113
+ f"Binning range [{lo:.4g}, {hi:.4g}] Hz is empty for a spectrum "
114
+ f"spanning [{spectrum.freq[0]:.4g}, {spectrum.freq[-1]:.4g}] Hz."
115
+ )
116
+ return np.logspace(np.log10(lo), np.log10(hi), self.n_bins + 1)
117
+
118
+ def smooth(self, spectrum: Spectrum) -> Spectrum:
119
+ edges = self.edges_for(spectrum)
120
+ # Bin centres are the geometric midpoints, matching the log spacing.
121
+ centres = np.sqrt(edges[:-1] * edges[1:])
122
+
123
+ idx = np.digitize(spectrum.freq, edges) - 1
124
+ valid = (idx >= 0) & (idx < self.n_bins)
125
+ if not valid.any():
126
+ raise ValueError(
127
+ f"Binning range [{edges[0]:.4g}, {edges[-1]:.4g}] Hz does not "
128
+ f"overlap the spectrum, which spans "
129
+ f"[{spectrum.freq[0]:.4g}, {spectrum.freq[-1]:.4g}] Hz."
130
+ )
131
+ idx, amp = idx[valid], spectrum.amp[valid]
132
+
133
+ counts = np.bincount(idx, minlength=self.n_bins)
134
+ values = np.full(self.n_bins, np.nan)
135
+
136
+ if self.statistic == "median":
137
+ for b in np.flatnonzero(counts):
138
+ values[b] = np.median(amp[idx == b])
139
+ else:
140
+ data = np.log10(amp) if self.statistic == "geometric" else amp
141
+ sums = np.bincount(idx, weights=data, minlength=self.n_bins)
142
+ with np.errstate(invalid="ignore", divide="ignore"):
143
+ means = sums / counts
144
+ values = 10**means if self.statistic == "geometric" else means
145
+
146
+ sparse = counts < self.min_count
147
+ values[sparse] = np.nan
148
+ if sparse.all():
149
+ raise ValueError(
150
+ f"No bin reached min_count={self.min_count}. The spectrum has "
151
+ f"{len(spectrum)} samples across {self.n_bins} bins spanning "
152
+ f"[{edges[0]:.4g}, {edges[-1]:.4g}] Hz; use fewer bins, a lower "
153
+ f"min_count, or a narrower range."
154
+ )
155
+ keep = ~sparse if self.drop_empty else np.ones(self.n_bins, dtype=bool)
156
+
157
+ return replace(
158
+ spectrum,
159
+ freq=centres[keep],
160
+ amp=values[keep],
161
+ meta=record_smoothing(
162
+ spectrum.meta,
163
+ self.name,
164
+ n_bins=self.n_bins,
165
+ statistic=self.statistic,
166
+ f_min=float(edges[0]),
167
+ f_max=float(edges[-1]),
168
+ counts=counts[keep].tolist(),
169
+ drop_empty=self.drop_empty,
170
+ ),
171
+ )
@@ -0,0 +1,65 @@
1
+ """Source, attenuation and motion models, and the registry that names them.
2
+
3
+ The spectrum being fitted is a product of three things, so this package keeps
4
+ them separate and composes them:
5
+
6
+ .. code-block:: text
7
+
8
+ log10 A(f) = log10 S(f) + log10 D(f) + log10 G(f)
9
+ source attenuation motion
10
+
11
+ Splitting them matters because they are chosen independently and confused
12
+ easily. A Brune source with frequency-dependent attenuation is a legitimate
13
+ combination; so is a Boatwright source with constant ``t*``. The legacy code
14
+ bound the pair together at import time through ``MODEL = which_model(...)``,
15
+ which is why a Brune and a Boatwright could not be fitted in one session.
16
+
17
+ What a source model has to carry
18
+ --------------------------------
19
+ Not just a spectral shape. **The relation between corner frequency and source
20
+ radius belongs to the model**, and forgetting that is a specific trap:
21
+
22
+ Madariaga is omega-squared like Brune and sits at the *same* ``(gamma, n)``, so
23
+ adding it as a shape alone changes no fitted parameter — the fit is identical
24
+ — while the source radius it implies is quite different. Stress drop goes as
25
+ ``r**-3``, so that is roughly an order of magnitude on identical data, arriving
26
+ silently. :attr:`SourceModel.corner_frequency_coefficient` exists so the
27
+ difference cannot be lost that way, and so that a model which changes nothing
28
+ about the fit still changes what is derived from it.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from .attenuation import (
34
+ ATTENUATION_MODELS,
35
+ AttenuationModel,
36
+ ConstantQ,
37
+ FrequencyDependentQ,
38
+ get_attenuation_model,
39
+ )
40
+ from .composite import SpectralModel, build_model, from_config
41
+ from .motion import motion_scaling
42
+ from .source import (
43
+ SOURCE_MODELS,
44
+ BoatwrightSource,
45
+ BruneSource,
46
+ SourceModel,
47
+ get_source_model,
48
+ )
49
+
50
+ __all__ = [
51
+ "ATTENUATION_MODELS",
52
+ "SOURCE_MODELS",
53
+ "AttenuationModel",
54
+ "BoatwrightSource",
55
+ "BruneSource",
56
+ "ConstantQ",
57
+ "FrequencyDependentQ",
58
+ "SourceModel",
59
+ "SpectralModel",
60
+ "build_model",
61
+ "from_config",
62
+ "get_attenuation_model",
63
+ "get_source_model",
64
+ "motion_scaling",
65
+ ]
@@ -0,0 +1,110 @@
1
+ """Path attenuation, parameterised by ``t*``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Protocol, runtime_checkable
7
+
8
+ import numpy as np
9
+ from numpy.typing import NDArray
10
+
11
+ __all__ = [
12
+ "ATTENUATION_MODELS",
13
+ "AttenuationModel",
14
+ "ConstantQ",
15
+ "FrequencyDependentQ",
16
+ "get_attenuation_model",
17
+ ]
18
+
19
+
20
+ @runtime_checkable
21
+ class AttenuationModel(Protocol):
22
+ """Anything that attenuates a source spectrum along the path."""
23
+
24
+ @property
25
+ def name(self) -> str: ...
26
+
27
+ @property
28
+ def parameters(self) -> tuple[str, ...]:
29
+ """Free parameter names, in the order the fitter should take them."""
30
+ ...
31
+
32
+ def log10_decay(
33
+ self, freq: NDArray[np.float64], *values: float
34
+ ) -> NDArray[np.float64]:
35
+ """``log10 D(f)``, given this model's parameters in order."""
36
+ ...
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class ConstantQ:
41
+ """Frequency-independent ``t*``.
42
+
43
+ .. math:: \\log_{10} D(f) = -\\pi f t^{*} / \\ln 10
44
+
45
+ The ``ln 10`` is the conversion into base-10 logs, which is the space the
46
+ fit is performed in. Getting it wrong scales ``t*`` by 2.3 and leaves the
47
+ spectrum looking plausible.
48
+ """
49
+
50
+ @property
51
+ def name(self) -> str:
52
+ return "constant_q"
53
+
54
+ @property
55
+ def parameters(self) -> tuple[str, ...]:
56
+ return ("ts",)
57
+
58
+ def log10_decay(
59
+ self, freq: NDArray[np.float64], *values: float
60
+ ) -> NDArray[np.float64]:
61
+ (ts,) = values
62
+ return np.asarray(
63
+ -(np.pi * np.asarray(freq, dtype=np.float64) * ts / np.log(10))
64
+ )
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class FrequencyDependentQ:
69
+ """``t*`` with a power-law frequency dependence.
70
+
71
+ .. math:: \\log_{10} D(f) = -\\pi f^{1-a} t^{*} / \\ln 10
72
+
73
+ ``a = 0`` recovers :class:`ConstantQ`. The two are separate models rather
74
+ than one with a switch because they have different free parameters, and a
75
+ fitter needs to know that before it starts.
76
+ """
77
+
78
+ @property
79
+ def name(self) -> str:
80
+ return "frequency_dependent_q"
81
+
82
+ @property
83
+ def parameters(self) -> tuple[str, ...]:
84
+ return ("ts", "a")
85
+
86
+ def log10_decay(
87
+ self, freq: NDArray[np.float64], *values: float
88
+ ) -> NDArray[np.float64]:
89
+ ts, a = values
90
+ f = np.asarray(freq, dtype=np.float64)
91
+ return np.asarray(-(np.pi * f ** (1.0 - a) * ts / np.log(10)))
92
+
93
+
94
+ #: Registered attenuation models.
95
+ ATTENUATION_MODELS: dict[str, type[ConstantQ] | type[FrequencyDependentQ]] = {
96
+ "constant_q": ConstantQ,
97
+ "frequency_dependent_q": FrequencyDependentQ,
98
+ }
99
+
100
+
101
+ def get_attenuation_model(name: str) -> AttenuationModel:
102
+ """Resolve a registered attenuation model by name."""
103
+ try:
104
+ cls = ATTENUATION_MODELS[name]
105
+ except KeyError:
106
+ raise ValueError(
107
+ f"Unknown attenuation model {name!r}. "
108
+ f"Available: {sorted(ATTENUATION_MODELS)}."
109
+ ) from None
110
+ return cls()
@@ -0,0 +1,135 @@
1
+ """Composing source, attenuation and motion into something a fitter can call."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+
9
+ import numpy as np
10
+ from numpy.typing import NDArray
11
+
12
+ from ..core.units import Motion
13
+ from .attenuation import AttenuationModel, get_attenuation_model
14
+ from .motion import motion_scaling
15
+ from .source import SourceModel, get_source_model
16
+
17
+ __all__ = ["SpectralModel", "build_model", "from_config"]
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class SpectralModel:
22
+ """A source, an attenuation law and a motion, evaluated together.
23
+
24
+ .. math::
25
+
26
+ \\log_{10} A(f) = \\log_{10} S(f) + \\log_{10} D(f) + \\log_{10} G(f)
27
+
28
+ Immutable and self-describing: it knows its own parameter names, so a
29
+ fitter does not have to be told them separately and cannot be told them
30
+ wrongly. That is what the legacy could not do — ``FitSpectra.set_model``
31
+ took a bare function, and the parameter names came from introspecting its
32
+ signature, so the model and its parameters could disagree.
33
+ """
34
+
35
+ source: SourceModel
36
+ attenuation: AttenuationModel
37
+ motion: Motion
38
+
39
+ @property
40
+ def parameters(self) -> tuple[str, ...]:
41
+ """Free parameters, in the order :meth:`evaluate` takes them."""
42
+ return ("llpsp", "fc", *self.attenuation.parameters)
43
+
44
+ def evaluate(
45
+ self, freq: NDArray[np.float64], *values: float
46
+ ) -> NDArray[np.float64]:
47
+ """``log10 A(f)`` for the given parameter values, in order."""
48
+ expected = len(self.parameters)
49
+ if len(values) != expected:
50
+ raise TypeError(
51
+ f"{self.describe()} takes {expected} parameters "
52
+ f"{self.parameters}, got {len(values)}"
53
+ )
54
+ log10_omega, f_c, *decay = values
55
+ return np.asarray(
56
+ self.source.log10_shape(freq, log10_omega, f_c)
57
+ + self.attenuation.log10_decay(freq, *decay)
58
+ + motion_scaling(freq, self.motion)
59
+ )
60
+
61
+ def describe(self) -> str:
62
+ """One line naming every choice this model makes."""
63
+ return (
64
+ f"{self.source.name}+{self.attenuation.name} in {Motion(self.motion).value}"
65
+ )
66
+
67
+ def as_callable(self) -> Callable[..., NDArray[np.float64]]:
68
+ """A plain function of ``(f, llpsp, fc, ...)``, for ``lmfit.Model``.
69
+
70
+ ``lmfit`` discovers parameter names with :func:`inspect.signature`
71
+ (checked, rather than assumed — it does not read ``co_varnames``), so
72
+ attaching a ``__signature__`` is enough and no code has to be
73
+ generated. A wrapper taking ``*args`` would give the fit a single
74
+ positional parameter and nothing to vary.
75
+ """
76
+
77
+ names = self.parameters
78
+
79
+ def evaluate(
80
+ f: NDArray[np.float64], *args: float, **kwargs: float
81
+ ) -> NDArray[np.float64]:
82
+ # lmfit reads the names off `__signature__` but calls with
83
+ # keywords, so both forms have to work — and the keyword form has
84
+ # to be put back into the declared order, not the caller's.
85
+ values = args if args else tuple(kwargs[name] for name in names)
86
+ return self.evaluate(f, *values)
87
+
88
+ evaluate.__signature__ = inspect.Signature( # type: ignore[attr-defined]
89
+ [inspect.Parameter("f", inspect.Parameter.POSITIONAL_OR_KEYWORD)]
90
+ + [
91
+ inspect.Parameter(name, inspect.Parameter.POSITIONAL_OR_KEYWORD)
92
+ for name in self.parameters
93
+ ]
94
+ )
95
+ evaluate.__name__ = self.describe()
96
+ return evaluate
97
+
98
+
99
+ def from_config() -> SpectralModel:
100
+ """The model the current configuration asks for.
101
+
102
+ One call, so that ``[model] source = "boatwright"`` in a study file is what
103
+ decides the shape being fitted — which it was not before.
104
+ """
105
+ from ..config import load_config # noqa: PLC0415 - avoids a config->sources cycle
106
+
107
+ model = load_config().config.model
108
+ return build_model(
109
+ source=model.source,
110
+ motion=model.motion,
111
+ frequency_dependent_attenuation=model.frequency_dependent_attenuation,
112
+ )
113
+
114
+
115
+ def build_model(
116
+ *,
117
+ source: str = "brune",
118
+ motion: Motion | str = Motion.VELOCITY,
119
+ frequency_dependent_attenuation: bool = False,
120
+ ) -> SpectralModel:
121
+ """Assemble a :class:`SpectralModel` from configuration names.
122
+
123
+ This is the join that did not exist. ``ModelConfig.source`` was a
124
+ ``Literal["brune", "boatwright"]`` that nothing read: ``FitSpectra`` took
125
+ the model *function* as an argument, so the caller supplied Brune or
126
+ Boatwright by hand and the configured value was decorative. Anything
127
+ reading configuration now comes through here.
128
+ """
129
+ return SpectralModel(
130
+ source=get_source_model(source),
131
+ attenuation=get_attenuation_model(
132
+ "frequency_dependent_q" if frequency_dependent_attenuation else "constant_q"
133
+ ),
134
+ motion=Motion(motion),
135
+ )
@@ -0,0 +1,40 @@
1
+ """Converting a displacement source spectrum to the recorded motion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ from numpy.typing import NDArray
7
+
8
+ from ..core.units import Motion
9
+
10
+ __all__ = ["motion_scaling"]
11
+
12
+ #: Differentiation in the frequency domain multiplies by ``2 pi f`` per order.
13
+ _ORDERS: dict[Motion, int] = {
14
+ Motion.DISPLACEMENT: 0,
15
+ Motion.VELOCITY: 1,
16
+ Motion.ACCELERATION: 2,
17
+ }
18
+
19
+
20
+ def motion_scaling(
21
+ freq: NDArray[np.float64], motion: Motion | str
22
+ ) -> NDArray[np.float64]:
23
+ """``log10 G(f)``: the factor taking displacement to ``motion``.
24
+
25
+ Source models are written for displacement, because that is where
26
+ ``Omega`` and hence ``M0`` are defined. Records are usually velocity. Each
27
+ order of differentiation multiplies by ``2 pi f``, so in log space it is
28
+ that many copies of ``log10(2 pi f)``.
29
+
30
+ The order comes from :class:`specmod.core.units.Motion` rather than a
31
+ string comparison, so an unrecognised motion fails at the enum instead of
32
+ silently returning zero — which would fit a displacement model to velocity
33
+ data and put ``Omega`` out by a factor of ``2 pi f``.
34
+ """
35
+ order = _ORDERS[Motion(motion)]
36
+ if order == 0:
37
+ return np.zeros_like(np.asarray(freq, dtype=np.float64))
38
+ return np.asarray(
39
+ order * np.log10(2.0 * np.pi * np.asarray(freq, dtype=np.float64))
40
+ )