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.
- 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 post Teaching a Model to See the Ping River Rise 13 Hours Early
- 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”- Station flow map: the Ping and its tributaries as OpenStreetMap river geometry, each segment styled (width, colour, dash speed) by the discharge at the nearest gauge; ThaiWater rain gauges as 24 h rainfall dots; Chiang Mai inundation-zone polygons with a stage each
- Flood-risk outlook: for P.1, the probability of crossing each of the seven official inundation stages (3.70 m to 4.60 m) within 24 h; per-station warning/danger probabilities and predicted peak at 6, 12 and 24 h
- Station history: any gauge, any range, level and discharge
- Replay Oct 2024 flood: the dashboard driven by the historical record hour by hour, including what the (pre-flood-trained) model said at each moment
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, up to 75 minutes for
the forecast, which the hourly collection cycle refreshes); an X-Data-Stale: true header
marks a response served from cache because the database was momentarily unreachable.
Please keep polling to once a minute or slower; the data only changes hourly.
Running it yourself
Section titled “Running it yourself”Python 3.11 (3.13 breaks the pinned psycopg2-binary), PostgreSQL, and
uv:
git clone https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor.gitcd Northern-Thailand-Ping-River-Monitoruv sync --python 3.11cp .env.example .env # DB_TYPE=postgresql, POSTGRES_CONNECTION_STRING, optional Matrix alertsuv run python run.py --web-apiSQLite works for a quick look (DB_TYPE=sqlite) but the forecasting path expects the
PostgreSQL history. scripts/install.sh sets up the production layout: a water-monitor
system user, /opt/thailand-water-monitor, the API service, and the monthly retrain
timer. Training all stations takes about 12 minutes on a small VPS, no GPU:
uv run python scripts/train_flood_model.py --stations allLimits 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.