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
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:
1 parent
e3694d7591
commit
4e619aa2ae
26 files changed
+5836
-15
No files matched your search
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user