Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,29 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
Works for fixed and moving references with the stretching estimator; NaN
otherwise. The default two-tuple return and all gating behavior are
unchanged (CC-gating remains fixed-reference-only).
- **`run_pipeline(..., prefiltered=True)`** — accept CCFs already band-passed
at `cfg["band"]` and skip the estimators' internal band-pass, so callers
evaluating several stack/reference variants at the same band filter the raw
matrix once. Exact to float rounding because the band-pass is linear and
commutes with linear stacking; only valid at an identical band and only for
the estimators whose band usage is that one linear filter (stretching, WCC,
DTW, MWCS — the wavelet estimators raise).
- **`measure_stretching_trailing`** — vectorized stretching against a trailing
(moving) reference. The stretched sample positions `t/(1+eps)` are
data-independent, so the interpolation gather indices/weights are computed
once per epsilon and applied to all days at once; trailing references come
from a cumulative sum and the band-pass runs once over the whole matrix.
`deviations._moving_reference` dispatches to it for the stretching
estimator (~4.9x on the 3-year volcano synthetic), keeping the generic
per-day loop for the other estimators.

### Changed

- **`_trailing_stack`** is now a difference of float64 cumulative sums —
O(ndays x nlag) independent of the stack length instead of
O(ndays x k x nlag) (~2.3x at k=45). All three fast paths reproduce the
replaced per-day loops to ~1e-15 in dv/v, enforced by regression tests at
atol=1e-12; combined, a 5-member same-band ensemble drops ~4x in runtime.

## 0.3.0 — 2026-07-27

Expand Down
47 changes: 44 additions & 3 deletions src/codameter/deviations.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
measure,
measure_inversion,
measure_stretching,
measure_stretching_trailing,
volcano_truth,
)

Expand Down Expand Up @@ -94,7 +95,17 @@ def _moving_reference(

With ``collect_cc=True``, also returns the per-epoch correlation
coefficient for estimators that produce one (stretching); NaN otherwise.

Stretching dispatches to the vectorized
:func:`codameter.synthetic_demo.measure_stretching_trailing` fast path
(identical to float rounding, ~5x faster); the generic per-day loop below
serves every other estimator.
"""
if name == "stretching (TS)":
out, cc_out = measure_stretching_trailing(
ccfs, t, band=band, fs=fs, window=window, ref_days=ref_days, **kw
)
return (out, cc_out) if collect_cc else out
ndays = ccfs.shape[0]
out = np.full(ndays, np.nan)
cc_out = np.full(ndays, np.nan)
Expand All @@ -113,7 +124,12 @@ def _moving_reference(
return (out, cc_out) if collect_cc else out


def run_pipeline(ccfs, t, fs, cfg, *, eps_max=0.05, return_cc=False):
# Estimators whose only use of the band is one linear band-pass of the input
# waveforms, so a caller may apply that band-pass once and skip it here.
_PREFILTER_OK = {"stretching (TS)", "WCC", "DTW", "MWCS"}


def run_pipeline(ccfs, t, fs, cfg, *, eps_max=0.05, return_cc=False, prefiltered=False):
"""Recover dv/v(t) under one processing configuration ``cfg``.

Returns ``(dvv, valid)``: the per-day series and a boolean mask of epochs the
Expand All @@ -129,18 +145,41 @@ def run_pipeline(ccfs, t, fs, cfg, *, eps_max=0.05, return_cc=False):
CC-gating (``cfg["gate"]``) applies to the fixed reference only, as it
always has; the moving-reference CC is returned for error modelling but
does not change ``valid``.

With ``prefiltered=True``, ``ccfs`` is taken as already band-passed at
``cfg["band"]`` and the estimator skips its internal band-pass. Callers
that evaluate several stack/reference variants at the *same* band can
band-pass the raw CCF matrix once and share it. This is exact (to float
rounding) because the band-pass is linear, so it commutes with the linear
stacking that builds trailing stacks and references — it is only valid at
an identical band and only for the estimators whose band usage is that one
linear filter (stretching, WCC, DTW, MWCS; the wavelet estimators apply no
such filter, so ``prefiltered`` raises for them).
"""
name = cfg["estimator"]
band, window, k, ref = cfg["band"], cfg["window"], cfg["stack"], cfg["reference"]
if prefiltered and name not in _PREFILTER_OK:
raise ValueError(
f"prefiltered=True is only valid for {sorted(_PREFILTER_OK)}, not {name!r}"
)
stacked = _trailing_stack(ccfs, k)
extra = {"eps_max": eps_max} if name in ("stretching (TS)", "WTS") else {}
if prefiltered:
extra["prefiltered"] = True

cc = None
if ref == "fixed":
reference = ccfs[: int(0.6 * len(ccfs))].mean(axis=0) # long stable stack
if name == "stretching (TS)":
dvv, cc = measure_stretching(
stacked, reference, t, band=band, fs=fs, window=window, eps_max=eps_max
stacked,
reference,
t,
band=band,
fs=fs,
window=window,
eps_max=eps_max,
prefiltered=prefiltered,
)
else:
dvv = measure(
Expand All @@ -163,7 +202,9 @@ def run_pipeline(ccfs, t, fs, cfg, *, eps_max=0.05, return_cc=False):
name, stacked, t, band=band, fs=fs, window=window, **extra
)
elif ref == "inversion": # Brenguier et al. (2014) joint inversion (stretching)
dvv = measure_inversion(ccfs, t, band=band, fs=fs, window=window)
dvv = measure_inversion(
ccfs, t, band=band, fs=fs, window=window, prefiltered=prefiltered
)
else:
raise ValueError(ref)

Expand Down
106 changes: 94 additions & 12 deletions src/codameter/synthetic_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,21 +345,27 @@ def stretching_cc(
branch: str = "both",
eps_max: float = 0.06,
n_eps: int = 161,
prefiltered: bool = False,
) -> tuple[np.ndarray, np.ndarray]:
"""The full correlation-coefficient image ``CC(epsilon, time)``.

Returns ``(es, cc)`` where ``cc`` has shape ``[ndays, n_eps]`` — the object
that aggregation workflows either reduce to a per-trace peak *before*
averaging, or average *as images* before peak-picking (see
:func:`peak_dvv` and the aggregation demo).

With ``prefiltered=True``, ``cur_mat`` and ``ref`` are taken as already
band-passed at ``band`` and the internal band-pass is skipped (valid
because band-passing is linear and commutes with the linear stacking that
builds references — see :func:`codameter.deviations.run_pipeline`).
"""
cur_mat = np.atleast_2d(cur_mat)
reff = bandpass(ref, fs, *band)
reff = ref if prefiltered else bandpass(ref, fs, *band)
es = np.linspace(-eps_max, eps_max, n_eps)
sel = _window_mask(t, window, branch)
trials = np.stack([np.interp(t / (1.0 + e), t, reff)[sel] for e in es])
trials = trials / (np.linalg.norm(trials, axis=1, keepdims=True) + 1e-12)
curf = bandpass(cur_mat, fs, *band)[:, sel]
curf = (cur_mat if prefiltered else bandpass(cur_mat, fs, *band))[:, sel]
curf = curf / (np.linalg.norm(curf, axis=1, keepdims=True) + 1e-12)
return es, curf @ trials.T # [ndays, n_eps]

Expand All @@ -384,13 +390,15 @@ def measure_stretching(
branch: str = "both",
eps_max: float = 0.06,
n_eps: int = 161,
prefiltered: bool = False,
) -> tuple[np.ndarray, np.ndarray]:
"""Stretching dv/v: grid-search the stretch maximizing windowed correlation.

``ref`` is a single reference vector (fixed-reference scheme). ``branch``
selects the causal, acausal, or both coda branches — measuring the two
branches separately is the standard clock-error diagnostic. Returns the
per-day dv/v and the peak correlation coefficient.
per-day dv/v and the peak correlation coefficient. ``prefiltered`` is
forwarded to :func:`stretching_cc`.
"""
es, cc = stretching_cc(
cur_mat,
Expand All @@ -402,6 +410,7 @@ def measure_stretching(
branch=branch,
eps_max=eps_max,
n_eps=n_eps,
prefiltered=prefiltered,
)
return peak_dvv(es, cc)

Expand Down Expand Up @@ -433,6 +442,68 @@ def measure_stretching_moving(
return out


def measure_stretching_trailing(
cur_mat: np.ndarray,
t: np.ndarray,
*,
band: tuple[float, float],
fs: float,
window: tuple[float, float],
ref_days: int = 45,
branch: str = "both",
eps_max: float = 0.06,
n_eps: int = 161,
prefiltered: bool = False,
) -> tuple[np.ndarray, np.ndarray]:
"""Vectorized stretching against a trailing reference (previous ``ref_days``).

Numerically equivalent (to float rounding, ~1e-15 in dv/v) to calling
:func:`measure_stretching` day by day against
``cur_mat[d - ref_days : d].mean(axis=0)``, but ~5x faster: the stretched
sample positions ``t / (1 + eps)`` are data-independent, so the
linear-interpolation gather indices and weights are computed once per
epsilon and applied to every day's band-passed trailing reference at once.
The trailing references are built as a difference of cumulative sums and
the band-pass runs over the whole matrix in one FFT.

Returns ``(dvv, cc)`` over the full length of ``cur_mat``; the ``ref_days``
warm-up epochs are NaN.
"""
cur_mat = np.atleast_2d(np.asarray(cur_mat, float))
ndays, nlag = cur_mat.shape
dvv = np.full(ndays, np.nan)
cc_peak = np.full(ndays, np.nan)
if ndays <= ref_days:
return dvv, cc_peak
# Trailing reference for day d is the mean of rows [d - ref_days, d).
csum = np.cumsum(cur_mat, axis=0, dtype=np.float64)
head = csum[ref_days - 1 : ndays - 1]
tail = np.concatenate([np.zeros((1, nlag)), csum[: ndays - ref_days - 1]], axis=0)
refs = (head - tail) / float(ref_days)
reffs = refs if prefiltered else bandpass(refs, fs, *band)

sel = _window_mask(t, window, branch)
tsel = t[sel]
curf = (
cur_mat[ref_days:] if prefiltered else bandpass(cur_mat[ref_days:], fs, *band)
)[:, sel]
curf = curf / (np.linalg.norm(curf, axis=1, keepdims=True) + 1e-12)

es = np.linspace(-eps_max, eps_max, n_eps)
cc_img = np.empty((ndays - ref_days, n_eps))
for ei, e in enumerate(es):
# Gather indices/weights of np.interp(t / (1 + e), t, .) on the window,
# clamped at the grid ends exactly as np.interp clamps.
q = tsel / (1.0 + e)
j = np.clip(np.searchsorted(t, q, side="right") - 1, 0, t.size - 2)
w = np.clip((q - t[j]) / (t[j + 1] - t[j]), 0.0, 1.0)
trials = reffs[:, j] * (1.0 - w) + reffs[:, j + 1] * w
trials = trials / (np.linalg.norm(trials, axis=1, keepdims=True) + 1e-12)
cc_img[:, ei] = np.einsum("ij,ij->i", curf, trials)
dvv[ref_days:], cc_peak[ref_days:] = peak_dvv(es, cc_img)
return dvv, cc_peak


def measure_mwcs(
cur_mat: np.ndarray,
ref: np.ndarray,
Expand All @@ -443,6 +514,7 @@ def measure_mwcs(
window: tuple[float, float],
subwin_s: float = 6.0,
step_s: float = 3.0,
prefiltered: bool = False,
) -> np.ndarray:
"""MWCS dv/v: cross-spectral phase delay per sub-window, slope of dt vs lapse.

Expand All @@ -455,8 +527,8 @@ def measure_mwcs(
dv/v (e.g. pre-failure landslides) where stretching stays robust.
"""
cur_mat = np.atleast_2d(cur_mat)
reff = bandpass(ref, fs, *band)
curf = bandpass(cur_mat, fs, *band)
reff = ref if prefiltered else bandpass(ref, fs, *band)
curf = cur_mat if prefiltered else bandpass(cur_mat, fs, *band)
centers = np.arange(window[0] + subwin_s / 2, window[1] - subwin_s / 2, step_s)
half = int(round(subwin_s / 2 * fs))
taper = np.hanning(2 * half)
Expand Down Expand Up @@ -500,6 +572,7 @@ def measure_wcc(
window: tuple[float, float],
subwin_s: float = 6.0,
step_s: float = 3.0,
prefiltered: bool = False,
) -> np.ndarray:
"""WCC dv/v: time-domain windowed cross-correlation delay, slope of dt vs lapse.

Expand All @@ -510,8 +583,8 @@ def measure_wcc(
the seven estimators in NoisePy's ``monitoring_methods`` (``wcc_dvv``).
"""
cur_mat = np.atleast_2d(cur_mat)
reff = bandpass(ref, fs, *band)
curf = bandpass(cur_mat, fs, *band)
reff = ref if prefiltered else bandpass(ref, fs, *band)
curf = cur_mat if prefiltered else bandpass(cur_mat, fs, *band)
centers = np.arange(window[0] + subwin_s / 2, window[1] - subwin_s / 2, step_s)
half = int(round(subwin_s / 2 * fs))
taper = np.hanning(2 * half)
Expand Down Expand Up @@ -579,6 +652,7 @@ def measure_dtw(
fs: float,
window: tuple[float, float],
max_lag_s: float = 0.8,
prefiltered: bool = False,
) -> np.ndarray:
"""DTW dv/v: warp the current trace onto the reference, slope of lag vs lapse.

Expand All @@ -587,8 +661,8 @@ def measure_dtw(
changes (Yuan et al. 2021). NoisePy ``dtw_dvv``.
"""
cur_mat = np.atleast_2d(cur_mat)
reff = bandpass(ref, fs, *band)
curf = bandpass(cur_mat, fs, *band)
reff = ref if prefiltered else bandpass(ref, fs, *band)
curf = cur_mat if prefiltered else bandpass(cur_mat, fs, *band)
sel = (t >= window[0]) & (t <= window[1]) # causal branch only
tt = t[sel]
max_lag = int(round(max_lag_s * fs))
Expand Down Expand Up @@ -875,6 +949,7 @@ def measure_inversion(
block_days: int = 7,
max_lag_blocks: int = 10,
smooth: float = 5.0,
prefiltered: bool = False,
) -> np.ndarray:
"""Brenguier et al. (2014)-style joint inversion for a continuous dv/v series.

Expand Down Expand Up @@ -903,6 +978,7 @@ def measure_inversion(
window=window,
eps_max=0.03,
n_eps=81,
prefiltered=prefiltered,
)
for i in range(j + 1, min(m, j + max_lag_blocks + 1)):
rows += [eq, eq]
Expand Down Expand Up @@ -1059,9 +1135,15 @@ def _yrs(days: np.ndarray) -> np.ndarray:
def _trailing_stack(ccfs: np.ndarray, k: int) -> np.ndarray:
if k <= 1:
return ccfs
out = np.empty_like(ccfs)
for d in range(ccfs.shape[0]):
out[d] = ccfs[max(0, d - k + 1) : d + 1].mean(axis=0)
# Trailing mean of the last k days (shorter at the start), as a difference
# of float64 cumulative sums: O(ndays * nlag) instead of O(ndays * k * nlag).
ndays = ccfs.shape[0]
csum = np.cumsum(ccfs, axis=0, dtype=np.float64)
out = np.empty_like(csum)
out[:k] = csum[:k]
np.subtract(csum[k:], csum[:-k], out=out[k:]) # window sum over [d-k+1, d]
counts = np.minimum(np.arange(1, ndays + 1), k).astype(np.float64)
out /= counts[:, None]
return out


Expand Down
Loading