Skip to content

Add Aurora v1.5 deterministic and ensemble prognostic model wrappers#965

Open
swbg wants to merge 4 commits into
NVIDIA:mainfrom
swbg:feat/aurora-v1p5
Open

Add Aurora v1.5 deterministic and ensemble prognostic model wrappers#965
swbg wants to merge 4 commits into
NVIDIA:mainfrom
swbg:feat/aurora-v1p5

Conversation

@swbg

@swbg swbg commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Description

Add AuroraV1p5 and AuroraV1p5Ensemble prognostic model wrappers for the Microsoft Aurora v1.5 0.25-degree global weather model.

Model details

Property Value
Architecture Transformer (Swin-V2 encoder–processor–decoder), PyTorch
Time step 6h AR base; 1h resolution via multi-lead-time queries
Input variables 83 (65 atmos: z, q, t, u, v × 13 levels; 18 surface)
Output variables 90 (83 inputs + 7 output-only diagnostics: i10fg, blh, uvb1h, ssrd1h, ttr1h, tp1h, sf1h)
Spatial resolution 0.25° × 0.25° (720 × 1440 global grid)
History required Two time steps: t−6h and t
Checkpoint source HuggingFace: ikwessel/aurora-1.5
Checkpoint size ~4.7 GB (deterministic), ~4.8 GB (ensemble)
Checkpoint license Microsoft Research License (non-commercial)
Reference https://arxiv.org/abs/2405.13063
GitHub https://github.com/microsoft/aurora

Dependencies added

Package Version License License URL Reason
microsoft-aurora >=2.0.0 MIT link Aurora model code and checkpoint loader

⚠️ The model checkpoint itself is released under the Microsoft Research License, which restricts commercial use. The microsoft-aurora Python package is MIT-licensed.

Reference comparison

Reference comparison validated against the official aurora.rollout() path using ERA5 initial conditions (2025-01-01T00:00 UTC) fetched directly from the ARCO GCS zarr store, with deterministic settings (torch.inference_mode()).

Cycle 1 (h+1 through h+6):

  • Max absolute difference: 0.0 (bit-perfect)
  • Correlation: 1.0

Cycle 2 (h+7 through h+12):

  • Max absolute difference: 0.0 (bit-perfect after fixing AR clipping)
  • Correlation: 1.0

A key fix was discovered during validation: _AR_CLIP_BOUNDS was missing three variables (swvl1, sic, sd) that Aurora's apply_rollout_input_clipping applies at the cycle boundary. Once added, both AR cycles achieve full bit-level agreement.

Validation

See the reference-comparison validation comment below. Plot placeholders are
included for manual image upload by the PR author.

Checklist

  • I am familiar with the Contributing Guidelines.
  • New or existing tests cover these changes.
  • The documentation is up to date with these changes.
  • The CHANGELOG.md is up to date with these changes.
  • An issue is linked to this pull request.
  • Assess and address Greptile feedback (AI code review bot).

Adds AuroraV1p5 and AuroraV1p5Ensemble wrappers for the Microsoft Aurora
v1.5 0.25-degree global weather model. The wrappers use an hourly rollout:
the 6-hour auto-regressive base step is queried at lead times t+1h through
t+6h before advancing the AR state, yielding 90-variable outputs (65 atmos
+ 18 surface + 7 output-only diagnostics). Bit-perfect agreement with the
official aurora.rollout() path verified across 12 hourly steps (2 full AR
cycles).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@swbg

swbg commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator Author

Reference Comparison Validation

Model: AuroraV1p5 / AuroraV1p5Ensemble
Comparison date: 2026-07-14T00:00:00
Environment: GPU validation run

Initial condition: ERA5 at 2025-01-01T00:00 UTC (ARCO GCS zarr store, 0.25°).
Vanilla reference uses aurora.rollout(steps=2, fine_lead_times=[1..6]).
E2S reference uses AuroraV1p5.create_iterator for 12 hourly steps.
Both run under torch.inference_mode().

Results Summary

Metric h+01 h+06 h+07 h+12
Max absolute difference 0.0 0.0 0.0 0.0
Mean absolute difference 0.0 0.0 0.0 0.0
Correlation 1.00000000 1.00000000 1.00000000 1.00000000
Finite fraction 1.0 1.0 1.0 1.0

Full per-step table (all 12 steps):

Hour     MaxAbsErr        RelErr        Corr    NaN(v)    NaN(e)
-----------------------------------------------------------------
  h+01      0.000000      0.000000  1.00000000         0         0
  h+02      0.000000      0.000000  1.00000000         0         0
  h+03      0.000000      0.000000  1.00000000         0         0
  h+04      0.000000      0.000000  1.00000000         0         0
  h+05      0.000000      0.000000  1.00000000         0         0
  h+06      0.000000      0.000000  1.00000000         0         0
  h+07      0.000000      0.000000  1.00000000         0         0
  h+08      0.000000      0.000000  1.00000000         0         0
  h+09      0.000000      0.000000  1.00000000         0         0
  h+10      0.000000      0.000000  1.00000000         0         0
  h+11      0.000000      0.000000  1.00000000         0         0
  h+12      0.000000      0.000000  1.00000000         0         0

Mean correlation across 12 steps: 1.00000000
Min  correlation across 12 steps: 1.00000000

Key findings:

  • Bit-perfect agreement (MaxAbsErr = 0.0, Corr = 1.0) across all 12 hourly steps, spanning 2 full 6-hour AR cycles.
  • An intermediate validation finding: _AR_CLIP_BOUNDS was initially missing three variables (swvl1 [0, 70], sic [0, 1], sd [0, 10]) from Aurora's apply_rollout_input_clipping. Before this fix, cycle 2 (h+7–h+12) showed max differences up to 3482.5 J/m² in ssrd1h (0.15% relative error). The fix restores full bit-level agreement.
  • No NaN/Inf values in any output step for either implementation.
  • Visual sanity plots (reference_aurora_v1p5_sanity.png) show physically plausible global fields with expected temperature gradients, jet stream structure, and sea-ice patterns.

Reference Plots

TODO: Upload reference_aurora_v1p5_sanity.png (sanity check: t2m, z500, u10m at h+1) here.

image

TODO: Upload reference_aurora_v1p5_compare.png (E2S vs Vanilla t2m at h+1, h+6, h+12 — rows: E2S / Vanilla / |diff|) here.

image

Validation Scripts

Vanilla reference script
"""Vanilla Aurora v1.5 inference — NO Earth2Studio imports.

Uses the official aurora.rollout() function and fetches initial conditions
directly from the ARCO ERA5 zarr store on GCS (no Earth2Studio data utilities).
Runs 12 hourly steps (2 full AR cycles of 6 hours each).

Saves output tensors to reference_aurora_v1p5_vanilla.pt.

Requires: pip install microsoft-aurora>=2.0.0 gcsfs zarr
"""

import os
import pickle
from datetime import datetime, timezone

import gcsfs
import numpy as np
import torch
import zarr
from aurora import AuroraV1p5 as AuroraModel
from aurora import Batch, Metadata
from aurora.insolation import insolation as aurora_insolation
from aurora.normalisation import log_untransform as aurora_log_untransform
from aurora.rollout import rollout

DEVICE = "cuda:0"

T0 = np.datetime64("2025-01-01T00:00")
T_MINUS_6 = T0 - np.timedelta64(6, "h")

ATMOS_LEVELS = [1000, 925, 850, 700, 600, 500, 400, 300, 250, 200, 150, 100, 50]

ARCO_PATH = "/gcp-public-data-arco-era5/ar/full_37-1h-0p25deg-chunk-1.zarr-v3"
ARCO_EPOCH = np.datetime64("1900-01-01T00:00")

# Populate via: python -c "from aurora import AuroraV1p5; AuroraV1p5.load_model(AuroraV1p5.load_default_package())"
# or run the E2S script first so the cache is warm.
CACHE_DIR = os.path.expanduser("~/.cache/earth2studio/aurora-v1p5")
CKPT_PATH = os.path.join(CACHE_DIR, "aurora-0.25-v1.5.ckpt")
STATIC_PATH = os.path.join(CACHE_DIR, "aurora-0.25-v1.5-static.pickle")

_AURORA_TO_ARCO_SURF = {
    "msl": "mean_sea_level_pressure",
    "10u": "10m_u_component_of_wind",
    "10v": "10m_v_component_of_wind",
    "2t": "2m_temperature",
    "2d": "2m_dewpoint_temperature",
    "tcwv": "total_column_water_vapour",
    "tcc": "total_cloud_cover",
    "100u": "100m_u_component_of_wind",
    "100v": "100m_v_component_of_wind",
    "sp": "surface_pressure",
    "lcc": "low_cloud_cover",
    "mcc": "medium_cloud_cover",
    "hcc": "high_cloud_cover",
    "skt": "skin_temperature",
    "stl1": "soil_temperature_level_1",
    "swvl1": "volumetric_soil_water_layer_1",
    "ci": "sea_ice_cover",
    "scaled_sd": "snow_depth",
}

_AURORA_TO_ARCO_ATMOS = {
    "z": "geopotential",
    "q": "specific_humidity",
    "t": "temperature",
    "u": "u_component_of_wind",
    "v": "v_component_of_wind",
}

_OUTPUT_ONLY_SURF_VARS = [
    ("i10fg", "i10fg", False),
    ("blh", "blh", False),
    ("uvb1h", "uvb_1h", False),
    ("ssrd1h", "ssrd_1h", False),
    ("ttr1h", "ttr_1h", False),
    ("tp1h", "scaled_tp_1h", True),
    ("sf1h", "scaled_sf_1h", True),
]


def ts_to_datetime(ts: np.datetime64) -> datetime:
    epoch = np.datetime64("1970-01-01T00:00:00")
    seconds = float((ts.astype("datetime64[s]") - epoch) / np.timedelta64(1, "s"))
    return datetime.fromtimestamp(seconds, tz=timezone.utc)


def arco_time_index(ts: np.datetime64) -> int:
    return int((ts - ARCO_EPOCH) / np.timedelta64(1, "h"))


print("Loading Aurora v1.5 weights from local cache...")
model = AuroraModel()
model.load_checkpoint_local(CKPT_PATH)
model.eval().to(DEVICE)

print("Loading static vars from pickle...")
with open(STATIC_PATH, "rb") as f:
    static_raw = pickle.load(f)  # noqa: S301
static_vars = {k: torch.from_numpy(np.asarray(v))[:720, :] for k, v in static_raw.items()}

LAT = np.linspace(90, -90, 720, endpoint=False)
LON = np.linspace(0, 359.75, 1440)

print("Opening ARCO zarr store...")
fs = gcsfs.GCSFileSystem(token="anon", asynchronous=False)
zstore = zarr.storage.FsspecStore(fs, path=ARCO_PATH)
zg = zarr.open(store=zstore, mode="r")

all_levels = zg["level"][:]
level_indices = [int(np.searchsorted(all_levels, lv)) for lv in ATMOS_LEVELS]
ti_minus6 = arco_time_index(T_MINUS_6)
ti_0 = arco_time_index(T0)

atmos_data = {}
for aurora_name, arco_name in _AURORA_TO_ARCO_ATMOS.items():
    arr = zg[arco_name]
    chunk = np.stack(
        [arr[ti_minus6, level_indices, :720, :], arr[ti_0, level_indices, :720, :]],
        axis=0,
    ).astype(np.float32)
    atmos_data[aurora_name] = torch.from_numpy(chunk).unsqueeze(0)

surf_data = {}
for aurora_name, arco_name in _AURORA_TO_ARCO_SURF.items():
    arr = zg[arco_name]
    chunk = np.stack(
        [arr[ti_minus6, :720, :], arr[ti_0, :720, :]],
        axis=0,
    ).astype(np.float32)
    surf_data[aurora_name] = torch.from_numpy(chunk).unsqueeze(0)

for k in surf_data:
    surf_data[k] = torch.nan_to_num(surf_data[k], nan=0.0)

dt0 = ts_to_datetime(T_MINUS_6)
dt1 = ts_to_datetime(T0)
insol = aurora_insolation((dt0, dt1), LAT, LON, enforce_2d=True)
surf_data["insolation"] = torch.from_numpy(np.asarray(insol)).float().unsqueeze(0)

batch = Batch(
    surf_vars=surf_data,
    static_vars=static_vars,
    atmos_vars=atmos_data,
    metadata=Metadata(
        lat=torch.from_numpy(LAT).float(),
        lon=torch.from_numpy(LON).float(),
        time=(dt1,),
        atmos_levels=tuple(int(lv) for lv in ATMOS_LEVELS),
        rollout_step=0,
    ),
)

print("Running official aurora.rollout() — 2 AR cycles, fine_lead_times=[1..6]...")
sub_outputs = {}

with torch.inference_mode():
    for h, pred in enumerate(
        rollout(model, batch, steps=2, fine_lead_times=[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]),
        start=1,
    ):
        atmos_out = torch.cat(
            [pred.atmos_vars["z"], pred.atmos_vars["q"], pred.atmos_vars["t"],
             pred.atmos_vars["u"], pred.atmos_vars["v"]],
            dim=2,
        )
        surf_keys_ordered = list(_AURORA_TO_ARCO_SURF.keys())
        surf_out = torch.cat(
            [pred.surf_vars[k].unsqueeze(2) for k in surf_keys_ordered],
            dim=2,
        )
        diag_tensors = []
        for _e2s_name, aurora_name, log_scaled in _OUTPUT_ONLY_SURF_VARS:
            v = pred.surf_vars[aurora_name]
            if log_scaled:
                v = aurora_log_untransform(v)
            diag_tensors.append(v.unsqueeze(2))
        diag_out = torch.cat(diag_tensors, dim=2)
        out_tensor = torch.cat([atmos_out, surf_out, diag_out], dim=2).cpu()
        sub_outputs[h] = out_tensor

torch.save(sub_outputs, "reference_aurora_v1p5_vanilla.pt")
print("Saved reference_aurora_v1p5_vanilla.pt")
Earth2Studio reference script
"""Earth2Studio AuroraV1p5 wrapper inference.

Fetches the same ERA5 initial conditions from ARCO as the vanilla script,
runs 12 hourly steps (2 full AR cycles) using the E2S AuroraV1p5 wrapper via
create_iterator, and saves the output tensors to reference_aurora_v1p5_e2s.pt.

Requires: pip install earth2studio[aurora-v1p5]
"""

import numpy as np
import torch

from earth2studio.data import ARCO
from earth2studio.data.utils import fetch_data
from earth2studio.models.px.aurora_v1p5 import VARIABLES, AuroraV1p5
from earth2studio.utils.coords import map_coords

DEVICE = "cuda:0"
TIME = np.array([np.datetime64("2025-01-01T00:00")])
N_STEPS = 12

print("Loading AuroraV1p5 via E2S...")
package = AuroraV1p5.load_default_package()
model = AuroraV1p5.load_model(package).to(DEVICE)
model.eval()

print("Fetching ARCO ERA5 data...")
data = ARCO()
lead_time = model.input_coords()["lead_time"]
variable = np.array(VARIABLES)
x, coords = fetch_data(data, TIME, variable, lead_time, device=DEVICE)
x, coords = map_coords(x, coords, model.input_coords())

print(f"Running {N_STEPS} E2S steps via create_iterator...")
sub_outputs = {}

with torch.inference_mode():
    p_iter = model.create_iterator(x, coords)
    next(p_iter)  # skip IC yield (h+0 with NaN output-only channels)
    for h, (out, _) in enumerate(p_iter, start=1):
        result = out.cpu().view(1, 1, 90, 720, 1440)
        sub_outputs[h] = result
        print(f"  h+{h}: shape={result.shape}  max={result.abs().max():.4f}")
        if h >= N_STEPS:
            break

torch.save(sub_outputs, "reference_aurora_v1p5_e2s.pt")
print("Saved reference_aurora_v1p5_e2s.pt")
Comparison script
"""Compare vanilla Aurora v1.5 outputs against Earth2Studio wrapper outputs.

Loads reference_aurora_v1p5_vanilla.pt and reference_aurora_v1p5_e2s.pt and
reports max absolute difference, relative error, and correlation for each of
the 12 hourly steps (2 full AR cycles). Also generates a comparison plot
(t2m at h+1, h+6, h+12; rows: E2S / Vanilla / |diff|).

Relative error denominator: max(abs(vanilla), 1e-6).
"""

import numpy as np
import torch

from earth2studio.models.px.aurora_v1p5 import VARIABLES

vanilla = torch.load("reference_aurora_v1p5_vanilla.pt", map_location="cpu")
e2s = torch.load("reference_aurora_v1p5_e2s.pt", map_location="cpu")

N_STEPS = len(vanilla)
T2M_IDX = list(VARIABLES).index("t2m")

print(f"{'Hour':>4}  {'MaxAbsErr':>12}  {'RelErr':>12}  {'Corr':>10}  {'NaN(v)':>8}  {'NaN(e)':>8}")
print("-" * 65)

corrs = []
for h in range(1, N_STEPS + 1):
    v = vanilla[h].float()  # (1, 1, 90, 720, 1440)
    e = e2s[h].float()      # (1, 1, 90, 720, 1440)

    diff = (v - e).abs()
    max_err = diff.max().item()
    denom = v.abs().clamp(min=1e-6)
    rel_err = (diff / denom).max().item()

    v_flat = v.flatten()
    e_flat = e.flatten()
    corr = torch.corrcoef(torch.stack([v_flat, e_flat]))[0, 1].item()
    corrs.append(corr)

    nan_v = v.isnan().sum().item()
    nan_e = e.isnan().sum().item()

    print(f"  h+{h:02d}  {max_err:>12.6f}  {rel_err:>12.6f}  {corr:>10.8f}  {nan_v:>8}  {nan_e:>8}")

print()
print(f"Mean correlation across {N_STEPS} steps: {np.mean(corrs):.8f}")
print(f"Min  correlation across {N_STEPS} steps: {np.min(corrs):.8f}")

try:
    import matplotlib.pyplot as plt

    steps = [1, 6, N_STEPS]
    fig, axes = plt.subplots(3, len(steps), figsize=(15, 9))
    fig.suptitle("AuroraV1p5: E2S vs Vanilla t2m (K)", fontsize=13)

    vmin, vmax = 220, 320
    for col, h in enumerate(steps):
        v_t2m = vanilla[h][0, 0, T2M_IDX].numpy()
        e_t2m = e2s[h][0, 0, T2M_IDX].numpy()
        err = np.abs(v_t2m - e_t2m)

        axes[0, col].imshow(e_t2m, vmin=vmin, vmax=vmax, origin="upper", aspect="auto")
        axes[0, col].set_title(f"E2S  h+{h}")
        axes[0, col].axis("off")

        axes[1, col].imshow(v_t2m, vmin=vmin, vmax=vmax, origin="upper", aspect="auto")
        axes[1, col].set_title(f"Vanilla  h+{h}")
        axes[1, col].axis("off")

        im = axes[2, col].imshow(err, origin="upper", aspect="auto", cmap="Reds")
        axes[2, col].set_title(f"|E2S - Vanilla|  h+{h}")
        axes[2, col].axis("off")
        plt.colorbar(im, ax=axes[2, col], fraction=0.03)

    plt.tight_layout()
    plt.savefig("reference_aurora_v1p5_compare.png", dpi=120, bbox_inches="tight")
    print("Saved reference_aurora_v1p5_compare.png")
    plt.close()
except ImportError:
    print("matplotlib not available — skipping plot")

@greptile-apps

greptile-apps Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds Aurora v1.5 prognostic model support. The main changes are:

  • New deterministic and ensemble Aurora v1.5 wrappers.
  • New Aurora v1.5 optional dependency and install docs.
  • Added model exports, documentation entries, and changelog text.
  • Added lexicon entries for new inputs and diagnostics.
  • Added mock and package tests for the new wrappers.

Confidence Score: 4/5

The rollout iterator state needs a fix before merging.

  • Reusing one wrapper instance can carry preds_idx into a new forecast.
  • Interleaved iterators can update the same rollout counter.
  • The optional dependency handling matches the existing model pattern.

earth2studio/models/px/aurora_v1p5.py

Important Files Changed

Filename Overview
earth2studio/models/px/aurora_v1p5.py Adds the Aurora v1.5 wrappers, hourly rollout logic, AR clipping, output conversion, and ensemble noise reset; rollout-step state persists across independent iterators.
earth2studio/models/px/init.py Exports the new Aurora v1.5 model classes from the prognostic model package.
pyproject.toml Adds the aurora-v1p5 extra and includes it in the all extra.
test/models/px/test_aurora_v1p5.py Adds unit and package tests for first-use call and iterator behavior.
earth2studio/lexicon/base.py Adds vocabulary entries for Aurora v1.5 diagnostic variables.
earth2studio/lexicon/ncar.py Adds NCAR ERA5 mappings for additional Aurora v1.5 surface variables.
docs/modules/models_px.rst Adds the new Aurora v1.5 wrappers to the model documentation index.
docs/userguide/about/install.md Documents installation for the new Aurora v1.5 extra.
CHANGELOG.md Records the new Aurora v1.5 deterministic and ensemble wrappers.

Reviews (1): Last reviewed commit: "Add Aurora v1.5 deterministic and ensemb..." | Re-trigger Greptile

Comment thread earth2studio/models/px/aurora1p5.py
Reset preds_idx to 0 at the start of _default_generator so each
create_iterator() call starts Aurora's rollout_step counter fresh.
Without this, a second forecast on the same instance carries the
previous cycle count forward, causing positive-variable clamping to
activate at cycle 1 instead of cycle 2 and producing wrong predictions.

Add test_aurora_v1p5_iter_repeated to guard against regressions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@swbg

swbg commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator Author

Re: Greptile P1 — Shared Rollout Step State

Fixed in commit 0830e27. preds_idx is now reset to 0 at the top of _default_generator, which is the single entry point for both AuroraV1p5.create_iterator and AuroraV1p5Ensemble.create_iterator. A regression test test_aurora_v1p5_iter_repeated has been added that calls create_iterator() twice on the same instance and asserts the outputs are identical across both calls.

@swbg

swbg commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator Author

Caveat: Aurora 1.5 is pretrained on ERA5/IFS including sea ice are fraction, which is not available from the IFS public data archive. Users can either run the model on ERA5 (for historical studies, not recommended because the model is finetuned on IFS) or bring their own IFS data.

Comment thread earth2studio/models/px/aurora_v1p5.py Outdated
Comment thread earth2studio/models/px/aurora_v1p5.py Outdated
Comment thread pyproject.toml Outdated
aurora = [
"microsoft-aurora>=1.6.0",
]
aurora-v1p5 = [

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.

instead of adding a new group, can we bump aurora groups requirement to 2.0? Is aurora 1 still runnable with v 2.0?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Seems possible based on the code and I have run a quick sanity check with the original Aurora:

All 6 steps (h+6 through h+36) pass. Summary:

Variable Range across all steps Status
t2m [214.8, 316.0] K OK — realistic global surface temps
u10m [−20.7, +25.4] m/s OK — moderate surface winds
z500 [46768, 58064] m²/s² OK — typical mid-latitude range

Zero NaN/Inf throughout.

image

Comment thread pyproject.toml
@@ -153,6 +153,9 @@ atlas = [
aurora = [

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.

need to run uv lock

Comment thread earth2studio/models/px/aurora_v1p5.py Outdated
Comment thread earth2studio/models/px/aurora_v1p5.py Outdated
self,
x: torch.Tensor,
coords: CoordSystem,
lead_time_hours: float = 1.0,

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.

float or should this be an int

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I have made this an int now and pytorch cast it to float ahead of passing it to the model.

@check_optional_dependencies()
def load_model(
cls,
package: Package,

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.

typically for ens models we need a seed parameter of sorts to make it reproducable, can you see if this is possible?

E.g. http://github.com/NVIDIA/earth2studio/blob/main/earth2studio/models/px/aifs2ens.py#L433

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.

Thanks for adding the sanity check scripts, this makes be very confident in the determistic model. Can you perhaps produce similar scripts for the ENS model since youre adding both there.

Good test for getting the random state controlled

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I made it a bit of a mix of aifs2ens and fcn3 now, calling torch.manual_seed and exposing a set_rng method (to have a similar interface to fcn3). The Aurora repo simply calls torch.rand at some point without a much better way to set a seed I think. There is some difference between running the Earth2Studio wrapper vs. the original repo (because of different GPU ops like cat etc. after calling torch.manual_seed) but runs with the same seed are reproducible within Earth2Studio.

grid (720, 1440) with 5 atmospheric variables across 13 pressure levels and
18 surface variables plus 7 output-only surface variables.

This wrapper uses an hourly rollout by default: the underlying 6-hour

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.

any reason for it to be 6 hour batched? Just curious, also seem kinda implmentation detail, not sure if needed for doc string

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The same batch is used to generate 6 consecutive hours (t-6, t+0) > (t+1, t+2, t+3, t+4, t+5, t+6). The rollout advances in groups of 6 so it doesn't have to prepare the same batch over and over again. I've set the default interval to 1 hour, in theory one could advance 2 or 3 hours each step. I have adjusted the docstrings though, seemed a bit too detailed in some parts.

@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.

Thanks for using the skill Stefan, left a few initial comments. Looking good

@swbg

swbg commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

Reference Comparison Validation

Models: Aurora1p5 / Aurora1p5Ensemble
Comparison date: 2026-07-17T00:00:00
Environment: GPU validation run

Initial condition: ERA5 at 2025-01-01T00:00 UTC (ARCO GCS zarr store, 0.25°).
Vanilla reference uses aurora.rollout(steps=2, fine_lead_times=[1..6]).
E2S reference uses Aurora1p5.create_iterator / Aurora1p5Ensemble.create_iterator for 12 hourly steps.
Both run under torch.inference_mode().


Aurora1p5 (Deterministic) Results

Metric h+01 h+06 h+07 h+12
Max absolute difference 0.0 0.0 0.0 0.0
Mean absolute difference 0.0 0.0 0.0 0.0
Correlation 1.00000000 1.00000000 1.00000000 1.00000000
Finite fraction 1.0 1.0 1.0 1.0

Full 12-step table (all steps: bit-perfect):

Hour     MaxAbsErr        RelErr        Corr    NaN(v)    NaN(e)
-----------------------------------------------------------------
  h+01      0.000000      0.000000  1.00000000         0         0
  h+02      0.000000      0.000000  1.00000000         0         0
  h+03      0.000000      0.000000  1.00000000         0         0
  h+04      0.000000      0.000000  1.00000000         0         0
  h+05      0.000000      0.000000  1.00000000         0         0
  h+06      0.000000      0.000000  1.00000000         0         0
  h+07      0.000000      0.000000  1.00000000         0         0
  h+08      0.000000      0.000000  1.00000000         0         0
  h+09      0.000000      0.000000  1.00000000         0         0
  h+10      0.000000      0.000000  1.00000000         0         0
  h+11      0.000000      0.000000  1.00000000         0         0
  h+12      0.000000      0.000000  1.00000000         0         0

Mean correlation across 12 steps: 1.00000000
Min  correlation across 12 steps: 1.00000000

Key findings:

  • Bit-perfect agreement (MaxAbsErr = 0.0, Corr = 1.0) across all 12 hourly steps, spanning 2 full 6-hour AR cycles.
  • No NaN/Inf values in any output step.
  • Visual sanity plots show physically plausible global fields with expected temperature gradients, jet stream structure, and sea-ice patterns. t2m range h+1…h+12: [216.8, 317.7] K.

Aurora1p5Ensemble (Stochastic, seed=42) Results

Exact bit-level agreement between a stochastic E2S wrapper and the vanilla rollout() path is not expected: both paths start from the same torch.manual_seed(42) + model.reset_noise(), but the different code paths between the seed call and the first torch.randn draw in the backbone consume the CUDA RNG state differently. Within E2S, two consecutive create_iterator(seed=42) calls are bit-perfect (see reproducibility check below).

Per-variable MaxAbsErr (seed=42, 1 member):

Hour   t2m MaxAbsErr   u10m MaxAbsErr   z500 MaxAbsErr   Corr (all)
--------------------------------------------------------------------
  h+01          9.7346           8.0355           155.54   0.99648160
  h+02         13.3249           9.6025           126.07   0.99629134
  h+03         13.5736           8.0260           132.62   0.99629235
  h+04          9.5585           9.0987           170.28   0.99634087
  h+05         10.5998          13.3731           199.75   0.99645942
  h+06         12.2784          14.0232           185.01   0.99631739
  h+07         13.3042          14.5108           207.94   0.99433982
  h+08         11.2733          17.8615           181.33   0.99477166
  h+09         10.6723          16.9188           220.83   0.99536920
  h+10         10.1789          14.8981           239.86   0.99550050
  h+11         10.6516          12.5198           171.10   0.99550188
  h+12         15.0844          14.7952           236.59   0.99538159

Mean correlation across 12 steps: 0.99575397
Min  correlation across 12 steps: 0.99433982

Reproducibility check — two create_iterator(seed=42) calls on the same instance:

  h+ 1: MATCH    h+ 2: MATCH    h+ 3: MATCH    h+ 4: MATCH
  h+ 5: MATCH    h+ 6: MATCH    h+ 7: MATCH    h+ 8: MATCH
  h+ 9: MATCH    h+10: MATCH    h+11: MATCH    h+12: MATCH
✓ Bit-perfect agreement across all steps with same seed.

Key findings:

  • Correlation ≥ 0.994 across all 12 steps, confirming the wrapper produces physically equivalent ensemble trajectories to the vanilla path.
  • Bit-perfect reproducibility within E2S: create_iterator called twice with the same seed produces identical outputs.
  • The ~0.4% correlation gap vs. vanilla is expected for stochastic models: CUDA non-determinism in the noise MLP path causes sub-ULP differences that compound, but does not indicate a wrapper defect.
  • No NaN/Inf values in any output step. t2m range h+1…h+12: [213.9, 318.4] K.

Reference Plots

TODO: Upload reference_aurora_v1p5_sanity.png (sanity check: t2m, msl, z500 at h+6, h+12) here.

image

TODO: Upload reference_aurora_v1p5_compare.png (E2S vs Vanilla t2m at h+1, h+6, h+12 — rows: E2S / Vanilla / |diff|) here.

image

TODO: Upload reference_aurora_v1p5_ensemble_sanity.png (ensemble sanity check: t2m, msl, z500 at h+6, h+12) here.

image

TODO: Upload reference_aurora_v1p5_ens_compare.png (Ensemble E2S vs Vanilla t2m at h+1, h+6, h+12 — rows: E2S / Vanilla / |diff|) here.

image

Validation Scripts

Aurora1p5 — vanilla reference script
"""Vanilla Aurora v1.5 inference — NO Earth2Studio imports.

Uses the official aurora.rollout() function and fetches initial conditions
directly from the ARCO ERA5 zarr store on GCS (no Earth2Studio data utilities).
Runs 12 hourly steps (2 full AR cycles of 6 hours each).

Saves output tensors to reference_aurora_v1p5_vanilla.pt.

Run with:
    uv run python reference_aurora_v1p5_vanilla.py
"""

import os
import pickle
from datetime import datetime, timezone

import gcsfs
import numpy as np
import torch
import zarr
from aurora import AuroraV1p5 as AuroraModel
from aurora import Batch, Metadata
from aurora.insolation import insolation as aurora_insolation
from aurora.normalisation import log_untransform as aurora_log_untransform
from aurora.rollout import rollout

DEVICE = "cuda:0"

T0 = np.datetime64("2025-01-01T00:00")
T_MINUS_6 = T0 - np.timedelta64(6, "h")

ATMOS_LEVELS = [1000, 925, 850, 700, 600, 500, 400, 300, 250, 200, 150, 100, 50]

ARCO_PATH = "/gcp-public-data-arco-era5/ar/full_37-1h-0p25deg-chunk-1.zarr-v3"
ARCO_EPOCH = np.datetime64("1900-01-01T00:00")

CACHE_DIR = os.path.expanduser("~/.cache/earth2studio/aurora1p5")
CKPT_PATH = os.path.join(CACHE_DIR, "aurora-0.25-v1.5.ckpt")
STATIC_PATH = os.path.join(CACHE_DIR, "aurora-0.25-v1.5-static.pickle")

_AURORA_TO_ARCO_SURF = {
    "msl": "mean_sea_level_pressure",
    "10u": "10m_u_component_of_wind",
    "10v": "10m_v_component_of_wind",
    "2t": "2m_temperature",
    "2d": "2m_dewpoint_temperature",
    "tcwv": "total_column_water_vapour",
    "tcc": "total_cloud_cover",
    "100u": "100m_u_component_of_wind",
    "100v": "100m_v_component_of_wind",
    "sp": "surface_pressure",
    "lcc": "low_cloud_cover",
    "mcc": "medium_cloud_cover",
    "hcc": "high_cloud_cover",
    "skt": "skin_temperature",
    "stl1": "soil_temperature_level_1",
    "swvl1": "volumetric_soil_water_layer_1",
    "ci": "sea_ice_cover",
    "scaled_sd": "snow_depth",
}

_AURORA_TO_ARCO_ATMOS = {
    "z": "geopotential",
    "q": "specific_humidity",
    "t": "temperature",
    "u": "u_component_of_wind",
    "v": "v_component_of_wind",
}

_OUTPUT_ONLY_SURF_VARS = [
    ("i10fg", "i10fg", False),
    ("blh", "blh", False),
    ("uvb1h", "uvb_1h", False),
    ("ssrd1h", "ssrd_1h", False),
    ("ttr1h", "ttr_1h", False),
    ("tp1h", "scaled_tp_1h", True),
    ("sf1h", "scaled_sf_1h", True),
]


def ts_to_datetime(ts: np.datetime64) -> datetime:
    epoch = np.datetime64("1970-01-01T00:00:00")
    seconds = float((ts.astype("datetime64[s]") - epoch) / np.timedelta64(1, "s"))
    return datetime.fromtimestamp(seconds, tz=timezone.utc)


def arco_time_index(ts: np.datetime64) -> int:
    return int((ts - ARCO_EPOCH) / np.timedelta64(1, "h"))


print("Loading Aurora v1.5 weights from local cache...")
model = AuroraModel()
model.load_checkpoint_local(CKPT_PATH)
model.eval().to(DEVICE)

print("Loading static vars from pickle...")
with open(STATIC_PATH, "rb") as f:
    static_raw = pickle.load(f)  # noqa: S301
static_vars = {k: torch.from_numpy(np.asarray(v))[:720, :] for k, v in static_raw.items()}

LAT = np.linspace(90, -90, 720, endpoint=False)
LON = np.linspace(0, 359.75, 1440)

print("Opening ARCO zarr store...")
fs = gcsfs.GCSFileSystem(token="anon", asynchronous=False)
zstore = zarr.storage.FsspecStore(fs, path=ARCO_PATH)
zg = zarr.open(store=zstore, mode="r")

all_levels = zg["level"][:]
level_indices = [int(np.searchsorted(all_levels, lv)) for lv in ATMOS_LEVELS]
ti_minus6 = arco_time_index(T_MINUS_6)
ti_0 = arco_time_index(T0)

atmos_data = {}
for aurora_name, arco_name in _AURORA_TO_ARCO_ATMOS.items():
    arr = zg[arco_name]
    chunk = np.stack(
        [arr[ti_minus6, level_indices, :720, :], arr[ti_0, level_indices, :720, :]],
        axis=0,
    ).astype(np.float32)
    atmos_data[aurora_name] = torch.from_numpy(chunk).unsqueeze(0)

surf_data = {}
for aurora_name, arco_name in _AURORA_TO_ARCO_SURF.items():
    arr = zg[arco_name]
    chunk = np.stack(
        [arr[ti_minus6, :720, :], arr[ti_0, :720, :]],
        axis=0,
    ).astype(np.float32)
    surf_data[aurora_name] = torch.from_numpy(chunk).unsqueeze(0)

for k in surf_data:
    surf_data[k] = torch.nan_to_num(surf_data[k], nan=0.0)

dt0 = ts_to_datetime(T_MINUS_6)
dt1 = ts_to_datetime(T0)
insol = aurora_insolation((dt0, dt1), LAT, LON, enforce_2d=True)
surf_data["insolation"] = torch.from_numpy(np.asarray(insol)).float().unsqueeze(0)

batch = Batch(
    surf_vars=surf_data,
    static_vars=static_vars,
    atmos_vars=atmos_data,
    metadata=Metadata(
        lat=torch.from_numpy(LAT).float(),
        lon=torch.from_numpy(LON).float(),
        time=(dt1,),
        atmos_levels=tuple(int(lv) for lv in ATMOS_LEVELS),
        rollout_step=0,
    ),
)

print("Running official aurora.rollout() — 2 AR cycles, fine_lead_times=[1..6]...")
sub_outputs = {}

with torch.inference_mode():
    for h, pred in enumerate(
        rollout(model, batch, steps=2, fine_lead_times=[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]),
        start=1,
    ):
        atmos_out = torch.cat(
            [pred.atmos_vars["z"], pred.atmos_vars["q"], pred.atmos_vars["t"],
             pred.atmos_vars["u"], pred.atmos_vars["v"]],
            dim=2,
        )
        surf_keys_ordered = list(_AURORA_TO_ARCO_SURF.keys())
        surf_out = torch.cat(
            [pred.surf_vars[k].unsqueeze(2) for k in surf_keys_ordered],
            dim=2,
        )
        diag_tensors = []
        for _, aurora_name, log_scaled in _OUTPUT_ONLY_SURF_VARS:
            v = pred.surf_vars[aurora_name]
            if log_scaled:
                v = aurora_log_untransform(v)
            diag_tensors.append(v.unsqueeze(2))
        diag_out = torch.cat(diag_tensors, dim=2)
        out_tensor = torch.cat([atmos_out, surf_out, diag_out], dim=2).cpu()
        sub_outputs[h] = out_tensor

torch.save(sub_outputs, "reference_aurora_v1p5_vanilla.pt")
print("Saved reference_aurora_v1p5_vanilla.pt")
Aurora1p5 — Earth2Studio reference script
"""Earth2Studio Aurora1p5 wrapper inference.

Fetches the same ERA5 initial conditions from ARCO as the vanilla script,
runs 12 hourly steps (2 full AR cycles) using the E2S Aurora1p5 wrapper via
create_iterator, and saves the output tensors to reference_aurora_v1p5_e2s.pt.

Run with:
    uv run python reference_aurora_v1p5_e2s.py
"""

import numpy as np
import torch

from earth2studio.data import ARCO
from earth2studio.data.utils import fetch_data
from earth2studio.models.px.aurora1p5 import VARIABLES, Aurora1p5
from earth2studio.utils.coords import map_coords

DEVICE = "cuda:0"
TIME = np.array([np.datetime64("2025-01-01T00:00")])
N_STEPS = 12

print("Loading Aurora1p5 via E2S...")
package = Aurora1p5.load_default_package()
model = Aurora1p5.load_model(package).to(DEVICE)
model.eval()

print("Fetching ARCO ERA5 data...")
data = ARCO()
lead_time = model.input_coords()["lead_time"]
variable = np.array(VARIABLES)
x, coords = fetch_data(data, TIME, variable, lead_time, device=DEVICE)
x, coords = map_coords(x, coords, model.input_coords())

print(f"Running {N_STEPS} E2S steps via create_iterator...")
sub_outputs = {}

with torch.inference_mode():
    p_iter = model.create_iterator(x, coords)
    next(p_iter)  # skip IC yield (h+0 with NaN output-only channels)
    for h, (out, _) in enumerate(p_iter, start=1):
        result = out.cpu().view(1, 1, 90, 720, 1440)
        sub_outputs[h] = result
        if h >= N_STEPS:
            break

torch.save(sub_outputs, "reference_aurora_v1p5_e2s.pt")
print("Saved reference_aurora_v1p5_e2s.pt")
Aurora1p5 — comparison script
"""Compare vanilla Aurora v1.5 outputs against Earth2Studio wrapper outputs.

Loads reference_aurora_v1p5_vanilla.pt and reference_aurora_v1p5_e2s.pt and
reports max absolute difference, relative error, and correlation for each of
the 12 hourly steps. Also generates a comparison plot (t2m at h+1, h+6, h+12;
rows: E2S / Vanilla / |diff|).

Relative error denominator: max(abs(vanilla), 1e-6).

Run with:
    uv run python reference_aurora_v1p5_compare.py
"""

import numpy as np
import torch

from earth2studio.models.px.aurora1p5 import OUTPUT_VARIABLES as VARIABLES

vanilla = torch.load("reference_aurora_v1p5_vanilla.pt", map_location="cpu", weights_only=False)
e2s = torch.load("reference_aurora_v1p5_e2s.pt", map_location="cpu", weights_only=False)

N_STEPS = len(vanilla)
T2M_IDX = list(VARIABLES).index("t2m")

print(f"{'Hour':>4}  {'MaxAbsErr':>12}  {'RelErr':>12}  {'Corr':>10}  {'NaN(v)':>8}  {'NaN(e)':>8}")
print("-" * 65)

corrs = []
for h in range(1, N_STEPS + 1):
    v = vanilla[h].float()
    e = e2s[h].float()

    diff = (v - e).abs()
    max_err = diff.max().item()
    denom = v.abs().clamp(min=1e-6)
    rel_err = (diff / denom).max().item()

    corr = torch.corrcoef(torch.stack([v.flatten(), e.flatten()]))[0, 1].item()
    corrs.append(corr)

    nan_v = v.isnan().sum().item()
    nan_e = e.isnan().sum().item()

    print(f"  h+{h:02d}  {max_err:>12.6f}  {rel_err:>12.6f}  {corr:>10.8f}  {nan_v:>8}  {nan_e:>8}")

print()
print(f"Mean correlation across {N_STEPS} steps: {np.mean(corrs):.8f}")
print(f"Min  correlation across {N_STEPS} steps: {np.min(corrs):.8f}")

try:
    import matplotlib.pyplot as plt

    steps = [1, 6, N_STEPS]
    fig, axes = plt.subplots(3, len(steps), figsize=(15, 9))
    fig.suptitle("Aurora1p5: E2S vs Vanilla t2m (K)", fontsize=13)

    vmin, vmax = 220, 320
    for col, h in enumerate(steps):
        v_t2m = vanilla[h][0, 0, T2M_IDX].numpy()
        e_t2m = e2s[h][0, 0, T2M_IDX].numpy()
        err = np.abs(v_t2m - e_t2m)

        axes[0, col].imshow(e_t2m, vmin=vmin, vmax=vmax, origin="upper", aspect="auto")
        axes[0, col].set_title(f"E2S  h+{h}")
        axes[0, col].axis("off")

        axes[1, col].imshow(v_t2m, vmin=vmin, vmax=vmax, origin="upper", aspect="auto")
        axes[1, col].set_title(f"Vanilla  h+{h}")
        axes[1, col].axis("off")

        im = axes[2, col].imshow(err, origin="upper", aspect="auto", cmap="Reds")
        axes[2, col].set_title(f"|E2S - Vanilla|  h+{h}")
        axes[2, col].axis("off")
        plt.colorbar(im, ax=axes[2, col], fraction=0.03)

    plt.tight_layout()
    plt.savefig("reference_aurora_v1p5_compare.png", dpi=120, bbox_inches="tight")
    plt.close()
except ImportError:
    print("matplotlib not available — skipping plot")
Aurora1p5 — sanity check script
"""Sanity-check plots for Aurora1p5 using ARCO ERA5 initial conditions.

Runs 12 hourly forecast steps (2 AR cycles) and plots t2m, msl, and z500
at h+6 and h+12. Checks for NaN/Inf in all outputs.

Run with:
    uv run python reference_aurora_v1p5_sanity.py
"""

import numpy as np
import torch

from earth2studio.data import ARCO
from earth2studio.data.utils import fetch_data
from earth2studio.models.px.aurora1p5 import VARIABLES, Aurora1p5
from earth2studio.utils.coords import map_coords

DEVICE = "cuda:0"
TIME = np.array([np.datetime64("2025-01-01T00:00")])
N_STEPS = 12

PLOT_VARS = {
    "t2m":  {"idx": list(VARIABLES).index("t2m"),  "vmin": 220,   "vmax": 320,   "label": "t2m (K)"},
    "msl":  {"idx": list(VARIABLES).index("msl"),  "vmin": 97000, "vmax": 103000, "label": "msl (Pa)"},
    "z500": {"idx": list(VARIABLES).index("z500"), "vmin": 49000, "vmax": 59000,  "label": "z500 (m²/s²)"},
}

package = Aurora1p5.load_default_package()
model = Aurora1p5.load_model(package).to(DEVICE)
model.eval()

data = ARCO()
lead_time = model.input_coords()["lead_time"]
variable = np.array(VARIABLES)
x, coords = fetch_data(data, TIME, variable, lead_time, device=DEVICE)
x, coords = map_coords(x, coords, model.input_coords())

outputs = {}
iterator = model.create_iterator(x, coords)
next(iterator)

with torch.inference_mode():
    for out, out_coords in iterator:
        h = int(out_coords["lead_time"][0] / np.timedelta64(1, "h"))
        outputs[h] = out[0, 0].cpu()
        if h >= N_STEPS:
            break

try:
    import matplotlib.pyplot as plt

    plot_steps = [6, 12]
    fig, axes = plt.subplots(len(PLOT_VARS), len(plot_steps), figsize=(12, len(PLOT_VARS) * 3))
    fig.suptitle("Aurora1p5 sanity check — IC: 2025-01-01T00Z", fontsize=12)

    for row, (var_name, meta) in enumerate(PLOT_VARS.items()):
        for col, h in enumerate(plot_steps):
            field = outputs[h][meta["idx"]].numpy()
            im = axes[row, col].imshow(
                field, vmin=meta["vmin"], vmax=meta["vmax"], origin="upper", aspect="auto"
            )
            axes[row, col].set_title(f"{meta['label']}  h+{h}")
            axes[row, col].axis("off")
            plt.colorbar(im, ax=axes[row, col], fraction=0.03)

    plt.tight_layout()
    plt.savefig("reference_aurora_v1p5_sanity.png", dpi=120, bbox_inches="tight")
    plt.close()
except ImportError:
    print("matplotlib not available — skipping plot")

total_nan = sum(t.isnan().sum().item() for t in outputs.values())
total_inf = sum(t.isinf().sum().item() for t in outputs.values())
print(f"Total NaN: {total_nan}  Total Inf: {total_inf}")
t2m_idx = list(VARIABLES).index("t2m")
t2m_range = [outputs[h][t2m_idx] for h in sorted(outputs)]
print(f"t2m range h+1…h+{N_STEPS}: [{min(t.min() for t in t2m_range):.1f}, {max(t.max() for t in t2m_range):.1f}] K")
Aurora1p5Ensemble — vanilla reference script
"""Vanilla Aurora v1.5 ensemble inference — NO Earth2Studio imports.

Uses aurora.rollout() with AuroraV1p5Ensemble and fetches initial conditions
directly from the ARCO ERA5 zarr store on GCS. Runs 12 hourly steps (2 AR
cycles). Seeds with torch.manual_seed + reset_noise to match the E2S
wrapper's set_rng behaviour.

Saves output tensors to reference_aurora_v1p5_ens_vanilla.pt.

Run with:
    uv run python reference_aurora_v1p5_ens_vanilla.py
"""

import os
import pickle
from datetime import datetime, timezone

import gcsfs
import numpy as np
import torch
import zarr
from aurora import AuroraV1p5Ensemble as AuroraEnsembleModel
from aurora import Batch, Metadata
from aurora.insolation import insolation as aurora_insolation
from aurora.normalisation import log_untransform as aurora_log_untransform
from aurora.rollout import rollout

DEVICE = "cuda:0"
SEED = 42

T0 = np.datetime64("2025-01-01T00:00")
T_MINUS_6 = T0 - np.timedelta64(6, "h")

ATMOS_LEVELS = [1000, 925, 850, 700, 600, 500, 400, 300, 250, 200, 150, 100, 50]
ARCO_PATH = "/gcp-public-data-arco-era5/ar/full_37-1h-0p25deg-chunk-1.zarr-v3"
ARCO_EPOCH = np.datetime64("1900-01-01T00:00")

CACHE_DIR = os.path.expanduser("~/.cache/earth2studio/aurora1p5")
CKPT_PATH = os.path.join(CACHE_DIR, "aurora-0.25-v1.5-ensemble.ckpt")
STATIC_PATH = os.path.join(CACHE_DIR, "aurora-0.25-v1.5-static.pickle")

_AURORA_TO_ARCO_SURF = {
    "msl": "mean_sea_level_pressure", "10u": "10m_u_component_of_wind",
    "10v": "10m_v_component_of_wind", "2t": "2m_temperature",
    "2d": "2m_dewpoint_temperature", "tcwv": "total_column_water_vapour",
    "tcc": "total_cloud_cover", "100u": "100m_u_component_of_wind",
    "100v": "100m_v_component_of_wind", "sp": "surface_pressure",
    "lcc": "low_cloud_cover", "mcc": "medium_cloud_cover",
    "hcc": "high_cloud_cover", "skt": "skin_temperature",
    "stl1": "soil_temperature_level_1", "swvl1": "volumetric_soil_water_layer_1",
    "ci": "sea_ice_cover", "scaled_sd": "snow_depth",
}
_AURORA_TO_ARCO_ATMOS = {
    "z": "geopotential", "q": "specific_humidity", "t": "temperature",
    "u": "u_component_of_wind", "v": "v_component_of_wind",
}
_OUTPUT_ONLY_SURF_VARS = [
    ("i10fg", "i10fg", False), ("blh", "blh", False),
    ("uvb1h", "uvb_1h", False), ("ssrd1h", "ssrd_1h", False),
    ("ttr1h", "ttr_1h", False), ("tp1h", "scaled_tp_1h", True),
    ("sf1h", "scaled_sf_1h", True),
]


def ts_to_datetime(ts):
    epoch = np.datetime64("1970-01-01T00:00:00")
    seconds = float((ts.astype("datetime64[s]") - epoch) / np.timedelta64(1, "s"))
    return datetime.fromtimestamp(seconds, tz=timezone.utc)


def arco_time_index(ts):
    return int((ts - ARCO_EPOCH) / np.timedelta64(1, "h"))


model = AuroraEnsembleModel()
model.load_checkpoint_local(CKPT_PATH)
model.eval().to(DEVICE)

with open(STATIC_PATH, "rb") as f:
    static_raw = pickle.load(f)  # noqa: S301
static_vars = {k: torch.from_numpy(np.asarray(v))[:720, :] for k, v in static_raw.items()}

LAT = np.linspace(90, -90, 720, endpoint=False)
LON = np.linspace(0, 359.75, 1440)

fs = gcsfs.GCSFileSystem(token="anon", asynchronous=False)
zstore = zarr.storage.FsspecStore(fs, path=ARCO_PATH)
zg = zarr.open(store=zstore, mode="r")

all_levels = zg["level"][:]
level_indices = [int(np.searchsorted(all_levels, lv)) for lv in ATMOS_LEVELS]
ti_minus6 = arco_time_index(T_MINUS_6)
ti_0 = arco_time_index(T0)

atmos_data = {}
for aurora_name, arco_name in _AURORA_TO_ARCO_ATMOS.items():
    arr = zg[arco_name]
    chunk = np.stack(
        [arr[ti_minus6, level_indices, :720, :], arr[ti_0, level_indices, :720, :]],
        axis=0,
    ).astype(np.float32)
    atmos_data[aurora_name] = torch.from_numpy(chunk).unsqueeze(0)

surf_data = {}
for aurora_name, arco_name in _AURORA_TO_ARCO_SURF.items():
    arr = zg[arco_name]
    chunk = np.stack(
        [arr[ti_minus6, :720, :], arr[ti_0, :720, :]], axis=0,
    ).astype(np.float32)
    surf_data[aurora_name] = torch.from_numpy(chunk).unsqueeze(0)
for k in surf_data:
    surf_data[k] = torch.nan_to_num(surf_data[k], nan=0.0)

dt0 = ts_to_datetime(T_MINUS_6)
dt1 = ts_to_datetime(T0)
insol = aurora_insolation((dt0, dt1), LAT, LON, enforce_2d=True)
surf_data["insolation"] = torch.from_numpy(np.asarray(insol)).float().unsqueeze(0)

batch = Batch(
    surf_vars=surf_data, static_vars=static_vars, atmos_vars=atmos_data,
    metadata=Metadata(
        lat=torch.from_numpy(LAT).float(), lon=torch.from_numpy(LON).float(),
        time=(dt1,), atmos_levels=tuple(int(lv) for lv in ATMOS_LEVELS), rollout_step=0,
    ),
)

torch.manual_seed(SEED)
model.reset_noise()

sub_outputs = {}
with torch.inference_mode():
    for h, pred in enumerate(
        rollout(model, batch, steps=2, fine_lead_times=[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]),
        start=1,
    ):
        atmos_out = torch.cat(
            [pred.atmos_vars["z"], pred.atmos_vars["q"], pred.atmos_vars["t"],
             pred.atmos_vars["u"], pred.atmos_vars["v"]], dim=2,
        )
        surf_out = torch.cat(
            [pred.surf_vars[k].unsqueeze(2) for k in _AURORA_TO_ARCO_SURF], dim=2,
        )
        diag_tensors = []
        for _, aurora_name, log_scaled in _OUTPUT_ONLY_SURF_VARS:
            v = pred.surf_vars[aurora_name]
            if log_scaled:
                v = aurora_log_untransform(v)
            diag_tensors.append(v.unsqueeze(2))
        diag_out = torch.cat(diag_tensors, dim=2)
        sub_outputs[h] = torch.cat([atmos_out, surf_out, diag_out], dim=2).cpu()

torch.save(sub_outputs, "reference_aurora_v1p5_ens_vanilla.pt")
print("Saved reference_aurora_v1p5_ens_vanilla.pt")
Aurora1p5Ensemble — Earth2Studio reference script
"""Earth2Studio Aurora1p5Ensemble wrapper inference.

Fetches the same ERA5 initial conditions from ARCO as the vanilla ensemble
script, runs 12 hourly steps (2 full AR cycles) using the E2S
Aurora1p5Ensemble wrapper via create_iterator with seed=42.

Saves output tensors to reference_aurora_v1p5_ens_e2s.pt.

Run with:
    uv run python reference_aurora_v1p5_ens_e2s.py
"""

import numpy as np
import torch

from earth2studio.data import ARCO
from earth2studio.data.utils import fetch_data
from earth2studio.models.px.aurora1p5 import VARIABLES, Aurora1p5Ensemble
from earth2studio.utils.coords import map_coords

DEVICE = "cuda:0"
TIME = np.array([np.datetime64("2025-01-01T00:00")])
N_STEPS = 12
SEED = 42

package = Aurora1p5Ensemble.load_default_package()
model = Aurora1p5Ensemble.load_model(package).to(DEVICE)
model.seed = SEED
model.eval()

data = ARCO()
lead_time = model.input_coords()["lead_time"]
variable = np.array(VARIABLES)
x, coords = fetch_data(data, TIME, variable, lead_time, device=DEVICE)
x, coords = map_coords(x, coords, model.input_coords())

sub_outputs = {}
with torch.inference_mode():
    p_iter = model.create_iterator(x, coords)
    next(p_iter)
    for h, (out, _) in enumerate(p_iter, start=1):
        sub_outputs[h] = out.cpu().view(1, 1, 90, 720, 1440)
        if h >= N_STEPS:
            break

torch.save(sub_outputs, "reference_aurora_v1p5_ens_e2s.pt")
print("Saved reference_aurora_v1p5_ens_e2s.pt")
Aurora1p5Ensemble — comparison script
"""Compare vanilla Aurora v1.5 ensemble outputs against Earth2Studio wrapper.

Loads reference_aurora_v1p5_ens_vanilla.pt and reference_aurora_v1p5_ens_e2s.pt
and reports per-variable MaxAbsErr (t2m, u10m, z500) and overall correlation.
Generates a comparison plot (t2m at h+1, h+6, h+12; rows: E2S / Vanilla / |diff|).

Relative error denominator: max(abs(vanilla), 1e-6).
Both runs must use SEED=42.

Run with:
    uv run python reference_aurora_v1p5_ens_compare.py
"""

import numpy as np
import torch

from earth2studio.models.px.aurora1p5 import OUTPUT_VARIABLES

vanilla = torch.load("reference_aurora_v1p5_ens_vanilla.pt", map_location="cpu", weights_only=False)
e2s = torch.load("reference_aurora_v1p5_ens_e2s.pt", map_location="cpu", weights_only=False)

N_STEPS = len(vanilla)
VARS = list(OUTPUT_VARIABLES)
T2M_IDX  = VARS.index("t2m")
U10M_IDX = VARS.index("u10m")
Z500_IDX = VARS.index("z500")

header = f"{'Hour':>4}  {'t2m MaxAbsErr':>14}  {'u10m MaxAbsErr':>15}  {'z500 MaxAbsErr':>15}  {'Corr (all)':>11}"
print(header)
print("-" * len(header))

corrs = []
for h in range(1, N_STEPS + 1):
    v = vanilla[h].float()
    e = e2s[h].float()
    diff = (v - e).abs()
    t2m_err  = diff[0, 0, T2M_IDX].max().item()
    u10m_err = diff[0, 0, U10M_IDX].max().item()
    z500_err = diff[0, 0, Z500_IDX].max().item()
    corr = torch.corrcoef(torch.stack([v.flatten(), e.flatten()]))[0, 1].item()
    corrs.append(corr)
    print(f"  h+{h:02d}  {t2m_err:>14.4f}  {u10m_err:>15.4f}  {z500_err:>15.2f}  {corr:>11.8f}")

print(f"\nMean correlation: {np.mean(corrs):.8f}  Min: {np.min(corrs):.8f}")

try:
    import matplotlib.pyplot as plt

    steps = [1, 6, N_STEPS]
    fig, axes = plt.subplots(3, len(steps), figsize=(15, 9))
    fig.suptitle("Aurora1p5Ensemble: E2S vs Vanilla t2m (K)  [seed=42]", fontsize=13)

    vmin, vmax = 220, 320
    for col, h in enumerate(steps):
        v_t2m = vanilla[h][0, 0, T2M_IDX].numpy()
        e_t2m = e2s[h][0, 0, T2M_IDX].numpy()
        err = np.abs(v_t2m - e_t2m)
        axes[0, col].imshow(e_t2m, vmin=vmin, vmax=vmax, origin="upper", aspect="auto")
        axes[0, col].set_title(f"E2S  h+{h}"); axes[0, col].axis("off")
        axes[1, col].imshow(v_t2m, vmin=vmin, vmax=vmax, origin="upper", aspect="auto")
        axes[1, col].set_title(f"Vanilla  h+{h}"); axes[1, col].axis("off")
        im = axes[2, col].imshow(err, origin="upper", aspect="auto", cmap="Reds")
        axes[2, col].set_title(f"|E2S - Vanilla|  h+{h}"); axes[2, col].axis("off")
        plt.colorbar(im, ax=axes[2, col], fraction=0.03)

    plt.tight_layout()
    plt.savefig("reference_aurora_v1p5_ens_compare.png", dpi=120, bbox_inches="tight")
    plt.close()
except ImportError:
    print("matplotlib not available — skipping plot")
Aurora1p5Ensemble — sanity check and reproducibility script
"""Sanity-check and reproducibility test for Aurora1p5Ensemble.

Runs 12 hourly forecast steps with seed=42, then runs again with the same
seed and asserts bit-perfect agreement. Plots t2m, msl, z500 at h+6 and h+12.

Run with:
    PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True uv run python reference_aurora_v1p5_ensemble_sanity.py
"""

import numpy as np
import torch

from earth2studio.data import ARCO
from earth2studio.data.utils import fetch_data
from earth2studio.models.px.aurora1p5 import VARIABLES, Aurora1p5Ensemble
from earth2studio.utils.coords import map_coords

DEVICE = "cuda:0"
TIME = np.array([np.datetime64("2025-01-01T00:00")])
N_STEPS = 12
N_MEMBERS = 1
SEED = 42

PLOT_VARS = {
    "t2m":  {"idx": list(VARIABLES).index("t2m"),  "vmin": 220,   "vmax": 320,   "label": "t2m (K)"},
    "msl":  {"idx": list(VARIABLES).index("msl"),  "vmin": 97000, "vmax": 103000, "label": "msl (Pa)"},
    "z500": {"idx": list(VARIABLES).index("z500"), "vmin": 49000, "vmax": 59000,  "label": "z500 (m²/s²)"},
}

package = Aurora1p5Ensemble.load_default_package()
model = Aurora1p5Ensemble.load_model(package).to(DEVICE)
model.eval()

data = ARCO()
lead_time = model.input_coords()["lead_time"]
variable = np.array(VARIABLES)
x, coords = fetch_data(data, TIME, variable, lead_time, device=DEVICE)
x, coords = map_coords(x, coords, model.input_coords())

x_ens = x.unsqueeze(0).repeat(N_MEMBERS, 1, 1, 1, 1, 1)
coords_ens = coords.copy()
coords_ens["ensemble"] = np.arange(N_MEMBERS)
coords_ens.move_to_end("ensemble", last=False)


def run_iterator(seed):
    model.seed = seed
    outputs = {}
    iterator = model.create_iterator(x_ens, coords_ens)
    next(iterator)
    with torch.inference_mode():
        for out, out_coords in iterator:
            h = int(out_coords["lead_time"][0] / np.timedelta64(1, "h"))
            outputs[h] = out[:, 0, 0].cpu()
            if h >= N_STEPS:
                break
    return outputs


print(f"Run 1 (seed={SEED})...")
outputs = run_iterator(SEED)
t2m_idx = list(VARIABLES).index("t2m")
for h in sorted(outputs):
    t2m = outputs[h][:, t2m_idx]
    print(f"  h+{h:2d}: t2m [{t2m.min():.1f}, {t2m.max():.1f}] K"
          f"  NaN={outputs[h].isnan().sum().item()}  Inf={outputs[h].isinf().sum().item()}")

print(f"\nRun 2 (seed={SEED}) — reproducibility check...")
outputs2 = run_iterator(SEED)
all_match = all(torch.equal(outputs[h], outputs2[h]) for h in sorted(outputs))
for h in sorted(outputs):
    print(f"  h+{h:2d}: {'MATCH' if torch.equal(outputs[h], outputs2[h]) else 'MISMATCH'}")
print("✓ Bit-perfect agreement." if all_match else "✗ Reproducibility FAILED.")

try:
    import matplotlib.pyplot as plt

    plot_steps = [6, 12]
    fig, axes = plt.subplots(len(PLOT_VARS), len(plot_steps), figsize=(10, len(PLOT_VARS) * 3))
    fig.suptitle(f"Aurora1p5Ensemble sanity — IC: 2025-01-01T00Z  (seed={SEED})", fontsize=11)

    for row, (var_name, meta) in enumerate(PLOT_VARS.items()):
        for col, h in enumerate(plot_steps):
            field = outputs[h][0, meta["idx"]].numpy()
            im = axes[row, col].imshow(
                field, vmin=meta["vmin"], vmax=meta["vmax"], origin="upper", aspect="auto"
            )
            axes[row, col].set_title(f"{meta['label']}  h+{h}")
            axes[row, col].axis("off")
            plt.colorbar(im, ax=axes[row, col], fraction=0.03)

    plt.tight_layout()
    plt.savefig("reference_aurora_v1p5_ensemble_sanity.png", dpi=100, bbox_inches="tight")
    plt.close()
except ImportError:
    print("matplotlib not available — skipping plot")

total_nan = sum(t.isnan().sum().item() for t in outputs.values())
total_inf = sum(t.isinf().sum().item() for t in outputs.values())
print(f"\nTotal NaN: {total_nan}  Total Inf: {total_inf}")

swbg and others added 2 commits July 17, 2026 12:27
- Rename AuroraV1p5/AuroraV1p5Ensemble → Aurora1p5/Aurora1p5Ensemble
  (matching FCN1/FCN3 naming convention)
- Rename aurora-v1p5 extra → aurora; bump microsoft-aurora requirement
  to >=2.0.0 and drop the separate aurora extra (backwards-compatible)
- Switch HuggingFace checkpoint source from ikwessel/aurora-1.5 to
  official microsoft/aurora repo at commit c171214
- Add Aurora1p5Ensemble.set_rng(seed) for reproducible noise seeding
- Add product badges: precip, land, ocean, solar
- Fix year badge to 2026
- Change lead_time_hours to list[int] in _forward_sub_steps
- Consolidate _forward/_forward_sub_steps into a single method
- Trim docstrings; fix stale wording
- Run uv lock

Co-Authored-By: Claude Sonnet 4.6 (1M context) <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.

2 participants