jtlevine Claude Opus 4.7 (1M context) commited on
Commit
a3a194e
·
1 Parent(s): 64c4f2a

Add GFS init source as alternative to ERA5T (Phase 2 wiring)

Browse files

Mirrors 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 CHANGED
@@ -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)
pytest.ini ADDED
@@ -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
requirements.txt CHANGED
@@ -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
src/init_sources/__init__.py ADDED
@@ -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
src/init_sources/gfs.py ADDED
@@ -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
src/init_sources/static_vars.py ADDED
@@ -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
+ )
src/init_sources/variable_mapping.py ADDED
@@ -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())
src/pipeline.py CHANGED
@@ -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
- init_date = (datetime.utcnow() - timedelta(days=5)).strftime("%Y-%m-%d")
 
 
 
 
 
 
 
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(
src/prediction/graphcast_inference.py CHANGED
@@ -250,10 +250,18 @@ def fetch_era5_for_graphcast(
250
  import xarray
251
  from graphcast import data_utils
252
 
253
- log.info("Opening ARCO ERA5 Zarr for %s...", target_date)
254
- gcs_opts = {"token": "anon"}
255
- full_ds = xarray.open_zarr(
256
- ERA5_PATH, chunks=None, storage_options=gcs_opts, consolidated=True)
 
 
 
 
 
 
 
 
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")
tests/test_init_sources/__init__.py ADDED
File without changes
tests/test_init_sources/test_gfs_live.py ADDED
@@ -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
+ )
tests/test_init_sources/test_gfs_shape.py ADDED
@@ -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)
tests/test_init_sources/test_static_vars.py ADDED
@@ -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
+ )
tests/test_init_sources/test_variable_mapping.py ADDED
@@ -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)