Skip to content

fix(da): five data assimilation correctness fixes - #125

Merged
cheginit merged 26 commits into
developmentfrom
fix/data-assimilation-correctness
Aug 27, 2026
Merged

fix(da): five data assimilation correctness fixes#125
cheginit merged 26 commits into
developmentfrom
fix/data-assimilation-correctness

Conversation

@cheginit

@cheginit cheginit commented Aug 25, 2026

Copy link
Copy Markdown

Data assimilation correctness fixes, found while chasing a KeyError: 'totalCounts' when
a short-range RFC forecast restored a warm-up state written with assimilation off. Each
was a run that died with an error naming the wrong thing, or exited 0 while assimilating
less than it reported.

Additions

  • span_da_runs, one TimeSlice list covering every window. The reader's gap fill is
    non-local, so a per-window list made the injected observations depend on
    max_loop_size.
  • Model._reservoir_da_enabled and Model._streamflow_nudging_enabled, plus a
    reservoir_da_enabled entry in the serialized state, so a restore can tell a
    deliberately empty frame from a starved one.
  • _require_reservoir_da_params in compute.py, naming an observation/parameter
    mismatch instead of dying on a column lookup.
  • Automatic max_loop_size. 0 is the new default and means 24 forcing columns,
    floored at the DA span and capped by memory. Both drivers log the window they picked.
  • Tests for the lake-in-flow-frame invariant, the reservoir DA parameter guard, DA block
    normalization, and the run window guard.

Removals

  • The warn-and-drop path in GageTree.with_positions, and the module logger in
    gage_tree.py that only served it.

Changes

  • streamflow_nudging defaults to False. The scaling DA replaces it. Nudging stays
    available for comparison.
  • with_positions raises on an unresolvable pruned branch and the caller drops the site,
    rather than returning a partial tree whose confluence denominator is short.
  • apply_in_kernel settles every refusal before subtracting a gage's nudge, which
    otherwise stayed subtracted with nothing to restore it.
  • load_state restores DA frames by the run's config rather than by emptiness, resolves
    every frame before mutating anything, and keeps live state only until the model routes.
  • lastobs is harvested for either streamflow arm, so stale-obs decay survives a window
    boundary.
  • Output and lakeout merges touch only the parts this run wrote.
  • A final window shorter than the DA span folds into the one before it, matching the CLI.
  • The memory estimate behind the window cap is calibrated to the Tier A sweep in
    benchmark/RESULTS.md. It read 21x under measured peak, and cpu_pool was applied as
    a multiplier where the measured pool cost is 1.13x.
  • Cadence substitutions name every distinct requested -> used pair, not one example.
  • The reservoir DA parameter guard fires only when the job has waterbodies of that type.

Testing

  1. pixi run pytest -m "not integration": 503 passed, 5 skipped, 1 xfailed.
  2. Partition invariance measured at 0.00000000 on both drivers. Before the observation
    fix the same comparison gave 76.55 cms over 73,553 cells.
  3. AnA overlap verified on VPU 01 for new observations, revised values, and QC
    withdrawal.
  4. Every fix mutation checked: the guard reverted, the pinning test observed to fail, the
    guard restored.
  5. ruff clean on all new modules by path. No new rule types on touched files.
  6. Codex adversarial review on the final change. Three findings, all confirmed and fixed.
  7. Not yet run at scale. The originating RFC short-range forecast still needs a cluster
    run to confirm it assimilates rather than merely not crashing.

Screenshots

N/A, no user facing surface.

Todos

  • Confirm the short-range RFC forecast on the cluster.

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
  • Visually tested in supported browsers and devices (see checklist below 👇)
  • Project documentation has been updated (including the "Unreleased" section of the CHANGELOG)
  • Reviewers requested with the Reviewers tool ➡️

Testing checklist

Target Environment support

  • Windows
  • Linux
  • Browser

Accessibility

  • Keyboard friendly
  • Screen reader friendly

Other

  • Is useable without CSS
  • Is useable without JS
  • Flexible from small to large screens
  • No linting errors or warnings
  • JavaScript tests are passing

A directory written at another cadence substitutes every file, on every window,
for every arm. Naming each distinct requested and used pair keeps a directory
that changes product partway from reporting only one of them.
A pruned branch with no flow-frame column left the confluence denominator short,
so a surviving sibling took the share the stopped branch owns. The in-kernel
wrapper settles every refusal before subtracting the gage nudge, which otherwise
republished the gage with its own nudge removed.
An empty frame cannot say whether its DA type is switched on, so load_state both
erased parameters a run had just built and let a run with the type off launder
stale ones into the next. create_state now records which types produced the
frames. An empty lastobs frame drops streamflow DA to open loop, so it takes the
same guard.
Each block gates on the observation frame and then reads the parameter frame, so
a mismatch died on a column lookup naming nothing useful. Raises only when the
job has waterbodies of that type: with none selected the lookups are .loc[[]],
which the empty frame serves.
load_state rewound the clock before the restore guards ran, so a refused load
left the model half applied and an identical retry accepted it. The saved-off
exemption also kept frames postdating the restored time, which the kernel reads
update_time from. lastobs launders like the reservoir frames, so it now reads
the nudging flag.
The partition only reaches the result once the DA has a time span. At zero span
the spread is output-only and applied per timestep, and splitting a window is
bit-identical, so refusing a short update there made the shipped Standard AnA
config unrunnable: 3 forcing columns against a max_loop_size default of 24.
Omitting a block leaves it present and null after validation, while consumers reach
through it with chained .get, so a config that never mentioned reservoir_da killed
the NHF build on NoneType.get. Doing this inside model_dump covers every caller;
four entry points build this dict and two of them had no wrapping.
The kernel re-seeds its at-gage lastobs per window and update_after_compute persists
it only for nudging, so a gage with an observation gap is nudged differently across a
boundary even at zero span. Only an update capped to its own forcing is one window
and therefore safe; a memory-driven split stays fatal.
The kernel re-seeded lastobs from each window's own t0 observation, so a gage that
fell silent stopped being corrected at whatever boundary max_loop_size landed on and
a chunked AnA cycle differed from an unchunked one. The scaling arm drives the same
nudging override, so the harvest reads what the kernel already records. The frame
rides in the BMI checkpoint, so the continuity spans cycles too.
The merge globbed the output directory and replaced files[0] with everything it
found, so an AnA cycle sharing a directory destroyed the previous cycle's published
results. Also retires the window guard's stated reason, which cited a per-window
lastobs reset that no longer happens.
The reader interpolates across gaps in both directions, so the filled value at a
timestamp depends on how much series was loaded. Giving each window only its own
TimeSlice files therefore made the injected observations, and the discharge, depend
on max_loop_size: measured on VPU 01 at 52.1 cms in the injected frame and 76.6 cms
in routed flow, against 0.0 once every window reads the same run-spanning list.
Separate BMI update calls still differ, which is real-time semantics rather than a
defect. Nudging and reservoir persistence keep their per-window lists.
A scaling run discarded its own checkpointed lastobs, because the restore asked
whether legacy nudging was on rather than whether this run owns the frame. It now
asks the latter, and trims the restored rows to the roster this run assimilates: a
nudging checkpoint carries holdout, reservoir-routed and co-located gages that the
scaling arm excludes, and installing them raised "not in index" downstream. The lake
output merge also globbed the whole directory, so a later AnA cycle destroyed the
previous cycle's published file the way the stream merge already did.
Measurements belong in the PR, not in every docstring that cites them.
Both arms default off, and the scaling path never reads streamflow_nudging, so
removing nudging later stays seamless. This is the seam that would rot silently.
0 is the new default and means automatic: 24 forcing columns, floored at the
DA span, capped by memory. Explicit values are honored unchanged. Both drivers
log the window they picked.

The estimate behind that cap read 21x under the Tier A sweep's measured peak,
and cpu_pool was applied as a multiplier where measured tree PSS at pool 8 is
1.13x the main process. Both are calibrated now, with one test anchored on the
published numbers instead of on the helper it tests.

Also folds a final window shorter than the DA span into the one before it,
matching the CLI. Automatic sizing produces such remainders routinely.
24 was being demanded of any update, so an 18 column update against a 12 hour
span failed a run that is correct as one window. Only the span is a floor; the
window is a preference. An explicit max_loop_size still refuses to shrink, since
that value is a promise about the DA's operating window.

The refusal no longer tells an operator to lower a knob they never set.
A window longer than the update cannot bound memory, since the update already caps
it, so refusing over one stopped a run for nothing. The hard error stays where no
window choice helps: an update shorter than the DA span.
Folding a short final window into its neighbor ran after the memory cap and could
push that window past it: 47 columns at a cap of 23 came out as one window of 47.
They chunked their own way, so 100 columns at a width of 24 gave [24, 24, 24, 28]
on one driver and [20] x 5 on the other. Both now call troute.window_plan.
psutil reads the host, which is not what a container may use. Also says once, on
the second update, that update cadence is result-bearing under a DA span.
Read as one per-element constant, Ohio gives 409 B and CONUS 31 B. The per-column
term is 94% of a small domain's slope, so fitting Ohio alone put CONUS at 152 GB a
window and refused any scaling run there. Numbers in benchmark/RESULTS.md 5b.
The DA adds 67.8 MB per column and 13 B per element, where it was modeled as
+34 B per element and nothing per column. Cluster runs assimilate, so this is the
arm that matters.
ceil(nts / ceil(required / available)) rounds the wrong way and gave a 4-column
window against a budget of 3.4, 18% over. Only a non-integral budget exposes it.
An exhausted cgroup also divided by zero.
Collapsing to one window turned a 2161-column run at span 60 into a single
2161-column window, and the CLI has no memory cap to catch it. Also: the DA
figures carry no Courant block, since the sweep ran with the lag off.
A Slurm or PBS job cgroup is nested below the mount root, so reading the root
found no limit and returned host RAM. Walks this process's own cgroup and takes
the smallest limit up the tree, or Slurm's stated allocation.
Cut to what would stop the next reader undoing the change; numbers and provenance
stay in benchmark/RESULTS.md 5b. Also two British spellings.
@cheginit
cheginit merged commit 3ac42fe into development Aug 27, 2026
6 checks passed
@cheginit
cheginit deleted the fix/data-assimilation-correctness branch August 28, 2026 13:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant