fix(forecast): no nightly gaps in the issued model forecasts
The P.1 chart's "24 h peak, as issued" line had a gap every night and a dip at 21:00. Two causes: - A run forecasts from the newest reading only. RID publishes 22:00..06:00 in one batch in the morning (and now and then one hour late), so those hours were never forecast from. After each run the collector now forecasts the hours of the last 48 h (two weeks on the first run after a start) that have a RID reading but no model forecast, each as a run at that hour would have: gauges, HII fill and dam state up to that hour. Rain is fetched once, so a late hour's rain_fc24 partly holds rain that has since fallen (as in training). Only missing (hour, station) pairs are written. `ping-collector catch-up-forecasts --hours N` goes further back. - Overnight each hourly run re-issues the 21:00 reading, after 6 h as the heuristic, which overwrote the 21:00 model row. A heuristic row no longer replaces a model row in forecast_history. The dashboard draws only model rows on the issued line. Checked on a local copy of 18 days of RID data: the catch-up of an hour matches `ping-collector forecast --now <hour>`; 288 hours took 20 s (debug build). Tests for both database rules.
This commit is contained in:
1 parent
cf10f2e4c8
commit
180d95c6bd
10 files changed
+311
-3
No files matched your search
@@ -404,6 +404,109 @@ async fn forecast_outputs_round_trip() {
|
||||
db.drop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn heuristic_never_replaces_a_model_forecast() {
|
||||
let Some(db) = TestDb::new().await else {
|
||||
return;
|
||||
};
|
||||
// overnight the run re-issues the 21:00 reading, after 6 h as heuristic
|
||||
let model = forecast_row("P.1", 24, 0.2);
|
||||
let heuristic = ping_forecast::Forecast {
|
||||
p_warning: 0.0,
|
||||
predicted_max_level: 1.23,
|
||||
model_version: "heuristic-v1".into(),
|
||||
source: "heuristic".into(),
|
||||
..forecast_row("P.1", 24, 0.0)
|
||||
};
|
||||
let stored = async || -> (String, f64) {
|
||||
sqlx::query_as("SELECT source::text, predicted_max_level::float8 FROM forecast_history")
|
||||
.fetch_one(&db.pool)
|
||||
.await
|
||||
.unwrap()
|
||||
};
|
||||
db::save_forecast_history(&db.pool, std::slice::from_ref(&model))
|
||||
.await
|
||||
.unwrap();
|
||||
db::save_forecast_history(&db.pool, std::slice::from_ref(&heuristic))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(stored().await, ("model".into(), 3.123));
|
||||
// a model row does replace a heuristic one (and a newer model row)
|
||||
sqlx::query("DELETE FROM forecast_history")
|
||||
.execute(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
db::save_forecast_history(&db.pool, &[heuristic])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(stored().await, ("heuristic".into(), 1.23));
|
||||
db::save_forecast_history(&db.pool, &[model]).await.unwrap();
|
||||
assert_eq!(stored().await, ("model".into(), 3.123));
|
||||
db.drop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hours_without_a_model_forecast() {
|
||||
let Some(db) = TestDb::new().await else {
|
||||
return;
|
||||
};
|
||||
let stations = Stations::bundled();
|
||||
let r = |h: u32, num: u32| Reading {
|
||||
timestamp: t(2026, 10, 18, h, 0),
|
||||
station_num: num, // 8 = P.1, 1 = P.20
|
||||
water_level: 1.1,
|
||||
discharge: None,
|
||||
discharge_percent: None,
|
||||
};
|
||||
// RID's morning batch: 11..13 arrive at once; only 13:00 (the newest)
|
||||
// and P.20's 11:00 were forecast, and 12:00 only by the heuristic
|
||||
db::save_rid(
|
||||
&db.pool,
|
||||
&[r(11, 8), r(12, 8), r(13, 8), r(11, 1), r(12, 1), r(13, 1)],
|
||||
&stations,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let issued = |code: &str, h: u32, source: &str| ping_forecast::Forecast {
|
||||
as_of: t(2026, 10, 18, h, 0),
|
||||
source: source.into(),
|
||||
..forecast_row(code, 24, 0.1)
|
||||
};
|
||||
db::save_forecast_history(
|
||||
&db.pool,
|
||||
&[
|
||||
issued("P.1", 13, "model"),
|
||||
issued("P.20", 13, "model"),
|
||||
issued("P.20", 11, "model"),
|
||||
issued("P.1", 12, "heuristic"),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let codes = vec!["P.1".to_string(), "P.20".to_string()];
|
||||
let missing = db::hours_without_model_forecast(
|
||||
&db.pool,
|
||||
&codes,
|
||||
t(2026, 10, 18, 0, 0),
|
||||
t(2026, 10, 18, 14, 0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let at = |h: u32, code: &str| (t(2026, 10, 18, h, 0), code.to_string());
|
||||
assert_eq!(missing, vec![at(11, "P.1"), at(12, "P.1"), at(12, "P.20")]);
|
||||
// stations without a model are not asked about
|
||||
let only_p20 = db::hours_without_model_forecast(
|
||||
&db.pool,
|
||||
&codes[1..],
|
||||
t(2026, 10, 18, 0, 0),
|
||||
t(2026, 10, 18, 14, 0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(only_p20, vec![at(12, "P.20")]);
|
||||
db.drop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notification_state_round_trip() {
|
||||
let Some(db) = TestDb::new().await else {
|
||||
|
||||
Reference in new issue
Block a user