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

Fix GFS shortName regression: use ecCodes names, not cfgrib names

Browse files

Mirror 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, 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(
 
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": ("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
  }
 
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
- 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):
 
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):