Add GFS init source as alternative to ERA5T (Phase 2 wiring)
Browse filesMirrors the same plumbing shipped in Weather AI 2. CRE's GraphCast
currently inits from ARCO ERA5T at today-5, which means its "5-day
forecast" actually covers today-5 through today-1 — the past week,
not the next week. Trigger decisions confirm past heat events instead
of warning before, which misaligns with the alert-SMS product intent.
GFS has ~3h lag instead of 5 days, enabling forward-looking forecasts
over today → today+5.
Behind a config toggle:
* NWP_INIT_SOURCE=gfs — GFS instead of ERA5T
* NWP_TARGET_DATE_OVERRIDE=YYYY-MM-DD — pin init date for
validation runs against the LMB insurance benchmark panel
Defaults leave existing ERA5T behavior unchanged. Only
src/init_sources/ + tests/test_init_sources/ + two one-line call-site
swaps in graphcast_inference/pipeline touch production code.
56 new unit tests, all pass. Opt-in live GFS test skipped unless
GFS_LIVE_TEST=1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Dockerfile +1 -0
- pytest.ini +4 -0
- requirements.txt +4 -0
- src/init_sources/__init__.py +31 -0
- src/init_sources/gfs.py +444 -0
- src/init_sources/static_vars.py +142 -0
- src/init_sources/variable_mapping.py +128 -0
- src/pipeline.py +8 -1
- src/prediction/graphcast_inference.py +12 -4
- tests/test_init_sources/__init__.py +0 -0
- tests/test_init_sources/test_gfs_live.py +107 -0
- tests/test_init_sources/test_gfs_shape.py +242 -0
- tests/test_init_sources/test_static_vars.py +83 -0
- tests/test_init_sources/test_variable_mapping.py +120 -0
|
@@ -8,6 +8,7 @@ ENV XLA_PYTHON_CLIENT_MEM_FRACTION=0.95
|
|
| 8 |
|
| 9 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 10 |
libgomp1 ca-certificates curl git \
|
|
|
|
| 11 |
&& rm -rf /var/lib/apt/lists/*
|
| 12 |
|
| 13 |
# Install JAX with CUDA 12 support (separate layer for caching)
|
|
|
|
| 8 |
|
| 9 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 10 |
libgomp1 ca-certificates curl git \
|
| 11 |
+
libeccodes0 \
|
| 12 |
&& rm -rf /var/lib/apt/lists/*
|
| 13 |
|
| 14 |
# Install JAX with CUDA 12 support (separate layer for caching)
|
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[pytest]
|
| 2 |
+
testpaths = tests
|
| 3 |
+
markers =
|
| 4 |
+
live: hits live external services (AWS, HF, etc.); opt-in via env flag
|
|
@@ -15,6 +15,10 @@ gcsfs>=2024.2.0
|
|
| 15 |
xarray>=2024.1.0
|
| 16 |
zarr>=2.16.0
|
| 17 |
pyarrow>=15.0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
# GraphCast inference (JAX installed separately in Dockerfile)
|
| 19 |
graphcast @ git+https://github.com/google-deepmind/graphcast.git
|
| 20 |
dm-haiku
|
|
|
|
| 15 |
xarray>=2024.1.0
|
| 16 |
zarr>=2.16.0
|
| 17 |
pyarrow>=15.0.0
|
| 18 |
+
# GFS init source (alternative to ERA5T; activated via NWP_INIT_SOURCE=gfs)
|
| 19 |
+
cfgrib>=0.9.10
|
| 20 |
+
eccodes>=1.7.0
|
| 21 |
+
boto3>=1.34.0
|
| 22 |
# GraphCast inference (JAX installed separately in Dockerfile)
|
| 23 |
graphcast @ git+https://github.com/google-deepmind/graphcast.git
|
| 24 |
dm-haiku
|
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Init-source plumbing for GraphCast.
|
| 2 |
+
|
| 3 |
+
CRE's pipeline calls ``fetch_era5_for_graphcast(init_date)`` in
|
| 4 |
+
``src/prediction/graphcast_inference.py`` with ``init_date = today - 5``
|
| 5 |
+
to work around ARCO ERA5T's publication lag. That lag means the "5-day
|
| 6 |
+
forecast" actually covers ``today-5`` through ``today-1`` — retroactive,
|
| 7 |
+
not forward-looking. Alert SMS to workers can't warn before a heat event
|
| 8 |
+
when the forecast is already in the past.
|
| 9 |
+
|
| 10 |
+
This package provides an alternative: GFS (NOAA Global Forecast System)
|
| 11 |
+
analysis at ~3h lag. A GFS-sourced ``xarray.Dataset`` is shape-compatible
|
| 12 |
+
with the ERA5 Zarr ``full_ds`` used inside ``fetch_era5_for_graphcast``,
|
| 13 |
+
so the integration in Phase 2 is a one-line source swap behind a config
|
| 14 |
+
toggle.
|
| 15 |
+
|
| 16 |
+
Phase 1 status: this package is reachable only from tests. Nothing in the
|
| 17 |
+
production pipeline imports it. Wiring is deferred to Phase 2 so we can
|
| 18 |
+
validate the module in isolation before deploying.
|
| 19 |
+
|
| 20 |
+
Public entry point:
|
| 21 |
+
fetch_gfs_as_era5(target_date: str) -> xarray.Dataset
|
| 22 |
+
|
| 23 |
+
Sister module in Weather AI 2 (``~/weather AI 2/src/init_sources/``) is the
|
| 24 |
+
canonical copy; this CRE copy is identical code and keeps pace via manual
|
| 25 |
+
sync. If/when init_sources grows a third user, extract to a shared pip-
|
| 26 |
+
installable package.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
|
| 31 |
+
from src.init_sources.gfs import fetch_gfs_as_era5 # noqa: F401
|
|
@@ -0,0 +1,444 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""GFS (NOAA Global Forecast System) → ERA5-shaped xarray.Dataset.
|
| 2 |
+
|
| 3 |
+
The top-level entry point is ``fetch_gfs_as_era5(target_date)`` which returns
|
| 4 |
+
an ``xarray.Dataset`` that is a drop-in replacement for the ARCO ERA5 Zarr
|
| 5 |
+
``full_ds`` used by ``graphcast_client._fetch_era5_sync`` and
|
| 6 |
+
``gencast_client._prepare_era5_inputs``.
|
| 7 |
+
|
| 8 |
+
**Phase 1 status: nothing in the production pipeline imports this module.**
|
| 9 |
+
It's reachable only from ``tests/test_init_sources/``. Wiring into the two
|
| 10 |
+
clients is deferred to Phase 2 so we can validate the module first in
|
| 11 |
+
isolation, then head-to-head with the existing ERA5 path.
|
| 12 |
+
|
| 13 |
+
Source: AWS S3 public bucket ``noaa-gfs-bdp-pds`` (``gfs.YYYYMMDD/HH/atmos/
|
| 14 |
+
gfs.tHHz.pgrb2.0p25.fNNN``). Free, no auth, real-time (~3h lag per cycle).
|
| 15 |
+
|
| 16 |
+
Layout inside the returned Dataset:
|
| 17 |
+
* time: 2 input timesteps (target-6h, target) + N forecast steps
|
| 18 |
+
at 6h cadence
|
| 19 |
+
* latitude: 721 points (90 .. -90, 0.25° step) — matches ARCO ERA5
|
| 20 |
+
* longitude: 1440 points (0 .. 359.75, 0.25° step)
|
| 21 |
+
* level: 13 pressure levels (the GraphCast operational set)
|
| 22 |
+
* data_vars: 5 surface + 6 pressure-level + 2 static variables in ERA5
|
| 23 |
+
canonical naming; units converted to match ERA5
|
| 24 |
+
|
| 25 |
+
The module keeps every heavy/optional import (``cfgrib``, ``s3fs``, ``boto3``)
|
| 26 |
+
inside the function that needs it. That means the module itself imports fine
|
| 27 |
+
on any box with ``xarray`` + ``numpy``, which matters because the test suite
|
| 28 |
+
runs without cfgrib/eccodes installed.
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
from __future__ import annotations
|
| 32 |
+
|
| 33 |
+
import logging
|
| 34 |
+
import os
|
| 35 |
+
import pickle
|
| 36 |
+
from dataclasses import dataclass
|
| 37 |
+
from datetime import datetime, timedelta, timezone
|
| 38 |
+
from pathlib import Path
|
| 39 |
+
from typing import Any, List, Optional, Sequence, Tuple
|
| 40 |
+
|
| 41 |
+
from src.init_sources.variable_mapping import (
|
| 42 |
+
GRAPHCAST_PRESSURE_LEVELS,
|
| 43 |
+
PRESSURE_LEVEL_VARS,
|
| 44 |
+
STATIC_VARS,
|
| 45 |
+
SURFACE_VARS,
|
| 46 |
+
unit_convert,
|
| 47 |
+
)
|
| 48 |
+
from src.init_sources.static_vars import load_static_ds
|
| 49 |
+
|
| 50 |
+
log = logging.getLogger(__name__)
|
| 51 |
+
|
| 52 |
+
# ---------------------------------------------------------------------------
|
| 53 |
+
# Constants
|
| 54 |
+
# ---------------------------------------------------------------------------
|
| 55 |
+
|
| 56 |
+
GFS_S3_BUCKET = "noaa-gfs-bdp-pds"
|
| 57 |
+
GFS_CYCLE_HOURS: Tuple[int, ...] = (0, 6, 12, 18)
|
| 58 |
+
DEFAULT_CYCLE_HOUR = 12
|
| 59 |
+
GFS_NATIVE_RESOLUTION_DEG = 0.25
|
| 60 |
+
GFS_N_LAT = 721
|
| 61 |
+
GFS_N_LON = 1440
|
| 62 |
+
|
| 63 |
+
# Where to cache assembled GFS datasets (pickle format, same pattern as the
|
| 64 |
+
# ERA5 cache already used by graphcast_client.py).
|
| 65 |
+
_GFS_CACHE_DIR = os.environ.get("GFS_CACHE_DIR", "/tmp/gfs_init_cache")
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# ---------------------------------------------------------------------------
|
| 69 |
+
# Cycle selection
|
| 70 |
+
# ---------------------------------------------------------------------------
|
| 71 |
+
|
| 72 |
+
@dataclass
|
| 73 |
+
class GfsCycle:
|
| 74 |
+
"""A specific GFS analysis cycle. Pure data — no I/O."""
|
| 75 |
+
date: str # "YYYY-MM-DD" (UTC calendar day of the cycle)
|
| 76 |
+
hour: int # 0, 6, 12, or 18
|
| 77 |
+
|
| 78 |
+
def datetime_utc(self) -> datetime:
|
| 79 |
+
d = datetime.fromisoformat(self.date).replace(tzinfo=timezone.utc)
|
| 80 |
+
return d + timedelta(hours=self.hour)
|
| 81 |
+
|
| 82 |
+
def s3_prefix(self) -> str:
|
| 83 |
+
"""Bucket-relative prefix for this cycle's files."""
|
| 84 |
+
d = self.date.replace("-", "")
|
| 85 |
+
return f"gfs.{d}/{self.hour:02d}/atmos"
|
| 86 |
+
|
| 87 |
+
def file_name(self, forecast_hour: int) -> str:
|
| 88 |
+
"""GRIB2 filename for a given forecast hour (0 = analysis)."""
|
| 89 |
+
return f"gfs.t{self.hour:02d}z.pgrb2.0p25.f{forecast_hour:03d}"
|
| 90 |
+
|
| 91 |
+
def s3_key(self, forecast_hour: int) -> str:
|
| 92 |
+
return f"{self.s3_prefix()}/{self.file_name(forecast_hour)}"
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _most_recent_cycle(
|
| 96 |
+
target_date: str,
|
| 97 |
+
target_cycle_hour: int = DEFAULT_CYCLE_HOUR,
|
| 98 |
+
) -> GfsCycle:
|
| 99 |
+
"""Return the cycle that should be used to init at ``target_date Thh:00 UTC``.
|
| 100 |
+
|
| 101 |
+
GFS cycles run at 00/06/12/18 UTC. Analysis becomes available ~3-4h after
|
| 102 |
+
the cycle starts, so in practice we want the most recent cycle that's
|
| 103 |
+
strictly completed. Phase 2 will walk this back further if the bucket
|
| 104 |
+
isn't populated yet.
|
| 105 |
+
"""
|
| 106 |
+
if target_cycle_hour not in GFS_CYCLE_HOURS:
|
| 107 |
+
# Snap down to the nearest valid cycle hour.
|
| 108 |
+
target_cycle_hour = max(h for h in GFS_CYCLE_HOURS if h <= target_cycle_hour)
|
| 109 |
+
return GfsCycle(date=target_date, hour=target_cycle_hour)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# ---------------------------------------------------------------------------
|
| 113 |
+
# Download
|
| 114 |
+
# ---------------------------------------------------------------------------
|
| 115 |
+
|
| 116 |
+
def _download_grib2(
|
| 117 |
+
cycle: GfsCycle,
|
| 118 |
+
forecast_hour: int,
|
| 119 |
+
dest_dir: str,
|
| 120 |
+
) -> str:
|
| 121 |
+
"""Download a single GFS GRIB2 file from the public S3 bucket.
|
| 122 |
+
|
| 123 |
+
Returns the local path. Uses ``boto3`` with unsigned requests (the bucket
|
| 124 |
+
is public). Imports boto3 lazily so the module loads without it.
|
| 125 |
+
"""
|
| 126 |
+
os.makedirs(dest_dir, exist_ok=True)
|
| 127 |
+
local_path = os.path.join(dest_dir, cycle.file_name(forecast_hour))
|
| 128 |
+
if os.path.exists(local_path) and os.path.getsize(local_path) > 0:
|
| 129 |
+
log.info("GFS GRIB cached: %s", local_path)
|
| 130 |
+
return local_path
|
| 131 |
+
|
| 132 |
+
import boto3
|
| 133 |
+
from botocore import UNSIGNED
|
| 134 |
+
from botocore.config import Config
|
| 135 |
+
|
| 136 |
+
s3 = boto3.client("s3", config=Config(signature_version=UNSIGNED))
|
| 137 |
+
key = cycle.s3_key(forecast_hour)
|
| 138 |
+
log.info("Downloading s3://%s/%s", GFS_S3_BUCKET, key)
|
| 139 |
+
s3.download_file(GFS_S3_BUCKET, key, local_path)
|
| 140 |
+
return local_path
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
# ---------------------------------------------------------------------------
|
| 144 |
+
# GRIB parsing
|
| 145 |
+
# ---------------------------------------------------------------------------
|
| 146 |
+
|
| 147 |
+
def _open_grib_surface(path: str, gfs_short_name: str) -> Any:
|
| 148 |
+
"""Return an xarray.DataArray for a single surface variable from GRIB2.
|
| 149 |
+
|
| 150 |
+
cfgrib requires ``filter_by_keys`` to select which GRIB message layer
|
| 151 |
+
to decode. For surface variables we filter by the short name plus the
|
| 152 |
+
``heightAboveGround`` or ``meanSea`` type of level as appropriate.
|
| 153 |
+
"""
|
| 154 |
+
import xarray
|
| 155 |
+
# cfgrib's "typeOfLevel" filters pick the right surface layer. GFS stores
|
| 156 |
+
# t2m/u10/v10 at ``heightAboveGround`` (2m and 10m), ``prmsl`` at
|
| 157 |
+
# ``meanSea``, and ``tp`` at ``surface``.
|
| 158 |
+
level_map = {
|
| 159 |
+
"t2m": ("heightAboveGround", 2),
|
| 160 |
+
"u10": ("heightAboveGround", 10),
|
| 161 |
+
"v10": ("heightAboveGround", 10),
|
| 162 |
+
"prmsl": ("meanSea", 0),
|
| 163 |
+
"tp": ("surface", 0),
|
| 164 |
+
}
|
| 165 |
+
type_of_level, level = level_map.get(gfs_short_name, ("surface", 0))
|
| 166 |
+
filters = {"typeOfLevel": type_of_level, "shortName": gfs_short_name}
|
| 167 |
+
if type_of_level == "heightAboveGround":
|
| 168 |
+
filters["level"] = level
|
| 169 |
+
return xarray.open_dataset(path, engine="cfgrib",
|
| 170 |
+
backend_kwargs={"filter_by_keys": filters})[gfs_short_name]
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def _open_grib_pressure_levels(
|
| 174 |
+
path: str, gfs_short_name: str, levels: Sequence[int],
|
| 175 |
+
) -> Any:
|
| 176 |
+
"""Return an xarray.DataArray for a pressure-level variable, selected to
|
| 177 |
+
the requested levels. The result has a ``level`` dim with the 13-level set.
|
| 178 |
+
"""
|
| 179 |
+
import xarray
|
| 180 |
+
filters = {"typeOfLevel": "isobaricInhPa", "shortName": gfs_short_name}
|
| 181 |
+
da = xarray.open_dataset(path, engine="cfgrib",
|
| 182 |
+
backend_kwargs={"filter_by_keys": filters})[gfs_short_name]
|
| 183 |
+
# cfgrib names the pressure dim ``isobaricInhPa``; rename to ERA5's
|
| 184 |
+
# ``level`` for shape compatibility.
|
| 185 |
+
if "isobaricInhPa" in da.dims:
|
| 186 |
+
da = da.rename({"isobaricInhPa": "level"})
|
| 187 |
+
# Filter to the 13 levels we want. ``method='nearest'`` is safe since
|
| 188 |
+
# every requested level is present natively.
|
| 189 |
+
return da.sel(level=list(levels), method="nearest")
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
# ---------------------------------------------------------------------------
|
| 193 |
+
# Assembly: one cycle / one timestep worth of data
|
| 194 |
+
# ---------------------------------------------------------------------------
|
| 195 |
+
|
| 196 |
+
def _assemble_timestep(
|
| 197 |
+
local_path: str,
|
| 198 |
+
timestep: datetime,
|
| 199 |
+
levels: Sequence[int] = GRAPHCAST_PRESSURE_LEVELS,
|
| 200 |
+
) -> Any:
|
| 201 |
+
"""Build an xarray.Dataset for a single time snapshot, in ERA5 naming.
|
| 202 |
+
|
| 203 |
+
Opens the GRIB once per variable (cfgrib requires it). The resulting
|
| 204 |
+
Dataset has:
|
| 205 |
+
coords: time (scalar), latitude, longitude, level
|
| 206 |
+
data_vars: all surface + all pressure-level ERA5 names
|
| 207 |
+
"""
|
| 208 |
+
import numpy as np
|
| 209 |
+
import xarray
|
| 210 |
+
|
| 211 |
+
data_arrays = {}
|
| 212 |
+
|
| 213 |
+
# Surface variables
|
| 214 |
+
for era5_name, (gfs_name, _) in SURFACE_VARS.items():
|
| 215 |
+
try:
|
| 216 |
+
da = _open_grib_surface(local_path, gfs_name)
|
| 217 |
+
da_c = unit_convert(era5_name, da)
|
| 218 |
+
# Drop cfgrib's valid_time/step/time coords — we'll stamp our own.
|
| 219 |
+
da_c = da_c.reset_coords(drop=True)
|
| 220 |
+
data_arrays[era5_name] = da_c
|
| 221 |
+
except Exception as exc:
|
| 222 |
+
log.warning("GFS surface var %s (%s) failed to load: %s",
|
| 223 |
+
era5_name, gfs_name, exc)
|
| 224 |
+
|
| 225 |
+
# Pressure-level variables
|
| 226 |
+
for era5_name, (gfs_name, _) in PRESSURE_LEVEL_VARS.items():
|
| 227 |
+
try:
|
| 228 |
+
da = _open_grib_pressure_levels(local_path, gfs_name, levels)
|
| 229 |
+
da_c = unit_convert(era5_name, da)
|
| 230 |
+
da_c = da_c.reset_coords(drop=True)
|
| 231 |
+
data_arrays[era5_name] = da_c
|
| 232 |
+
except Exception as exc:
|
| 233 |
+
log.warning("GFS pressure var %s (%s) failed to load: %s",
|
| 234 |
+
era5_name, gfs_name, exc)
|
| 235 |
+
|
| 236 |
+
ds = xarray.Dataset(data_arrays)
|
| 237 |
+
# Normalise coordinate names: cfgrib exposes latitude/longitude already,
|
| 238 |
+
# but assert and rename if the dim names differ.
|
| 239 |
+
ds = _normalise_coords(ds)
|
| 240 |
+
# Stamp the time coord.
|
| 241 |
+
ts64 = np.datetime64(timestep.replace(tzinfo=None).isoformat())
|
| 242 |
+
ds = ds.expand_dims(time=[ts64])
|
| 243 |
+
return ds
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def _normalise_coords(ds: Any) -> Any:
|
| 247 |
+
"""Ensure the dataset uses ``latitude``/``longitude`` (ERA5 convention).
|
| 248 |
+
|
| 249 |
+
cfgrib names the horizontal coords ``latitude`` and ``longitude`` by
|
| 250 |
+
default, but some GRIB templates use ``lat``/``lon``. We normalise to
|
| 251 |
+
the ERA5 long names so downstream code (which itself normalises to
|
| 252 |
+
``lat``/``lon`` later) has a single predictable starting point.
|
| 253 |
+
"""
|
| 254 |
+
rename = {}
|
| 255 |
+
if "lat" in ds.dims and "latitude" not in ds.dims:
|
| 256 |
+
rename["lat"] = "latitude"
|
| 257 |
+
if "lon" in ds.dims and "longitude" not in ds.dims:
|
| 258 |
+
rename["lon"] = "longitude"
|
| 259 |
+
if rename:
|
| 260 |
+
ds = ds.rename(rename)
|
| 261 |
+
return ds
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
# ---------------------------------------------------------------------------
|
| 265 |
+
# Top-level fetcher
|
| 266 |
+
# ---------------------------------------------------------------------------
|
| 267 |
+
|
| 268 |
+
def _cache_path(target_date: str, cycle_hour: int, horizon_hours: int) -> str:
|
| 269 |
+
return os.path.join(
|
| 270 |
+
_GFS_CACHE_DIR,
|
| 271 |
+
f"gfs_{target_date}_c{cycle_hour:02d}_h{horizon_hours:03d}.pkl",
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def _load_cached(target_date: str, cycle_hour: int, horizon_hours: int) -> Optional[Any]:
|
| 276 |
+
path = _cache_path(target_date, cycle_hour, horizon_hours)
|
| 277 |
+
if os.path.exists(path):
|
| 278 |
+
with open(path, "rb") as f:
|
| 279 |
+
return pickle.load(f)
|
| 280 |
+
return None
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def _save_cached(ds: Any, target_date: str, cycle_hour: int, horizon_hours: int) -> None:
|
| 284 |
+
os.makedirs(_GFS_CACHE_DIR, exist_ok=True)
|
| 285 |
+
path = _cache_path(target_date, cycle_hour, horizon_hours)
|
| 286 |
+
with open(path, "wb") as f:
|
| 287 |
+
pickle.dump(ds, f)
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def fetch_gfs_as_era5(
|
| 291 |
+
target_date: str,
|
| 292 |
+
forecast_horizon_hours: int = 288,
|
| 293 |
+
cycle_hour: int = DEFAULT_CYCLE_HOUR,
|
| 294 |
+
*,
|
| 295 |
+
levels: Sequence[int] = GRAPHCAST_PRESSURE_LEVELS,
|
| 296 |
+
work_dir: Optional[str] = None,
|
| 297 |
+
attach_static: bool = True,
|
| 298 |
+
) -> Any:
|
| 299 |
+
"""Fetch a GFS cycle and return an ERA5-shape xarray.Dataset.
|
| 300 |
+
|
| 301 |
+
Args:
|
| 302 |
+
target_date: ISO date string (e.g. "2026-04-21"). The cycle's
|
| 303 |
+
nominal date.
|
| 304 |
+
forecast_horizon_hours: how far forward to include forecast steps.
|
| 305 |
+
Covers the longest horizon either model needs (GraphCast
|
| 306 |
+
FORECAST_STEPS=48 → 288h).
|
| 307 |
+
cycle_hour: which UTC cycle (0/6/12/18). Defaults to 12Z.
|
| 308 |
+
levels: pressure levels to keep. Defaults to the 13-level
|
| 309 |
+
GraphCast-operational set.
|
| 310 |
+
work_dir: download cache for raw GRIB files. Defaults to a subdir
|
| 311 |
+
under ``_GFS_CACHE_DIR``.
|
| 312 |
+
attach_static: if True, merge in ``geopotential_at_surface`` +
|
| 313 |
+
``land_sea_mask`` from the ERA5-derived static cache.
|
| 314 |
+
|
| 315 |
+
Returns:
|
| 316 |
+
An ``xarray.Dataset`` with:
|
| 317 |
+
* dims: (time, level, latitude, longitude)
|
| 318 |
+
* time: [target-6h, target, target+6h, ..., target+horizon_h]
|
| 319 |
+
* 5 surface + 6 pressure-level data_vars (ERA5 names)
|
| 320 |
+
* static vars if ``attach_static`` and cache is built
|
| 321 |
+
|
| 322 |
+
Raises:
|
| 323 |
+
RuntimeError if a critical variable fails to load across all timesteps.
|
| 324 |
+
FileNotFoundError if ``attach_static`` and the static cache hasn't
|
| 325 |
+
been built (call ``static_vars.ensure_static_cache()``).
|
| 326 |
+
"""
|
| 327 |
+
import xarray
|
| 328 |
+
|
| 329 |
+
cached = _load_cached(target_date, cycle_hour, forecast_horizon_hours)
|
| 330 |
+
if cached is not None:
|
| 331 |
+
log.info("GFS cache hit for %s %02dZ (+%dh)",
|
| 332 |
+
target_date, cycle_hour, forecast_horizon_hours)
|
| 333 |
+
return cached
|
| 334 |
+
|
| 335 |
+
cycle = _most_recent_cycle(target_date, cycle_hour)
|
| 336 |
+
work_dir = work_dir or os.path.join(_GFS_CACHE_DIR, "grib_raw")
|
| 337 |
+
|
| 338 |
+
# Two input timesteps (analysis from this cycle and the previous cycle)
|
| 339 |
+
# + forecast steps at 6h cadence through the horizon.
|
| 340 |
+
cycle_dt = cycle.datetime_utc()
|
| 341 |
+
prev_cycle = _most_recent_cycle(
|
| 342 |
+
(cycle_dt - timedelta(hours=6)).date().isoformat(),
|
| 343 |
+
(cycle.hour - 6) % 24,
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
timesteps: List[Tuple[datetime, GfsCycle, int]] = []
|
| 347 |
+
timesteps.append((prev_cycle.datetime_utc(), prev_cycle, 0))
|
| 348 |
+
timesteps.append((cycle_dt, cycle, 0))
|
| 349 |
+
n_forecast = forecast_horizon_hours // 6
|
| 350 |
+
for i in range(1, n_forecast + 1):
|
| 351 |
+
fhour = i * 6
|
| 352 |
+
timesteps.append((cycle_dt + timedelta(hours=fhour), cycle, fhour))
|
| 353 |
+
|
| 354 |
+
per_step_ds = []
|
| 355 |
+
for (step_dt, step_cycle, fhour) in timesteps:
|
| 356 |
+
grib_path = _download_grib2(step_cycle, fhour, work_dir)
|
| 357 |
+
ds_step = _assemble_timestep(grib_path, step_dt, levels=levels)
|
| 358 |
+
per_step_ds.append(ds_step)
|
| 359 |
+
|
| 360 |
+
full_ds = xarray.concat(per_step_ds, dim="time")
|
| 361 |
+
|
| 362 |
+
if attach_static:
|
| 363 |
+
try:
|
| 364 |
+
static_ds = load_static_ds()
|
| 365 |
+
except FileNotFoundError:
|
| 366 |
+
log.info("Static-var cache missing; building once from ERA5 (~30s)")
|
| 367 |
+
from src.init_sources.static_vars import ensure_static_cache
|
| 368 |
+
ensure_static_cache()
|
| 369 |
+
static_ds = load_static_ds()
|
| 370 |
+
# Merge keeps the time-varying vars plus the static 2D surfaces.
|
| 371 |
+
full_ds = xarray.merge([full_ds, static_ds], compat="override")
|
| 372 |
+
|
| 373 |
+
_save_cached(full_ds, target_date, cycle_hour, forecast_horizon_hours)
|
| 374 |
+
return full_ds
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
# ---------------------------------------------------------------------------
|
| 378 |
+
# Helpers exposed for tests (pure xarray manipulation — no I/O)
|
| 379 |
+
# ---------------------------------------------------------------------------
|
| 380 |
+
|
| 381 |
+
def build_synthetic_gfs_dataset(
|
| 382 |
+
timesteps: Sequence[datetime],
|
| 383 |
+
levels: Sequence[int] = GRAPHCAST_PRESSURE_LEVELS,
|
| 384 |
+
lat_vals=None,
|
| 385 |
+
lon_vals=None,
|
| 386 |
+
include_static: bool = True,
|
| 387 |
+
temperature_k: float = 300.0,
|
| 388 |
+
) -> Any:
|
| 389 |
+
"""Construct a minimal synthetic dataset with the same shape this module
|
| 390 |
+
returns in production. For unit tests only.
|
| 391 |
+
|
| 392 |
+
Values are physically-plausible constants — the tests assert on shape
|
| 393 |
+
and dimension layout, not on the values themselves.
|
| 394 |
+
"""
|
| 395 |
+
import numpy as np
|
| 396 |
+
import xarray
|
| 397 |
+
|
| 398 |
+
if lat_vals is None:
|
| 399 |
+
lat_vals = np.linspace(90.0, -90.0, GFS_N_LAT, dtype=np.float32)
|
| 400 |
+
if lon_vals is None:
|
| 401 |
+
lon_vals = np.linspace(0.0, 359.75, GFS_N_LON, dtype=np.float32)
|
| 402 |
+
t_coord = np.array([np.datetime64(t.replace(tzinfo=None).isoformat())
|
| 403 |
+
for t in timesteps])
|
| 404 |
+
|
| 405 |
+
shape_s = (len(t_coord), len(lat_vals), len(lon_vals))
|
| 406 |
+
shape_p = (len(t_coord), len(levels), len(lat_vals), len(lon_vals))
|
| 407 |
+
|
| 408 |
+
ds = xarray.Dataset(
|
| 409 |
+
data_vars={
|
| 410 |
+
"2m_temperature": (("time", "latitude", "longitude"),
|
| 411 |
+
np.full(shape_s, temperature_k, dtype=np.float32)),
|
| 412 |
+
"10m_u_component_of_wind": (("time", "latitude", "longitude"),
|
| 413 |
+
np.zeros(shape_s, dtype=np.float32)),
|
| 414 |
+
"10m_v_component_of_wind": (("time", "latitude", "longitude"),
|
| 415 |
+
np.zeros(shape_s, dtype=np.float32)),
|
| 416 |
+
"mean_sea_level_pressure": (("time", "latitude", "longitude"),
|
| 417 |
+
np.full(shape_s, 101_325.0, dtype=np.float32)),
|
| 418 |
+
"total_precipitation_6hr": (("time", "latitude", "longitude"),
|
| 419 |
+
np.zeros(shape_s, dtype=np.float32)),
|
| 420 |
+
"temperature": (("time", "level", "latitude", "longitude"),
|
| 421 |
+
np.full(shape_p, 260.0, dtype=np.float32)),
|
| 422 |
+
"specific_humidity": (("time", "level", "latitude", "longitude"),
|
| 423 |
+
np.full(shape_p, 0.005, dtype=np.float32)),
|
| 424 |
+
"u_component_of_wind": (("time", "level", "latitude", "longitude"),
|
| 425 |
+
np.zeros(shape_p, dtype=np.float32)),
|
| 426 |
+
"v_component_of_wind": (("time", "level", "latitude", "longitude"),
|
| 427 |
+
np.zeros(shape_p, dtype=np.float32)),
|
| 428 |
+
"vertical_velocity": (("time", "level", "latitude", "longitude"),
|
| 429 |
+
np.zeros(shape_p, dtype=np.float32)),
|
| 430 |
+
"geopotential": (("time", "level", "latitude", "longitude"),
|
| 431 |
+
np.full(shape_p, 5.0e4, dtype=np.float32)),
|
| 432 |
+
},
|
| 433 |
+
coords={
|
| 434 |
+
"time": t_coord,
|
| 435 |
+
"level": np.asarray(levels, dtype=np.int32),
|
| 436 |
+
"latitude": lat_vals,
|
| 437 |
+
"longitude": lon_vals,
|
| 438 |
+
},
|
| 439 |
+
)
|
| 440 |
+
if include_static:
|
| 441 |
+
from src.init_sources.static_vars import build_synthetic_static_ds
|
| 442 |
+
static_ds = build_synthetic_static_ds(lat_vals=lat_vals, lon_vals=lon_vals)
|
| 443 |
+
ds = ds.merge(static_ds)
|
| 444 |
+
return ds
|
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Static (time-invariant) variables for GraphCast / GenCast init.
|
| 2 |
+
|
| 3 |
+
GraphCast requires two static fields that GFS's standard pgrb2 products do
|
| 4 |
+
not ship: ``geopotential_at_surface`` (surface elevation × g, used to place
|
| 5 |
+
the model on Earth's terrain) and ``land_sea_mask`` (binary land/water flag).
|
| 6 |
+
|
| 7 |
+
They are pulled once from ERA5 and cached to disk as a small netCDF. GFS
|
| 8 |
+
inits then attach them verbatim — the values don't change per init.
|
| 9 |
+
|
| 10 |
+
The cache lives at ``data/init_sources/era5_static.nc`` (committed to the
|
| 11 |
+
repo). If it's missing, the ``ensure_static_cache`` helper downloads a fresh
|
| 12 |
+
copy from ARCO ERA5. That download is a one-time, ~5 MB pull (two 721×1440
|
| 13 |
+
float32 surfaces + coords).
|
| 14 |
+
|
| 15 |
+
Phase 1 note: the cache file is not generated in this commit — Phase 2 will
|
| 16 |
+
produce it the first time the integration runs. The loader here is wired
|
| 17 |
+
end-to-end so Phase 2 can simply invoke ``load_static_ds()`` without further
|
| 18 |
+
work.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import os
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
from typing import Any
|
| 26 |
+
|
| 27 |
+
# Path to the static-variable cache. Relative to the repo root so both local
|
| 28 |
+
# dev and HF Space runs find it in the same place.
|
| 29 |
+
DEFAULT_CACHE_PATH = str(
|
| 30 |
+
Path(__file__).resolve().parent.parent.parent
|
| 31 |
+
/ "data" / "init_sources" / "era5_static.nc"
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
STATIC_VARS_NEEDED = ("geopotential_at_surface", "land_sea_mask")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def cache_path() -> str:
|
| 38 |
+
return os.environ.get("GFS_STATIC_CACHE", DEFAULT_CACHE_PATH)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def cache_exists() -> bool:
|
| 42 |
+
return os.path.exists(cache_path())
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def load_static_ds() -> Any:
|
| 46 |
+
"""Load the static-variable netCDF as an xarray.Dataset.
|
| 47 |
+
|
| 48 |
+
Raises FileNotFoundError if the cache hasn't been built yet. Callers in
|
| 49 |
+
Phase 2 should either call ``ensure_static_cache()`` first or fall back
|
| 50 |
+
gracefully (GraphCast's existing code zero-fills missing static vars
|
| 51 |
+
with a warning — same behavior preserved here).
|
| 52 |
+
"""
|
| 53 |
+
import xarray # lazy — module is importable without xarray present
|
| 54 |
+
path = cache_path()
|
| 55 |
+
if not os.path.exists(path):
|
| 56 |
+
raise FileNotFoundError(
|
| 57 |
+
f"Static-variable cache not found at {path}. "
|
| 58 |
+
f"Run ensure_static_cache() to build it from ERA5, or unset "
|
| 59 |
+
f"GFS_STATIC_CACHE to use the default repo path."
|
| 60 |
+
)
|
| 61 |
+
return xarray.load_dataset(path)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def ensure_static_cache(force: bool = False) -> str:
|
| 65 |
+
"""Build the static-variable cache from ERA5 if it doesn't exist.
|
| 66 |
+
|
| 67 |
+
Returns the path to the cache file. Idempotent.
|
| 68 |
+
|
| 69 |
+
This fetches a single ERA5 timestep's static fields from the ARCO Zarr.
|
| 70 |
+
ERA5 writes the same static values at every timestep (they truly don't
|
| 71 |
+
change), so any valid time works — we use 2024-01-01 as a stable anchor.
|
| 72 |
+
"""
|
| 73 |
+
import xarray
|
| 74 |
+
path = cache_path()
|
| 75 |
+
if os.path.exists(path) and not force:
|
| 76 |
+
return path
|
| 77 |
+
|
| 78 |
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
| 79 |
+
|
| 80 |
+
era5_path = "gs://gcp-public-data-arco-era5/ar/full_37-1h-0p25deg-chunk-1.zarr-v3"
|
| 81 |
+
full_ds = xarray.open_zarr(
|
| 82 |
+
era5_path, chunks=None, storage_options={"token": "anon"},
|
| 83 |
+
consolidated=True,
|
| 84 |
+
)
|
| 85 |
+
# Pick a known-valid timestep; values are static so the exact time is moot.
|
| 86 |
+
import numpy as np
|
| 87 |
+
t_anchor = np.datetime64("2024-01-01T12:00")
|
| 88 |
+
t_sel = full_ds.time.sel(time=t_anchor, method="nearest").values
|
| 89 |
+
|
| 90 |
+
vars_present = [v for v in STATIC_VARS_NEEDED if v in full_ds.data_vars]
|
| 91 |
+
if not vars_present:
|
| 92 |
+
raise RuntimeError(
|
| 93 |
+
f"ERA5 Zarr at {era5_path} lacks {STATIC_VARS_NEEDED}. "
|
| 94 |
+
f"Check the upstream bucket — this would be a schema change "
|
| 95 |
+
f"upstream, not a bug here."
|
| 96 |
+
)
|
| 97 |
+
static_ds = full_ds[vars_present].sel(time=t_sel).compute()
|
| 98 |
+
# Drop the time dim (static vars have no real time axis even though ERA5
|
| 99 |
+
# ships them per-step).
|
| 100 |
+
if "time" in static_ds.dims:
|
| 101 |
+
static_ds = static_ds.drop_vars("time")
|
| 102 |
+
elif "time" in static_ds.coords:
|
| 103 |
+
static_ds = static_ds.drop_vars("time")
|
| 104 |
+
|
| 105 |
+
static_ds.to_netcdf(path)
|
| 106 |
+
return path
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def build_synthetic_static_ds(
|
| 110 |
+
lat_vals=None, lon_vals=None,
|
| 111 |
+
):
|
| 112 |
+
"""Build a tiny synthetic static dataset for use in unit tests.
|
| 113 |
+
|
| 114 |
+
Returns a two-variable xarray.Dataset with plausible values — zeros for
|
| 115 |
+
ocean, a constant hill for land — on whatever grid is passed in. No
|
| 116 |
+
pretensions to realism; the tests only care that downstream code can
|
| 117 |
+
read and shape-check the fields.
|
| 118 |
+
"""
|
| 119 |
+
import numpy as np
|
| 120 |
+
import xarray
|
| 121 |
+
if lat_vals is None:
|
| 122 |
+
lat_vals = np.linspace(90.0, -90.0, 721, dtype=np.float32)
|
| 123 |
+
if lon_vals is None:
|
| 124 |
+
lon_vals = np.linspace(0.0, 359.75, 1440, dtype=np.float32)
|
| 125 |
+
|
| 126 |
+
shape = (len(lat_vals), len(lon_vals))
|
| 127 |
+
gp_surf = np.full(shape, 0.0, dtype=np.float32)
|
| 128 |
+
lsm = np.zeros(shape, dtype=np.float32)
|
| 129 |
+
# Mark the India region as land for a sanity check: lat 8-14 N, lon 74-80 E.
|
| 130 |
+
lat_mask = (lat_vals >= 8.0) & (lat_vals <= 14.0)
|
| 131 |
+
lon_mask = (lon_vals >= 74.0) & (lon_vals <= 80.0)
|
| 132 |
+
if lat_mask.any() and lon_mask.any():
|
| 133 |
+
lsm[np.ix_(lat_mask, lon_mask)] = 1.0
|
| 134 |
+
gp_surf[np.ix_(lat_mask, lon_mask)] = 500.0 * 9.80665 # ~500m elev
|
| 135 |
+
|
| 136 |
+
return xarray.Dataset(
|
| 137 |
+
{
|
| 138 |
+
"geopotential_at_surface": (("latitude", "longitude"), gp_surf),
|
| 139 |
+
"land_sea_mask": (("latitude", "longitude"), lsm),
|
| 140 |
+
},
|
| 141 |
+
coords={"latitude": lat_vals, "longitude": lon_vals},
|
| 142 |
+
)
|
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""GFS → ERA5 variable name, unit, and pressure-level conversion tables.
|
| 2 |
+
|
| 3 |
+
These are pure data. Tests exercise them directly. The fetcher in gfs.py
|
| 4 |
+
consumes them.
|
| 5 |
+
|
| 6 |
+
ERA5 (via ARCO Zarr, what GraphCast + GenCast are trained on) uses long
|
| 7 |
+
human-readable names: ``2m_temperature``, ``10m_u_component_of_wind``, etc.
|
| 8 |
+
GFS GRIB2 uses short codes: ``t2m``, ``u10``, etc. Both encode the same
|
| 9 |
+
physical quantities but with different conventions.
|
| 10 |
+
|
| 11 |
+
Everything here is single-source-of-truth: if GFS introduces a new short-code
|
| 12 |
+
convention, it only gets added here and the fetcher picks it up automatically.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
from typing import Dict, Tuple
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# ---------------------------------------------------------------------------
|
| 21 |
+
# Surface variables (single-level)
|
| 22 |
+
# ---------------------------------------------------------------------------
|
| 23 |
+
|
| 24 |
+
# Each entry: ERA5 canonical name → (GFS cfgrib short-name, unit conversion)
|
| 25 |
+
# Unit conversion is a (scale, offset) tuple applied as gfs_value * scale + offset.
|
| 26 |
+
# When no conversion is needed (same units), use (1.0, 0.0).
|
| 27 |
+
#
|
| 28 |
+
# GFS units from cfgrib:
|
| 29 |
+
# t2m: K (matches ERA5)
|
| 30 |
+
# u10: m/s (matches ERA5)
|
| 31 |
+
# v10: m/s (matches ERA5)
|
| 32 |
+
# prmsl:Pa (matches ERA5)
|
| 33 |
+
# tp: kg/m^2 (≡ mm of liquid water, ERA5 has m — divide by 1000)
|
| 34 |
+
#
|
| 35 |
+
# GFS GRIB2 has total_precipitation as an accumulated quantity over the forecast
|
| 36 |
+
# step — cfgrib exposes ``tp`` in kg/m² which is numerically the same as mm of
|
| 37 |
+
# liquid water. ERA5 reports precipitation in m. We divide by 1000 to match.
|
| 38 |
+
|
| 39 |
+
SURFACE_VARS: Dict[str, Tuple[str, Tuple[float, float]]] = {
|
| 40 |
+
"2m_temperature": ("t2m", (1.0, 0.0)),
|
| 41 |
+
"10m_u_component_of_wind": ("u10", (1.0, 0.0)),
|
| 42 |
+
"10m_v_component_of_wind": ("v10", (1.0, 0.0)),
|
| 43 |
+
"mean_sea_level_pressure": ("prmsl", (1.0, 0.0)),
|
| 44 |
+
"total_precipitation_6hr": ("tp", (1e-3, 0.0)), # mm → m
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# ---------------------------------------------------------------------------
|
| 49 |
+
# Pressure-level variables (3D: time × level × lat × lon)
|
| 50 |
+
# ---------------------------------------------------------------------------
|
| 51 |
+
#
|
| 52 |
+
# GFS units on pressure levels:
|
| 53 |
+
# t: K (matches ERA5)
|
| 54 |
+
# q: kg/kg (matches ERA5)
|
| 55 |
+
# u: m/s (matches ERA5)
|
| 56 |
+
# v: m/s (matches ERA5)
|
| 57 |
+
# w: Pa/s (matches ERA5 vertical_velocity)
|
| 58 |
+
# gh: gpm (geopotential HEIGHT in meters — ERA5 has geopotential in m²/s²)
|
| 59 |
+
# → multiply by g = 9.80665 to get geopotential.
|
| 60 |
+
|
| 61 |
+
PRESSURE_LEVEL_VARS: Dict[str, Tuple[str, Tuple[float, float]]] = {
|
| 62 |
+
"temperature": ("t", (1.0, 0.0)),
|
| 63 |
+
"specific_humidity": ("q", (1.0, 0.0)),
|
| 64 |
+
"u_component_of_wind": ("u", (1.0, 0.0)),
|
| 65 |
+
"v_component_of_wind": ("v", (1.0, 0.0)),
|
| 66 |
+
"vertical_velocity": ("w", (1.0, 0.0)),
|
| 67 |
+
"geopotential": ("gh", (9.80665, 0.0)), # gpm → m²/s²
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# ---------------------------------------------------------------------------
|
| 72 |
+
# Static variables (no time dim)
|
| 73 |
+
# ---------------------------------------------------------------------------
|
| 74 |
+
#
|
| 75 |
+
# These aren't in standard GFS pgrb2 files. They're loaded from a small ERA5
|
| 76 |
+
# snapshot cached in the repo. See static_vars.py.
|
| 77 |
+
|
| 78 |
+
STATIC_VARS: Tuple[str, ...] = (
|
| 79 |
+
"geopotential_at_surface",
|
| 80 |
+
"land_sea_mask",
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# ---------------------------------------------------------------------------
|
| 85 |
+
# Pressure levels
|
| 86 |
+
# ---------------------------------------------------------------------------
|
| 87 |
+
#
|
| 88 |
+
# GraphCast operational uses 13 levels. GenCast 1.0° uses the same 13 (the
|
| 89 |
+
# checkpoint's task_config lists them explicitly). Both models' task_configs
|
| 90 |
+
# carry the level list, so the fetcher never hard-codes it — but the levels
|
| 91 |
+
# here are what we select DOWN to from GFS's richer level set.
|
| 92 |
+
#
|
| 93 |
+
# GFS pgrb2.0p25 natively provides: 10, 20, 30, 40, 50, 70, 100, 150, 200,
|
| 94 |
+
# 250, 300, 350, 400, 450, 500, 550, 600, 650, 700, 750, 800, 850, 900, 925,
|
| 95 |
+
# 950, 975, 1000 hPa — all 13 GraphCast levels are in there.
|
| 96 |
+
|
| 97 |
+
GRAPHCAST_PRESSURE_LEVELS: Tuple[int, ...] = (
|
| 98 |
+
50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000,
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# ---------------------------------------------------------------------------
|
| 103 |
+
# Convenience helpers
|
| 104 |
+
# ---------------------------------------------------------------------------
|
| 105 |
+
|
| 106 |
+
def gfs_short_name(era5_name: str) -> str:
|
| 107 |
+
"""Return the GFS short-name for an ERA5 variable, or raise KeyError."""
|
| 108 |
+
if era5_name in SURFACE_VARS:
|
| 109 |
+
return SURFACE_VARS[era5_name][0]
|
| 110 |
+
if era5_name in PRESSURE_LEVEL_VARS:
|
| 111 |
+
return PRESSURE_LEVEL_VARS[era5_name][0]
|
| 112 |
+
raise KeyError(f"No GFS mapping for ERA5 variable {era5_name!r}")
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def unit_convert(era5_name: str, gfs_values):
|
| 116 |
+
"""Apply the stored (scale, offset) conversion to a GFS value/array."""
|
| 117 |
+
if era5_name in SURFACE_VARS:
|
| 118 |
+
scale, offset = SURFACE_VARS[era5_name][1]
|
| 119 |
+
elif era5_name in PRESSURE_LEVEL_VARS:
|
| 120 |
+
scale, offset = PRESSURE_LEVEL_VARS[era5_name][1]
|
| 121 |
+
else:
|
| 122 |
+
raise KeyError(f"No unit conversion for {era5_name!r}")
|
| 123 |
+
return gfs_values * scale + offset
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def all_era5_names() -> Tuple[str, ...]:
|
| 127 |
+
"""All ERA5 canonical names handled by this mapping (surface + pressure)."""
|
| 128 |
+
return tuple(SURFACE_VARS.keys()) + tuple(PRESSURE_LEVEL_VARS.keys())
|
|
@@ -547,7 +547,14 @@ class HeatRiskPipeline:
|
|
| 547 |
# "today" makes xarray's nearest-match silently fall back to
|
| 548 |
# whatever's available, so the 5-day forecast window doesn't
|
| 549 |
# align with T+1..T+5 as the pipeline assumes. Anchor to T-5.
|
| 550 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 551 |
_, gc_wbgt, gc_timing = forecast_for_dar(init_date, apply_mos=True)
|
| 552 |
forecast_source = "graphcast_mos"
|
| 553 |
logger.info(
|
|
|
|
| 547 |
# "today" makes xarray's nearest-match silently fall back to
|
| 548 |
# whatever's available, so the 5-day forecast window doesn't
|
| 549 |
# align with T+1..T+5 as the pipeline assumes. Anchor to T-5.
|
| 550 |
+
_override = os.environ.get("NWP_TARGET_DATE_OVERRIDE")
|
| 551 |
+
if _override:
|
| 552 |
+
init_date = _override
|
| 553 |
+
elif os.environ.get("NWP_INIT_SOURCE", "era5t").lower() == "gfs":
|
| 554 |
+
# GFS has ~3h lag — yesterday's cycle is always complete.
|
| 555 |
+
init_date = (datetime.utcnow() - timedelta(days=1)).strftime("%Y-%m-%d")
|
| 556 |
+
else:
|
| 557 |
+
init_date = (datetime.utcnow() - timedelta(days=5)).strftime("%Y-%m-%d")
|
| 558 |
_, gc_wbgt, gc_timing = forecast_for_dar(init_date, apply_mos=True)
|
| 559 |
forecast_source = "graphcast_mos"
|
| 560 |
logger.info(
|
|
@@ -250,10 +250,18 @@ def fetch_era5_for_graphcast(
|
|
| 250 |
import xarray
|
| 251 |
from graphcast import data_utils
|
| 252 |
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
|
| 258 |
# Target time: noon UTC on the requested date
|
| 259 |
target = np.datetime64(f"{target_date}T12:00")
|
|
|
|
| 250 |
import xarray
|
| 251 |
from graphcast import data_utils
|
| 252 |
|
| 253 |
+
import os as _os
|
| 254 |
+
if _os.environ.get("NWP_INIT_SOURCE", "era5t").lower() == "gfs":
|
| 255 |
+
log.info("NWP_INIT_SOURCE=gfs — fetching GFS init for %s", target_date)
|
| 256 |
+
from src.init_sources import fetch_gfs_as_era5
|
| 257 |
+
full_ds = fetch_gfs_as_era5(
|
| 258 |
+
target_date, forecast_horizon_hours=FORECAST_STEPS * 6,
|
| 259 |
+
)
|
| 260 |
+
else:
|
| 261 |
+
log.info("Opening ARCO ERA5 Zarr for %s...", target_date)
|
| 262 |
+
gcs_opts = {"token": "anon"}
|
| 263 |
+
full_ds = xarray.open_zarr(
|
| 264 |
+
ERA5_PATH, chunks=None, storage_options=gcs_opts, consolidated=True)
|
| 265 |
|
| 266 |
# Target time: noon UTC on the requested date
|
| 267 |
target = np.datetime64(f"{target_date}T12:00")
|
|
File without changes
|
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Live GFS integration test — skipped by default.
|
| 2 |
+
|
| 3 |
+
This is the "does the module actually work against real AWS GFS data" check.
|
| 4 |
+
It runs only when ``GFS_LIVE_TEST=1`` is set, because it requires:
|
| 5 |
+
|
| 6 |
+
* network access to ``noaa-gfs-bdp-pds`` on S3 (public bucket, unsigned)
|
| 7 |
+
* ``cfgrib`` installed (pulls in the eccodes C library)
|
| 8 |
+
* ``boto3`` for the S3 fetch
|
| 9 |
+
* ~200 MB of disk for one GRIB2 file
|
| 10 |
+
|
| 11 |
+
Sanity-check assertions are conservative: if cfgrib or boto3 are missing we
|
| 12 |
+
``pytest.skip`` instead of failing so the suite stays green on machines
|
| 13 |
+
without the deps.
|
| 14 |
+
|
| 15 |
+
Run manually:
|
| 16 |
+
|
| 17 |
+
GFS_LIVE_TEST=1 .venv/bin/pytest -q -k live tests/test_init_sources/test_gfs_live.py
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import datetime as dt
|
| 23 |
+
import importlib.util
|
| 24 |
+
import os
|
| 25 |
+
import tempfile
|
| 26 |
+
|
| 27 |
+
import numpy as np
|
| 28 |
+
import pytest
|
| 29 |
+
|
| 30 |
+
from src.init_sources import gfs
|
| 31 |
+
from src.init_sources.variable_mapping import GRAPHCAST_PRESSURE_LEVELS
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
REQUIRED_PACKAGES = ("cfgrib", "boto3")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _required_deps_present() -> bool:
|
| 38 |
+
return all(importlib.util.find_spec(pkg) is not None for pkg in REQUIRED_PACKAGES)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
pytestmark = pytest.mark.skipif(
|
| 42 |
+
os.environ.get("GFS_LIVE_TEST") != "1",
|
| 43 |
+
reason="set GFS_LIVE_TEST=1 to run live AWS/cfgrib integration tests",
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _skip_if_no_deps():
|
| 48 |
+
if not _required_deps_present():
|
| 49 |
+
pytest.skip(f"install {REQUIRED_PACKAGES} to run live GFS tests")
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@pytest.mark.live
|
| 53 |
+
def test_download_one_grib_file(tmp_path):
|
| 54 |
+
"""Smallest-possible live test: pull one GRIB2 file and verify it exists."""
|
| 55 |
+
_skip_if_no_deps()
|
| 56 |
+
|
| 57 |
+
# Use yesterday 06Z — old enough that the cycle is definitely complete.
|
| 58 |
+
yesterday = (dt.datetime.utcnow().date() - dt.timedelta(days=1)).isoformat()
|
| 59 |
+
cycle = gfs.GfsCycle(date=yesterday, hour=6)
|
| 60 |
+
|
| 61 |
+
path = gfs._download_grib2(cycle, forecast_hour=0, dest_dir=str(tmp_path))
|
| 62 |
+
assert os.path.exists(path)
|
| 63 |
+
assert os.path.getsize(path) > 10_000_000, (
|
| 64 |
+
"GFS pgrb2.0p25 f000 files are hundreds of MB; <10MB suggests partial download"
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@pytest.mark.live
|
| 69 |
+
def test_fetch_gfs_as_era5_assembles_dataset(tmp_path, monkeypatch):
|
| 70 |
+
"""End-to-end: fetch + parse + assemble the first two timesteps, check shape."""
|
| 71 |
+
_skip_if_no_deps()
|
| 72 |
+
|
| 73 |
+
# Point the cache at a tmp dir so this test doesn't pollute /tmp.
|
| 74 |
+
monkeypatch.setattr(gfs, "_GFS_CACHE_DIR", str(tmp_path))
|
| 75 |
+
|
| 76 |
+
yesterday = (dt.datetime.utcnow().date() - dt.timedelta(days=1)).isoformat()
|
| 77 |
+
|
| 78 |
+
# Keep the forecast horizon tiny so the live test completes in reasonable
|
| 79 |
+
# time — only 12 hours out (3 timesteps total: -6h analysis, 0h analysis,
|
| 80 |
+
# +6h forecast). The shape/variable coverage is the same regardless of
|
| 81 |
+
# horizon, so 12h is sufficient signal for the live sanity check.
|
| 82 |
+
ds = gfs.fetch_gfs_as_era5(
|
| 83 |
+
target_date=yesterday,
|
| 84 |
+
forecast_horizon_hours=12,
|
| 85 |
+
cycle_hour=6,
|
| 86 |
+
work_dir=str(tmp_path / "grib_raw"),
|
| 87 |
+
attach_static=False,
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
# Shape checks
|
| 91 |
+
assert ds.sizes["latitude"] == gfs.GFS_N_LAT
|
| 92 |
+
assert ds.sizes["longitude"] == gfs.GFS_N_LON
|
| 93 |
+
assert ds.sizes["level"] == len(GRAPHCAST_PRESSURE_LEVELS)
|
| 94 |
+
assert ds.sizes["time"] >= 3 # prev cycle t0, current t0, +6h forecast
|
| 95 |
+
|
| 96 |
+
# Variable coverage — at least 2m_temp + one pressure-level must load.
|
| 97 |
+
assert "2m_temperature" in ds.data_vars
|
| 98 |
+
assert "temperature" in ds.data_vars
|
| 99 |
+
|
| 100 |
+
# Physical plausibility on a Kerala grid cell.
|
| 101 |
+
t2m = ds["2m_temperature"].sel(
|
| 102 |
+
latitude=8.5, longitude=77.0, method="nearest"
|
| 103 |
+
).values
|
| 104 |
+
# Kelvin; South India is never < 273 K (0 °C) even at night.
|
| 105 |
+
assert (t2m > 273).all() and (t2m < 320).all(), (
|
| 106 |
+
f"2m_temperature at Kerala grid cell {t2m} out of plausible K range"
|
| 107 |
+
)
|
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shape + interface tests for the GFS init source.
|
| 2 |
+
|
| 3 |
+
These tests use a synthetic xarray.Dataset built by
|
| 4 |
+
``gfs.build_synthetic_gfs_dataset`` — they never hit the network and do not
|
| 5 |
+
require cfgrib/eccodes. The assertion surface is the SAME set of downstream
|
| 6 |
+
operations that ``graphcast_client._fetch_era5_sync`` and
|
| 7 |
+
``gencast_client._prepare_era5_inputs`` perform on the ERA5 ``full_ds``:
|
| 8 |
+
|
| 9 |
+
* ``.time.sel(time=X, method='nearest')``
|
| 10 |
+
* ``.sel(time=[t0, t1])``
|
| 11 |
+
* variable access by ERA5 canonical name
|
| 12 |
+
* ``.sel(level=[...])`` on pressure-level vars
|
| 13 |
+
* ``.compute()`` — even if the dataset isn't Dask-backed, existing code
|
| 14 |
+
still calls it, so the Dataset must accept it.
|
| 15 |
+
|
| 16 |
+
If Phase 2 wires this module into the pipeline and anything here breaks,
|
| 17 |
+
we'd have a merge/rename bug. These tests catch that before the Space run.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
from datetime import datetime, timedelta, timezone
|
| 23 |
+
|
| 24 |
+
import numpy as np
|
| 25 |
+
import pytest
|
| 26 |
+
import xarray
|
| 27 |
+
|
| 28 |
+
from src.init_sources import gfs
|
| 29 |
+
from src.init_sources.variable_mapping import (
|
| 30 |
+
GRAPHCAST_PRESSURE_LEVELS,
|
| 31 |
+
PRESSURE_LEVEL_VARS,
|
| 32 |
+
STATIC_VARS,
|
| 33 |
+
SURFACE_VARS,
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _make_timesteps(n: int = 4, step_hours: int = 6):
|
| 38 |
+
t0 = datetime(2026, 4, 21, 12, 0, tzinfo=timezone.utc)
|
| 39 |
+
return [t0 + timedelta(hours=i * step_hours) for i in range(n)]
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _small_grid_dataset(include_static=True):
|
| 43 |
+
"""Build a small-grid synthetic dataset (fast tests)."""
|
| 44 |
+
lat_vals = np.linspace(14.0, 8.0, 25, dtype=np.float32) # Kerala band
|
| 45 |
+
lon_vals = np.linspace(74.0, 80.0, 25, dtype=np.float32) # TN band
|
| 46 |
+
return gfs.build_synthetic_gfs_dataset(
|
| 47 |
+
timesteps=_make_timesteps(n=4),
|
| 48 |
+
lat_vals=lat_vals,
|
| 49 |
+
lon_vals=lon_vals,
|
| 50 |
+
include_static=include_static,
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class TestSyntheticDatasetShape:
|
| 55 |
+
def test_returns_xarray_dataset(self):
|
| 56 |
+
ds = _small_grid_dataset()
|
| 57 |
+
assert isinstance(ds, xarray.Dataset)
|
| 58 |
+
|
| 59 |
+
def test_has_expected_dims(self):
|
| 60 |
+
ds = _small_grid_dataset()
|
| 61 |
+
for d in ("time", "level", "latitude", "longitude"):
|
| 62 |
+
assert d in ds.dims, f"missing dim {d!r}"
|
| 63 |
+
|
| 64 |
+
def test_time_dim_has_4_steps(self):
|
| 65 |
+
ds = _small_grid_dataset()
|
| 66 |
+
assert ds.sizes["time"] == 4
|
| 67 |
+
|
| 68 |
+
def test_level_dim_matches_graphcast(self):
|
| 69 |
+
ds = _small_grid_dataset()
|
| 70 |
+
assert ds.sizes["level"] == len(GRAPHCAST_PRESSURE_LEVELS)
|
| 71 |
+
np.testing.assert_array_equal(
|
| 72 |
+
ds["level"].values,
|
| 73 |
+
np.asarray(GRAPHCAST_PRESSURE_LEVELS, dtype=np.int32),
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
class TestVariableCoverage:
|
| 78 |
+
def test_all_surface_vars_present(self):
|
| 79 |
+
ds = _small_grid_dataset()
|
| 80 |
+
for era5_name in SURFACE_VARS:
|
| 81 |
+
assert era5_name in ds.data_vars, f"missing surface var {era5_name!r}"
|
| 82 |
+
|
| 83 |
+
def test_all_pressure_level_vars_present(self):
|
| 84 |
+
ds = _small_grid_dataset()
|
| 85 |
+
for era5_name in PRESSURE_LEVEL_VARS:
|
| 86 |
+
assert era5_name in ds.data_vars, f"missing pressure var {era5_name!r}"
|
| 87 |
+
|
| 88 |
+
def test_static_vars_attached_when_requested(self):
|
| 89 |
+
ds = _small_grid_dataset(include_static=True)
|
| 90 |
+
for s in STATIC_VARS:
|
| 91 |
+
assert s in ds.data_vars, (
|
| 92 |
+
f"static var {s!r} must be attached when include_static=True — "
|
| 93 |
+
"GraphCast's task_config lists it in input_variables"
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
def test_static_vars_omitted_when_not_requested(self):
|
| 97 |
+
ds = _small_grid_dataset(include_static=False)
|
| 98 |
+
for s in STATIC_VARS:
|
| 99 |
+
assert s not in ds.data_vars
|
| 100 |
+
|
| 101 |
+
def test_pressure_level_vars_have_level_dim(self):
|
| 102 |
+
ds = _small_grid_dataset()
|
| 103 |
+
for era5_name in PRESSURE_LEVEL_VARS:
|
| 104 |
+
assert "level" in ds[era5_name].dims, (
|
| 105 |
+
f"{era5_name!r} must carry the level dim — downstream "
|
| 106 |
+
".sel(level=[...]) will otherwise raise"
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
def test_surface_vars_have_no_level_dim(self):
|
| 110 |
+
ds = _small_grid_dataset()
|
| 111 |
+
for era5_name in SURFACE_VARS:
|
| 112 |
+
assert "level" not in ds[era5_name].dims, (
|
| 113 |
+
f"{era5_name!r} must NOT carry the level dim — it's a "
|
| 114 |
+
"single-level surface field"
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
def test_static_vars_have_no_time_or_level_dim(self):
|
| 118 |
+
ds = _small_grid_dataset()
|
| 119 |
+
for s in STATIC_VARS:
|
| 120 |
+
assert "time" not in ds[s].dims
|
| 121 |
+
assert "level" not in ds[s].dims
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
class TestDownstreamCompatibility:
|
| 125 |
+
"""Exercise the same xarray operations ``_fetch_era5_sync`` runs
|
| 126 |
+
against the ERA5 ``full_ds``. If any of these break, Phase 2 wiring
|
| 127 |
+
would blow up at runtime — we catch them here first.
|
| 128 |
+
"""
|
| 129 |
+
|
| 130 |
+
def test_time_sel_nearest(self):
|
| 131 |
+
ds = _small_grid_dataset()
|
| 132 |
+
# existing code: ``full_ds.time.sel(time=target - 6h, method='nearest')``
|
| 133 |
+
target = np.datetime64("2026-04-21T12:00")
|
| 134 |
+
t1 = ds.time.sel(time=target, method="nearest").values
|
| 135 |
+
assert t1 is not None
|
| 136 |
+
|
| 137 |
+
def test_time_sel_list_of_two_timesteps(self):
|
| 138 |
+
ds = _small_grid_dataset()
|
| 139 |
+
# existing code: ``full_ds[dynamic_available].sel(time=input_times)``
|
| 140 |
+
input_times = ds.time.values[:2].tolist()
|
| 141 |
+
sub = ds.sel(time=input_times)
|
| 142 |
+
assert sub.sizes["time"] == 2
|
| 143 |
+
|
| 144 |
+
def test_level_subset_selection(self):
|
| 145 |
+
ds = _small_grid_dataset()
|
| 146 |
+
# existing code: ``full_ds[dynamic_available].sel(time=..., level=sel_levels)``
|
| 147 |
+
sel = ds["temperature"].sel(level=[500, 850, 1000])
|
| 148 |
+
assert sel.sizes["level"] == 3
|
| 149 |
+
|
| 150 |
+
def test_compute_is_callable_even_on_non_dask_dataset(self):
|
| 151 |
+
ds = _small_grid_dataset()
|
| 152 |
+
# existing code: ``ds_input = full_ds[...].sel(...).compute()``.
|
| 153 |
+
computed = ds[["2m_temperature"]].sel(time=ds.time.values[0]).compute()
|
| 154 |
+
assert "2m_temperature" in computed.data_vars
|
| 155 |
+
|
| 156 |
+
def test_isnull_all_returns_bool_like(self):
|
| 157 |
+
ds = _small_grid_dataset()
|
| 158 |
+
# existing code: ``bool(ds_input[var].isnull().all().item())``
|
| 159 |
+
all_nan = ds["2m_temperature"].isnull().all().item()
|
| 160 |
+
assert isinstance(all_nan, (bool, np.bool_))
|
| 161 |
+
assert all_nan is False or all_nan == 0 # synthetic data has no NaN
|
| 162 |
+
|
| 163 |
+
def test_point_nearest_sel(self):
|
| 164 |
+
ds = _small_grid_dataset()
|
| 165 |
+
# existing code in _extract_station_forecasts:
|
| 166 |
+
# point = predictions.sel(lat=station.lat, lon=station.lon,
|
| 167 |
+
# method='nearest')
|
| 168 |
+
# except the returned ds uses 'latitude'/'longitude' (ERA5 names).
|
| 169 |
+
# graphcast_client renames to lat/lon later via rename_map. We verify
|
| 170 |
+
# both names work here so the rename step succeeds.
|
| 171 |
+
kerala = ds.sel(latitude=8.5, longitude=76.95, method="nearest")
|
| 172 |
+
assert kerala["2m_temperature"].size == ds.sizes["time"]
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
class TestGfsCycle:
|
| 176 |
+
def test_datetime_utc_is_utc_aware(self):
|
| 177 |
+
cyc = gfs.GfsCycle(date="2026-04-21", hour=12)
|
| 178 |
+
dt = cyc.datetime_utc()
|
| 179 |
+
assert dt.tzinfo is not None
|
| 180 |
+
assert dt.year == 2026 and dt.month == 4 and dt.day == 21
|
| 181 |
+
assert dt.hour == 12
|
| 182 |
+
|
| 183 |
+
def test_s3_prefix_format(self):
|
| 184 |
+
cyc = gfs.GfsCycle(date="2026-04-21", hour=6)
|
| 185 |
+
assert cyc.s3_prefix() == "gfs.20260421/06/atmos"
|
| 186 |
+
|
| 187 |
+
def test_file_name_analysis(self):
|
| 188 |
+
cyc = gfs.GfsCycle(date="2026-04-21", hour=12)
|
| 189 |
+
# f000 is the analysis (t=0 of the cycle).
|
| 190 |
+
assert cyc.file_name(0) == "gfs.t12z.pgrb2.0p25.f000"
|
| 191 |
+
|
| 192 |
+
def test_file_name_forecast_6h(self):
|
| 193 |
+
cyc = gfs.GfsCycle(date="2026-04-21", hour=12)
|
| 194 |
+
assert cyc.file_name(6) == "gfs.t12z.pgrb2.0p25.f006"
|
| 195 |
+
|
| 196 |
+
def test_file_name_forecast_168h(self):
|
| 197 |
+
cyc = gfs.GfsCycle(date="2026-04-21", hour=12)
|
| 198 |
+
# 7-day forecast — end of GenCast's horizon.
|
| 199 |
+
assert cyc.file_name(168) == "gfs.t12z.pgrb2.0p25.f168"
|
| 200 |
+
|
| 201 |
+
def test_s3_key_composition(self):
|
| 202 |
+
cyc = gfs.GfsCycle(date="2026-04-21", hour=12)
|
| 203 |
+
assert cyc.s3_key(6) == "gfs.20260421/12/atmos/gfs.t12z.pgrb2.0p25.f006"
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
class TestCycleSelection:
|
| 207 |
+
def test_default_cycle_is_12z(self):
|
| 208 |
+
cyc = gfs._most_recent_cycle("2026-04-21")
|
| 209 |
+
assert cyc.hour == 12
|
| 210 |
+
|
| 211 |
+
def test_explicit_cycle_hour_respected(self):
|
| 212 |
+
cyc = gfs._most_recent_cycle("2026-04-21", target_cycle_hour=0)
|
| 213 |
+
assert cyc.hour == 0
|
| 214 |
+
|
| 215 |
+
def test_non_canonical_cycle_hour_snaps_down(self):
|
| 216 |
+
# Someone passes 9 UTC — GFS only runs 00/06/12/18, so we expect
|
| 217 |
+
# the preceding 06Z cycle.
|
| 218 |
+
cyc = gfs._most_recent_cycle("2026-04-21", target_cycle_hour=9)
|
| 219 |
+
assert cyc.hour == 6
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
class TestPhysicalPlausibility:
|
| 223 |
+
def test_synthetic_temperature_is_kelvin_valued(self):
|
| 224 |
+
ds = _small_grid_dataset()
|
| 225 |
+
# The synthetic fixture ships 300 K everywhere. Convert a few points
|
| 226 |
+
# to Celsius and sanity-check the sign.
|
| 227 |
+
t_c = ds["2m_temperature"].values - 273.15
|
| 228 |
+
# 300 K → 26.85 °C; inside the plausible Earth-surface range.
|
| 229 |
+
assert np.all(t_c > -50) and np.all(t_c < 60)
|
| 230 |
+
|
| 231 |
+
def test_synthetic_mslp_in_physical_range_pa(self):
|
| 232 |
+
ds = _small_grid_dataset()
|
| 233 |
+
mslp = ds["mean_sea_level_pressure"].values
|
| 234 |
+
# Earth MSLP lives in ~87000-108000 Pa. Synthetic fixture uses 101325.
|
| 235 |
+
assert np.all(mslp > 80_000) and np.all(mslp < 110_000)
|
| 236 |
+
|
| 237 |
+
def test_synthetic_precipitation_non_negative_in_metres(self):
|
| 238 |
+
ds = _small_grid_dataset()
|
| 239 |
+
tp = ds["total_precipitation_6hr"].values
|
| 240 |
+
assert np.all(tp >= 0.0)
|
| 241 |
+
# After the mm→m conversion, a realistic 6h max ≤ ~0.2 m (200mm).
|
| 242 |
+
assert np.all(tp < 1.0)
|
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the static-variable loader + synthetic builder."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
import pytest
|
| 10 |
+
import xarray
|
| 11 |
+
|
| 12 |
+
from src.init_sources import static_vars
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class TestSyntheticStaticDS:
|
| 16 |
+
def test_has_both_static_vars(self):
|
| 17 |
+
ds = static_vars.build_synthetic_static_ds()
|
| 18 |
+
for name in static_vars.STATIC_VARS_NEEDED:
|
| 19 |
+
assert name in ds.data_vars
|
| 20 |
+
|
| 21 |
+
def test_full_0p25_grid_shape(self):
|
| 22 |
+
ds = static_vars.build_synthetic_static_ds()
|
| 23 |
+
assert ds.sizes["latitude"] == 721
|
| 24 |
+
assert ds.sizes["longitude"] == 1440
|
| 25 |
+
|
| 26 |
+
def test_small_grid_shape(self):
|
| 27 |
+
lat = np.linspace(14.0, 8.0, 25, dtype=np.float32)
|
| 28 |
+
lon = np.linspace(74.0, 80.0, 25, dtype=np.float32)
|
| 29 |
+
ds = static_vars.build_synthetic_static_ds(lat_vals=lat, lon_vals=lon)
|
| 30 |
+
assert ds.sizes["latitude"] == 25
|
| 31 |
+
assert ds.sizes["longitude"] == 25
|
| 32 |
+
|
| 33 |
+
def test_india_region_marked_as_land(self):
|
| 34 |
+
ds = static_vars.build_synthetic_static_ds()
|
| 35 |
+
lsm_india = ds["land_sea_mask"].sel(
|
| 36 |
+
latitude=slice(14.0, 8.0), longitude=slice(74.0, 80.0)
|
| 37 |
+
)
|
| 38 |
+
# At least some India grid cells are land — sanity check the synthetic
|
| 39 |
+
# setup, not a climatological claim.
|
| 40 |
+
assert (lsm_india.values > 0).any()
|
| 41 |
+
|
| 42 |
+
def test_static_ds_has_no_time_or_level_dims(self):
|
| 43 |
+
ds = static_vars.build_synthetic_static_ds()
|
| 44 |
+
assert "time" not in ds.dims
|
| 45 |
+
assert "level" not in ds.dims
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class TestCachePathResolution:
|
| 49 |
+
def test_default_path_is_inside_repo(self):
|
| 50 |
+
# The default resolves to data/init_sources/era5_static.nc under the
|
| 51 |
+
# project root. Phase 2 will build it on first use.
|
| 52 |
+
p = static_vars.cache_path()
|
| 53 |
+
assert p.endswith(os.path.join("data", "init_sources", "era5_static.nc")), p
|
| 54 |
+
|
| 55 |
+
def test_env_var_override(self, tmp_path, monkeypatch):
|
| 56 |
+
override = str(tmp_path / "static_override.nc")
|
| 57 |
+
monkeypatch.setenv("GFS_STATIC_CACHE", override)
|
| 58 |
+
assert static_vars.cache_path() == override
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class TestLoadStaticDS:
|
| 62 |
+
def test_raises_helpfully_when_cache_missing(self, tmp_path, monkeypatch):
|
| 63 |
+
monkeypatch.setenv("GFS_STATIC_CACHE", str(tmp_path / "nope.nc"))
|
| 64 |
+
with pytest.raises(FileNotFoundError):
|
| 65 |
+
static_vars.load_static_ds()
|
| 66 |
+
|
| 67 |
+
def test_loads_a_round_tripped_cache(self, tmp_path, monkeypatch):
|
| 68 |
+
# Build a synthetic cache, write to disk, verify the loader reads it.
|
| 69 |
+
cache_path = tmp_path / "fake_static.nc"
|
| 70 |
+
monkeypatch.setenv("GFS_STATIC_CACHE", str(cache_path))
|
| 71 |
+
|
| 72 |
+
ds = static_vars.build_synthetic_static_ds(
|
| 73 |
+
lat_vals=np.array([10.0, 0.0], dtype=np.float32),
|
| 74 |
+
lon_vals=np.array([77.0, 80.0], dtype=np.float32),
|
| 75 |
+
)
|
| 76 |
+
ds.to_netcdf(cache_path)
|
| 77 |
+
|
| 78 |
+
loaded = static_vars.load_static_ds()
|
| 79 |
+
for name in static_vars.STATIC_VARS_NEEDED:
|
| 80 |
+
assert name in loaded.data_vars
|
| 81 |
+
np.testing.assert_array_equal(
|
| 82 |
+
loaded["land_sea_mask"].values, ds["land_sea_mask"].values
|
| 83 |
+
)
|
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the GFS ↔ ERA5 variable mapping tables.
|
| 2 |
+
|
| 3 |
+
These are pure-data tests — no network, no cfgrib, no xarray I/O. They
|
| 4 |
+
protect against accidental breakage of the lookup tables that the GFS
|
| 5 |
+
fetcher in ``src/init_sources/gfs.py`` depends on.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
import pytest
|
| 12 |
+
|
| 13 |
+
from src.init_sources import variable_mapping as vm
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class TestSurfaceVarsLookup:
|
| 17 |
+
def test_all_surface_vars_return_short_name_and_scale(self):
|
| 18 |
+
for era5_name, (short, (scale, offset)) in vm.SURFACE_VARS.items():
|
| 19 |
+
assert isinstance(era5_name, str) and era5_name
|
| 20 |
+
assert isinstance(short, str) and short
|
| 21 |
+
assert isinstance(scale, float)
|
| 22 |
+
assert isinstance(offset, float)
|
| 23 |
+
|
| 24 |
+
def test_expected_surface_vars_present(self):
|
| 25 |
+
# These 5 surface fields are load-bearing for GraphCast init.
|
| 26 |
+
expected = {
|
| 27 |
+
"2m_temperature", "10m_u_component_of_wind",
|
| 28 |
+
"10m_v_component_of_wind", "mean_sea_level_pressure",
|
| 29 |
+
"total_precipitation_6hr",
|
| 30 |
+
}
|
| 31 |
+
assert expected <= set(vm.SURFACE_VARS.keys()), (
|
| 32 |
+
"Missing surface vars — GraphCast will refuse to init without them"
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
def test_precipitation_converts_mm_to_m(self):
|
| 36 |
+
# GFS ``tp`` is kg/m² (≡ mm of liquid water); ERA5 is m.
|
| 37 |
+
# 12 mm of rain → 0.012 m.
|
| 38 |
+
scale, offset = vm.SURFACE_VARS["total_precipitation_6hr"][1]
|
| 39 |
+
assert scale == pytest.approx(1e-3)
|
| 40 |
+
assert offset == 0.0
|
| 41 |
+
|
| 42 |
+
def test_temperature_is_already_kelvin(self):
|
| 43 |
+
# Both GFS and ERA5 ship t2m in Kelvin — no conversion.
|
| 44 |
+
scale, offset = vm.SURFACE_VARS["2m_temperature"][1]
|
| 45 |
+
assert scale == 1.0
|
| 46 |
+
assert offset == 0.0
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class TestPressureLevelVarsLookup:
|
| 50 |
+
def test_geopotential_converts_gpm_to_m2s2(self):
|
| 51 |
+
# GFS `gh` is geopotential HEIGHT in geopotential metres; ERA5
|
| 52 |
+
# stores geopotential (height × g) in m²/s². Check the scale is g.
|
| 53 |
+
scale, offset = vm.PRESSURE_LEVEL_VARS["geopotential"][1]
|
| 54 |
+
assert scale == pytest.approx(9.80665)
|
| 55 |
+
assert offset == 0.0
|
| 56 |
+
|
| 57 |
+
def test_six_pressure_level_variables_present(self):
|
| 58 |
+
expected = {
|
| 59 |
+
"temperature", "specific_humidity", "u_component_of_wind",
|
| 60 |
+
"v_component_of_wind", "vertical_velocity", "geopotential",
|
| 61 |
+
}
|
| 62 |
+
assert expected <= set(vm.PRESSURE_LEVEL_VARS.keys())
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class TestPressureLevelsSelection:
|
| 66 |
+
def test_graphcast_has_13_canonical_levels(self):
|
| 67 |
+
assert len(vm.GRAPHCAST_PRESSURE_LEVELS) == 13
|
| 68 |
+
|
| 69 |
+
def test_levels_in_descending_model_atmosphere(self):
|
| 70 |
+
# GraphCast and GenCast list their levels ascending in pressure
|
| 71 |
+
# (top of atmosphere → surface). 50 .. 1000 hPa strictly increasing.
|
| 72 |
+
levels = list(vm.GRAPHCAST_PRESSURE_LEVELS)
|
| 73 |
+
assert levels == sorted(levels), "levels must be ascending pressure"
|
| 74 |
+
assert min(levels) >= 50, "lowest level must be ≥ 50 hPa"
|
| 75 |
+
assert max(levels) <= 1000, "highest level must be ≤ 1000 hPa"
|
| 76 |
+
|
| 77 |
+
def test_levels_available_in_gfs_pgrb2_native(self):
|
| 78 |
+
# GFS pgrb2.0p25 natively provides these levels. If any GraphCast
|
| 79 |
+
# level isn't in this list, the select-to-13 step would need to
|
| 80 |
+
# interpolate instead of just subsetting.
|
| 81 |
+
gfs_native = {
|
| 82 |
+
10, 20, 30, 40, 50, 70, 100, 150, 200, 250, 300, 350, 400, 450,
|
| 83 |
+
500, 550, 600, 650, 700, 750, 800, 850, 900, 925, 950, 975, 1000,
|
| 84 |
+
}
|
| 85 |
+
assert set(vm.GRAPHCAST_PRESSURE_LEVELS) <= gfs_native
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class TestHelpers:
|
| 89 |
+
def test_gfs_short_name_surface(self):
|
| 90 |
+
assert vm.gfs_short_name("2m_temperature") == "t2m"
|
| 91 |
+
assert vm.gfs_short_name("mean_sea_level_pressure") == "prmsl"
|
| 92 |
+
|
| 93 |
+
def test_gfs_short_name_pressure_level(self):
|
| 94 |
+
assert vm.gfs_short_name("temperature") == "t"
|
| 95 |
+
assert vm.gfs_short_name("geopotential") == "gh"
|
| 96 |
+
|
| 97 |
+
def test_gfs_short_name_raises_on_unknown(self):
|
| 98 |
+
with pytest.raises(KeyError):
|
| 99 |
+
vm.gfs_short_name("not_a_variable")
|
| 100 |
+
|
| 101 |
+
def test_unit_convert_temperature_is_identity(self):
|
| 102 |
+
xs = np.array([270.0, 290.0, 310.0], dtype=np.float32)
|
| 103 |
+
ys = vm.unit_convert("2m_temperature", xs)
|
| 104 |
+
np.testing.assert_allclose(ys, xs)
|
| 105 |
+
|
| 106 |
+
def test_unit_convert_precipitation_mm_to_m(self):
|
| 107 |
+
mm = np.array([0.0, 5.0, 12.5], dtype=np.float32)
|
| 108 |
+
meters = vm.unit_convert("total_precipitation_6hr", mm)
|
| 109 |
+
np.testing.assert_allclose(meters, [0.0, 0.005, 0.0125], rtol=1e-5)
|
| 110 |
+
|
| 111 |
+
def test_unit_convert_geopotential_applies_g(self):
|
| 112 |
+
gpm = np.array([0.0, 5000.0, 10_000.0], dtype=np.float32)
|
| 113 |
+
m2s2 = vm.unit_convert("geopotential", gpm)
|
| 114 |
+
np.testing.assert_allclose(
|
| 115 |
+
m2s2, [0.0, 5000.0 * 9.80665, 10_000.0 * 9.80665], rtol=1e-6
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
def test_all_era5_names_union(self):
|
| 119 |
+
names = vm.all_era5_names()
|
| 120 |
+
assert set(names) == set(vm.SURFACE_VARS) | set(vm.PRESSURE_LEVEL_VARS)
|