feat(rid): screen one-hour spikes; refetch and redo-from to repair the store
CI / Format & lint (push) Successful in 15s
CI / Test suite (push) Successful in 20s
Docs / Validate documentation (push) Successful in 9s
Rust / fmt, clippy, test, build (push) Successful in 3m6s

RID's report sometimes carries a wrong value for one hour and corrects it
later. P.1 read 10.6 m at 2026-10-25 00:00 between 1.04 and 1.07 (RID now
says 1.06); the store kept it, because a report is re-fetched only while it
has holes, and the model issued a 10.8 m forecast for that hour.

- rid::judge / rid::screen: a reading that leaves the previous one (within
  3 h) by more than 1 m (2 m on the flashy tributaries P.76, P.77, P.81,
  P.82, P.84, P.85, P.87) while the next reading comes back near the
  previous level is a spike: not stored, a stored copy is deleted. Limbs
  and steps the next reading confirms are kept; the newest reading waits
  for the next one when it jumps more than twice the limit. On 2018-2026
  history: 16 spikes found (slipped decimals, sign flips, -5/-6/-9 m
  placeholders), no genuine change rejected.
- Every store goes through it (rid::store: run_cycle, fill-gaps, refetch).
- `ping-collector refetch FROM [TO]`: re-fetch reports regardless of holes
  (corrects revised readings, fills the discharge of level-only evening
  hours, removes stored spikes) and print the catch-up command when
  readings changed.
- `catch-up-forecasts --redo-from T`: delete the model forecasts issued
  from T on, then recompute them.
- Tests: judge/screen unit tests; a DB test of refetch, the live hold and
  the forecast deletion. Checked end to end against RID on a scratch DB.
- Docs: DATA_SOURCES (spikes, level-only hours), FLOOD_FORECASTING,
  rust/README.
This commit is contained in:
grabowski committed 2026-10-26 15:04:01 +07:00
1 parent a610abcdb8
commit 9bf904b23c
8 files changed
+684 -15

No files matched your search

+20 -1
View File
@@ -23,10 +23,29 @@ The only source persisted to the database and used by the ML pipeline.
| Params | `DW[UtokID]=1`, `DW[BasinID]=6` (Ping), `DW[TimeCurrent]=<Buddhist-calendar date>`, `rows=100` | | Params | `DW[UtokID]=1`, `DW[BasinID]=6` (Ping), `DW[TimeCurrent]=<Buddhist-calendar date>`, `rows=100` |
| Variables | Water level (m, gauge datum), discharge (m³/s, `'***'` = malformed), discharge % of channel capacity | | Variables | Water level (m, gauge datum), discharge (m³/s, `'***'` = malformed), discharge % of channel capacity |
| Stations | 16 P-series gauges (P.1 anchor at Nawarat Bridge; see `src/data/stations.json`) | | Stations | 16 P-series gauges (P.1 anchor at Nawarat Bridge; see `src/data/stations.json`) |
| Client | `rust/collector/src/rid.rs` (`ping-collector`; `fill-gaps` re-fetches old reports) | | Client | `rust/collector/src/rid.rs` (`ping-collector`; `fill-gaps` re-fetches old reports with holes, `refetch FROM [TO]` any reports) |
Human-facing page: <https://hyd-app-db.rid.go.th/hydro1h.html> Human-facing page: <https://hyd-app-db.rid.go.th/hydro1h.html>
**Spikes.** The report sometimes carries a wrong value for one hour and corrects
it later: a slipped decimal point (P.1 10.6 m between 1.04 and 1.07 on 2026-10-25
00:00, later 1.06; P.85 0.101 for 1.1), a flipped sign (P.4A ±1.18, P.67 ±0.6) or
a placeholder (−5, −6, −9, 50 m). The collector re-fetches a report only while it
has holes, so a later correction never arrived. Since 2026-10-26 every batch is
screened before it is stored (`rid::judge`, `rid::screen`): a reading that leaves
the previous one (within 3 h) by more than 1 m (2 m on the flashy tributaries
P.76, P.77, P.81, P.82, P.84, P.85, P.87) while the next reading comes back near
the previous level is not stored, and a stored copy is deleted. A rising or falling
limb, or a step the next reading confirms, is kept. The newest reading of a gauge
waits for the next one when it jumps more than twice the limit. On the 2018–2026
history the rule finds 16 spikes and would have rejected no genuine change (the
largest genuine 1-h changes: 1.1 m on the mainstem, 3.1 m at P.76). Two wrong hours
in a row are not caught.
Evening hours often arrive level-only (no discharge) and gain the discharge
later; the API's history fills a missing discharge with 20 × level (an old
placeholder, kept for compatibility). `refetch` brings in RID's discharge.
--- ---
## 2. ThaiWater / HII ecosystem ## 2. ThaiWater / HII ecosystem
+4 -1
View File
@@ -643,7 +643,10 @@ Open-Meteo's estimate of rain that has since fallen, as in training, where
the forward rain is observed rain. Such rows are written only where no model the forward rain is observed rain. Such rows are written only where no model
row exists; `created_at` tells them apart (more than an hour after `as_of`). row exists; `created_at` tells them apart (more than an hour after `as_of`).
`ping-collector catch-up-forecasts --hours N` does the same further back (up `ping-collector catch-up-forecasts --hours N` does the same further back (up
to 60 days). to 60 days); with `--redo-from T` it first deletes the model forecasts issued
from T on, to recompute them after `ping-collector refetch` corrected the
readings they came from (a bad reading reaches the features for 72 h through
`roll_max_72`).
A model row in `forecast_history` is never replaced by a heuristic one. While A model row in `forecast_history` is never replaced by a heuristic one. While
RID is silent overnight each hourly run re-issues the 21:00 reading, and once RID is silent overnight each hourly run re-issues the 21:00 reading, and once
+7 -2
View File
@@ -22,12 +22,17 @@ ping-collector serve [--listen A] [--no-analytics]
ping-collector once [--dry-run] # one cycle of every source; --dry-run writes nothing ping-collector once [--dry-run] # one cycle of every source; --dry-run writes nothing
# and compares the RID fetch with the database # and compares the RID fetch with the database
ping-collector fill-gaps <days|all> # re-fetch RID reports with holes ping-collector fill-gaps <days|all> # re-fetch RID reports with holes
ping-collector refetch FROM [TO] # re-fetch the RID reports of FROM..=TO (YYYY-MM-DD) even
# without holes, through the spike screening: corrects
# readings RID revised, deletes stored spikes; prints the
# catch-up-forecasts command when readings changed
ping-collector check-db # connect, create missing tables, row counts ping-collector check-db # connect, create missing tables, row counts
ping-collector forecast [--json] [--save] [--now T] [--rain-file F] ping-collector forecast [--json] [--save] [--now T] [--rain-file F]
# forecast from the database and print it # forecast from the database and print it
ping-collector catch-up-forecasts [--hours N] ping-collector catch-up-forecasts [--hours N] [--redo-from T]
# forecast the hours whose readings came late (RID's # forecast the hours whose readings came late (RID's
# overnight batch) as a run then would have; default 336 h # overnight batch) as a run then would have; default 336 h;
# --redo-from first deletes the model forecasts from T on
ping-collector notify --dry-run # the readings the notifier judges, and what it would send ping-collector notify --dry-run # the readings the notifier judges, and what it would send
``` ```
+56
View File
@@ -237,6 +237,62 @@ pub async fn save_rid(pool: &PgPool, readings: &[Reading], stations: &Stations)
Ok(rows.len()) Ok(rows.len())
} }
/// Stored RID levels of report columns `nums` in [start, end], for the
/// screening (rid::screen).
pub async fn rid_readings_between(
pool: &PgPool,
nums: &[u32],
start: NaiveDateTime,
end: NaiveDateTime,
) -> Result<Vec<crate::rid::StoredReading>> {
let rows: Vec<(NaiveDateTime, i32, f64, Option<f64>)> = sqlx::query_as(
"SELECT timestamp, station_id, water_level::float8, discharge::float8
FROM water_measurements
WHERE station_id = ANY($1) AND timestamp >= $2 AND timestamp <= $3
AND water_level IS NOT NULL",
)
.bind(nums.iter().map(|&n| n as i32).collect::<Vec<_>>())
.bind(start)
.bind(end)
.fetch_all(pool)
.await
.context("reading stored RID levels")?;
Ok(rows
.into_iter()
.map(|(t, n, l, q)| (t, n as u32, l, q))
.collect())
}
/// Delete stored RID readings (timestamp, report column).
pub async fn delete_rid_readings(pool: &PgPool, keys: &[(NaiveDateTime, u32)]) -> Result<usize> {
if keys.is_empty() {
return Ok(0);
}
let r = sqlx::query(
"DELETE FROM water_measurements w
USING UNNEST($1::timestamp[], $2::int4[]) AS k(ts, sid)
WHERE w.timestamp = k.ts AND w.station_id = k.sid",
)
.bind(keys.iter().map(|(t, _)| *t).collect::<Vec<_>>())
.bind(keys.iter().map(|(_, n)| *n as i32).collect::<Vec<_>>())
.execute(pool)
.await
.context("deleting RID spikes")?;
Ok(r.rows_affected() as usize)
}
/// Delete the model rows of forecast_history issued at or after `from`, so
/// a catch-up recomputes them (after the readings they were computed from
/// changed).
pub async fn delete_model_forecasts_since(pool: &PgPool, from: NaiveDateTime) -> Result<usize> {
let r = sqlx::query("DELETE FROM forecast_history WHERE as_of >= $1 AND source = 'model'")
.bind(from)
.execute(pool)
.await
.context("deleting forecast_history rows")?;
Ok(r.rows_affected() as usize)
}
/// Day -> hours (0-23) that have at least one RID reading, for [start, end]. /// Day -> hours (0-23) that have at least one RID reading, for [start, end].
pub async fn recorded_hours_by_day( pub async fn recorded_hours_by_day(
pool: &PgPool, pool: &PgPool,
+141
View File
@@ -191,6 +191,147 @@ async fn yesterdays_late_hours_are_recovered_after_midnight() {
db.drop().await; db.drop().await;
} }
/// A report source with fixed P.1 levels per hour of one report date.
struct ScriptedRid(Mutex<HashMap<NaiveDate, Vec<(i64, f64)>>>);
impl RidSource for ScriptedRid {
async fn fetch_report(&self, date: NaiveDate) -> Result<Vec<Reading>> {
let script = self
.0
.lock()
.unwrap()
.get(&date)
.cloned()
.unwrap_or_default();
Ok(script
.into_iter()
.map(|(h, level)| Reading {
timestamp: date.and_time(NaiveTime::MIN) + Duration::hours(h),
station_num: 8,
water_level: level,
discharge: Some(45.0),
discharge_percent: None,
})
.collect())
}
}
async fn p1_levels(pool: &PgPool) -> Vec<(NaiveDateTime, f64, Option<f64>)> {
sqlx::query_as(
"SELECT timestamp, water_level::float8, discharge::float8 FROM water_measurements
WHERE station_id = 8 ORDER BY timestamp",
)
.fetch_all(pool)
.await
.unwrap()
}
#[tokio::test]
async fn spikes_are_screened_and_refetch_repairs_the_store() {
let Some(db) = TestDb::new().await else {
return;
};
let stations = Stations::bundled();
let day = d(2026, 10, 24);
// what the store held before screening existed: the slip, level-only
db::save_rid(
&db.pool,
&[
Reading {
timestamp: t(2026, 10, 24, 23, 0),
station_num: 8,
water_level: 1.04,
discharge: None,
discharge_percent: None,
},
Reading {
timestamp: t(2026, 10, 25, 0, 0),
station_num: 8,
water_level: 10.6,
discharge: None,
discharge_percent: None,
},
Reading {
timestamp: t(2026, 10, 25, 1, 0),
station_num: 8,
water_level: 1.07,
discharge: Some(45.0),
discharge_percent: None,
},
],
&stations,
)
.await
.unwrap();
// RID still serves the slip: refetch deletes it and fills the discharge
let src = ScriptedRid(Mutex::new(HashMap::from([(
day,
vec![(23, 1.04), (24, 10.6)],
)])));
let s = rid::refetch(&src, &db.pool, &stations, day, day)
.await
.unwrap();
assert_eq!((s.saved, s.spikes, s.deleted), (1, 1, 1));
assert_eq!(s.first_change, Some(t(2026, 10, 24, 23, 0)));
assert_eq!(
p1_levels(&db.pool).await,
vec![
(t(2026, 10, 24, 23, 0), 1.04, Some(45.0)),
(t(2026, 10, 25, 1, 0), 1.07, Some(45.0)),
]
);
// RID corrected it: the hour comes back with the right value
src.0
.lock()
.unwrap()
.insert(day, vec![(23, 1.04), (24, 1.06)]);
let s = rid::refetch(&src, &db.pool, &stations, day, day)
.await
.unwrap();
assert_eq!((s.saved, s.spikes, s.deleted, s.changed), (2, 0, 0, 1));
assert_eq!(s.first_change, Some(t(2026, 10, 25, 0, 0)));
assert_eq!(p1_levels(&db.pool).await.len(), 3);
// a live cycle: the newest hour jumps and waits for the next one
let today = d(2026, 10, 25);
src.0
.lock()
.unwrap()
.insert(today, vec![(1, 1.07), (2, 9.9)]);
// (yesterday's report comes along: it still has holes)
let o = rid::run_cycle(&src, &db.pool, &stations, t(2026, 10, 25, 2, 10)).await;
assert_eq!((o.fetched, o.saved), (4, 3));
assert_eq!(
p1_levels(&db.pool).await.last().unwrap().0,
t(2026, 10, 25, 1, 0)
);
// 03:00 is back at 1.1: 02:00 was a spike and never stored
src.0
.lock()
.unwrap()
.insert(today, vec![(1, 1.07), (2, 9.9), (3, 1.1)]);
let o = rid::run_cycle(&src, &db.pool, &stations, t(2026, 10, 25, 3, 10)).await;
assert_eq!((o.fetched, o.saved), (5, 4));
let levels: Vec<f64> = p1_levels(&db.pool).await.iter().map(|r| r.1).collect();
assert_eq!(levels, vec![1.04, 1.06, 1.07, 1.1]);
// the forecasts issued from the changed hours can be redone
let mut f = forecast_row("P.1", 24, 0.9);
f.as_of = t(2026, 10, 25, 0, 0);
let mut g = forecast_row("P.1", 24, 0.0);
g.as_of = t(2026, 10, 24, 23, 0);
db::save_forecast_history(&db.pool, &[f, g]).await.unwrap();
assert_eq!(
db::delete_model_forecasts_since(&db.pool, t(2026, 10, 25, 0, 0))
.await
.unwrap(),
1
);
db.drop().await;
}
#[tokio::test] #[tokio::test]
async fn rid_upsert_revises_values_and_rounds_like_numeric() { async fn rid_upsert_revises_values_and_rounds_like_numeric() {
let Some(db) = TestDb::new().await else { let Some(db) = TestDb::new().await else {
+50 -1
View File
@@ -62,6 +62,14 @@ enum Command {
}, },
/// Re-fetch RID reports that have holes: a number of days back, or "all" /// Re-fetch RID reports that have holes: a number of days back, or "all"
FillGaps { days: String }, FillGaps { days: String },
/// Re-fetch the RID reports of FROM..=TO (YYYY-MM-DD; day D's report holds
/// D 01:00 to D+1 00:00) even without holes, through the spike screening:
/// corrects readings RID has revised and removes stored spikes
Refetch {
from: chrono::NaiveDate,
/// Last report date [default: FROM]
to: Option<chrono::NaiveDate>,
},
/// Connect, create missing tables, print row counts and the newest RID reading /// Connect, create missing tables, print row counts and the newest RID reading
CheckDb, CheckDb,
/// Forecast from the database now and print it; stores nothing without --save /// Forecast from the database now and print it; stores nothing without --save
@@ -87,6 +95,11 @@ enum Command {
/// How many hours back to look /// How many hours back to look
#[arg(long, default_value_t = 336)] #[arg(long, default_value_t = 336)]
hours: i64, hours: i64,
/// First delete the model forecasts issued from this local time on
/// (e.g. 2026-10-25T00:00) so they are recomputed: after `refetch`
/// changed the readings they came from
#[arg(long)]
redo_from: Option<String>,
}, },
/// Show the readings the notifier judges and the ntfy transitions due now /// Show the readings the notifier judges and the ntfy transitions due now
Notify { Notify {
@@ -212,7 +225,43 @@ async fn run(cli: Cli) -> Result<()> {
let rain = rain_file.as_deref().map(read_rain_file).transpose()?; let rain = rain_file.as_deref().map(read_rain_file).transpose()?;
collector.forecast(json, save, now, rain).await collector.forecast(json, save, now, rain).await
} }
Command::CatchUpForecasts { hours } => collector.catch_up_forecasts(hours).await, Command::CatchUpForecasts { hours, redo_from } => {
let redo_from = redo_from.as_deref().map(parse_local).transpose()?;
collector.catch_up_forecasts(hours, redo_from).await
}
Command::Refetch { from, to } => {
let to = to.unwrap_or(from);
if to < from {
bail!("refetch: TO ({to}) is before FROM ({from})");
}
db::ensure_schema(&collector.pool).await?;
let s = rid::refetch(
&collector.rid,
&collector.pool,
&collector.stations(),
from,
to,
)
.await?;
println!(
"{} readings saved ({} new or changed), {} spikes rejected, {} stored spikes deleted, {} held for want of the next reading",
s.saved,
s.changed - s.deleted,
s.spikes,
s.deleted,
s.held
);
if let Some(t) = s.first_change {
let hours = (util::bangkok_now() - t).num_hours() + 2;
if hours <= 24 * 60 {
println!(
"forecasts issued since {t} came from other readings; recompute them with\n ping-collector catch-up-forecasts --hours {hours} --redo-from {}",
t.format("%Y-%m-%dT%H:%M")
);
}
}
Ok(())
}
Command::Notify { dry_run } => collector.notify(dry_run).await, Command::Notify { dry_run } => collector.notify(dry_run).await,
Command::CheckDb => { Command::CheckDb => {
db::ensure_schema(&collector.pool).await?; db::ensure_schema(&collector.pool).await?;
+388 -8
View File
@@ -9,6 +9,12 @@
//! every reading it gets (not only "the current hour"), and re-fetches //! every reading it gets (not only "the current hour"), and re-fetches
//! yesterday's report while the store still has holes in it: hours 22-24 of a //! yesterday's report while the store still has holes in it: hours 22-24 of a
//! day are reachable only through that day's report. //! day are reachable only through that day's report.
//!
//! Before storing, readings are screened for single-hour spikes (see
//! [`judge`]): RID's report sometimes carries a slipped decimal point, a
//! flipped sign or a placeholder for one hour (P.1 10.6 m between 1.04 and
//! 1.07 on 2026-10-25 00:00), which would raise a false alarm and
//! distort the forecast features for three days.
use crate::db; use crate::db;
use crate::stations::Stations; use crate::stations::Stations;
@@ -17,7 +23,7 @@ use anyhow::{Context, Result};
use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime}; use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime};
use serde_json::Value; use serde_json::Value;
use sqlx::PgPool; use sqlx::PgPool;
use std::collections::{BTreeSet, HashMap, HashSet}; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::future::Future; use std::future::Future;
pub const N_STATIONS: u32 = 16; pub const N_STATIONS: u32 = 16;
@@ -96,6 +102,235 @@ pub fn parse_report(report_date: NaiveDate, payload: &Value) -> Vec<Reading> {
out out
} }
// ---- Screening -------------------------------------------------------------
/// Neighbours of a reading are looked for this many hours before and after it.
const SCREEN_WINDOW_H: i64 = 3;
/// Gauges on flashy tributaries: genuine 1-h changes of 1.3-3.1 m are on
/// record (P.76 0.09 -> 3.18 m on 2026-05-29). Elsewhere the largest genuine
/// 1-h change since 2018 is 1.1 m (P.1: 0.56 m).
const FLASHY: &[&str] = &["P.76", "P.77", "P.81", "P.82", "P.84", "P.85", "P.87"];
/// A change from the previous reading larger than this (m) needs the next
/// reading to tell a spike from a real change.
pub fn jump_limit(code: &str) -> f64 {
if FLASHY.contains(&code) { 2.0 } else { 1.0 }
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Verdict {
Keep,
/// The newest reading jumped more than twice the limit: wait for the next
/// one (an hour at most while RID is current) before storing it.
Hold,
/// Out and back within an hour or two: not stored.
Spike,
}
/// `prev`: the last accepted level within [`SCREEN_WINDOW_H`] before the
/// reading, `next`: the following reading within the window, if any.
///
/// A spike leaves the previous level by more than `limit` and the next
/// reading comes back, closer to the previous level than half of either
/// jump. A rising or falling limb (prev < level < next) or a step the next
/// reading confirms is kept. 2018-2026 history: 16 spikes (sign flips,
/// slipped decimal points, -5/-6/-9 m placeholders) and no genuine change
/// would have been rejected.
pub fn judge(limit: f64, prev: Option<f64>, level: f64, next: Option<f64>) -> Verdict {
let Some(p) = prev else {
return Verdict::Keep;
};
let out = (level - p).abs();
if out <= limit {
return Verdict::Keep;
}
let Some(n) = next else {
return if out > 2.0 * limit {
Verdict::Hold
} else {
Verdict::Keep
};
};
let back = (level - n).abs();
if back > limit && (n - p).abs() < out.min(back) / 2.0 {
Verdict::Spike
} else {
Verdict::Keep
}
}
/// A reading already in the database: (timestamp, report column, level, discharge).
pub type StoredReading = (NaiveDateTime, u32, f64, Option<f64>);
#[derive(Debug, Default)]
pub struct Screened {
pub keep: Vec<Reading>,
pub held: Vec<Reading>,
pub spikes: Vec<Reading>,
/// Stored rows at a spike's hour that are spikes themselves: delete.
pub delete: Vec<(NaiveDateTime, u32)>,
}
/// Screen a batch against itself and the stored readings around it. A batch
/// reading replaces the stored one at the same hour; stored readings count
/// as accepted. `code` maps a report column to its station code.
pub fn screen(
readings: &[Reading],
stored: &[StoredReading],
code: impl Fn(u32) -> String,
) -> Screened {
#[derive(Clone, Copy)]
enum Src {
Batch(usize),
Stored,
}
let mut out = Screened::default();
let mut by_station: BTreeMap<u32, BTreeMap<NaiveDateTime, (f64, Src)>> = BTreeMap::new();
for (t, n, level, _) in stored {
by_station
.entry(*n)
.or_default()
.insert(*t, (*level, Src::Stored));
}
let stored_at: HashMap<(NaiveDateTime, u32), f64> =
stored.iter().map(|(t, n, l, _)| ((*t, *n), *l)).collect();
// the last copy of an hour wins, as in the upsert
for (i, r) in readings.iter().enumerate() {
by_station
.entry(r.station_num)
.or_default()
.insert(r.timestamp, (r.water_level, Src::Batch(i)));
}
let window = Duration::hours(SCREEN_WINDOW_H);
for (n, series) in &by_station {
let limit = jump_limit(&code(*n));
let mut last: Option<(NaiveDateTime, f64)> = None;
let entries: Vec<_> = series.iter().map(|(t, v)| (*t, *v)).collect();
for (k, &(t, (level, src))) in entries.iter().enumerate() {
let prev = last.filter(|(pt, _)| t - *pt <= window).map(|(_, l)| l);
let i = match src {
Src::Stored => {
last = Some((t, level));
continue;
}
Src::Batch(i) => i,
};
let next = entries
.get(k + 1)
.filter(|(nt, _)| *nt - t <= window)
.map(|(_, (l, _))| *l);
match judge(limit, prev, level, next) {
Verdict::Keep => {
out.keep.push(readings[i].clone());
last = Some((t, level));
}
Verdict::Hold => out.held.push(readings[i].clone()),
Verdict::Spike => {
out.spikes.push(readings[i].clone());
if let Some(&old) = stored_at.get(&(t, *n)) {
if judge(limit, prev, old, next) == Verdict::Spike {
out.delete.push((t, *n));
} else {
last = Some((t, old));
}
}
}
}
}
}
out.keep.sort_by_key(|r| (r.timestamp, r.station_num));
out
}
#[derive(Debug, Default)]
pub struct StoreOutcome {
pub saved: usize,
pub held: usize,
pub spikes: usize,
pub deleted: usize,
/// Saved readings that were new or differ from the stored ones, plus deletions.
pub changed: usize,
/// The earliest hour whose stored data changed (forecasts from then on
/// were computed from other data).
pub first_change: Option<NaiveDateTime>,
}
/// Screen `readings` against the stored ones around them, then save them and
/// delete stored spikes.
pub async fn store(
pool: &PgPool,
readings: &[Reading],
stations: &Stations,
) -> Result<StoreOutcome> {
let mut out = StoreOutcome::default();
let (Some(first), Some(last)) = (
readings.iter().map(|r| r.timestamp).min(),
readings.iter().map(|r| r.timestamp).max(),
) else {
return Ok(out);
};
let window = Duration::hours(SCREEN_WINDOW_H);
let nums: Vec<u32> = readings
.iter()
.map(|r| r.station_num)
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
let stored = db::rid_readings_between(pool, &nums, first - window, last + window).await?;
let code = |n: u32| stations.get(n).code;
let s = screen(readings, &stored, code);
for r in &s.spikes {
tracing::warn!(
"RID {} {}: {} m is a one-hour spike, not stored",
r.timestamp,
code(r.station_num),
r.water_level
);
}
for r in &s.held {
tracing::warn!(
"RID {} {}: {} m jumps from the previous reading; waiting for the next one",
r.timestamp,
code(r.station_num),
r.water_level
);
}
let before: HashMap<(NaiveDateTime, u32), (f64, Option<f64>)> = stored
.iter()
.map(|(t, n, l, q)| ((*t, *n), (*l, *q)))
.collect();
// NUMERIC(10,3) / (10,2): compare at the stored precision
let differs = |a: f64, b: f64, places: i32| {
let f = 10f64.powi(places);
(a * f).round() != (b * f).round()
};
let mut changed: Vec<NaiveDateTime> = s
.keep
.iter()
.filter(|r| match before.get(&(r.timestamp, r.station_num)) {
None => true,
Some((l, q)) => {
differs(*l, r.water_level, 3)
|| match (q, r.discharge) {
(Some(a), Some(b)) => differs(*a, b, 2),
(None, None) => false,
_ => true,
}
}
})
.map(|r| r.timestamp)
.collect();
changed.extend(s.delete.iter().map(|(t, _)| *t));
out.saved = db::save_rid(pool, &s.keep, stations).await?;
out.deleted = db::delete_rid_readings(pool, &s.delete).await?;
out.held = s.held.len();
out.spikes = s.spikes.len();
out.changed = changed.len();
out.first_change = changed.into_iter().min();
Ok(out)
}
/// Anything that can produce a parsed RID report (HTTP in production, /// Anything that can produce a parsed RID report (HTTP in production,
/// canned rows in tests). /// canned rows in tests).
pub trait RidSource { pub trait RidSource {
@@ -248,12 +483,14 @@ pub async fn run_cycle(
tracing::warn!("RID: no data fetched for {:?}", out.dates); tracing::warn!("RID: no data fetched for {:?}", out.dates);
return out; return out;
} }
if let Err(e) = db::save_rid(pool, &readings, stations).await { match store(pool, &readings, stations).await {
tracing::error!("RID save failed: {e:#}"); Ok(s) => out.saved = s.saved,
out.error = Some(format!("save failed: {e:#}")); Err(e) => {
return out; tracing::error!("RID save failed: {e:#}");
out.error = Some(format!("save failed: {e:#}"));
return out;
}
} }
out.saved = readings.len();
let latest = readings let latest = readings
.iter() .iter()
.map(|r| r.timestamp) .map(|r| r.timestamp)
@@ -308,8 +545,7 @@ pub async fn fill_gaps(
} }
match src.fetch_report(*d).await { match src.fetch_report(*d).await {
Ok(readings) if !readings.is_empty() => { Ok(readings) if !readings.is_empty() => {
db::save_rid(pool, &readings, stations).await?; filled += store(pool, &readings, stations).await?.saved;
filled += readings.len();
} }
Ok(_) => { Ok(_) => {
empty += 1; empty += 1;
@@ -332,6 +568,52 @@ pub async fn fill_gaps(
Ok(filled) Ok(filled)
} }
/// Re-fetch the reports of `from..=to` (report dates: day D's report holds
/// D 01:00 to D+1 00:00) whether or not they have holes, and store them
/// through the screening: corrects readings RID has revised since (and
/// fills the discharge of level-only hours), removes stored spikes.
pub async fn refetch(
src: &impl RidSource,
pool: &PgPool,
stations: &Stations,
from: NaiveDate,
to: NaiveDate,
) -> Result<StoreOutcome> {
let mut total = StoreOutcome::default();
let mut day = from;
while day <= to {
if day > from {
tokio::time::sleep(std::time::Duration::from_secs(1)).await; // be polite
}
match src.fetch_report(day).await {
Ok(readings) => {
let s = store(pool, &readings, stations).await?;
tracing::info!(
"refetch {day}: {} fetched, {} saved ({} new or changed), {} spikes rejected, {} stored spikes deleted, {} held",
readings.len(),
s.saved,
s.changed - s.deleted,
s.spikes,
s.deleted,
s.held
);
total.saved += s.saved;
total.held += s.held;
total.spikes += s.spikes;
total.deleted += s.deleted;
total.changed += s.changed;
total.first_change = match (total.first_change, s.first_change) {
(Some(a), Some(b)) => Some(a.min(b)),
(a, b) => a.or(b),
};
}
Err(e) => tracing::warn!("refetch {day}: {e:#}"),
}
day += Duration::days(1);
}
Ok(total)
}
/// Next RID poll: `offset` minutes past the next hour when current, else in /// Next RID poll: `offset` minutes past the next hour when current, else in
/// `retry` minutes (never later than that hourly poll). /// `retry` minutes (never later than that hourly poll).
pub fn next_poll_time( pub fn next_poll_time(
@@ -407,6 +689,104 @@ mod tests {
assert!(!by.contains_key(&5)); // negative discharge drops the reading assert!(!by.contains_key(&5)); // negative discharge drops the reading
} }
#[test]
fn judge_spikes_steps_and_limbs() {
use Verdict::*;
// the P.1 slip of 2026-10-25 00:00, a sign flip, a placeholder
assert_eq!(judge(1.0, Some(1.04), 10.6, Some(1.07)), Spike);
assert_eq!(judge(1.0, Some(-1.18), 1.18, Some(-1.18)), Spike);
assert_eq!(judge(2.0, Some(-0.05), -5.0, Some(-0.05)), Spike);
// small changes, no neighbour, a confirmed step, rising limbs
assert_eq!(judge(1.0, Some(1.0), 1.9, Some(1.0)), Keep);
assert_eq!(judge(1.0, None, 10.6, Some(1.07)), Keep);
assert_eq!(judge(1.0, Some(1.0), 2.5, Some(2.6)), Keep);
assert_eq!(judge(2.0, Some(0.04), 1.61, Some(2.67)), Keep);
assert_eq!(judge(1.0, Some(0.82), 1.78, Some(2.82)), Keep);
// a peak that falls back less than halfway in the next hour is real
assert_eq!(judge(1.0, Some(1.0), 3.0, Some(1.8)), Keep);
// the newest reading: a jump over twice the limit waits
assert_eq!(judge(1.0, Some(1.04), 10.6, None), Hold);
assert_eq!(judge(1.0, Some(1.0), 2.5, None), Keep);
assert_eq!(judge(2.0, Some(0.09), 3.18, None), Keep);
assert_eq!(jump_limit("P.76"), 2.0);
assert_eq!(jump_limit("P.1"), 1.0);
}
fn rd(h: u32, n: u32, level: f64) -> Reading {
Reading {
timestamp: t(2026, 10, 25, h, 0),
station_num: n,
water_level: level,
discharge: None,
discharge_percent: None,
}
}
fn code(n: u32) -> String {
if n == 8 {
"P.1".into()
} else {
format!("P.{}", 19 + n)
}
}
#[test]
fn screen_rejects_a_spike_and_deletes_its_stored_copy() {
let stored = vec![
(t(2026, 10, 24, 23, 0), 8, 1.04, None),
(t(2026, 10, 25, 0, 0), 8, 10.6, None),
];
// the report re-fetched, RID still has the slip
let batch = vec![rd(0, 8, 10.6), rd(1, 8, 1.07), rd(2, 8, 1.09)];
let s = screen(&batch, &stored, code);
assert_eq!(s.spikes, vec![rd(0, 8, 10.6)]);
assert_eq!(s.delete, vec![(t(2026, 10, 25, 0, 0), 8)]);
assert_eq!(s.keep, vec![rd(1, 8, 1.07), rd(2, 8, 1.09)]);
// RID corrected it: the new value replaces the stored spike
let s = screen(&[rd(0, 8, 1.06), rd(1, 8, 1.07)], &stored, code);
assert!(s.spikes.is_empty() && s.delete.is_empty());
assert_eq!(s.keep, vec![rd(0, 8, 1.06), rd(1, 8, 1.07)]);
}
#[test]
fn screen_keeps_a_good_stored_copy_and_judges_the_next_hour_by_it() {
let stored = vec![
(t(2026, 10, 25, 0, 0), 8, 1.06, None),
(t(2026, 10, 25, 1, 0), 8, 1.07, None),
];
let s = screen(&[rd(1, 8, 10.7), rd(2, 8, 1.09)], &stored, code);
assert_eq!(s.spikes, vec![rd(1, 8, 10.7)]);
assert!(s.delete.is_empty());
assert_eq!(s.keep, vec![rd(2, 8, 1.09)]);
}
#[test]
fn screen_holds_the_newest_jump_until_the_next_reading() {
let stored = vec![(t(2026, 10, 25, 13, 0), 8, 1.04, None)];
let s = screen(&[rd(14, 8, 10.6)], &stored, code);
assert_eq!(s.held, vec![rd(14, 8, 10.6)]);
assert!(s.keep.is_empty() && s.spikes.is_empty());
// next poll: the report again, now with 15:00
let s = screen(&[rd(14, 8, 10.6), rd(15, 8, 1.06)], &stored, code);
assert_eq!(s.spikes, vec![rd(14, 8, 10.6)]);
assert_eq!(s.keep, vec![rd(15, 8, 1.06)]);
// a real jump the next reading confirms is stored then
let s = screen(&[rd(14, 8, 3.5), rd(15, 8, 3.6)], &stored, code);
assert_eq!(s.keep, vec![rd(14, 8, 3.5), rd(15, 8, 3.6)]);
}
#[test]
fn screen_window_and_stations_are_separate() {
// previous reading 4 h back: nothing to compare with
let stored = vec![(t(2026, 10, 25, 9, 0), 8, 1.0, None)];
let s = screen(&[rd(13, 8, 5.0), rd(13, 1, 0.5)], &stored, code);
assert_eq!(s.keep.len(), 2);
// a jump at one gauge does not involve the others
let stored = vec![(t(2026, 10, 25, 12, 0), 1, 0.1, None)];
let s = screen(&[rd(13, 8, 1.0), rd(13, 1, 0.12)], &stored, code);
assert_eq!(s.keep.len(), 2);
}
#[test] #[test]
fn missing_rows_key_is_empty() { fn missing_rows_key_is_empty() {
assert!(parse_report(d(2026, 7, 22), &json!({"unexpected": true})).is_empty()); assert!(parse_report(d(2026, 7, 22), &json!({"unexpected": true})).is_empty());
+18 -2
View File
@@ -400,10 +400,22 @@ impl Collector {
/// `ping-collector catch-up-forecasts`: `forecasting::catch_up` over /// `ping-collector catch-up-forecasts`: `forecasting::catch_up` over
/// `hours`, e.g. once after an upgrade for the nights before it. /// `hours`, e.g. once after an upgrade for the nights before it.
pub async fn catch_up_forecasts(&self, hours: i64) -> Result<()> { pub async fn catch_up_forecasts(
&self,
hours: i64,
redo_from: Option<NaiveDateTime>,
) -> Result<()> {
if !(1..=24 * 60).contains(&hours) { if !(1..=24 * 60).contains(&hours) {
bail!("--hours must be between 1 and 1440 (Open-Meteo keeps 92 days of past rain)"); bail!("--hours must be between 1 and 1440 (Open-Meteo keeps 92 days of past rain)");
} }
let now = bangkok_now();
if let Some(from) = redo_from
&& from < floor_hour(now) - Duration::hours(hours)
{
bail!(
"--redo-from {from} is more than --hours {hours} back: those hours would not be recomputed"
);
}
let mut store = ModelStore::new(&self.cfg.models_dir); let mut store = ModelStore::new(&self.cfg.models_dir);
let models = store.refresh()?; let models = store.refresh()?;
if models.is_empty() { if models.is_empty() {
@@ -413,11 +425,15 @@ impl Collector {
); );
} }
db::ensure_schema(&self.pool).await?; db::ensure_schema(&self.pool).await?;
if let Some(from) = redo_from {
let n = db::delete_model_forecasts_since(&self.pool, from).await?;
eprintln!("deleted {n} model forecasts issued since {from}; recomputing");
}
let c = forecasting::catch_up( let c = forecasting::catch_up(
&self.pool, &self.pool,
&self.rain, &self.rain,
&models, &models,
bangkok_now(), now,
hours, hours,
&Default::default(), &Default::default(),
) )