How a spectrum is processed, end to end#
Every step from a raw waveform to a fitted source model, with the equation each one applies and a pointer to the code that applies it.
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 \(x_n\), \(n = 0 \dots N-1\), sampled at interval \(\Delta t\), so its duration is \(T = N \Delta t\) and its Nyquist frequency is \(f_{\mathrm{Nyq}} = 1/(2\Delta t)\). Frequency-domain quantities are written \(X(f)\).
Pipeline at a glance#
# |
Stage |
Code |
|---|---|---|
1 |
Instrument correction and detrending |
|
2 |
Window selection and refinement |
|
3 |
Spectral estimation |
|
4 |
Amplitude convention |
|
5 |
Log binning |
|
6 |
Noise rescaling and rotation |
|
7 |
Signal-to-noise and bandwidth |
|
8 |
Source model fitting |
|
1. Instrument correction#
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.
2. Window selection#
The S-window opens at a fixed fraction \(r\) of the elapsed P–S time after the P arrival:
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 \(I\) reaches the 1st and 99th percentiles. This is why the 28 PNR windows come out at 1.8–3.7 s rather than the nominal 20 s.
The noise window ends \(0.2\) s before the P arrival and is asked for the same length as the refined signal window, so the two would be comparable without further correction. In practice it rarely gets it: the window is truncated wherever the record does not start early enough, and on the PNR data that is all 28 windows, which run 1.1–1.7 s against 1.8–3.7 s signals.
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.
3. Spectral estimation#
Every estimator returns a one-sided spectrum over \((0, f_{\mathrm{Nyq}}]\) obeying one contract, so a single test suite pins all of them.
The FFT path#
Taper, transform, fold, normalise:
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. \(\sqrt{\langle w^2 \rangle}\) preserves total power, which is the right choice for a transient — and a seismic arrival is a transient. The alternative, dividing by \(\langle w \rangle\), preserves the peak of a coherent sinusoid instead. For a Tukey taper with \(\alpha = 0.05\) they differ by well under a percent, but the choice is explicit rather than implied.
Normalisation is keyed off \(\Delta t\), never off the transform length. The DFT sums over the \(N\) non-zero samples whatever \(n_\mathrm{fft}\) is, so it approximates the same continuous transform and merely evaluates it on a finer grid. Zero-padding therefore changes frequency sampling and nothing else. The pre-refactor code used \(\mathrm{len}(f)\) as a stand-in for \(T/2\), which is true only for an unpadded one-sided transform — padding to \(4N\) halved the amplitude.
The Parseval contract#
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 \(\Omega\). It is what lets multitaper, Welch and the CWT be interchangeable here despite reaching their amplitudes by very different routes.
Multitaper#
\(K\) orthogonal DPSS tapers \(v^{(k)}\) of time-bandwidth \(NW\) give eigenspectra \(S_k(f)\), combined with adaptive weights (Thomson 1982, eq. 5.1b):
iterated to convergence. The regularisation term \(b_k\) has units of
\([x]^2\!\cdot\!s\) — the same as the eigenspectra — so \(\sigma^2\) must be
derived from the spectra themselves and not from x.var(), which is
\([x]^2\) and too large by \(1/\Delta t\). That units mismatch made the weights
collapse toward zero for off-centre transients; the recovered amplitude ratio
went from 0.203 to 0.956 once corrected.
Continuous wavelet transform#
An L2-normalised Morlet at scale \(s\), with the Torrence & Compo reconstruction factor \(C_\delta\), and a cone of influence that discards the scales a window of this length cannot resolve.
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 \(1.4\times\) stricter than \(1/T\) in practice — measured as the ratio of median resolution floors over the 28 PNR windows — which is why the CWT’s usable band opens higher than multitaper’s on the same record.
4. Amplitude convention#
Two conventions are in play and the factor between them is exactly 2.
Kind |
Definition |
Energy relation |
|---|---|---|
|
\(2\lvert X(f)\rvert\), folded |
\(E = \int A^2/2 \,\mathrm{d}f\) |
|
\(\lvert X(f)\rvert = \lvert\mathrm{rfft}(x)\rvert\,\Delta t\), unfolded |
\(E = 2\int \lvert X\rvert^2\,\mathrm{d}f\) |
core.Spectrum carries FAS. The pipeline converts to MAGNITUDE,
because that is the convention \(\Omega\) is defined in: the long-period plateau
of the displacement spectrum is \(\lvert X(f\!\to\!0)\rvert = \lvert\int u\,\mathrm{d}t\rvert\)
and \(M_0 \propto \Omega\). Folding would put \(M_0\) out by two, which is
\(0.2\) magnitude units on every event.
Both are self-consistent and both recover the record’s energy. Anyone reading
core.Spectrum.amp and calling it \(\Omega\) needs to halve it first.
The conversion from a PSD is
keyed off the physical duration \(T\), for the reason given in §3.
5. Log binning#
Amplitudes are averaged into \(M\) bins spaced evenly in \(\log_{10} f\) between the record’s own \(f_{\min}\) and \(f_{\max}\) — clamped to the record, so the requested bin count is the count you get.
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 \(f \ge f^{\text{lo}}_i\) and \(f \le f^{\text{hi}}_i\) against each edge in turn — closed at both ends, so a sample landing on an interior edge belonged to two bins. On a CWT axis this reported 51 bins from 49 samples: more bins than samples, which is only possible by double counting.
6. Noise rescaling and rotation#
Rescaling#
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.
Raising the noise level#
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 |
|---|---|---|
|
default |
Noise under the signal follows the recorded shape, scaled by a power of a frequency ramp |
|
available |
The recorded window is representative as measured |
|
available |
Legacy |
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 \(\Omega\).
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 \(s(f)\) running from \(\approx 0\) at one end of the band to \(\approx 1\) at the other, and the noise is raised by
Since \(s < 1\), increasing \(\eta\) raises the small-\(s\) end fastest. The exponent wanted is the smallest \(\eta\) at which any bin in the half reaches the signal. That has a closed form. A bin touches when
so the first touch across the half is
with \(\eta^\star = 0\) when some bin already satisfies \(A \ge S\), and no lift at all when no bin has \(s < 1\).
Why this matters beyond elegance. The original implementation searched for \(\eta\) by stepping it in increments of \(\mathrm{inc} = 0.05\) and stopping at the first step past the touching point — that is, it returned \(\mathrm{inc}\cdot\lceil \eta^\star/\mathrm{inc}\rceil\). Two consequences, both measured:
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 \(\eta^\star\) directly fixes both: the exponent is now a continuous function of its input, and it is the quantity the algorithm was always trying to compute. Measured end to end, perturbing the input by \(10^{-15}\) now moves the noise by \(1.8\times10^{-11}\) and no band edge at all.
The rotate method#
ROT_METHOD = 1, described in the legacy source as “actual rotation, quite
aggressive”. Writing \(X = \log_{10} f\) and \(Y = \log_{10} A\), it tilts the
noise spectrum about its low-frequency end:
The trailing \(Y_0\theta\) is what makes this a rotation about the low-frequency
end rather than about the axis origin — without it the curve translates as
well as tilts, and the correction stops being anchored to the part of the noise
record least contaminated by signal. As with 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 \(\eta^\star\), the touching angle has no closed form: \(\theta\) appears inside \(\sin\) and \(\cos\). It is bracketed on a coarse grid and then bisected to \(10^{-12}\), which is enough to make it continuous in the input — the grid only chooses the bracket, never the answer. The legacy stepped \(\theta\) by a fixed increment and stopped at the first trial past the touch, with exactly the irreproducibility described above.
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.
7. Signal-to-noise and bandwidth#
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:
The usable band is the widest contiguous run of bins with \(R_i \ge R_{\min}\),
bridging gaps of a single failing bin so that one noisy bin does not end a
band. It returns nothing — not a band plus a flag — when no run of at least
min_width bins survives.
The previous method integrated \(\mathrm{sign}(R - R_{\min})\) and read the edges off the 1st and 99th percentiles, with a retry loop when they crossed. Every step there is discontinuous and they compound: an edge could move 13 bins between machines. It also lagged: on a clean 5–30 Hz passing region it returned 9.41 Hz for the low edge, because the 1st percentile of a cumulative integral arrives late. The low edge is what constrains \(\Omega\). The contiguous-run method returns 5.45 Hz on the same input, within one bin of the truth.
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 \(1/T\) before this existed. Set
snr.resolution_floor = false to reproduce a run made before it.
8. Source model#
Fitted in \(\log_{10}\) space over the selected band. The generalised Boatwright source shape is
with \((\gamma, n) = (1,2)\) for Brune and \((2,2)\) for Boatwright. Attenuation is a frequency-independent \(t^\ast\),
(or \(f^{1-a}\) in the frequency-dependent variant), and the motion term converts from displacement:
The fitted model is their sum:
Free parameters are \(\Omega\), \(f_c\) and \(t^\ast\), minimised with Powell’s method by default.
The package stops here. Converting \(\Omega\) to \(M_0\) and \(M_w\) needs a density, velocity, radiation-pattern coefficient and geometrical spreading model, none of which SpecMod currently owns, so those live in the caller.
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. See
REFACTOR_PLAN.md§4.6.5.
Reproducibility#
As of the change described in §6 and §7, the pipeline is continuous in its input: perturbing a record by \(10^{-15}\) moves the noise by \(1.8\times10^{-11}\) and no band edge at all. Before, a last-bit difference between two machines moved the noise by up to 82% and a band edge by 13 bins.
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 \(\Omega\).