Files
Northern-Thailand-Ping-Rive…/rust/forecast/src/model.rs
T
grabowski 5c4f464383
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
feat(forecast): Rust forecast crate, bit-for-bit with the Python serving path
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.
2026-10-18 14:21:56 +07:00

166 lines
5.5 KiB
Rust

//! 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()
);
}
}
}