Skip to content
This repository was archived by the owner on Oct 6, 2026. It is now read-only.

Old River diversion: persist the last observation and keep the subtraction out of the routing state - #127

Merged
cheginit merged 22 commits into
developmentfrom
feat/diversion-persistence
Sep 17, 2026
Merged

cheginit merged 22 commits into
developmentfrom
feat/diversion-persistence

Conversation

@cheginit

@cheginit cheginit commented Sep 14, 2026 •

Copy link
Copy Markdown

The Old River Control Structure diversion must assimilate observations and persist the
last one rather than decay it. Two base defects sat under it: the subtraction fed back
through the Muskingum-Cunge state, so the Mississippi lost 1.25 times the observation,
and when the gage record ended the transfer stopped within the hour while the
Atchafalaya decayed over two.

Additions

  • diversion_da.diversion_persist_days (whole days, default 11, 0 disables), the same
    idiom as reservoir_rfc_forecast_persist_days. The last observation is held flat on
    the routing grid until an absolute deadline; after it both sides stop at once.
  • The diversion reads its own gage from usgs_timeslices_folder (a named consumer in
    both build_da_sets copies); nothing depends on streamflow_nudging. A process with
    no report at or before t0 and no checkpoint seed scans the folder back to the horizon,
    once, and remembers an empty result.
  • The amount each donor gave up is kernel state: returned as results element 11 by the
    job that routes the donor, carried to the next window by both drivers and by the BMI
    checkpoint.
  • The five production configs carry the block; horizons cover each range plus 28 h of
    data age (12 days for medium range, 32 for long range).
  • The NHF driver writes a lite restart (q0 by routing link plus waterbody states) after
    every window when output_parameters.lite_restart names a directory, and the network
    reads it back as it is (a file with a duplicated link is refused); the call had been
    commented out since the driver was created.
    The Old River demonstration spins each case up five days and restarts from that file;
    a restarted run is bit-identical to the uninterrupted one.

Removals

  • persist_historical_median and the hardcoded monthly means.

Changes (base fixes)

  • Kernel restores the remembered subtraction when loading qdp, as qdpp does for
    bottom-added qlat.
  • A diversion gage with no observation keeps its routed flow instead of the lastobs
    decay (keyed on the gage; the receiver's job usually lacks the donor).
  • Diversion gages join the nudging roster whenever they have observations, so a
    lastobs_file that predates the diversion cannot silence the receiver.
  • The BMI merges the scaling frame into the window's local frame; stored on the DA
    object it handed every later window one column of the run-spanning rows.
  • A crosswalk entry whose flowpath or gage is not in the domain is a warning, not a
    failed network build, so one configuration serves every domain.
  • A window's missing TimeSlice files are reported in one line, not one per file.
  • A configuration with only diversion_da set, which the schema dumps with every other
    DA section as None, initializes and rolls over instead of crashing on those sections.
  • A flowpath-level channel restart reaches the kernel in its column order (qu0, qd0,
    h0, ql0); the reader emitted qd0, h0, qu0, ql0, which put the flow in the depth slot.
  • The Muskingum-Cunge depth search restarts off its degenerate near-zero-depth root
    (secant residual above a tenth of the Muskingum estimate). A link handed a flow far
    above its previous outflow used to publish a dry channel and pass about 100 cms per
    step, which put five April dips at Simmesport in the demo; pinned by
    test_mc_depth_seed.py, and the three test_mc_nonconvergence.py cases that froze
    depth at bankfull now solve to their normal depth. Ordinary channels are bit-identical
    to development; only collapsed searches differ: cold-start transients, and trickles
    of a few centimeters over very wide beds (under 0.3 cms on a bed wider than 100 m).

Testing

  1. pixi run pytest -m "not integration": 920 passed, 4 skipped, 1 xfailed; every
    integration test in the repository 7 passed, 6 skipped (unprepped domains, obsolete modules).
  2. pytest test/nhf/old_river -m integration: 3 passed. Record cut 2011-05-31; both
    nodes hold 14,484 cms for 30 days; the donor gives up 1.000 times the observation
    (was 1.251). Runs with nudging on and, against a control with no DA, off. Simmesport moves less than
    10 percent in any hour while the observation is applied or held.
  3. BMI chunked run bit-identical to the CLI windowed run over 2,772 hours; kernel toy
    cases identical under every compute method, pools 1 and 2, including a donor at a
    subnetwork cut.
  4. Demo notebook re-run on the final kernel, twelve figures embedded. The demonstration
    and the tests now start 2011-04-19 from restarts written by five-day spin-ups of each
    case; the retrospective hot start carries neither the Vicksburg nudge nor the transfer
    and put a two-day dip at Baton Rouge in every earlier figure.
  5. test/nhf/old_river/old-river-diversion.ipynb checks each expected behavior on real
    inputs with asserted numbers: the lateral inflow at the structure is at most 0.1 cms
    against 17,000 to 66,000 routed; the amount removed at the donor and added at the
    receiver equals the observation at every written hour; the transfer reaches Baton
    Rouge and Simmesport with the same volume; the last value holds 45 days and the donor
    stops at the horizon; a fourfold observation floors the donor at zero and the run logs
    the mass imbalance while the Atchafalaya still receives the full amount. Run logs go
    to a file, not the cells.

Notes

  • The TimeSlice folder is now required wherever diversion_da is set, nudging on or
    off, with station 07381482 in the files; every range's deployment stages it.
  • Staging depth: a forecast cycle started without a checkpoint scans the folder back
    diversion_persist_days for a quiet gage, so the routing job stages that many days of
    the per-day usgs_timeslices directories into the folder. A scan that finds nothing
    logs the folder's oldest file.

Checklist

  • PR has an informative and human-readable title
  • Changes are limited to a single goal (no scope creep)
  • Code can be automatically merged (no conflicts)
  • Code follows project standards (link if applicable)
  • Passes all existing automated tests
  • Any change in functionality is tested
  • New functions are documented (with a description, list of inputs, and expected output)
  • Placeholder code is flagged / future todos are captured in comments
  • Project documentation has been updated (including the "Unreleased" section of the CHANGELOG)
  • Reviewers requested with the Reviewers tool ➡️

@sclaw sclaw left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good, Taher. Before I approve, could you please re-run test/nhf/old_river/run-troute-with-water-transfer.ipynb and paste the output figures? Also please respond to my two comments.

Comment thread src/troute-routing/troute/routing/fast_reach/mc_reach.pyx
Comment thread src/troute-routing/troute/routing/fast_reach/mc_reach.pyx Outdated
Comment thread src/troute-nwm/src/nwm_routing/input.py
@cheginit
cheginit force-pushed the feat/diversion-persistence branch from 0189e50 to 71b0f0a Compare September 16, 2026 17:08
@cheginit

Copy link
Copy Markdown
Author

@sclaw Thanks for the review. I addressed all your concerns and found some new issues with the secant solver that I addressed as well. I created a new notebook that you can find here.

@cheginit
cheginit force-pushed the feat/diversion-persistence branch 2 times, most recently from cad2b8d to bea040e Compare September 17, 2026 12:55
The kernel subtracts the observed diversion from the donor's stored outflow,
which the next step reads back as qdp and carries through C3, so the donor
lost d/(1-C3): 1.25 times the observation at Old River, where C3 is 0.2.
The kernel now remembers what it removed per gage, restores it when loading
qdp (as qdpp does for bottom-added qlat), and returns it as state: the DA
object keeps the amount each donor gave up at its last step, both drivers
hand it to the next window and the BMI checkpoint carries it, so a clamp or
a missing observation at a window boundary routes exactly. Pinned with an
analytic steady state, a one-step pulse, and windowed runs against a
continuous one.
…y it

A diversion gage with no observation fell into the kernel's lastobs decay,
so the receiving river kept gaining water for two hours after the donor had
stopped giving it. The kernel now takes the diversion gage set, keyed on the
gage because the receiving node's job usually lacks the donor, and leaves
such a gage at its routed flow.
The roster comes from the lastobs frame, so a lastobs_file that predates
the diversion left its gage off it: the receiver was never nudged while
the injected row still drove the donor subtraction. The diversion gages
join the roster whenever they have observations, with an empty lastobs row.
The horizon idiom the other DAs use (reservoir_rfc_forecast_persist_days):
whole days, default 11, 0 disables. Replaces persist_historical_median. Two
donors on one gage are rejected, and a crosswalk without
usgs_timeslices_folder warns that nothing will be diverted.
…ts gage without nudging

The diversion reads its gage's row from usgs_timeslices_folder itself, as a
named TimeSlice consumer in both build_da_sets copies (which now carry the
window length), and extends the row onto the window's routing grid with the
last observation held, causally, until diversion_persist_days after it. A
seed carries the hold across windows and a report at the seed's instant
wins. The scaling DA's merge, whose gage set includes the diversion gage
with raw observations only, protects the diversion's rows in both drivers.
The hardcoded monthly climatology and its fill are gone; a gage with nothing
to divert is reported.
The seed is taken as of the checkpoint's t0, the restore keeps the later of
the checkpoint's seed and the one init built, and re-runs the fill on the
frame's own columns, since the rows were filled at init.
The merge reindexes every surviving row onto the window's columns, and
nothing rebuilds the run-spanning rows between BMI windows, so a merge
stored on the DA object handed every later window one column of the nudging
and diversion rows read at init.
…none

A forecast cycle started without a checkpoint reads only the reader's
lookback before t0, so a gage quiet for longer than a day would divert
nothing although the horizon allows eleven. The diversion scans the
TimeSlice files newest first, back to the horizon, for a gage with no
carried seed and no report in the frame, and holds its last report that the
reader would accept. The hold's deadline is absolute, so hourly AnA cycles
expire it at the same instant as one long window.
The gage record is cut a month before the run ends; the last value must
hold at both nodes, with nudging on and, against a control with no DA at
all, with nudging off.
All five ranges set usgs_timeslices_folder and the diversion_da block. The
horizon covers each range plus 28 hours of data age: 11 days for the
analysis and short ranges, 12 for medium range, 32 for long range.
A forecast window lists every future instant and none of them exists yet,
so build_da_sets warned once per file, thousands of lines per long-range
window. Both copies now name the count and the span once.
One configuration serves every domain, so a crosswalk entry the domain
does not contain is a warning and no diversion there, not a failed build.
…ate root

Seeded near zero depth the secant settles where C3 = 1 and the segment
passes its own previous outflow whatever arrives. A residual above a tenth
of the Muskingum estimate restarts the search from the flow's normal
depths; cold-start transients change.
The kernel reads q0 by position as qu0, qd0, h0, ql0; the reader emitted
qd0, h0, qu0, ql0, putting the flow in the depth slot.
…g link

The write call had been commented out since the driver was created, and
the reader took only flowpath-keyed files. A run restarted from the written
file matches the uninterrupted run exactly.
The retrospective hot start carries neither the Vicksburg nudge nor the
transfer, which reach Baton Rouge a day and four days apart; each case now
restarts from its own spun-up state.
…ates

The third run is the persistence case with the record cut at 2011-05-31.
…rs only

A bare dropna() made every column a drop criterion, so a dam with a null
native lake_id, or a frame carrying lakeout columns, lost its reservoir.
Both gates now share LEVEL_POOL_PARAMS, the columns the kernel reads.
…list

A parameter missing from LEVEL_POOL_PARAMS reaches the kernel as NaN and
routes plausible wrong numbers; the kernel-facing column list is named in
compute.py and the two are asserted equal.
Each expected behavior of the transfer checked with an asserted number on
real inputs; helpers in notebook_utils.py, logs to a file.
pip resolved matplotlib's extras to numpy 2.5.3 over the 1.26.4 the
project pins, so CI built and tested the kernel on a numpy the project
does not target; the deprecation noise in the test log is pandas and
netCDF4 under that numpy. The dev image installs the same way.
The schema dumps a section the configuration leaves out as None, and a
configuration with only the diversion block crashed on
`reservoir_da.get` at init and on `streamflow_da.get` at rollover.
@cheginit
cheginit force-pushed the feat/diversion-persistence branch from bea040e to e31d880 Compare September 17, 2026 14:16
@cheginit
cheginit merged commit 559ce16 into development Sep 17, 2026
6 checks passed
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants