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
+2889 -29

No files matched your search

+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"]);
}
}