Skip to content

Ping River Monitor

The Ping River Monitor is our open-source flood monitoring and forecasting service for the Ping River basin in Northern Thailand. It collects hourly gauge data from public sources, keeps the full history in PostgreSQL, and serves a dashboard, a REST API, and 6/12/24-hour flood-risk forecasts per gauge. It has been running continuously since it was built after the October 2024 flood. Since October 2026 the service is one Rust program, ping-collector, that collects, forecasts, notifies and serves the website and API; the models are trained in Python.

Everything the monitor shows comes from public instruments operated by others. We aggregate, store, fill gaps, and forecast; we do not (yet) run sensors of our own.

Source What Cadence Since
Royal Irrigation Department (RID) telemetry Water level (m, gauge datum) and discharge (m³/s) at 16 gauges on the Ping mainstem and western tributaries, Chiang Dao to Nakhon Sawan Hourly 2018-08
Hydro-Informatics Institute / ThaiWater (api-v3) Independent water level (m MSL) and rain gauges (rain_1h, rain_24h) at 400+ stations in the Ping basin; used to fill RID gaps and as a second rain source Hourly Levels 2019 (archive); rain 2026-08 (no archive)
Open-Meteo Hourly precipitation at five upper-Ping catchment points; the forecast-model archive for training and the live forecast at serving time Hourly, +48 h forecast 2021-03
RID large-dam report Mae Ngat reservoir storage, inflow, outflow Daily 2018

The full endpoint catalog, including quirks (the ThaiWater rain archive endpoint ignores its date range, for example), is in docs/DATA_SOURCES.md in the repository.

P.20 Ban Chiang Dao · P.92 Ban Muang Aut · P.75 Ban Chai Lat · P.4A Ban Mae Taeng · P.67 Ban Tae · P.21 Ban Rim Tai · P.103 Ring Bridge 3 · P.1 Nawarat Bridge (the Chiang Mai city gauge and the basin anchor) · P.81 · P.5 · P.82 · P.84 · P.87 · P.77 · P.85 · P.76. GET /stations returns codes, Thai and English names and coordinates.

Coverage matters more than it looks: the raw RID grid had readings for only ~56 percent of hours. A full-history re-fetch plus HII gap-fill brought it to ~93 percent (GET /api/stats reports the current figure), and every model number below was measured on the filled grid.

A one-screen console (since October 2026; design rules in docs/DASHBOARD_REDESIGN.md). Thai unless the browser prefers English; light and dark.

  • Top bar: the verdict for Chiang Mai (P.1 level and 24 h forecast), gauges reporting, the highest 24 h flood chance in the basin, data age, alert subscription (ntfy)
  • Gauge list: the 16 RID gauges with distance to the warning level, a 72 h sparkline and the 6 h change, sortable by river order, closeness to the warning level or rise; a second list of ThaiWater/HII basin stations as a share of bank height
  • Map: a self-hosted vector basemap of the basin (Protomaps extract of OpenStreetMap data, labels in the page language); gauges coloured by status; layers for basin stations, 24 h rain from the ThaiWater gauges, and the Chiang Mai inundation zones
  • Station panel: level, 6 h change, discharge, warning/danger levels and margins; history for any range back to 2018 (daily mean and low–high band for long ranges) with the issued 24 h forecasts; the forecast table at 6, 12 and 24 h; for P.1 the probability of each of the seven inundation stages (3.70 m to 4.60 m) and the live forecast accuracy
  • Status rule (map, list and panel alike): Watch within 0.5 m of the warning level or at a 24 h warning chance of 25 % or more; Warning and Danger at the station’s thresholds; No data after 24 h without a reading
  • Replay of the October 2024 flood: the whole console driven hour by hour by the record, with what a model trained before September 2024 said at each moment; ?demo_level=4.4 and ?demo_rise=1 simulate P.1. Both are labelled REPLAY / SIMULATION and never shown as live

Per gauge and per horizon (6, 12, 24 h), a HistGradientBoosting model (scikit-learn) predicts the rise above the current level within the horizon and, separately, the probability of crossing the station’s warning and danger thresholds. Serving takes the worse of the two paths so an alert can never be quieter than the regression implies.

Features: the gauge’s own recent history; upstream gauges shifted by their cross-correlation-measured travel time (P.20 to P.1 is 17 h); day of year; and catchment rain: trailing 6/24/72 h sums plus the forward-24 h forecast sum. Fifteen of the sixteen gauges have trained models; P.4A (17 percent data fill, effectively dead 2019 to 2024) is served by a persistence heuristic and flagged as such.

Thresholds are per station, calibrated from RID’s percent-of-channel-capacity figures. P.1 uses the municipality’s inundation map instead: warning 3.70 m (stage 1, city flooding begins), danger 4.20 m (stage 5).

Evaluation is rolling-origin and event-based: train on data ending before a monsoon season, forecast that season cold, record when the model first alerts relative to each warning crossing. The repository’s scripts/backtest_render.py regenerates the charts and fails if the 2024 lead falls below 12 h.

Event P.1 peak First alert Lead
October 2024 record flood (trained through 2024-08-31) 5.30 m 04:00 24 Sep, river at 2.9 m 13 h before the 17:00 crossing
September 2025 (trained through 2024-12-31) 3.93 m 16:00 27 Sep 2 h; a marginal crest, zero false alarms on the season

P.1 level MAE on the 2025 to 2026 holdout is 4.9 / 7.2 / 8.7 cm at 6 / 12 / 24 h. Documented negatives: Mae Ngat dam state as a feature costs 1 to 3 h of lead (the daily report lags the onset); quantile regression wins on MAE and loses on lead. Details and the full metrics are in FLOOD_FORECASTING.md section 5.

Every /forecast row carries model_version (hgb-v3+<git sha>; v2 is gauges-only, v3 adds rain), trained_at, and source (model or heuristic). Models are retrained monthly by a systemd timer; the trainer refuses to run without the rain series rather than silently producing a v2 model, and new bundles are promoted only after a check that they are what they claim to be.

Base URL https://water.buildfor.life. All endpoints below are read-only, need no key, and return JSON. Timestamps are local time (Asia/Bangkok, UTC+7) without an offset suffix.

Endpoint Returns
GET /stations The 16 RID gauges: station_code, Thai and English names, coordinates
GET /measurements/latest?limit=N Newest reading per station
GET /measurements/history/{code}?hours=N Hourly history for one gauge; or ?start=YYYY-MM-DD&end=YYYY-MM-DD; limit up to 100000 (about four years)
GET /forecast Current flood-risk forecast, every station × horizon, with thresholds and (for P.1) inundation-stage probabilities
GET /api/forecast/history/{code}?hours=N&horizon=24 Issued forecasts, i.e. what the model predicted and when; for auditing lead time after the fact
GET /api/hii/rainfall/latest?hours=N Latest rain_1h / rain_24h per ThaiWater/HII gauge
GET /api/hii/waterlevel/latest?hours=N Latest water level (m MSL) per ThaiWater/HII station
GET /api/hii/rainfall/catchment?days=N Upper-Ping catchment-mean hourly rain from the HII gauges next to the Open-Meteo series the model uses, with a 24 h-sum bias/MAE/correlation over the window
GET /api/stats Row counts per source, date range, hourly coverage percent
GET /health Database, upstream API and memory checks

A forecast row looks like:

{
"station_code": "P.1",
"horizon_hours": 24,
"current_level": 1.65,
"predicted_max_level": 1.76,
"p_warning": 2.4e-06,
"p_danger": 6.6e-07,
"threshold_warning": 3.7,
"threshold_danger": 4.2,
"as_of": "2026-09-12T02:00:00",
"model_version": "hgb-v3+f6570ac",
"trained_at": "2026-09-12T02:07:51",
"source": "model",
"stages": [{ "stage": 1, "level": 3.7, "p_exceed": 2.4e-06 }, "..."]
}

Responses are cached briefly server-side (five minutes for history). /forecast serves the forecast stored after the latest collection cycle with new readings (at least hourly). An X-Data-Stale: true header marks a response served from cache because the database was momentarily unreachable, or a stored forecast that is older than it should be. Please keep polling to once a minute or slower; the data only changes hourly.

The service needs Rust 1.88 or newer (rustup; on Linux also pkg-config and libssl-dev) and PostgreSQL. Python 3.11 with uv is only needed to train models.

Terminal window
git clone https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor.git
cd Northern-Thailand-Ping-River-Monitor
cp .env.example .env # POSTGRES_CONNECTION_STRING, WEB_LISTEN=127.0.0.1:8000
cargo build --release --manifest-path rust/Cargo.toml
rust/target/release/ping-collector check-db # create the tables
rust/target/release/ping-collector fill-gaps 30 # the last 30 days from RID
rust/target/release/ping-collector run # collect, forecast, serve

Without trained models the forecast is the persistence heuristic, marked source: "heuristic". Training needs years of history and the Open-Meteo archive, and takes about 12 minutes for all stations on a small VPS, no GPU:

Terminal window
uv sync --python 3.11
uv run python scripts/train_flood_model.py --stations all

scripts/install.sh sets up the production layout: a water-monitor system user, /opt/thailand-water-monitor, the ping-collector service and the monthly retrain timer.

  • Lead time from gauges alone is capped by the longest upstream travel time, 17 h. Rain is what extends it, and the rain archive only starts in 2021.
  • The P.1 danger threshold (4.20 m) has no out-of-sample validation yet: the river has not reached it since evaluation began.
  • Two floods is two floods. The evaluation is honest about what it measured; it is not a large sample.
  • All instruments are third-party. A gauge going dark is outside our control, and the rain input is a model, not a measurement; in the first two weeks of comparison against ~130 physical gauges it ran about 45 percent wet. Whether and where to add sensors of our own is under evaluation.