Differentiable distributed routing. A BURN-based Rust port of the Muskingum-Cunge routing solver from DDR (Python/PyTorch), gradient-exact against the reference at single precision.
cargo install --path .This puts the ddrs binary in ~/.cargo/bin/. If that directory isn't on
your PATH:
echo 'export PATH="$HOME/.cargo/bin:$PATH"' >> ~/.bashrc
source ~/.bashrcFrom your project root:
ddrs plan # probes GPU + smoke test (first run), opens $EDITOR on
# ddrs.yaml if missing, locks data sources, validates,
# builds adjacency/baseline caches, prints the plan
ddrs run # executes the workflow, writes manifest + outputsThe first ddrs plan runs a 5-reach RAPID sandbox parity check on CUDA when
available and falls back to CPU otherwise — so the install path works on
laptops and CI. The verdict is cached; later plans are fast. When no
ddrs.yaml exists, plan asks whether to start from your last successful
run's config or the clean bundled template (config/merit_training.yaml).
To start fresh at any time: rm ddrs.yaml && ddrs plan.
The adjacency stores are managed, so data_sources only needs the raw
inputs:
data_sources:
geospatial_fabric: /path/to/riv_pfaf_7_..._bugfix1.shp # MERIT flowlines (.shp/.dbf/.gpkg)
attributes: /path/to/merit_global_attributes_v2.nc
streamflow: /path/to/merit_dhbv2_UH_retrospective.ic
observations: /path/to/usgs_daily_observations
gages: /path/to/gages_3000.csvOn the first ddrs plan, the CONUS and per-gauge adjacency zarr stores are
built from the fabric's attribute table into .ddrs/adjacency/<key>/ (~10 s
for the CONUS dbf, content-addressed and reused afterwards). The fabric may
also be a GeoPackage — e.g. a merged global MERIT flowlines .gpkg — in
which case attributes are read via SQL and geometry is never touched; if the
gpkg holds more than one feature layer, pick one with
geospatial_fabric_layer: <name>. If you already have pre-built zarr
stores, drop geospatial_fabric and set both conus_adjacency and
gages_adjacency to their paths instead.
| Path | Written by | Purpose |
|---|---|---|
ddrs.yaml |
ddrs plan (via $EDITOR) |
Workflow + experiment config |
.ddrs/system.json |
ddrs plan |
GPU/driver/smoke-test record |
.ddrs/sources.lock |
ddrs plan |
Fingerprints of data_sources paths |
.ddrs/adjacency/<key>/ |
ddrs plan (managed build) |
Cached CONUS + gauges adjacency zarr stores |
.ddrs/runs/<id>/manifest.json |
ddrs run |
Per-run manifest (config + sources + git SHA + outputs) |
.ddrs/runs/<id>/run.log |
ddrs run |
Timestamped tee of everything the run printed (stdout + stderr, incl. CUDA messages) |
.ddrs/runs/<id>/eval/predictions.zarr |
ddrs run --workflow eval / train-and-test Phase 2 |
Predictions for plotting |
.ddrs/runs/<id>/checkpoints/epoch_*_mb_*/ |
ddrs run --workflow train / train-and-test Phase 1 |
KAN checkpoints |
.ddrs/runs/<id>/kan_parameters.nc |
train-and-test Phase 2 (leakance runs only) |
Per-reach eval-window zeta diagnostic (see Leakance below) |
Run ids are <UTC timestamp>-[<group>-]<workflow> — e.g.
2026-06-12T14-02-10Z-global-train-and-test. The <group> segment appears
when the config's data_sources matches a saved group (see ddrs sources
below), so run dirs say which dataset they were trained on.
The workflow: key in ddrs.yaml is what plan/run use by default. To
override for a single invocation:
ddrs plan --workflow eval
ddrs run --workflow trainmode: and workflow: must agree (mode: training ↔ workflow ∈ {train, train-and-test}; mode: testing ↔ workflow: eval). ddrs plan will reject contradictions at load time.
The top-level device: key in ddrs.yaml selects the CUDA device ordinal
(default 0, mirrors DDR's device: key) — on multi-GPU hosts set e.g.
device: 1 to keep training off the display/shared GPU.
Named "save files" for the data_sources: block, stored under
config/sources/<name>.yaml (tracked in git). Groups that ship in-repo:
| Group | Streamflow source | Notes |
|---|---|---|
conus |
dHBV2 UH retrospective (daily) | Default CONUS source |
conus-hourly |
same + AORC precip | Enables daily→hourly disaggregation head |
global |
Global zarr-v2 Q' stores | 2.94M reaches |
daily-lstm |
CudaLSTM unit-catchment forwards (daily) | NH LSTM output |
hourly-lstm |
MTS-LSTM unit-catchment forwards (hourly-native) | NH LSTM output; no disagg |
Switching datasets never requires hand-editing ddrs.yaml:
ddrs sources list # '*' marks the group matching ddrs.yaml
ddrs sources save <name> # snapshot current data_sources (--force to overwrite)
ddrs sources use <name> # splice group into ddrs.yaml + refresh sources.lockStarting a global train from a CONUS workspace is just
ddrs sources use global && ddrs plan && ddrs run.
Any icechunk store that follows the DDR Q' contract
(Qr(divide_id, time) f32 m³/s, CF days since/hours since time axis)
can be registered as a source group in one command:
ddrs import <store-path> --dry-run # validate + coverage report only
ddrs import <store-path> --name <group> # validate + register config/sources/<group>.yamlThe reader auto-detects resolution from the CF time units — hourly stores are
sliced natively (no repeat-24 interpolation, no disaggregation head — combining
a disaggregation config with an hourly-native source is rejected at dataset
open). The daily-lstm and hourly-lstm groups were registered this way;
they can serve as reference examples. After import:
ddrs sources use <group> && ddrs plan && ddrs run --workflow trainSee docs/nh-qprime-store-contract.md for the full producer/consumer contract.
An optional groundwater–surface-water loss term
zeta = leakance_factor · area_z · K_D · (depth − d_gw) subtracted from the
routing right-hand side each timestep (positive zeta = losing stream), with
the three parameters learned per reach by the KAN head. Off by default;
enabling it takes params.use_leakance: true, the three extra
learnable_parameters, and their parameter_ranges — see the "Leakance"
section in CLAUDE.md for the exact keys, and
config/experiments/leakance_hourly_on.yaml for a complete working config.
Research status (2026-07-06): NOT PROMOTABLE. A full identifiability
campaign established that zeta cannot be constrained from gauged discharge alone
— the observation operator (gauge = Σ upstream zeta) is not invertible for the
per-reach distribution. The term is code-complete and gradient-exact; it is left
in place as the anchor for the paper's selective-equifinality axis. Do not
attempt to promote it without reading
docs/2026-07-06-leakance-nogo-scientific-summary.md.
ddrs show <run_id>— inspect a past run's manifestddrs status— list runsddrs gc— clean up old run directoriesddrs <cmd> --helpfor full flag list