From 0f662096c7ea8959b84a59f9d70e319b9ed33dd4 Mon Sep 17 00:00:00 2001 From: minereda <84080887+minereda@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:06:49 +0200 Subject: [PATCH 1/5] feat(forecast_nwp): expose member= ensemble selector for GEFS/CFS (#74) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add keyword-only member: str | None = None to the weather impl - Validate member EARLY (pre-[nwp]-import): reject on non-member models naming gefs/cfs; reject out-of-enum values listing sorted valid members - Thread member to build_fetch_plan via per_model_kwargs only when non-None (member=None stays byte-identical — never overrides the path-builder default) - Multi-cycle recursion forwards member to each single-cycle call - TDD: TestForecastNwpMember (A-F) written RED first, then GREEN Co-Authored-By: Claude Fable 5 --- .../src/mostlyright/weather/forecast_nwp.py | 54 ++++- packages/weather/tests/test_forecast_nwp.py | 212 ++++++++++++++++++ 2 files changed, 264 insertions(+), 2 deletions(-) diff --git a/packages/weather/src/mostlyright/weather/forecast_nwp.py b/packages/weather/src/mostlyright/weather/forecast_nwp.py index f5646a6..0cffcd5 100644 --- a/packages/weather/src/mostlyright/weather/forecast_nwp.py +++ b/packages/weather/src/mostlyright/weather/forecast_nwp.py @@ -114,6 +114,13 @@ _RESERVED_MODELS: frozenset[str] = frozenset(NWP_MODEL_VALUES) - _WIRED_NWP_MODELS +#: Issue #74: models whose ensemble ``member=`` is wired into their path +#: builder (``_gefs_path`` / ``_cfs_path``). RRFS declares members but its +#: member is NOT threaded into the path builder, so it is excluded here — +#: passing ``member=`` for any non-listed model raises ``ValueError``. +_MEMBER_CAPABLE_MODELS: frozenset[str] = frozenset({"gefs", "cfs"}) + + #: cfgrib's canonical short-name for each GRIB2 ``(variable, level)`` pair #: in the mostlyright variable maps. Lifted from cfgrib's CF / GRIB2 short- #: name table; used to project per-record xarray datasets back to the @@ -324,14 +331,24 @@ def _try_fetch_records_for_mirror( fxx: int, variable_map: dict[str, tuple[str, str]], client: httpx.Client, + member: str | None = None, ) -> tuple[NwpFetchPlan, list[IdxRecord], int] | None: """Resolve a fetch plan + ``.idx`` + content length on one mirror. Returns ``None`` if the mirror failed (caller falls back to the next one in the chain). Returns a tuple ``(plan, records, content_length)`` on success — caller does the per-record byte-range fetch. + + Issue #74: ``member`` is threaded to ``build_fetch_plan`` (and on to + the GEFS/CFS path builder) ONLY when non-None — passing ``member=None`` + would override the path-builder default (``c00`` / ``01``) and crash + f-string formatting. """ - plan = build_fetch_plan(model=model, mirror=mirror, cycle=cycle, fxx=fxx) + # Build per-model kwargs, including ``member`` only when explicitly set. + per_model_kwargs: dict[str, Any] = {} + if member is not None: + per_model_kwargs["member"] = member + plan = build_fetch_plan(model=model, mirror=mirror, cycle=cycle, fxx=fxx, **per_model_kwargs) try: idx_text = fetch_idx_text(plan, client=client) # Phase 17 FORECAST-04: dispatch idx parser style per model. @@ -583,6 +600,7 @@ def forecast_nwp( cycle_range_end: datetime | None = None, fxx: int | None = None, mirror: str | None = None, + member: str | None = None, client: httpx.Client | None = None, backend: str = "pandas", return_type: str = "dataframe", @@ -601,6 +619,12 @@ def forecast_nwp( fxx: Forecast hour ahead of ``cycle``. Default ``1`` (next hour). mirror: Force a specific mirror (``"aws_bdp"`` or ``"nomads"``). Default: try AWS first then NOMADS. + member: Ensemble member id — only valid for the member-capable + models GEFS (e.g. ``"p05"``, default ``"c00"``) and CFS + (``"01"``..``"04"``, default ``"01"``). ``None`` (the default) + keeps the path-builder default and is byte-identical to + pre-issue-#74 behavior. Threaded to the GEFS/CFS path builder + only when non-None. client: Reuse an ``httpx.Client`` for connection pooling. A fresh client is created (and closed) per call if omitted. @@ -614,7 +638,9 @@ def forecast_nwp( Raises: NwpModelNotAvailableError: ``model`` is reserved (ECMWF Tier-2). ValueError: ``model`` or ``mirror`` is not in the supported set; - ``fxx`` is negative; ``cycle`` is naive. + ``fxx`` is negative; ``cycle`` is naive; ``member`` is set for + a non-member model, or is not a valid member of the requested + model's ensemble (GEFS/CFS). SourceUnavailableError: the ``[nwp]`` optional extra (``cfgrib`` + ``xarray`` + ``sklearn``) is not installed. NoLiveForNwpError: every wired mirror failed (typically while @@ -688,6 +714,28 @@ def forecast_nwp( f"got {mirror!r}" ) + # Issue #74: validate the ``member=`` ensemble selector EARLY — before + # the lazy ``[nwp]`` imports below — so callers without cfgrib still get + # the right ValueError. Only GEFS / CFS have their member wired into the + # path builder; RRFS member is NOT wired and is intentionally excluded. + # The member enums are plain frozensets (gefs.py / cfs.py import no + # cfgrib/xarray at module level), so importing them here is cheap. + if member is not None: + if model not in _MEMBER_CAPABLE_MODELS: + raise ValueError( + f"member= is only supported for models " + f"{sorted(_MEMBER_CAPABLE_MODELS)}; got model={model!r}" + ) + if model == "gefs": + from ._fetchers._nwp_grids.gefs import GEFS_MEMBERS as _MEMBERS + else: # model == "cfs" + from ._fetchers._nwp_grids.cfs import CFS_MEMBERS as _MEMBERS + if member not in _MEMBERS: + raise ValueError( + f"member={member!r} is not a valid {model} member; " + f"valid members are {sorted(_MEMBERS)}" + ) + # Phase 17 Wave-2 iter-3: model-aware fxx default. RTMA / URMA are # analysis products with no forecast hour -- default to 0. All other # models default to fxx=1. The None sentinel lets us distinguish an @@ -751,6 +799,7 @@ def _fetch_cycle(_c: datetime) -> pd.DataFrame | None: cycle=_c, fxx=fxx if fxx is not None else (0 if model in {"rtma", "urma"} else 1), mirror=mirror, + member=member, client=client, backend="pandas", return_type="dataframe", @@ -919,6 +968,7 @@ def _fetch_cycle(_c: datetime) -> pd.DataFrame | None: fxx=fxx, variable_map=variable_map, client=client, + member=member, ) if attempt is None: continue diff --git a/packages/weather/tests/test_forecast_nwp.py b/packages/weather/tests/test_forecast_nwp.py index 694dff0..f7bf53f 100644 --- a/packages/weather/tests/test_forecast_nwp.py +++ b/packages/weather/tests/test_forecast_nwp.py @@ -832,6 +832,218 @@ def fail_transport(request: httpx.Request) -> httpx.Response: client.close() +# --------------------------------------------------------------------------- +# Issue #74 — member= ensemble selector for GEFS / CFS +# --------------------------------------------------------------------------- +class TestForecastNwpMember: + """Validate + thread the ``member=`` kwarg (issue #74). + + ``member`` is only meaningful for the wired ensemble models GEFS / CFS. + Misuse (member on a non-member model, or an out-of-enum member value) + raises ``ValueError`` BEFORE the lazy ``[nwp]`` imports, so callers + without cfgrib installed still get the right error. When valid, the + member string is threaded to ``build_fetch_plan`` (and on to the + GEFS/CFS path builders) ONLY when non-None — passing ``member=None`` + would override the path-builder default and crash f-string formatting. + """ + + # A GEFS/CFS cycle inside each model's archive depth on the 6h grid so + # the single-cycle path runs deterministically without network. + _GEFS_CYCLE: ClassVar[datetime] = datetime(2025, 6, 1, 12, 0, tzinfo=UTC) + _CFS_CYCLE: ClassVar[datetime] = datetime(2025, 6, 1, 12, 0, tzinfo=UTC) + + def test_member_on_non_member_model_raises(self) -> None: + """Test A — member= on a wired non-member model (hrrr) raises, + naming the member-capable models. Fires before any fetch/import.""" + from mostlyright.weather.forecast_nwp import forecast_nwp + + with pytest.raises(ValueError) as exc_info: + forecast_nwp("KNYC", "hrrr", member="p05") + msg = str(exc_info.value) + assert "gefs" in msg + assert "cfs" in msg + + def test_invalid_member_on_gefs_raises_listing_valid(self) -> None: + """Test B — an out-of-enum member on gefs raises, listing the + sorted valid GEFS members. No network.""" + from mostlyright.weather._fetchers._nwp_grids.gefs import GEFS_MEMBERS + from mostlyright.weather.forecast_nwp import forecast_nwp + + with pytest.raises(ValueError) as exc_info: + forecast_nwp("KNYC", "gefs", member="zzz") + msg = str(exc_info.value) + # The message must list the real sorted member set. + for m in sorted(GEFS_MEMBERS): + assert m in msg + + @staticmethod + def _capturing_build_fetch_plan(captured: list[dict]): + """Wrap the real ``build_fetch_plan`` to record its kwargs. + + ``build_fetch_plan`` does no network I/O (pure URL construction), so + we delegate to the real implementation to get a valid plan, then let + the caller's ``MockTransport`` 404 the ``.idx`` fetch — driving the + helper down its ``except httpx.HTTPStatusError`` → ``None`` path.""" + from mostlyright.weather.forecast_nwp import build_fetch_plan as _real + + def _wrapped(*args, **kwargs): + captured.append(kwargs) + return _real(*args, **kwargs) + + return _wrapped + + @staticmethod + def _mock_404_client() -> httpx.Client: + def _handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(404, text="not found") + + return httpx.Client(transport=httpx.MockTransport(_handler)) + + def test_member_threads_to_build_fetch_plan_gefs(self) -> None: + """Test C — member="p05" threads member="p05" into build_fetch_plan + for gefs. Exercises the single-cycle threading helper + ``_try_fetch_records_for_mirror`` directly so it runs in CI without + the ``[nwp]`` extra (the helper sits below the lazy-import gate). A + 404 MockTransport drives the helper to its mirror-fallback None.""" + from mostlyright.weather.forecast_nwp import _try_fetch_records_for_mirror + + captured: list[dict] = [] + client = self._mock_404_client() + try: + with patch( + "mostlyright.weather.forecast_nwp.build_fetch_plan", + side_effect=self._capturing_build_fetch_plan(captured), + ): + result = _try_fetch_records_for_mirror( + model="gefs", + mirror="aws_bdp", + cycle=self._GEFS_CYCLE, + fxx=1, + variable_map={"temp_k_2m": ("TMP", "2 m above ground")}, + client=client, + member="p05", + ) + finally: + client.close() + + assert result is None # 404 → None (mirror fallback) + assert captured, "build_fetch_plan was never called" + assert all(c.get("member") == "p05" for c in captured) + + def test_member_none_omits_kwarg_gefs(self) -> None: + """Test D (regression) — member=None (default) must NOT pass a + member key to build_fetch_plan (passing member=None would override + the path-builder default c00 and crash). Byte-identical to today. + Direct on ``_try_fetch_records_for_mirror`` so it runs without the + ``[nwp]`` extra.""" + from mostlyright.weather.forecast_nwp import _try_fetch_records_for_mirror + + captured: list[dict] = [] + client = self._mock_404_client() + try: + with patch( + "mostlyright.weather.forecast_nwp.build_fetch_plan", + side_effect=self._capturing_build_fetch_plan(captured), + ): + _try_fetch_records_for_mirror( + model="gefs", + mirror="aws_bdp", + cycle=self._GEFS_CYCLE, + fxx=1, + variable_map={"temp_k_2m": ("TMP", "2 m above ground")}, + client=client, + ) + finally: + client.close() + + assert captured, "build_fetch_plan was never called" + assert all("member" not in c for c in captured) + + def test_member_threads_to_build_fetch_plan_cfs(self) -> None: + """Test E — member="03" threads member="03" into build_fetch_plan + for cfs. Direct on ``_try_fetch_records_for_mirror`` (CI-safe).""" + from mostlyright.weather.forecast_nwp import _try_fetch_records_for_mirror + + captured: list[dict] = [] + client = self._mock_404_client() + try: + with patch( + "mostlyright.weather.forecast_nwp.build_fetch_plan", + side_effect=self._capturing_build_fetch_plan(captured), + ): + _try_fetch_records_for_mirror( + model="cfs", + mirror="aws_bdp", + cycle=self._CFS_CYCLE, + fxx=1, + variable_map={"temp_k_2m": ("TMP", "2 m above ground")}, + client=client, + member="03", + ) + finally: + client.close() + + assert captured, "build_fetch_plan was never called" + assert all(c.get("member") == "03" for c in captured) + + def test_member_validation_fires_before_nwp_import(self) -> None: + """Tests A/B fire pre-import; this pins the ordering explicitly for + valid members too — a valid member on gefs reaches the single-cycle + path (and ultimately the lazy ``[nwp]`` import) rather than tripping + validation. Without the extra installed, the call surfaces + ``SourceUnavailableError`` (NOT a member ValueError), proving a valid + member passed validation. With the extra, it would proceed to fetch; + we only assert the no-ValueError property here.""" + from mostlyright.weather.forecast_nwp import forecast_nwp + + if _HAS_NWP_EXTRA: + pytest.skip("absence-of-extra ordering check; extra is installed") + with pytest.raises(SourceUnavailableError): + forecast_nwp("KNYC", "gefs", cycle=self._GEFS_CYCLE, member="p05") + + def test_member_threads_through_multi_cycle_gefs(self) -> None: + """Test F (multi-cycle) — every recursive single-cycle call in a + cycle_range backfill carries member="p05". Patches the module-level + ``forecast_nwp`` recursion target (mirroring the existing multi-cycle + test) to capture the per-cycle kwargs WITHOUT hitting the ``[nwp]`` + import gate, so it runs in CI without the extra.""" + from mostlyright.weather import forecast_nwp as fnwp_module + + start = datetime(2025, 6, 1, 0, 0, tzinfo=UTC) + end = datetime(2025, 6, 1, 6, 0, tzinfo=UTC) # GEFS 6h grid: 00, 06 + real_forecast_nwp = fnwp_module.forecast_nwp + per_cycle_members: list[str | None] = [] + + def _fake_single(*args, **kwargs): + cycle = kwargs.get("cycle") + # Only intercept the per-cycle recursive call (no range kwargs). + if cycle is not None and kwargs.get("cycle_range_start") is None: + per_cycle_members.append(kwargs.get("member")) + return None # no rows for this cycle; range path concats empties + return real_forecast_nwp(*args, **kwargs) + + with ( + patch( + "mostlyright.weather._fetchers._nwp_cycle_chunks.check_historical_depth", + return_value=None, + ), + patch( + "mostlyright.weather.forecast_nwp.forecast_nwp", + side_effect=_fake_single, + ), + ): + real_forecast_nwp( + station="KNYC", + model="gefs", + cycle_range_start=start, + cycle_range_end=end, + member="p05", + ) + + assert per_cycle_members, "no per-cycle recursive calls observed" + assert all(m == "p05" for m in per_cycle_members) + + # --------------------------------------------------------------------------- # Live integration (network-bound, marked + gated) # --------------------------------------------------------------------------- From 0119d2012432f5d6cf877f3715ca25cd7022eee5 Mon Sep 17 00:00:00 2001 From: minereda <84080887+minereda@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:17:12 +0200 Subject: [PATCH 2/5] feat(forecasts): forward member= through core wrapper + TS option parity (#74) - Core mostlyright.forecasts.forecast_nwp accepts member= and forwards it verbatim to the weather impl (validation stays in the weather package) - TS ForecastNwpOptions exposes optional member?: string (signature-forward parity per the Dual-SDK rule; runtime stub still throws) - TDD: core wrapper test written RED (TypeError) then GREEN; TS test locks the member? option shape at compile time Co-Authored-By: Claude Fable 5 --- packages-ts/weather/src/forecasts/nwp-stub.ts | 6 +++++ .../weather/tests/forecasts/nwp-stub.test.ts | 13 ++++++++++- packages/core/src/mostlyright/forecasts.py | 12 +++++++++- .../tests/test_forecast_nwp_schema_phase17.py | 22 +++++++++++++++++++ 4 files changed, 51 insertions(+), 2 deletions(-) diff --git a/packages-ts/weather/src/forecasts/nwp-stub.ts b/packages-ts/weather/src/forecasts/nwp-stub.ts index a49ec72..a4fcb24 100644 --- a/packages-ts/weather/src/forecasts/nwp-stub.ts +++ b/packages-ts/weather/src/forecasts/nwp-stub.ts @@ -53,6 +53,12 @@ export interface ForecastNwpOptions { readonly fxx?: number; /** Force a mirror (e.g. `"aws_bdp"`). */ readonly mirror?: string; + /** + * Ensemble member id (e.g. GEFS `"p05"`, CFS `"03"`). Mirrors the + * Python `member=` kwarg (issue #74); only meaningful for GEFS / CFS. + * Signature-forward only — TS NWP execution lands in v2.0+. + */ + readonly member?: string; } /** diff --git a/packages-ts/weather/tests/forecasts/nwp-stub.test.ts b/packages-ts/weather/tests/forecasts/nwp-stub.test.ts index dc3c38c..bdf73e9 100644 --- a/packages-ts/weather/tests/forecasts/nwp-stub.test.ts +++ b/packages-ts/weather/tests/forecasts/nwp-stub.test.ts @@ -7,7 +7,7 @@ import { describe, expect, it } from "vitest"; import { DataAvailabilityError, NwpNotAvailableError } from "@mostlyrightmd/core"; -import { forecastNwp } from "../../src/forecasts/index.js"; +import { type ForecastNwpOptions, forecastNwp } from "../../src/forecasts/index.js"; describe("forecastNwp (Phase 21 21-07 messaging)", () => { it("raises NwpNotAvailableError (subclass of DataAvailabilityError)", async () => { @@ -115,4 +115,15 @@ describe("forecastNwp (Phase 21 21-07 messaging)", () => { // Exercise one call to lock the runtime behavior. await expect(forecastNwp("KNYC", models[0])).rejects.toThrow(); }); + + it("ForecastNwpOptions accepts an optional member (issue #74 parity)", async () => { + // Compile-level check: `member?` must exist on ForecastNwpOptions, or + // tsc fails. Runtime still throws the v1.x stub error. + const opts: ForecastNwpOptions = { member: "p05" }; + await expect(forecastNwp("KNYC", "gefs", opts)).rejects.toThrow(NwpNotAvailableError); + // Inline-literal form mirrors the Python call shape. + await expect(forecastNwp("KNYC", "cfs", { member: "03" })).rejects.toThrow( + NwpNotAvailableError, + ); + }); }); diff --git a/packages/core/src/mostlyright/forecasts.py b/packages/core/src/mostlyright/forecasts.py index e4c834f..9dc9062 100644 --- a/packages/core/src/mostlyright/forecasts.py +++ b/packages/core/src/mostlyright/forecasts.py @@ -131,6 +131,7 @@ def forecast_nwp( cycle_range_end: datetime | None = None, fxx: int | None = None, mirror: str | None = None, + member: str | None = None, client: httpx.Client | None = None, ) -> pd.DataFrame: """Fetch an NWP forecast from NOAA Big Data Program direct-fetch. @@ -149,6 +150,12 @@ def forecast_nwp( fxx: Forecast hour ahead of ``cycle``. Default ``1``. mirror: Force a specific NOAA BDP mirror (``"aws_bdp"`` or ``"nomads"``). Default: try AWS then NOMADS. + member: Ensemble member id — only valid for the member-capable + models GEFS (``"c00"``/``"p01"``..``"p30"``/``"avg"``/``"spr"``, + default ``"c00"``) and CFS (``"01"``..``"04"``, default + ``"01"``). ``None`` (default) is byte-identical to today. + Validation + the valid-member enums live in the weather impl; + this wrapper passes ``member`` straight through (issue #74). client: Reuse an ``httpx.Client`` for connection pooling. Returns: @@ -157,7 +164,9 @@ def forecast_nwp( Raises: ValueError: ``model`` not in :data:`SUPPORTED_NWP_MODELS` and not a reserved ECMWF id; ``fxx`` is negative; ``cycle`` is - naive; ``mirror`` outside the supported set. + naive; ``mirror`` outside the supported set; ``member`` set + for a non-member model or not a valid member of the model's + ensemble (validated in the weather impl). NwpModelNotAvailableError: ``model`` is a reserved ECMWF id. SourceUnavailableError: ``[nwp]`` optional extra not installed. NoLiveForNwpError: every wired NOAA BDP mirror failed. @@ -269,5 +278,6 @@ def forecast_nwp( cycle_range_end=cycle_range_end, fxx=fxx, mirror=mirror, + member=member, client=client, ) diff --git a/packages/core/tests/test_forecast_nwp_schema_phase17.py b/packages/core/tests/test_forecast_nwp_schema_phase17.py index 8179e9f..b9b7b89 100644 --- a/packages/core/tests/test_forecast_nwp_schema_phase17.py +++ b/packages/core/tests/test_forecast_nwp_schema_phase17.py @@ -126,3 +126,25 @@ def test_rtma_default_call_does_not_raise_analysis_guard() -> None: ) except Exception: pass + + +# --------------------------------------------------------------------------- +# Issue #74 — core wrapper forwards member= to the weather impl +# --------------------------------------------------------------------------- +def test_core_wrapper_forwards_member_to_impl() -> None: + """The public ``mostlyright.forecasts.forecast_nwp`` wrapper must accept + ``member=`` and forward it verbatim to the weather impl. Patch the + delegation target (``mostlyright.weather.forecast_nwp.forecast_nwp``) + with a Mock so no network / [nwp] extra is required.""" + from unittest.mock import Mock, patch + + from mostlyright.forecasts import forecast_nwp + + fake_impl = Mock(return_value="sentinel-df") + with patch("mostlyright.weather.forecast_nwp.forecast_nwp", fake_impl): + result = forecast_nwp("KNYC", "gefs", member="p05") + + assert result == "sentinel-df" + fake_impl.assert_called_once() + _, kwargs = fake_impl.call_args + assert kwargs.get("member") == "p05" From f05cbff7252111c30f21dc0cfac7cbb9c05a9290 Mon Sep 17 00:00:00 2001 From: minereda <84080887+minereda@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:21:26 +0200 Subject: [PATCH 3/5] docs(forecasts): document member= for GEFS/CFS + correct GEFS member count (#74) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GEFS row: 31-member ensemble (c00 + p01..p30) plus avg/spr statistical products — 33 values (was incorrectly '32 members') - CFS row: document member= (01..04, default 01) - Add an 'Ensemble members (member=)' section with GEFS/CFS examples, validation behavior, and the no-member-column scope note Co-Authored-By: Claude Fable 5 --- docs/forecasts.md | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/docs/forecasts.md b/docs/forecasts.md index aabee4d..d4963b9 100644 --- a/docs/forecasts.md +++ b/docs/forecasts.md @@ -64,14 +64,14 @@ rows roll into the correct calendar settlement. | `hrrr` | ✓ wired | CONUS 3km | hourly | 2014-07-30 | High-resolution rapid refresh | | `hrrrak` | ✓ wired | Alaska 3km | 3-hourly | 2018-01-01 | HRRR for Alaska | | `gfs` | ✓ wired | Global 0.25° | 6-hourly | 2021-01-01 | Standard global model | -| `gefs` | ✓ wired | Global 0.5° ensemble (32 members) | 6-hourly | 2017-01-01 | Default member `c00`; opt in via `member=` | +| `gefs` | ✓ wired | Global 0.5° 31-member ensemble (`c00` + `p01`..`p30`) plus `avg`/`spr` statistical products | 6-hourly | 2017-01-01 | Default member `c00`; opt in via `member=` (e.g. `member="p05"`) | | `gdas` | ✓ wired | Global 0.25° (short-range) | 6-hourly | 2021-01-01 | GFS analysis system | | `nbm` | ✓ wired | Regional blend | hourly | 2020-01-01 | National Blend; `fxx=0` auto-bumps to `1` | | `rap` | ✓ wired | CONUS 13km | hourly | 2020-01-01 | Rapid refresh | | `rrfs` | ✓ wired | CONUS 3km | hourly | 2024-01-01 | HRRR successor (pre-operational) | | `rtma` | ✓ wired | CONUS 2.5km analysis | hourly | 2024-01-01 | Real-time mesoscale analysis (`fxx=0` only) | | `urma` | ✓ wired | CONUS 2.5km analysis | hourly | 2024-01-01 | Un-Restricted MA (`fxx=0` only) | -| `cfs` | ✓ wired | Global 1° (4-member) | 6-hourly | 2011-01-01 | Climate Forecast System | +| `cfs` | ✓ wired | Global 1° 4-member ensemble (`01`..`04`) | 6-hourly | 2011-01-01 | Climate Forecast System; default member `01`, opt in via `member=` (e.g. `member="03"`) | All 11 NCEP-family models are end-to-end wired in v1.0. @@ -207,6 +207,36 @@ df = pd.concat(frames, ignore_index=True) BDP depths are documented above; older cycles raise `HistoricalDepthError`. +### Ensemble members (`member=`) + +The ensemble models **GEFS** and **CFS** accept a `member=` selector +(issue #74). It threads to the path builder so you fetch a specific +ensemble member instead of the default control run: + +```python +from mostlyright.forecasts import forecast_nwp + +# GEFS perturbation member p05 (default is the c00 control run) +df = forecast_nwp(station="KNYC", model="gefs", member="p05") + +# CFS member 03 (default is 01) +df = forecast_nwp(station="KNYC", model="cfs", member="03") +``` + +- **GEFS** members: `c00` (control, default), `p01`..`p30` + (perturbations), plus `avg` / `spr` statistical products — 33 values. +- **CFS** members: `01`..`04` (default `01`). +- `member=` is **only** valid for `gefs` / `cfs`. Passing it for any + other model raises `ValueError`. An out-of-enum member value also + raises `ValueError` listing the valid members. Both errors fire before + the `[nwp]` extra is imported. +- `member=None` (the default) is byte-identical to pre-#74 behavior — no + `member` is threaded and the path-builder default is used. + +> **Note:** `member=` does not (yet) add a `member` column to the output +> DataFrame — it selects which member's grid is fetched. A per-row +> `member` column is tracked as future work. + ### Settlement-day envelope (Mode 2) `research(include_forecast=True, forecast_models=[...])` fetches a From 569c8941cec28b072df4355f40f52905dd35cde4 Mon Sep 17 00:00:00 2001 From: minereda <84080887+minereda@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:38:20 +0200 Subject: [PATCH 4/5] test(forecast_nwp): cover public single-cycle member= threading (#74, architect iter-1 HIGH) The mirror-loop call site passing member= to _try_fetch_records_for_mirror had zero test executions (CI lacks the [nwp] extra; Tests C/E hit the helper directly; Test F intercepts the recursion). Stub the lazy [nwp] imports into sys.modules and capture the helper kwargs through the public forecast_nwp() entry point. Mutation-verified: deleting member=member at the call site fails this test. Co-Authored-By: Claude Fable 5 --- packages/weather/tests/test_forecast_nwp.py | 50 +++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/packages/weather/tests/test_forecast_nwp.py b/packages/weather/tests/test_forecast_nwp.py index f7bf53f..01dff92 100644 --- a/packages/weather/tests/test_forecast_nwp.py +++ b/packages/weather/tests/test_forecast_nwp.py @@ -1043,6 +1043,56 @@ def _fake_single(*args, **kwargs): assert per_cycle_members, "no per-cycle recursive calls observed" assert all(m == "p05" for m in per_cycle_members) + def test_member_threads_through_public_single_cycle_gefs(self) -> None: + """Architect iter-1 HIGH — the public ``forecast_nwp()`` single-cycle + mirror loop must pass ``member=`` to ``_try_fetch_records_for_mirror`` + (the headline ``forecast_nwp("KNYC", "gefs", member="p05")`` shape). + Tests C/E exercise the helper directly and Test F intercepts the + recursion before the single-cycle body runs, so without this test the + ``member=member`` argument at the mirror-loop call site would have + zero executions in CI (no ``[nwp]`` extra) AND in a with-extra run — + deleting it would silently fetch the c00 control run. Stub the lazy + ``[nwp]`` imports into ``sys.modules`` so the import gate passes + without the extra, and capture the helper's kwargs; every mirror + "fails" (returns None) so the call exits via ``NoLiveForNwpError`` + before any extraction.""" + import sys + import types + + from mostlyright.weather.forecast_nwp import forecast_nwp + + fake_neighbors = types.ModuleType("sklearn.neighbors") + fake_neighbors.BallTree = object # satisfies `from ... import BallTree` + fake_sklearn = types.ModuleType("sklearn") + fake_sklearn.neighbors = fake_neighbors # type: ignore[attr-defined] + + captured: list[dict] = [] + + def _capture_and_fail(*args, **kwargs): + captured.append(kwargs) + return None # mirror failed → loop tries next → NoLiveForNwpError + + with ( + patch.dict( + sys.modules, + { + "cfgrib": types.ModuleType("cfgrib"), + "xarray": types.ModuleType("xarray"), + "sklearn": fake_sklearn, + "sklearn.neighbors": fake_neighbors, + }, + ), + patch( + "mostlyright.weather.forecast_nwp._try_fetch_records_for_mirror", + side_effect=_capture_and_fail, + ), + pytest.raises(NoLiveForNwpError), + ): + forecast_nwp("KNYC", "gefs", cycle=self._GEFS_CYCLE, member="p05") + + assert captured, "_try_fetch_records_for_mirror was never called" + assert all(c.get("member") == "p05" for c in captured) + # --------------------------------------------------------------------------- # Live integration (network-bound, marked + gated) From 0753d9ef27d44a5f8e774ab1f7080b9a89accec8 Mon Sep 17 00:00:00 2001 From: minereda <84080887+minereda@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:07:40 +0200 Subject: [PATCH 5/5] fix(forecasts): skew-guard member pass-through + bump research weather floor to 1.7.0 (#74) Core 1.7.0 passing member=member unconditionally would TypeError on EVERY forecast_nwp call against a pre-member mostlyrightmd-weather (<=1.6.0) installed outside the [research] extra. Thread member only when set (default calls stay skew-tolerant), pin the [research] extra to weather>=1.7.0 (mirrors #64 codex P1 precedent for variables=), and pin the default-call omission with a core-layer test mirroring weather Test D. Co-Authored-By: Claude Fable 5 --- packages/core/pyproject.toml | 10 ++++++---- packages/core/src/mostlyright/forecasts.py | 10 +++++++++- .../tests/test_forecast_nwp_schema_phase17.py | 19 +++++++++++++++++++ 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/packages/core/pyproject.toml b/packages/core/pyproject.toml index 0a77ec5..72d1141 100644 --- a/packages/core/pyproject.toml +++ b/packages/core/pyproject.toml @@ -72,10 +72,12 @@ parquet = [ # pandas upper bound aligned with `parquet` extra at <4.0; both backends # are exercised by the dual-pandas CI matrix. research = [ - # >=1.6.0: research() now calls fetch_open_meteo(variables=...), a kwarg - # introduced in mostlyrightmd-weather 1.6.0 (#64). An older weather pin would - # raise TypeError on research(..., forecast_source="open_meteo") (codex P1). - "mostlyrightmd-weather>=1.6.0,<2.0", + # >=1.7.0: forecast_nwp() threads member= to the weather impl, a kwarg + # introduced in mostlyrightmd-weather 1.7.0 (#74) — an older weather would + # TypeError on forecast_nwp(..., member=...). (Supersedes the >=1.6.0 floor + # for fetch_open_meteo(variables=...), #64 codex P1; default calls stay + # skew-tolerant via the core wrapper's conditional member threading.) + "mostlyrightmd-weather>=1.7.0,<2.0", "pyarrow>=17.0,<24.0", "pandas>=2.2,<4.0", ] diff --git a/packages/core/src/mostlyright/forecasts.py b/packages/core/src/mostlyright/forecasts.py index 9dc9062..b0e503a 100644 --- a/packages/core/src/mostlyright/forecasts.py +++ b/packages/core/src/mostlyright/forecasts.py @@ -270,6 +270,14 @@ def forecast_nwp( source=f"nwp.{model}", ) from None + # Issue #74 cross-version skew guard: thread ``member`` only when the + # caller actually set it. Passing ``member=member`` unconditionally would + # make EVERY core-wrapper call TypeError against an older + # mostlyrightmd-weather whose impl predates the kwarg (core 1.7.0 + + # weather <=1.6.0 outside the [research] extra's floor). With the guard, + # default calls stay call-compatible across the skew; only explicit + # ``member=`` callers on an old weather see the loud TypeError. + member_kwargs: dict[str, str] = {} if member is None else {"member": member} return _impl( station, model, @@ -278,6 +286,6 @@ def forecast_nwp( cycle_range_end=cycle_range_end, fxx=fxx, mirror=mirror, - member=member, client=client, + **member_kwargs, ) diff --git a/packages/core/tests/test_forecast_nwp_schema_phase17.py b/packages/core/tests/test_forecast_nwp_schema_phase17.py index b9b7b89..dccd82a 100644 --- a/packages/core/tests/test_forecast_nwp_schema_phase17.py +++ b/packages/core/tests/test_forecast_nwp_schema_phase17.py @@ -148,3 +148,22 @@ def test_core_wrapper_forwards_member_to_impl() -> None: fake_impl.assert_called_once() _, kwargs = fake_impl.call_args assert kwargs.get("member") == "p05" + + +def test_core_wrapper_omits_member_kwarg_by_default() -> None: + """Cross-version skew guard (#74): a default call (no ``member=``) must + NOT pass a ``member`` kwarg to the weather impl, so core stays + call-compatible with a pre-1.7.0 mostlyrightmd-weather whose impl lacks + the parameter. Mirrors the weather-side Test D at the delegation layer.""" + from unittest.mock import Mock, patch + + from mostlyright.forecasts import forecast_nwp + + fake_impl = Mock(return_value="sentinel-df") + with patch("mostlyright.weather.forecast_nwp.forecast_nwp", fake_impl): + result = forecast_nwp("KNYC", "gefs") + + assert result == "sentinel-df" + fake_impl.assert_called_once() + _, kwargs = fake_impl.call_args + assert "member" not in kwargs