Skip to content

obspy-free pre-processing at rm_resp=NO; obspy becomes an extra - #357

Draft
mdenolle wants to merge 1 commit into
mainfrom
feat/seisfetch-data-path
Draft

obspy-free pre-processing at rm_resp=NO; obspy becomes an extra#357
mdenolle wants to merge 1 commit into
mainfrom
feat/seisfetch-data-path

Conversation

@mdenolle

Copy link
Copy Markdown
Collaborator

Step (i) and the NoisePy half of step (iii) of the migration justified by seisfetch's obspy-replacement evaluation: pre-process without obspy at rm_resp=NO, and demote obspy to an optional extra.

Opened as a draft on purpose. It needs TraceSegment from noisepy/noisepy-io#51, so CI will fail against the currently pinned noisepy-seis-io==0.3.5. Sequence: merge #51 → release noisepy-seis-io → bump the pin here → mark ready. Reviewing the code now is useful; merging it now is not possible.

What changes

noise_module.preprocess_raw_np is the obspy-free pre-processing chain for rm_resp=NO: gap check, per-segment nan/inf zeroing, float32 cast, demean, detrend, 5% taper, merge with zero fill, 5%/50 s taper, bandpass pre-filter, Fourier resample with the sub-sample segment_interpolate alignment, and a nearest-sample trim/pad. correlate.preprocess dispatches to it, and now fetches the inventory only when rm_resp != NO — response removal is the one step that needs it. Any other rm_resp setting falls back to the obspy chain, as does an ImportError or a window the numpy chain declines.

The operations are imported from seisfetch.contrib, not copied in. They are translations of obspy routines and live in an LGPL-licensed file there, so importing keeps this MIT tree clear of it, and both projects test one implementation instead of two copies drifting apart.

obspy becomes an extra (obspy / asdf / all), needed only for response removal, the FDSN downloader, StationXML catalogs, the obspy-backed stores, and ASDF I/O. Every import of it is lazy, and noisepy.seis exposes download through a module __getattr__ so importing the package does not pull in the obspy-based downloader.

Why you can trust the numbers are unchanged

The bar is bit-identity, not similarity, because these outputs get published.

tests/test_preprocess_equivalence.py asserts np.array_equal against the obspy chain — plus equal dtype, sampling rate and start time — on both fixture sets at 40, 20 and 1 Hz. The fixtures earn their keep: CI.CCA/CI.HEC 2022-002 start at 00:00:00.0195, which is 0.218 samples off the requested window at 40 Hz and not a whole microsecond, so the resample branch, the sub-sample alignment inside it, and the nearest-sample trim all fire. A companion test fails if those fixtures ever become sample-aligned and stop covering that.

Independently, seisfetch's harnesses run identical SCEDC/NCEDC/EarthScope bytes through both paths and then through NoisePy's own compute_fft/correlate: max|diff|/peak = 0.00e+00 on single-station EN/EZ/NZ/ZZ and on three cross-station pairs.

Two problems the evaluation had not flagged

Both were found by running the real pipeline rather than the ports in isolation:

  1. cc_parameters called obspy.geodetics.base.gps2dist_azimuth for every station pair, writing dist/azi/baz into every saved CCF. It now uses seisfetch's Vincenty port (exactly equal to obspy on 2000 random pairs). taper() similarly called obspy's ("taper", "hann") entry point, which resolves to scipy.signal.windows.hann — now called directly, with a test asserting the two windows are equal.
  2. Microsecond rounding. obspy's miniSEED reader rounds sample times to whole microseconds and noisepy derives fric from UTCDateTime.microsecond; pymseed keeps exact nanoseconds. On a day file starting at 00:00:00.019537920 the chains computed nfric 1.6e-6 apart and outputs differed by ~1e-8 relative — small, but not bit-identical. Fixed upstream in seisfetch 0.4.1.

The acceptance test

tests/test_no_obspy.py runs, in a subprocess where importing obspy and pyasdf raises, the whole default cloud path: import noisepy.seis → pre-process → FFT → cross-correlate to a non-empty CCF with valid dist.

It blocks the module rather than inspecting sys.modules afterwards, deliberately: a lazy import obspy inside a rarely-taken branch would sail through a "was it imported?" check while still making obspy a hard requirement for whoever takes that branch.

This retires the evaluation's two-env caveat. The claim is no longer "the data path does not use obspy" but "the pipeline runs without it installed".

Testing

204 passed, 13 of them new.

One pre-existing failure, unrelated to this branch: tests/test_scheduler_additional.py::test_get_array_size_raises_when_size_missing fails in a full-suite run on main too (verified by checking out origin/main and running the suite). It is order-dependent — the test patches boto3.client, but an earlier test has already cached a client, so describe_jobs returns a MagicMock instead of None and the expected TypeError never fires. It passes in isolation. Worth a separate fix.

Verified against the published seisfetch 0.4.1 wheel from PyPI, not a local checkout.

Before merge

🤖 Generated with Claude Code

Step (i) and the NoisePy half of step (iii) of the migration in seisfetch's
docs/noisepy-obspy-replacement-report.md, which the 2026-08 evaluation
justified. Requires noisepy-seis-io with the array-backed ChannelData.

noise_module.preprocess_raw_np is the obspy-free pre-processing chain for
rm_resp=NO: gap check, per-segment nan/inf zeroing, float32 cast, demean,
detrend, 5% taper, merge with zero fill, 5%/50 s taper, bandpass pre-filter,
Fourier resample with the sub-sample segment_interpolate alignment, and a
nearest-sample trim/pad. correlate.preprocess dispatches to it and now fetches
the inventory only when rm_resp != NO, since response removal is the only step
that needs it. Any other rm_resp setting falls back to the obspy chain, as does
an ImportError or a window the numpy chain declines.

The operations themselves are IMPORTED from seisfetch.contrib rather than copied
in. They are translations of obspy routines and live in an LGPL-licensed file
there, so importing keeps this MIT tree clear of it, and both projects test one
implementation instead of two copies drifting.

The bar is bit-identity, not similarity, because these outputs get published.
tests/test_preprocess_equivalence.py asserts np.array_equal against the obspy
chain — plus equal dtype, sampling rate and start time — on both fixture sets at
40, 20 and 1 Hz. The fixtures earn their keep: CI.CCA/CI.HEC 2022-002 start at
00:00:00.0195, which is 0.218 samples off the requested window at 40 Hz and not
a whole microsecond, so the resample branch, the sub-sample alignment inside it
and the nearest-sample trim all fire. A companion test fails if those fixtures
ever become sample-aligned and stop covering that.

Getting there needed two fixes beyond the plan, both on paths the report did not
flag. taper() called obspy's ("taper", "hann") entry point, which resolves to
scipy.signal.windows.hann — now called directly, with a test asserting the two
windows are equal. cc_parameters called obspy.geodetics.base.gps2dist_azimuth
for every station pair, writing dist/azi/baz into every saved CCF; it now uses
seisfetch's Vincenty port, which is exactly equal to obspy on 2000 random pairs.

obspy is now an extra (obspy / asdf / all), needed only for response removal,
the FDSN downloader, StationXML catalogs, the obspy-backed stores, and ASDF I/O.
Every import of it is lazy: noise_module and correlate import it inside the
functions that use it, annotations are deferred via TYPE_CHECKING, and
noisepy.seis exposes `download` through a module __getattr__ so importing the
package does not pull in the obspy-based downloader.

tests/test_no_obspy.py is the acceptance test: in a subprocess where importing
obspy and pyasdf RAISES, it imports noisepy.seis and runs read -> preprocess ->
FFT -> cross-correlate to a non-empty CCF. It blocks the module rather than
inspecting sys.modules afterwards, because a lazy import inside a rarely-taken
branch would pass an "was it imported?" check while still making obspy a hard
requirement for whoever takes that branch. This retires the evaluation's
two-env caveat: the claim is no longer "the data path does not use obspy" but
"the pipeline runs without it installed".

Pins seisfetch[noisepy]==0.4.1 — the first release whose boto3 floor (>=1.26)
co-exists with the aiobotocore==2.5.2 pin here. Verified against the published
wheel, not just a local checkout.

Tests: 201 passed (188 before, 13 new); the 3 mpi4py errors pre-date this work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant