MERRA-2 pipeline guide¶
What this pipeline does¶
Downloads MERRA-2 reanalysis data from NASA GES DISC via Earthdata Cloud
OPeNDAP
(server-side subsetting — no global file is ever downloaded), cropped to
a fixed Europe bounding box (same footprint as ERA5-Land:
N,W,S,E = 72,-11,34,32), and produces one monthly NetCDF-4 file per
year (MERRA2_<YYYY>_<MM>_all_attrs.nc), matching ERA5-Land's output
convention.
MERRA-2 coverage: 1980 to present.
Collections and attributes¶
Three GES DISC collections cover the 13 downloaded attributes (one OPeNDAP request per collection per day):
| Collection | Short key | Attributes |
|---|---|---|
M2T1NXRAD.5.12.4 |
rad |
SWGDN (-> GHI), ALBEDO |
M2T1NXSLV.5.12.4 |
slv |
T2M, QV2M, U2M, V2M, U10M, V10M, U50M, V50M, PS |
M2T1NXLND.5.12.4 |
lnd |
SNODP, PRECSNOLAND |
Why M2T1NXLND (SNODP/PRECSNOLAND) and U50M/V50M were added.
This was originally scoped out — a 3rd collection means one more
OPeNDAP request per day, and ALBEDO (free within the already-fetched
rad request) was judged more immediately useful. That changed once a
confirmed downstream consumer
(merra2-energy-pipeline,
src/data_pipeline/config.py) specified exactly what its PV and wind
models need: SNODP/PRECSNOLAND for PV snow-loss derating, and
U50M/V50M (hub-height wind, more relevant to turbines than 10 m)
for the wind model. U50M/V50M came free within the existing slv
request (same collection as U10M/V10M); lnd is the one genuinely
new per-day request. PRECSNOLAND arrives as kg/m^2/s and is
converted to kg/m^2/h in transform._convert_units, matching
COSMO's SNOW_GSP+SNOW_CON / ERA5-Land's sf convention.
build_monthly_dataset()'s signature changed from (rad_paths,
slv_paths, *, year, month) to (rad_paths, slv_paths, lnd_paths, *,
year, month) — a breaking change for any direct caller; the pipeline's
own caller (pipeline.py) was updated. Verified live: all 12
months of 2018 re-downloaded/re-transformed against real GES DISC data
(lnd collection included) and checked with verify_merra2_months.py
— correct hour counts, HH:30 span, no gaps, SNODP/PRECSNOLAND/
U50M/V50M all populated with plausible values.
A real bug found during this rerun: export_netcdf always skipped¶
export_netcdf() had its own unconditional if output_path.exists():
skip check, completely independent of pipeline.py's skip_existing/
resume parameter. The first live rerun after adding the lnd
collection reported "OK" for all 12 months while silently writing
zero bytes — the old 2018 output files (from before lnd existed)
were already present on disk, so every write was skipped regardless of
--resume being passed or not. Fixed by removing the internal check
from export_netcdf in all three providers' export.py — the
skip/overwrite decision belongs solely to the caller's already-correct
resume-gated logic. If you need to force a real regeneration and
still see "already exists, skipping" in old logs from before this fix,
delete the stale output file(s) first, then re-run.
Authentication (NASA Earthdata)¶
- Register at https://urs.earthdata.nasa.gov/ (free).
- In Applications > Authorized Apps, add "NASA GESDISC DATA ARCHIVE" to your approved list.
- Either set in
.env:
or create ~/.netrc:
(chmod 600 ~/.netrc). Resolution order is env vars first, then
~/.netrc — see weather.common.net.earthdata_auth.
Why a custom session is needed¶
NASA's login flow redirects across hosts (urs.earthdata.nasa.gov <->
the Earthdata Cloud OPeNDAP host opendap.earthdata.nasa.gov). requests
strips the Authorization header on cross-host redirects by default (a
security precaution), which breaks this specific flow. weather.common
.net.build_session(..., preserve_auth_hosts={...}) re-attaches the
header for exactly these two trusted hosts — see
Merra2Downloader._get_session.
Spatial cropping (OPeNDAP constraint expressions)¶
MERRA-2's native grid is fixed: 0.5° latitude x 0.625° longitude, global
origin at -90/-180. Merra2Downloader._bbox_indices converts the
configured [N, W, S, E] box (MERRA2_AREA env var, default the Europe
box above) to inclusive grid-index ranges via:
Those indices are embedded in the request URL as a DAP4 constraint (shown unencoded), e.g.:
https://opendap.earthdata.nasa.gov/collections/C1276812851-GES_DISC/granules/M2T1NXRAD.5.12.4%3AMERRA2_400.tavg1_2d_rad_Nx.20180101.nc4.dap.nc4
?dap4.ce=/SWGDN[0:1:23][248:1:324][270:1:340];/ALBEDO[0:1:23][248:1:324][270:1:340];/time;/lat[248:1:324];/lon[270:1:340]
Known limitation — no cross-provider regridding. MERRA-2's native grid (0.5°x0.625°) is coarser than and not cell-aligned with ERA5-Land's (0.1°x0.1°). This pipeline crops to the same geographic box on MERRA-2's own grid — it does not interpolate/regrid onto ERA5-Land's grid. Cross-provider regridding, if ever needed, is a separate future task.
Timestamp convention (does NOT align with COSMO/ERA5-Land)¶
MERRA-2's tavg1_2d collections (rad, slv) report true hourly
time-averages labeled at the midpoint of the averaging interval, so
every timestamp lands on HH:30 (e.g. 2018-03-01T00:30, T01:30, …
T23:30) — confirmed against a live 2018-03 smoke test. ERA5-Land and
COSMO-REA6 both label on the hour (HH:00). This is a genuine
provider difference, not a bug: shifting MERRA-2's index to HH:00
would mislabel the averaging window it actually represents.
This is left as-is by design. Do not silently relabel MERRA-2 timestamps to force alignment. Any code that merges/concatenates MERRA-2 with COSMO/ERA5-Land data on a shared time index must handle the 30-minute offset explicitly (e.g. resample/interpolate one series onto the other's index), rather than assuming the raw indices match.
Derived fields: GHI, wind speed, relative humidity¶
GHI = SWGDN directly (no de-accumulation)¶
Unlike ERA5-Land's accumulated ssrd (which needs de-accumulation and
month-boundary repair — see providers/era5_land/boundary_repair.py),
MERRA-2's
SWGDN is an instantaneous flux. GHI is simply SWGDN,
night-masked via weather.common.derived_attributes.apply_derived_fields
(ds, "MERRA2", sol_pos, times, fields=["GHI"]). No boundary bookkeeping
is needed at all.
DNI/DHI are NOT computed in bulk¶
common/derived_attributes.py's DERIVED_FIELDS["MERRA2"] registry
also defines DHI/DNI formulas via pvlib's DIRINT algorithm — but
DIRINT operates on a 1-D time series per site and cannot broadcast over
a full (time, y, x) grid. This is the same limitation that made
ERA5-Land defer DNI/DHI to an opt-in point-wise helper
(era5_land/dni_pointwise.py) rather than compute it in bulk. MERRA-2's
transform.py therefore only requests fields=["GHI"] from the
registry. A merra2/dni_pointwise.py mirroring ERA5-Land's is a
documented future extension, not built in this phase.
Wind speed¶
WS_10M = sqrt(U10M**2 + V10M**2), same convention as COSMO/ERA5-Land.
Relative humidity — specific-humidity-based (new formula)¶
Unlike COSMO (direct RELHUM_2M) or ERA5-Land (dew-point Magnus
formula), MERRA-2 has no direct RH or dew-point field — only QV2M
(specific humidity at 2 m). RH is derived from QV2M + T2M + PS
using the standard specific-humidity -> vapor-pressure conversion,
combined with the Bolton (1980) saturation-vapor-pressure curve:
e = QV2M * PS / (0.622 + 0.378 * QV2M) # vapor pressure, Pa
es = 611.2 * exp(17.67 * T / (T + 243.5)) # Bolton 1980, Pa (T in degC)
RH = 100 * e / es, clipped to [0, 100]
Implemented in weather.providers.merra2.transform._compute_rh.
Important ordering note: unlike ERA5-Land's Magnus RH (which needs
pre-conversion Kelvin inputs, computed before the K->degC step), this
Bolton formula needs T2M in Celsius — so _compute_rh runs
after the unit-conversion step, the reverse of ERA5-Land's ordering.
This is called out explicitly in the transform.py docstring so a
future maintainer doesn't copy ERA5-Land's ordering blindly.
Running the pipeline¶
# Single month smoke test
python src/weather/tests/test_merra2_one_month.py --year 2018 --month 1
# Single year, all 12 months
python src/weather/tests/test_merra2_one_year.py --year 2018 --ncores 8
Bulk multi-year run (sd26, tmux)¶
Before starting, on sd26:
- Sync code. This session's fixes (the
lnd-collection live verification, theexport_netcdfskip-if-exists bug fix, and theCOSMO_CLEANUP/ERA5_CLEANUP/MERRA_CLEANUPcentralization) are local, uncommitted changes on the dev machine as of this writing — commit + push, thengit pullonsd26, before starting a fresh 1980-2025 run. Running without theexport_netcdffix means any month whose output file already exists (e.g. from a prior partial attempt) will silently fail to update, exactly the bug this session found. - Check credentials.
EARTHDATA_USERNAME/EARTHDATA_PASSWORD(or~/.netrc) must be set up onsd26, not just this dev machine. - Check disk. MERRA-2's full 1980-2025 footprint is modest — the
2018 year alone was ~1 GB of monthly output; 46 years is on the
order of tens of GB, trivial against
sd26's 15 TB. No need for--cleanup; the new default (MERRA_CLEANUP=false) keeps daily files, which is what you want anyway per the same reasoning as the COSMO cleanup discussion.
tmux new -s merra2
conda activate weather_env
cd /path/to/weather # your sd26 checkout
git pull
export MERRA_WORK_DIR=/data/soma/merra2
export MERRA2_AREA=72,-11,34,32
mkdir -p logs
python src/weather/tests/test_merra2_multi_year.py \
--from-year 1980 --to-year 2025 \
--ncores 80 --parallel-years 6 \
--resume \
2>&1 | tee -a "logs/merra2_multi_year_$(date +%Y%m%d_%H%M%S).log"
# detach: Ctrl-b then d reattach: tmux attach -t merra2
Notes on the flags:
--resumeis safe to leave on for every invocation, including the first — it only skips a year whose all-12-months output already exists (_all_monthly_exist()), so it's the right default for both a fresh start and any later restart after an interruption.--ncores 80 --parallel-years 6leaves headroom onsd26's 94 cores for other users/jobs, and divides into ~13 cores/year — close to the useful ceiling, since each year's transform phase only ever parallelizes across 12 months (ProcessPoolExecutorcaps atmin(ncores_per_year, 12); cores beyond that per year are wasted, not harmful). Push--parallel-yearsdown (e.g. to 4) ifsd26is shared with other work at the time, since 6 years running concurrently each also issue up toMERRA2_OPENDAP_MAX_CONCURRENT(default 8) requests against GES DISC — 6x8=48 concurrent requests is probably fine (no CDS-style per-account queue) but has not been tested at this scale; watch the first hour of logs for repeated timeouts/retries before trusting it unattended overnight.- No
--cleanup— matches the new centralized default (keep everything; see.claude/open.md's cleanup-centralization entry). - The
teeredirect keeps a persistent, timestamped log outside the tmux scrollback, sotmux attachisn't the only way to check progress or diagnose a failure after the fact. - Runtime estimate: the 2018 rerun this session (
lnd-only download, rad/slv already cached) took ~173 s. A fresh year (all 3 collections, ~1095 daily files) will take meaningfully longer — budget roughly 8-10 minutes/year sequentially as a rough planning number;--parallel-years 6should cut wall-clock time substantially, though the actual speedup depends on how GES DISC responds to sustained concurrent load, which hasn't been measured at this scale. Consider a small trial first (e.g.--from-year 2015 --to-year 2018) to sanity-check throughput before committing to the full 46-year range unattended.
Unlike ERA5-Land's CDS queue (effectively 1 job/account), GES DISC's
OPeNDAP server has no per-account job queue — MERRA2_OPENDAP_MAX_CONCURRENT
(default 8) can be raised more freely than ERA5's ERA5_CDS_MAX_CONCURRENT,
though --parallel-years still divides transform cores the same way.
Fixed: multi-month export deadlock¶
Running more than one month's transform+export concurrently (the
normal case for test_merra2_one_year.py/test_merra2_multi_year.py
with ncores > months-in-flight) used to be able to hang indefinitely.
Root cause: export.py called ds.to_netcdf() directly on the lazy,
xr.open_mfdataset-backed dataset, which hands the write to dask's
default threaded scheduler — spawning ncores threads to write a
single file, all serializing on xarray's netCDF4 write lock. With
multiple sibling ProcessPoolExecutor worker processes each doing this
for their own file at the same time, this could deadlock (confirmed via
py-spy dump: every dask worker thread stuck at
xarray.backends.locks.CombinedLock.__enter__, zero progress for 30+
minutes). Fixed by materialising each variable with .compute() one at
a time before to_netcdf() — the same pattern ERA5-Land's exporter
already used. If you see a test_merra2_one_year.py run stall with no
log output for several minutes and elevated but static process CPU
time, that is this bug; make sure you're on a weather checkout at or
after this fix.