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