Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions data/pipelines/era5_zonal_monthly.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# Copyright (C) 2026 University Corporation for Atmospheric Research
# SPDX-License-Identifier: Apache-2.0
"""ERA5 -> the committed zonal-mean monthly climatology (task 1.1, SCIENCE-1, BLOCKING-5).

Produces ``studio/science/data/era5_zonal_monthly_v1.npz`` and its manifest. The product is
(month x level x latitude) for temperature, specific humidity and geopotential height -- a few MB,
committed to the repository with a checksum, so no run ever fetches anything (ADR-006: the raw ERA5
fields are NOT redistributed; the Copernicus licence permits derived products with attribution).

Conventions, each a decision recorded in ASSUMPTIONS.md (ASSUMPTION-9):

* **Years 1991-2020** -- the WMO standard climate normal, so "the June climatology" cites a
standard rather than a habit.
* **Zonal mean, monthly mean** -- SCIENCE-1's answer (issue #53), decided 2026-08-18.
* **2.5-degree grid** at retrieval. The zonal mean over 144 longitudes is insensitive to this, and
it keeps the download ~10^2 MB instead of gigabytes. Latitude resolution of the product is 2.5
degrees; the lookup interpolates.
* **15 pressure levels, 300-5 hPa** -- the lower-to-middle stratosphere the box lives in. The
lookup refuses to extrapolate outside this range.

Usage (this script does NOT run in Studio's locked venv -- it needs ``cdsapi``; make a scratch env):

python -m venv /tmp/cds && /tmp/cds/bin/pip install cdsapi netCDF4 numpy
/tmp/cds/bin/python data/pipelines/era5_zonal_monthly.py fetch /path/to/raw/
/tmp/cds/bin/python data/pipelines/era5_zonal_monthly.py reduce /path/to/raw/

``fetch`` needs ``~/.cdsapirc``. ``reduce`` writes the product and prints the sha256 that goes into
the manifest; commit both files together.
"""

from __future__ import annotations

import argparse
import datetime
import hashlib
import json
from pathlib import Path

#: Pressure levels [hPa], descending altitude. The box's native coordinate is pressure (ADR-003).
LEVELS_HPA = [5, 7, 10, 20, 30, 50, 70, 100, 125, 150, 175, 200, 225, 250, 300]

#: WMO standard climate normal.
YEARS = list(range(1991, 2021))

VARIABLES = {"temperature": "t", "specific_humidity": "q", "geopotential": "z"}

#: Identifier recorded in every run's provenance when the ERA5 dataset is selected.
DATASET_ID = "era5_zonal_monthly_v1"

#: Standard gravity [m s^-2], to turn geopotential into geopotential height (WMO value).
STANDARD_GRAVITY = 9.80665

PRODUCT = Path(__file__).resolve().parents[2] / "studio" / "science" / "data"


def fetch(raw_dir: Path) -> None:
"""One CDS request per variable: each queues independently, and one failure loses one file."""
import cdsapi

client = cdsapi.Client(quiet=True)
for variable in VARIABLES:
target = raw_dir / f"era5_{variable}.nc"
print(f"requesting {variable} -> {target}")
client.retrieve(
"reanalysis-era5-pressure-levels-monthly-means",
{
"product_type": ["monthly_averaged_reanalysis"],
"variable": [variable],
"pressure_level": [str(level) for level in LEVELS_HPA],
"year": [str(year) for year in YEARS],
"month": [f"{month:02d}" for month in range(1, 13)],
"time": ["00:00"],
"data_format": "netcdf",
"grid": [2.5, 2.5],
},
str(target),
)


def reduce(raw_dir: Path) -> None:
"""Raw monthly fields -> (month x level x lat) climatology, zonal mean then multi-year mean."""
import netCDF4
import numpy as np

arrays: dict[str, "np.ndarray"] = {}
latitude = level = None
for variable, short in VARIABLES.items():
with netCDF4.Dataset(raw_dir / f"era5_{variable}.nc") as ds:
data = np.asarray(ds[short][:], dtype=np.float64) # (time, level, lat, lon)
times = netCDF4.num2date(ds["valid_time"][:], ds["valid_time"].units)
months = np.asarray([t.month for t in times])
years = np.asarray([t.year for t in times])
latitude = np.asarray(ds["latitude"][:], dtype=np.float64)
level = np.asarray(ds["pressure_level"][:], dtype=np.float64)
expected = len(YEARS) * 12
if data.shape[0] != expected:
raise SystemExit(f"{variable}: {data.shape[0]} months, expected {expected} -- refetch")
if sorted(set(years)) != YEARS:
raise SystemExit(f"{variable}: years {min(years)}-{max(years)} != {YEARS[0]}-{YEARS[-1]}")
zonal = data.mean(axis=3) # over longitude
monthly = np.stack([zonal[months == m].mean(axis=0) for m in range(1, 13)])
arrays[short] = monthly.astype(np.float32) # (12, level, lat)

assert latitude is not None and level is not None
# BOTH axes ascending, so the lookup's searchsorted works without a flip at read time. CDS
# delivers latitude 90..-90 and pressure levels 300..5; trusting either order as-is is how the
# first cut of this pipeline put the 300 hPa row under every lookup -- caught by the
# stratosphere-shaped physics tests (T(55 hPa) came back 240 K, a tropospheric number).
lat_order = np.argsort(latitude)
latitude = latitude[lat_order]
level_order = np.argsort(level)
level = level[level_order]
for short in arrays:
arrays[short] = arrays[short][:, level_order][:, :, lat_order]

PRODUCT.mkdir(parents=True, exist_ok=True)
out = PRODUCT / f"{DATASET_ID}.npz"
np.savez_compressed(
out,
latitude_deg=latitude.astype(np.float32),
level_hpa=level.astype(np.float32),
month=np.arange(1, 13, dtype=np.int16),
temperature_k=arrays["t"],
specific_humidity_kg_kg=arrays["q"],
geopotential_height_m=(arrays["z"] / STANDARD_GRAVITY).astype(np.float32),
)
digest = hashlib.sha256(out.read_bytes()).hexdigest()
manifest = {
"dataset_id": DATASET_ID,
"source": "ERA5 monthly averaged reanalysis on pressure levels (Copernicus CDS)",
"doi": "10.24381/cds.6860a573",
"licence": (
"Contains modified Copernicus Climate Change Service information (1991-2020). "
"Derived product; raw ERA5 fields are not redistributed."
),
"convention": "zonal mean, then 1991-2020 mean, per calendar month (SCIENCE-1, issue #53)",
"years": [YEARS[0], YEARS[-1]],
"grid_deg": 2.5,
"levels_hpa": LEVELS_HPA,
"created": datetime.date.today().isoformat(),
"sha256": digest,
}
(PRODUCT / f"{DATASET_ID}.json").write_text(json.dumps(manifest, indent=2) + "\n")
size_mb = out.stat().st_size / 1e6
print(f"wrote {out} ({size_mb:.1f} MB), sha256={digest}")


if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("command", choices=["fetch", "reduce"])
parser.add_argument("raw_dir", type=Path)
args = parser.parse_args()
(fetch if args.command == "fetch" else reduce)(args.raw_dir)
22 changes: 22 additions & 0 deletions docs/studio/ASSUMPTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,25 @@ observations in ways this repository does not quantify. Per-dataset caveat that
each background's diameters are **dry or ambient** is implicit in the dataset
(`AMBIENT_BACKGROUNDS` in `coupled/backgrounds.py`) rather than a declared field.

## ASSUMPTION-9 — Climatology product conventions: 1991–2020, 2.5°, 15 levels, no extrapolation

**Made:** 2026-08-19 · **Affects:** `era5_zonal_monthly_v1` (the committed product), and every run
with `site.dataset = ERA5` · **Tracks:** SCIENCE-1 (answered), #94 (other platforms)

Four choices inside SCIENCE-1's answer that the answer itself did not fix:

- **Years 1991–2020** — the WMO standard climate normal, so "the June climatology" cites a standard
rather than a habit.
- **2.5° retrieval grid.** The zonal mean over 144 longitudes is insensitive to this; it keeps the
raw download ~10² MB instead of gigabytes. The product's latitude resolution is therefore 2.5°,
interpolated linearly at lookup.
- **15 pressure levels, 300–5 hPa** — the lower-to-middle stratosphere the box lives in. Linear
interpolation in **log-pressure** between levels.
- **No extrapolation, ever.** Outside 300–5 hPa the derivation raises (ADR-005). A plausible
temperature for 400 hPa from a stratospheric product is exactly the fabricated number this
project forbids.

**How to revisit.** Regenerate with `data/pipelines/era5_zonal_monthly.py` after editing its
constants; the product version in the filename and the manifest's sha256 change together, and
provenance pins which product each run used, so old runs stay attributable.

38 changes: 38 additions & 0 deletions docs/studio/PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,44 @@ derivations to resolve rather than fixtures.

---

### 2026-08-19 — Task 1.1: ERA5 is in the product, the schema, and the wizard

Raised in use: *"I do not see ERA5 data being utilized"* — correct; the decisions existed and the
pipeline did not. Now:

**The committed product.** `data/pipelines/era5_zonal_monthly.py` fetches ERA5 monthly means
(1991–2020, the WMO normal; 15 levels, 300–5 hPa; 2.5° grid — ASSUMPTION-9) and reduces 343 MB of
raw fields to a **0.1 MB** `(month × level × lat)` npz, committed at
`studio/science/data/era5_zonal_monthly_v1.npz` with a sha256 manifest. Raw ERA5 is not
redistributed; the derived product is, with attribution (BLOCKING-5).

**The schema (0.4.0).** `site.dataset` (USER | ERA5) with the emission-system structure: entered
`given_temperature_k` / `given_h2o_ppmv` kept-but-inert, `temperature_k` / `h2o_ppmv` derived.
Pressure stays entered — it places the box, so it is the lookup's coordinate. Default USER, so every
existing config resolves byte-identically. Provenance records the dataset id + checksum
automatically for ERA5 runs (`ProvenanceRecord.datasets`, empty since Phase 0, now used).

**The wizard.** Stage 1 gains the ERA5 temperature-profile panel with the box marked on it; under
USER it is context, under ERA5 the marker is the run's value.

**The physics check that paid for itself twice.** The acceptance tests assert the stratosphere looks
like the stratosphere (tropical cold point 185–205 K, summer pole warmer than winter, H₂O single-digit
ppmv). The first cut of the pipeline shipped CDS's *descending* pressure levels into an interpolation
that assumed ascending — every lookup silently read the 300 hPa row, and T(55 hPa) came back 240 K
with 414 ppmv of water. Plausible numbers at a glance; impossible as a stratosphere. Fixed in the
pipeline (both axes sorted ascending) and refused in the loader (a non-monotonic axis raises rather
than being reordered, since the checksum pins what was read).

**The headline.** ERA5 says **209.42 K** at the ensemble's site (30°N, June, 55 hPa) where the
ensemble typed **210 K** — the paper's number confirmed to half a kelvin by 30 years of reanalysis.
H₂O comes out 4.15 ppmv against the typed 6.91 (RH = 3% assumption), consistent with the known dry
bias (MLS is issue #94). Tropical cold point: 191.4 K.

357 Python Tier-A (+11, all running in CI — the product is committed, so the climatology is the
first substantive science whose tests CI executes), 55 vitest.

---

### 2026-08-18 — SCIENCE-2 and SCIENCE-3 answered; ERA5 confirmed

Three decisions in one message, closing every science question that gated Phases 1–4 (only
Expand Down
2 changes: 1 addition & 1 deletion docs/studio/plan/PHASE_1.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ All 42 schema fields are placed and rendered; `test_layout.py` fails if a new on

- **1.0 Wizard shell** — *done (2026-08-17)*. Layout manifest, generated form, override/stale
actions, review with diff, submit. React + Vite + TS per ADR-007.
- **1.1 Climatology product** — *unblocked (2026-08-18).* SCIENCE-1 is answered: **zonal-mean,
- **1.1 Climatology product** — *done (2026-08-19).* SCIENCE-1 is answered: **zonal-mean,
monthly**, so the reduced product is `(lat × month × level)` — of order 86k values per field, a few
MB, small enough to **commit with a checksum** rather than fetch at run time. Longitude stays an
input for the solar zenith angle but does not select the meteorology, and the convention goes into
Expand Down
56 changes: 52 additions & 4 deletions studio/modelio/preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,9 +226,12 @@ def concentration_sensitivity(config: RunConfig) -> dict[str, Any]:

resolved = resolve(config).config
volume = resolved.injection.plume_volume_cm3
if volume is None or volume <= 0.0:
raise ValueError("plume_volume_cm3 is unresolved; resolve the config before previewing")
air = air_number_density(resolved.site.pressure_mbar, resolved.site.temperature_k)
temperature = resolved.site.temperature_k
if volume is None or volume <= 0.0 or temperature is None:
raise ValueError(
"plume_volume_cm3 / temperature_k are unresolved; resolve the config before previewing"
)
air = air_number_density(resolved.site.pressure_mbar, temperature)
decades = 3.0
volumes = np.logspace(
math.log10(volume) - decades, math.log10(volume) + decades, _CURVE_POINTS // 3
Expand All @@ -239,7 +242,7 @@ def concentration_sensitivity(config: RunConfig) -> dict[str, Any]:
molar_mass_g_per_mol=SO2_MOLAR_MASS_G_PER_MOL,
volume_cm3=float(v),
pressure_mbar=resolved.site.pressure_mbar,
temperature_k=resolved.site.temperature_k,
temperature_k=temperature,
)
for v in volumes
]
Expand All @@ -253,10 +256,55 @@ def concentration_sensitivity(config: RunConfig) -> dict[str, Any]:
}


def climatology_profile(config: RunConfig) -> dict[str, Any]:
"""The ERA5 zonal-mean monthly profile at this latitude and month (stage 1).

Drawn whatever the dataset selection: under USER it is context ("here is what ERA5 thinks this
place looks like") with the typed values marked against it; under ERA5 the marker IS the derived
value. Pure product read -- no model, no JAX.
"""
from studio.science.climatology import DATASET_ID, geopotential_height_m, load, profile

resolved = resolve(config).config
month = resolved.schedule.month
latitude = resolved.site.latitude_deg
data = profile(month=month, latitude_deg=latitude)

# Altitude as a second LABELING of the pressure axis, not a second scale: round-kilometre ticks
# placed at the pressures where those altitudes actually sit at this latitude and month, from
# the product's own geopotential. Interpolated in log-p against z, which is near-linear.
z_km = np.asarray(data["geopotential_height_m"], dtype=float) / 1000.0
log_p = np.log(np.asarray(data["level_hpa"], dtype=float))
ascending = np.argsort(z_km) # z falls as pressure rises, so sort once for interp
altitude_ticks = []
for km in range(int(np.ceil(z_km.min())), int(np.floor(z_km.max())) + 1):
if km % 5 == 0:
pressure = float(np.exp(np.interp(km, z_km[ascending], log_p[ascending])))
altitude_ticks.append({"km": km, "pressure_hpa": pressure})

box_altitude_m = geopotential_height_m(
month=month, latitude_deg=latitude, pressure_mbar=resolved.site.pressure_mbar
)
return {
**data,
"altitude_ticks": altitude_ticks,
"box_altitude_km": box_altitude_m / 1000.0,
"dataset_id": DATASET_ID,
"sha256_12": load().sha256[:12],
"month": month,
"latitude_deg": latitude,
"selected_dataset": resolved.site.dataset.value,
"box_pressure_mbar": resolved.site.pressure_mbar,
"box_temperature_k": resolved.site.temperature_k,
"box_h2o_ppmv": resolved.site.h2o_ppmv,
}


#: Panel name -> builder. The API exposes exactly these, so a typo in a panel name is a 404 naming
#: the ones that exist rather than an empty chart.
PANELS = {
"sza": sza_diurnal,
"climatology": climatology_profile,
"dilution": dilution_curve,
"size-distribution": size_distribution,
"bins": bin_grid,
Expand Down
14 changes: 13 additions & 1 deletion studio/modelio/provenance.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,18 @@ def record_for(
"""
config.require_consistent()
root = repo_root or repository_root()

# Datasets are recorded automatically from the config, not trusted to the caller: a run whose
# temperature came from the ERA5 climatology but whose provenance does not say so would defeat
# the point of ADR-006. The import is lazy and the load cached; under USER nothing is touched.
recorded_datasets = dict(datasets or {})
from studio.schema.enums import ClimatologyDataset

if config.config.site.dataset is ClimatologyDataset.ERA5:
from studio.science.climatology import load

product = load()
recorded_datasets[product.dataset_id] = product.sha256
submodules = {}
for name in MODEL_SUBMODULES:
path = root / name
Expand All @@ -201,7 +213,7 @@ def record_for(
studio_version=studio.__version__,
sandbox=describe_checkout(root),
submodules=submodules,
datasets=dict(datasets or {}),
datasets=recorded_datasets,
resolved_config=config.config.model_dump(mode="json"),
overrides={path: record.value for path, record in sorted(config.overrides.items())},
)
Expand Down
14 changes: 12 additions & 2 deletions studio/modelio/scenario.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,16 @@ def to_scenario(config: RunConfig | ResolvedConfig) -> CoupledScenario:
"start with no SO2."
)

temperature = run.site.temperature_k
h2o = run.site.h2o_ppmv
if temperature is None or h2o is None:
raise ValueError(
"site.temperature_k / site.h2o_ppmv are unresolved. They are DERIVED fields since the "
"ambient state gained a dataset selector (SCIENCE-1): run the config through "
"studio.resolve.resolve() before converting it, rather than letting the model run at "
"a temperature nobody chose."
)

day_of_year = run.schedule.day_of_year
if day_of_year is None:
raise ValueError(
Expand All @@ -89,9 +99,9 @@ def to_scenario(config: RunConfig | ResolvedConfig) -> CoupledScenario:
}

return CoupledScenario(
T=run.site.temperature_k,
T=temperature,
P=run.site.pressure_mbar,
WTR=run.site.h2o_ppmv,
WTR=h2o,
latitude=run.site.latitude_deg,
longitude=run.site.longitude_deg,
day_of_year=day_of_year,
Expand Down
Loading
Loading