Every step from a raw waveform to a moment magnitude, with the equation each one applies and a pointer to the code that applies it. Together with the normalisation and units conventions in §3 and §4, this is the reference for what SpecMod computes and in which units.
This exists because a pipeline described only in prose is a pipeline nobody can check. Writing the noise rotation down as an equation rather than a loop is what revealed that it was solving for a quantity it could compute directly — and that the search it used instead was both biased and not reproducible (§6). The rest of this document is the same exercise applied to every other stage.
Notation. A record is
| # | Stage | Code |
|---|---|---|
| 1 | Instrument correction and detrending | preprocess |
| 2 | Window selection and refinement | preprocess.s_window, signal_intensity |
| 3 | Spectral estimation | transforms/ |
| 4 | Amplitude convention | core.Spectrum.to_kind |
| 5 | Log binning | core.collection.log_bin |
| 6 | Noise rescaling and rotation | core.collection.parseval_scale, core.noise.NOISE_MODELS |
| 7 | Signal-to-noise and bandwidth | core.collection.find_bandwidth |
| 8 | Source model fitting | sources/, fitting/ |
| 9 | Moment and magnitude | magnitude, spreading |
Applied in the caller, not inside the package, so that the choice stays visible in the run script. The tutorial sequence is linear detrend, demean, 5% cosine taper, then response removal to velocity.
Demeaning matters more than it looks. A non-zero mean puts all of its energy
in the DC bin, which every estimator here discards — so leaving it in loses
energy that the Parseval check in §3 would report as
a failure. transforms.prepare_record therefore demeans unconditionally,
whatever the caller did.
The S-window opens at a fixed fraction
and runs for a fixed duration (time_after="absolute_time") or a multiple of
the P–S time ("relative_ps").
Refinement. The window is then tightened to the part of it that actually carries energy. With the cumulative squared amplitude
the refined window runs between the times at which
The noise window ends
Each trace therefore carries two window records: wstart/wend, which are
what the trace actually holds, and wstart_requested/wend_requested, which
are what the cut asked for. They differ on every truncated noise trace, and by
up to half a sample on signal traces, where trim snaps to sample boundaries.
The resulting resolution difference is what
§6 corrects, and it is why
SpectrumPair carries a resolution_floor rather than assuming the two
spectra share a frequency axis. tests/test_preprocess.py pins this so that a
change which quietly started delivering full-length noise windows would move
every noise spectrum in the reference visibly rather than silently.
Every estimator returns a one-sided spectrum obeying one contract, so a
single test suite pins all of them. With the shipped defaults the axis is
drop_dc=False, and an odd
transform length ends half a bin below Nyquist instead of on it.
Taper, transform, fold, normalise:
$$A(f_k) = \frac{2,\Delta t}{\sqrt{\langle w^2\rangle}},\bigl|,\mathrm{DFT}x_n w_n,\bigr|$$
with the factor of 2 applied at every bin except DC and — for even transform length — Nyquist, which have no negative-frequency twin. Getting that exception wrong is a silent error at the two ends of the band, which is why the fold factor is computed per bin rather than applied as a scalar.
Taper correction.
Normalisation is keyed off
Every estimator is held to
which is a falsifiable check rather than a convention: an estimator arriving
on a different amplitude convention fails it instead of silently rescaling
iterated to convergence. The regularisation term x.var(), which is
An L2-normalised Morlet at scale
Open issue,
cwtonly. Every other estimator reproduces exactly across machines.cwt's signal amplitudes and frequency axis do too — but its post-rotation noise differs by 1-2% on 4 of 28 stations between Linux and macOS runners carrying identical library versions. It is the one transform implemented here from scratch, which makes it the natural suspect, and evaluating PyWavelets as an independent implementation is tracked inREFACTOR_PLAN.md§4.4.4. Note that the evidence does not yet point at the transform itself: what moves is downstream of it.
The COI limit is about
Four kinds, of which two carry the factor of 2 that matters. Writing FAS and
| Kind | Definition | From FAS
|
Energy relation |
|---|---|---|---|
FAS |
|
— | |
MAGNITUDE |
|
||
PSD |
one-sided power density, |
— | |
ASD |
one-sided amplitude density, |
— |
The MAGNITUDE row states the fold factor as 2, which is what it is at every
bin that has a negative-frequency twin — that is, everywhere except DC and, for
an even transform length, Nyquist, exactly as
§3 sets out. Neither ever falls inside the
SNR-selected band, so no Spectrum.to_kind applies the
per-bin factor rather than a flat 2, so a conversion made through the API is
right at those bins too.
core.Spectrum carries FAS. The pipeline converts to MAGNITUDE,
because that is the convention
Both are self-consistent and both recover the record's energy. Anyone reading
core.Spectrum.amp and calling it
Inverting the PSD row gives the conversion back:
keyed off the physical duration
:::{warning}
Not MAGNITUDE kind, and it is the form the legacy
psd_to_amp used because it worked in the unfolded convention. Anything
carried over from that code reads a PSD into a FAS a factor of two low.
:::
Amplitudes are averaged into bins spaced evenly in
Expect fewer bins out than you asked for. n_bins counts bin edges, so
it yields at most n_bins - 1 intervals, and empty bins are then dropped. The
shipped n_bins = 151 gives 104 on a typical PNR window; 101 gives 75.
The average is geometric, matching the scale the bins are spaced on:
Empty bins are expected — log bins over a linear frequency grid are inevitably sparse at the low end — and are dropped.
Membership is computed, not tested:
so every sample belongs to exactly one bin. The previous implementation tested
The two windows are rarely the same length, and a shorter record spreads the same power over fewer bins, so the noise is put on the signal's footing:
It is then interpolated onto the signal's frequency axis. np.interp does
not extrapolate — it repeats the edge value — so below the noise window's
own lowest frequency the "noise level" is a flat continuation rather than a
measurement. §7 is what keeps the selected
band out of that region.
A recorded noise window understates the noise beneath a strong signal: it is
a sample of the same process, but taken where the signal is not. How to correct
for that is a modelling choice, not a fact, so core.noise holds a set of
methods behind one signature — given frequencies, noise and signal, return a
multiplicative factor. NOISE_MODELS maps names to implementations, the same
way transforms.ESTIMATORS does for spectral estimators, so the band search
never needs to know which was used.
| Name | Status | Assumption |
|---|---|---|
boost |
default | Noise under the signal follows the recorded shape, scaled by a power of a frequency ramp |
none |
available | The recorded window is representative as measured |
rotate |
available | Legacy ROT_METHOD = 1; the discrepancy is a tilt in log–log space rather than a level offset |
none is not a placeholder. It is the honest choice when the noise window is
genuinely representative, and it is what a run needs in order to show what the
correction is doing — every other model should be compared against it before
being trusted, because the assumption is the whole content of the method,
and it propagates into every bandwidth and so into every
The default, boost, lifts the low and high tails independently until each
touches the signal, then keeps the larger of the two at every frequency.
Each bin is mapped onto a scale
Since
so the first touch across the half is
with
Why this matters beyond elegance. The original implementation searched for
-
It was irreproducible. The stopping test is a comparison, so the result
was a step function of its input. Two machines differing in the last bit
landed on either side of a step and the noise moved by
$s_{\min}^{-0.05} = 1.41$ . Observed on CI: 41% and 82% — one and two steps. -
It was biased. Rounding was always upward, so the lifted noise was
consistently overstated — a median
$1.18\times$ , up to$1.41\times$ , across 39 lifts on the 28 PNR windows. That made signal-to-noise pessimistic at exactly the band edges the ratio is read from.
Using
ROT_METHOD = 1, described in the legacy source as "actual rotation, quite
aggressive". Writing
The trailing boost, one angle is found for
each half and the larger result kept at every frequency.
It assumes the recorded window has the right level somewhere and the wrong
slope, where boost assumes the right shape and the wrong level. Those are
genuinely different claims about the noise process, which is why both are kept:
comparing the two bands is the only way to see how much of a result is the
method. On the 28 PNR windows they disagree on 25, rotate always narrower —
LV.L002..HHE runs 1.51–38.60 Hz under boost and 1.51–13.87 Hz under
rotate.
Unlike
Two departures from the legacy implementation are recorded in
REFACTOR_PLAN §4.5.3:
the solved angle, and taking the low/high split from the signal rather than the
noise. Neither can move a published number — ROT_METHOD = 1 was commented out
on master and has never produced one.
The ratio is taken bin by bin on the binned spectra, which share bin edges by construction because the noise was moved onto the signal's axis before binning:
Which bins that leaves is a registry, core.bandwidth.BANDWIDTH_SELECTORS,
in the same way the noise models in §6 are.
Three are shipped; the two automatic ones can disagree completely:
peak — the default ([snr] bandwidth_method)
: Walk outward from the bin with the highest ratio until BW_METHOD = 2 did.
widest
: The widest contiguous run of bins with min_width (default 3).
fixed
: Not a selection. Returns the band named in [snr] fixed_band, intersected
with the frequencies present, whatever the ratio says. For a band held
constant across a study, or one imposed by an instrument. The pair records
band_imposed in its metadata, so a result read back later still
distinguishes a band that was asserted from one that was measured.
Either automatic selector returns nothing — not a band plus a flag — when no band survives. So does the resolution floor below, and the Nyquist ceiling, both of which can reject a pair outright rather than only narrowing it.
They are not cosmetic variants. On a spectrum with a wide low-frequency
passing run beneath a narrow high-SNR peak, peak and widest return disjoint
bands. Since the low edge is what constrains
The previous method integrated widest returns 5.45 Hz on the same input, within one bin of the truth. Both
shipped selectors are continuous in the input, which is the property that
mattered; the 9.41 Hz figure is what the legacy method gave.
Resolution floor. The band is finally clamped to
which refuses the region where the noise level is np.interp's repeated edge
value rather than a measurement. On the 28 PNR pairs, 6 selected a band
opening below the noise window's own snr.resolution_floor = false to reproduce a run made before it.
Nyquist ceiling. The other end has the opposite problem, and it is not
guarded by default. Both automatic selectors walk while the ratio holds, and
approaching Nyquist the signal and the noise roll off into the anti-alias
filter together — so
where [snr] max_nyquist_fraction, unset by default. A fraction
rather than a frequency because a network mixes sampling rates — the PNR
stations record at both 100 and 200 Hz — and the roll-off moves with the rate.
The right value is a property of the instrument rather than of this package,
which is why nothing is imposed.
It is applied after the selector and after the floor, so it constrains every
strategy the same way, fixed included: it is a statement about the recording,
not about how the band was chosen.
This is not a presentational setting. Capping the PNR event at
Fitted in
with
(or
The fitted model is their sum:
Free parameters are
A note for anyone adding a source model. Madariaga is omega-squared like Brune and sits at the same
$(\gamma, n) = (1,2)$ , so adding it as a spectral shape alone changes no fitted parameter. The difference is the constant relating$f_c$ to source radius, and since stress drop goes as$r^{-3}$ that is roughly an order of magnitude on identical data. The$f_c$ -to-radius scaling has to be a named property of the model, not a constant buried in whatever computes stress drop. SeeREFACTOR_PLAN.md§4.6.5.
specmod.magnitude. The fit gives
specmod.spreading), which
corrects the observed plateau back to the reference distance
Defaults are the S-wave values of Holt (2019) Ch. 1 §1.4 and Ch. 2 §2.2, all
taken at the source, and all overridable through MediumConstants:
| Symbol | Field | Default | |
|---|---|---|---|
density |
2700 | kg m⁻³ | |
velocity |
3500 | m s⁻¹, cubed here | |
radiation_pattern |
0.55 | average SH over the focal sphere | |
free_surface |
2 | vertically incident SH | |
reference_distance_m |
1000 | metres |
That looks like a units bug and is not: they are different distances. They cancel only when the spreading exponent is exactly 1, so a model with any other exponent genuinely needs both, in their own units.
The fit reports llpsp — seismic_moment takes ValueError on a non-positive plateau rather than returning
a moment roughly
And MAGNITUDE: reading a folded FAS
plateau as
station_moments appends m0 and mw per channel. event_magnitude
aggregates, following Holt (2019) §2.2:
- the sample mean of station magnitudes, excluding anything beyond
outlier_sigma(default 2.5) standard deviations, in a single pass rather than iterated to convergence; - no event value at all below
min_stations(default 3). Fewer than three stations cannot average out the radiation pattern, so the published method declines to report a value rather than reporting a poor one, and this raises instead of returning something usable-looking.
Averaging in magnitude rather than in
:::{warning}
The absolute calibration is unverified. The shape of the fit is checked
against golden references, and the constants above are sourced; what has not
been confirmed is that the resulting REFACTOR_PLAN.md is closed.
:::
As of the change described in §6 and
§7, the pipeline is continuous in its
input: perturbing a record by
That is checked, not assumed. tests/golden/pipeline_reference.json records
the full pipeline output over 28 real windows and 5 estimators, and
tests/test_golden_reference.py holds every later change to it. Regenerate
with python tools/make_golden.py, and say in the commit message what moved
and why — regenerating without that explanation removes the only check
standing between a refactor and a silently different