feat(rust): ping-collector, phase 1 of the Rust rewrite
CI / Format & lint (push) Successful in 19s
CI / Test suite (push) Successful in 47s
Docs / Validate documentation (push) Successful in 19s
Rust / fmt, clippy, test, build (push) Successful in 7m35s
Security / Dependency vulnerabilities (push) Successful in 1m37s
Security / Static analysis (push) Successful in 17s
Security / License report (push) Successful in 1m30s

rust/collector replaces the collection half of the Python web app: RID hourly
gauges (with the late-report semantics: store everything, retry every 10 min
while RID is behind, re-fetch yesterday's holes, startup gap fill), HII rain +
water level, RID dams (today + yesterday) and Open-Meteo rain. Same tables and
upsert semantics, one UNNEST statement per table, Bangkok wall-clock time
regardless of the server time zone.

Python side: COLLECTION_ENABLED=false makes the API leader stop collecting and
instead re-forecast + notify when the newest RID reading in the database
advances (polled every FORECAST_POLL_SECONDS) and once per clock hour;
/scraping/status then serves the collector's status file; /scrape/trigger
answers 409.

Verified: 20 Rust tests (parsers, gap/schedule logic, database tests against
PostgreSQL); live parity run of both collectors into two empty databases:
all 9 tables row-for-row identical (scripts/collector_parity.py); the Python
API served health, measurements, stats, status and model forecasts from a
database filled only by ping-collector. 177 Python tests pass.

reqwest uses native-tls: app.rid.go.th offers only TLS 1.2 CBC suites, which
rustls refuses.

Deploy/cutover/rollback: scripts/install_collector.sh, scripts/ping-collector.service;
CI: .gitea/workflows/rust.yml (fmt, clippy, tests with a PostgreSQL service,
release artifact).
This commit is contained in:
grabowski committed 2026-10-17 22:20:02 +07:00
1 parent e3694d7591
commit 4e619aa2ae
26 files changed
+5836 -15

No files matched your search

+476
View File
@@ -0,0 +1,476 @@
//! PostgreSQL access: the same tables and upsert semantics as the Python
//! collectors (src/database_adapters.py, hii_collector.py, rid_reservoir.py,
//! ml/rain.py), written as one set-based statement per table (UNNEST) instead
//! of a statement per row.
use crate::dams::DamRecord;
use crate::hii::{RainRecord, WaterLevelRecord};
use crate::rain::RainRow;
use crate::rid::Reading;
use crate::stations::Stations;
use anyhow::{Context, Result};
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
use sqlx::PgPool;
use sqlx::postgres::PgPoolOptions;
use std::collections::{BTreeMap, HashMap, HashSet};
pub async fn connect(url: &str) -> Result<PgPool> {
PgPoolOptions::new()
.max_connections(4)
.acquire_timeout(std::time::Duration::from_secs(30))
.connect(url)
.await
.context("connecting to PostgreSQL")
}
/// Idempotent DDL, identical to what the Python app creates (so either side
/// can start first against an empty database).
const SCHEMA: &[&str] = &[
"CREATE TABLE IF NOT EXISTS stations (
id SERIAL PRIMARY KEY,
station_code VARCHAR(10) UNIQUE NOT NULL,
thai_name VARCHAR(255) NOT NULL,
english_name VARCHAR(255) NOT NULL,
latitude DECIMAL(10,8),
longitude DECIMAL(11,8),
geohash VARCHAR(20),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)",
"CREATE TABLE IF NOT EXISTS water_measurements (
id BIGSERIAL PRIMARY KEY,
timestamp TIMESTAMP NOT NULL,
station_id INTEGER NOT NULL,
water_level NUMERIC(10,3),
discharge NUMERIC(10,2),
discharge_percent NUMERIC(5,2),
status VARCHAR(20) DEFAULT 'active',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (station_id) REFERENCES stations(id),
UNIQUE(timestamp, station_id))",
"CREATE INDEX IF NOT EXISTS idx_timestamp ON water_measurements(timestamp)",
"CREATE INDEX IF NOT EXISTS idx_station_timestamp ON water_measurements(station_id, timestamp DESC)",
"CREATE TABLE IF NOT EXISTS hii_rain_stations (
id INTEGER PRIMARY KEY,
oldcode VARCHAR(60),
name_th VARCHAR(255),
name_en VARCHAR(255),
latitude NUMERIC(10,6),
longitude NUMERIC(10,6),
sub_basin_id VARCHAR(10),
agency VARCHAR(40),
updated_at TIMESTAMP)",
"CREATE TABLE IF NOT EXISTS hii_rainfall (
station_id INTEGER NOT NULL,
timestamp TIMESTAMP NOT NULL,
rain_1h NUMERIC(7,2),
rain_24h NUMERIC(8,2),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (station_id, timestamp))",
"CREATE TABLE IF NOT EXISTS hii_wl_stations (
id INTEGER PRIMARY KEY,
oldcode VARCHAR(60),
rid_code VARCHAR(10),
name_th VARCHAR(255),
name_en VARCHAR(255),
latitude NUMERIC(10,6),
longitude NUMERIC(10,6),
agency VARCHAR(40),
river_name VARCHAR(255),
offset_msl NUMERIC(8,3),
ground_level_msl NUMERIC(8,3),
min_bank_msl NUMERIC(8,3),
critical_level_msl NUMERIC(8,3),
critical_level_m NUMERIC(8,3),
qmax NUMERIC(10,2),
is_key_station BOOLEAN,
updated_at TIMESTAMP)",
"CREATE TABLE IF NOT EXISTS hii_waterlevel (
station_id INTEGER NOT NULL,
timestamp TIMESTAMP NOT NULL,
wl_msl NUMERIC(8,3),
wl_m NUMERIC(8,3),
discharge NUMERIC(10,2),
flow_rate NUMERIC(10,2),
storage_percent NUMERIC(6,2),
situation_level INTEGER,
diff_wl_bank NUMERIC(8,3),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (station_id, timestamp))",
"CREATE INDEX IF NOT EXISTS idx_hii_rainfall_ts ON hii_rainfall(timestamp)",
"CREATE INDEX IF NOT EXISTS idx_hii_waterlevel_ts ON hii_waterlevel(timestamp)",
"CREATE TABLE IF NOT EXISTS rid_dams (
dam_id VARCHAR(10) PRIMARY KEY,
region VARCHAR(40),
name_th VARCHAR(255),
latitude NUMERIC(10,6),
longitude NUMERIC(10,6),
capacity_max_mcm NUMERIC(10,2),
capacity_normal_mcm NUMERIC(10,2),
updated_at TIMESTAMP)",
"CREATE TABLE IF NOT EXISTS rid_reservoir_daily (
dam_id VARCHAR(10) NOT NULL,
date DATE NOT NULL,
storage_mcm NUMERIC(10,2),
storage_pct NUMERIC(8,2),
inflow_mcm NUMERIC(10,2),
outflow_mcm NUMERIC(10,2),
level_msl NUMERIC(8,2),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (dam_id, date))",
"CREATE INDEX IF NOT EXISTS idx_rid_reservoir_date ON rid_reservoir_daily(date)",
"CREATE TABLE IF NOT EXISTS openmeteo_rain (
timestamp TIMESTAMP PRIMARY KEY,
chiang_dao NUMERIC(6,2), mae_taeng NUMERIC(6,2), mae_ngat NUMERIC(6,2),
mae_rim NUMERIC(6,2), chiang_mai NUMERIC(6,2),
catchment_mean NUMERIC(6,2),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)",
];
pub async fn ensure_schema(pool: &PgPool) -> Result<()> {
for ddl in SCHEMA {
sqlx::query(ddl)
.execute(pool)
.await
.with_context(|| format!("DDL failed: {ddl}"))?;
}
Ok(())
}
/// Keep the last record per key (a later report revision wins), preserving
/// first-seen order; Postgres refuses to upsert the same key twice in one
/// statement.
fn dedupe_last<T, K: std::hash::Hash + Eq + Clone>(items: &[T], key: impl Fn(&T) -> K) -> Vec<&T> {
let mut index: HashMap<K, usize> = HashMap::new();
let mut out: Vec<&T> = Vec::new();
for item in items {
let k = key(item);
match index.get(&k) {
Some(&i) => out[i] = item,
None => {
index.insert(k, out.len());
out.push(item);
}
}
}
out
}
pub async fn save_rid(pool: &PgPool, readings: &[Reading], stations: &Stations) -> Result<usize> {
let rows = dedupe_last(readings, |r| (r.timestamp, r.station_num));
let station_nums: Vec<u32> = rows
.iter()
.map(|r| r.station_num)
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
let infos: Vec<_> = station_nums.iter().map(|&n| stations.get(n)).collect();
let mut tx = pool.begin().await?;
sqlx::query(
"INSERT INTO stations (id, station_code, thai_name, english_name, latitude, longitude, geohash, updated_at)
SELECT *, NOW() FROM UNNEST($1::int4[], $2::text[], $3::text[], $4::text[], $5::float8[], $6::float8[], $7::text[])
ON CONFLICT (id) DO UPDATE SET
thai_name = EXCLUDED.thai_name, english_name = EXCLUDED.english_name,
latitude = EXCLUDED.latitude, longitude = EXCLUDED.longitude,
geohash = EXCLUDED.geohash, updated_at = NOW()",
)
.bind(station_nums.iter().map(|&n| n as i32).collect::<Vec<_>>())
.bind(infos.iter().map(|s| s.code.clone()).collect::<Vec<_>>())
.bind(infos.iter().map(|s| s.thai_name.clone()).collect::<Vec<_>>())
.bind(infos.iter().map(|s| s.english_name.clone()).collect::<Vec<_>>())
.bind(infos.iter().map(|s| s.latitude).collect::<Vec<_>>())
.bind(infos.iter().map(|s| s.longitude).collect::<Vec<_>>())
.bind(infos.iter().map(|s| s.geohash.clone()).collect::<Vec<_>>())
.execute(&mut *tx)
.await
.context("upserting stations")?;
sqlx::query(
"INSERT INTO water_measurements (timestamp, station_id, water_level, discharge, discharge_percent, status)
SELECT *, 'active' FROM UNNEST($1::timestamp[], $2::int4[], $3::float8[], $4::float8[], $5::float8[])
ON CONFLICT (timestamp, station_id) DO UPDATE SET
water_level = EXCLUDED.water_level, discharge = EXCLUDED.discharge,
discharge_percent = EXCLUDED.discharge_percent, status = EXCLUDED.status",
)
.bind(rows.iter().map(|r| r.timestamp).collect::<Vec<_>>())
.bind(rows.iter().map(|r| r.station_num as i32).collect::<Vec<_>>())
.bind(rows.iter().map(|r| r.water_level).collect::<Vec<_>>())
.bind(rows.iter().map(|r| r.discharge).collect::<Vec<_>>())
.bind(rows.iter().map(|r| r.discharge_percent).collect::<Vec<_>>())
.execute(&mut *tx)
.await
.context("upserting water_measurements")?;
tx.commit().await?;
Ok(rows.len())
}
/// Day -> hours (0-23) that have at least one RID reading, for [start, end].
pub async fn recorded_hours_by_day(
pool: &PgPool,
start: NaiveDate,
end: NaiveDate,
) -> Result<HashMap<NaiveDate, HashSet<u32>>> {
let rows: Vec<(NaiveDate, i32)> = sqlx::query_as(
"SELECT CAST(timestamp AS DATE) AS day, CAST(EXTRACT(HOUR FROM timestamp) AS INTEGER) AS hour
FROM water_measurements WHERE timestamp >= $1 AND timestamp < $2
GROUP BY 1, 2",
)
.bind(start.and_time(NaiveTime::MIN))
.bind((end + chrono::Duration::days(1)).and_time(NaiveTime::MIN))
.fetch_all(pool)
.await?;
let mut out: HashMap<NaiveDate, HashSet<u32>> = HashMap::new();
for (day, hour) in rows {
out.entry(day).or_default().insert(hour as u32);
}
Ok(out)
}
pub async fn earliest_rid_timestamp(pool: &PgPool) -> Result<Option<NaiveDateTime>> {
Ok(
sqlx::query_scalar("SELECT MIN(timestamp) FROM water_measurements")
.fetch_one(pool)
.await?,
)
}
pub async fn latest_rid_timestamp(pool: &PgPool) -> Result<Option<NaiveDateTime>> {
Ok(
sqlx::query_scalar("SELECT MAX(timestamp) FROM water_measurements")
.fetch_one(pool)
.await?,
)
}
/// How many of `readings` are already stored (dry-run comparison).
pub async fn count_stored_rid(pool: &PgPool, readings: &[Reading]) -> Result<i64> {
Ok(sqlx::query_scalar(
"SELECT COUNT(*) FROM water_measurements w
JOIN UNNEST($1::timestamp[], $2::int4[]) AS k(ts, sid) ON w.timestamp = k.ts AND w.station_id = k.sid",
)
.bind(readings.iter().map(|r| r.timestamp).collect::<Vec<_>>())
.bind(readings.iter().map(|r| r.station_num as i32).collect::<Vec<_>>())
.fetch_one(pool)
.await?)
}
pub async fn save_hii_rain(
pool: &PgPool,
records: &[RainRecord],
now: NaiveDateTime,
) -> Result<usize> {
if records.is_empty() {
return Ok(0);
}
let st = dedupe_last(records, |r| r.station_id);
let ms = dedupe_last(records, |r| (r.station_id, r.timestamp));
let mut tx = pool.begin().await?;
sqlx::query(
"INSERT INTO hii_rain_stations (id, oldcode, name_th, name_en, latitude, longitude, sub_basin_id, agency, updated_at)
SELECT * FROM UNNEST($1::int4[], $2::text[], $3::text[], $4::text[], $5::float8[], $6::float8[], $7::text[], $8::text[], $9::timestamp[])
ON CONFLICT (id) DO UPDATE SET
oldcode = EXCLUDED.oldcode, name_th = EXCLUDED.name_th, name_en = EXCLUDED.name_en,
latitude = EXCLUDED.latitude, longitude = EXCLUDED.longitude,
sub_basin_id = EXCLUDED.sub_basin_id, agency = EXCLUDED.agency, updated_at = EXCLUDED.updated_at",
)
.bind(st.iter().map(|r| r.station_id as i32).collect::<Vec<_>>())
.bind(st.iter().map(|r| r.oldcode.clone()).collect::<Vec<_>>())
.bind(st.iter().map(|r| r.name_th.clone()).collect::<Vec<_>>())
.bind(st.iter().map(|r| r.name_en.clone()).collect::<Vec<_>>())
.bind(st.iter().map(|r| r.latitude).collect::<Vec<_>>())
.bind(st.iter().map(|r| r.longitude).collect::<Vec<_>>())
.bind(st.iter().map(|r| r.sub_basin_id.clone()).collect::<Vec<_>>())
.bind(st.iter().map(|r| r.agency.clone()).collect::<Vec<_>>())
.bind(vec![now; st.len()])
.execute(&mut *tx)
.await
.context("upserting hii_rain_stations")?;
sqlx::query(
"INSERT INTO hii_rainfall (station_id, timestamp, rain_1h, rain_24h)
SELECT * FROM UNNEST($1::int4[], $2::timestamp[], $3::float8[], $4::float8[])
ON CONFLICT (station_id, timestamp) DO UPDATE SET
rain_1h = EXCLUDED.rain_1h, rain_24h = EXCLUDED.rain_24h",
)
.bind(ms.iter().map(|r| r.station_id as i32).collect::<Vec<_>>())
.bind(ms.iter().map(|r| r.timestamp).collect::<Vec<_>>())
.bind(ms.iter().map(|r| r.rain_1h).collect::<Vec<_>>())
.bind(ms.iter().map(|r| r.rain_24h).collect::<Vec<_>>())
.execute(&mut *tx)
.await
.context("upserting hii_rainfall")?;
tx.commit().await?;
Ok(ms.len())
}
pub async fn save_hii_waterlevel(
pool: &PgPool,
records: &[WaterLevelRecord],
now: NaiveDateTime,
) -> Result<usize> {
if records.is_empty() {
return Ok(0);
}
let st = dedupe_last(records, |r| r.station_id);
let ms = dedupe_last(records, |r| (r.station_id, r.timestamp));
let mut tx = pool.begin().await?;
sqlx::query(
"INSERT INTO hii_wl_stations (id, oldcode, rid_code, name_th, name_en, latitude, longitude, agency,
river_name, offset_msl, ground_level_msl, min_bank_msl, critical_level_msl, critical_level_m,
qmax, is_key_station, updated_at)
SELECT * FROM UNNEST($1::int4[], $2::text[], $3::text[], $4::text[], $5::text[], $6::float8[],
$7::float8[], $8::text[], $9::text[], $10::float8[], $11::float8[], $12::float8[], $13::float8[],
$14::float8[], $15::float8[], $16::bool[], $17::timestamp[])
ON CONFLICT (id) DO UPDATE SET
oldcode = EXCLUDED.oldcode, rid_code = EXCLUDED.rid_code, name_th = EXCLUDED.name_th,
name_en = EXCLUDED.name_en, latitude = EXCLUDED.latitude, longitude = EXCLUDED.longitude,
agency = EXCLUDED.agency, river_name = EXCLUDED.river_name, offset_msl = EXCLUDED.offset_msl,
ground_level_msl = EXCLUDED.ground_level_msl, min_bank_msl = EXCLUDED.min_bank_msl,
critical_level_msl = EXCLUDED.critical_level_msl, critical_level_m = EXCLUDED.critical_level_m,
qmax = EXCLUDED.qmax, is_key_station = EXCLUDED.is_key_station, updated_at = EXCLUDED.updated_at",
)
.bind(st.iter().map(|r| r.station_id as i32).collect::<Vec<_>>())
.bind(st.iter().map(|r| r.oldcode.clone()).collect::<Vec<_>>())
.bind(st.iter().map(|r| r.rid_code.clone()).collect::<Vec<_>>())
.bind(st.iter().map(|r| r.name_th.clone()).collect::<Vec<_>>())
.bind(st.iter().map(|r| r.name_en.clone()).collect::<Vec<_>>())
.bind(st.iter().map(|r| r.latitude).collect::<Vec<_>>())
.bind(st.iter().map(|r| r.longitude).collect::<Vec<_>>())
.bind(st.iter().map(|r| r.agency.clone()).collect::<Vec<_>>())
.bind(st.iter().map(|r| r.river_name.clone()).collect::<Vec<_>>())
.bind(st.iter().map(|r| r.offset_msl).collect::<Vec<_>>())
.bind(st.iter().map(|r| r.ground_level_msl).collect::<Vec<_>>())
.bind(st.iter().map(|r| r.min_bank_msl).collect::<Vec<_>>())
.bind(st.iter().map(|r| r.critical_level_msl).collect::<Vec<_>>())
.bind(st.iter().map(|r| r.critical_level_m).collect::<Vec<_>>())
.bind(st.iter().map(|r| r.qmax).collect::<Vec<_>>())
.bind(st.iter().map(|r| r.is_key_station).collect::<Vec<_>>())
.bind(vec![now; st.len()])
.execute(&mut *tx)
.await
.context("upserting hii_wl_stations")?;
sqlx::query(
"INSERT INTO hii_waterlevel (station_id, timestamp, wl_msl, wl_m, discharge, flow_rate,
storage_percent, situation_level, diff_wl_bank)
SELECT * FROM UNNEST($1::int4[], $2::timestamp[], $3::float8[], $4::float8[], $5::float8[],
$6::float8[], $7::float8[], $8::int4[], $9::float8[])
ON CONFLICT (station_id, timestamp) DO UPDATE SET
wl_msl = EXCLUDED.wl_msl, wl_m = EXCLUDED.wl_m, discharge = EXCLUDED.discharge,
flow_rate = EXCLUDED.flow_rate, storage_percent = EXCLUDED.storage_percent,
situation_level = EXCLUDED.situation_level, diff_wl_bank = EXCLUDED.diff_wl_bank",
)
.bind(ms.iter().map(|r| r.station_id as i32).collect::<Vec<_>>())
.bind(ms.iter().map(|r| r.timestamp).collect::<Vec<_>>())
.bind(ms.iter().map(|r| r.wl_msl).collect::<Vec<_>>())
.bind(ms.iter().map(|r| r.wl_m).collect::<Vec<_>>())
.bind(ms.iter().map(|r| r.discharge).collect::<Vec<_>>())
.bind(ms.iter().map(|r| r.flow_rate).collect::<Vec<_>>())
.bind(ms.iter().map(|r| r.storage_percent).collect::<Vec<_>>())
.bind(ms.iter().map(|r| r.situation_level).collect::<Vec<_>>())
.bind(ms.iter().map(|r| r.diff_wl_bank).collect::<Vec<_>>())
.execute(&mut *tx)
.await
.context("upserting hii_waterlevel")?;
tx.commit().await?;
Ok(ms.len())
}
/// rid_dams metadata never blanks a stored value (COALESCE), and the daily
/// `level_msl` is preserved too: only the range backfill carries a level, the
/// daily snapshot would NULL it on every cycle.
pub async fn save_dams(pool: &PgPool, records: &[DamRecord], now: NaiveDateTime) -> Result<usize> {
if records.is_empty() {
return Ok(0);
}
let dams = dedupe_last(records, |r| r.dam_id.clone());
let ms = dedupe_last(records, |r| (r.dam_id.clone(), r.date));
let mut tx = pool.begin().await?;
sqlx::query(
"INSERT INTO rid_dams (dam_id, region, name_th, latitude, longitude, capacity_max_mcm,
capacity_normal_mcm, updated_at)
SELECT * FROM UNNEST($1::text[], $2::text[], $3::text[], $4::float8[], $5::float8[],
$6::float8[], $7::float8[], $8::timestamp[])
ON CONFLICT (dam_id) DO UPDATE SET
region = COALESCE(EXCLUDED.region, rid_dams.region),
name_th = COALESCE(EXCLUDED.name_th, rid_dams.name_th),
latitude = COALESCE(EXCLUDED.latitude, rid_dams.latitude),
longitude = COALESCE(EXCLUDED.longitude, rid_dams.longitude),
capacity_max_mcm = COALESCE(EXCLUDED.capacity_max_mcm, rid_dams.capacity_max_mcm),
capacity_normal_mcm = COALESCE(EXCLUDED.capacity_normal_mcm, rid_dams.capacity_normal_mcm),
updated_at = EXCLUDED.updated_at",
)
.bind(dams.iter().map(|r| r.dam_id.clone()).collect::<Vec<_>>())
.bind(dams.iter().map(|r| r.region.clone()).collect::<Vec<_>>())
.bind(dams.iter().map(|r| r.name_th.clone()).collect::<Vec<_>>())
.bind(dams.iter().map(|r| r.latitude).collect::<Vec<_>>())
.bind(dams.iter().map(|r| r.longitude).collect::<Vec<_>>())
.bind(dams.iter().map(|r| r.capacity_max_mcm).collect::<Vec<_>>())
.bind(dams.iter().map(|r| r.capacity_normal_mcm).collect::<Vec<_>>())
.bind(vec![now; dams.len()])
.execute(&mut *tx)
.await
.context("upserting rid_dams")?;
sqlx::query(
"INSERT INTO rid_reservoir_daily (dam_id, date, storage_mcm, storage_pct, inflow_mcm, outflow_mcm, level_msl)
SELECT * FROM UNNEST($1::text[], $2::date[], $3::float8[], $4::float8[], $5::float8[], $6::float8[], $7::float8[])
ON CONFLICT (dam_id, date) DO UPDATE SET
storage_mcm = EXCLUDED.storage_mcm, storage_pct = EXCLUDED.storage_pct,
inflow_mcm = EXCLUDED.inflow_mcm, outflow_mcm = EXCLUDED.outflow_mcm,
level_msl = COALESCE(EXCLUDED.level_msl, rid_reservoir_daily.level_msl)",
)
.bind(ms.iter().map(|r| r.dam_id.clone()).collect::<Vec<_>>())
.bind(ms.iter().map(|r| r.date).collect::<Vec<_>>())
.bind(ms.iter().map(|r| r.storage_mcm).collect::<Vec<_>>())
.bind(ms.iter().map(|r| r.storage_pct).collect::<Vec<_>>())
.bind(ms.iter().map(|r| r.inflow_mcm).collect::<Vec<_>>())
.bind(ms.iter().map(|r| r.outflow_mcm).collect::<Vec<_>>())
.bind(ms.iter().map(|r| r.level_msl).collect::<Vec<_>>())
.execute(&mut *tx)
.await
.context("upserting rid_reservoir_daily")?;
tx.commit().await?;
Ok(ms.len())
}
pub async fn save_rain(pool: &PgPool, rows: &[RainRow]) -> Result<usize> {
if rows.is_empty() {
return Ok(0);
}
let rows = dedupe_last(rows, |r| r.timestamp);
let col = |i: usize| rows.iter().map(|r| r.points[i]).collect::<Vec<_>>();
sqlx::query(
"INSERT INTO openmeteo_rain (timestamp, chiang_dao, mae_taeng, mae_ngat, mae_rim, chiang_mai, catchment_mean)
SELECT * FROM UNNEST($1::timestamp[], $2::float8[], $3::float8[], $4::float8[], $5::float8[], $6::float8[], $7::float8[])
ON CONFLICT (timestamp) DO UPDATE SET
chiang_dao = EXCLUDED.chiang_dao, mae_taeng = EXCLUDED.mae_taeng, mae_ngat = EXCLUDED.mae_ngat,
mae_rim = EXCLUDED.mae_rim, chiang_mai = EXCLUDED.chiang_mai, catchment_mean = EXCLUDED.catchment_mean",
)
.bind(rows.iter().map(|r| r.timestamp).collect::<Vec<_>>())
.bind(col(0))
.bind(col(1))
.bind(col(2))
.bind(col(3))
.bind(col(4))
.bind(rows.iter().map(|r| r.catchment_mean).collect::<Vec<_>>())
.execute(pool)
.await
.context("upserting openmeteo_rain")?;
Ok(rows.len())
}
/// Row counts per table, for `check-db`.
pub async fn table_counts(pool: &PgPool) -> Result<BTreeMap<&'static str, i64>> {
let mut out = BTreeMap::new();
for table in [
"water_measurements",
"hii_rainfall",
"hii_waterlevel",
"rid_reservoir_daily",
"openmeteo_rain",
] {
let n: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {table}"))
.fetch_one(pool)
.await?;
out.insert(table, n);
}
Ok(out)
}