Skip to content

Cross-station CCF equivalence + three-archive demo notebook - #4

Merged
mdenolle merged 8 commits into
mainfrom
feature/xcorr-three-archives
Aug 6, 2026
Merged

Cross-station CCF equivalence + three-archive demo notebook#4
mdenolle merged 8 commits into
mainfrom
feature/xcorr-three-archives

Conversation

@mdenolle

@mdenolle mdenolle commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

What

Extends the noisepy equivalence evidence from single-station to cross-station across three archives, adds an obspy-free instrument-response-removal demonstration end to end, and ships the validation as a notebook + benchmark-page section.

Bit-identity evidence

  • New harness benchmarks/noisepy_eval/run_xcorr_eval.py: CI.PASC (SCEDC S3) × BK.PKD (NCEDC S3) × II.PFO (EarthScope S3) through real noisepy compute_fft/correlate at a 20 sps target — the Fourier-resample and sub-sample-alignment branches run inside the chain. Bit-identical (max abs diff 0.0) on all three pairs; the pass criterion is now strict bit-identity. Integration test added; benchmarks/RESULTS.md/.html gained a "NoisePy equivalence" section with the tables and validation figures.
  • Three fidelity bugs found and fixed: (1) segment_interpolate_np must replicate numba's mixed-precision type unification — and the fix must use np.multiply(dtype=np.float64) because numpy 1.x value-based casting silently demotes the float64-scalar product (invisible under numpy 2; caught by the strict harness); (2) preprocess_raw_np no longer force-casts to float32 — noisepy leaves float64 after resample when the fric branch doesn't fire; (3) the single-station harness had been silently broken by the sample-alignment guard — both harnesses now snap day windows to the data's sample grid (integer-exact epoch-ns; archive day files start sub-sample off midnight).

Response removal without obspy

notebooks/06_cross_correlation_three_archives.ipynb (executed, committed): four stations (added CI.RPV, 52 km across the LA basin), two months (Jan–Feb 2022, 236 station-days, ~2.3 GB), fully obspy-free — StationXML fetched via FDSNClient.get_station_text raw HTTP, parsed and removed with seisfetch.contrib.response (cross-checked at 7.6e-16 of peak vs obspy.Trace.remove_response on this data), every station-day in physical ground velocity before the NoisePy chain. The four response-corrected spectra collapse onto the same microseism spectrum; CCF stacks show 1 day → 1 week → 1 month → 2 months convergence with surface-wave moveouts and a folded record section (52–455 km).

Also fixed on the way: _parse_iso_utc rejects EarthScope II StationXML's 4-digit fractional seconds under Python ≤3.10 — normalized, with a regression test.

Review

Copilot review fully addressed (commit a258188, replies on all four threads) — including rejecting one suggestion that was a numpy-1.x casting trap, with the version-stable fix verified bit-exact against numba on numpy 1.26.4 and 2.5.1.

Tests

🤖 Generated with Claude Code

mdenolle and others added 2 commits August 5, 2026 08:56
Extends the noisepy equivalence evidence from single-station to the
cross-station case: CI.PASC (SCEDC) x BK.PKD (NCEDC) x II.PFO
(EarthScope), 2022-01-02, three interstation pairs (~150/230/380 km),
through real noisepy compute_fft/correlate at a 20 sps target so the
Fourier-resample and sub-sample-alignment branches run inside the chain.
All three pairs bit-identical (max abs diff 0.0).

Two fidelity bugs found and fixed on the way:
- segment_interpolate_np was 1 ulp off on ~30% of samples (2e-5 of CCF
  peak): numba's type unification computes (1 - nfric) in float64 while
  nfric*sig1[ii] stays float32; the port now encodes those semantics
  exactly and a reference-loop test locks them down.
- preprocess_raw_np force-cast its output to float32; noisepy leaves
  float64 after resample when the fric branch does not fire. The adapter
  now keeps the chain's natural dtype (caught by the new sr=20 chain
  equivalence case).

Also fixes the single-station harness, silently broken since the
sample-alignment guard landed: both harnesses now snap the day window to
the data's sample grid (CI.PASC starts 0.218 samples off midnight) and
feed the same snapped window to both paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A week of BHZ noise from CI.PASC (SCEDC S3), BK.PKD (NCEDC S3) and
II.PFO (EarthScope S3), obspy-free from fetch through preprocessing,
correlated with NoisePy's own compute_fft/correlate. Shows raw hour-long
waveforms per archive, single-day vs 7-day stacked CCFs with 3/1.5 km/s
moveouts, and a folded record section vs interstation distance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 5, 2026 07:13

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Extends the NoisePy numerical-equivalence validation from single-station to cross-station cross-correlation across three S3 archives, and tightens the numpy adapter to reproduce NoisePy/numba mixed-precision behavior and dtype propagation.

Changes:

  • Add a new cross-station equivalence harness (run_xcorr_eval.py) + an integration test wrapper to validate bit-level agreement through real NoisePy FFT/correlation.
  • Fix two fidelity mismatches in the adapter: segment_interpolate_np mixed-precision semantics and preprocess_raw_np output dtype preservation (float32 vs float64 depending on branch).
  • Update the single-station harness to snap day windows to the channel sample grid, and commit new benchmark result JSONs.

Reviewed changes

Copilot reviewed 8 out of 10 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tests/precision/test_xcorr_equivalence.py Adds integration test invoking the new cross-station harness.
tests/precision/test_preprocess_equivalence.py Adds targeted tests for segment_interpolate_np semantics and exercises the resample branch in chain-equivalence testing.
seisfetch/contrib/noisepy_adapter.py Updates adapter semantics for mixed precision in segment_interpolate_np and preserves natural dtype in preprocess_raw_np.
notebooks/README.md Adds an entry for the new three-archive cross-correlation notebook.
benchmarks/results/xcorr_three_archives_2022-01-02.json Stores cross-station harness results for the three interstation pairs.
benchmarks/results/ccf_eval_ci_pasc_2022-01-02.json Stores updated single-station harness results after window snapping fix.
benchmarks/noisepy_eval/run_xcorr_eval.py New cross-station harness (three archives, three station pairs) using NoisePy compute_fft/correlate.
benchmarks/noisepy_eval/run_ccf_eval.py Adds snap_window() and uses it to align windows across paths for fair comparison.
.gitignore Ignores local cache directories for the evaluation harnesses.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread seisfetch/contrib/noisepy_adapter.py Outdated
Comment on lines +227 to +230
nf32 = np.float32(nfric)
sig2[1:-1] = (1.0 - np.float64(nf32)) * sig1[2:].astype(np.float64) + (
nf32 * sig1[1:-1]
).astype(np.float64)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in a258188, with a twist: the suggested plain expression is a trap under numpy 1.x. Value-based casting means a float64 scalar does not upcast a float32 array, so np.float64(...) * float32_array silently runs in float32 there (numpy 2 promotes, so the difference is invisible in this repo's test env — but noisepy pins numpy<2). The now-strict harness caught the 2e-5 CCF divergence immediately. Fixed with np.multiply(..., dtype=np.float64), which forces float64 on every numpy version and drops the intermediate astype copy you flagged. Verified bit-exact vs numba on numpy 1.26.4 and 2.5.1.

Comment thread benchmarks/noisepy_eval/run_ccf_eval.py Outdated
Comment on lines +56 to +59
dt_ns = round(1e9 / seg.sampling_rate)
day0_ns = int(day0.timestamp() * 1e9)
start_ns = seg.starttime_ns + round((day0_ns - seg.starttime_ns) / dt_ns) * dt_ns
start = datetime.fromtimestamp(start_ns / 1e9, tz=timezone.utc)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a258188: to_epoch_ns() computes exact integer nanoseconds via timedelta floor-division, used by both snap_window and path_b (which had the same timestamp()*1e9 issue).

Comment on lines +100 to +107
cache.mkdir(parents=True, exist_ok=True)
f = cache / f"{net}.{sta}.{day}.ms"
if f.exists():
return f.read_bytes()
kwargs = {} if dc == "earthscope" else {"location": loc, "channel": cha}
raw = S3OpenClient(datacenter=dc).get_raw(net, sta, day, **kwargs)
f.write_bytes(raw)
return raw

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a258188: cache keys now encode datacenter and location/channel (scedc.CI.PASC.00.BHZ.<day>.ms, earthscope.II.PFO.all.<day>.ms); the notebook uses the same scheme.

Comment on lines +140 to +154
max_abs = float(np.abs(a - b).max())
peak = float(np.abs(a).max())
corr = float(np.corrcoef(a, b)[0, 1])
pair_ok = corr > 0.99999
ok &= pair_ok
results[f"{s}-{r}"] = {
"max_abs_diff": max_abs,
"max_abs_rel_to_peak": max_abs / peak if peak else 0.0,
"waveform_corr": corr,
"pass": bool(pair_ok),
}
print(
f"{s}-{r}: corr={corr:.9f} max|diff|/peak={max_abs / peak:.2e} "
f"-> {'PASS' if pair_ok else 'FAIL'}"
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a258188: all-zero CCFs now raise explicitly, and the pass criterion is max_abs_diff == 0.0 (bit-identity), matching the precision-bank policy that any future drift must be seen and justified. Making it strict paid off immediately — it caught a numpy-1.x casting regression while addressing the comment above.

mdenolle and others added 6 commits August 5, 2026 09:53
- snap_window/path_b: exact integer epoch-ns via timedelta division;
  dt.timestamp()*1e9 exceeds float64's 2**53 integer range and shifted
  the rounding target by hundreds of ns.
- run_xcorr_eval: cache keys now encode datacenter + location/channel so
  a rerun with different parameters can never silently reuse the wrong
  bytes (notebook 06 uses the same scheme, re-executed).
- Harness pass criterion is now bit-identity (max_abs_diff == 0.0), with
  an explicit error on all-zero CCFs instead of a ZeroDivisionError.
- segment_interpolate_np: dropped the redundant temp copies, but NOT the
  way Copilot suggested — under numpy 1.x value-based casting a float64
  scalar does not upcast a float32 array, so the plain expression
  silently ran the first product in float32 (2e-5 CCF divergence, caught
  by the now-strict harness; invisible under numpy 2). np.multiply with
  dtype=np.float64 forces float64 on every numpy version without an
  intermediate astype copy. Verified bit-exact vs numba on numpy 1.26.4
  and vs the reference loop on numpy 2.5.1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
January 2022 (93 station-days, 1.1 GB across the three archives).
The comparison panel now shows 1 day / 1 week / 1 month: interstation
Rayleigh-wave packets emerge cleanly in the month stack on all three
pairs, stepping out with distance in the folded record section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
January-February 2022 (177 station-days, 2 GB across the three
archives). The comparison panel now spans 1 day / 1 week / 1 month /
2 months: the interstation Rayleigh-wave packets sharpen and the coda
stabilizes between the one- and two-month stacks on all three pairs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI.RPV (Rancho Palos Verdes, SCEDC S3, blank location code) added as a
fourth station: the 52-km path across the LA basin produces a dominant
symmetric Rayleigh-wave packet visible from a single day of data, and
the four-pair record section now shows moveout from 52 to 455 km.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Notebook 06 now demonstrates the full obspy-free path Marine asked for:
StationXML fetched from each archive's FDSN station service with
seisfetch's raw-HTTP client, parsed and removed with
seisfetch.contrib.response (remove_response_np, cross-checked at
7.6e-16 of peak vs obspy on this data), then every station-day
converted to ground velocity before the NoisePy correlation chain.
A new spectra figure shows all four instruments collapsing onto the
same microseism spectrum in physical units. The prose now states
explicitly that the bit-identity harness runs at rm_resp=NO and where
the response port's own equivalence evidence lives.

Fixes a real dirty-metadata bug found on the way: EarthScope II
StationXML carries 4-digit fractional seconds, which fromisoformat
rejects before Python 3.11 — _parse_iso_utc now normalizes fractions
(regression test added).

benchmarks/RESULTS.md gains a 'NoisePy equivalence' section rendering
the bit-identity JSONs as tables and embedding the two notebook
validation figures (HTML inliner learned PNGs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mdenolle
mdenolle merged commit 15160da into main Aug 6, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants