Skip to content

Migrate JPSS VIIRS, ATMS, and CrIS data sources to obstore - #1062

Merged
negin513 merged 7 commits into
mainfrom
obstore-jpss-trio
Aug 17, 2026
Merged

Migrate JPSS VIIRS, ATMS, and CrIS data sources to obstore#1062
negin513 merged 7 commits into
mainfrom
obstore-jpss-trio

Conversation

@negin513

@negin513 negin513 commented Aug 12, 2026

Copy link
Copy Markdown
Member

Earth2Studio Pull Request

Description

Closes NVIDIA/physicsnemo-roadmap#2879, closes NVIDIA/physicsnemo-roadmap#2880, closes NVIDIA/physicsnemo-roadmap#2881.

Migrates the three JPSS data sources from s3fs to obstore as one batched effort — they share the NOAA NESDIS S3 bucket layout (noaa-nesdis-{n20,n21,snpp}-pds) and the granule-discovery-by-listing pattern, so the migration and test-fixture work amortizes across all three. Continues the pattern established by GOES/GLM (#1042), Himawari AHI (#1043), and #1058.

  • JPSS (VIIRS) — single-bucket store per instance. Day-directory listings now go through obstore_list_prefix with per-instance memoization: previously every (time, variable) pair re-listed the same day directory (plus one more per geolocation fetch); now a completed day is listed once per product folder, while in-progress days are always re-listed so near-real-time polling still sees new granules. available() moves from sync s3fs to a sync obstore listing that verifies both the data and geolocation prefixes (one listed entry proves existence).
  • JPSS_ATMS — instances span multiple satellites, so _async_init builds one store per bucket. The _ls + FileNotFoundError handling becomes an empty-listing check (obstore lists missing prefixes as empty). Download retry loop, backoff, and exception semantics are unchanged; only the transfer line moved to obstore_read_range.
  • JPSS_CRIS — same per-bucket store layout. The dual SDR/GEO day-listing (asyncio.gather at the old jpss_cris.py:839) stays concurrent as two gathered obstore_list_prefix coroutines, and the entire two-level FileNotFoundError fallback block is deleted — empty listings subsume it. Completed-day listings are memoized per bucket.

Warm-cache compatibility: cache-key hashing is byte-identical everywhere — VIIRS hashes the same bucket-prefixed bucket/key paths s3fs used to return, and ATMS/CrIS rebuild the exact s3://bucket/key URI strings that _cache_path hashes — so local caches populated before the migration remain valid.

Tests: each source gains offline tests that inject fake obspec stores (the _FakeStore pattern from test_himawari_ahi.py) rather than patching obstore internals — covering granule discovery/time-window selection, SDR↔GEO pairing and missing-GEO skip (CrIS), empty-listing/data-gap branches, available(), and a full VIIRS __call__ mock over synthetic HDF5 granules. Combined suite: 57 passed offline, plus the two live available() network tests xpass through the new obstore path.

earth2studio/data/utils.py is untouched — all helpers needed already exist — so there is no overlap with #1058.

Checklist

  • I am familiar with the Contributing Guidelines.
  • New or existing tests cover these changes.
  • The documentation is up to date with these changes.
  • Changelog updated.

Performance

Cold-fetch medians (cache=False, same host; interleaved A/B where noted):

Source File type Benchmark main PR Speedup Source of the win
VIIRS HDF5 (SDR/EDR granules, ~1 data + 1 GEO file per time/var, S3) 2 times × 2 M-band vars 3.7s 2.4s 1.6× obstore transfer path + day-listing memoization
ATMS BUFR (one scan line per file, ~37 small files for ±10m, S3) n20 ±10m, 931k rows 2.8s 0.9s 3.1× obstore + vectorized decode + fetch/decode pipelining
VIIRS HDF5 (M-band SDR granules, S3) 1 time × full M-band lexicon (16 vars, 17 files) ~6.4s ~7.1s ~1.0× parity — 17 granule downloads dominate (high S3 variance, 6 reps/side)
CrIS HDF5 (SCRIF + GCRSO pairs, ~16 MB each, S3) n20 ±2m, 3 channels ~8.2s ~4.4s 1.9× chunked parallel byte-range downloads
CrIS HDF5, full spectrum n20 ±2m, all 2211 channels (16M rows) ~7.0s ~4.7s ~1.5× chunked downloads; decode share grows so the transfer win dilutes

Variable-space coverage: ATMS is inherently the full space (the single atms variable carries all 22 channels via sensor_index). VIIRS is covered for the full M-band lexicon (16 variables); I-band (5 variables, ~4× larger granules) and L2 EDR (14 variables) run the identical code path with different file sizes/counts and were not separately benchmarked. CrIS is covered at both the subset (3 of 2211 channels) and full-spectrum extremes.

VIIRS full M-band reads as parity because 17 granule downloads dominate and S3 variance is high (worst rep 36s on a slow granule — bucket-side, both code paths affected). CrIS full-spectrum dilutes from 1.9× to ~1.5× because decode's share grows relative to transfer at 16M rows; its remaining warm-path costs are S3 listing latency (~60%) and the Planck inversion in radiance_to_bt, both out of scope here.

Beyond the migration itself, the follow-up commits:

  • Vectorize ATMS BUFR decode — numpy column assembly + one Arrow table per BUFR message instead of ~1M per-(FOV, channel) dict appends and eleven astype passes; warm decode+compile 1.8s → 0.6s for 37 granules. Verified bit-identical via assert_frame_equal(check_exact=True) on the full 931k-row frame (values, dtypes, row order, NaN retention, NaT skips).
  • Pipeline ATMS decode behind downloads — each file decodes in a worker thread (serialized by an eccodes lock; eccodes is not thread-safe) as soon as its download lands; decode deduplicated to once per unique file. Same exact-equality parity.
  • VIIRS decode off the event loop — h5py blocks move to asyncio.to_thread under an HDF5 lock (himawari pattern). No measured end-to-end change at small request sizes (interleaved A/B within noise); prevents decode from serializing downloads on large fan-outs. Output verified identical.
  • CrIS chunked downloads — large granules fetch as 1 MiB × 8 concurrent byte ranges instead of one single-stream GET (chunk size tuned empirically; 1 MiB clearly beat 2–8 MiB). sha256 byte parity verified against the single-range path; also reduces variance (4.5–5.8s vs 6.0–12.6s spread).

CrIS decode was measured at 0.14s warm and left alone — it is transfer-bound, which is why its win comes from the download path.

Replaces s3fs with the shared obstore helpers across the three JPSS
sources, which share the NOAA NESDIS S3 bucket layout and the
granule-discovery-by-listing pattern:

- JPSS (VIIRS): single-bucket store; day-directory listings memoized per
  instance once a day can no longer gain files, so repeated
  (time, variable) requests issue one LIST instead of one per pair;
  available() now verifies both data and geolocation prefixes via a sync
  obstore listing
- JPSS_ATMS / JPSS_CRIS: one store per satellite bucket; missing day
  directories are detected via empty listings (obstore does not raise
  FileNotFoundError), and the CrIS SDR/GEO dual listings remain
  concurrent via asyncio.gather over obstore_list_prefix
- Cache-key hashing is unchanged everywhere (sha256 of the historical
  bucket-prefixed path / s3://bucket/key URI), so warm caches populated
  before the migration remain valid
- Tests: fake obspec store injection (Himawari pattern) for listing,
  discovery, availability, and a full VIIRS __call__ mock; no obstore
  internals patched
@copy-pr-bot

copy-pr-bot Bot commented Aug 12, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@greptile-apps

greptile-apps Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR migrates JPSS VIIRS, ATMS, and CrIS discovery and downloads from s3fs to per-bucket obstore stores while preserving cache keys and near-real-time listing behavior.

  • Adds completed-day listing memoization with current-day relisting.
  • Preserves CrIS SDR/GEO pairing and concurrent discovery.
  • Adds offline fake-store coverage for listing, availability, pairing, and fetch behavior.

Confidence Score: 5/5

The PR appears safe to merge with no concrete blocking or non-blocking defects identified.

The migrated listing, download, cache-key, and granule-pairing paths preserve the existing source contracts, and the investigated cache, timezone, and retry concerns do not produce a reachable changed-code failure.

Important Files Changed

Filename Overview
earth2studio/data/jpss.py Migrates VIIRS listing, availability checks, and cached downloads to obstore while retaining bucket-prefixed cache hashing.
earth2studio/data/jpss_atms.py Adds per-satellite obstore instances, bucket-relative day listings, and obstore byte-range downloads without changing task-selection semantics.
earth2studio/data/jpss_cris.py Migrates concurrent SDR/GEO discovery and paired downloads to per-bucket obstore stores while preserving granule matching.
test/data/test_jpss.py Adds offline coverage for availability, listing memoization, closest-granule selection, complete VIIRS fetches, and warm-cache compatibility.
test/data/test_jpss_atms.py Adds fake-store tests for ATMS task discovery, time-window filtering, and empty listings.
test/data/test_jpss_cris.py Adds fake-store tests for CrIS SDR/GEO pairing, missing geolocation files, and empty listings.

Reviews (1): Last reviewed commit: "Migrate JPSS VIIRS, ATMS, and CrIS data ..." | Re-trigger Greptile

Replaces the per-(FOV, channel) Python dict loop in _decode_bufr with
numpy column assembly (repeat/tile over the arrays eccodes already
returns) and one pyarrow Table per BUFR message, concatenated and
converted to pandas once per file. This removes ~1M dict appends plus
eleven sequential column astype passes for a typical +/-10m window.

Decode semantics are preserved exactly and verified by exact-equality
comparison (pandas assert_frame_equal, check_exact) of the full 931k-row
DataFrame against the previous implementation on a real NOAA-20 granule
set: identical values, dtypes, row order, and NaN handling (the
historical fill test keeps NaN observations; invalid FOV timestamps
coerce to NaT and are dropped, matching the old ValueError skip).

Warm-cache decode+compile drops from ~1.8s to ~0.6s for 37 granules
(931k rows); cold end-to-end median 2.2s -> 1.4s on top of the obstore
migration (2.8s on main), i.e. ~2x for the source overall.
fetch_data and fetch_geolocation decoded HDF5 inline in async
coroutines, blocking the event loop (and therefore all in-flight
granule downloads) for the duration of each decode. The h5py blocks now
run via asyncio.to_thread under a module-level _HDF5_LOCK (HDF5 is not
thread-safe), following the himawari_ahi.py pattern.

Decoded output verified identical (np.array_equal with equal_nan) on a
live NOAA-20 M-band fetch. End-to-end time is unchanged at small
request sizes (interleaved A/B within noise); the win is for large
time/variable fan-outs where decode otherwise serializes downloads.
fetch() previously ran two serial phases: download every unique BUFR
file, then decode them all. Each file now decodes in a worker thread
(serialized by a module-level _ECCODES_LOCK — eccodes keeps global C
state and is not thread-safe) as soon as its download lands, while
other downloads continue. Decode work is also deduplicated to once per
unique (uri, variable) instead of once per task, so overlapping
tolerance windows no longer re-decode the same file.

_compile_dataframe keeps all assembly semantics (task order, per-task
window mask, drop_duplicates, empty-result shape, attrs). Output
verified bit-identical (assert_frame_equal check_exact) against the
931k-row baseline. Cold end-to-end median 1.4s -> 0.9s.
CrIS is transfer-bound: granule HDF5 files are tens of MB and a typical
request touches only a few files, so each file downloaded as a single
whole-object GET at single-stream S3 throughput while the worker pool
sat idle. _fetch_remote_file now reads large objects through a chunked
helper: head for size, then 1 MiB byte-range GETs with up to 8 in
flight per file, reassembled in order. Chunk size was tuned empirically
on a real 16 MB SCRIF granule (1 MiB x 8 beat 2-8 MiB variants, which
yield too few concurrent streams). Objects at or under one chunk take a
single range GET.

Retry/backoff, exception tuple, cache-write, and not-found translation
(FileNotFoundError, mirroring obstore_read_range) are unchanged; the
helper stays local to jpss_cris.py to avoid touching utils.py while
another PR has it in flight. Byte parity verified via sha256 against
the single-range path on a real granule; offline fake-store tests cover
chunk reassembly, range coverage, the small-object path, and not-found.

Cold end-to-end median ~8.2s -> ~4.4s (~1.9x).

@NickGeneva NickGeneva left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Over all looks good, main item is the streaming util in the cris streaming, if that should be something thats more general util. Approving to unblock.

Comment thread earth2studio/data/jpss.py
Comment thread earth2studio/data/jpss.py
Comment thread earth2studio/data/jpss_cris.py Outdated
Comment thread earth2studio/data/jpss_cris.py Outdated
Address PR #1062 review feedback:
- Move the CrIS-local _read_object_chunked helper to
  earth2studio/data/utils.py as obstore_read_chunked and give
  obstore_fetch_to_cache a chunked option, so CrIS granule downloads go
  through the shared save-to-cache util (cache file names unchanged)
- Relocate the chunked-read unit tests next to the other obstore util
  tests in test_data_utils.py and cover the chunked ValueError guard
- Clarify why VIIRS HDF5 decode runs via asyncio.to_thread despite
  _HDF5_LOCK, and why day-listing memoization waits an hour past the
  UTC day boundary
@negin513

Copy link
Copy Markdown
Member Author

/ok to test 384a75a

negin513 added a commit to negin513/earth2studio that referenced this pull request Aug 17, 2026
… series

Benchmark harness and results comparing every optimized data source
before/after the obstore migration series (NVIDIA#855-NVIDIA#1065): cold cache,
maximum common variable set, sequential runs, per-run subprocess isolation.
Includes claimed-vs-measured comparison against PR-body numbers and rows
for the open PRs (NVIDIA#914, NVIDIA#1062, NVIDIA#1063, NVIDIA#1065).
@negin513
negin513 merged commit bd5fc48 into main Aug 17, 2026
16 checks passed
@negin513
negin513 deleted the obstore-jpss-trio branch August 17, 2026 01:45
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