Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
122 commits
Select commit Hold shift + click to select a range
7e355b7
Add DA v2 EnKF script for per-catchment CFE state assimilation
sonalivyascse19-stack May 13, 2026
df4a811
Extend DA v2 from 2-state to 4-state assimilation
sonalivyascse19-stack May 14, 2026
7e23742
Implement true stochastic EnKF in test loop and mass-conserving cascade
sonalivyascse19-stack May 14, 2026
3369fba
Recognize qkrig_variance column from obs CSVs with kriging variance
sonalivyascse19-stack May 14, 2026
fc3693a
Rewrite top docstring and class docstring to match current code
sonalivyascse19-stack May 14, 2026
aac6ca3
Comment forecast-vs-analysis convention in test loop
sonalivyascse19-stack May 15, 2026
4f09b89
Lognormal precip noise + per-timestep process noise on 4 states
sonalivyascse19-stack May 15, 2026
3b835f8
Split process noise into per-state sigma values
sonalivyascse19-stack May 15, 2026
3ca70b5
Add Vrugt 2005 heteroscedastic R option scaled by kriging variance
sonalivyascse19-stack May 15, 2026
fe9ec22
Ship Vrugt R as the production default
sonalivyascse19-stack May 15, 2026
7d68a86
Add Helene grid plot script and DA v2 (Vrugt R) KGE comparison table
sonalivyascse19-stack May 15, 2026
5289cae
Add Helene grid and KGE comparison table figures (PNGs)
sonalivyascse19-stack May 15, 2026
29b9e73
Remove calibration code path; this script is now a pure DA test runner
sonalivyascse19-stack May 15, 2026
55ba289
Add 4-day Helene peak zoom plot script (Sep 24-28, 2024)
sonalivyascse19-stack May 15, 2026
e31eba7
Add Helene peak zoom (Sep 24-28) figure
sonalivyascse19-stack May 15, 2026
e196854
Add ensemble-spread sensitivity scripts (ensemble sensitivity spaghet…
sonalivyascse19-stack May 18, 2026
1818016
Add spread-vs-time plot and per-member factor decomposition tools
sonalivyascse19-stack May 18, 2026
a789d24
Add single-panel ensemble forecast plot (paper-style) for one catchment
sonalivyascse19-stack May 18, 2026
1167881
Save per-member precip/PET/initial states and add input/output diagno…
sonalivyascse19-stack May 18, 2026
b8d2a0f
Refactor per-member I/O diagnostic into stacked hydrograph panels
sonalivyascse19-stack May 18, 2026
7bdc961
Per-member I/O diagnostic: fix title overlap and inner subpanel spacing
sonalivyascse19-stack May 18, 2026
866b2fb
Add shaded-band ensemble plot organized by perturbation category
sonalivyascse19-stack May 19, 2026
8d0dc4f
Switch perturbation-category bands from 10-90 percentile to min-max e…
sonalivyascse19-stack May 19, 2026
bbe95b2
Organise da_methods into 4-step Qkrig DA pipeline structure
sonalivyascse19-stack May 20, 2026
992e13f
Fix snap_to_nearest in issue-time hydrograph to handle TimedeltaIndex
sonalivyascse19-stack May 20, 2026
7d45d30
Add lead-time routing, fix ensemble plot zoom and coverage stub
sonalivyascse19-stack May 20, 2026
2ff3005
Add ensemble provenance and restart capability to crossed ensemble
sonalivyascse19-stack May 21, 2026
dea92d9
Remove all personal names from comments and docstrings
sonalivyascse19-stack May 21, 2026
0796820
Rewrite README with full pipeline documentation
sonalivyascse19-stack May 21, 2026
78d0e60
Add three-way comparison script: DA vs routed Qkrig vs USGS
sonalivyascse19-stack May 21, 2026
aa871c5
Add 4-folder experiment structure and supporting scripts
sonalivyascse19-stack May 23, 2026
efa3eb8
Mark Folder 1 4b complete; add 4-folder comparison slides to pptx
sonalivyascse19-stack May 23, 2026
4f0e1c7
Add Folder 2 deterministic analysis and complete 4-folder comparison
sonalivyascse19-stack May 25, 2026
c28aec5
Restructure da_methods into 4 self-contained experiment folders
sonalivyascse19-stack May 25, 2026
07a1a27
Complete F3 and F4 pipeline: scripts, batch runners, eval structure
sonalivyascse19-stack May 25, 2026
84e1db6
Add F3/F4 figures, rebuild pptx to 25 slides, track pptx_figures
sonalivyascse19-stack May 27, 2026
51fdb98
Clean up da_methods root; add F3/F4 4c run scripts
sonalivyascse19-stack May 27, 2026
ba41d5a
Add F3/F4 4c reconstructed timeseries; mark all experiments complete
sonalivyascse19-stack May 27, 2026
00ea480
Rewrite READMEs with 4-experiment structure and seed/obs caveats
sonalivyascse19-stack May 27, 2026
51970f2
Restructure README around pipeline design and add open-loop overlay t…
sonalivyascse19-stack Jun 1, 2026
703e299
Add context section distinguishing distributed CFE, custom T-route wr…
sonalivyascse19-stack Jun 1, 2026
9f0eef9
Move shared calibration and routing scripts to top-level folders
sonalivyascse19-stack Jun 1, 2026
82f01b5
Rename pptx_figures to figures
sonalivyascse19-stack Jun 1, 2026
f258e10
Update build_pptx.py figures path after folder rename
sonalivyascse19-stack Jun 1, 2026
96594d1
Organise figures/ into per-experiment subfolders
sonalivyascse19-stack Jun 1, 2026
96c81c6
Clean up loose figures and fix .gitignore
sonalivyascse19-stack Jun 1, 2026
25471e9
Remove personal name references from code comments
sonalivyascse19-stack Jun 2, 2026
13acdcf
Remove poster draft markdown files from repo
sonalivyascse19-stack Jun 2, 2026
e9878aa
Wrap 20% held-out gauge experiments under test_20prcnt_heldout_gauges/
sonalivyascse19-stack Jun 2, 2026
9427af2
Fix folder name: prcnt → pct (standard abbreviation for percent)
sonalivyascse19-stack Jun 2, 2026
b839fed
Remove build_pptx.py from test folder
sonalivyascse19-stack Jun 2, 2026
1537b8f
Add test_1_heldout_gauge/ skeleton for single held-out gauge experiment
sonalivyascse19-stack Jun 2, 2026
eed03c1
Add gapfill_krig_obs.py for 1-gauge holdout obs preprocessing
sonalivyascse19-stack Jun 2, 2026
17a68db
Add 1-gauge holdout DA pipeline: calibration, EnKF, ensemble, evaluation
sonalivyascse19-stack Jun 2, 2026
536cfdc
Add F1 and F2 evaluation figures for 1-gauge holdout experiment
sonalivyascse19-stack Jun 2, 2026
2300e98
Add F1 perturbation arm figures and plot script for 1-gauge holdout
sonalivyascse19-stack Jun 2, 2026
0661201
Add data-generation and evaluation run scripts for 1-gauge holdout F1…
sonalivyascse19-stack Jun 2, 2026
2516e16
Fix batch scripts to pass --prod-script for calibrate_catchment_cfe_d…
sonalivyascse19-stack Jun 2, 2026
7aaa9f5
Add --obs-dir fallback to 4a lead-time decay scripts (F1 and F2)
sonalivyascse19-stack Jun 3, 2026
3abf35c
Pass --obs-dir to 4a decay plots in run_4a_f1 and run_4a_f2 scripts
sonalivyascse19-stack Jun 3, 2026
d382555
Fix load_obs to handle datetime column name in kriging obs CSVs
sonalivyascse19-stack Jun 3, 2026
667b7df
Add calibrated best_params for 1-gauge holdout experiment (F1 and F2)
sonalivyascse19-stack Jun 3, 2026
9d996ad
Fix 4b/4c eval scripts for 1-gauge holdout: correct paths + obs loading
sonalivyascse19-stack Jun 3, 2026
0d262b0
Fix global declaration order in 4b scripts (must precede use in argpa…
sonalivyascse19-stack Jun 3, 2026
235aeea
Add crossed ensemble plot batch runners for F1 and F2 (1-gauge holdout)
sonalivyascse19-stack Jun 3, 2026
c760ad7
Fallback to production per-member median in run_route.py
sonalivyascse19-stack Jun 3, 2026
2158ab3
Add forcing-arm nowcast fallback in run_route.py for experiments with…
sonalivyascse19-stack Jun 3, 2026
3124740
Add --hardcoded-r to run_production_per_member.py; add F2 production …
sonalivyascse19-stack Jun 3, 2026
2afb5ca
Add Qkrig observation comparison plot for 20% vs 1-gauge holdout
sonalivyascse19-stack Jun 3, 2026
ff5d334
Fix F4 heldout batch script: correct OBS_DIR and OUT_DIR
sonalivyascse19-stack Jun 3, 2026
ad2ccd4
Add F4 evaluation pipeline scripts for 1-gauge holdout
sonalivyascse19-stack Jun 3, 2026
7cb835e
Add --direct-variance to run_production_per_member.py; add F4 product…
sonalivyascse19-stack Jun 3, 2026
3ad675c
Add F4 routed ensemble plots and combined F1-vs-F4 comparison script
sonalivyascse19-stack Jun 3, 2026
e531931
Fix F1 routed CSV path in run_4b_f4.sh (folder1_vrugt_routed)
sonalivyascse19-stack Jun 3, 2026
79f1607
Add batch hydro-arm plot runner for F1, F2, F4 (1-gauge holdout)
sonalivyascse19-stack Jun 3, 2026
6d01f2b
Add F5 re-kriging variance folder: production batch and evaluation sc…
sonalivyascse19-stack Jun 3, 2026
c81fe6a
F5: redirect OUT_DIR and F5_DIR to writable space
sonalivyascse19-stack Jun 3, 2026
f817a56
F5: add deterministic and ensemble routing scripts; extend arm plots …
sonalivyascse19-stack Jun 3, 2026
5337d9a
F5: add representative figures to figures/f5_rekrig_variance_direct
sonalivyascse19-stack Jun 3, 2026
b1e3607
figures: add F2 and F4 representative figures
sonalivyascse19-stack Jun 3, 2026
bf51448
add open-loop batch script for no-DA baseline routing KGE
sonalivyascse19-stack Jun 3, 2026
1ca22dc
add open-loop overlay to 2b hydro-arm plots for all experiments
sonalivyascse19-stack Jun 3, 2026
1016e78
update 2b hydro-arm figures to include open-loop dashed baseline
sonalivyascse19-stack Jun 3, 2026
db0301c
add --ymax clip to arm plots; add F2 replot script with ymax=2200
sonalivyascse19-stack Jun 3, 2026
018ddcc
update F2 2b arm figure with ymax=2200 clip and open-loop overlay
sonalivyascse19-stack Jun 3, 2026
d75e307
fix open-loop overlay to use routed output for correct timing
sonalivyascse19-stack Jun 3, 2026
2f54a1f
update 2b arm figures with routed open-loop baseline (correct timing)
sonalivyascse19-stack Jun 3, 2026
b8994b4
add F5 lead-time forecast sweep and decay plot scripts
sonalivyascse19-stack Jun 4, 2026
610815c
fix F5 lead-time decay plot to fall back to kriged obs file when test…
sonalivyascse19-stack Jun 4, 2026
2498358
fix obs column detection in F5 lead-time plot to skip timestep/index …
sonalivyascse19-stack Jun 4, 2026
cbb5b93
Complete 1-gauge folder: remove F3, add F4/F5 evaluation scripts
sonalivyascse19-stack Jun 4, 2026
6232cbf
add deterministic T-route routing scripts for F1, F2, F4 (1-gauge hol…
sonalivyascse19-stack Jun 4, 2026
0299662
fill spec gaps in 1-gauge folder: route_leadtime, 4a error plots, 4b …
sonalivyascse19-stack Jun 4, 2026
23824d5
add perturbation sensitivity scripts for F2 and F5 (1-gauge holdout)
sonalivyascse19-stack Jun 4, 2026
06960e0
add crossed-ensemble routing scripts for F1/F2/F4; fix F1_DIR path in…
sonalivyascse19-stack Jun 4, 2026
91a42b5
complete F4/F5 assimilation and routing scripts (1-gauge holdout)
sonalivyascse19-stack Jun 4, 2026
4af2b85
add missing figures for 1-gauge holdout: sensitivity, 4b ensemble, 2a…
sonalivyascse19-stack Jun 4, 2026
4a8efd3
complete 20pct folder: add 3_routing, 4a/4b/4c run scripts, missing P…
sonalivyascse19-stack Jun 4, 2026
e5ecba6
Add F5 re-kriged variance experiment for 20% gauge holdout
sonalivyascse19-stack Jun 8, 2026
389ec5c
Add F5 re-kriged variance figures for 20% gauge holdout experiment
sonalivyascse19-stack Jun 8, 2026
1c15475
fix view1 plot title; add F5 lead-time NSE evaluation scripts (20pct …
sonalivyascse19-stack Jun 8, 2026
096b1ee
add F5 lead-time routing script for 20pct holdout
sonalivyascse19-stack Jun 8, 2026
e914438
add F5 lead-time NSE decay figures; fix numpy scalar deprecation warning
sonalivyascse19-stack Jun 8, 2026
e72f58e
add F5 Helene-window ensemble fan plot (gauge 03463300, 20% holdout)
sonalivyascse19-stack Jun 8, 2026
da70519
fix helene ensemble fan: filter by issue_time, group by init day, ext…
sonalivyascse19-stack Jun 8, 2026
ea10e9c
update helene ensemble fan with 120-hour lead forecasts (20% holdout)
sonalivyascse19-stack Jun 8, 2026
40c5437
fix x-axis ticks for 120h leads; use single representative init per d…
sonalivyascse19-stack Jun 9, 2026
2416254
add lead-time forecast sweep script for F5 20pct holdout (FORECAST_LE…
sonalivyascse19-stack Jun 9, 2026
3006bd5
set FORECAST_LEAD_HOURS=18 in F5 20pct lead-time sweep
sonalivyascse19-stack Jun 9, 2026
8ca5cdf
fix x-axis ticks in F5 lead-time NSE plot for 18h leads
sonalivyascse19-stack Jun 9, 2026
81551c2
add catchment-level DA sanity check plot for F5 20pct holdout
sonalivyascse19-stack Jun 9, 2026
f95c2e9
simplify catchment DA check plot to cat-1016300 only
sonalivyascse19-stack Jun 9, 2026
9fa6ce8
add gauge-level routed DA check plot for F5 20pct holdout
sonalivyascse19-stack Jun 9, 2026
a8882f4
remove incorrect hydro arm figure from 20pct F5 figures
sonalivyascse19-stack Jun 9, 2026
821201f
add correctly generated hydro arm figure for F5 20pct holdout
sonalivyascse19-stack Jun 9, 2026
0ec28c8
add hydro arm + open loop plotting script for F5 20pct holdout
sonalivyascse19-stack Jun 9, 2026
702f493
fix USGS unit conversion in hydro arm plot (mm/h to m3/s)
sonalivyascse19-stack Jun 9, 2026
a0ea141
add missing WATERSHED_AREA_KM2 constant to hydro arm plot
sonalivyascse19-stack Jun 9, 2026
d818932
update F5 20pct arm figures with open loop overlay
sonalivyascse19-stack Jun 9, 2026
f22964f
add arm plotting script to F5 20pct folder5
sonalivyascse19-stack Jun 9, 2026
47cf424
replace 2b hydro arm figure with properly T-Route routed version (all…
sonalivyascse19-stack Jun 9, 2026
8ef1c47
fix background shading in hydro arm plot (Helene peak only)
sonalivyascse19-stack Jun 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
10 changes: 10 additions & 0 deletions da_methods/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
*.png
!figures/**/*.png
!**/figures/**/*.png
*.pdf
*.pptx
__pycache__/
~$*
poster_kriging_troute.md
poster_kriging_troute_v2.md
poster_f1_vs_f2.md
866 changes: 866 additions & 0 deletions da_methods/1_distributed_cfe/calibrate_catchment_cfe_da_v2.py

Large diffs are not rendered by default.

350 changes: 350 additions & 0 deletions da_methods/2_troute_routing/run_route.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,350 @@
#!/usr/bin/env python3
"""
run_route.py
Route a single deterministic DA trajectory through T-route Muskingum-Cunge
to gauge 03463300 (South Toe River Near Celo, NC).

Reads per-catchment *_test_results.csv files (sim_mm_h column) from a DA
results directory, routes the full timeseries through the channel network,
and writes routed_Q_test.csv at the gauge outlet.

Works for any DA run that produces the standard _test_results.csv layout:
columns: date, sim_mm_h, obs_mm_h, precip_mm_h

Usage:
/home/svyas/miniconda3/envs/troute/bin/python run_route.py \\
--gpkg /mnt/disk1/usgs_streamflow_allgauges/subdaily_15min/test/gage-03463300_subset.gpkg \\
--da-dir /mnt/disk2/1400_sites_helene/da_results_dynamic_vrugt_seeded \\
--out-dir /mnt/disk2/suma_helen_poster/da_results/dynamic_vrugt_seeded_routed \\
--usgs-csv /mnt/disk2/suma_helen_poster/03463300_usgs_hourly_2018_2024.csv \\
--kv-dir /mnt/disk2/1400_sites_helene/catchment_ts_03463300_spliced_dyn_helene

# For the no-Vrugt (raw variance) seeded run:
/home/svyas/miniconda3/envs/troute/bin/python run_route.py \\
--gpkg /mnt/disk1/usgs_streamflow_allgauges/subdaily_15min/test/gage-03463300_subset.gpkg \\
--da-dir /mnt/disk2/1400_sites_helene/da_results_dynamic_novrugt_seeded \\
--out-dir /mnt/disk2/suma_helen_poster/da_results/dynamic_novrugt_seeded_routed \\
--usgs-csv /mnt/disk2/suma_helen_poster/03463300_usgs_hourly_2018_2024.csv \\
--kv-dir /mnt/disk2/1400_sites_helene/catchment_ts_03463300_spliced_dyn_helene
"""

import argparse
import os
import sys
import sqlite3
import types as _types
from functools import partial

import numpy as np
import pandas as pd

# Coverage stub — required for numba/troute on Python 3.10
_stub = _types.ModuleType('coverage.types')
for _cls in ['Tracer', 'TTraceData', 'TShouldTraceFn', 'TFileDisposition',
'TShouldStartContextFn', 'TWarnFn', 'TTraceFn']:
setattr(_stub, _cls, type(_cls, (), {}))
sys.modules['coverage.types'] = _stub

import troute.nhd_network as nhd_network
from troute.routing.fast_reach.mc_reach import compute_network_structured

TERMINAL_INT = 1016283 # wb-1016283 → gauge 03463300
DT = 3600.0 # 1-hour timestep (seconds)
QTS_SUBDIVISIONS = 1
WATERSHED_AREA_KM2 = 113.18

CATS = [
'cat-1016279', 'cat-1016280', 'cat-1016281', 'cat-1016282', 'cat-1016283',
'cat-1016300', 'cat-1016301', 'cat-1016302', 'cat-1016303', 'cat-1016304',
'cat-1016305', 'cat-1016306', 'cat-1016307', 'cat-1016308', 'cat-1016309',
'cat-1016310', 'cat-1016311', 'cat-1016312', 'cat-1016313', 'cat-1016314',
'cat-1016315',
]


def seg_int(wb_id):
return int(wb_id[3:])


def read_network(gpkg):
con = sqlite3.connect(gpkg)
fp_attr = pd.read_sql(
'SELECT link, "to", BtmWdth, TopWdth, TopWdthCC, n, nCC, ChSlp, So, Length_m '
'FROM "flowpath-attributes"', con)
fp = pd.read_sql('SELECT divide_id, areasqkm FROM flowpaths', con)
con.close()
return fp_attr, fp


def build_connections(fp_attr):
link_set = {seg_int(r) for r in fp_attr['link']}
connections = {}
for _, row in fp_attr.iterrows():
us = seg_int(row['link'])
ds = int(row['to'][4:])
connections[us] = [ds] if ds in link_set else []
return connections


def build_param_df(fp_attr):
rows = [{
'seg_id': seg_int(r['link']),
'dt': float(DT),
'bw': float(r['BtmWdth']), 'tw': float(r['TopWdth']),
'twcc':float(r['TopWdthCC']), 'dx': float(r['Length_m']),
'n': float(r['n']), 'ncc': float(r['nCC']),
'cs': float(r['ChSlp']), 's0': float(r['So']),
'alt': 0.0,
} for _, r in fp_attr.iterrows()]
df = pd.DataFrame(rows).set_index('seg_id').sort_index()
return df.astype('float32')


def route_timeseries(reaches_wTypes, upstreams, param_df, q0_df,
qlat_arr, nts, terminal_pos):
"""Route qlat_arr (n_segs × nts, m³/s) and return Q at terminal (nts,)."""
e1i = np.zeros(0, dtype='int32')
e1f = np.zeros(0, dtype='float32')
e2f = np.zeros((0, nts), dtype='float32')
e00f32 = np.zeros((0, 0), dtype='float32')
e00f64 = np.zeros((0, 0), dtype='float64')
e00i32 = np.zeros((0, 0), dtype='int32')

results = compute_network_structured(
nts, DT, QTS_SUBDIVISIONS,
reaches_wTypes, upstreams,
param_df.index.values.astype('int64'),
param_df.columns.values,
param_df.values,
q0_df.values.astype('float32'),
qlat_arr.astype('float32'),
[], e00f64, {}, e00i32, False,
'2023-10-01_00:00:00',
e2f, e1i, e1i, e1i, e1f, e1f, 0.0,
e2f, e1i, e1f, e1f, e1f, e1f, e1f,
e2f, e1i, e1f, e1f, e1f, e1f, e1f,
e2f, e1i, e1i, [], e1i, e1i, e1f, e1i, e1i,
e1i, e1i, e1f, e1i, e1f, e1i, e1i, e00f32,
)
seg_ids = np.asarray(results[0])
fvd = np.asarray(results[1])
Q_var = fvd[terminal_pos, :nts]
Q_time = fvd[terminal_pos, 0::3]
return Q_var if Q_var.max() > Q_time.max() else Q_time


def load_usgs(usgs_csv):
if not usgs_csv or not os.path.exists(usgs_csv):
return None
df = pd.read_csv(usgs_csv)
date_col = next(c for c in df.columns
if c.lower() in ('datetime', 'date', 'time', 'timestamp'))
q_col = next(c for c in df.columns
if any(k in c.lower() for k in ('q', 'flow', 'discharge')))
df[date_col] = pd.to_datetime(df[date_col])
series = df.set_index(date_col)[q_col].astype(float)
if 'mm' in q_col.lower():
series = series * (WATERSHED_AREA_KM2 * 1000.0 / 3600.0)
return series


def load_krig(kv_dir, dates):
"""Load Qkrig (mm/h) from the outlet catchment obs CSV and convert to m³/s."""
for fname in ('cat-1016300.csv',):
p = os.path.join(kv_dir, fname)
if not os.path.exists(p):
print(f' WARNING: Qkrig file not found: {p}')
return None
df = pd.read_csv(p)
df.columns = [c.strip() for c in df.columns]
# Accept any date-like first column
date_col = next((c for c in df.columns
if c.lower() in ('datetime', 'date', 'time', 'timestamp')),
df.columns[0])
df[date_col] = pd.to_datetime(df[date_col])
# Accept qkrig, qkrig_mm_hr, or any qkrig column without 'var'
q_col = next((c for c in df.columns
if 'qkrig' in c.lower() and 'var' not in c.lower()), None)
if q_col is None:
print(f' WARNING: no qkrig column in {p}. Columns: {list(df.columns)}')
return None
df = df.set_index(date_col).sort_index()
area_m2 = 113.18 * 1e6 # full watershed — Qkrig at outlet catchment
krig_m3s = df[q_col].astype(float) / 1000.0 / 3600.0 * area_m2
return krig_m3s.reindex(dates)
return None


def kge(obs, sim):
mask = np.isfinite(obs) & np.isfinite(sim)
o, s = obs[mask], sim[mask]
if len(o) < 2 or np.std(o) == 0:
return np.nan
r = np.corrcoef(o, s)[0, 1]
return 1.0 - np.sqrt((r-1)**2 + (np.std(s)/np.std(o)-1)**2
+ (np.mean(s)/np.mean(o)-1)**2)


def nse(obs, sim):
mask = np.isfinite(obs) & np.isfinite(sim)
o, s = obs[mask], sim[mask]
if len(o) < 2:
return np.nan
denom = np.sum((o - o.mean())**2)
return 1.0 - np.sum((o - s)**2) / denom if denom > 0 else np.nan


def main():
parser = argparse.ArgumentParser()
parser.add_argument('--gpkg', required=True,
help='GeoPackage with flowpath-attributes and flowpaths tables')
parser.add_argument('--da-dir', required=True,
help='Dir containing <cat>/<cat>_test_results.csv (sim_mm_h column)')
parser.add_argument('--out-dir', required=True,
help='Output directory for routed_Q_test.csv')
parser.add_argument('--usgs-csv', default=None,
help='USGS obs CSV for KGE/NSE summary (optional)')
parser.add_argument('--kv-dir', default=None,
help='Obs dir with per-catchment Qkrig CSVs (optional)')
args = parser.parse_args()
os.makedirs(args.out_dir, exist_ok=True)

# ── Network ────────────────────────────────────────────────────────────
print('Reading network from GPKG...')
fp_attr, fp = read_network(args.gpkg)
connections = build_connections(fp_attr)
rconn = nhd_network.reverse_network(connections)
path_func = partial(nhd_network.split_at_junction, rconn)
reach_list = nhd_network.dfs_decomposition(rconn, path_func)
reaches_wTypes = [(r, 0) for r in reach_list]
upstreams = dict(rconn)
param_df = build_param_df(fp_attr)
n_segs = len(param_df)

area_map = {int(r['divide_id'][4:]): r['areasqkm'] * 1e6
for _, r in fp.iterrows()
if r['divide_id'] and str(r['divide_id']).startswith('cat-')}
print(f' {n_segs} segments | {len(area_map)} catchment areas loaded')

seg_ids_sorted = param_df.index.values
terminal_pos = int(np.where(seg_ids_sorted == TERMINAL_INT)[0][0])
print(f' Terminal wb-{TERMINAL_INT} at index {terminal_pos}')

q0_df = pd.DataFrame(
np.zeros((n_segs, 3), dtype='float32'),
index=param_df.index,
columns=['qu0', 'qd0', 'h0'])

# ── Load per-catchment sim_mm_h ────────────────────────────────────────
print('Loading DA outputs...')
cat_series = {}
missing = []
for cat in CATS:
sid = int(cat[4:])
p = os.path.join(args.da_dir, cat, f'{cat}_test_results.csv')
if os.path.exists(p):
df = pd.read_csv(p, parse_dates=['date'])
df = df.set_index('date').sort_index()
cat_series[sid] = df['sim_mm_h'].astype(float)
continue
# Fallback 1: use ensemble median from production per-member CSV
p_prod = os.path.join(args.da_dir, cat, f'{cat}_production_per_member.csv')
if os.path.exists(p_prod):
df = pd.read_csv(p_prod, parse_dates=['date'])
df = df.set_index('date').sort_index()
mem_cols = [c for c in df.columns if c.startswith('member_')]
cat_series[sid] = df[mem_cols].median(axis=1).astype(float)
continue
# Fallback 2: reconstruct nowcast from forcing arm lead_hour=1
# valid_time = issue_time + 1h; covers only the Helene assimilation window
p_arm = os.path.join(args.da_dir, cat, f'{cat}_da_forcing_arm.csv')
if os.path.exists(p_arm):
df = pd.read_csv(p_arm, parse_dates=['issue_time'])
now = df[df['lead_hour'] == 1].copy()
now['valid_time'] = now['issue_time'] + pd.Timedelta(hours=1)
now = now.set_index('valid_time').sort_index()
mem_cols = [c for c in now.columns if c.startswith('member_')]
cat_series[sid] = now[mem_cols].median(axis=1).astype(float)
continue
missing.append(cat)

if missing:
print(f' WARNING: missing outputs for {missing} — zero inflow assumed')
print(f' Loaded {len(cat_series)}/21 catchments')

# Common time index from first available catchment
ref_dates = next(iter(cat_series.values())).index
nts = len(ref_dates)
print(f' Timesteps: {nts} ({ref_dates[0]} → {ref_dates[-1]})')

# ── Build lateral inflow array (n_segs × nts, m³/s) ───────────────────
print('Building lateral inflow array...')
qlat = np.zeros((n_segs, nts), dtype='float32')
seg_pos = {sid: i for i, sid in enumerate(seg_ids_sorted)}

for sid, q_mm_h in cat_series.items():
if sid not in seg_pos:
continue
area_m2 = area_map.get(sid, 0.0)
if area_m2 == 0.0:
print(f' WARNING: no area for cat-{sid}')
continue
q_m3s = q_mm_h.reindex(ref_dates).fillna(0.0).values / 1000.0 / 3600.0 * area_m2
qlat[seg_pos[sid], :] = q_m3s.astype('float32')

# ── Route ──────────────────────────────────────────────────────────────
print(f'Routing {nts} timesteps through T-route...')
Q_routed = route_timeseries(
reaches_wTypes, upstreams, param_df, q0_df, qlat, nts, terminal_pos)
print(f' Done. Peak routed Q = {Q_routed.max():.2f} m³/s')

# ── Build output DataFrame ─────────────────────────────────────────────
out_df = pd.DataFrame({'date': ref_dates, 'Q_routed_m3s': Q_routed})
out_df = out_df.set_index('date')

# Attach USGS obs
usgs = load_usgs(args.usgs_csv)
if usgs is not None:
out_df['Q_usgs_m3s'] = usgs.reindex(ref_dates)

# Attach Qkrig
if args.kv_dir:
krig = load_krig(args.kv_dir, ref_dates)
if krig is not None:
out_df['Q_krig_m3s'] = krig.values

out_path = os.path.join(args.out_dir, 'routed_Q_test.csv')
out_df.to_csv(out_path)
print(f'Saved: {out_path}')

# ── KGE / NSE summary ─────────────────────────────────────────────────
if 'Q_usgs_m3s' in out_df.columns:
obs = out_df['Q_usgs_m3s'].values
sim = out_df['Q_routed_m3s'].values

helene = ((out_df.index >= '2024-09-24') &
(out_df.index <= '2024-09-29 23:00:00'))

print('\n── Routed vs USGS ─────────────────────────────────────────')
print(f' Full period : KGE={kge(obs, sim):+.3f} NSE={nse(obs, sim):+.3f}'
f' peak_sim={sim.max():.1f} peak_obs={np.nanmax(obs):.1f} m³/s')
if helene.sum() > 0:
oh, sh = obs[helene], sim[helene]
print(f' Helene window: KGE={kge(oh, sh):+.3f} NSE={nse(oh, sh):+.3f}'
f' peak_sim={sh.max():.1f} peak_obs={np.nanmax(oh):.1f} m³/s'
f' ({sh.max()/np.nanmax(oh)*100:.0f}% of USGS)')

if 'Q_krig_m3s' in out_df.columns:
krig_v = out_df['Q_krig_m3s'].values
print(f'\n── Qkrig-routed vs USGS ───────────────────────────────────')
print(f' Full period : KGE={kge(obs, krig_v):+.3f} '
f'NSE={nse(obs, krig_v):+.3f}')
if helene.sum() > 0:
ok, sk = obs[helene], krig_v[helene]
print(f' Helene window: KGE={kge(ok, sk):+.3f} '
f'NSE={nse(ok, sk):+.3f}')
print()


if __name__ == '__main__':
main()
Loading