Skip to content

Add source-coverage manifest and upfront date-range validation for production data-availability handling #26

Description

@helloiamvu

Summary

At v1.x, the SDK has asymmetric handling of out-of-range date requests: NWP forecasts validate upfront and raise loudly, while every observation/climate source silently returns empty results. For a quant-research SDK whose value prop is leakage-free training pairs, silent empty results are exactly the failure mode that corrupts training sets without anyone noticing.

This issue proposes a production solution: a source-coverage manifest + upfront validation + one new exception class, mirroring the HistoricalDepthError pattern that already works well for NWP.


Current behavior

NWP forecasts — explicit, loud (good)

_nwp_cycle_chunks.py:89-228 defines a hardcoded NWP_HISTORICAL_DEPTH table (HRRR ≥ 2014-07-30, GFS ≥ 2021-01-01, live-only models capped at 7 days). check_historical_depth(model, cycle) runs before any HTTP call and raises HistoricalDepthError carrying model, requested_cycle, archive_depth.

Observations / climate — silent, graceful-degrade (problematic)

research() accepts from_date/to_date with zero upfront validation:

Source Behavior on out-of-range
AWC Hardcoded 168hr cap; beyond that the endpoint truncates/empties — passed through (awc.py:45-47)
IEM ASOS 404 → warning log, continue (research.py:246-253)
GHCNh 404 → info log, empty list (research.py:295-326)
NWS CLI 404 → info log, continue (research.py:548-607)

No `DataAvailabilityError`, no "requested range exceeds source coverage" warning. A user asking for `2010-01-01 → 2010-12-31` (before AWC backfill, before GHCNh for many stations) gets an empty DataFrame, not an error.

What's already in place (partial)

…but both only report what's in our local cache, not what the upstream source actually covers. That's the missing layer.


Why this matters at v1.x

  • Leakage-sensitive use case. Quants training NHIGH/NLOW models can't distinguish "no data for this range" from "empty result because we asked wrong" — both surface as a zero-row DataFrame.
  • Agent / MCP consumers (v0.2 roadmap) need a queryable surface to know what's available before constructing a query. Right now they'd have to probe by trial.
  • The NWP fix already exists and works well. The pattern just needs extending to observations.

Proposed design

Layer 1 — Source-coverage manifest

New module: `packages/weather/src/mostlyright/weather/_sources/coverage.py`

```python
@DataClass(frozen=True)
class SourceCoverage:
source: str # "awc" | "iem_asos" | "ghcnh" | "nws_cli" | "iem_mos"
earliest: date | str | None # date, or "per_station" / "per_wfo" sentinel, or None=unbounded
latest_offset: timedelta | None # latest = now - offset; None = up to now
rolling_window: timedelta | None # for AWC's 168hr cap
station_fn: Callable[[str], tuple[date, date]] | None # per-station bound lookup

SOURCE_COVERAGE: dict[str, SourceCoverage] = {
"awc": SourceCoverage("awc", None, None, timedelta(hours=168), None),
"iem_asos": SourceCoverage("iem_asos", "per_station", timedelta(minutes=15), None, _iem_station_bounds),
"ghcnh": SourceCoverage("ghcnh", "per_station", timedelta(days=1), None, _ghcnh_station_bounds),
"nws_cli": SourceCoverage("nws_cli", "per_wfo", timedelta(hours=24), None, _wfo_bounds),
"iem_mos": SourceCoverage("iem_mos", date(2007,1,1), timedelta(hours=6), None, None),
}
```

Per-station bounds for IEM ASOS / GHCNh come from a manifest file shipped with the wheel (built from IEM's `network=ASOS` metadata API). For unknown stations, fall back to a permissive default + warn on first 404. Refreshable via a `mostlyright refresh-coverage` CLI command.

Layer 2 — One new exception class

`packages/core/src/mostlyright/core/exceptions.py`:

```python
class DataAvailabilityError(TradewindsError):
default_error_code = "DATA_AVAILABILITY"
# attributes:
# source: str
# station: str | None
# requested: tuple[date, date]
# available: tuple[date | None, date | None]
# kind: Literal[
# "no_overlap", # request entirely outside coverage
# "partial_before", # request starts before earliest
# "partial_after", # request ends after latest
# "rolling_window", # request exceeds rolling cap (AWC)
# "unknown_station", # station not in coverage manifest
# ]
```

Mirrors `HistoricalDepthError`'s shape exactly (already battle-tested in NWP).

Layer 3 — Upfront validation at public entry points

Every public entry point — `research()`, `observations()`, `climate()`, the forecast helpers — runs `validate_coverage()` before the first HTTP call. Behavior controlled by a single kwarg:

```python
research(..., on_unavailable: Literal["raise", "clamp", "warn"] = "warn")
```

Mode Behavior Use case
`raise` `DataAvailabilityError` on any gap Production training pipelines, CI fixtures
`clamp` Silently clamp to available range, attach `df.attrs["coverage_note"]` Notebook exploration
`warn` (default) Clamp + `UserWarning` naming source + clamped range Best default — loud signal, soft failure

Layer 4 — Public query surface

```python
mostlyright.coverage(source: str | None = None, station: str | None = None) -> DataAvailability
```

Lets agents/users discover what's available before constructing a query. Merges source-manifest data with cache state (extending existing `discovery.availability(station)`).


Key tradeoff: default `warn` vs default `raise`

  • `raise`-by-default is the leakage-safe choice but forces every notebook user to know exact source bounds before any query — high friction for a tool whose value prop includes discoverability.
  • `warn`-by-default gets the safety signal in logs / pytest output without breaking exploration. CI users opt into `raise`.

Recommendation: ship `warn` as default; document `raise` as the production-pipeline pattern. Open to flipping if user research says otherwise.


Migration plan (phased, non-breaking)

  1. Phase 1 — Manifest + exception + `validate_coverage()` helper. No call-site changes. Ships behind `mostlyright.coverage.check(source, station, range)` so users can adopt early.
  2. Phase 2 — Wire `on_unavailable` kwarg into `research()`, `observations()`, `climate()`, forecast helpers. Default `warn`.
  3. Phase 3 — Extend `discovery.availability(station)` to merge cache state with source coverage. Single call answers both "what can I get" and "what do I have".
  4. Phase 4 — Refreshable per-station manifest via packaged data file + CLI command (`mostlyright refresh-coverage`).

Each phase is independently shippable and additive (no breaking changes until a future major version flips the default to `raise`, if ever).


Open questions

  1. Where should the per-station manifest live? Shipped in `mostlyrightmd-weather` wheel (~MB scale) vs lazy-fetched from IEM on first use vs hybrid (small canonical set in wheel, full set lazy)?
  2. Should `coverage()` hit the network? If yes (live freshness probe), it's slow but accurate. If no (manifest-only), it's instant but can drift. Probably manifest-only with a `live=True` opt-in.
  3. NWP integration. `HistoricalDepthError` predates this design. Keep it as a subclass of `DataAvailabilityError`, or leave parallel? Subclassing gives users one `except` to catch all date-range issues.
  4. TS Parity. This is a public-API change — needs the Dual-SDK Planning Rule TS Parity section when this becomes a phase plan. Likely identical API, adapted implementation (no pandas in TS).
  5. Catalog "backfill jobs" for stations with non-obvious coverage (e.g., a station that has IEM data from 1995 but GHCNh data from 2007 — coverage is source-specific even within one station). Manifest must be `(source, station)` keyed, not just `station`.

Acceptance criteria

  • `SOURCE_COVERAGE` manifest module with all 5 observation sources + IEM MOS
  • `DataAvailabilityError` exception class with `source`, `station`, `requested`, `available`, `kind` attributes; subclasses or peer to existing `NwpError` hierarchy
  • `validate_coverage(source, station, from_date, to_date) -> CoverageResult` helper (returns intersection or raises)
  • `on_unavailable` kwarg on all public entry points; `warn` default
  • Per-station manifest data file for IEM ASOS + GHCNh (sourced from IEM metadata)
  • `mostlyright.coverage()` public function
  • Hypothesis property tests: `validate_coverage` never returns a range outside source coverage
  • Updated `research()` docstring + README example showing all three `on_unavailable` modes
  • TS Parity ticket filed (or in-phase TS implementation)

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions