Fix GFS shortName regression: use ecCodes names, not cfgrib names
Browse filesMirror of the WA2 fix — same bug, same change, synced to stay in lockstep
until init_sources is extracted to a shared package.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
src/init_sources/gfs.py
CHANGED
|
@@ -144,30 +144,48 @@ def _download_grib2(
|
|
| 144 |
# GRIB parsing
|
| 145 |
# ---------------------------------------------------------------------------
|
| 146 |
|
| 147 |
-
def _open_grib_surface(path: str,
|
| 148 |
"""Return an xarray.DataArray for a single surface variable from GRIB2.
|
| 149 |
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
``
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
"""
|
| 154 |
import xarray
|
| 155 |
-
#
|
| 156 |
-
#
|
| 157 |
-
# ``meanSea``, and ``tp`` at ``surface``.
|
| 158 |
level_map = {
|
| 159 |
-
"
|
| 160 |
-
"
|
| 161 |
-
"
|
| 162 |
"prmsl": ("meanSea", 0),
|
| 163 |
"tp": ("surface", 0),
|
| 164 |
}
|
| 165 |
-
type_of_level, level = level_map.get(
|
| 166 |
-
filters = {"typeOfLevel": type_of_level, "shortName":
|
| 167 |
if type_of_level == "heightAboveGround":
|
| 168 |
filters["level"] = level
|
| 169 |
-
|
| 170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
|
| 172 |
|
| 173 |
def _open_grib_pressure_levels(
|
|
|
|
| 144 |
# GRIB parsing
|
| 145 |
# ---------------------------------------------------------------------------
|
| 146 |
|
| 147 |
+
def _open_grib_surface(path: str, ecmwf_short_name: str) -> Any:
|
| 148 |
"""Return an xarray.DataArray for a single surface variable from GRIB2.
|
| 149 |
|
| 150 |
+
``ecmwf_short_name`` is the ecCodes shortName (e.g. ``2t``, ``10u``,
|
| 151 |
+
``prmsl``, ``tp``) — what GRIB messages are indexed by, NOT cfgrib's
|
| 152 |
+
output variable name (e.g. ``t2m``, ``u10``). cfgrib's
|
| 153 |
+
``filter_by_keys`` takes ecCodes names; getting this wrong returns an
|
| 154 |
+
empty dataset silently, which was the Phase-2-take-1 failure.
|
| 155 |
+
|
| 156 |
+
The filter is typeOfLevel + shortName (+ level for heightAboveGround).
|
| 157 |
+
After filtering there's exactly one message remaining, so we return the
|
| 158 |
+
first data_var regardless of cfgrib's rename.
|
| 159 |
"""
|
| 160 |
import xarray
|
| 161 |
+
# GFS stores ``2t``/``10u``/``10v`` at ``heightAboveGround`` (2m and
|
| 162 |
+
# 10m), ``prmsl`` at ``meanSea``, and ``tp`` at ``surface``.
|
|
|
|
| 163 |
level_map = {
|
| 164 |
+
"2t": ("heightAboveGround", 2),
|
| 165 |
+
"10u": ("heightAboveGround", 10),
|
| 166 |
+
"10v": ("heightAboveGround", 10),
|
| 167 |
"prmsl": ("meanSea", 0),
|
| 168 |
"tp": ("surface", 0),
|
| 169 |
}
|
| 170 |
+
type_of_level, level = level_map.get(ecmwf_short_name, ("surface", 0))
|
| 171 |
+
filters = {"typeOfLevel": type_of_level, "shortName": ecmwf_short_name}
|
| 172 |
if type_of_level == "heightAboveGround":
|
| 173 |
filters["level"] = level
|
| 174 |
+
ds = xarray.open_dataset(
|
| 175 |
+
path, engine="cfgrib",
|
| 176 |
+
backend_kwargs={"filter_by_keys": filters},
|
| 177 |
+
)
|
| 178 |
+
names = list(ds.data_vars)
|
| 179 |
+
if not names:
|
| 180 |
+
raise RuntimeError(
|
| 181 |
+
f"cfgrib returned 0 variables for shortName={ecmwf_short_name!r}, "
|
| 182 |
+
f"typeOfLevel={type_of_level!r}. Filter likely wrong — check "
|
| 183 |
+
f"ecCodes naming (2t vs t2m, 10u vs u10, etc.)."
|
| 184 |
+
)
|
| 185 |
+
if len(names) != 1:
|
| 186 |
+
log.warning("Multiple data vars (%s) after GRIB filter for %s; "
|
| 187 |
+
"using first", names, ecmwf_short_name)
|
| 188 |
+
return ds[names[0]]
|
| 189 |
|
| 190 |
|
| 191 |
def _open_grib_pressure_levels(
|
src/init_sources/variable_mapping.py
CHANGED
|
@@ -36,10 +36,20 @@ from typing import Dict, Tuple
|
|
| 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": ("
|
| 41 |
-
"10m_u_component_of_wind": ("
|
| 42 |
-
"10m_v_component_of_wind": ("
|
| 43 |
"mean_sea_level_pressure": ("prmsl", (1.0, 0.0)),
|
| 44 |
"total_precipitation_6hr": ("tp", (1e-3, 0.0)), # mm → m
|
| 45 |
}
|
|
|
|
| 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 |
+
#
|
| 40 |
+
# ``ecCodes`` shortName differs from cfgrib's output variable name for a
|
| 41 |
+
# handful of surface fields. The GRIB *message* is indexed by the ecCodes
|
| 42 |
+
# shortName (``2t``, ``10u``, ``10v``, ``prmsl``, ``tp``) which is what
|
| 43 |
+
# ``filter_by_keys`` takes. When cfgrib materialises the filtered message as
|
| 44 |
+
# an xarray variable, it uses its own naming (``t2m``, ``u10``, ``v10``,
|
| 45 |
+
# ``prmsl``, ``tp``). We carry the ecCodes shortName here because the filter
|
| 46 |
+
# is load-bearing — get this wrong and filter_by_keys returns an empty
|
| 47 |
+
# dataset, which was the Phase-2-take-1 failure mode.
|
| 48 |
+
|
| 49 |
SURFACE_VARS: Dict[str, Tuple[str, Tuple[float, float]]] = {
|
| 50 |
+
"2m_temperature": ("2t", (1.0, 0.0)),
|
| 51 |
+
"10m_u_component_of_wind": ("10u", (1.0, 0.0)),
|
| 52 |
+
"10m_v_component_of_wind": ("10v", (1.0, 0.0)),
|
| 53 |
"mean_sea_level_pressure": ("prmsl", (1.0, 0.0)),
|
| 54 |
"total_precipitation_6hr": ("tp", (1e-3, 0.0)), # mm → m
|
| 55 |
}
|
tests/test_init_sources/test_variable_mapping.py
CHANGED
|
@@ -45,6 +45,25 @@ class TestSurfaceVarsLookup:
|
|
| 45 |
assert scale == 1.0
|
| 46 |
assert offset == 0.0
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
class TestPressureLevelVarsLookup:
|
| 50 |
def test_geopotential_converts_gpm_to_m2s2(self):
|
|
@@ -87,7 +106,8 @@ class TestPressureLevelsSelection:
|
|
| 87 |
|
| 88 |
class TestHelpers:
|
| 89 |
def test_gfs_short_name_surface(self):
|
| 90 |
-
|
|
|
|
| 91 |
assert vm.gfs_short_name("mean_sea_level_pressure") == "prmsl"
|
| 92 |
|
| 93 |
def test_gfs_short_name_pressure_level(self):
|
|
|
|
| 45 |
assert scale == 1.0
|
| 46 |
assert offset == 0.0
|
| 47 |
|
| 48 |
+
def test_surface_short_names_are_eccodes_not_cfgrib(self):
|
| 49 |
+
# Phase 2 regression: cfgrib's ``filter_by_keys`` takes the ecCodes
|
| 50 |
+
# shortName (``2t``, ``10u``, ``10v``, etc.), NOT cfgrib's output
|
| 51 |
+
# xarray variable name (``t2m``, ``u10``, ``v10``). Getting this
|
| 52 |
+
# wrong returns an empty Dataset silently — the first smoke run
|
| 53 |
+
# burned ~$3 of A100 discovering it.
|
| 54 |
+
expected_eccodes_names = {
|
| 55 |
+
"2m_temperature": "2t",
|
| 56 |
+
"10m_u_component_of_wind": "10u",
|
| 57 |
+
"10m_v_component_of_wind": "10v",
|
| 58 |
+
"mean_sea_level_pressure": "prmsl",
|
| 59 |
+
"total_precipitation_6hr": "tp",
|
| 60 |
+
}
|
| 61 |
+
for era5_name, eccodes in expected_eccodes_names.items():
|
| 62 |
+
assert vm.SURFACE_VARS[era5_name][0] == eccodes, (
|
| 63 |
+
f"{era5_name!r}: must use ecCodes shortName "
|
| 64 |
+
f"{eccodes!r}, not cfgrib output name"
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
|
| 68 |
class TestPressureLevelVarsLookup:
|
| 69 |
def test_geopotential_converts_gpm_to_m2s2(self):
|
|
|
|
| 106 |
|
| 107 |
class TestHelpers:
|
| 108 |
def test_gfs_short_name_surface(self):
|
| 109 |
+
# ecCodes shortName convention — see test_surface_short_names_are_eccodes_not_cfgrib.
|
| 110 |
+
assert vm.gfs_short_name("2m_temperature") == "2t"
|
| 111 |
assert vm.gfs_short_name("mean_sea_level_pressure") == "prmsl"
|
| 112 |
|
| 113 |
def test_gfs_short_name_pressure_level(self):
|