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.
- Live: water.buildfor.life (English and Thai, works on a phone)
- API reference: water.buildfor.life/docs (interactive OpenAPI; no key required)
- Source: git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor (MIT)
- Background: blog posts Teaching a Model to See the Ping River Rise 13 Hours Early (the model) and Rewriting the Ping River Monitor, Bit for Bit (the Rust service and the console)
- Model write-up: docs/FLOOD_FORECASTING.md in the repository, the authoritative description of the forecasting system and its measured performance
Data sources
Section titled “Data sources”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.
The 16 RID gauges
Section titled “The 16 RID gauges”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.
Dashboard
Section titled “Dashboard”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.4and?demo_rise=1simulate P.1. Both are labelled REPLAY / SIMULATION and never shown as live
Flood forecasting
Section titled “Flood forecasting”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).
Measured performance
Section titled “Measured performance”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.
Model versioning
Section titled “Model versioning”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.
Running it yourself
Section titled “Running it yourself”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.
git clone https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor.gitcd Northern-Thailand-Ping-River-Monitorcp .env.example .env # POSTGRES_CONNECTION_STRING, WEB_LISTEN=127.0.0.1:8000cargo build --release --manifest-path rust/Cargo.tomlrust/target/release/ping-collector check-db # create the tablesrust/target/release/ping-collector fill-gaps 30 # the last 30 days from RIDrust/target/release/ping-collector run # collect, forecast, serveWithout 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:
uv sync --python 3.11uv run python scripts/train_flood_model.py --stations allscripts/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.
Limits worth knowing
Section titled “Limits worth knowing”- 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.