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.

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.

  • 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

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, 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.

Python 3.11 (3.13 breaks the pinned psycopg2-binary), PostgreSQL, and uv:

Terminal window
git clone https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor.git
cd Northern-Thailand-Ping-River-Monitor
uv sync --python 3.11
cp .env.example .env # DB_TYPE=postgresql, POSTGRES_CONNECTION_STRING, optional Matrix alerts
uv run python run.py --web-api

SQLite 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:

Terminal window
uv run python scripts/train_flood_model.py --stations all
  • 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.