Motivation
DDR currently couples lateral inflow ingestion to a specific storage convention (icechunk stores with Qr variable, (divide_id, time) dims, mm/day units, 1980-01-01 origin). Adding any new inflow source — a different LSTM variant, NWM output, a NetCDF file, on-the-fly model output, parquet, etc. — requires editing src/ddr/io/readers.py directly.
This is a concrete blocker for the ARC-GR paper work, which evaluates parameter transferability across lateral inflow models (dHBV vs LSTM vs others). Each new inflow source we want to compare currently means a code change in core DDR rather than a drop-in module.
Current state
Friction points in the existing design:
StreamflowReader is tightly coupled to icechunk + Qr. src/ddr/io/readers.py hardcodes the variable name, dim names, units, and time origin.
- Daily/hourly is a boolean flag, not a resolution abstraction.
is_hourly: bool branches the read path; sub-daily-but-not-hourly inputs have no clean home.
- Divide ID convention is dataset-specific and implicit. MERIT integers vs Lynker
"cat-{id}" — readers must know which BaseGeoDataset is active.
- Unit and origin assumptions are implicit. mm/day→m³/s conversion uses catchment area from attributes; 1980-01-01 origin is hardcoded. Neither is documented at the interface boundary.
- No registry / plugin pattern. Config selects a store path, not a reader implementation.
Proposed approach (staged)
Stage 1 — Specify the canonical contract (docs only)
Document the invariants any lateral inflow source must satisfy when handed off to dmc:
- Returns a
torch.Tensor of shape (num_segments, num_hours), float32, m³/s.
- Segment ordering matches the batch adjacency in
RoutingDataclass.
- Hourly resolution (
dt = 3600s), aligned to Dates.batch_hourly_time_range.
- Source is responsible for its own unit conversion, temporal interpolation, and segment alignment.
Add this to CLAUDE.md and a new docs/inflow_sources.md. No code changes. This unblocks contributors writing one-off adapters today.
Stage 2 — Introduce LateralInflowReader Protocol
# src/ddr/io/protocols.py
from typing import Protocol
import torch
from ddr.geodatazoo.dataclasses import RoutingDataclass
class LateralInflowReader(Protocol):
def __call__(self, routing_dataclass: RoutingDataclass) -> torch.Tensor: ...
- Existing
StreamflowReader already matches this shape — annotate it as the reference implementation, no behavior change.
dmc and training/testing/routing scripts type-hint against the Protocol, not the concrete class.
Stage 3 — Config-driven reader selection
Extend DataSources to accept either the current path-based shorthand (preserves all existing configs) or an explicit reader spec:
data_sources:
streamflow:
reader: ddr.io.readers.StreamflowReader # dotted path
kwargs:
store: ${oc.env:DDR_STREAMFLOW_STORE}
is_hourly: false
Resolution happens in validate_config() via importlib. Existing configs continue to work via a back-compat branch that constructs StreamflowReader from a bare path string.
Stage 4 — Reference adapters (as needed)
Ship one or two worked examples in src/ddr/io/adapters/:
NetCDFInflowAdapter — reads a generic NetCDF file with configurable variable/dim/unit/origin.
CallableInflowAdapter — wraps any Callable[[RoutingDataclass], Tensor] for users streaming from a live model.
Each gets a test fixture and a short example in examples/.
Acceptance criteria
Out of scope
- Pluggable observation readers (
IcechunkUSGSReader). Same shape of problem, separate issue.
- MCP server / Claude skill tooling for adapter scaffolding. Reasonable v2 once the Protocol lands; bundling muddies scope.
- Sub-hourly routing.
dt = 3600s is hardcoded in mmc.py:192 and orthogonal to this issue.
Risk / concerns
- Back-compat for configs. Existing YAML uses a bare path string; the new spec is a dict. Resolution logic in
validate_config() must accept both. Risk: silent breakage of user configs. Mitigation: explicit regression test loading every YAML in config/.
- Dotted-path imports in config. Lets users execute arbitrary code via config. Acceptable for a research tool but worth flagging in docs.
- Protocol vs ABC. Using
typing.Protocol keeps it duck-typed and avoids forcing inheritance on third-party readers. Tradeoff: weaker static enforcement; mypy will catch most cases.
Notes for whoever picks this up
- Start with Stage 1 only and open a PR. Stages 2–4 should be separate PRs.
- The existing
StreamflowReader.__call__ signature is the de facto contract — read it before drafting the Protocol.
- ARC-GR work (parameter transferability across inflow models) is the forcing function; if you need a worked example to validate the design, swapping daily LSTM for dHBV2.0 is the canonical case.
Motivation
DDR currently couples lateral inflow ingestion to a specific storage convention (icechunk stores with
Qrvariable,(divide_id, time)dims, mm/day units, 1980-01-01 origin). Adding any new inflow source — a different LSTM variant, NWM output, a NetCDF file, on-the-fly model output, parquet, etc. — requires editingsrc/ddr/io/readers.pydirectly.This is a concrete blocker for the ARC-GR paper work, which evaluates parameter transferability across lateral inflow models (dHBV vs LSTM vs others). Each new inflow source we want to compare currently means a code change in core DDR rather than a drop-in module.
Current state
Friction points in the existing design:
StreamflowReaderis tightly coupled to icechunk +Qr.src/ddr/io/readers.pyhardcodes the variable name, dim names, units, and time origin.is_hourly: boolbranches the read path; sub-daily-but-not-hourly inputs have no clean home."cat-{id}"— readers must know whichBaseGeoDatasetis active.Proposed approach (staged)
Stage 1 — Specify the canonical contract (docs only)
Document the invariants any lateral inflow source must satisfy when handed off to
dmc:torch.Tensorof shape(num_segments, num_hours), float32, m³/s.RoutingDataclass.dt = 3600s), aligned toDates.batch_hourly_time_range.Add this to
CLAUDE.mdand a newdocs/inflow_sources.md. No code changes. This unblocks contributors writing one-off adapters today.Stage 2 — Introduce
LateralInflowReaderProtocolStreamflowReaderalready matches this shape — annotate it as the reference implementation, no behavior change.dmcand training/testing/routing scripts type-hint against the Protocol, not the concrete class.Stage 3 — Config-driven reader selection
Extend
DataSourcesto accept either the current path-based shorthand (preserves all existing configs) or an explicit reader spec:Resolution happens in
validate_config()viaimportlib. Existing configs continue to work via a back-compat branch that constructsStreamflowReaderfrom a bare path string.Stage 4 — Reference adapters (as needed)
Ship one or two worked examples in
src/ddr/io/adapters/:NetCDFInflowAdapter— reads a generic NetCDF file with configurable variable/dim/unit/origin.CallableInflowAdapter— wraps anyCallable[[RoutingDataclass], Tensor]for users streaming from a live model.Each gets a test fixture and a short example in
examples/.Acceptance criteria
docs/inflow_sources.mdexists and documents the canonical contract.LateralInflowReaderProtocol exported fromddr.io.StreamflowReaderannotated against the Protocol with no behavior change.config/continue to work unchanged (regression test).src/ddr/io/readers.pyorsrc/ddr/io/__init__.py.NetCDFInflowAdapterorCallableInflowAdapter) lands with tests.scripts/train.py,scripts/test.py,scripts/router.pyresolve readers via config, not hardcoded import.Out of scope
IcechunkUSGSReader). Same shape of problem, separate issue.dt = 3600sis hardcoded inmmc.py:192and orthogonal to this issue.Risk / concerns
validate_config()must accept both. Risk: silent breakage of user configs. Mitigation: explicit regression test loading every YAML inconfig/.typing.Protocolkeeps it duck-typed and avoids forcing inheritance on third-party readers. Tradeoff: weaker static enforcement; mypy will catch most cases.Notes for whoever picks this up
StreamflowReader.__call__signature is the de facto contract — read it before drafting the Protocol.