feat(forecast): Rust forecast crate, bit-for-bit with the Python serving path
CI / Test suite (push) Successful in 1m2s
CI / Format & lint (push) Successful in 22s
Rust / fmt, clippy, test, build (push) Successful in 9m53s
Security / Dependency vulnerabilities (push) Successful in 1m52s
Security / Static analysis (push) Successful in 20s
Security / License report (push) Successful in 1m41s

Phase 2a of docs/RUST_REWRITE.md. rust/forecast (ping-forecast) ports the
serving half of src/ml: the HII mirror gap fill, the hourly grid, every
feature (pandas' Kahan rolling sums included), the Mae Ngat dam frame,
HistGradientBoosting tree inference, the heuristic fallback, stale-threshold
handling and the P.1 flood-stage probabilities.

Hand-over from Python (src/ml/export.py):
- models/flood_<station>.json.gz next to each joblib bundle; train_all() writes
  it, retrain.sh promotes it, `python -m src.ml.export` exports existing ones.
- rust/forecast/flood_config.json: thresholds, stages, leads and notify
  constants, compiled in. tests/test_model_export.py fails while it differs
  from the Python source.

Parity (rust/forecast/tests/parity.rs, fixtures from
testdata/gen_forecast_fixtures.py on the production code path): four
scenarios incl. the 2024 flood with RID lagging behind HII. Merged readings,
all features and all levels identical; with all 15 local models 377/384
probabilities bit-identical, the rest within 2 ulp (exp).

Python refactor without behaviour change: predict.forecasts_from_long, and
get_latest_forecasts/load_latest/serving_frame take now/rain for
reproducible comparisons; the HII query orders its rows.
This commit is contained in:
grabowski committed 2026-10-18 14:21:56 +07:00
1 parent 6d4beae65a
commit 5c4f464383
31 files changed
+2887 -27

No files matched your search

+1
View File
@@ -146,6 +146,7 @@ grafana_data/
# Trained flood-forecast model artifacts (produced on the server, ~100 MB; see docs/FLOOD_FORECASTING.md) # Trained flood-forecast model artifacts (produced on the server, ~100 MB; see docs/FLOOD_FORECASTING.md)
models/*.joblib models/*.joblib
models/*.json.gz
models/cache/ models/cache/
models/metrics.json models/metrics.json
# scripts/retrain.sh working dirs (staging + one rollback generation) # scripts/retrain.sh working dirs (staging + one rollback generation)
+62 -2
View File
@@ -2,6 +2,12 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.5" version = "1.1.5"
@@ -277,6 +283,15 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
[[package]]
name = "crc32fast"
version = "1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "crossbeam-queue" name = "crossbeam-queue"
version = "0.3.14" version = "0.3.14"
@@ -400,6 +415,17 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "flate2"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide",
"zlib-rs",
]
[[package]] [[package]]
name = "flume" name = "flume"
version = "0.11.1" version = "0.11.1"
@@ -969,6 +995,16 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "miniz_oxide"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.2.4" version = "1.2.4"
@@ -1173,6 +1209,18 @@ dependencies = [
"tracing-subscriber", "tracing-subscriber",
] ]
[[package]]
name = "ping-forecast"
version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
"flate2",
"serde",
"serde_json",
"tracing",
]
[[package]] [[package]]
name = "pkcs1" name = "pkcs1"
version = "0.7.5" version = "0.7.5"
@@ -1393,7 +1441,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -1592,6 +1640,12 @@ dependencies = [
"rand_core", "rand_core",
] ]
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@@ -1911,7 +1965,7 @@ dependencies = [
"getrandom 0.4.3", "getrandom 0.4.3",
"once_cell", "once_cell",
"rustix", "rustix",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -2632,6 +2686,12 @@ dependencies = [
"syn 3.0.9", "syn 3.0.9",
] ]
[[package]]
name = "zlib-rs"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.23" version = "1.0.23"
+2 -1
View File
@@ -1,6 +1,6 @@
[workspace] [workspace]
resolver = "3" resolver = "3"
members = ["collector"] members = ["collector", "forecast"]
[workspace.package] [workspace.package]
# 1.88: edition 2024 let-chains, and dependencies (home, icu_*) need it. Distro # 1.88: edition 2024 let-chains, and dependencies (home, icu_*) need it. Distro
@@ -15,6 +15,7 @@ repository = "https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor"
anyhow = "1" anyhow = "1"
chrono = { version = "0.4", default-features = false, features = ["std", "clock", "serde"] } chrono = { version = "0.4", default-features = false, features = ["std", "clock", "serde"] }
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
flate2 = "1"
log = "0.4" log = "0.4"
# native-tls (OpenSSL on Linux, SChannel on Windows), not rustls: app.rid.go.th (dams) # native-tls (OpenSSL on Linux, SChannel on Windows), not rustls: app.rid.go.th (dams)
# offers only TLS 1.2 CBC suites (ECDHE-RSA-AES256-SHA384), which rustls refuses. # offers only TLS 1.2 CBC suites (ECDHE-RSA-AES256-SHA384), which rustls refuses.
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "ping-forecast"
version = "0.1.0"
description = "Ping River flood forecast inference (port of src/ml: features, HistGradientBoosting trees, heuristic fallback)"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
repository.workspace = true
[dependencies]
anyhow.workspace = true
chrono.workspace = true
flate2.workspace = true
serde.workspace = true
serde_json.workspace = true
tracing.workspace = true
[dev-dependencies]
flate2.workspace = true
+348
View File
@@ -0,0 +1,348 @@
{
"thresholds": {
"*": [
3.0,
4.5
],
"P.1": [
3.7,
4.2
],
"P.103": [
5.95,
6.75
],
"P.20": [
2.35,
2.8
],
"P.21": [
3.2,
3.6
],
"P.4A": [
3.4,
3.9
],
"P.5": [
4.55,
4.95
],
"P.67": [
2.45,
2.9
],
"P.75": [
3.2,
3.65
],
"P.76": [
5.35,
5.45
],
"P.77": [
4.3,
4.9
],
"P.81": [
5.15,
6.3
],
"P.82": [
3.4,
3.75
],
"P.84": [
3.45,
3.9
],
"P.85": [
2.9,
3.35
],
"P.87": [
3.75,
4.05
],
"P.92": [
2.95,
3.6
]
},
"flood_stages": {
"P.1": [
{
"stage": 1,
"level": 3.7,
"discharge_cms": 405.0
},
{
"stage": 2,
"level": 3.9,
"discharge_cms": 438.0
},
{
"stage": 3,
"level": 4.0,
"discharge_cms": 458.0
},
{
"stage": 4,
"level": 4.1,
"discharge_cms": 478.0
},
{
"stage": 5,
"level": 4.2,
"discharge_cms": 493.0
},
{
"stage": 6,
"level": 4.3,
"discharge_cms": 508.0
},
{
"stage": 7,
"level": 4.6,
"discharge_cms": 558.0
}
]
},
"upstream_leads": {
"P.1": [
[
"P.103",
1
],
[
"P.67",
7
],
[
"P.21",
9
],
[
"P.75",
12
],
[
"P.4A",
12
],
[
"P.92",
15
],
[
"P.20",
17
]
],
"P.103": [
[
"P.67",
6
],
[
"P.21",
8
],
[
"P.75",
11
],
[
"P.4A",
11
],
[
"P.92",
14
],
[
"P.20",
16
]
],
"P.21": [
[
"P.67",
1
],
[
"P.75",
3
],
[
"P.4A",
3
],
[
"P.92",
6
],
[
"P.20",
8
]
],
"P.67": [
[
"P.75",
5
],
[
"P.4A",
5
],
[
"P.92",
8
],
[
"P.20",
10
]
],
"P.75": [
[
"P.92",
3
],
[
"P.20",
5
]
],
"P.4A": [
[
"P.92",
3
],
[
"P.20",
5
]
],
"P.92": [
[
"P.20",
2
]
],
"P.20": [],
"P.5": [
[
"P.1",
12
],
[
"P.103",
13
]
],
"P.81": [
[
"P.1",
4
],
[
"P.103",
5
]
],
"P.82": [],
"P.84": [],
"P.87": [],
"P.77": [],
"P.85": [],
"P.76": []
},
"dam_stations": [
"P.1",
"P.103",
"P.21",
"P.5",
"P.67",
"P.81"
],
"monsoon_months": [
6,
7,
8,
9,
10
],
"ffill_limit_h": 3,
"basin_anchor": "P.1",
"forward_rain_h": 24,
"horizons": [
6,
12,
24
],
"stale_after_h": 6.0,
"heuristic_sigma": 0.3,
"heuristic_version": "heuristic-v1",
"latest_window_h": 336,
"hii_fill_stations": [
"P.1",
"P.103",
"P.20",
"P.4A",
"P.67",
"P.75",
"P.82",
"P.84",
"P.92",
"P.81"
],
"hii_exact_mirrors": [
"P.1",
"P.103",
"P.20",
"P.4A",
"P.67",
"P.75",
"P.82",
"P.84",
"P.92"
],
"hii_min_overlap_hours": 168,
"dam": {
"dam_id": "200103",
"report_hour": 7,
"ffill_limit_h": 48,
"serving_days": 21
},
"notify": {
"clear_margin_m": 0.1,
"capacity_guard_min_pct": 60.0,
"capacity_guard_exempt": [
"P.1"
],
"outlook_on": 0.5,
"outlook_off": 0.25,
"outlook_horizon": 24,
"feed_stale_after_h": 3.0,
"station_names": {
"P.1": "Nawarat Bridge, Chiang Mai city",
"P.103": "Ring Road Bridge 3, Chiang Mai",
"P.67": "Ban Tae (Mae Taeng)",
"P.21": "Ban Rim Tai (Mae Rim)",
"P.75": "Ban Chai Lat",
"P.92": "Ban Muang Aut",
"P.20": "Ban Chiang Dao",
"P.4A": "Ban Mae Taeng",
"P.5": "Tha Nang Bridge (downstream)",
"P.81": "Ban Pong (downstream)",
"P.82": "Ban Sob Win",
"P.84": "Ban Panton",
"P.87": "Ban Pa Sang",
"P.77": "Ban Sop Mae Sapuat",
"P.85": "Ban Lai Kaew",
"P.76": "Ban Mae I Hai"
}
}
}
+106
View File
@@ -0,0 +1,106 @@
//! Constants shared with Python, generated from it: `flood_config.json` is
//! written by `uv run python -m src.ml.export --config`, and
//! tests/test_model_export.py fails while it differs from the Python source.
use serde::Deserialize;
use std::collections::BTreeMap;
use std::sync::OnceLock;
#[derive(Debug, Deserialize)]
pub struct Stage {
pub stage: u32,
pub level: f64,
pub discharge_cms: f64,
}
#[derive(Debug, Deserialize)]
pub struct DamConfig {
pub dam_id: String,
pub report_hour: u32,
pub ffill_limit_h: usize,
pub serving_days: i64,
}
#[derive(Debug, Deserialize)]
pub struct NotifyConfig {
pub clear_margin_m: f64,
pub capacity_guard_min_pct: f64,
pub capacity_guard_exempt: Vec<String>,
pub outlook_on: f64,
pub outlook_off: f64,
pub outlook_horizon: u32,
pub feed_stale_after_h: f64,
pub station_names: BTreeMap<String, String>,
}
#[derive(Debug, Deserialize)]
pub struct FloodConfig {
thresholds: BTreeMap<String, (f64, f64)>,
pub flood_stages: BTreeMap<String, Vec<Stage>>,
upstream_leads: BTreeMap<String, Vec<(String, i64)>>,
pub dam_stations: Vec<String>,
pub monsoon_months: Vec<u32>,
pub ffill_limit_h: usize,
pub basin_anchor: String,
pub forward_rain_h: usize,
pub horizons: Vec<u32>,
pub stale_after_h: f64,
pub heuristic_sigma: f64,
pub heuristic_version: String,
pub latest_window_h: i64,
pub hii_fill_stations: Vec<String>,
pub hii_exact_mirrors: Vec<String>,
pub hii_min_overlap_hours: usize,
pub dam: DamConfig,
pub notify: NotifyConfig,
}
impl FloodConfig {
/// (warning, danger) for a station, or the "*" default.
pub fn thresholds(&self, station: &str) -> (f64, f64) {
self.thresholds
.get(station)
.or_else(|| self.thresholds.get("*"))
.copied()
.unwrap_or((f64::INFINITY, f64::INFINITY))
}
/// (upstream station, lead hours) pairs, in Python's order.
pub fn upstream_leads(&self, station: &str) -> &[(String, i64)] {
self.upstream_leads.get(station).map_or(&[], Vec::as_slice)
}
/// The stations the models are built for (Python's `UPSTREAM_LEADS` keys).
pub fn forecast_stations(&self) -> impl Iterator<Item = &str> {
self.upstream_leads.keys().map(String::as_str)
}
pub fn is_dam_station(&self, station: &str) -> bool {
self.dam_stations.iter().any(|s| s == station)
}
}
pub fn config() -> &'static FloodConfig {
static CONFIG: OnceLock<FloodConfig> = OnceLock::new();
CONFIG.get_or_init(|| {
serde_json::from_str(include_str!("../flood_config.json"))
.expect("flood_config.json is generated by src/ml/export.py and must parse")
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generated_config_parses_and_has_the_basin() {
let c = config();
assert_eq!(c.thresholds("P.1"), (3.7, 4.2));
assert_eq!(c.thresholds("P.999"), (3.0, 4.5));
assert_eq!(c.upstream_leads("P.1")[0], ("P.103".to_string(), 1));
assert!(c.upstream_leads("P.82").is_empty());
assert_eq!(c.forecast_stations().count(), 16);
assert_eq!(c.horizons, vec![6, 12, 24]);
assert!(c.is_dam_station("P.1") && !c.is_dam_station("P.20"));
}
}
+98
View File
@@ -0,0 +1,98 @@
//! Mae Ngat reservoir state for the dam features (`src/ml/dam.py`).
use crate::config::config;
use crate::rolling::ffill;
use chrono::{Duration, NaiveDate, NaiveDateTime};
use std::collections::BTreeMap;
/// One `rid_reservoir_daily` row; NaN = missing.
#[derive(Clone, Debug, PartialEq)]
pub struct DamDay {
pub date: NaiveDate,
pub storage_pct: f64,
pub inflow_mcm: f64,
pub outflow_mcm: f64,
}
/// Hourly dam state: each daily report valid from 07:00 of its date,
/// forward-filled at most 48 h (`dam.hourly_frame`).
#[derive(Clone, Debug, Default)]
pub struct DamFrame {
pub hours: BTreeMap<NaiveDateTime, [f64; 3]>,
}
impl DamFrame {
/// Columns storage_pct, inflow_mcm, outflow_mcm on the given hours
/// (`DataFrame.reindex`); an empty frame gives NaN columns.
pub fn reindex(&self, times: &[NaiveDateTime]) -> [Vec<f64>; 3] {
let col = |k: usize| {
times
.iter()
.map(|t| self.hours.get(t).map_or(f64::NAN, |r| r[k]))
.collect()
};
[col(0), col(1), col(2)]
}
}
/// `dam.hourly_frame`; `None` for no rows (Python then serves an empty frame).
pub fn hourly_frame(days: &[DamDay]) -> Option<DamFrame> {
let cfg = &config().dam;
let mut by_time: BTreeMap<NaiveDateTime, [f64; 3]> = BTreeMap::new();
for d in days {
let t = d.date.and_hms_opt(cfg.report_hour, 0, 0)?;
by_time.insert(t, [d.storage_pct, d.inflow_mcm, d.outflow_mcm]); // keep last
}
let first = *by_time.keys().next()?;
let last = *by_time.keys().next_back()? + Duration::hours(cfg.ffill_limit_h as i64);
let n = ((last - first).num_hours() + 1) as usize;
let times: Vec<NaiveDateTime> = (0..n).map(|i| first + Duration::hours(i as i64)).collect();
let cols: Vec<Vec<f64>> = (0..3)
.map(|k| {
let raw: Vec<f64> = times
.iter()
.map(|t| by_time.get(t).map_or(f64::NAN, |r| r[k]))
.collect();
ffill(&raw, cfg.ffill_limit_h)
})
.collect();
Some(DamFrame {
hours: times
.iter()
.enumerate()
.map(|(i, t)| (*t, [cols[0][i], cols[1][i], cols[2][i]]))
.collect(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn day(d: u32, storage: f64) -> DamDay {
DamDay {
date: NaiveDate::from_ymd_opt(2026, 10, d).unwrap(),
storage_pct: storage,
inflow_mcm: 1.0,
outflow_mcm: 2.0,
}
}
#[test]
fn report_valid_from_seven_and_filled_two_days() {
let f = hourly_frame(&[day(1, 50.0), day(5, 60.0)]).unwrap();
let at = |d: u32, h: u32| {
NaiveDate::from_ymd_opt(2026, 10, d)
.unwrap()
.and_hms_opt(h, 0, 0)
.unwrap()
};
assert!(!f.hours.contains_key(&at(1, 6)));
assert_eq!(f.hours[&at(1, 7)][0], 50.0);
assert_eq!(f.hours[&at(3, 7)][0], 50.0); // +48 h
assert!(f.hours[&at(3, 8)][0].is_nan());
assert_eq!(f.hours[&at(7, 7)][0], 60.0);
assert_eq!(f.hours.keys().next_back(), Some(&at(7, 7)));
assert!(hourly_frame(&[]).is_none());
}
}
+166
View File
@@ -0,0 +1,166 @@
//! `features.build_features`: the feature columns for one target station,
//! in Python's construction order. Features are causal except `rain_fc24`,
//! the forward sum of the weather forecast.
use crate::config::config;
use crate::dam::DamFrame;
use crate::grid::Grid;
use crate::rain::{RainSeries, reindex};
use crate::rolling::*;
use chrono::{Datelike, Duration};
use std::f64::consts::PI;
pub type Columns = Vec<(String, Series)>;
/// `rain`: `None` omits the rain columns (Python's `rain=None`), an empty
/// series gives NaN columns. Same for `dam`, which only DAM_STATIONS receive.
pub fn build_features(
grid: &Grid,
station: &str,
rain: Option<&RainSeries>,
dam: Option<&DamFrame>,
) -> Columns {
let cfg = config();
let n = grid.len();
let ffill_h = cfg.ffill_limit_h;
let filled = |s: &str| -> Series {
grid.level
.get(s)
.map_or_else(|| nan_series(n), |v| ffill(v, ffill_h))
};
let mut cols: Columns = Vec::with_capacity(80);
let mut put = |name: String, s: Series| cols.push((name, s));
let level = filled(station);
let discharge = grid
.discharge
.get(station)
.cloned()
.unwrap_or_else(|| nan_series(n));
let mask: Vec<bool> = grid.level.get(station).map_or_else(
|| vec![false; n],
|v| v.iter().map(|x| !x.is_nan()).collect(),
);
put("level".into(), level.clone());
for k in [1, 2, 3, 6, 12, 24, 48, 72] {
put(format!("level_lag_{k}"), shift(&level, k));
}
for k in [1, 3, 6, 12, 24] {
put(format!("rise_{k}"), sub(&level, &shift(&level, k)));
}
put("roll_mean_6".into(), rolling_mean(&level, 6));
put("roll_mean_24".into(), rolling_mean(&level, 24));
put("roll_max_6".into(), rolling_max(&level, 6));
put("roll_max_24".into(), rolling_max(&level, 24));
put("roll_max_72".into(), rolling_max(&level, 72));
put("roll_min_24".into(), rolling_min(&level, 24));
put("discharge".into(), discharge.clone());
put("discharge_lag_6".into(), shift(&discharge, 6));
put("discharge_lag_24".into(), shift(&discharge, 24));
put(
"discharge_rise_6".into(),
sub(&discharge, &shift(&discharge, 6)),
);
// hours since the last observed level, NaN beyond the ffill limit
let mut last: Option<usize> = None;
let age: Series = mask
.iter()
.enumerate()
.map(|(i, &m)| {
if m {
last = Some(i);
}
match last {
Some(l) if i - l <= ffill_h => (i - l) as f64,
_ => f64::NAN,
}
})
.collect();
put("obs_age_h".into(), age);
let mask_f: Series = mask.iter().map(|&m| if m { 1.0 } else { 0.0 }).collect();
put("cov_24h".into(), rolling_mean(&mask_f, 24));
for (up, lead) in cfg.upstream_leads(station) {
let lead = *lead;
let u = filled(up);
let u_rise_6 = sub(&u, &shift(&u, 6));
let u_rollmax_24 = rolling_max(&u, 24);
let near = (lead - 3).max(0);
put(format!("{up}_level_lag_{near}"), shift(&u, near));
put(format!("{up}_level_lag_{lead}"), shift(&u, lead));
put(format!("{up}_level_lag_{}", lead + 3), shift(&u, lead + 3));
put(format!("{up}_rise_6_lag_{lead}"), shift(&u_rise_6, lead));
put(
format!("{up}_rollmax_24_lag_{near}"),
shift(&u_rollmax_24, near),
);
}
if station != cfg.basin_anchor {
let p1 = filled(&cfg.basin_anchor);
let rollmax = rolling_max(&p1, 24);
let rise = sub(&p1, &shift(&p1, 24));
put("P1_level".into(), p1);
put("P1_rollmax_24".into(), rollmax);
put("P1_rise_24".into(), rise);
}
let mut put = |name: &str, s: Series| cols.push((name.to_string(), s));
let doy: Vec<f64> = grid.times.iter().map(|t| t.ordinal() as f64).collect();
put(
"doy_sin",
doy.iter().map(|d| (2.0 * PI * d / 365.25).sin()).collect(),
);
put(
"doy_cos",
doy.iter().map(|d| (2.0 * PI * d / 365.25).cos()).collect(),
);
put(
"is_monsoon",
grid.times
.iter()
.map(|t| {
if cfg.monsoon_months.contains(&t.month()) {
1.0
} else {
0.0
}
})
.collect(),
);
if let Some(rain) = rain {
let r = reindex(rain, &grid.times);
put("rain_6h", rolling_sum(&r, 6));
put("rain_24h", rolling_sum(&r, 24));
put("rain_72h", rolling_sum(&r, 72));
// forward sum over (t, t+24], on the grid extended past its last row
// so the newest row sees the weather forecast
let mut ext_times = grid.times.clone();
if let Some(&last) = grid.times.last() {
ext_times.extend((1..=cfg.forward_rain_h as i64).map(|h| last + Duration::hours(h)));
}
let mut fc = forward_sum(&reindex(rain, &ext_times), cfg.forward_rain_h);
fc.truncate(grid.len());
put("rain_fc24", fc);
}
if let Some(dam) = dam
&& cfg.is_dam_station(station)
{
let [storage, inflow, outflow] = dam.reindex(&grid.times);
put("dam_storage_pct", storage.clone());
put("dam_storage_pct_d3", sub(&storage, &shift(&storage, 72)));
put("dam_inflow", inflow);
put("dam_outflow", outflow);
}
cols
}
/// The row at index `i` as name -> value.
pub fn row_at(cols: &Columns, i: usize) -> std::collections::HashMap<&str, f64> {
cols.iter().map(|(k, v)| (k.as_str(), v[i])).collect()
}
+86
View File
@@ -0,0 +1,86 @@
//! Long readings -> complete hourly grid (`features.make_hourly_grid`).
use crate::rolling::{Series, nan_series};
use chrono::{Duration, NaiveDateTime, Timelike};
use std::collections::BTreeMap;
/// One reading in long format; NaN = missing.
#[derive(Clone, Debug, PartialEq)]
pub struct Row {
pub timestamp: NaiveDateTime,
pub station_code: String,
pub water_level: f64,
pub discharge: f64,
}
pub fn floor_hour(t: NaiveDateTime) -> NaiveDateTime {
t.with_nanosecond(0)
.and_then(|t| t.with_second(0))
.and_then(|t| t.with_minute(0))
.unwrap_or(t)
}
/// Every hour from the first to the last reading; per station the observed
/// (unfilled) level and discharge, NaN where nothing was recorded.
pub struct Grid {
pub times: Vec<NaiveDateTime>,
pub level: BTreeMap<String, Series>,
pub discharge: BTreeMap<String, Series>,
}
impl Grid {
/// Timestamps are floored to the hour; for a duplicate (station, hour)
/// the later row wins (pandas `drop_duplicates(keep="last")`).
pub fn from_rows(rows: &[Row]) -> Grid {
let mut cells: BTreeMap<(String, NaiveDateTime), (f64, f64)> = BTreeMap::new();
for r in rows {
cells.insert(
(r.station_code.clone(), floor_hour(r.timestamp)),
(r.water_level, r.discharge),
);
}
let (Some(first), Some(last)) = (
cells.keys().map(|k| k.1).min(),
cells.keys().map(|k| k.1).max(),
) else {
return Grid {
times: vec![],
level: BTreeMap::new(),
discharge: BTreeMap::new(),
};
};
let n = ((last - first).num_hours() + 1) as usize;
let times: Vec<NaiveDateTime> = (0..n).map(|i| first + Duration::hours(i as i64)).collect();
let mut level: BTreeMap<String, Series> = BTreeMap::new();
let mut discharge: BTreeMap<String, Series> = BTreeMap::new();
for ((code, t), (l, q)) in cells {
let i = (t - first).num_hours() as usize;
level.entry(code.clone()).or_insert_with(|| nan_series(n))[i] = l;
discharge.entry(code).or_insert_with(|| nan_series(n))[i] = q;
}
Grid {
times,
level,
discharge,
}
}
pub fn len(&self) -> usize {
self.times.len()
}
pub fn is_empty(&self) -> bool {
self.times.is_empty()
}
pub fn index_of(&self, t: NaiveDateTime) -> Option<usize> {
let first = *self.times.first()?;
let i = (t - first).num_hours();
(i >= 0 && (i as usize) < self.len() && self.times[i as usize] == t).then_some(i as usize)
}
/// The station's last hour with an observed level.
pub fn last_observed(&self, station: &str) -> Option<usize> {
self.level.get(station)?.iter().rposition(|x| !x.is_nan())
}
}
+104
View File
@@ -0,0 +1,104 @@
//! Inference for scikit-learn HistGradientBoosting heads exported as flat
//! tree arrays (src/ml/export.py). Mirrors sklearn's `_predictor.pyx`: NaN
//! follows `missing_go_to_left`, otherwise `x <= threshold` goes left; raw =
//! baseline + leaf values summed in tree order (sklearn's order, so the
//! regression output is bit-identical).
use anyhow::{Result, bail};
use serde::Deserialize;
fn thresholds<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<f64>, D::Error> {
let raw: Vec<Option<f64>> = Deserialize::deserialize(d)?;
Ok(raw
.into_iter()
.map(|t| t.unwrap_or(f64::INFINITY))
.collect())
}
#[derive(Debug, Deserialize)]
pub struct Tree {
feature: Vec<usize>,
#[serde(deserialize_with = "thresholds")]
threshold: Vec<f64>,
missing_left: Vec<bool>,
left: Vec<usize>,
right: Vec<usize>,
leaf: Vec<bool>,
value: Vec<f64>,
}
impl Tree {
#[inline]
fn predict(&self, x: &[f64]) -> f64 {
let mut n = 0;
while !self.leaf[n] {
let v = x[self.feature[n]];
let go_left = if v.is_nan() {
self.missing_left[n]
} else {
v <= self.threshold[n]
};
n = if go_left { self.left[n] } else { self.right[n] };
}
self.value[n]
}
fn validate(&self, n_features: usize) -> Result<()> {
let n = self.value.len();
if n == 0
|| [
self.feature.len(),
self.threshold.len(),
self.missing_left.len(),
self.left.len(),
self.right.len(),
self.leaf.len(),
]
.iter()
.any(|&l| l != n)
{
bail!("tree arrays have different lengths");
}
for i in 0..n {
if !self.leaf[i]
&& (self.feature[i] >= n_features || self.left[i] >= n || self.right[i] >= n)
{
bail!("tree node {i} points outside the tree or the feature vector");
}
}
Ok(())
}
}
#[derive(Debug, Deserialize, PartialEq, Eq, Clone, Copy)]
#[serde(rename_all = "lowercase")]
pub enum Kind {
Regressor,
Classifier,
}
#[derive(Debug, Deserialize)]
pub struct Head {
pub kind: Kind,
baseline: f64,
trees: Vec<Tree>,
}
impl Head {
/// Regressor: the prediction. Classifier: P(class 1), as
/// `predict_proba(X)[:, 1]` (scipy's `expit`).
pub fn predict(&self, x: &[f64]) -> f64 {
let mut raw = self.baseline;
for t in &self.trees {
raw += t.predict(x);
}
match self.kind {
Kind::Regressor => raw,
Kind::Classifier => 1.0 / (1.0 + (-raw).exp()),
}
}
pub fn validate(&self, n_features: usize) -> Result<()> {
self.trees.iter().try_for_each(|t| t.validate(n_features))
}
}
+224
View File
@@ -0,0 +1,224 @@
//! Gap fill from the HII/ThaiWater mirror telemetry (`data.fill_from_hii`).
//!
//! For stations whose HII twin is the same sensor, hours missing from the RID
//! series (or carrying no level) are filled from HII, shifted by the median
//! offset between the two series over their overlap. This is also what keeps
//! the forecast current at night while RID's report lags.
use crate::config::config;
use crate::grid::{Row, floor_hour};
use chrono::NaiveDateTime;
use std::collections::{BTreeMap, BTreeSet, HashSet};
/// One `hii_waterlevel` row joined to its RID station code; NaN = missing.
#[derive(Clone, Debug, PartialEq)]
pub struct HiiRow {
pub timestamp: NaiveDateTime,
pub station_code: String,
pub wl_msl: f64,
pub discharge: f64,
}
/// `data._prepare_hii_levels`: rows without a level dropped, one row per
/// station-hour, the latest reading of the hour winning (stable on ties).
pub fn prepare(rows: &[HiiRow]) -> Vec<HiiRow> {
let mut kept: Vec<&HiiRow> = rows.iter().filter(|r| !r.wl_msl.is_nan()).collect();
kept.sort_by_key(|r| r.timestamp); // stable
let mut by_hour: BTreeMap<(String, NaiveDateTime), HiiRow> = BTreeMap::new();
for r in kept {
let t = floor_hour(r.timestamp);
by_hour.insert(
(r.station_code.clone(), t),
HiiRow {
timestamp: t,
..r.clone()
},
);
}
by_hour.into_values().collect()
}
/// The HII station codes worth fetching for these RID rows.
pub fn fill_candidates(rid: &[Row]) -> Vec<String> {
let present: HashSet<&str> = rid.iter().map(|r| r.station_code.as_str()).collect();
config()
.hii_fill_stations
.iter()
.filter(|c| present.contains(c.as_str()))
.cloned()
.collect()
}
/// Per-station result of the fill, for logs and the notifier.
#[derive(Clone, Debug, PartialEq)]
pub struct FillInfo {
pub station_code: String,
pub offset: f64,
pub filled_hours: usize,
}
/// pandas `Series.median` (numpy: mean of the two middle values when even).
fn median(mut v: Vec<f64>) -> f64 {
v.sort_by(f64::total_cmp);
let n = v.len();
if n % 2 == 1 {
v[n / 2]
} else {
(v[n / 2 - 1] + v[n / 2]) / 2.0
}
}
/// `data.fill_from_hii`: `rid` as loaded (already one row per station-hour),
/// `hii` from [`prepare`]. Returns the merged rows (fill rows replace RID rows
/// that have no level) and what was filled.
pub fn fill(rid: &[Row], hii: &[HiiRow]) -> (Vec<Row>, Vec<FillInfo>) {
let cfg = config();
let candidates: BTreeSet<String> = fill_candidates(rid).into_iter().collect();
let mut mirrors: BTreeMap<&str, Vec<&HiiRow>> = BTreeMap::new();
for h in hii {
if candidates.contains(&h.station_code) {
mirrors.entry(h.station_code.as_str()).or_default().push(h);
}
}
let mut fills: Vec<Row> = Vec::new();
let mut info = Vec::new();
for (code, mirror) in mirrors {
let base: BTreeMap<NaiveDateTime, f64> = rid
.iter()
.filter(|r| r.station_code == code)
.map(|r| (r.timestamp, r.water_level))
.collect();
let diffs: Vec<f64> = mirror
.iter()
.filter_map(|m| match base.get(&m.timestamp) {
Some(level) if !level.is_nan() => Some(m.wl_msl - level),
_ => None,
})
.collect();
if diffs.len() < cfg.hii_min_overlap_hours {
continue;
}
let offset = median(diffs);
let exact = cfg.hii_exact_mirrors.iter().any(|c| c == code);
let before = fills.len();
for m in mirror {
if base.get(&m.timestamp).is_some_and(|l| !l.is_nan()) {
continue;
}
fills.push(Row {
timestamp: m.timestamp,
station_code: code.to_string(),
water_level: m.wl_msl - offset,
discharge: if exact { m.discharge } else { f64::NAN },
});
}
if fills.len() > before {
info.push(FillInfo {
station_code: code.to_string(),
offset,
filled_hours: fills.len() - before,
});
}
}
if fills.is_empty() {
return (rid.to_vec(), info);
}
// concat + drop_duplicates(keep="last"): a fill replaces a level-less RID row
let mut merged: BTreeMap<(String, NaiveDateTime), Row> = BTreeMap::new();
for r in rid.iter().chain(fills.iter()) {
merged.insert((r.station_code.clone(), r.timestamp), r.clone());
}
(merged.into_values().collect(), info)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Duration, NaiveDate};
fn t0() -> NaiveDateTime {
NaiveDate::from_ymd_opt(2026, 10, 1)
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap()
}
fn rid(code: &str, hours: std::ops::Range<i64>) -> Vec<Row> {
hours
.map(|h| Row {
timestamp: t0() + Duration::hours(h),
station_code: code.into(),
water_level: 1.0 + h as f64 * 0.01,
discharge: 50.0,
})
.collect()
}
fn mirror(code: &str, hours: std::ops::Range<i64>, offset: f64) -> Vec<HiiRow> {
hours
.map(|h| HiiRow {
timestamp: t0() + Duration::hours(h),
station_code: code.into(),
wl_msl: 1.0 + h as f64 * 0.01 + offset,
discharge: 51.0,
})
.collect()
}
#[test]
fn trailing_hours_filled_with_the_overlap_offset() {
let r = rid("P.1", 0..200);
let h = prepare(&mirror("P.1", 0..203, 300.0));
let (merged, info) = fill(&r, &h);
assert_eq!(merged.len(), 203);
assert_eq!(info[0].filled_hours, 3);
let last = merged.last().unwrap();
assert_eq!(last.timestamp, t0() + Duration::hours(202));
assert!((last.water_level - 3.02).abs() < 1e-9);
assert_eq!(last.discharge, 51.0); // exact mirror keeps discharge
}
#[test]
fn short_overlap_or_other_sensor_is_not_merged() {
let r = rid("P.1", 0..100);
assert_eq!(
fill(&r, &prepare(&mirror("P.1", 0..103, 300.0))).0.len(),
100
);
let r = rid("P.77", 0..200);
assert_eq!(
fill(&r, &prepare(&mirror("P.77", 0..203, 300.0))).0.len(),
200
);
}
#[test]
fn p81_is_bias_corrected_without_discharge() {
let (merged, _) = fill(&rid("P.81", 0..200), &prepare(&mirror("P.81", 0..201, 5.0)));
assert!(merged.last().unwrap().discharge.is_nan());
}
#[test]
fn latest_reading_of_an_hour_wins() {
let mut rows = mirror("P.1", 0..1, 300.0);
rows.push(HiiRow {
timestamp: t0() + Duration::minutes(40),
wl_msl: 302.0,
..rows[0].clone()
});
rows.push(HiiRow {
timestamp: t0() + Duration::minutes(10),
wl_msl: 301.5,
..rows[0].clone()
});
rows.push(HiiRow {
wl_msl: f64::NAN,
timestamp: t0() + Duration::minutes(50),
..rows[0].clone()
});
let p = prepare(&rows);
assert_eq!(p.len(), 1);
assert_eq!(p[0].wl_msl, 302.0);
assert_eq!(p[0].timestamp, t0());
}
}
+24
View File
@@ -0,0 +1,24 @@
//! Flood forecast inference: a port of the serving path of `src/ml`
//! (`features.py`, `predict.py`, the HII gap fill of `data.py`, `dam.py`).
//!
//! Training stays in Python (scikit-learn). `src/ml/export.py` writes each
//! trained bundle as `models/flood_<station>.json.gz` and the shared constants
//! as `flood_config.json` (compiled in). Given the same inputs, this crate
//! reproduces Python's forecasts: features and levels bit for bit,
//! probabilities to the last bits of `exp` (see `tests/parity.rs`).
pub mod config;
pub mod dam;
pub mod features;
pub mod grid;
pub mod hgb;
pub mod hii;
pub mod model;
pub mod predict;
pub mod rain;
pub mod rolling;
pub use config::config;
pub use grid::Row;
pub use model::{Bundle, ModelSet, ModelStore};
pub use predict::{Forecast, StageProbability, forecasts};
+165
View File
@@ -0,0 +1,165 @@
//! Trained bundles exported by src/ml/export.py (`flood_<station>.json.gz`),
//! reloaded when a retrain replaces a file.
use crate::hgb::Head;
use anyhow::{Context, Result, bail};
use serde::Deserialize;
use std::collections::BTreeMap;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::SystemTime;
pub const FORMAT_VERSION: u32 = 1;
#[derive(Debug, Deserialize)]
pub struct BundleThresholds {
pub warning: f64,
pub danger: f64,
}
#[derive(Debug, Deserialize)]
pub struct Bundle {
pub format_version: u32,
pub station_code: String,
pub model_version: String,
pub trained_at: Option<String>,
pub regression_target: Option<String>,
pub feature_names: Vec<String>,
pub horizons: Vec<u32>,
/// Validation-residual sigma per horizon (keys are the hours as text).
pub sigma: BTreeMap<String, f64>,
pub thresholds: BundleThresholds,
/// `max_<h>` regressors and `warn_<h>` / `danger_<h>` classifiers; a head
/// skipped at training time is null.
pub heads: BTreeMap<String, Option<Head>>,
}
impl Bundle {
pub fn head(&self, name: &str) -> Option<&Head> {
self.heads.get(name).and_then(Option::as_ref)
}
pub fn sigma(&self, horizon: u32, default: f64) -> f64 {
self.sigma
.get(&horizon.to_string())
.copied()
.unwrap_or(default)
}
pub fn from_json(text: &str) -> Result<Self> {
let bundle: Bundle = serde_json::from_str(text)?;
if bundle.format_version != FORMAT_VERSION {
bail!(
"bundle format {} (this build reads {FORMAT_VERSION}): rebuild ping-collector",
bundle.format_version
);
}
for (name, head) in &bundle.heads {
if let Some(h) = head {
h.validate(bundle.feature_names.len())
.with_context(|| format!("head {name}"))?;
}
}
Ok(bundle)
}
pub fn load(path: &Path) -> Result<Self> {
let raw = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
let text = if path.extension().is_some_and(|e| e == "gz") {
let mut s = String::new();
flate2::read::GzDecoder::new(&raw[..])
.read_to_string(&mut s)
.with_context(|| format!("decompressing {}", path.display()))?;
s
} else {
String::from_utf8(raw)?
};
Self::from_json(&text).with_context(|| format!("parsing {}", path.display()))
}
}
/// The bundles available for one forecast run, by station.
pub type ModelSet = BTreeMap<String, Arc<Bundle>>;
/// `models/flood_<station>.json.gz`, cached by modification time.
pub struct ModelStore {
dir: PathBuf,
loaded: BTreeMap<String, (SystemTime, Arc<Bundle>)>,
}
fn station_of(path: &Path) -> Option<String> {
let name = path.file_name()?.to_str()?;
let code = name.strip_prefix("flood_")?.strip_suffix(".json.gz")?;
(!code.is_empty()).then(|| code.to_string())
}
impl ModelStore {
pub fn new(dir: impl Into<PathBuf>) -> Self {
Self {
dir: dir.into(),
loaded: BTreeMap::new(),
}
}
pub fn dir(&self) -> &Path {
&self.dir
}
/// Rescan the directory: load new or replaced bundles, drop removed ones.
/// A bundle that fails to load is skipped (that station falls back to the
/// heuristic, as in Python) and the previous copy, if any, is kept.
pub fn refresh(&mut self) -> Result<ModelSet> {
let entries = std::fs::read_dir(&self.dir)
.with_context(|| format!("reading models directory {}", self.dir.display()))?;
let mut seen = BTreeMap::new();
for entry in entries.flatten() {
let path = entry.path();
let Some(code) = station_of(&path) else {
continue;
};
let mtime = entry.metadata().and_then(|m| m.modified()).ok();
seen.insert(code, (path, mtime));
}
self.loaded.retain(|code, _| seen.contains_key(code));
for (code, (path, mtime)) in seen {
let current = self.loaded.get(&code).map(|(t, _)| *t);
if mtime.is_some() && current == mtime {
continue;
}
self.warn_if_export_is_stale(&code, &path);
match Bundle::load(&path) {
Ok(b) if b.station_code == code => {
tracing::info!("model {code}: {} loaded", b.model_version);
self.loaded
.insert(code, (mtime.unwrap_or(SystemTime::UNIX_EPOCH), Arc::new(b)));
}
Ok(b) => tracing::error!(
"{}: bundle is for {}, not {code}; ignored",
path.display(),
b.station_code
),
Err(e) => tracing::error!("model {code}: {e:#}"),
}
}
Ok(self
.loaded
.iter()
.map(|(k, (_, b))| (k.clone(), b.clone()))
.collect())
}
fn warn_if_export_is_stale(&self, code: &str, json: &Path) {
let joblib = self.dir.join(format!("flood_{code}.joblib"));
let modified = |p: &Path| std::fs::metadata(p).and_then(|m| m.modified()).ok();
if let (Some(j), Some(e)) = (modified(&joblib), modified(json))
&& j > e
{
tracing::warn!(
"{} is newer than its export {}: run `python -m src.ml.export`",
joblib.display(),
json.display()
);
}
}
}
+245
View File
@@ -0,0 +1,245 @@
//! `predict.get_forecasts`: one forecast row per station x horizon, from the
//! trained bundle when it is usable, else the persistence heuristic.
use crate::config::config;
use crate::dam::DamFrame;
use crate::features::{build_features, row_at};
use crate::grid::{Grid, Row};
use crate::model::{Bundle, ModelSet};
use crate::rain::RainSeries;
use chrono::{Duration, NaiveDateTime};
use serde::{Serialize, Serializer};
fn iso<S: Serializer>(t: &NaiveDateTime, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&t.format("%Y-%m-%dT%H:%M:%S").to_string())
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct StageProbability {
pub stage: u32,
pub level: f64,
pub p_exceed: f64,
}
/// One `/forecast` row; field order and names as Python's dicts.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct Forecast {
pub station_code: String,
pub horizon_hours: u32,
pub p_warning: f64,
pub p_danger: f64,
pub predicted_max_level: f64,
pub current_level: f64,
#[serde(serialize_with = "iso")]
pub as_of: NaiveDateTime,
pub model_version: String,
pub trained_at: Option<String>,
pub source: String,
pub threshold_warning: f64,
pub threshold_danger: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub stages: Option<Vec<StageProbability>>,
}
fn sigmoid(predicted_max: f64, threshold: f64, sigma: f64) -> f64 {
1.0 / (1.0 + (-(predicted_max - threshold) / sigma).exp())
}
fn clip(p: f64) -> f64 {
p.clamp(0.0, 1.0)
}
struct Current {
station: String,
as_of: NaiveDateTime,
level: f64,
level_t_minus_3: Option<f64>,
}
fn heuristic(c: &Current, horizons: &[u32]) -> Vec<Forecast> {
let cfg = config();
let (warn, danger) = cfg.thresholds(&c.station);
let rate = c
.level_t_minus_3
.map_or(0.0, |l3| ((c.level - l3) / 3.0).max(0.0));
horizons
.iter()
.map(|&h| {
let predicted_max = (c.level + rate * h as f64 * 0.7).max(c.level);
let p_warning = clip(sigmoid(predicted_max, warn, cfg.heuristic_sigma));
let p_danger = clip(sigmoid(predicted_max, danger, cfg.heuristic_sigma)).min(p_warning);
Forecast {
station_code: c.station.clone(),
horizon_hours: h,
p_warning,
p_danger,
predicted_max_level: predicted_max,
current_level: c.level,
as_of: c.as_of,
model_version: cfg.heuristic_version.clone(),
trained_at: None,
source: "heuristic".into(),
threshold_warning: warn,
threshold_danger: danger,
stages: None,
}
})
.collect()
}
/// `predict._model_forecast`: `None` when the bundle's features cannot be
/// built (the station then falls back to the heuristic); per horizon `None`
/// when that horizon's regressor was skipped at training time.
fn model_forecast(
c: &Current,
grid: &Grid,
bundle: &Bundle,
rain: Option<&RainSeries>,
dam: Option<&DamFrame>,
) -> Option<Vec<Option<Forecast>>> {
let cfg = config();
let (mut warn, mut danger) = (bundle.thresholds.warning, bundle.thresholds.danger);
// Thresholds changed since training: the classifier heads answer the old
// question, so use regression + sigma against the configured levels.
let configured = cfg.thresholds(&c.station);
let thresholds_stale = configured != (warn, danger);
if thresholds_stale {
tracing::warn!(
"{}: bundle thresholds ({warn}, {danger}) differ from configured {configured:?}; \
using regression-derived probabilities until the model is retrained",
c.station
);
(warn, danger) = configured;
}
let cols = build_features(grid, &c.station, rain, dam);
let i = grid.index_of(c.as_of)?;
let row = row_at(&cols, i);
let mut x = Vec::with_capacity(bundle.feature_names.len());
for name in &bundle.feature_names {
match row.get(name.as_str()) {
Some(v) => x.push(*v),
None => {
tracing::error!(
"feature mismatch for {} (missing {name}); falling back to heuristic",
c.station
);
return None;
}
}
}
let stages = cfg.flood_stages.get(&c.station);
let rows = bundle
.horizons
.iter()
.map(|&h| {
let reg = bundle.head(&format!("max_{h}"))?;
let mut raw = reg.predict(&x);
if bundle.regression_target.as_deref() == Some("rise") {
raw += c.level; // v2+ bundles predict the rise over the current level
}
let predicted_max = raw.max(c.level);
let sigma = bundle.sigma(h, cfg.heuristic_sigma);
// classifier OR regression-sigmoid, whichever is more alarmed
let head_p = |kind: &str| {
(!thresholds_stale)
.then(|| bundle.head(&format!("{kind}_{h}")))
.flatten()
.map(|head| head.predict(&x))
};
let mut p_warning = sigmoid(predicted_max, warn, sigma);
if let Some(p) = head_p("warn") {
p_warning = p_warning.max(p);
}
let mut p_danger = sigmoid(predicted_max, danger, sigma);
if let Some(p) = head_p("danger") {
p_danger = p_danger.max(p);
}
let p_warning = clip(p_warning);
let p_danger = clip(p_danger).min(p_warning);
Some(Forecast {
station_code: c.station.clone(),
horizon_hours: h,
p_warning,
p_danger,
predicted_max_level: predicted_max,
current_level: c.level,
as_of: c.as_of,
model_version: bundle.model_version.clone(),
trained_at: bundle.trained_at.clone(),
source: "model".into(),
threshold_warning: warn,
threshold_danger: danger,
stages: stages.map(|stages| {
stages
.iter()
.map(|s| StageProbability {
stage: s.stage,
level: s.level,
p_exceed: clip(sigmoid(predicted_max, s.level, sigma)),
})
.collect()
}),
})
})
.collect();
Some(rows)
}
fn forecast_station(
station: &str,
grid: &Grid,
models: &ModelSet,
now: NaiveDateTime,
rain: Option<&RainSeries>,
dam: Option<&DamFrame>,
) -> Vec<Forecast> {
let cfg = config();
let Some(i) = grid.last_observed(station) else {
tracing::warn!("no observed readings for station {station}; omitting");
return vec![];
};
let levels = &grid.level[station];
let as_of = grid.times[i];
let t3 = as_of - Duration::hours(3);
let c = Current {
station: station.to_string(),
as_of,
level: levels[i],
level_t_minus_3: grid.index_of(t3).map(|j| levels[j]).filter(|v| !v.is_nan()),
};
let staleness_h = (now - as_of)
.num_nanoseconds()
.map_or(f64::INFINITY, |ns| ns as f64 / 1e9 / 3600.0);
let bundle = match models.get(station) {
Some(b) if staleness_h <= cfg.stale_after_h => b,
_ => return heuristic(&c, &cfg.horizons),
};
let Some(rows) = model_forecast(&c, grid, bundle, rain, dam) else {
return heuristic(&c, &cfg.horizons);
};
bundle
.horizons
.iter()
.zip(rows)
.flat_map(|(&h, row)| match row {
Some(r) => vec![r],
None => heuristic(&c, &[h]),
})
.collect()
}
/// Forecasts for every station in `rows` (the merged RID + HII-fill window),
/// stations in code order. `rain` and `dam` as `build_features`.
pub fn forecasts(
rows: &[Row],
models: &ModelSet,
now: NaiveDateTime,
rain: Option<&RainSeries>,
dam: Option<&DamFrame>,
) -> Vec<Forecast> {
let grid = Grid::from_rows(rows);
grid.level
.keys()
.flat_map(|station| forecast_station(station, &grid, models, now, rain, dam))
.collect()
}
+27
View File
@@ -0,0 +1,27 @@
//! Catchment rain series (`src/ml/rain.py`: `catchment_mean`).
use chrono::NaiveDateTime;
use std::collections::BTreeMap;
/// Hourly catchment-average precipitation (mm); NaN = no value.
pub type RainSeries = BTreeMap<NaiveDateTime, f64>;
/// pandas `mean(axis=1)` over the catchment points: the points that have a
/// value, summed in column order, divided by their count.
pub fn catchment_mean(points: &[Option<f64>]) -> Option<f64> {
let mut sum = 0.0;
let mut n = 0usize;
for v in points.iter().flatten() {
sum += *v;
n += 1;
}
(n > 0).then(|| sum / n as f64)
}
/// The series on the given hours (`Series.reindex`), NaN where absent.
pub fn reindex(rain: &RainSeries, times: &[NaiveDateTime]) -> Vec<f64> {
times
.iter()
.map(|t| rain.get(t).copied().unwrap_or(f64::NAN))
.collect()
}
+206
View File
@@ -0,0 +1,206 @@
//! The pandas Series operations `features.py` uses, with pandas'
//! floating-point behaviour (pandas 2.0, `_libs/window/aggregations.pyx`).
//! NaN is "missing" throughout, as in pandas.
pub type Series = Vec<f64>;
pub fn nan_series(n: usize) -> Series {
vec![f64::NAN; n]
}
/// `ffill(limit=limit)`: at most `limit` consecutive NaNs after a value are filled.
pub fn ffill(v: &[f64], limit: usize) -> Series {
let mut out = v.to_vec();
let mut last = f64::NAN;
let mut run = 0;
for x in out.iter_mut() {
if x.is_nan() {
run += 1;
if !last.is_nan() && run <= limit {
*x = last;
}
} else {
last = *x;
run = 0;
}
}
out
}
/// `shift(k)`: positive k looks back, negative k looks ahead; NaN-padded.
pub fn shift(v: &[f64], k: i64) -> Series {
let n = v.len() as i64;
(0..n)
.map(|i| {
let j = i - k;
if (0..n).contains(&j) {
v[j as usize]
} else {
f64::NAN
}
})
.collect()
}
pub fn sub(a: &[f64], b: &[f64]) -> Series {
a.iter().zip(b).map(|(x, y)| x - y).collect()
}
/// `rolling(w, min_periods=1).sum()`.
pub fn rolling_sum(v: &[f64], w: usize) -> Series {
kahan_window(v, w, false)
}
/// `rolling(w, min_periods=1).mean()`.
pub fn rolling_mean(v: &[f64], w: usize) -> Series {
kahan_window(v, w, true)
}
/// `rolling(w, min_periods=1).max()`.
pub fn rolling_max(v: &[f64], w: usize) -> Series {
extreme(v, w, f64::max)
}
/// `rolling(w, min_periods=1).min()`.
pub fn rolling_min(v: &[f64], w: usize) -> Series {
extreme(v, w, f64::min)
}
/// Sum over the next `w` rows, excluding the current one:
/// `s.shift(-1).iloc[::-1].rolling(w, min_periods=1).sum().iloc[::-1]`.
pub fn forward_sum(v: &[f64], w: usize) -> Series {
let mut ahead = shift(v, -1);
ahead.reverse();
let mut out = rolling_sum(&ahead, w);
out.reverse();
out
}
fn extreme(v: &[f64], w: usize, pick: fn(f64, f64) -> f64) -> Series {
(0..v.len())
.map(|i| {
let lo = (i + 1).saturating_sub(w);
v[lo..=i]
.iter()
.copied()
.filter(|x| !x.is_nan())
.fold(
f64::NAN,
|acc, x| if acc.is_nan() { x } else { pick(acc, x) },
)
})
.collect()
}
/// `roll_sum` / `roll_mean` for a fixed window, operation for operation: the
/// window moves incrementally with separate Kahan compensations for adds and
/// removes, the "run of identical values" shortcut (GH#42064) and, for the
/// mean, the sign clamp. A plain per-window sum differs in the last bits, and
/// HGB thresholds can sit exactly on such values.
fn kahan_window(v: &[f64], w: usize, mean: bool) -> Series {
let (mut sum, mut comp_add, mut comp_remove) = (0.0f64, 0.0f64, 0.0f64);
let (mut nobs, mut neg_ct, mut same_run) = (0i64, 0i64, 0i64);
let mut prev = v.first().copied().unwrap_or(f64::NAN);
let mut out = Vec::with_capacity(v.len());
for i in 0..v.len() {
if w > 0 && i >= w {
let val = v[i - w];
if !val.is_nan() {
nobs -= 1;
let y = -val - comp_remove;
let t = sum + y;
comp_remove = t - sum - y;
sum = t;
if val.is_sign_negative() {
neg_ct -= 1;
}
}
}
let val = v[i];
if !val.is_nan() {
nobs += 1;
let y = val - comp_add;
let t = sum + y;
comp_add = t - sum - y;
sum = t;
if val.is_sign_negative() {
neg_ct += 1;
}
same_run = if val == prev { same_run + 1 } else { 1 };
prev = val;
}
out.push(if nobs < 1 {
f64::NAN
} else if mean {
let r = sum / nobs as f64;
if same_run >= nobs {
prev
} else if (neg_ct == 0 && r < 0.0) || (neg_ct == nobs && r > 0.0) {
0.0
} else {
r
}
} else if same_run >= nobs {
prev * nobs as f64
} else {
sum
});
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn same(a: &[f64], b: &[f64]) -> bool {
a.len() == b.len()
&& a.iter()
.zip(b)
.all(|(x, y)| x.to_bits() == y.to_bits() || (x.is_nan() && y.is_nan()))
}
#[test]
fn ffill_respects_the_limit() {
let n = f64::NAN;
assert!(same(
&ffill(&[n, 1.0, n, n, n, n, 2.0], 3),
&[n, 1.0, 1.0, 1.0, 1.0, n, 2.0]
));
}
#[test]
fn shift_both_ways() {
let n = f64::NAN;
assert!(same(&shift(&[1.0, 2.0, 3.0], 1), &[n, 1.0, 2.0]));
assert!(same(&shift(&[1.0, 2.0, 3.0], -1), &[2.0, 3.0, n]));
}
#[test]
fn rolling_skips_nan_and_needs_one_value() {
let n = f64::NAN;
let v = [n, 1.0, n, 3.0, n, n, n];
assert!(same(&rolling_sum(&v, 2), &[n, 1.0, 1.0, 3.0, 3.0, n, n]));
assert!(same(&rolling_mean(&v, 3), &[n, 1.0, 1.0, 2.0, 3.0, 3.0, n]));
assert!(same(&rolling_max(&v, 3), &[n, 1.0, 1.0, 3.0, 3.0, 3.0, n]));
assert!(same(&rolling_min(&v, 3), &[n, 1.0, 1.0, 1.0, 3.0, 3.0, n]));
}
#[test]
fn forward_sum_excludes_the_current_row() {
let n = f64::NAN;
assert!(same(
&forward_sum(&[1.0, 2.0, 3.0, 4.0], 2),
&[5.0, 7.0, 4.0, n]
));
}
#[test]
fn identical_run_shortcut_removes_float_residue() {
// a window of identical values yields value * count (sum) or the
// value itself (mean), whatever the Kahan accumulator holds
let v = [0.7, 0.1, 0.1, 0.1];
assert_eq!(rolling_sum(&v, 3)[3], 0.1 * 3.0);
assert_eq!(rolling_mean(&v, 3)[3], 0.1);
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+310
View File
@@ -0,0 +1,310 @@
"""Parity fixtures for rust/forecast: inputs plus what Python computes from them.
Run from the repo root (needs the trained bundles in models/ and the
models/cache/ snapshot, i.e. a machine that has trained or pulled models):
uv run python rust/forecast/testdata/gen_forecast_fixtures.py
Writes, next to this file:
models/flood_P.1.json.gz the P.1 bundle as src/ml/export.py writes it
fixture_<scenario>.json.gz inputs (RID rows, HII mirror rows, rain points,
dam days, now) and Python's outputs: merged
rows, the feature row at each station's as_of,
forecasts with only P.1's model (checked in CI)
and with every local model (checked when
../../models has the exports).
The Python side is the production code path: data._normalize_long and
data.fill_from_hii (with the HII query replaced by the fixture rows),
rain.catchment_mean, dam.hourly_frame and predict.forecasts_from_long.
"""
import datetime
import gzip
import json
import math
import shutil
import sys
import tempfile
from pathlib import Path
from unittest import mock
import joblib
import numpy as np
import pandas as pd
HERE = Path(__file__).resolve().parent
REPO = HERE.parents[2]
sys.path.insert(0, str(REPO))
from src.ml import dam as dam_mod # noqa: E402
from src.ml import data, export, features, predict # noqa: E402
from src.ml import rain as rain_mod # noqa: E402
MODELS = REPO / "models"
CACHE = MODELS / "cache"
FMT = "%Y-%m-%dT%H:%M:%S"
# Synthetic MSL offsets of the HII twins (P.81 also gets a noisy bias).
HII_OFFSETS = {
"P.1": 300.0,
"P.103": 296.473,
"P.20": 410.31,
"P.4A": 352.09,
"P.67": 318.552,
"P.75": 389.11,
"P.82": 520.6,
"P.84": 288.125,
"P.92": 371.0,
"P.81": 281.402,
"P.77": 402.5, # a different sensor: must never be merged
}
def num(v):
if v is None:
return None
v = float(v)
return None if math.isnan(v) else v
def scenario_inputs(long, rain_points, dam_daily, now, rid_lag, null_levels):
end = now
start = now - datetime.timedelta(hours=336)
window = long[(long.timestamp >= start) & (long.timestamp <= end)].copy()
# HII twins see every hour the gauges saw, including those RID "lags" on
rng = np.random.default_rng(42)
hii = []
for code, offset in HII_OFFSETS.items():
rows = window[(window.station_code == code) & window.water_level.notna()]
for ts, level, q in zip(rows.timestamp, rows.water_level, rows.discharge):
wl = level + offset
if code == "P.81":
wl += rng.normal(0, 0.05) + 0.19
hii.append(
[ts, code, round(wl, 3), num(None if pd.isna(q) else round(q + 1.5, 2))]
)
if code == "P.1" and ts.hour % 5 == 0:
# a second, later reading in the same hour wins; an earlier
# one and a level-less one must not
hii.append(
[ts + pd.Timedelta(minutes=40), code, round(wl + 0.004, 3), None]
)
hii.append(
[ts + pd.Timedelta(minutes=10), code, round(wl - 0.25, 3), None]
)
hii.append([ts + pd.Timedelta(minutes=50), code, None, None])
hii = pd.DataFrame(
hii, columns=["timestamp", "station_code", "wl_msl", "discharge"]
)
hii = hii[(hii.timestamp >= start) & (hii.timestamp <= end)]
# the query's ORDER BY m.timestamp, m.station_id (code stands in for the id)
hii = hii.sort_values(["timestamp", "station_code"], kind="stable").reset_index(
drop=True
)
# RID behind on some stations (the night case), NULL levels on others
newest = window.timestamp.max()
for code, hours in rid_lag.items():
cut = newest - pd.Timedelta(hours=hours)
window = window[~((window.station_code == code) & (window.timestamp > cut))]
for code, every in null_levels.items():
sel = (window.station_code == code) & (window.timestamp.dt.hour % every == 0)
window.loc[sel, "water_level"] = np.nan
window = window.sort_values(["timestamp", "station_code"], kind="stable")
day0 = pd.Timestamp(now.date())
rain_rows = rain_points[
(rain_points.index >= day0 - pd.Timedelta(days=5))
& (rain_points.index < day0 + pd.Timedelta(days=2))
]
dam_days = dam_daily[
dam_daily.index >= pd.Timestamp(now.date() - datetime.timedelta(days=21))
]
return start, end, window, hii, rain_rows, dam_days
def python_side(window, hii, rain_rows, dam_days, start, end, now, models_dirs):
df = data._normalize_long(window)
def fake_fetch(db_url, codes, s, e):
assert (s, e) == (start, end)
return data._prepare_hii_levels(hii[hii.station_code.isin(codes)])
with mock.patch.object(data, "_fetch_hii_levels", fake_fetch):
merged = data.fill_from_hii(df, "fixture://", start=start, end=end)
rain = rain_mod.catchment_mean(rain_rows)
if rain is None:
rain = pd.Series(dtype=float)
dam_frame = dam_mod.hourly_frame(dam_days)
if dam_frame is None:
dam_frame = pd.DataFrame()
grid = features.make_hourly_grid(merged)
feature_rows = {}
for code in sorted(merged.station_code.unique()):
observed = grid.observed[(code, "water_level")].dropna()
if observed.empty:
continue
as_of = observed.index.max()
X = features.build_features(grid, code, rain=rain, dam=dam_frame)
feature_rows[code] = {
"as_of": as_of.strftime(FMT),
"features": {k: num(v) for k, v in X.loc[as_of].items()},
}
forecasts = {
name: json.loads(
json.dumps(
predict.forecasts_from_long(
merged, models_dir=d, now=now, rain=rain, dam=dam_frame
),
default=float,
)
)
for name, d in models_dirs.items()
}
merged_rows = [
[t.strftime(FMT), c, num(l), num(q)]
for t, c, l, q in zip(
merged.timestamp, merged.station_code, merged.water_level, merged.discharge
)
]
merged_rows.sort(key=lambda r: (r[1], r[0]))
return merged_rows, feature_rows, forecasts
def main():
long = data._read_cache(CACHE, None)
rain_points = pd.read_csv(
CACHE / "rain_openmeteo.csv.gz", index_col=0, parse_dates=True
)
rain_points = rain_points[[p[0] for p in rain_mod.CATCHMENT_POINTS]]
dam_daily = pd.read_csv(CACHE / "dam_200103.csv.gz", index_col=0, parse_dates=True)
out_models = HERE / "models"
out_models.mkdir(exist_ok=True)
p1 = joblib.load(MODELS / "flood_P.1.joblib")
export.write_bundle_json(p1, out_models / "flood_P.1.json.gz")
tmp = Path(tempfile.mkdtemp())
try:
p1_only = tmp / "p1"
p1_only.mkdir()
shutil.copy(MODELS / "flood_P.1.joblib", p1_only)
# the configured thresholds moved since training -> regression path
p1_moved = tmp / "p1_moved"
p1_moved.mkdir()
moved = dict(p1)
moved["thresholds"] = {"warning": 3.5, "danger": 4.0}
joblib.dump(moved, p1_moved / "flood_P.1.joblib")
scenarios = {
# rising limb of the October 2024 record flood, RID 3 h behind
# on three HII-mirrored stations
"flood2024": dict(
now=datetime.datetime(2024, 10, 3, 1, 17, 31, 500000),
rid_lag={"P.1": 3, "P.103": 3, "P.67": 2},
null_levels={"P.21": 7},
),
"peak2024": dict(
now=datetime.datetime(2024, 10, 5, 3, 5, 0),
rid_lag={"P.1": 1},
null_levels={},
),
# quiet season, the newest cached data, with dam state
"recent2026": dict(
now=datetime.datetime(2026, 8, 13, 19, 40, 12),
rid_lag={},
null_levels={"P.85": 5},
),
# every station > 6 h old: heuristic only
"stale2026": dict(
now=datetime.datetime(2026, 8, 14, 3, 45, 0),
rid_lag={"P.20": 30},
null_levels={},
),
}
for name, sc in scenarios.items():
start, end, window, hii, rain_rows, dam_days = scenario_inputs(
long,
rain_points,
dam_daily,
sc["now"],
sc["rid_lag"],
sc["null_levels"],
)
dirs = {"p1_only": p1_only, "all_models": MODELS}
if name == "recent2026":
dirs["p1_moved_thresholds"] = p1_moved
merged, feats, fcs = python_side(
window, hii, rain_rows, dam_days, start, end, sc["now"], dirs
)
fixture = {
"now": sc["now"].strftime(FMT + ".%f"),
"window": [start.strftime(FMT + ".%f"), end.strftime(FMT + ".%f")],
"rid_rows": [
[t.strftime(FMT), c, num(l), num(q)]
for t, c, l, q in zip(
window.timestamp,
window.station_code,
window.water_level,
window.discharge,
)
],
"hii_rows": [
[t.strftime(FMT), c, num(w), num(q)]
for t, c, w, q in zip(
hii.timestamp, hii.station_code, hii.wl_msl, hii.discharge
)
],
"rain_points": [
[t.strftime(FMT), [num(v) for v in row]]
for t, row in zip(rain_rows.index, rain_rows.to_numpy().tolist())
],
"dam_days": [
[d.strftime("%Y-%m-%d"), num(s), num(i), num(o)]
for d, s, i, o in zip(
dam_days.index,
dam_days.storage_pct,
dam_days.inflow_mcm,
dam_days.outflow_mcm,
)
],
"expected": {
"merged_rows": merged,
"features": feats,
"forecasts": fcs,
"all_models_versions": sorted(
{
joblib.load(p)["model_version"]
for p in MODELS.glob("flood_*.joblib")
}
),
},
}
path = HERE / f"fixture_{name}.json.gz"
with (
open(path, "wb") as raw,
gzip.GzipFile(fileobj=raw, mode="wb", mtime=0) as gz,
):
gz.write(json.dumps(fixture, separators=(",", ":")).encode())
n_model = sum(r["source"] == "model" for r in fcs["p1_only"])
p1_24 = [
r
for r in fcs["all_models"]
if r["station_code"] == "P.1" and r["horizon_hours"] == 24
]
print(
f"{name}: {len(fixture['rid_rows'])} RID rows, {len(fixture['hii_rows'])} HII rows, "
f"{len(merged)} merged, {len(feats)} stations, model rows (P.1 only) {n_model}, "
f"P.1 24h {[(round(r['p_warning'], 3), round(r['predicted_max_level'], 2)) for r in p1_24]}"
)
finally:
shutil.rmtree(tmp, ignore_errors=True)
if __name__ == "__main__":
main()
Binary file not shown.
+322
View File
@@ -0,0 +1,322 @@
//! Replays the fixtures written by testdata/gen_forecast_fixtures.py (the
//! Python production path on the same inputs) through this crate.
//!
//! Exact equality for the merged readings, every feature and every level;
//! probabilities may differ in the last bits of `exp`, and `doy_sin`/`doy_cos`
//! in the last bits of the platform's `sin`/`cos` (numpy vs libm).
use chrono::{NaiveDate, NaiveDateTime};
use ping_forecast::dam::{DamDay, DamFrame, hourly_frame};
use ping_forecast::features::{build_features, row_at};
use ping_forecast::grid::{Grid, Row};
use ping_forecast::hii::{self, HiiRow};
use ping_forecast::model::{Bundle, ModelSet, ModelStore};
use ping_forecast::rain::{RainSeries, catchment_mean};
use ping_forecast::{Forecast, forecasts};
use serde_json::Value;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::Arc;
const SCENARIOS: [&str; 4] = ["flood2024", "peak2024", "recent2026", "stale2026"];
/// Max distance in units in the last place for transcendental results.
const ULPS: u64 = 4;
fn testdata() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("testdata")
}
fn load(name: &str) -> Value {
let raw = std::fs::read(testdata().join(format!("fixture_{name}.json.gz"))).unwrap();
let mut s = String::new();
flate2::read::GzDecoder::new(&raw[..])
.read_to_string(&mut s)
.unwrap();
serde_json::from_str(&s).unwrap()
}
fn ts(v: &Value) -> NaiveDateTime {
let s = v.as_str().unwrap();
NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f").unwrap()
}
fn f(v: &Value) -> f64 {
v.as_f64().unwrap_or(f64::NAN)
}
fn rows(v: &Value) -> Vec<Row> {
v.as_array()
.unwrap()
.iter()
.map(|r| Row {
timestamp: ts(&r[0]),
station_code: r[1].as_str().unwrap().into(),
water_level: f(&r[2]),
discharge: f(&r[3]),
})
.collect()
}
struct Inputs {
now: NaiveDateTime,
rid: Vec<Row>,
hii: Vec<HiiRow>,
rain: RainSeries,
dam: DamFrame,
}
fn inputs(fx: &Value) -> Inputs {
let hii = fx["hii_rows"]
.as_array()
.unwrap()
.iter()
.map(|r| HiiRow {
timestamp: ts(&r[0]),
station_code: r[1].as_str().unwrap().into(),
wl_msl: f(&r[2]),
discharge: f(&r[3]),
})
.collect();
let rain = fx["rain_points"]
.as_array()
.unwrap()
.iter()
.map(|r| {
let points: Vec<Option<f64>> =
r[1].as_array().unwrap().iter().map(Value::as_f64).collect();
(ts(&r[0]), catchment_mean(&points).unwrap_or(f64::NAN))
})
.collect();
let days: Vec<DamDay> = fx["dam_days"]
.as_array()
.unwrap()
.iter()
.map(|r| DamDay {
date: NaiveDate::parse_from_str(r[0].as_str().unwrap(), "%Y-%m-%d").unwrap(),
storage_pct: f(&r[1]),
inflow_mcm: f(&r[2]),
outflow_mcm: f(&r[3]),
})
.collect();
Inputs {
now: ts(&fx["now"]),
rid: rows(&fx["rid_rows"]),
hii,
rain,
dam: hourly_frame(&days).unwrap_or_default(),
}
}
fn same_bits(a: f64, b: f64) -> bool {
a.to_bits() == b.to_bits() || (a.is_nan() && b.is_nan())
}
fn ulps(a: f64, b: f64) -> u64 {
if same_bits(a, b) || a == b {
return 0;
}
if a.is_sign_negative() != b.is_sign_negative() || a.is_nan() || b.is_nan() {
return u64::MAX;
}
a.to_bits().abs_diff(b.to_bits())
}
fn merged(inp: &Inputs) -> Vec<Row> {
let (mut m, _) = hii::fill(&inp.rid, &hii::prepare(&inp.hii));
m.sort_by(|a, b| (&a.station_code, a.timestamp).cmp(&(&b.station_code, b.timestamp)));
m
}
#[test]
fn hii_fill_matches_python() {
for name in SCENARIOS {
let fx = load(name);
let got = merged(&inputs(&fx));
let want = rows(&fx["expected"]["merged_rows"]);
assert_eq!(got.len(), want.len(), "{name}: merged row count");
for (g, w) in got.iter().zip(&want) {
assert!(
g.timestamp == w.timestamp
&& g.station_code == w.station_code
&& same_bits(g.water_level, w.water_level)
&& same_bits(g.discharge, w.discharge),
"{name}: {g:?} != {w:?}"
);
}
}
}
#[test]
fn features_match_python_bit_for_bit() {
let mut checked = 0;
for name in SCENARIOS {
let fx = load(name);
let inp = inputs(&fx);
let grid = Grid::from_rows(&merged(&inp));
for (code, exp) in fx["expected"]["features"].as_object().unwrap() {
let cols = build_features(&grid, code, Some(&inp.rain), Some(&inp.dam));
let i = grid.index_of(ts(&exp["as_of"])).unwrap();
assert_eq!(grid.last_observed(code), Some(i), "{name} {code}: as_of");
let row = row_at(&cols, i);
let want = exp["features"].as_object().unwrap();
let mut names: Vec<&str> = row.keys().copied().collect();
names.sort_unstable();
let mut want_names: Vec<&str> = want.keys().map(String::as_str).collect();
want_names.sort_unstable();
assert_eq!(names, want_names, "{name} {code}: feature set");
for (k, v) in want {
let (g, w) = (row[k.as_str()], f(v));
let ok = if k.starts_with("doy_") {
ulps(g, w) <= ULPS
} else {
same_bits(g, w)
};
assert!(ok, "{name} {code} {k}: rust {g:?} python {w:?}");
checked += 1;
}
}
}
assert!(checked > 3000, "only {checked} feature values compared");
}
fn compare(name: &str, got: &[Forecast], want: &Value) {
let want = want.as_array().unwrap();
assert_eq!(got.len(), want.len(), "{name}: row count");
for (g, w) in got.iter().zip(want) {
let ctx = format!("{name} {} {}h", g.station_code, g.horizon_hours);
assert_eq!(g.station_code, w["station_code"].as_str().unwrap(), "{ctx}");
assert_eq!(
g.horizon_hours as u64,
w["horizon_hours"].as_u64().unwrap(),
"{ctx}"
);
assert_eq!(g.as_of, ts(&w["as_of"]), "{ctx}");
assert_eq!(g.source, w["source"].as_str().unwrap(), "{ctx}");
assert_eq!(
g.model_version,
w["model_version"].as_str().unwrap(),
"{ctx}"
);
assert_eq!(g.trained_at.as_deref(), w["trained_at"].as_str(), "{ctx}");
for (field, gv) in [
("current_level", g.current_level),
("predicted_max_level", g.predicted_max_level),
("threshold_warning", g.threshold_warning),
("threshold_danger", g.threshold_danger),
] {
assert!(
same_bits(gv, f(&w[field])),
"{ctx} {field}: {gv} vs {}",
w[field]
);
}
for (field, gv) in [("p_warning", g.p_warning), ("p_danger", g.p_danger)] {
let d = ulps(gv, f(&w[field]));
assert!(d <= ULPS, "{ctx} {field}: {gv} vs {} ({d} ulp)", w[field]);
}
match (&g.stages, w.get("stages")) {
(None, None) => {}
(Some(gs), Some(ws)) => {
let ws = ws.as_array().unwrap();
assert_eq!(gs.len(), ws.len(), "{ctx}: stages");
for (s, w) in gs.iter().zip(ws) {
assert_eq!(s.stage as u64, w["stage"].as_u64().unwrap());
assert!(same_bits(s.level, f(&w["level"])));
assert!(
ulps(s.p_exceed, f(&w["p_exceed"])) <= ULPS,
"{ctx} stage {}",
s.stage
);
}
}
_ => panic!("{ctx}: stages present on one side only"),
}
}
}
fn p1_models() -> ModelSet {
ModelStore::new(testdata().join("models"))
.refresh()
.unwrap()
}
#[test]
fn forecasts_match_python_with_the_p1_model() {
let models = p1_models();
assert_eq!(models.keys().collect::<Vec<_>>(), ["P.1"]);
let mut model_rows = 0;
for name in SCENARIOS {
let fx = load(name);
let inp = inputs(&fx);
let got = forecasts(
&merged(&inp),
&models,
inp.now,
Some(&inp.rain),
Some(&inp.dam),
);
model_rows += got.iter().filter(|r| r.source == "model").count();
compare(name, &got, &fx["expected"]["forecasts"]["p1_only"]);
}
assert_eq!(model_rows, 9, "P.1 model path in the three fresh scenarios");
}
#[test]
fn moved_thresholds_use_the_regression_path_like_python() {
let path = testdata().join("models/flood_P.1.json.gz");
let mut bundle = Bundle::load(&path).unwrap();
bundle.thresholds.warning = 3.5;
bundle.thresholds.danger = 4.0;
let models: ModelSet = [("P.1".to_string(), Arc::new(bundle))].into();
let fx = load("recent2026");
let inp = inputs(&fx);
let got = forecasts(
&merged(&inp),
&models,
inp.now,
Some(&inp.rain),
Some(&inp.dam),
);
compare(
"recent2026 moved",
&got,
&fx["expected"]["forecasts"]["p1_moved_thresholds"],
);
}
/// Every local model (needs `python -m src.ml.export` run in ../../models on
/// a machine with the same trained bundles as the fixture generator).
#[test]
fn forecasts_match_python_with_all_local_models() {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../models");
let Ok(models) = ModelStore::new(&dir).refresh() else {
eprintln!("skipped: no {}", dir.display());
return;
};
let fx = load("recent2026");
let want: Vec<&str> = fx["expected"]["all_models_versions"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
let mut have: Vec<&str> = models.values().map(|b| b.model_version.as_str()).collect();
have.sort_unstable();
have.dedup();
if models.len() < 15 || have != want {
eprintln!("skipped: local models {have:?} are not the fixture's {want:?}");
return;
}
for name in SCENARIOS {
let fx = load(name);
let inp = inputs(&fx);
let got = forecasts(
&merged(&inp),
&models,
inp.now,
Some(&inp.rain),
Some(&inp.dam),
);
compare(name, &got, &fx["expected"]["forecasts"]["all_models"]);
}
}
+5 -4
View File
@@ -11,9 +11,9 @@
# * A run that produced gauge-only (v2) bundles, or trained too few stations, # * A run that produced gauge-only (v2) bundles, or trained too few stations,
# must NOT replace the deployed models. train.py already aborts on a # must NOT replace the deployed models. train.py already aborts on a
# missing rain series; this script re-checks the written metrics anyway. # missing rain series; this script re-checks the written metrics anyway.
# * No API restart is needed: predict.py reloads changed bundles on the next # * No restart is needed: predict.py (Python) and ping-collector (Rust,
# precompute (every scrape cycle, hourly), so the new models are live # FORECAST_IN_COLLECTOR=true) reload changed bundles on their next
# within an hour. Restart manually if you want them live immediately. # forecast run, so the new models are live within an hour.
# #
# Exit codes: 0 ok, 2 training refused (see log), 3 verification failed. # Exit codes: 0 ok, 2 training refused (see log), 3 verification failed.
set -euo pipefail set -euo pipefail
@@ -79,7 +79,8 @@ fi
# Promote: per-file rename is atomic; readers see either the old or the new # Promote: per-file rename is atomic; readers see either the old or the new
# bundle, never a partial one. Keep one previous generation for rollback. # bundle, never a partial one. Keep one previous generation for rollback.
mkdir -p "${MODELS_DIR}/.previous" mkdir -p "${MODELS_DIR}/.previous"
for f in "${STAGE_DIR}"/flood_*.joblib "${STAGE_DIR}/metrics.json"; do # flood_*.json.gz is the Rust forecaster's export of the same bundle.
for f in "${STAGE_DIR}"/flood_*.joblib "${STAGE_DIR}"/flood_*.json.gz "${STAGE_DIR}/metrics.json"; do
name="$(basename "${f}")" name="$(basename "${f}")"
if [ -f "${MODELS_DIR}/${name}" ]; then if [ -f "${MODELS_DIR}/${name}" ]; then
mv -f "${MODELS_DIR}/${name}" "${MODELS_DIR}/.previous/${name}" mv -f "${MODELS_DIR}/${name}" "${MODELS_DIR}/.previous/${name}"
+5 -2
View File
@@ -34,6 +34,7 @@ logger = logging.getLogger(__name__)
REPORT_HOUR = 7 # daily value valid from 07:00 local on its own date REPORT_HOUR = 7 # daily value valid from 07:00 local on its own date
FFILL_LIMIT_H = 48 # two missed daily reports -> NaN, not stale state FFILL_LIMIT_H = 48 # two missed daily reports -> NaN, not stale state
SERVING_DAYS = 21 # 336 h feature window + 72 h storage-delta lag, rounded up
DAM_COLUMNS = ("storage_pct", "inflow_mcm", "outflow_mcm") DAM_COLUMNS = ("storage_pct", "inflow_mcm", "outflow_mcm")
CACHE_FILE = f"dam_{MAE_NGAT_DAM_ID}.csv.gz" CACHE_FILE = f"dam_{MAE_NGAT_DAM_ID}.csv.gz"
@@ -101,9 +102,11 @@ def load_history(db_url: Optional[str] = None) -> Optional[pd.DataFrame]:
def serving_frame( def serving_frame(
db_url: Optional[str] = None, days: int = 21 db_url: Optional[str] = None,
days: int = SERVING_DAYS,
today: Optional[datetime.date] = None,
) -> Optional[pd.DataFrame]: ) -> Optional[pd.DataFrame]:
"""Recent hourly dam state for inference (covers the 336 h feature window """Recent hourly dam state for inference (covers the 336 h feature window
plus the 72 h storage-delta lag).""" plus the 72 h storage-delta lag)."""
start = datetime.date.today() - datetime.timedelta(days=days) start = (today or datetime.date.today()) - datetime.timedelta(days=days)
return hourly_frame(load_daily(db_url, start=start)) return hourly_frame(load_daily(db_url, start=start))
+3 -1
View File
@@ -143,6 +143,7 @@ def _fetch_hii_levels(
query += f" AND s.rid_code IN ({placeholders})" query += f" AND s.rid_code IN ({placeholders})"
for i, code in enumerate(stations): for i, code in enumerate(stations):
params[f"station_{i}"] = code params[f"station_{i}"] = code
query += " ORDER BY m.timestamp, m.station_id"
with engine.connect() as connection: with engine.connect() as connection:
df = pd.read_sql(text(query), connection, params=params) df = pd.read_sql(text(query), connection, params=params)
@@ -360,9 +361,10 @@ def load_latest(
db_url: Optional[str] = None, db_url: Optional[str] = None,
hours: int = 336, hours: int = 336,
stations: Optional[List[str]] = None, stations: Optional[List[str]] = None,
end: Optional[datetime.datetime] = None,
) -> pd.DataFrame: ) -> pd.DataFrame:
"""Load the last `hours` of history for all (or given) stations. Never cached to disk.""" """Load the last `hours` of history for all (or given) stations. Never cached to disk."""
end = datetime.datetime.now() end = end or datetime.datetime.now()
start = end - datetime.timedelta(hours=hours) start = end - datetime.timedelta(hours=hours)
return load_measurements( return load_measurements(
db_url=db_url, db_url=db_url,
+207
View File
@@ -0,0 +1,207 @@
"""Hand-over of trained models and forecast settings to the Rust forecaster.
The Rust service (rust/forecast, run by ping-collector) cannot read
joblib/pickle. It reads:
* ``models/flood_<station>.json.gz`` -- the bundle's metadata plus every
HistGradientBoosting head as flat tree arrays (sklearn's predictor node
table). ``train_all()`` writes one next to each ``.joblib``;
``python -m src.ml.export`` (re)exports the bundles already in ``models/``.
* ``rust/forecast/flood_config.json`` -- thresholds, flood stages, upstream
leads and the other constants features.py / predict.py / notify.py use,
compiled into the Rust binary. ``python -m src.ml.export --config``
regenerates it; tests/test_model_export.py fails while it is out of date,
so a threshold change cannot reach Python without reaching Rust.
Floats go through ``json`` (shortest round-trip repr) and the Rust side parses
with serde_json's ``float_roundtrip``, so every threshold and leaf value
arrives bit-identical.
"""
import argparse
import gzip
import json
import os
import sys
from pathlib import Path
from typing import Optional
import numpy as np
from . import data as data_mod
from . import features, predict
FORMAT_VERSION = 1
REPO_ROOT = Path(__file__).resolve().parents[2]
CONFIG_PATH = REPO_ROOT / "rust" / "forecast" / "flood_config.json"
def export_head(est) -> dict:
"""One fitted HistGradientBoosting{Regressor,Classifier} as flat arrays."""
trees = []
for per_iteration in est._predictors:
if len(per_iteration) != 1:
raise ValueError("multiclass heads are not supported")
nodes = per_iteration[0].nodes
if nodes["is_categorical"].any():
raise ValueError("categorical splits are not supported")
if not np.isfinite(nodes["value"]).all():
raise ValueError("non-finite leaf value")
thr = nodes["num_threshold"]
if np.isnan(thr).any() or np.isneginf(thr).any():
raise ValueError("NaN or -inf split threshold")
trees.append(
{
"feature": nodes["feature_idx"].astype(int).tolist(),
# +inf ("missing vs everything" split) is not valid JSON:
# written as null, read back as +inf.
"threshold": [None if np.isposinf(t) else float(t) for t in thr],
"missing_left": nodes["missing_go_to_left"].astype(bool).tolist(),
"left": nodes["left"].astype(int).tolist(),
"right": nodes["right"].astype(int).tolist(),
"leaf": nodes["is_leaf"].astype(bool).tolist(),
"value": nodes["value"].astype(float).tolist(),
}
)
return {
"kind": "classifier" if hasattr(est, "predict_proba") else "regressor",
"baseline": float(np.ravel(est._baseline_prediction)[0]),
"trees": trees,
}
def bundle_to_dict(bundle: dict) -> dict:
"""The serving-relevant part of a train.py bundle, JSON-ready."""
return {
"format_version": FORMAT_VERSION,
"station_code": bundle["station_code"],
"model_version": bundle["model_version"],
"trained_at": bundle.get("trained_at"),
"regression_target": bundle.get("regression_target"),
"feature_names": list(bundle["feature_names"]),
"horizons": [int(h) for h in bundle["horizons"]],
"sigma": {str(int(h)): float(v) for h, v in bundle["sigma"].items()},
"thresholds": {k: float(v) for k, v in bundle["thresholds"].items()},
# a head skipped at training time (too few positives) stays null
"heads": {
name: (None if est is None else export_head(est))
for name, est in bundle["heads"].items()
},
}
def write_bundle_json(bundle: dict, path: Path) -> None:
"""Atomic write: readers see the old file or the complete new one."""
path = Path(path)
tmp = path.with_name(path.name + ".tmp")
payload = json.dumps(bundle_to_dict(bundle), separators=(",", ":"))
with open(tmp, "wb") as raw:
# mtime=0: same model, same bytes
with gzip.GzipFile(fileobj=raw, mode="wb", mtime=0) as gz:
gz.write(payload.encode("utf-8"))
os.replace(tmp, path)
def json_path_for(joblib_path: Path) -> Path:
return joblib_path.with_name(joblib_path.stem + ".json.gz")
def export_models_dir(models_dir: Path, force: bool = False) -> int:
"""Export every flood_*.joblib whose .json.gz is missing or older."""
import joblib
count = 0
for path in sorted(Path(models_dir).glob("flood_*.joblib")):
out = json_path_for(path)
if not force and out.exists() and out.stat().st_mtime >= path.stat().st_mtime:
continue
# joblib.load runs pickle code: only ever our own train.py output
write_bundle_json(joblib.load(path), out)
count += 1
print(f"exported {path.name} -> {out.name}")
return count
def flood_config() -> dict:
"""Every constant the Rust forecaster and notifier share with Python."""
from .. import notify
from . import dam as dam_mod
return {
"thresholds": {
k: [float(w), float(d)] for k, (w, d) in features.THRESHOLDS.items()
},
"flood_stages": {
code: [
{
"stage": int(s["stage"]),
"level": float(s["level"]),
"discharge_cms": float(s["discharge_cms"]),
}
for s in stages
]
for code, stages in features.FLOOD_STAGES.items()
},
"upstream_leads": {
code: [[up, int(lead)] for up, lead in leads]
for code, leads in features.UPSTREAM_LEADS.items()
},
"dam_stations": sorted(features.DAM_STATIONS),
"monsoon_months": sorted(features.MONSOON_MONTHS),
"ffill_limit_h": features.FFILL_LIMIT_H,
"basin_anchor": features.BASIN_ANCHOR,
"forward_rain_h": features.FORWARD_RAIN_H,
"horizons": list(predict.DEFAULT_HORIZONS),
"stale_after_h": float(predict.STALE_AFTER_H),
"heuristic_sigma": float(predict.HEURISTIC_SIGMA),
"heuristic_version": predict.HEURISTIC_VERSION,
"latest_window_h": predict.LATEST_WINDOW_H,
"hii_fill_stations": list(data_mod.HII_FILL_STATIONS),
"hii_exact_mirrors": sorted(data_mod._HII_EXACT_MIRRORS),
"hii_min_overlap_hours": data_mod._HII_MIN_OVERLAP_HOURS,
"dam": {
"dam_id": dam_mod.MAE_NGAT_DAM_ID,
"report_hour": dam_mod.REPORT_HOUR,
"ffill_limit_h": dam_mod.FFILL_LIMIT_H,
"serving_days": dam_mod.SERVING_DAYS,
},
"notify": {
"clear_margin_m": float(notify.CLEAR_MARGIN_M),
"capacity_guard_min_pct": float(notify.CAPACITY_GUARD_MIN_PCT),
"capacity_guard_exempt": sorted(notify.CAPACITY_GUARD_EXEMPT),
"outlook_on": float(notify.OUTLOOK_ON),
"outlook_off": float(notify.OUTLOOK_OFF),
"outlook_horizon": int(notify.OUTLOOK_HORIZON),
"feed_stale_after_h": float(notify.FEED_STALE_AFTER_H),
"station_names": dict(notify.STATION_NAMES),
},
}
def flood_config_text() -> str:
return json.dumps(flood_config(), indent=1, ensure_ascii=False) + "\n"
def main(argv: Optional[list] = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument(
"models_dir", nargs="?", default=str(predict.DEFAULT_MODELS_DIR)
)
parser.add_argument("--force", action="store_true", help="re-export everything")
parser.add_argument(
"--config",
action="store_true",
help=f"write {CONFIG_PATH.relative_to(REPO_ROOT)} instead of exporting models",
)
args = parser.parse_args(argv)
if args.config:
CONFIG_PATH.write_text(flood_config_text(), encoding="utf-8", newline="\n")
print(f"wrote {CONFIG_PATH}")
return 0
n = export_models_dir(Path(args.models_dir), force=args.force)
print(f"{n} bundle(s) exported")
return 0
if __name__ == "__main__":
sys.exit(main())
+38 -16
View File
@@ -27,6 +27,7 @@ DEFAULT_HORIZONS: Tuple[int, ...] = (6, 12, 24)
STALE_AFTER_H = 6.0 STALE_AFTER_H = 6.0
HEURISTIC_SIGMA = 0.3 HEURISTIC_SIGMA = 0.3
HEURISTIC_VERSION = "heuristic-v1" HEURISTIC_VERSION = "heuristic-v1"
LATEST_WINDOW_H = 336 # history window a live forecast is computed from
# Keyed by (path, mtime) so a retrained model (new mtime) invalidates the old entry. # Keyed by (path, mtime) so a retrained model (new mtime) invalidates the old entry.
_MODEL_CACHE: Dict[Tuple[str, float], dict] = {} _MODEL_CACHE: Dict[Tuple[str, float], dict] = {}
@@ -352,22 +353,14 @@ def get_forecasts(
return results return results
def get_latest_forecasts( def forecasts_from_long(
db_url: Optional[str] = None, df_long: pd.DataFrame,
models_dir: Union[str, Path] = DEFAULT_MODELS_DIR, models_dir: Union[str, Path] = DEFAULT_MODELS_DIR,
hours: int = 336, now: Optional[Union[datetime.datetime, str]] = None,
rain: Optional[pd.Series] = None,
dam: Optional[pd.DataFrame] = None,
) -> List[dict]: ) -> List[dict]:
"""Convenience wrapper for web_api: load the latest window from the DB/API and forecast. """Forecasts from a loaded long window (load_latest's output)."""
Raises FileNotFoundError when no trained model bundle exists at all, so the
API can 503 instead of serving purely heuristic output as if it were a forecast.
"""
from .data import load_latest
if not sorted(Path(models_dir).glob("flood_*.joblib")):
raise FileNotFoundError(f"no trained model bundles in {models_dir}")
df_long = load_latest(db_url=db_url, hours=hours)
readings_by_station: Dict[str, List[dict]] = {} readings_by_station: Dict[str, List[dict]] = {}
if not df_long.empty: if not df_long.empty:
for station_code, group in df_long.groupby("station_code"): for station_code, group in df_long.groupby("station_code"):
@@ -380,10 +373,37 @@ def get_latest_forecasts(
logger.warning( logger.warning(
f"No recent data for station {missing_station}; omitting from forecasts" f"No recent data for station {missing_station}; omitting from forecasts"
) )
return get_forecasts(
readings_by_station, models_dir=models_dir, now=now, rain=rain, dam=dam
)
def get_latest_forecasts(
db_url: Optional[str] = None,
models_dir: Union[str, Path] = DEFAULT_MODELS_DIR,
hours: int = LATEST_WINDOW_H,
now: Optional[datetime.datetime] = None,
rain: Optional[pd.Series] = None,
) -> List[dict]:
"""Convenience wrapper for web_api: load the latest window from the DB/API and forecast.
Raises FileNotFoundError when no trained model bundle exists at all, so the
API can 503 instead of serving purely heuristic output as if it were a forecast.
``now`` and ``rain`` are for reproducible comparisons
(scripts/forecast_parity.py); by default the clock and a live fetch.
"""
from .data import load_latest
if not sorted(Path(models_dir).glob("flood_*.joblib")):
raise FileNotFoundError(f"no trained model bundles in {models_dir}")
now = now or datetime.datetime.now()
df_long = load_latest(db_url=db_url, hours=hours, end=now)
# Live rain: trailing days + next-48h forecast. On fetch failure pass an # Live rain: trailing days + next-48h forecast. On fetch failure pass an
# EMPTY series (not None) so rain-trained bundles still find their columns # EMPTY series (not None) so rain-trained bundles still find their columns
# (as NaN) and serve model output instead of tripping the feature guard. # (as NaN) and serve model output instead of tripping the feature guard.
if rain is None:
from .rain import serving_series from .rain import serving_series
rain = serving_series() rain = serving_series()
@@ -396,7 +416,7 @@ def get_latest_forecasts(
from . import dam as dam_mod from . import dam as dam_mod
try: try:
dam = dam_mod.serving_frame(db_url=db_url) dam = dam_mod.serving_frame(db_url=db_url, today=now.date())
except Exception as error: except Exception as error:
logger.warning(f"dam serving frame failed: {error}") logger.warning(f"dam serving frame failed: {error}")
dam = None dam = None
@@ -404,4 +424,6 @@ def get_latest_forecasts(
logger.warning("dam state unavailable; dam features will be NaN") logger.warning("dam state unavailable; dam features will be NaN")
dam = pd.DataFrame() dam = pd.DataFrame()
return get_forecasts(readings_by_station, models_dir=models_dir, rain=rain, dam=dam) return forecasts_from_long(
df_long, models_dir=models_dir, now=now, rain=rain, dam=dam
)
+4
View File
@@ -554,6 +554,10 @@ def train_all(
station_results[station] = station_metrics station_results[station] = station_metrics
continue continue
joblib.dump(bundle, models_dir / f"flood_{station}.joblib") joblib.dump(bundle, models_dir / f"flood_{station}.joblib")
# the Rust forecaster's copy (src/ml/export.py)
from .export import write_bundle_json
write_bundle_json(bundle, models_dir / f"flood_{station}.json.gz")
logger.info( logger.info(
f"{station}: trained, {bundle['n_train_rows']} rows, " f"{station}: trained, {bundle['n_train_rows']} rows, "
f"{len(bundle['skipped_heads'])} heads skipped" f"{len(bundle['skipped_heads'])} heads skipped"
+3 -1
View File
@@ -54,6 +54,8 @@ OUTLOOK_ON = 0.50
OUTLOOK_OFF = 0.25 OUTLOOK_OFF = 0.25
# Below this the outlook is not announced at all (avoid "5 % chance" noise). # Below this the outlook is not announced at all (avoid "5 % chance" noise).
OUTLOOK_HORIZON = 24 OUTLOOK_HORIZON = 24
# No reading this many hours old or newer: the feed is stale.
FEED_STALE_AFTER_H = 3.0
STATION_NAMES: Dict[str, str] = { STATION_NAMES: Dict[str, str] = {
"P.1": "Nawarat Bridge, Chiang Mai city", "P.1": "Nawarat Bridge, Chiang Mai city",
@@ -222,7 +224,7 @@ def evaluate(
forecasts: Iterable[dict], forecasts: Iterable[dict],
state: NotificationState, state: NotificationState,
publisher: NtfyPublisher, publisher: NtfyPublisher,
stale_after_h: float = 3.0, stale_after_h: float = FEED_STALE_AFTER_H,
now: Optional[datetime.datetime] = None, now: Optional[datetime.datetime] = None,
) -> List[Notification]: ) -> List[Notification]:
"""Compare current readings/forecasts with last-sent state; publish transitions. """Compare current readings/forecasts with last-sent state; publish transitions.
+106
View File
@@ -0,0 +1,106 @@
"""Model/config hand-over to the Rust forecaster (src/ml/export.py)."""
import gzip
import json
import numpy as np
import pytest
from scipy.special import expit
from sklearn.ensemble import (
HistGradientBoostingClassifier,
HistGradientBoostingRegressor,
)
from src.ml import export
def test_rust_flood_config_is_current():
"""rust/forecast/flood_config.json is compiled into ping-collector.
If this fails, a threshold/stage/lead/notify constant changed: run
`uv run python -m src.ml.export --config` and rebuild the collector.
"""
assert export.CONFIG_PATH.read_text(encoding="utf-8") == export.flood_config_text()
def _data(n=600, seed=7):
rng = np.random.default_rng(seed)
X = rng.normal(size=(n, 4))
X[rng.random(size=X.shape) < 0.1] = np.nan # NaN routing is exported too
y = np.nan_to_num(X[:, 0]) * 2 + np.nan_to_num(X[:, 1]) + rng.normal(size=n) * 0.1
return X, y
def _walk(head, X):
out = np.full(len(X), head["baseline"])
for tree in head["trees"]:
thr = [np.inf if t is None else t for t in tree["threshold"]]
for i, row in enumerate(X):
node = 0
while not tree["leaf"][node]:
v = row[tree["feature"][node]]
left = tree["missing_left"][node] if np.isnan(v) else v <= thr[node]
node = tree["left"][node] if left else tree["right"][node]
out[i] += tree["value"][node]
return out
def test_exported_trees_reproduce_sklearn_exactly():
X, y = _data()
reg = HistGradientBoostingRegressor(max_iter=30, random_state=0).fit(X, y)
clf = HistGradientBoostingClassifier(max_iter=30, random_state=0).fit(X, y > 0.5)
# through JSON, as the Rust side reads it
reg_head = json.loads(json.dumps(export.export_head(reg)))
clf_head = json.loads(json.dumps(export.export_head(clf)))
assert reg_head["kind"] == "regressor" and clf_head["kind"] == "classifier"
assert np.array_equal(_walk(reg_head, X), reg.predict(X))
assert np.array_equal(expit(_walk(clf_head, X)), clf.predict_proba(X)[:, 1])
def test_bundle_json_roundtrip(tmp_path):
X, y = _data()
reg = HistGradientBoostingRegressor(max_iter=5, random_state=0).fit(X, y)
bundle = {
"station_code": "P.1",
"model_version": "hgb-v3+test",
"trained_at": "2026-10-01T03:30:00",
"regression_target": "rise",
"feature_names": ["a", "b", "c", "d"],
"horizons": [6, 12, 24],
"sigma": {6: 0.15, 12: 0.15, 24: 0.2},
"thresholds": {"warning": 3.7, "danger": 4.2},
"heads": {"max_24": reg, "warn_24": None},
"skipped_heads": {"warn_24": "too few positives"},
}
path = tmp_path / "flood_P.1.json.gz"
export.write_bundle_json(bundle, path)
with gzip.open(path, "rt", encoding="utf-8") as fh:
doc = json.load(fh)
assert doc["format_version"] == export.FORMAT_VERSION
assert doc["sigma"] == {"6": 0.15, "12": 0.15, "24": 0.2}
assert doc["heads"]["warn_24"] is None
assert doc["heads"]["max_24"]["kind"] == "regressor"
assert not (tmp_path / "flood_P.1.json.gz.tmp").exists()
first = path.read_bytes()
export.write_bundle_json(bundle, path)
assert path.read_bytes() == first # reproducible bytes (gzip mtime 0)
def test_export_models_dir_skips_up_to_date(tmp_path):
joblib = pytest.importorskip("joblib")
X, y = _data()
reg = HistGradientBoostingRegressor(max_iter=3, random_state=0).fit(X, y)
bundle = {
"station_code": "P.20",
"model_version": "hgb-v3+test",
"feature_names": ["a", "b", "c", "d"],
"horizons": [6],
"sigma": {6: 0.15},
"thresholds": {"warning": 2.35, "danger": 2.8},
"heads": {"max_6": reg},
}
joblib.dump(bundle, tmp_path / "flood_P.20.joblib")
assert export.export_models_dir(tmp_path) == 1
assert (tmp_path / "flood_P.20.json.gz").exists()
assert export.export_models_dir(tmp_path) == 0
assert export.export_models_dir(tmp_path, force=True) == 1