Skip to content

feat: integrate earthaccess for cloud-native NASA Earthdata streaming - #92

Draft
drnimbusrain wants to merge 7 commits into
ufs-community:developfrom
drnimbusrain:feature/earthaccess-cloud-stream-access
Draft

feat: integrate earthaccess for cloud-native NASA Earthdata streaming#92
drnimbusrain wants to merge 7 commits into
ufs-community:developfrom
drnimbusrain:feature/earthaccess-cloud-stream-access

Conversation

@drnimbusrain

@drnimbusrain drnimbusrain commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Motivation

CECE's current data pipeline requires every input stream (LAI, meteorology, soil properties, PAR, etc.) to be pre-staged as local NetCDF files before a run can start. This creates three compounding operational burdens:

  1. Users must independently identify the correct NASA dataset identifiers, versions, and temporal/spatial extents for each physics scheme.
  2. Files must be downloaded, renamed into the expected directory layout, and kept in sync with the run window.
  3. On HPC systems with limited scratch quotas this is frequently the bottleneck that delays or blocks science runs.

earthaccess is a NASA-supported Python library that authenticates against NASA Earthdata Login (EDL) once and returns fsspec file-like objects backed by S3 (in-region) or HTTPS (anywhere). netCDF4/h5netcdf and xarray can read these objects identically to local files — zero bytes hit the local drive unless the user explicitly asks for a download.

This PR adds a thin Python-layer bridge that lets any cece_data stream declare source: earthaccess, offloading granule discovery and credentialed streaming to earthaccess while leaving the C++ core and AMIO path completely unchanged.


Architecture

YAML config
  └─ source: earthaccess stream entry
       │  (short_name, temporal, bounding_box, variables map)
       ▼
src/python/config.py  ─ _from_dict()
       │  routes earthaccess entries to _cece_data["earthaccess_streams"]
       │  (AMIO file-path streams continue unchanged)
       ▼
src/python/earthaccess_resolver.py
  EarthAccessStreamResolver
       │  earthaccess.login()       → NASA EDL auth (env / .netrc / interactive)
       │  earthaccess.search_data() → CMR granule list
       │  earthaccess.open()        → list of fsspec file objects (no download)
       │  xr.open_mfdataset(engine="h5netcdf") → lazy xr.Dataset
       ▼
src/python/stream_bridge.py
  EarthAccessStreamBridge.inject_at_time(import_state, t)
       │  ds.sel(time=t, method="nearest") → per-timestep slice
       │  np.asfortranarray(..., dtype=float64)
       │  import_state.set_field(cece_field, arr)
       ▼
existing pybind11 CeceImportState  →  Kokkos views  →  physics routines

The pybind11 set_field injection path already exists; this PR adds nothing to the C++ layer.


Files Changed

New files

File Purpose
src/python/earthaccess_resolver.py EarthAccessStreamConfig dataclass (CMR search params) and EarthAccessStreamResolver (auth → search → open → xr.Dataset)
src/python/stream_bridge.py EarthAccessStreamBridge — per-timestep nearest-time slice and field injection into CeceImportState
examples/cece_config_earthaccess.yaml Worked example running MEGAN3 + BDSNP + Fengsha from six live NASA streams

Modified files

File Change
src/python/config.py _from_dict routes source: earthaccess streams to _cece_data["earthaccess_streams"]; adds earthaccess_streams property and parse_earthaccess_streams() module-level helper
src/python/__init__.py Exports EarthAccessStreamConfig, EarthAccessStreamResolver, EarthAccessStreamBridge, parse_earthaccess_streams
pyproject.toml Adds [cloud] optional-dependency group (earthaccess, xarray, h5netcdf, fsspec, s3fs)

Example: cece_config_earthaccess.yaml

Demonstrates running MEGAN3, BDSNP, and Fengsha driven entirely by live NASA Earthdata Cloud streams — no local NetCDF files required.

One-time credential setup

# Option A: environment variables (recommended for CI / containers / batch)
export EARTHDATA_USERNAME=<your-EDL-username>
export EARTHDATA_TOKEN=<your-EDL-token>

# Option B: store in ~/.netrc (earthaccess "netrc" strategy)
# machine urs.earthdata.nasa.gov login <user> password <pass>

Install cloud extras

pip install 'cece-tools[cloud]'

Stream-to-physics-scheme mapping

Stream NASA dataset DAAC CECE fields Physics scheme(s)
modis_lai MCD15A2H v061 LPDAAC_ECS leaf_area_index MEGAN3
smap_soil SPL4SMGP NSIDC_CPRD soil_moisture_root, soil_moisture MEGAN3, BDSNP
smap_soiltemp SPL4SMGP NSIDC_CPRD soil_temperature BDSNP
ceres_par CER_SYN1deg-Day LARC_ASDC par_direct, par_diffuse, solar_cosine MEGAN3
era5_wind ERA5_SURFACE_1HR GES_DISC u_wind, v_wind Fengsha
soil_texture MCD12Q1 v061 LPDAAC_ECS land_use_type Fengsha, BDSNP

Key config section

cece_data:
  streams:
    - name: modis_lai
      source: earthaccess          # ← triggers cloud path; no file: key needed
      short_name: MCD15A2H
      version: "061"
      daac: LPDAAC_ECS
      cloud_hosted: true
      temporal_start: "2022-06-25"   # extra window for prev-LAI interpolation
      temporal_end:   "2022-07-04"
      variables:
        Lai_500m: leaf_area_index

    - name: smap_soil
      source: earthaccess
      short_name: SPL4SMGP
      daac: NSIDC_CPRD
      cloud_hosted: true
      temporal_start: "2022-07-01"
      temporal_end:   "2022-07-04"
      variables:
        sm_rootzone: soil_moisture_root
        sm_surface:  soil_moisture

    - name: ceres_par
      source: earthaccess
      short_name: CER_SYN1deg-Day_Terra-Aqua-MODIS_Edition4A
      daac: LARC_ASDC
      cloud_hosted: true
      temporal_start: "2022-07-01"
      temporal_end:   "2022-07-04"
      variables:
        sfc_sw_down_dir_all_1h: par_direct
        sfc_sw_down_dif_all_1h: par_diffuse
        solar_zenith_angle:     solar_cosine

Python usage (pybind11 path)

import cece
from datetime import datetime, timedelta

config = cece.load_config("examples/cece_config_earthaccess.yaml")

# Opens all remote streams once — no data downloaded yet
bridge = cece.EarthAccessStreamBridge(config.earthaccess_streams)

cece.initialize(config)
state = cece.CeceState(nx=1440, ny=720, nz=1)

t = datetime(2022, 7, 1)
while t <= datetime(2022, 7, 3, 23):
    bridge.inject_at_time(state.import_state, t)   # streams slice on demand
    cece.compute(state, config)
    t += timedelta(hours=1)

Design decisions

Decision Rationale
Python-only bridge; C++ core untouched Avoids any risk to production builds; set_field pybind11 injection path already exists
engine="h5netcdf" Only xarray engine that accepts file-like objects for HDF5/NetCDF4; scipy requires seekable local paths
earthaccess.open() not download() Bytes fetched on-demand per slice; nothing written to disk
source: earthaccess opt-in key Existing AMIO/local-file configs work with zero changes
[cloud] optional-dep group Base install stays lean; HPC users with pre-staged files need nothing new
np.asfortranarray before set_field Matches Kokkos LayoutLeft (column-major) convention used by all CECE import state fields

Open items / follow-up

  • Validate short_name against CMR at config-parse time (warn before run starts rather than at first timestep)
  • Auto-derive bounding_box from driver grid extents when not set explicitly
  • Evaluate earthaccess.virtualize() (VirtualiZarr / DMR++) as an alternative to open_mfdataset for large granule counts
  • Add block_size / cache_type fsspec tuning knobs to EarthAccessStreamConfig
  • Unit tests: mock earthaccess.open() with local fixture files to allow CI without live EDL credentials

Tests added (tests/test_earthaccess_stream_bdsnp_megan3.py)

Result: 37 passed, 5 skipped, 0 failures (run: pytest tests/test_earthaccess_stream_bdsnp_megan3.py -v)

All tests run without NASA EDL credentials and without a CECE build. The 5 skipped tests are annotated: 2 require the pybind11 build (requires_cece_core), 3 require live EDL credentials (live_earthdata).

Test classes

Class Tests Grid Scope
TestEarthAccessStreamConfig 4 Dataclass construction, defaults, variable map, bounding box
TestParseEarthAccessStreams 4 parse_earthaccess_streams() — empty, AMIO-only, mixed, earthaccess-only
TestCeceConfigEarthAccessRouting 3 _from_dict sends source:earthaccess to earthaccess_streams; AMIO streams untouched
TestEarthAccessStreamResolverMocked 2 open_as_xarray() with mocked earthaccess; no-granule RuntimeError
TestEarthAccessStreamBridgeMocked 4 inject_at_time() populates state; Fortran-contiguous; float64; multi-stream
TestBDSNPFieldInjectionOnHemcoGrid 5 + 1 skipped 72×46 HEMCO 4°×5° Soil temperature and moisture fields present, shape (46, 72), T∈[200–340 K], SM∈[0–1], 24-h timestep advance. Mirrors PR #85 parity grid.
TestMEGAN3FieldInjectionOnHemcoGrid 8 + 1 skipped 72×46 HEMCO 4°×5° All 5 MEGAN3 fields present; 5 parametrized physical-range checks (LAI, soil moisture, PAR direct/diffuse, solar cosine); shape (46, 72); Fortran-contiguous; 5-timestep advance. Mirrors PR #90 parity grid.
TestCombinedBdsnpMegan3Injection 1 72×46 HEMCO 4°×5° All 7 BDSNP+MEGAN3 fields simultaneously from 3 streams in one inject_at_time call
TestEarthAccessImportError 1 Helpful ImportError + pip install 'cece-tools[cloud]' hint when cloud extras absent
TestExampleConfigParsing 4 tests/cece_config_earthaccess_4x5_test.yaml round-trip: file exists, parses, yields earthaccess streams, has both bdsnp and megan3 schemes
TestLiveEarthDataIntegration 3 skipped CMR granule search + fsspec open smoke test (SMAP + MODIS LAI). Run with: pytest -m live_earthdata

New test config: tests/cece_config_earthaccess_4x5_test.yaml

Matches the exact HEMCO 4°×5° reference grid from PR #85 (SoilNOx parity) and PR #90 (MEGAN isoprene parity):

  • Grid: 72 lon × 46 lat, 5° longitude spacing, ±89° polar centres
  • Run window: 2022-07-01 12:00–13:00 UTC (one 3600-s step)
  • Streams: MODIS MCD15A2H (LAI) + SMAP SPL4SMGP (soil temperature + moisture) + CERES SYN1deg (PAR)
  • Schemes: megan3 + bdsnp (soil_no_method: hemco_3_12_1)

Cross-over with PRs #85 and #90

The BDSNP grid tests assert input field shape (46, 72) and physical ranges matching the HEMCO 3.12.1 MERRA-2 soil inputs used in PR #85. The MEGAN3 grid tests assert all six import fields used by the PR #90 parity simulation are present, correctly shaped, and within physical range before reaching the C++ kernel. The combined injection test verifies all schemes can be fed simultaneously from a single inject_at_time call — the same execution pattern as a live CECE driver timestep.

Source fixes required for standalone test loading

  • src/python/stream_bridge.py: try/except on relative from .earthaccess_resolver import so the module loads without package context in the test runner
  • src/python/config.py: same try/except on from .earthaccess_resolver import EarthAccessStreamConfig

Running

# Install test deps (no NASA credentials needed)
pip install 'cece-tools[cloud]' pytest

# All CI-safe tests
pytest tests/test_earthaccess_stream_bdsnp_megan3.py -v

# Include live EDL smoke tests
export EARTHDATA_USERNAME=<user>
export EARTHDATA_TOKEN=<token>
pytest tests/test_earthaccess_stream_bdsnp_megan3.py -v -m live_earthdata

Adds a Python-layer bridge that lets CECE source any input data stream
directly from NASA Earthdata Cloud via earthaccess/fsspec, with no local
file staging required.

New files:
- src/python/earthaccess_resolver.py  -- EarthAccessStreamConfig dataclass
  and EarthAccessStreamResolver; authenticates against NASA EDL once then
  opens granules as fsspec file-like objects via earthaccess.open().
- src/python/stream_bridge.py         -- EarthAccessStreamBridge; slices
  remote xr.Datasets by nearest timestep and injects float64 arrays into
  CeceImportState via the existing pybind11 set_field path.
- examples/cece_config_earthaccess.yaml -- worked example running MEGAN3,
  BDSNP, and Fengsha from MODIS LAI, SMAP soil moisture, CERES PAR,
  ERA5 wind, and MCD12Q1 land-cover; all streams use source: earthaccess.

Modified files:
- src/python/config.py  -- _from_dict now routes source: earthaccess
  streams into _cece_data["earthaccess_streams"] instead of AMIO;
  adds earthaccess_streams property and parse_earthaccess_streams()
  module-level helper.
- src/python/__init__.py -- exports EarthAccessStreamConfig,
  EarthAccessStreamResolver, EarthAccessStreamBridge,
  parse_earthaccess_streams.
- pyproject.toml -- adds [cloud] optional-dependency group:
  earthaccess>=0.12, xarray, h5netcdf, fsspec, s3fs.

Backward compatible: existing file-path streams continue to use the
unmodified AMIO/netcdf4 path. Cloud deps are optional (pip install
'cece-tools[cloud]'); a clear ImportError is raised if missing.
37 tests, 5 skipped (5 require live EDL credentials or pybind11 build)
0 failures.

New files:
- tests/test_earthaccess_stream_bdsnp_megan3.py
  11 test classes covering the full earthaccess streaming stack:
    1. EarthAccessStreamConfig dataclass unit tests
    2. parse_earthaccess_streams() helper
    3. CeceConfig routing (source:earthaccess vs AMIO path)
    4. EarthAccessStreamResolver with mocked earthaccess
    5. EarthAccessStreamBridge.inject_at_time() end-to-end
    6. BDSNP soil-NO fields on HEMCO 72x46 4x5 grid (mirrors PR ufs-community#85)
    7. MEGAN3 isoprene fields on HEMCO 72x46 4x5 grid (mirrors PR ufs-community#90)
    8. Combined BDSNP+MEGAN3 simultaneous injection
    9. ImportError guard for missing cloud extras
   10. cece_config_earthaccess_4x5_test.yaml round-trip parse
   11. Live Earthdata smoke tests (skipped without EDL credentials)

- tests/cece_config_earthaccess_4x5_test.yaml
  Test config matching the exact HEMCO 4x5 grid from PRs ufs-community#85/ufs-community#90:
  72 lon x 46 lat, +-89 polar centres, one 3600-s step.
  MODIS MCD15A2H (LAI) + SMAP SPL4SMGP (soil) + CERES PAR streams.

Modified:
- src/python/stream_bridge.py   -- try/except for relative import so
  module loads standalone without package context (required by tests)
- src/python/config.py          -- same try/except on earthaccess_resolver
  import for standalone test loading
- examples/cece_config_earthaccess.yaml -- grid updated from F360 to
  HEMCO_4x5 to align with parity grids from PRs ufs-community#85 and ufs-community#90
@bbakernoaa

Copy link
Copy Markdown
Collaborator

I like the idea of this. I think we should also build in methods utilizing grib2io (followup PR) to directly stream met data from NOAA forecasts.

…1 oracle

Adds scripts/run_bdsnp_megan3_4x5_global_test.py, a driver that evaluates
CECE's checked-in native MEGAN3 (cece_megan3.cpp, cece_megan.hpp,
cece_emission_activity.cpp) and BDSNP (cece_bdsnp.cpp) equations directly
in NumPy on the 72x46 HEMCO 4x5 grid from
tests/cece_config_earthaccess_4x5_test.yaml, using synthetic global inputs
standing in for the MODIS/SMAP/CERES earthaccess streams (no Kokkos/ESMF
build or live NASA EDL credentials available in this environment).

Produces global isoprene and soil-NO maps, a percent-difference map and
zonal-mean comparison against the HEMCO 3.12.1 MEGAN source oracle from
PR ufs-community#90, a 16-case scalar validation table, and a summary report
(results/bdsnp_megan3_4x5_test/). The oracle self-check reproduces PR ufs-community#90's
reference CSV exactly (0 error); against it, CECE's current MEGAN3 kernel
diverges substantially because it hard-codes T_AVG_15=297K, PAR_AVG=400,
DOY=180, and a 30-day LAI age interval instead of HEMCO's cold-start
convention, and uses LDF=0.9996 vs HEMCO's 1.0.

Also carries in tests/data/hemco_megan/{README.md,
hemco_3_12_1_megan_reference.csv} from PR ufs-community#90
(feat/hemco-megan-3121-parity) as comparison reference data only; that PR
is not merged here. No HEMCO reference exists for BDSNP anywhere in the
repo, so BDSNP is reported on its own, cross-checked only against its
documented freezing behavior (soil T <= 0C -> 0 emission).
…list

Addresses all five open items from the PR ufs-community#92 conversation:

- Validate short_name against CMR at config-parse time: new
  validate_short_name()/validate_short_names() in earthaccess_resolver.py
  query earthaccess.search_datasets() and warn (never raise) before the run
  starts instead of failing at the first timestep. Opt-in via
  cece_data.validate_earthaccess_short_names: true so config parsing never
  requires network access by default.
- Auto-derive bounding_box from driver grid extents: CeceConfig now parses
  driver.grid and both CeceConfig._from_dict and parse_earthaccess_streams()
  fill in an unset stream bounding_box from it; explicit bounding_box values
  are left untouched.
- Evaluated earthaccess.virtualize()/open_virtual_mfdataset (VirtualiZarr /
  DMR++) as an open_mfdataset alternative: added opt-in
  EarthAccessStreamConfig.use_virtual (virtual: true in YAML). Not made the
  default because DMR++ sidecars aren't guaranteed for the DAAC collections
  this module targets; open_as_xarray falls back to open_mfdataset with a
  warning if it's unsupported or fails.
- Added block_size/cache_type fsspec tuning knobs to EarthAccessStreamConfig,
  forwarded to earthaccess.open() with a graceful fallback (+ warning) for
  earthaccess versions that reject them.
- Added unit tests that mock only earthaccess.login/search_data/open and
  read genuine local NetCDF4 fixture files through the real
  xr.open_mfdataset(engine="h5netcdf") path, so CI can exercise real file I/O
  without live EDL credentials.

Also exports validate_short_name/validate_short_names from the cece package
__init__, and adds dask to the [cloud] extras (required by open_mfdataset).
…ntax)

- scripts/run_bdsnp_megan3_4x5_global_test.py: csv.DictWriter now sets
  lineterminator="\n" explicitly, since csv's default "excel" dialect always
  writes CRLF regardless of file mode; regenerated
  scalar_case_validation.csv with LF endings.
- tests/test_earthaccess_stream_bdsnp_megan3.py: replaced parenthesized
  multi-context-manager `with (...)` statements (Python 3.9+ syntax) with
  backslash-continued `with a, b:` form, since ruff's configured target is
  Python 3.8 (pyproject.toml requires-python>=3.8) and rejected the newer
  syntax as invalid; added `# noqa: E402` to two intentionally late imports
  required by the module-loading bootstrap.
- Applied ruff-format's auto-formatting across the touched Python files.

Verified with `pre-commit run` on all touched files (all hooks pass) and
`pytest tests/test_earthaccess_stream_bdsnp_megan3.py` (63 passed, 5 skipped).
examples/cece_config_earthaccess.yaml and
tests/cece_config_earthaccess_4x5_test.yaml had manually aligned colons and
inline-comment spacing that prettier's YAML formatter normalizes (single
space after ':' and before trailing '#'). Ran `pre-commit run prettier
--all-files` and applied its output; whitespace-only, no semantic change.

Verified with `pre-commit run --all-files` (all hooks pass) and
`pytest tests/test_earthaccess_stream_bdsnp_megan3.py` (63 passed, 5 skipped).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants