Compare commits

..
12 Commits
Author SHA1 Message Date
grabowski a40faf3102 fix(rid): refetch's redo hint only for changed levels and deleted spikes
CI / Format & lint (push) Successful in 18s
CI / Test suite (push) Successful in 23s
Docs / Validate documentation (push) Successful in 28s
Rust / fmt, clippy, test, build (push) Successful in 3m32s
New hours are forecast by the catch-up anyway, and a forecast issued without
the discharge RID added later is still what was issued; only a corrected or
deleted level makes the issued forecasts wrong.
2026-10-26 15:08:36 +07:00
grabowski 9bf904b23c feat(rid): screen one-hour spikes; refetch and redo-from to repair the store
CI / Format & lint (push) Successful in 15s
CI / Test suite (push) Successful in 20s
Docs / Validate documentation (push) Successful in 9s
Rust / fmt, clippy, test, build (push) Successful in 3m6s
RID's report sometimes carries a wrong value for one hour and corrects it
later. P.1 read 10.6 m at 2026-10-25 00:00 between 1.04 and 1.07 (RID now
says 1.06); the store kept it, because a report is re-fetched only while it
has holes, and the model issued a 10.8 m forecast for that hour.

- rid::judge / rid::screen: a reading that leaves the previous one (within
  3 h) by more than 1 m (2 m on the flashy tributaries P.76, P.77, P.81,
  P.82, P.84, P.85, P.87) while the next reading comes back near the
  previous level is a spike: not stored, a stored copy is deleted. Limbs
  and steps the next reading confirms are kept; the newest reading waits
  for the next one when it jumps more than twice the limit. On 2018-2026
  history: 16 spikes found (slipped decimals, sign flips, -5/-6/-9 m
  placeholders), no genuine change rejected.
- Every store goes through it (rid::store: run_cycle, fill-gaps, refetch).
- `ping-collector refetch FROM [TO]`: re-fetch reports regardless of holes
  (corrects revised readings, fills the discharge of level-only evening
  hours, removes stored spikes) and print the catch-up command when
  readings changed.
- `catch-up-forecasts --redo-from T`: delete the model forecasts issued
  from T on, then recompute them.
- Tests: judge/screen unit tests; a DB test of refetch, the live hold and
  the forecast deletion. Checked end to end against RID on a scratch DB.
- Docs: DATA_SOURCES (spikes, level-only hours), FLOOD_FORECASTING,
  rust/README.
2026-10-26 15:04:01 +07:00
grabowski a610abcdb8 feat(dashboard): CMU's 2024 flood marks as a map layer
CI / Format & lint (push) Successful in 44s
CI / Test suite (push) Successful in 49s
Docs / Validate documentation (push) Successful in 18s
CMU Water Center marked the October 2024 high-water line at ~4,900 places in
Chiang Mai and Lamphun (depth above the road while P.1 stood at 5.30 m).

- src/static/flood-marks-2024.json (position and depth, 110 KB), built by
  scripts/build_flood_marks.py from CMU's GeoJSON.
- "Flood marks 2024" layer: canvas dots from zoom 12 in CMU's five depth
  classes, legend row, CMU credit; turning it on below zoom 12 flies to the
  city. A mark's popup gives the depth and its flood zone.
- Zone and marker popups name the nearest mark within 250 m (loaded with the
  zones).
- No depth estimate for other P.1 levels: the marks do not scale one-to-one
  with P.1 (marks in the 5.30 m zone stood a median 65 cm deep in 2024).
- The layer bar wraps on narrow maps; popups pan clear of it.
- Docs: DATA_SOURCES 5.1, DASHBOARD_REDESIGN, README.
2026-10-26 14:30:35 +07:00
grabowski 701bb87d34 feat(p1): city flood stages and zones from CMU Water Center's 2024 flood map
CI / Format & lint (push) Successful in 49s
CI / Test suite (push) Successful in 59s
Docs / Validate documentation (push) Successful in 27s
Security / Dependency vulnerabilities (push) Successful in 1m5s
Security / Static analysis (push) Successful in 17s
Security / License report (push) Successful in 45s
Rust / fmt, clippy, test, build (push) Successful in 3m42s
CMU Water Center's Chiang Mai flood hazard map (CMFlood) replaces the older
municipal map. It is built on the October 2024 flood and has the Ping leave
its banks above P.1 4.20 m, with five stages: 4.30, 4.50, 4.70, 5.00 and
5.30 m (the 2024 record). The old map's seven stages (3.70-4.60 m) said the
city floods from 3.70 m; CMU calls that map out of date.

- P1_FLOOD_STAGES: the five CMU stages, discharge on RID's 2024 rating.
  /forecast's P.1 `stages` now has five entries (same format). Thresholds
  are unchanged: 4.20 m danger is the bank top, 3.70 m warning half a metre
  below (as on CMU's gauge page). flood_config.json and the Rust parity
  fixtures regenerated (only the stages changed).
- flood-zones-p1.geojson: CMU's KML (L1..L5) as five disjoint zones, zone k
  floods first at stage k; scripts/build_flood_zones.py rebuilds it.
- Dashboard: CMU's colours and credit (map attribution); the stage grid follows
  the number of stages; zone lookup for markers honours holes. The city
  verdict uses statusOf's classes: flooding (over the banks) from 4.20 m,
  high water from 3.70 m. Zones show by themselves when the river is over its
  banks or stage 1 is >= 50 % likely. Alert, replay and verdict texts no
  longer call 3.70 m city flooding (EN + TH).
- Docs: DATA_SOURCES 5.1 (CMFlood: the map, flood marks, poles, city gauges),
  FLOOD_FORECASTING, DASHBOARD_REDESIGN, NOTIFICATIONS, README credits.
2026-10-26 14:19:52 +07:00
grabowski 180d95c6bd fix(forecast): no nightly gaps in the issued model forecasts
CI / Format & lint (push) Successful in 28s
CI / Test suite (push) Successful in 1m4s
Docs / Validate documentation (push) Successful in 17s
Rust / fmt, clippy, test, build (push) Successful in 13m1s
The P.1 chart's "24 h peak, as issued" line had a gap every night and a
dip at 21:00. Two causes:

- A run forecasts from the newest reading only. RID publishes 22:00..06:00
  in one batch in the morning (and now and then one hour late), so those
  hours were never forecast from. After each run the collector now
  forecasts the hours of the last 48 h (two weeks on the first run after a
  start) that have a RID reading but no model forecast, each as a run at
  that hour would have: gauges, HII fill and dam state up to that hour.
  Rain is fetched once, so a late hour's rain_fc24 partly holds rain that
  has since fallen (as in training). Only missing (hour, station) pairs are
  written. `ping-collector catch-up-forecasts --hours N` goes further back.
- Overnight each hourly run re-issues the 21:00 reading, after 6 h as the
  heuristic, which overwrote the 21:00 model row. A heuristic row no longer
  replaces a model row in forecast_history.

The dashboard draws only model rows on the issued line.

Checked on a local copy of 18 days of RID data: the catch-up of an hour
matches `ping-collector forecast --now <hour>`; 288 hours took 20 s
(debug build). Tests for both database rules.
2026-10-20 15:21:48 +07:00
grabowski cf10f2e4c8 feat(dashboard): console phase 4 - 2024 replay, simulation, replaces the old page
CI / Format & lint (push) Successful in 26s
CI / Test suite (push) Successful in 1m15s
Docs / Validate documentation (push) Successful in 12s
The console is now the dashboard at / (src/static/dashboard.html); the old
page and console.html are gone.

- Replay of the October 2024 flood (flood-2024-all.json): the page's clock,
  verdict, list, markers, flood zones and panel follow the replay; the panel
  charts the season on fixed axes with the 2024 model's 24 h peak. Hourly
  while the model alerts or P.1 is above 3.50 m, faster through quiet days;
  pause and drag. Rain, basin stations and the 5-minute refresh are off
  during the replay (today's data).
- ?demo_level= and ?demo_rise= simulate P.1. A violet REPLAY / amber
  SIMULATION bar, the top bar's KPI and the P.1 panel say so; the mode is
  drawn from state.mode alone, so a refresh or language switch cannot
  relabel either as live.
- History: rows without discharge_percent are ThaiWater's copy of the gauge
  (night hours RID publishes late), with a different rating (P.1: 26 against
  RID's 54 m3/s); their discharge is not drawn for gauges with RID discharge.
  The discharge strip has headroom so its label stays clear of the line.
- Swap: the old page's title, description, Open Graph and JSON-LD tags; no
  noindex; tests/test_dashboard.py = console tests + the old checks that
  still apply (one P.1 level, SEO files) + a replay/simulation labelling test.
- Docs: README screenshot (docs/img/dashboard.jpg, replacing two unused
  images), CLAUDE.md, CONTRIBUTING.md, FLOOD_FORECASTING.md (where the model
  shows), DASHBOARD_REDESIGN.md (phase 4 done, mode rules).

Deploy needs a restart: the server reads dashboard.html at start.
2026-10-20 13:47:15 +07:00
grabowski ffd3030352 feat(dashboard): console phase 3 - history, forecast accuracy, data statistics
CI / Format & lint (push) Successful in 25s
CI / Test suite (push) Successful in 58s
Docs / Validate documentation (push) Successful in 11s
- History in the station panel (inline SVG): 3 d / 7 d / 30 d / 90 d / 1 y /
  all or a date range; level and discharge, the 24 h peaks as the model
  issued them, warning/danger bands, hover/touch values. Up to 7 d the chart
  ends in the current forecast. Above 2,500 readings it shows the daily mean
  with the daily low-high band, so a flood peak is not averaged away; gaps
  (RID's nightly pause) break the line instead of being bridged. Days are
  Bangkok days computed arithmetically: Intl per reading took a minute for
  "All" (67,000 rows), now 2 s.
- "How accurate is the forecast?" for P.1 (/api/forecast/skill), loaded when
  opened because a cold skill query takes up to 20 s.
- Data statistics (/api/stats) behind "2.7M readings since 2018" in the
  status bar.

fix(dashboard): "All time" history ended in October 2024. The history route
returns the oldest rows first and stops at 50,000 by default; P.1 has about
67,000 hours. Both dashboards now ask for limit=100000 (the route's maximum).
2026-10-19 17:12:39 +07:00
grabowski 71cc1b0ca7 feat(dashboard): console phase 2 - self-hosted basemap, basin stations, markers
CI / Format & lint (push) Successful in 24s
CI / Test suite (push) Successful in 1m9s
Docs / Validate documentation (push) Successful in 14s
- Basemap: src/static/basemap/ping-basin.pmtiles, a Protomaps extract of the
  Ping basin (zoom 0-12, 15 MB, OpenStreetMap data), drawn by
  protomaps-leaflet 5.1.0 (unpkg, SRI) in its light/dark style with labels in
  the page language. It replaces the dimmed OSM raster tiles, whose baked-in
  labels were the noisiest part of the screen; OSM stays as the fallback.
  The browser reads the file with range requests (ServeDir answers 206;
  dev_proxy.py now does too). The map stays within the basin.
- Basin stations: a second list next to the RID gauges and a map layer of
  diamonds, from /api/hii/waterlevel/latest (falling back to
  /sensors/thaiwater), coloured by ThaiWater's bank-height classes (over the
  bank = danger, 70-100 % = watch). Gauges RID already lists appear once.
  The panel shows level (m MSL), % of bank height, distance to and height of
  the bank top, gauge reading and agency; selection in the URL (#hii-<id>).
- custom-markers.json as pins from zoom 12, with the flood zone they are in.

Refresh instructions for the basemap in src/static/basemap/README.md.
2026-10-19 16:58:21 +07:00
grabowski 301a629b42 feat(dashboard): the console, phase 1 of the redesign (preview at /static/console.html)
CI / Format & lint (push) Successful in 24s
CI / Test suite (push) Successful in 1m31s
Docs / Validate documentation (push) Successful in 10s
The user chose direction B of the redesign: a full-screen operations console.
It is built next to the current dashboard, served by ServeDir at
/static/console.html (noindex, links back to /), and replaces dashboard.html
once it covers every feature (docs/DASHBOARD_REDESIGN.md, phase 4).

Phase 1:
- top bar: the Chiang Mai verdict (P.1 level + 24 h forecast, the current
  dashboard's rule), gauges reporting, highest 24 h flood chance, data age
  ("overnight pause" instead of "stale" during RID's nightly gap, as in the
  notifier), EN/TH, light/dark (dark default), Get alerts
- gauge list: one status rule for list, map and panel (normal, watch within
  0.5 m or >= 25 % chance, warning, danger, no data), margin to the warning
  level, 72 h sparkline, 6 h change; filter; sort by river order, closest to
  the warning level, rising; arrow keys
- map: Ping mainstem and tributaries, status markers with code + level,
  layers Rain 24 h (on by default only with heavy rain) and Flood zones
  (auto-shown while P.1 is at a stage), one legend
- station panel: level, change, discharge (last RID value when the newest
  hour is level-only), warning/danger levels with margins, capacity, level
  scale with now and 24 h peak, 72 h chart with the forecast peaks, forecast
  table with the model and issue time, the P.1 inundation stages, data links
- alerts drawer with the ntfy topics; selection in the URL (#P.84); refresh
  every 5 minutes
- Inter and Noto Sans Thai self-hosted (src/static/fonts, 75 KB, OFL), no
  emoji, Thai first, Bangkok time

tests/test_console.py: routed fetch paths, EN/TH parity, translated aria
labels, no emoji, fonts and licences, noindex. dev_proxy.py serves
src/static with real content types (it sent HTML as octet-stream).
2026-10-19 16:31:09 +07:00
grabowski 0e30ddbcb6 fix(notify): no "gauge feed stale" for RID's nightly pause on a quiet river
CI / Format & lint (push) Successful in 21s
CI / Test suite (push) Successful in 51s
Docs / Validate documentation (push) Successful in 8s
Rust / fmt, clippy, test, build (push) Successful in 10m25s
ping-status sent "gauge feed stale" around midnight and "feed recovered"
around 07:00 on each of the last three nights (ntfy and, since phase 3, the
Matrix room). RID publishes no hourly report overnight: the 21:00 reading is
the last until the 22:00..06:00 hours arrive together (06:35, 06:55, 07:22 on
2026-10-17/18/19). The ThaiWater overlay was meant to bridge that, but
ThaiWater's copies of the RID gauges come from the same feed with the same
delay (P.1's mirror still has no value for 22:00-00:00 this morning), so it
does not.

Between 21:00 and FEED_NIGHT_UNTIL_HOUR (default 9, 0 = off) the feed of a
quiet river is no longer reported stale; one still without new readings at
09:00 is. While a station is at warning/danger or the P.1 outlook is on, the
3 h rule holds all night. A feed already stale stays stale through the night
(no false "recovered"), and a real recovery is announced at any hour. The
replay of the Python notifier runs with the window off. Docs no longer claim
that ThaiWater keeps the feed current at night.
2026-10-19 09:35:10 +07:00
grabowski 7f29b1831e fix(install): find uv for the .venv owner (root shells and sudo lack ~/.local/bin)
CI / Format & lint (push) Successful in 22s
CI / Test suite (push) Successful in 58s
Run as root, SUDO_USER is unset and uv is not on root's PATH, so the training environment was not synced. Now the sync runs as the owner of .venv, with uv from PATH, that user's ~/.local/bin or ~/.cargo/bin, /usr/local/bin, or UV=/path/to/uv.
2026-10-18 17:55:33 +07:00
grabowski 24998b033c fix(deps): python-dotenv 1.2.2 (PYSEC-2026-2270, set_key symlink overwrite)
CI / Format & lint (push) Successful in 23s
CI / Test suite (push) Successful in 1m0s
Rust / fmt, clippy, test, build (push) Successful in 10m56s
Security / Dependency vulnerabilities (push) Successful in 1m9s
Security / Static analysis (push) Successful in 20s
Security / License report (push) Successful in 1m17s
Now a direct dependency, pip-audit sees it; it used to come in through uvicorn. The .env parser is unchanged: rust/collector/testdata/gen_envfile_cases.py regenerates the 614-case fixture byte-identical under 1.2.2, so envfile.rs still matches.
2026-10-18 17:31:41 +07:00
46 changed files with 4439 additions and 2441 deletions

No files matched your search

+3
View File
@@ -53,6 +53,9 @@ NTFY_TOKEN=
PUBLIC_URL=https://water.buildfor.life/
# Report RID lagging this many hours while ThaiWater telemetry is current (0 = never)
RID_STALE_ALERT_HOURS=18
# RID publishes nothing overnight: from 21:00 until this hour a quiet river's
# feed is not reported stale (0 = off; docs/NOTIFICATIONS.md)
FEED_NIGHT_UNTIL_HOUR=9
# Matrix room for a team: the same transitions (docs/MATRIX_QUICK_START.md);
# off while token or room is empty
MATRIX_HOMESERVER=https://matrix.org
+6 -3
View File
@@ -30,9 +30,12 @@ plus the monthly `water-monitor-retrain.timer` (Python, `.venv`, uv-managed). Up
- No `git add -A`: zero-byte shell-accident files (`#`, `$(wc`, ...) have been committed
before. Stage files by name.
- Do not add Co-Authored-By trailers.
- The dashboard is a single file, `src/static/dashboard.html`, EN + TH via the `t()`
table: every user-visible string needs both languages. Every path it fetches must be
a route in `rust/collector/src/web/mod.rs` (tests/test_dashboard.py checks).
- The dashboard is a single file, `src/static/dashboard.html` (design and rules in
docs/DASHBOARD_REDESIGN.md), EN + TH via the `t()` table: every user-visible string
needs both languages, Thai is the default, no emoji. Every path it fetches must be a
route in `rust/collector/src/web/mod.rs` (tests/test_dashboard.py checks). A replay or
a simulation must never be shown as live data. Preview it against production with
`uv run python scripts/dev_proxy.py`; the server reads it at start (restart).
- Rust (`rust/`, see rust/README.md): `cargo fmt --all`, `cargo clippy --all-targets
-- -D warnings`, `cargo test`; minimum version 1.88
(`CARGO_TARGET_DIR=target/msrv cargo +1.88 check --all-targets --locked`). Read
+2 -1
View File
@@ -29,7 +29,8 @@ Without `TEST_DATABASE_URL` the database tests are skipped, the rest still run.
- One logical change per commit, imperative subject (`fix(collector): ...`,
`feat(web): ...`, `docs: ...`).
- Dashboard changes (`src/static/dashboard.html`): every visible string in English
and Thai (`t()` table); a screenshot in the merge request.
and Thai (`t()` table), no emoji; screenshots (light and dark, phone and desktop)
in the merge request. Preview with `uv run python scripts/dev_proxy.py`.
- API changes: keep existing fields and formats; external clients read them. New
parameters go into the route's declaration in `rust/collector/src/web/params.rs`,
which also documents them at `/docs`.
+11 -5
View File
@@ -11,6 +11,8 @@ a gauge crosses its warning or danger level.
after the [October 2024 flood](https://buildfor.life/blog/chiang-mai-flood-2024/) —
background in [Teaching a Model to See the Ping River Rise 13 Hours Early](https://buildfor.life/blog/ping-river-monitor/).
![The dashboard: Chiang Mai's flood verdict and the gauges in the top bar, the gauge list, the map and the panel for P.1 Nawarat Bridge with its history and forecast](docs/img/dashboard.jpg)
[![CI](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/actions/workflows/ci.yml/badge.svg)](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/actions)
[![Rust](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/actions/workflows/rust.yml/badge.svg)](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/actions)
[![Security](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/actions/workflows/security.yml/badge.svg)](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/actions)
@@ -35,9 +37,11 @@ background in [Teaching a Model to See the Ping River Rise 13 Hours Early](https
data through August 2024, the first alert came **13 hours before** P.1 crossed
3.70 m. Everything about the model, including what did not work:
[docs/FLOOD_FORECASTING.md](docs/FLOOD_FORECASTING.md).
- **Shows it.** A Leaflet map with the river drawn as OSM geometry and styled by live
discharge, rain gauges, the Chiang Mai inundation zones, per-station history, the
forecast card, a replay of the 2024 flood, English/Thai, light/dark.
- **Shows it.** One screen: Chiang Mai's flood verdict, the gauges with their status
and trend, a self-hosted map of the basin with rain, the city's flood zones and
the high-water marks of 2024, and a panel per station with its history since
2018, the forecast and how accurate it has been. ThaiWater's basin stations, a
replay of the 2024 flood, Thai/English, dark/light.
- **Notifies.** Public push alerts over a self-hosted [ntfy](https://ntfy.sh): one
message when a gauge crosses its warning or danger level, one all-clear on the
way down, an opt-in early-warning topic from the model, nothing in between.
@@ -176,6 +180,7 @@ models/ trained bundles (gitignored) and evaluation results
- [docs/MATRIX_QUICK_START.md](docs/MATRIX_QUICK_START.md) — the same alerts in a Matrix room
- [rust/README.md](rust/README.md) — the service: commands, configuration, develop, deploy
- [docs/RUST_REWRITE.md](docs/RUST_REWRITE.md) — why and how it moved from Python to Rust
- [docs/DASHBOARD_REDESIGN.md](docs/DASHBOARD_REDESIGN.md) — the dashboard: layout, rules, how it replaced the old page
- [docs/references/NOTABLE_DOCUMENTS.md](docs/references/NOTABLE_DOCUMENTS.md) — official Thai government resources
- Public overview: [buildfor.life/docs/tooling/ping-river-monitor](https://buildfor.life/docs/tooling/ping-river-monitor/)
@@ -189,8 +194,9 @@ models/ trained bundles (gitignored) and evaluation results
Royal Irrigation Department (RID) gauge telemetry and reservoir reports;
Hydro-Informatics Institute (HII) / ThaiWater open API; Open-Meteo; OpenStreetMap
contributors for the river geometry; Chiang Mai Municipality for the inundation map
the P.1 stages are keyed to. All instruments are theirs; we aggregate, store, fill
contributors for the river geometry; the CMU Water Center (Chiang Mai University)
for the flood hazard map of the 2024 flood that the P.1 stages and the city's flood
zones come from. All instruments are theirs; we aggregate, store, fill
gaps and forecast.
## License
+157
View File
@@ -0,0 +1,157 @@
# Dashboard redesign: the console
Status: **phase 4 done** (2026-10-20). The console is the dashboard: it replaced the
old page at `/` (`src/static/dashboard.html`; the server reads it at start, so a
change needs a restart). Phase 5 (polish) is open.
- **Locally:** `uv run python scripts/dev_proxy.py 8765`, then <http://localhost:8765/>.
The API is proxied to production.
- **Rules** (`tests/test_dashboard.py`):
- every path it fetches is a Rust route;
- every string exists in EN and TH, and Thai is the default;
- no emoji;
- timestamps are Bangkok wall-clock time;
- a replay or a simulation is never shown as live data.
## Why: the old page
Observed on 2026-10-19 at 1440 px and on a 390 px phone.
- **No clear first answer.** "Is Chiang Mai flooding?" is a thin green banner above
four KPI cards of equal weight. Two of them answer nothing for a resident: "Most
stressed gauge 16%" and "Chiang Mai river flow 53.9" (no unit in the number). At the
bottom there are five more (2,700,706 datapoints, 3,002 days, …).
- **The colours answer the wrong question.** Markers and river lines are coloured by
discharge class (m³/s), not by distance to the warning level, and the station list
shows discharge.
- **The map is noisy.** OSM terrain with Thai labels, numbered markers and animated
dashes. The legend covers about a third of the map, and more than half of the first
phone screen.
- **The header is busy.** It has six buttons with emoji (↻ 🌙 🔔 ▶); on a phone they
wrap into three rows.
- **Internal detail on the main page.** The model-skill table lists seven model hashes;
the history card is an empty 360 px box until a station is clicked.
- **Inconsistent styling.** A gradient background, several card and pill styles, three
greens.
## Decision
Two directions were sketched on 2026-10-19 against live data (throwaway HTML, not kept):
- **A:** a public information page.
- **B:** an operations console.
The user chose **B**.
## Layout
One screen, no page scroll on a desktop:
| Area | Content |
|---|---|
| Top bar | brand · **Chiang Mai verdict** (P.1 level + 24 h forecast, always visible, click selects P.1) · gauges reporting · highest 24 h flood chance · data age · EN/ไทย · light/dark · Get alerts |
| Left | two lists: **RID gauges** (status bar, code, name, margin to the warning level, 72 h sparkline, level and 6 h change; sort by river order, closest to the warning level, rising) and **Basin stations** (ThaiWater/HII: % of bank height, river and agency; sort fullest first or by name); filter; legend |
| Centre | map on a self-hosted vector basemap (labels in the page language): Ping mainstem and tributaries, gauges coloured by status with code + level labels, layers Basin stations (diamonds), Rain 24 h, Flood zones and Flood marks 2024, the team's markers from zoom 12, one legend |
| Right | selected RID gauge: level, 6 h change, discharge, warning/danger level with margins, capacity; level scale with now and 24 h peak; history (3 d / 7 d / 30 d / 90 d / 1 y / all or dates: level, discharge, the 24 h peaks as issued, warning/danger bands, hover values; up to 7 d it ends in the current forecast); forecast table (peak, chance of warning/danger at 6/12/24 h, model, issue time, disclaimer); for P.1 the five city flood stages, the zones toggle and "How accurate is the forecast?"; data links. A basin station shows level (m MSL), % of bank height with ThaiWater's class, distance to and height of the bank top, gauge reading, agency (latest reading only: no history is kept for them) |
| Status bar | data credits, "2.7M readings since 2018" (opens the data statistics), API, source code |
**Narrower screens:**
| Width | Layout |
|---|---|
| < 1280 px | The panel moves below list and map, in columns |
| < 860 px | One column: verdict, map, list, panel |
**Behaviour:**
- Selection is in the URL (`#P.84`, `#hii-1198299`); opening a basin station switches to its list.
- Arrow keys move through the list.
- Data refreshes every 5 minutes.
## Where every current element goes
| Old page (`dashboard.html` id) | Console | Phase |
|---|---|---|
| `flood-verdict`, `verdict-*` | Verdict in the top bar | 1 |
| `station-count`, `last-updated`, `data-age`, `live-pill` | Top-bar KPIs (reporting, data age) | 1 |
| `river-flow`, `peak-station` (P.1 flow, most stressed gauge) | Discharge and capacity in the panel; "Closest to warning level" sort | 1 |
| `station-map`, `map-legend`, `legend-toggle`, `rain-toggle`, `zones-toggle` | Map, layer buttons, one legend | 1 |
| side list, `station-search` | Gauge list with filter and sorts | 1 |
| `forecast-card`, `p1-outlook`, `p1-peak`, `p1-stages`, `forecast-grid` | Panel: forecast table for every gauge, stages for P.1 | 1 |
| `alerts-panel`, `alerts-*` | Alerts drawer | 1 |
| `refresh-button` | Removed: refresh every 5 min, age in the top bar | 1 |
| `lang-toggle`, `theme-toggle` | EN / ไทย segmented control, icon button | 1 |
| `error`, `loading` | Map overlay with retry; "Loading" in the list | 1 |
| `thaiwater-sensors`, `sensors-*` | Basin stations list + map layer | 2 |
| custom markers | Pins from zoom 12, popup with their flood zone | 2 |
| `history-card`, `history-*` | Panel history with ranges | 3 |
| `skill-panel`, `skill-*` | Panel section for P.1 | 3 |
| `db-stats`, `db-*` | Status-bar popover | 3 |
| `replay-2024`, `replay-*`, demo hooks | Replay/simulation mode with banner and mode pill | 4 |
## Design rules
- **Type:** Inter (Latin) and Noto Sans Thai, self-hosted in `src/static/fonts/` (75 KB, OFL). Numbers use tabular figures.
- **Colours:** CSS variables for light and dark, with dark the default; a saved choice wins (`ping-river-theme`, the old dashboard's key, so the choice carried over).
- **Accent:** `#1d6fd6` light, `#4c9bf0` dark.
- **Status:** colours are used only for status, each with an `-ink` variant for text (WCAG AA on white).
- **One status rule** (`statusOf`) for the map, list and panel. It is display-only; the notifier keeps its own transitions.
| Status | Rule |
|---|---|
| Normal | none of the rules below |
| Watch | within 0.5 m of the warning level, or 24 h warning chance ≥ 25 % |
| Warning | level ≥ `threshold_warning` |
| Danger | level ≥ `threshold_danger` |
| No data | no reading for 24 h |
- **City verdict:** the classes of `statusOf` for P.1 — flooding (the Ping over its banks) from 4.20 m, high water from 3.70 m; "flooding likely" at danger ≥ 50 %, "high water likely" at warning ≥ 70 %; watch at warning ≥ 20 % or level ≥ 3.20 m. (Until 2026-10-25 it said flooding from 3.70 m, after the older municipal map.)
- **Data age:** "feed stale" after 3 h, except during RID's overnight pause (21:00 to 09:00 with an evening reading). The pause shows as "overnight pause", the same rule as the notifier's `FEED_NIGHT_UNTIL_HOUR`.
- **Discharge:** comes with RID's report. When the newest hour is a level-only reading, the panel shows the last RID discharge with its time ("at 14:00").
- **Rain layer:** off by default unless any station has heavy rain (> 35 mm / 24 h); an explicit choice is remembered.
- **Flood zones:** `src/static/flood-zones-p1.geojson`, CMU Water Center's flood hazard map of the October 2024 flood (built by `scripts/build_flood_zones.py` from CMU's KML): five disjoint zones, zone k floods first when P.1 reaches stage k (4.30, 4.50, 4.70, 5.00, 5.30 m), in CMU's colours from deep red to yellow, credited in the map attribution. Shown automatically while P.1 is over its banks (danger level) or stage 1 is at least 50 % likely within 24 h, unless hidden by the user. A point's zone (team markers) honours the holes: dry islands inside a zone are outside it.
- **Basin stations:** ThaiWater's classes for the level as a share of the bank height (critically low ≤ 10 %, low ≤ 30 %, normal ≤ 70 %, high ≤ 100 %, over the bank). On the map and in the list over the bank is danger, high is watch, the rest normal; no data after 24 h. Gauges RID already lists are shown once, as RID gauges. The layer is off until the list is opened or the button pressed.
- **History:** above 2,500 readings (30 d and longer for most gauges) the chart shows the daily mean with the daily low–high band, so a flood peak is not averaged away; gaps longer than 3.5 h (2.5 days when daily) break the line rather than bridging RID's nightly pause. Short ranges reload with every refresh, long ones at most hourly. "All" asks for `limit=100000`: `/measurements/history` returns the oldest rows first and stops at its 50,000 default, which made the old dashboard's "All time" for P.1 end in October 2024.
- **Issued forecasts:** the history's violet line is the model's 24 h peak for every hour (`/api/forecast/history`, `source = model` only: a heuristic row is the level itself and drew a dip into the line). The nightly gaps were RID's overnight batch; the collector now forecasts those hours when they arrive (docs/FLOOD_FORECASTING.md, Serving).
- **Forecast accuracy:** loaded when the section is opened (a cold `/api/forecast/skill` query takes up to 20 s), cached for 15 minutes; bias and persistence error are in each row's tooltip.
- **Replay of October 2024:** `src/static/flood-2024-all.json` (hourly readings of the 16 gauges, 15 September – 12 October 2024, and the 24 h warning chance and peak of a model trained on data before 1 September 2024). The page's clock, verdict, list, markers, flood zones and panel follow the replay; the panel's chart shows the season on fixed axes up to the replay hour. It plays hour by hour while the model alerts or P.1 is above 3.50 m, faster through quiet days, and can be paused and dragged. Rain, the basin stations and the 5-minute refresh are off during the replay, since they are today's data. A violet bar (REPLAY, date, P.1 level, the model's message) and the top bar's "Replay time" replace the live data age.
- **Simulation:** `?demo_level=4.4` pins P.1 at 4.40 m; `?demo_rise=1` raises it by 5 cm every 0.7 s from the real level to the 2024 record (5.30 m). An amber SIMULATION bar names the real level; the top bar and the P.1 panel say "simulated". Only P.1 is simulated: the forecasts and other gauges stay live.
- **Flood marks 2024:** `src/static/flood-marks-2024.json`, CMU Water Center's ~4,900 marks of the October 2024 high-water line (depth above the road at P.1 5.30 m; `scripts/build_flood_marks.py`), on a canvas from zoom 12 in CMU's five depth classes; turning the layer on below zoom 12 flies to the city. A mark's popup gives the depth and its flood zone; zone and marker popups name the nearest mark within 250 m (the marks load with the zones). Off by default; also available in the replay, since it is 2024 data. Popups pan clear of the layer bar, which wraps to two rows on phones.
- **Basemap:** `src/static/basemap/ping-basin.pmtiles`, a Protomaps extract (bbox 97.85–99.95 E, 16.85–20.2 N, zoom 0–12, 15 MB, OpenStreetMap data), drawn by protomaps-leaflet 5.1.0 in its `light`/`dark` style with `name:th`/`name:en` labels. Refresh instructions in `src/static/basemap/README.md`. The map cannot be panned far outside the basin. OSM raster tiles are the fallback if the renderer does not load.
## Phases
Each phase is one commit, checked with screenshots: 1440, 1100 and 390 px; light and dark; EN and TH.
1. **Done — Console shell with live data.**
- Layout, design tokens, fonts, EN/TH.
- Verdict and KPIs, gauge list with sparklines and sorts.
- Map with status markers, rain and flood-zone layers.
- Station panel (readings, scale, 72 h chart, forecast table, P.1 stages).
- Alerts drawer (ntfy topics); URL selection; 5-minute refresh.
- `tests/test_console.py` (now `tests/test_dashboard.py`); `dev_proxy.py` serves `src/static` with proper content types.
2. **Done — Map and basin.**
- **Basemap:** the self-hosted Protomaps extract replaces the dimmed OSM raster tiles, whose baked-in labels were the noisiest part of the screen. Labels follow the page language; light and dark styles.
- **Server:** ServeDir answers HTTP range requests (checked: 206); `dev_proxy.py` now does too.
- **ThaiWater/HII stations:** the Basin stations list and map layer, from `/api/hii/waterlevel/latest`, falling back to `/sensors/thaiwater`.
- **Custom markers:** `custom-markers.json` as pins, with their flood-zone popup.
3. **Done — Station depth.**
- **History in the panel** (inline SVG, no chart library): ranges and dates, level and discharge, issued 24 h forecasts overlaid, warning/danger bands, hover/touch values.
- **P.1 forecast accuracy** as a collapsible panel section, from `/api/forecast/skill`.
- **Data statistics:** a status-bar popover, from `/api/stats`.
4. **Done — Modes and the swap.**
- **October 2024 replay:** banner, clock, markers and verdict follow the replay; zones auto-show.
- **Demo hooks:** `?demo_level=`, `?demo_rise=`. A mode pill (LIVE / REPLAY / SIMULATION) never relabels a replay or simulation as live.
- **The swap:** `console.html` became `dashboard.html`, with the old page's title, description, Open Graph and JSON-LD tags; the robots `noindex` is gone. `tests/test_dashboard.py` is the console's tests plus the old ones that still apply (one P.1 level, SEO and indexing files).
- **Docs:** README screenshot and the CLAUDE.md rule.
5. **Polish.**
- Phone layout pass.
- Native-speaker review of the Thai strings.
- Keyboard and screen reader pass.
- Lighthouse ≥ 90 (performance, accessibility).
- No console errors.
## Data notes from building phase 1
- **Negative levels.** P.4A (−1.28 m) and P.67 (−0.59 m) report negative levels: gauge datums, not errors. The panel's scale starts at the lowest of 0 and the station's 72 h readings.
- **P.1 discharge disagrees between endpoints.** At 15:00 on 2026-10-19, `/measurements/latest` had level 1.30 m with no discharge, while `/measurements/history` had 26.0 m³/s with no capacity. RID's 14:00 report said 53.9 m³/s. The rows without capacity (`discharge_percent`) are ThaiWater's copy of the gauge, which fills hours RID has not published yet (its night hours); their discharge comes from a different rating, about half of RID's at P.1. The panel shows the last RID discharge, and the history chart drops the discharge of those rows for gauges RID reports discharge for.
- **P.84 spike.** A one-hour spike at 13:00 the same day: 0.73 → 1.05 → 0.78 m.
+56 -1
View File
@@ -23,10 +23,29 @@ The only source persisted to the database and used by the ML pipeline.
| Params | `DW[UtokID]=1`, `DW[BasinID]=6` (Ping), `DW[TimeCurrent]=<Buddhist-calendar date>`, `rows=100` |
| Variables | Water level (m, gauge datum), discharge (m³/s, `'***'` = malformed), discharge % of channel capacity |
| Stations | 16 P-series gauges (P.1 anchor at Nawarat Bridge; see `src/data/stations.json`) |
| Client | `rust/collector/src/rid.rs` (`ping-collector`; `fill-gaps` re-fetches old reports) |
| Client | `rust/collector/src/rid.rs` (`ping-collector`; `fill-gaps` re-fetches old reports with holes, `refetch FROM [TO]` any reports) |
Human-facing page: <https://hyd-app-db.rid.go.th/hydro1h.html>
**Spikes.** The report sometimes carries a wrong value for one hour and corrects
it later: a slipped decimal point (P.1 10.6 m between 1.04 and 1.07 on 2026-10-25
00:00, later 1.06; P.85 0.101 for 1.1), a flipped sign (P.4A ±1.18, P.67 ±0.6) or
a placeholder (−5, −6, −9, 50 m). The collector re-fetches a report only while it
has holes, so a later correction never arrived. Since 2026-10-26 every batch is
screened before it is stored (`rid::judge`, `rid::screen`): a reading that leaves
the previous one (within 3 h) by more than 1 m (2 m on the flashy tributaries
P.76, P.77, P.81, P.82, P.84, P.85, P.87) while the next reading comes back near
the previous level is not stored, and a stored copy is deleted. A rising or falling
limb, or a step the next reading confirms, is kept. The newest reading of a gauge
waits for the next one when it jumps more than twice the limit. On the 2018–2026
history the rule finds 16 spikes and would have rejected no genuine change (the
largest genuine 1-h changes: 1.1 m on the mainstem, 3.1 m at P.76). Two wrong hours
in a row are not caught.
Evening hours often arrive level-only (no discharge) and gain the discharge
later; the API's history fills a missing discharge with 20 × level (an old
placeholder, kept for compatibility). `refetch` brings in RID's discharge.
---
## 2. ThaiWater / HII ecosystem
@@ -235,6 +254,39 @@ Oct 2024 flood). Mae Kuang Udom Thara is the second upstream reservoir.
| `https://hydro-1.net` | RID Upper-Northern Hydrology Center — hourly/daily tables, hydrology yearbooks (rating curves) for P-series stations; scrape/download |
| `https://water.rid.go.th/flood/flood/daily.pdf` | RID daily flood bulletin (PDF only) |
### 5.1 CMU Water Center — CMFlood (Chiang Mai city) (verified 2026-10-25)
`https://watercenter.cmu.ac.th/chiangmai/cmflood/warning/*`, the Chiang Mai urban
flood warning site of Chiang Mai University's water center (with RID, ONWR, DDPM
and the province). A Next.js app; its data paths are in the page chunks
(`/_next/static/chunks/app/...`). No licence is stated; we credit CMU.
- **Flood hazard map, 2024 flood** — `/data/KML5Layer/L1.kml` … `L5.kml`: the
flooded extent at P.1 = 4.30 / 4.50 / 4.70 / 5.00 / 5.30 m (2.5 / 3.0 / 22 / 57 /
156 km², cumulative). CMU: the Ping overflows its banks above P.1 4.20 m, and the
older municipal map (2011 flood) no longer fits the city's defences. **In use:**
`P1_FLOOD_STAGES` and `src/static/flood-zones-p1.geojson`
(`uv run scripts/build_flood_zones.py`).
- **Flood marks 2024** — `/data/New_floodMark_2025_fixed_like_pole.geojson` (4.9 MB):
4,869 points in Chiang Mai and Lamphun with the October 2024 depth above the road
in cm (P.1 = 5.30 m), the marker's place and type. **In use:** the dashboard's
"Flood marks 2024" layer, `src/static/flood-marks-2024.json` (position and depth
only, 110 KB; `uv run python scripts/build_flood_marks.py`). The depth does not
scale one-to-one with P.1: marks in the zone that floods only at 5.30 m stood a
median 65 cm deep in 2024, so the site shows the 2024 depth and no estimate for
other levels.
- **Flood poles** — `/data/PoleCNX2025_v2.geojson`: 219 poles with ground level and
maximum flood level (m MSL) and the depth.
- **City-reach gauges** (station page): P.67, P.103, P.1, P.104 (RID, `ST.16`
every 15 min), FloodBoy FBP.2/FBP.3 (CMU climate data centre) and DDPM CMI01–03
(camera gauges, ~5 min, public CCTV snapshots), with gauge zero, bank levels
and chainage. CMU reads them from its own Supabase project with a public browser
key (tables `disaster_level`, `flagship_hydro`, `water_levels`, `hydro_levels`;
timestamps are Bangkok time labelled `+00:00`); history starts 2026-08 (FBP
2025-09). Not ingested: too short to train on, and polling CMU's database is
using their infrastructure — prefer the original sources or ask CMU.
- **P.1 forecast** (`floodforecast`): login only.
---
## 6. Integration status & recommended order
@@ -250,6 +302,9 @@ Oct 2024 flood). Mae Kuang Udom Thara is the second upstream reservoir.
| Satellite QPE (GSMaP/IMERG) | ❌ | Backfill training rainfall (GEE) |
| Open-Meteo rain | ✅ Hourly by ping-collector → `openmeteo_rain` (5 catchment points, past 5 days + 48 h forecast); the 2021+ archive trains the hgb-v3 rain features | — |
| HII API key (dams, forecasts) | ❌ | Contact HII for sanctioned access |
| CMU CMFlood flood map (2024) | ✅ Static (2026-10-25): P.1 stages and the city's flood zones | Rebuild with `scripts/build_flood_zones.py` if CMU revises the map |
| CMU CMFlood flood marks 2024 | ✅ Static (2026-10-25): dashboard layer, nearest mark in zone and marker popups | Rebuild with `scripts/build_flood_marks.py` |
| CMU CMFlood poles, city gauges | ❌ | See 5.1 |
**Collector configuration** (ping-collector): HII, the dams and Open-Meteo are
fetched once per clock hour, also while RID is being retried; one-shot via
+51 -16
View File
@@ -465,15 +465,19 @@ validated.
**Thresholds are per-station as of 2026-08-10.** `THRESHOLDS` now carries
calibrated (warning, danger) pairs for all 16 stations, derived from the DB's
`discharge_percent` (RID % of channel capacity): warning = median level at
75–85% capacity, danger = median level at 95–105%. P.1 instead uses the official
Chiang Mai inundation map (`P1_FLOOD_STAGES`): warning 3.70 m (city flooding
begins, stage 1) and danger 4.20 m (stage 5). The prior single default of
75–85% capacity, danger = median level at 95–105%. P.1 is keyed to its banks:
danger 4.20 m, where the Ping overflows them (CMU Water Center's flood map of the
2024 flood; the lower bank is 304.65 m MSL = 4.15 m on the gauge), and warning
3.70 m, half a metre below. (Until 2026-10-25 the docs called 3.70 m "city flooding
begins", after the older municipal map; the values did not change.) The prior single default of
(3.0, 4.5) m made P.103 badly over-alert (its bank-full level is ~6.75 m) and
P.67 under-alert (overflow at ~2.9 m, 1.6 m below the old danger line).
**A retrain is required after any threshold change** — classifier labels depend
on them; until then, model rows report the thresholds baked into their bundle.
P.1 additionally reports `stages`: exceedance probability for each of the seven
official inundation stages (3.70–4.60 m), computed from the regression head and
P.1 additionally reports `stages`: exceedance probability for each of the five
city flood stages (`P1_FLOOD_STAGES`, 4.30–5.30 m, from CMU Water Center's flood
hazard map of the October 2024 flood; the seven stages of the older municipal map,
3.70–4.60 m, were replaced on 2026-10-25), computed from the regression head and
its calibration sigma, so they need no retrain and no per-stage classifiers.
### 2026-08-13: Mae Ngat dam features — a documented negative result
@@ -627,6 +631,28 @@ A stored forecast older than `FORECAST_MAX_AGE_SECONDS` (2 h, i.e. the service
has stopped forecasting) is still served, marked `X-Data-Stale: true`. See
`rust/README.md`.
**The issued history has every hour.** A run forecasts from the newest reading
only, and RID publishes 22:00 to 06:00 in one batch in the morning (and now and
then reports a single hour late), so those hours were never forecast from and
the 24 h peak "as issued" had a gap every night. After each run the collector
therefore forecasts the hours of the last 48 h (two weeks after a start) that
have a RID reading but no model forecast, each as a run at that hour would
have: gauges, HII fill and dam state up to that hour only. The exception is
rain: it is fetched once, so a late hour's `rain_fc24` partly holds
Open-Meteo's estimate of rain that has since fallen, as in training, where
the forward rain is observed rain. Such rows are written only where no model
row exists; `created_at` tells them apart (more than an hour after `as_of`).
`ping-collector catch-up-forecasts --hours N` does the same further back (up
to 60 days); with `--redo-from T` it first deletes the model forecasts issued
from T on, to recompute them after `ping-collector refetch` corrected the
readings they came from (a bad reading reaches the features for 72 h through
`roll_max_72`).
A model row in `forecast_history` is never replaced by a heuristic one. While
RID is silent overnight each hourly run re-issues the 21:00 reading, and once
it is 6 h old (`stale_after_h`) as the heuristic; that used to overwrite the
21:00 model forecast every night.
A station without a model bundle, or whose bundle needs a feature the Rust side
cannot build, gets the heuristic, as in Python. So **before the first training
run `/forecast` returns 200 with an all-heuristic payload**, provided there is
@@ -639,17 +665,26 @@ and `scripts/install.sh` does that on every install.
### Dashboard
The "Flood risk outlook" panel (`src/static/dashboard.html`, `loadForecasts()`)
loads non-blocking after the map renders and **stays hidden unless `/forecast`
returns a non-empty array** — a non-OK response, an empty array, or a thrown
fetch all just leave the panel hidden, and the rest of the dashboard is
unaffected. Per the note above, this means the panel appears with heuristic-only
content once data is flowing but before any model is trained; the per-chip
tooltip is what tells you so. Stations are sorted worst-risk first, each showing
three chips (6/12/24 h) coloured by risk band, with the tooltip carrying the exact
warning and danger percentages, the predicted peak level, and a "heuristic
fallback" note when the row did not come from a model. The panel is labelled
*experimental*.
The dashboard (`src/static/dashboard.html`) loads `/forecast` with the gauges; if
it fails or is empty, the page works without forecasts. Where the model shows:
- **Top bar.** Chiang Mai's verdict from P.1, in the classes of the gauge status:
flooding (over the banks) at ≥ 4.20 m; high water at ≥ 3.70 m; "flooding likely" at
a 24 h danger chance ≥ 50 %, "high water likely" at a warning chance ≥ 70 %;
"watch" at a warning chance ≥ 20 % or a level ≥ 3.20 m. Next to it, the highest
24 h warning chance of any gauge.
- **Gauge list and map.** A gauge is "watch" from a 24 h warning chance of 25 %.
- **Station panel.** Per horizon (6/12/24 h) the predicted peak and the warning and
danger chances, the model version and when it was trained, or "Simple estimate" when
the row came from the heuristic fallback. For P.1 the chance of reaching each of the
five city flood stages. The history chart overlays the 24 h peaks as they were
issued (`/api/forecast/history`), and "How accurate is the forecast?" loads
`/api/forecast/skill`.
- **Replay.** "Replay the October 2024 flood" plays `src/static/flood-2024-all.json`:
the gauge readings of 2024 with what a model trained only on data before
1 September 2024 said at each hour.
The page labels the forecast experimental.
### Training on the server
+19 -6
View File
@@ -26,7 +26,8 @@ seventy-two. In a quiet season a subscriber hears nothing.
Station slugs are the code lowercased without the dot: `p1`, `p103`, `p67`.
Thresholds are the ones in `src/ml/features.py` (`THRESHOLDS`): P.1 3.70 /
4.20 m, P.103 5.95 / 6.75 m, and so on.
4.20 m (half a metre below the banks / over the banks; the city floods in stages
from 4.30 m), P.103 5.95 / 6.75 m, and so on.
The outlook topic is opt-in for a reason: it is model output, and the message
says so. Observed-crossing topics only ever report a gauge reading.
@@ -43,11 +44,22 @@ keys (checked word for word on 751 generated messages), so the switch sent
nothing twice. One difference: it also uses ThaiWater (HII) telemetry. For the stations whose HII twin
is the same sensor (P.1, P.103, P.20, P.4A, P.67, P.75, P.82, P.84, P.92, and
P.81 bias-corrected), a reading newer than RID's drives the level alerts; the
message then says "ThaiWater telemetry; the RID report is not in yet". RID's
report regularly lags 2-10 h at night, which used to send "gauge feed stale"
around midnight and "recovered" in the morning. Now the feed is stale only
when neither source has a reading for 3 h, and RID alone lagging is reported
once after `RID_STALE_ALERT_HOURS` (18, 0 = off). With `MATRIX_*` set, the
message then says "ThaiWater telemetry; the RID report is not in yet". The feed
counts as stale when no source has a reading for 3 h, and RID alone lagging is
reported once after `RID_STALE_ALERT_HOURS` (18, 0 = off).
**RID's nightly pause.** RID publishes no hourly report overnight: on
2026-10-17, -18 and -19 the 21:00 reading was the last one until the hours
22:00-06:00 arrived together between 06:35 and 07:22. ThaiWater's copies of the
RID gauges come from the same feed and arrive with the same delay (P.1's
mirror had no value for 22:00-00:00 even the next morning), so they do not
bridge the night. Without a rule for it, every night sent "gauge feed stale"
around midnight and "feed recovered" around 07:00. So between 21:00 and
`FEED_NIGHT_UNTIL_HOUR` (9, 0 = off) a quiet river's feed is not reported
stale; a feed still without new readings at 09:00 is. While any station is at
warning or danger, or the P.1 outlook is on, the 3 h rule holds all night:
then a stalled dashboard matters. A feed that was already stale stays stale
through the night, and a recovery is announced whenever it happens. With `MATRIX_*` set, the
same transitions also go to a Matrix room, each once (per station, outlook,
feed; not the per-topic summaries), with their own `matrix:` state keys.
@@ -120,6 +132,7 @@ holds back an alert. Test the pipeline before the domain is live with
| `PUBLIC_URL` | `https://water.buildfor.life/` | click-through target in messages |
| `RID_STALE_ALERT_HOURS` | `18` | report RID lagging this long while ThaiWater is current (0 = never) |
| `FEED_NIGHT_UNTIL_HOUR` | `9` | end of the night window from 21:00 in which a quiet river's feed is not reported stale (0 = off) |
| `MATRIX_HOMESERVER` / `MATRIX_ACCESS_TOKEN` / `MATRIX_ROOM_ID` | *(empty = off)* | the team room (docs/MATRIX_QUICK_START.md) |
Tunables in `src/ml/export.py` (compiled into ping-collector through
Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 522 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

+3 -2
View File
@@ -36,8 +36,9 @@ dependencies = [
"scikit-learn==1.9.0",
"sqlalchemy==2.0.23",
"psycopg2-binary==2.9.9",
# .env loading; rust/collector/src/envfile.rs ports exactly this version
"python-dotenv==1.1.1",
# .env loading; rust/collector/src/envfile.rs ports its parser (the 614-case
# fixture is identical for 1.1.1 and 1.2.2; regenerate it when bumping)
"python-dotenv==1.2.2",
]
[project.urls]
+1 -1
View File
@@ -6,4 +6,4 @@ numpy>=1.24,<2 # pandas 2.0.3 wheels are ABI-incompatible with numpy 2.x
scikit-learn==1.9.0
sqlalchemy==2.0.23
psycopg2-binary==2.9.9
python-dotenv==1.1.1
python-dotenv==1.2.2
+12 -3
View File
@@ -22,9 +22,17 @@ ping-collector serve [--listen A] [--no-analytics]
ping-collector once [--dry-run] # one cycle of every source; --dry-run writes nothing
# and compares the RID fetch with the database
ping-collector fill-gaps <days|all> # re-fetch RID reports with holes
ping-collector refetch FROM [TO] # re-fetch the RID reports of FROM..=TO (YYYY-MM-DD) even
# without holes, through the spike screening: corrects
# readings RID revised, deletes stored spikes; prints the
# catch-up-forecasts command when stored levels changed
ping-collector check-db # connect, create missing tables, row counts
ping-collector forecast [--json] [--save] [--now T] [--rain-file F]
# forecast from the database and print it
ping-collector catch-up-forecasts [--hours N] [--redo-from T]
# forecast the hours whose readings came late (RID's
# overnight batch) as a run then would have; default 336 h;
# --redo-from first deletes the model forecasts from T on
ping-collector notify --dry-run # the readings the notifier judges, and what it would send
```
@@ -62,9 +70,10 @@ readings and forecasts with `notification_state`.
fails while it differs from Python; a change needs a rebuild.
- **ntfy** topics, texts and hysteresis: [docs/NOTIFICATIONS.md](../docs/NOTIFICATIONS.md).
For the stations whose ThaiWater twin is the same sensor, a reading newer
than RID's (RID lags 2-10 h at night) drives the level alerts, marked as
ThaiWater telemetry. "Gauge feed stale" means no source has a reading for
3 h; RID alone lagging is reported once after `RID_STALE_ALERT_HOURS`.
than RID's drives the level alerts, marked as ThaiWater telemetry. "Gauge
feed stale" means no source has a reading for 3 h, except during RID's
nightly pause (21:00 to `FEED_NIGHT_UNTIL_HOUR`, 9) while the river is
quiet; RID alone lagging is reported once after `RID_STALE_ALERT_HOURS`.
- **Matrix** (`MATRIX_*`): the room gets the same transitions (per station,
outlook, feed; not ntfy's per-topic summaries), each once, tracked under
its own `matrix:` keys in `notification_state`, so a Matrix outage is
+7
View File
@@ -50,6 +50,9 @@ pub struct Config {
pub public_url: String,
/// Report RID lagging this long while ThaiWater is current (0 = never).
pub rid_stale_alert_hours: f64,
/// FEED_NIGHT_UNTIL_HOUR: from 21:00 until this hour a quiet river's feed
/// is not reported stale (RID publishes nothing overnight); 0 = off.
pub feed_night_until_hour: u32,
/// Public ntfy URL (what subscribers use), shown by /api/notifications.
pub ntfy_server: Option<String>,
/// MATRIX_*: the notifications also go to this room (off when unset).
@@ -211,6 +214,10 @@ impl Config {
.var("PUBLIC_URL")
.unwrap_or_else(|| "https://water.buildfor.life/".into()),
rid_stale_alert_hours: v.number("RID_STALE_ALERT_HOURS", 18.0)?,
feed_night_until_hour: match v.number("FEED_NIGHT_UNTIL_HOUR", 9u32)? {
h if h < 21 => h,
h => bail!("FEED_NIGHT_UNTIL_HOUR must be 0 (off) to 20, got {h}"),
},
ntfy_server: v.var("NTFY_SERVER"),
matrix: match (v.var("MATRIX_ACCESS_TOKEN"), v.var("MATRIX_ROOM_ID")) {
(Some(access_token), Some(room_id)) => Some(MatrixConfig {
+91 -1
View File
@@ -237,6 +237,62 @@ pub async fn save_rid(pool: &PgPool, readings: &[Reading], stations: &Stations)
Ok(rows.len())
}
/// Stored RID levels of report columns `nums` in [start, end], for the
/// screening (rid::screen).
pub async fn rid_readings_between(
pool: &PgPool,
nums: &[u32],
start: NaiveDateTime,
end: NaiveDateTime,
) -> Result<Vec<crate::rid::StoredReading>> {
let rows: Vec<(NaiveDateTime, i32, f64, Option<f64>)> = sqlx::query_as(
"SELECT timestamp, station_id, water_level::float8, discharge::float8
FROM water_measurements
WHERE station_id = ANY($1) AND timestamp >= $2 AND timestamp <= $3
AND water_level IS NOT NULL",
)
.bind(nums.iter().map(|&n| n as i32).collect::<Vec<_>>())
.bind(start)
.bind(end)
.fetch_all(pool)
.await
.context("reading stored RID levels")?;
Ok(rows
.into_iter()
.map(|(t, n, l, q)| (t, n as u32, l, q))
.collect())
}
/// Delete stored RID readings (timestamp, report column).
pub async fn delete_rid_readings(pool: &PgPool, keys: &[(NaiveDateTime, u32)]) -> Result<usize> {
if keys.is_empty() {
return Ok(0);
}
let r = sqlx::query(
"DELETE FROM water_measurements w
USING UNNEST($1::timestamp[], $2::int4[]) AS k(ts, sid)
WHERE w.timestamp = k.ts AND w.station_id = k.sid",
)
.bind(keys.iter().map(|(t, _)| *t).collect::<Vec<_>>())
.bind(keys.iter().map(|(_, n)| *n as i32).collect::<Vec<_>>())
.execute(pool)
.await
.context("deleting RID spikes")?;
Ok(r.rows_affected() as usize)
}
/// Delete the model rows of forecast_history issued at or after `from`, so
/// a catch-up recomputes them (after the readings they were computed from
/// changed).
pub async fn delete_model_forecasts_since(pool: &PgPool, from: NaiveDateTime) -> Result<usize> {
let r = sqlx::query("DELETE FROM forecast_history WHERE as_of >= $1 AND source = 'model'")
.bind(from)
.execute(pool)
.await
.context("deleting forecast_history rows")?;
Ok(r.rows_affected() as usize)
}
/// Day -> hours (0-23) that have at least one RID reading, for [start, end].
pub async fn recorded_hours_by_day(
pool: &PgPool,
@@ -267,6 +323,34 @@ pub async fn earliest_rid_timestamp(pool: &PgPool) -> Result<Option<NaiveDateTim
)
}
/// (hour, station) pairs in `[start, end)` where one of `codes` has a RID
/// level but no model forecast: hours that arrived together with a later
/// one (RID's overnight batch, a late report), so no run forecast from them.
pub async fn hours_without_model_forecast(
pool: &PgPool,
codes: &[String],
start: NaiveDateTime,
end: NaiveDateTime,
) -> Result<Vec<(NaiveDateTime, String)>> {
sqlx::query_as(
"SELECT DISTINCT date_trunc('hour', m.timestamp) AS h, s.station_code::text
FROM water_measurements m JOIN stations s ON s.id = m.station_id
WHERE m.timestamp >= $1 AND m.timestamp < $2 AND m.water_level IS NOT NULL
AND s.station_code = ANY($3)
AND NOT EXISTS (
SELECT 1 FROM forecast_history f
WHERE f.station_code = s.station_code
AND f.as_of = date_trunc('hour', m.timestamp) AND f.source = 'model')
ORDER BY 1, 2",
)
.bind(start)
.bind(end)
.bind(codes)
.fetch_all(pool)
.await
.context("finding hours without a model forecast")
}
pub async fn latest_rid_timestamp(pool: &PgPool) -> Result<Option<NaiveDateTime>> {
Ok(
sqlx::query_scalar("SELECT MAX(timestamp) FROM water_measurements")
@@ -665,6 +749,11 @@ pub async fn latest_rid_readings(pool: &PgPool) -> Result<Vec<LatestReading>> {
}
/// `ForecastHistoryStore.save_rows`: one row per (as_of, station, horizon).
/// Store issued forecasts; a later run's row for the same hour replaces the
/// earlier one, except that a heuristic row never replaces a model row. The
/// hourly run re-issues the newest reading's hour while RID is silent
/// overnight, and after 6 h (`stale_after_h`) it does so as the heuristic:
/// without the exception the 21:00 model forecast was overwritten each night.
pub async fn save_forecast_history(
pool: &PgPool,
rows: &[ping_forecast::Forecast],
@@ -683,7 +772,8 @@ pub async fn save_forecast_history(
ON CONFLICT (as_of, station_code, horizon_hours) DO UPDATE SET
predicted_max_level = EXCLUDED.predicted_max_level, p_warning = EXCLUDED.p_warning,
p_danger = EXCLUDED.p_danger, current_level = EXCLUDED.current_level,
model_version = EXCLUDED.model_version, source = EXCLUDED.source",
model_version = EXCLUDED.model_version, source = EXCLUDED.source
WHERE forecast_history.source IS DISTINCT FROM 'model' OR EXCLUDED.source = 'model'",
)
.bind(rows.iter().map(|r| r.as_of).collect::<Vec<_>>())
.bind(
+253
View File
@@ -191,6 +191,156 @@ async fn yesterdays_late_hours_are_recovered_after_midnight() {
db.drop().await;
}
/// A report source with fixed P.1 levels per hour of one report date.
struct ScriptedRid(Mutex<HashMap<NaiveDate, Vec<(i64, f64)>>>);
impl RidSource for ScriptedRid {
async fn fetch_report(&self, date: NaiveDate) -> Result<Vec<Reading>> {
let script = self
.0
.lock()
.unwrap()
.get(&date)
.cloned()
.unwrap_or_default();
Ok(script
.into_iter()
.map(|(h, level)| Reading {
timestamp: date.and_time(NaiveTime::MIN) + Duration::hours(h),
station_num: 8,
water_level: level,
discharge: Some(45.0),
discharge_percent: None,
})
.collect())
}
}
async fn p1_levels(pool: &PgPool) -> Vec<(NaiveDateTime, f64, Option<f64>)> {
sqlx::query_as(
"SELECT timestamp, water_level::float8, discharge::float8 FROM water_measurements
WHERE station_id = 8 ORDER BY timestamp",
)
.fetch_all(pool)
.await
.unwrap()
}
#[tokio::test]
async fn spikes_are_screened_and_refetch_repairs_the_store() {
let Some(db) = TestDb::new().await else {
return;
};
let stations = Stations::bundled();
let day = d(2026, 10, 24);
// what the store held before screening existed: the slip, level-only
db::save_rid(
&db.pool,
&[
Reading {
timestamp: t(2026, 10, 24, 23, 0),
station_num: 8,
water_level: 1.04,
discharge: None,
discharge_percent: None,
},
Reading {
timestamp: t(2026, 10, 25, 0, 0),
station_num: 8,
water_level: 10.6,
discharge: None,
discharge_percent: None,
},
Reading {
timestamp: t(2026, 10, 25, 1, 0),
station_num: 8,
water_level: 1.07,
discharge: Some(45.0),
discharge_percent: None,
},
],
&stations,
)
.await
.unwrap();
// RID still serves the slip: refetch deletes it and fills the discharge
let src = ScriptedRid(Mutex::new(HashMap::from([(
day,
vec![(23, 1.04), (24, 10.6)],
)])));
let s = rid::refetch(&src, &db.pool, &stations, day, day)
.await
.unwrap();
assert_eq!((s.saved, s.spikes, s.deleted, s.changed), (1, 1, 1, 2));
assert_eq!(s.first_change, Some(t(2026, 10, 25, 0, 0)));
assert_eq!(
p1_levels(&db.pool).await,
vec![
(t(2026, 10, 24, 23, 0), 1.04, Some(45.0)),
(t(2026, 10, 25, 1, 0), 1.07, Some(45.0)),
]
);
// RID corrected it: the hour comes back with the right value
src.0
.lock()
.unwrap()
.insert(day, vec![(23, 1.04), (24, 1.06)]);
let s = rid::refetch(&src, &db.pool, &stations, day, day)
.await
.unwrap();
assert_eq!((s.saved, s.spikes, s.deleted, s.changed), (2, 0, 0, 1));
assert_eq!(s.first_change, None); // a new hour, nothing to redo
assert_eq!(p1_levels(&db.pool).await.len(), 3);
// RID revises a level: the forecasts from that hour on need a redo
src.0
.lock()
.unwrap()
.insert(day, vec![(23, 1.05), (24, 1.06)]);
let s = rid::refetch(&src, &db.pool, &stations, day, day)
.await
.unwrap();
assert_eq!(s.first_change, Some(t(2026, 10, 24, 23, 0)));
// a live cycle: the newest hour jumps and waits for the next one
let today = d(2026, 10, 25);
src.0
.lock()
.unwrap()
.insert(today, vec![(1, 1.07), (2, 9.9)]);
// (yesterday's report comes along: it still has holes)
let o = rid::run_cycle(&src, &db.pool, &stations, t(2026, 10, 25, 2, 10)).await;
assert_eq!((o.fetched, o.saved), (4, 3));
assert_eq!(
p1_levels(&db.pool).await.last().unwrap().0,
t(2026, 10, 25, 1, 0)
);
// 03:00 is back at 1.1: 02:00 was a spike and never stored
src.0
.lock()
.unwrap()
.insert(today, vec![(1, 1.07), (2, 9.9), (3, 1.1)]);
let o = rid::run_cycle(&src, &db.pool, &stations, t(2026, 10, 25, 3, 10)).await;
assert_eq!((o.fetched, o.saved), (5, 4));
let levels: Vec<f64> = p1_levels(&db.pool).await.iter().map(|r| r.1).collect();
assert_eq!(levels, vec![1.05, 1.06, 1.07, 1.1]);
// the forecasts issued from the changed hours can be redone
let mut f = forecast_row("P.1", 24, 0.9);
f.as_of = t(2026, 10, 25, 0, 0);
let mut g = forecast_row("P.1", 24, 0.0);
g.as_of = t(2026, 10, 24, 23, 0);
db::save_forecast_history(&db.pool, &[f, g]).await.unwrap();
assert_eq!(
db::delete_model_forecasts_since(&db.pool, t(2026, 10, 25, 0, 0))
.await
.unwrap(),
1
);
db.drop().await;
}
#[tokio::test]
async fn rid_upsert_revises_values_and_rounds_like_numeric() {
let Some(db) = TestDb::new().await else {
@@ -404,6 +554,109 @@ async fn forecast_outputs_round_trip() {
db.drop().await;
}
#[tokio::test]
async fn heuristic_never_replaces_a_model_forecast() {
let Some(db) = TestDb::new().await else {
return;
};
// overnight the run re-issues the 21:00 reading, after 6 h as heuristic
let model = forecast_row("P.1", 24, 0.2);
let heuristic = ping_forecast::Forecast {
p_warning: 0.0,
predicted_max_level: 1.23,
model_version: "heuristic-v1".into(),
source: "heuristic".into(),
..forecast_row("P.1", 24, 0.0)
};
let stored = async || -> (String, f64) {
sqlx::query_as("SELECT source::text, predicted_max_level::float8 FROM forecast_history")
.fetch_one(&db.pool)
.await
.unwrap()
};
db::save_forecast_history(&db.pool, std::slice::from_ref(&model))
.await
.unwrap();
db::save_forecast_history(&db.pool, std::slice::from_ref(&heuristic))
.await
.unwrap();
assert_eq!(stored().await, ("model".into(), 3.123));
// a model row does replace a heuristic one (and a newer model row)
sqlx::query("DELETE FROM forecast_history")
.execute(&db.pool)
.await
.unwrap();
db::save_forecast_history(&db.pool, &[heuristic])
.await
.unwrap();
assert_eq!(stored().await, ("heuristic".into(), 1.23));
db::save_forecast_history(&db.pool, &[model]).await.unwrap();
assert_eq!(stored().await, ("model".into(), 3.123));
db.drop().await;
}
#[tokio::test]
async fn hours_without_a_model_forecast() {
let Some(db) = TestDb::new().await else {
return;
};
let stations = Stations::bundled();
let r = |h: u32, num: u32| Reading {
timestamp: t(2026, 10, 18, h, 0),
station_num: num, // 8 = P.1, 1 = P.20
water_level: 1.1,
discharge: None,
discharge_percent: None,
};
// RID's morning batch: 11..13 arrive at once; only 13:00 (the newest)
// and P.20's 11:00 were forecast, and 12:00 only by the heuristic
db::save_rid(
&db.pool,
&[r(11, 8), r(12, 8), r(13, 8), r(11, 1), r(12, 1), r(13, 1)],
&stations,
)
.await
.unwrap();
let issued = |code: &str, h: u32, source: &str| ping_forecast::Forecast {
as_of: t(2026, 10, 18, h, 0),
source: source.into(),
..forecast_row(code, 24, 0.1)
};
db::save_forecast_history(
&db.pool,
&[
issued("P.1", 13, "model"),
issued("P.20", 13, "model"),
issued("P.20", 11, "model"),
issued("P.1", 12, "heuristic"),
],
)
.await
.unwrap();
let codes = vec!["P.1".to_string(), "P.20".to_string()];
let missing = db::hours_without_model_forecast(
&db.pool,
&codes,
t(2026, 10, 18, 0, 0),
t(2026, 10, 18, 14, 0),
)
.await
.unwrap();
let at = |h: u32, code: &str| (t(2026, 10, 18, h, 0), code.to_string());
assert_eq!(missing, vec![at(11, "P.1"), at(12, "P.1"), at(12, "P.20")]);
// stations without a model are not asked about
let only_p20 = db::hours_without_model_forecast(
&db.pool,
&codes[1..],
t(2026, 10, 18, 0, 0),
t(2026, 10, 18, 14, 0),
)
.await
.unwrap();
assert_eq!(only_p20, vec![at(12, "P.20")]);
db.drop().await;
}
#[tokio::test]
async fn notification_state_round_trip() {
let Some(db) = TestDb::new().await else {
+2 -1
View File
@@ -1,4 +1,5 @@
//! `.env` loading with python-dotenv 1.1.1 semantics (`load_dotenv()`:
//! `.env` loading with python-dotenv semantics (1.1.1 and 1.2.2 parse the
//! fixture identically; `load_dotenv()`:
//! interpolate `${VAR}` / `${VAR:-default}`, never override variables that
//! are already set).
//!
+109 -1
View File
@@ -5,7 +5,8 @@
//! least once per clock hour: load the last 336 h (RID plus the HII mirror
//! fill), fetch the rain forecast, run every exported model, store the result
//! (`forecast_history`, and `forecast_current` which the API serves as
//! /forecast) and push ntfy transitions.
//! /forecast) and push ntfy transitions. Then forecast the hours whose
//! readings came late (`catch_up`).
use crate::config::Config;
use crate::db;
@@ -20,6 +21,7 @@ use ping_forecast::hii::{self, FillInfo};
use ping_forecast::rain::RainSeries;
use ping_forecast::{Forecast, ModelSet, ModelStore};
use sqlx::PgPool;
use std::collections::{BTreeMap, BTreeSet, HashSet};
/// Everything one forecast run used and produced.
pub struct Snapshot {
@@ -93,6 +95,87 @@ pub async fn compute(
})
}
/// How far back every run looks for hours no run forecast from; the first
/// run after a start looks two weeks back (the nights before an upgrade).
pub const CATCH_UP_HOURS: i64 = 48;
pub const CATCH_UP_START_HOURS: i64 = 14 * 24;
/// What a catch-up did.
#[derive(Debug, Default)]
pub struct CatchUp {
/// hours computed (also those that produced no model row)
pub hours: Vec<NaiveDateTime>,
/// forecast_history rows stored
pub rows: usize,
}
/// Model forecasts for the hours whose readings arrived together with a
/// later hour's. RID publishes 22:00..06:00 in one batch in the morning, so
/// the hourly run only ever forecast from 06:00 and the night was missing
/// from the issued history (also single hours RID reported late).
///
/// Each hour is computed as a run at that hour would have been: RID, the
/// HII fill and the dam state up to that hour only. Rain is one fetch now,
/// so a caught-up hour's `rain_fc24` is partly Open-Meteo's estimate of rain
/// that has since fallen rather than a forecast (the training condition, not
/// what a live run had). Only missing (hour, station) pairs are written, and
/// a stored model row is never replaced. Hours in `skip` were tried before.
pub async fn catch_up(
pool: &PgPool,
rain_client: &OpenMeteoClient,
models: &ModelSet,
now: NaiveDateTime,
hours_back: i64,
skip: &HashSet<NaiveDateTime>,
) -> Result<CatchUp> {
let codes: Vec<String> = models.keys().cloned().collect();
let start = floor_hour(now) - Duration::hours(hours_back);
let mut missing: BTreeMap<NaiveDateTime, BTreeSet<String>> = BTreeMap::new();
for (hour, code) in db::hours_without_model_forecast(pool, &codes, start, now).await? {
if !skip.contains(&hour) {
missing.entry(hour).or_default().insert(code);
}
}
if missing.is_empty() {
return Ok(CatchUp::default());
}
// the rain windows (up to 72 h) of the oldest hour, plus a margin
let past_days = (hours_back / 24 + 5).min(92) as u32;
let rain: RainSeries = rain_client
.fetch_forecast(past_days, 2)
.await?
.into_iter()
.map(|r| (r.timestamp, r.catchment_mean.unwrap_or(f64::NAN)))
.collect();
let mut done = CatchUp::default();
for (hour, stations) in missing {
let snap = compute(pool, rain_client, models, hour, Some(rain.clone())).await?;
let rows: Vec<Forecast> = snap
.forecasts
.into_iter()
.filter(|f| {
f.as_of == hour && f.source == "model" && stations.contains(&f.station_code)
})
.collect();
done.rows += db::save_forecast_history(pool, &rows).await?;
done.hours.push(hour);
}
Ok(done)
}
pub fn describe_catch_up(c: &CatchUp) -> String {
match (c.hours.first(), c.hours.last()) {
(Some(first), Some(last)) => format!(
"{} hours from {} to {}, {} rows stored",
c.hours.len(),
first.format("%d %H:%M"),
last.format("%d %H:%M"),
c.rows
),
_ => "nothing to catch up".into(),
}
}
pub fn summarize(s: &Snapshot) -> String {
let model = s.forecasts.iter().filter(|f| f.source == "model").count();
let p1 = s
@@ -151,6 +234,7 @@ pub fn notifier(cfg: &Config, http: reqwest::Client) -> Option<Notifier> {
settings: Settings {
prefix: cfg.ntfy_topic_prefix.clone(),
rid_stale_alert_hours: cfg.rid_stale_alert_hours,
feed_night_until_hour: cfg.feed_night_until_hour,
},
})
}
@@ -170,6 +254,10 @@ pub struct Forecaster {
notifier: Option<Notifier>,
last_inputs: Option<(Option<NaiveDateTime>, Option<NaiveDateTime>)>,
last_slot: Option<NaiveDateTime>,
/// hours `catch_up` computed already (a station whose features cannot
/// be built at that hour would otherwise be retried on every run)
caught_up: HashSet<NaiveDateTime>,
caught_up_since_start: bool,
}
impl Forecaster {
@@ -201,6 +289,8 @@ impl Forecaster {
notifier,
last_inputs: None,
last_slot: None,
caught_up: HashSet::new(),
caught_up_since_start: false,
}
}
@@ -271,6 +361,24 @@ impl Forecaster {
Err(e) => tracing::warn!("notify cycle failed: {e:#}"),
}
}
// after the notifications: they are about now, this is history
let window = if self.caught_up_since_start {
CATCH_UP_HOURS
} else {
CATCH_UP_START_HOURS
};
match catch_up(pool, rain, &models, now, window, &self.caught_up).await {
Ok(c) => {
if !c.hours.is_empty() {
tracing::info!("forecast catch-up: {}", describe_catch_up(&c));
}
self.caught_up.extend(c.hours);
self.caught_up_since_start = true;
let oldest = floor_hour(now) - Duration::hours(CATCH_UP_HOURS);
self.caught_up.retain(|h| *h >= oldest);
}
Err(e) => tracing::warn!("forecast catch-up failed: {e:#}"),
}
Ok(RunInfo {
at: now,
rows: snap.forecasts.len(),
+58
View File
@@ -62,6 +62,14 @@ enum Command {
},
/// Re-fetch RID reports that have holes: a number of days back, or "all"
FillGaps { days: String },
/// Re-fetch the RID reports of FROM..=TO (YYYY-MM-DD; day D's report holds
/// D 01:00 to D+1 00:00) even without holes, through the spike screening:
/// corrects readings RID has revised and removes stored spikes
Refetch {
from: chrono::NaiveDate,
/// Last report date [default: FROM]
to: Option<chrono::NaiveDate>,
},
/// Connect, create missing tables, print row counts and the newest RID reading
CheckDb,
/// Forecast from the database now and print it; stores nothing without --save
@@ -80,6 +88,19 @@ enum Command {
#[arg(long)]
rain_file: Option<PathBuf>,
},
/// Forecast the hours no run forecast from (readings that arrived with a
/// later hour's, e.g. RID's overnight batch) as a run at each hour would
/// have, and store them; every run does this for the last 48 h
CatchUpForecasts {
/// How many hours back to look
#[arg(long, default_value_t = 336)]
hours: i64,
/// First delete the model forecasts issued from this local time on
/// (e.g. 2026-10-25T00:00) so they are recomputed: after `refetch`
/// changed the readings they came from
#[arg(long)]
redo_from: Option<String>,
},
/// Show the readings the notifier judges and the ntfy transitions due now
Notify {
/// Show what would be published; publish nothing, change no state
@@ -204,6 +225,43 @@ async fn run(cli: Cli) -> Result<()> {
let rain = rain_file.as_deref().map(read_rain_file).transpose()?;
collector.forecast(json, save, now, rain).await
}
Command::CatchUpForecasts { hours, redo_from } => {
let redo_from = redo_from.as_deref().map(parse_local).transpose()?;
collector.catch_up_forecasts(hours, redo_from).await
}
Command::Refetch { from, to } => {
let to = to.unwrap_or(from);
if to < from {
bail!("refetch: TO ({to}) is before FROM ({from})");
}
db::ensure_schema(&collector.pool).await?;
let s = rid::refetch(
&collector.rid,
&collector.pool,
&collector.stations(),
from,
to,
)
.await?;
println!(
"{} readings saved ({} new or changed), {} spikes rejected, {} stored spikes deleted, {} held for want of the next reading",
s.saved,
s.changed - s.deleted,
s.spikes,
s.deleted,
s.held
);
if let Some(t) = s.first_change {
let hours = (util::bangkok_now() - t).num_hours() + 2;
if hours <= 24 * 60 {
println!(
"stored levels changed from {t} on: the forecasts issued since came from wrong levels; recompute them with\n ping-collector catch-up-forecasts --hours {hours} --redo-from {}",
t.format("%Y-%m-%dT%H:%M")
);
}
}
Ok(())
}
Command::Notify { dry_run } => collector.notify(dry_run).await,
Command::CheckDb => {
db::ensure_schema(&collector.pool).await?;
+123 -16
View File
@@ -7,16 +7,16 @@
//!
//! Same topics, texts, hysteresis, capacity guard, outlook and persisted
//! state keys (`notification_state`) as Python had, so the cutover neither
//! repeated nor dropped a message. One change: for stations whose HII/ThaiWater twin is
//! the same sensor, a reading newer than RID's (RID's report often lags 2-10 h
//! at night) is used for the level alerts and the feed check. The feed is
//! "stale" only when neither source has a reading for 3 h; RID lagging alone
//! is reported separately after RID_STALE_ALERT_HOURS.
//! repeated nor dropped a message. Changes since: for stations whose HII/ThaiWater
//! twin is the same sensor, a reading newer than RID's is used for the level
//! alerts and the feed check; RID lagging alone is reported after
//! RID_STALE_ALERT_HOURS; and RID's nightly pause is not reported (see
//! [`NIGHT_FROM_HOUR`]).
use crate::config::MatrixConfig;
use crate::db::{self, LatestReading};
use anyhow::Result;
use chrono::{Duration, NaiveDateTime};
use chrono::{Duration, NaiveDateTime, Timelike};
use ping_forecast::Forecast;
use ping_forecast::config::config;
use ping_forecast::hii::FillInfo;
@@ -123,10 +123,28 @@ fn level_state(level: f64, warn: f64, danger: f64, prev: &str) -> &'static str {
"clear"
}
/// RID publishes no hourly report overnight: on 2026-10-17, -18 and -19 the
/// 21:00 reading was the last until the 22:00..06:00 hours arrived together
/// between 06:35 and 07:22 (ThaiWater's copies of the RID gauges arrive with
/// the same delay, so they cannot bridge it). Between this hour and
/// `Settings::feed_night_until_hour` a quiet river's feed is therefore not
/// reported stale; a feed still stale at that hour is. While any station is at
/// warning or danger, or the P.1 outlook is on, the 3 h rule holds all night.
pub const NIGHT_FROM_HOUR: u32 = 21;
pub struct Settings {
pub prefix: String,
/// 0 = off
pub rid_stale_alert_hours: f64,
/// End of the night window (hour of day); 0 = no night window.
pub feed_night_until_hour: u32,
}
impl Settings {
fn in_night_window(&self, now: NaiveDateTime) -> bool {
let h = now.hour();
self.feed_night_until_hour > 0 && (h >= NIGHT_FROM_HOUR || h < self.feed_night_until_hour)
}
}
impl Settings {
@@ -176,6 +194,8 @@ pub fn evaluate(
let (mut b_danger, mut b_warning, mut b_clear) = (vec![], vec![], vec![]);
let mut latest: Option<NaiveDateTime> = None;
let mut latest_rid: Option<NaiveDateTime> = None;
// any station at warning/danger or the outlook on: the night window is off
let mut river_high = false;
for r in readings {
latest_rid = latest_rid.max(r.rid_timestamp);
@@ -201,6 +221,7 @@ pub fn evaluate(
);
continue;
}
river_high |= cur != "clear";
if cur == prev {
continue;
}
@@ -302,6 +323,7 @@ pub fn evaluate(
}
// model outlook for the city gauge (opt-in topic, experimental)
river_high |= state("outlook:P.1") == Some("on");
if let Some(p1) = forecasts.iter().find(|f| {
f.station_code == "P.1" && f.horizon_hours == nc.outlook_horizon && f.source == "model"
}) {
@@ -313,6 +335,7 @@ pub fn evaluate(
} else {
"off"
};
river_high |= cur == "on";
if cur != prev {
let (warn, _) = cfg.thresholds("P.1");
let notification = if cur == "on" {
@@ -348,13 +371,22 @@ pub fn evaluate(
}
}
// feed health: stale only when no source has a recent reading
// feed health: stale only when no source has a recent reading; during
// RID's nightly pause a quiet river keeps its previous state (a recovery
// is still announced, a new stale message waits for the morning)
let mut feed_stale = false;
if let Some(latest) = latest {
let age_h = hours_between(now, latest);
let prev = state("feed").unwrap_or("ok");
feed_stale = age_h >= nc.feed_stale_after_h;
let cur = if feed_stale { "stale" } else { "ok" };
let deferred = !river_high && settings.in_night_window(now);
let cur = if age_h < nc.feed_stale_after_h {
"ok"
} else if deferred {
prev
} else {
"stale"
};
feed_stale = cur == "stale";
if cur != prev {
let notification = if feed_stale {
n(
@@ -743,6 +775,7 @@ mod tests {
Settings {
prefix: "ping".into(),
rid_stale_alert_hours: 18.0,
feed_night_until_hour: 9,
}
}
@@ -996,6 +1029,81 @@ mod tests {
);
}
fn at(day: u32, hour: u32, min: u32) -> NaiveDateTime {
NaiveDate::from_ymd_opt(2026, 10, day)
.unwrap()
.and_hms_opt(hour, min, 0)
.unwrap()
}
fn reading_at(code: &str, level: f64, t: NaiveDateTime) -> Reading {
Reading {
timestamp: t,
rid_timestamp: Some(t),
..reading(code, level, 0)
}
}
#[test]
fn rids_nightly_pause_is_not_reported_on_a_quiet_river() {
// 2026-10-19: last reading 21:00, nothing until the 06:00 batch at 06:55
let mut st = HashMap::new();
let evening = [reading_at("P.1", 1.26, at(18, 21, 0))];
for t in [at(18, 23, 5), at(19, 0, 5), at(19, 3, 5), at(19, 6, 5)] {
assert!(step(&evening, &[], &mut st, t).is_empty(), "at {t}");
}
let morning = [reading_at("P.1", 1.33, at(19, 6, 0))];
assert!(step(&morning, &[], &mut st, at(19, 6, 55)).is_empty());
// still nothing new at 09:00: reported then
let sent = step(&evening, &[], &mut st, at(19, 9, 5));
assert_eq!(
titles(&sent),
[("ping-status", "Ping River monitor: gauge feed stale")]
);
assert!(
sent[0]
.message
.starts_with("No new readings for 12 h (last 18 Oct 21:00)")
);
// the window can be turned off
let off = Settings {
feed_night_until_hour: 0,
..settings()
};
let plan = evaluate(&evening, &[], &HashMap::new(), &off, at(19, 0, 5));
assert_eq!(
plan[0].notification.title,
"Ping River monitor: gauge feed stale"
);
}
#[test]
fn night_window_keeps_a_stale_feed_stale_and_announces_recovery() {
let mut st = HashMap::new();
let old = [reading_at("P.1", 1.2, at(18, 14, 0))];
assert_eq!(step(&old, &[], &mut st, at(18, 18, 5)).len(), 1); // stale, daytime
assert!(step(&old, &[], &mut st, at(18, 22, 5)).is_empty()); // not "recovered"
let back = [reading_at("P.1", 1.2, at(18, 22, 0))];
assert_eq!(
step(&back, &[], &mut st, at(18, 23, 5))[0].title,
"Ping River monitor: feed recovered"
);
}
#[test]
fn the_three_hour_rule_holds_at_night_while_the_river_is_high() {
let mut st = HashMap::from([("level:P.1".to_string(), "warning".to_string())]);
let high = [reading_at("P.1", 3.8, at(18, 21, 0))];
assert_eq!(
titles(&step(&high, &[], &mut st, at(19, 0, 5))),
[("ping-status", "Ping River monitor: gauge feed stale")]
);
// the outlook being on counts too
let mut st = HashMap::from([("outlook:P.1".to_string(), "on".to_string())]);
let quiet = [reading_at("P.1", 1.3, at(18, 21, 0))];
assert_eq!(step(&quiet, &[], &mut st, at(19, 0, 5)).len(), 1);
}
#[test]
fn undelivered_transitions_are_retried() {
let mut st = HashMap::new();
@@ -1067,13 +1175,12 @@ mod tests {
.map(|v| v.as_str().unwrap())
.collect();
let mut delivered = vec![];
for p in evaluate(
&readings,
&forecasts,
&states,
&settings(),
ts(&step["now"]),
) {
// the Python notifier had no night window
let python = Settings {
feed_night_until_hour: 0,
..settings()
};
for p in evaluate(&readings, &forecasts, &states, &python, ts(&step["now"])) {
if down.contains(&p.notification.topic.as_str()) {
continue;
}
+401 -8
View File
@@ -9,6 +9,12 @@
//! every reading it gets (not only "the current hour"), and re-fetches
//! yesterday's report while the store still has holes in it: hours 22-24 of a
//! day are reachable only through that day's report.
//!
//! Before storing, readings are screened for single-hour spikes (see
//! [`judge`]): RID's report sometimes carries a slipped decimal point, a
//! flipped sign or a placeholder for one hour (P.1 10.6 m between 1.04 and
//! 1.07 on 2026-10-25 00:00), which would raise a false alarm and
//! distort the forecast features for three days.
use crate::db;
use crate::stations::Stations;
@@ -17,7 +23,7 @@ use anyhow::{Context, Result};
use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime};
use serde_json::Value;
use sqlx::PgPool;
use std::collections::{BTreeSet, HashMap, HashSet};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::future::Future;
pub const N_STATIONS: u32 = 16;
@@ -96,6 +102,248 @@ pub fn parse_report(report_date: NaiveDate, payload: &Value) -> Vec<Reading> {
out
}
// ---- Screening -------------------------------------------------------------
/// Neighbours of a reading are looked for this many hours before and after it.
const SCREEN_WINDOW_H: i64 = 3;
/// Gauges on flashy tributaries: genuine 1-h changes of 1.3-3.1 m are on
/// record (P.76 0.09 -> 3.18 m on 2026-05-29). Elsewhere the largest genuine
/// 1-h change since 2018 is 1.1 m (P.1: 0.56 m).
const FLASHY: &[&str] = &["P.76", "P.77", "P.81", "P.82", "P.84", "P.85", "P.87"];
/// A change from the previous reading larger than this (m) needs the next
/// reading to tell a spike from a real change.
pub fn jump_limit(code: &str) -> f64 {
if FLASHY.contains(&code) { 2.0 } else { 1.0 }
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Verdict {
Keep,
/// The newest reading jumped more than twice the limit: wait for the next
/// one (an hour at most while RID is current) before storing it.
Hold,
/// Out and back within an hour or two: not stored.
Spike,
}
/// `prev`: the last accepted level within [`SCREEN_WINDOW_H`] before the
/// reading, `next`: the following reading within the window, if any.
///
/// A spike leaves the previous level by more than `limit` and the next
/// reading comes back, closer to the previous level than half of either
/// jump. A rising or falling limb (prev < level < next) or a step the next
/// reading confirms is kept. 2018-2026 history: 16 spikes (sign flips,
/// slipped decimal points, -5/-6/-9 m placeholders) and no genuine change
/// would have been rejected.
pub fn judge(limit: f64, prev: Option<f64>, level: f64, next: Option<f64>) -> Verdict {
let Some(p) = prev else {
return Verdict::Keep;
};
let out = (level - p).abs();
if out <= limit {
return Verdict::Keep;
}
let Some(n) = next else {
return if out > 2.0 * limit {
Verdict::Hold
} else {
Verdict::Keep
};
};
let back = (level - n).abs();
if back > limit && (n - p).abs() < out.min(back) / 2.0 {
Verdict::Spike
} else {
Verdict::Keep
}
}
/// A reading already in the database: (timestamp, report column, level, discharge).
pub type StoredReading = (NaiveDateTime, u32, f64, Option<f64>);
#[derive(Debug, Default)]
pub struct Screened {
pub keep: Vec<Reading>,
pub held: Vec<Reading>,
pub spikes: Vec<Reading>,
/// Stored rows at a spike's hour that are spikes themselves: delete.
pub delete: Vec<(NaiveDateTime, u32)>,
}
/// Screen a batch against itself and the stored readings around it. A batch
/// reading replaces the stored one at the same hour; stored readings count
/// as accepted. `code` maps a report column to its station code.
pub fn screen(
readings: &[Reading],
stored: &[StoredReading],
code: impl Fn(u32) -> String,
) -> Screened {
#[derive(Clone, Copy)]
enum Src {
Batch(usize),
Stored,
}
let mut out = Screened::default();
let mut by_station: BTreeMap<u32, BTreeMap<NaiveDateTime, (f64, Src)>> = BTreeMap::new();
for (t, n, level, _) in stored {
by_station
.entry(*n)
.or_default()
.insert(*t, (*level, Src::Stored));
}
let stored_at: HashMap<(NaiveDateTime, u32), f64> =
stored.iter().map(|(t, n, l, _)| ((*t, *n), *l)).collect();
// the last copy of an hour wins, as in the upsert
for (i, r) in readings.iter().enumerate() {
by_station
.entry(r.station_num)
.or_default()
.insert(r.timestamp, (r.water_level, Src::Batch(i)));
}
let window = Duration::hours(SCREEN_WINDOW_H);
for (n, series) in &by_station {
let limit = jump_limit(&code(*n));
let mut last: Option<(NaiveDateTime, f64)> = None;
let entries: Vec<_> = series.iter().map(|(t, v)| (*t, *v)).collect();
for (k, &(t, (level, src))) in entries.iter().enumerate() {
let prev = last.filter(|(pt, _)| t - *pt <= window).map(|(_, l)| l);
let i = match src {
Src::Stored => {
last = Some((t, level));
continue;
}
Src::Batch(i) => i,
};
let next = entries
.get(k + 1)
.filter(|(nt, _)| *nt - t <= window)
.map(|(_, (l, _))| *l);
match judge(limit, prev, level, next) {
Verdict::Keep => {
out.keep.push(readings[i].clone());
last = Some((t, level));
}
Verdict::Hold => out.held.push(readings[i].clone()),
Verdict::Spike => {
out.spikes.push(readings[i].clone());
if let Some(&old) = stored_at.get(&(t, *n)) {
if judge(limit, prev, old, next) == Verdict::Spike {
out.delete.push((t, *n));
} else {
last = Some((t, old));
}
}
}
}
}
}
out.keep.sort_by_key(|r| (r.timestamp, r.station_num));
out
}
#[derive(Debug, Default)]
pub struct StoreOutcome {
pub saved: usize,
pub held: usize,
pub spikes: usize,
pub deleted: usize,
/// Saved readings that were new or differ from the stored ones, plus deletions.
pub changed: usize,
/// The earliest hour whose stored level changed or was deleted: the
/// forecasts issued from then on came from a wrong level. (New hours and
/// added discharges do not count: the catch-up forecasts new hours, and
/// a forecast issued without the discharge is still what was issued.)
pub first_change: Option<NaiveDateTime>,
}
/// Screen `readings` against the stored ones around them, then save them and
/// delete stored spikes.
pub async fn store(
pool: &PgPool,
readings: &[Reading],
stations: &Stations,
) -> Result<StoreOutcome> {
let mut out = StoreOutcome::default();
let (Some(first), Some(last)) = (
readings.iter().map(|r| r.timestamp).min(),
readings.iter().map(|r| r.timestamp).max(),
) else {
return Ok(out);
};
let window = Duration::hours(SCREEN_WINDOW_H);
let nums: Vec<u32> = readings
.iter()
.map(|r| r.station_num)
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
let stored = db::rid_readings_between(pool, &nums, first - window, last + window).await?;
let code = |n: u32| stations.get(n).code;
let s = screen(readings, &stored, code);
for r in &s.spikes {
tracing::warn!(
"RID {} {}: {} m is a one-hour spike, not stored",
r.timestamp,
code(r.station_num),
r.water_level
);
}
for r in &s.held {
tracing::warn!(
"RID {} {}: {} m jumps from the previous reading; waiting for the next one",
r.timestamp,
code(r.station_num),
r.water_level
);
}
let before: HashMap<(NaiveDateTime, u32), (f64, Option<f64>)> = stored
.iter()
.map(|(t, n, l, q)| ((*t, *n), (*l, *q)))
.collect();
// NUMERIC(10,3) / (10,2): compare at the stored precision
let differs = |a: f64, b: f64, places: i32| {
let f = 10f64.powi(places);
(a * f).round() != (b * f).round()
};
let mut changed: Vec<NaiveDateTime> = s
.keep
.iter()
.filter(|r| match before.get(&(r.timestamp, r.station_num)) {
None => true,
Some((l, q)) => {
differs(*l, r.water_level, 3)
|| match (q, r.discharge) {
(Some(a), Some(b)) => differs(*a, b, 2),
(None, None) => false,
_ => true,
}
}
})
.map(|r| r.timestamp)
.collect();
changed.extend(s.delete.iter().map(|(t, _)| *t));
let level_changed = s
.keep
.iter()
.filter(|r| {
before
.get(&(r.timestamp, r.station_num))
.is_some_and(|(l, _)| differs(*l, r.water_level, 3))
})
.map(|r| r.timestamp)
.chain(s.delete.iter().map(|(t, _)| *t))
.min();
out.saved = db::save_rid(pool, &s.keep, stations).await?;
out.deleted = db::delete_rid_readings(pool, &s.delete).await?;
out.held = s.held.len();
out.spikes = s.spikes.len();
out.changed = changed.len();
out.first_change = level_changed;
Ok(out)
}
/// Anything that can produce a parsed RID report (HTTP in production,
/// canned rows in tests).
pub trait RidSource {
@@ -248,12 +496,14 @@ pub async fn run_cycle(
tracing::warn!("RID: no data fetched for {:?}", out.dates);
return out;
}
if let Err(e) = db::save_rid(pool, &readings, stations).await {
tracing::error!("RID save failed: {e:#}");
out.error = Some(format!("save failed: {e:#}"));
return out;
match store(pool, &readings, stations).await {
Ok(s) => out.saved = s.saved,
Err(e) => {
tracing::error!("RID save failed: {e:#}");
out.error = Some(format!("save failed: {e:#}"));
return out;
}
}
out.saved = readings.len();
let latest = readings
.iter()
.map(|r| r.timestamp)
@@ -308,8 +558,7 @@ pub async fn fill_gaps(
}
match src.fetch_report(*d).await {
Ok(readings) if !readings.is_empty() => {
db::save_rid(pool, &readings, stations).await?;
filled += readings.len();
filled += store(pool, &readings, stations).await?.saved;
}
Ok(_) => {
empty += 1;
@@ -332,6 +581,52 @@ pub async fn fill_gaps(
Ok(filled)
}
/// Re-fetch the reports of `from..=to` (report dates: day D's report holds
/// D 01:00 to D+1 00:00) whether or not they have holes, and store them
/// through the screening: corrects readings RID has revised since (and
/// fills the discharge of level-only hours), removes stored spikes.
pub async fn refetch(
src: &impl RidSource,
pool: &PgPool,
stations: &Stations,
from: NaiveDate,
to: NaiveDate,
) -> Result<StoreOutcome> {
let mut total = StoreOutcome::default();
let mut day = from;
while day <= to {
if day > from {
tokio::time::sleep(std::time::Duration::from_secs(1)).await; // be polite
}
match src.fetch_report(day).await {
Ok(readings) => {
let s = store(pool, &readings, stations).await?;
tracing::info!(
"refetch {day}: {} fetched, {} saved ({} new or changed), {} spikes rejected, {} stored spikes deleted, {} held",
readings.len(),
s.saved,
s.changed - s.deleted,
s.spikes,
s.deleted,
s.held
);
total.saved += s.saved;
total.held += s.held;
total.spikes += s.spikes;
total.deleted += s.deleted;
total.changed += s.changed;
total.first_change = match (total.first_change, s.first_change) {
(Some(a), Some(b)) => Some(a.min(b)),
(a, b) => a.or(b),
};
}
Err(e) => tracing::warn!("refetch {day}: {e:#}"),
}
day += Duration::days(1);
}
Ok(total)
}
/// Next RID poll: `offset` minutes past the next hour when current, else in
/// `retry` minutes (never later than that hourly poll).
pub fn next_poll_time(
@@ -407,6 +702,104 @@ mod tests {
assert!(!by.contains_key(&5)); // negative discharge drops the reading
}
#[test]
fn judge_spikes_steps_and_limbs() {
use Verdict::*;
// the P.1 slip of 2026-10-25 00:00, a sign flip, a placeholder
assert_eq!(judge(1.0, Some(1.04), 10.6, Some(1.07)), Spike);
assert_eq!(judge(1.0, Some(-1.18), 1.18, Some(-1.18)), Spike);
assert_eq!(judge(2.0, Some(-0.05), -5.0, Some(-0.05)), Spike);
// small changes, no neighbour, a confirmed step, rising limbs
assert_eq!(judge(1.0, Some(1.0), 1.9, Some(1.0)), Keep);
assert_eq!(judge(1.0, None, 10.6, Some(1.07)), Keep);
assert_eq!(judge(1.0, Some(1.0), 2.5, Some(2.6)), Keep);
assert_eq!(judge(2.0, Some(0.04), 1.61, Some(2.67)), Keep);
assert_eq!(judge(1.0, Some(0.82), 1.78, Some(2.82)), Keep);
// a peak that falls back less than halfway in the next hour is real
assert_eq!(judge(1.0, Some(1.0), 3.0, Some(1.8)), Keep);
// the newest reading: a jump over twice the limit waits
assert_eq!(judge(1.0, Some(1.04), 10.6, None), Hold);
assert_eq!(judge(1.0, Some(1.0), 2.5, None), Keep);
assert_eq!(judge(2.0, Some(0.09), 3.18, None), Keep);
assert_eq!(jump_limit("P.76"), 2.0);
assert_eq!(jump_limit("P.1"), 1.0);
}
fn rd(h: u32, n: u32, level: f64) -> Reading {
Reading {
timestamp: t(2026, 10, 25, h, 0),
station_num: n,
water_level: level,
discharge: None,
discharge_percent: None,
}
}
fn code(n: u32) -> String {
if n == 8 {
"P.1".into()
} else {
format!("P.{}", 19 + n)
}
}
#[test]
fn screen_rejects_a_spike_and_deletes_its_stored_copy() {
let stored = vec![
(t(2026, 10, 24, 23, 0), 8, 1.04, None),
(t(2026, 10, 25, 0, 0), 8, 10.6, None),
];
// the report re-fetched, RID still has the slip
let batch = vec![rd(0, 8, 10.6), rd(1, 8, 1.07), rd(2, 8, 1.09)];
let s = screen(&batch, &stored, code);
assert_eq!(s.spikes, vec![rd(0, 8, 10.6)]);
assert_eq!(s.delete, vec![(t(2026, 10, 25, 0, 0), 8)]);
assert_eq!(s.keep, vec![rd(1, 8, 1.07), rd(2, 8, 1.09)]);
// RID corrected it: the new value replaces the stored spike
let s = screen(&[rd(0, 8, 1.06), rd(1, 8, 1.07)], &stored, code);
assert!(s.spikes.is_empty() && s.delete.is_empty());
assert_eq!(s.keep, vec![rd(0, 8, 1.06), rd(1, 8, 1.07)]);
}
#[test]
fn screen_keeps_a_good_stored_copy_and_judges_the_next_hour_by_it() {
let stored = vec![
(t(2026, 10, 25, 0, 0), 8, 1.06, None),
(t(2026, 10, 25, 1, 0), 8, 1.07, None),
];
let s = screen(&[rd(1, 8, 10.7), rd(2, 8, 1.09)], &stored, code);
assert_eq!(s.spikes, vec![rd(1, 8, 10.7)]);
assert!(s.delete.is_empty());
assert_eq!(s.keep, vec![rd(2, 8, 1.09)]);
}
#[test]
fn screen_holds_the_newest_jump_until_the_next_reading() {
let stored = vec![(t(2026, 10, 25, 13, 0), 8, 1.04, None)];
let s = screen(&[rd(14, 8, 10.6)], &stored, code);
assert_eq!(s.held, vec![rd(14, 8, 10.6)]);
assert!(s.keep.is_empty() && s.spikes.is_empty());
// next poll: the report again, now with 15:00
let s = screen(&[rd(14, 8, 10.6), rd(15, 8, 1.06)], &stored, code);
assert_eq!(s.spikes, vec![rd(14, 8, 10.6)]);
assert_eq!(s.keep, vec![rd(15, 8, 1.06)]);
// a real jump the next reading confirms is stored then
let s = screen(&[rd(14, 8, 3.5), rd(15, 8, 3.6)], &stored, code);
assert_eq!(s.keep, vec![rd(14, 8, 3.5), rd(15, 8, 3.6)]);
}
#[test]
fn screen_window_and_stations_are_separate() {
// previous reading 4 h back: nothing to compare with
let stored = vec![(t(2026, 10, 25, 9, 0), 8, 1.0, None)];
let s = screen(&[rd(13, 8, 5.0), rd(13, 1, 0.5)], &stored, code);
assert_eq!(s.keep.len(), 2);
// a jump at one gauge does not involve the others
let stored = vec![(t(2026, 10, 25, 12, 0), 1, 0.1, None)];
let s = screen(&[rd(13, 8, 1.0), rd(13, 1, 0.12)], &stored, code);
assert_eq!(s.keep.len(), 2);
}
#[test]
fn missing_rows_key_is_empty() {
assert!(parse_report(d(2026, 7, 22), &json!({"unexpected": true})).is_empty());
+44
View File
@@ -398,6 +398,50 @@ impl Collector {
Ok(())
}
/// `ping-collector catch-up-forecasts`: `forecasting::catch_up` over
/// `hours`, e.g. once after an upgrade for the nights before it.
pub async fn catch_up_forecasts(
&self,
hours: i64,
redo_from: Option<NaiveDateTime>,
) -> Result<()> {
if !(1..=24 * 60).contains(&hours) {
bail!("--hours must be between 1 and 1440 (Open-Meteo keeps 92 days of past rain)");
}
let now = bangkok_now();
if let Some(from) = redo_from
&& from < floor_hour(now) - Duration::hours(hours)
{
bail!(
"--redo-from {from} is more than --hours {hours} back: those hours would not be recomputed"
);
}
let mut store = ModelStore::new(&self.cfg.models_dir);
let models = store.refresh()?;
if models.is_empty() {
bail!(
"no exported models in {} (run `python -m src.ml.export`)",
self.cfg.models_dir.display()
);
}
db::ensure_schema(&self.pool).await?;
if let Some(from) = redo_from {
let n = db::delete_model_forecasts_since(&self.pool, from).await?;
eprintln!("deleted {n} model forecasts issued since {from}; recomputing");
}
let c = forecasting::catch_up(
&self.pool,
&self.rain,
&models,
now,
hours,
&Default::default(),
)
.await?;
eprintln!("forecast catch-up: {}", forecasting::describe_catch_up(&c));
Ok(())
}
/// `ping-collector notify`: what would be (or, without `dry_run`, is)
/// published right now against the stored notification state.
pub async fn notify(&self, dry_run: bool) -> Result<()> {
+2 -2
View File
@@ -58,8 +58,8 @@ fn check(status: &'static str, message: impl Into<String>) -> Check {
/// Data freshness. As the Python check it judges the newest reading of the
/// first station (`get_latest_measurements(limit=1)`: P.1) against 2 h. One
/// change, as in the notifier: while RID's report lags (2-10 h at night) but
/// the ThaiWater telemetry of the same gauges is current, the data is current.
/// change, as in the notifier: while RID's report lags but the ThaiWater
/// telemetry of the same gauges is current, the data is current.
async fn database_check(st: &Shared) -> Check {
let latest: Result<Option<(chrono::NaiveDateTime,)>, _> = sqlx::query_as(
"SELECT m.timestamp
+18 -28
View File
@@ -73,38 +73,28 @@
"P.1": [
{
"stage": 1,
"level": 3.7,
"discharge_cms": 405.0
},
{
"stage": 2,
"level": 3.9,
"discharge_cms": 438.0
},
{
"stage": 3,
"level": 4.0,
"discharge_cms": 458.0
},
{
"stage": 4,
"level": 4.1,
"discharge_cms": 478.0
},
{
"stage": 5,
"level": 4.2,
"discharge_cms": 493.0
},
{
"stage": 6,
"level": 4.3,
"discharge_cms": 508.0
},
{
"stage": 7,
"level": 4.6,
"discharge_cms": 558.0
"stage": 2,
"level": 4.5,
"discharge_cms": 538.0
},
{
"stage": 3,
"level": 4.7,
"discharge_cms": 564.0
},
{
"stage": 4,
"level": 5.0,
"discharge_cms": 604.0
},
{
"stage": 5,
"level": 5.3,
"discharge_cms": 656.0
}
]
},
+3 -2
View File
@@ -2,8 +2,9 @@
//!
//! For stations whose HII twin is the same sensor, hours missing from the RID
//! series (or carrying no level) are filled from HII, shifted by the median
//! offset between the two series over their overlap. This is also what keeps
//! the forecast current at night while RID's report lags.
//! offset between the two series over their overlap. ThaiWater relays RID's
//! own telemetry, so this repairs holes in RID's report but cannot bridge
//! RID's overnight pause (the mirror arrives with the same delay).
use crate::config::config;
use crate::grid::{Row, floor_hour};
Binary file not shown.
Binary file not shown.
Binary file not shown.
+71
View File
@@ -0,0 +1,71 @@
"""Build src/static/flood-marks-2024.json from CMU Water Center's flood marks.
After the October 2024 flood (P.1 peaked at 5.30 m on 5 October), CMU Water
Center marked the high-water line at about 5,000 places in Chiang Mai and
Lamphun (CMFlood, "Flood marks 2024"): the depth above the road surface, in
cm. This keeps position and depth only, in a compact array form:
uv run python scripts/build_flood_marks.py # fetch from CMU
uv run python scripts/build_flood_marks.py --geojson F # or a saved copy
"""
import argparse
import json
import urllib.request
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
OUT = REPO / "src" / "static" / "flood-marks-2024.json"
URL = "https://watercenter.cmu.ac.th/data/New_floodMark_2025_fixed_like_pole.geojson"
SOURCE = (
"CMU Water Center (ศูนย์วิชาการสนับสนุนการบริหารจัดการน้ำ มหาวิทยาลัยเชียงใหม่), "
"flood marks of October 2024, "
"https://watercenter.cmu.ac.th/chiangmai/cmflood/warning/floodmark"
)
def load(path: Path | None) -> dict:
if path:
return json.loads(path.read_text(encoding="utf-8"))
req = urllib.request.Request(URL, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=120) as r:
return json.loads(r.read().decode("utf-8"))
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--geojson", type=Path, help="read CMU's GeoJSON from here")
args = ap.parse_args()
marks, skipped = [], 0
for f in load(args.geojson)["features"]:
p = f.get("properties") or {}
lon, lat, depth = p.get("longitude"), p.get("latitude"), p.get("water_level")
if lon is None or lat is None or depth is None:
skipped += 1
continue
# 18-19.5 N, 98.5-99.5 E: a swapped or empty coordinate is skipped
if not (18.0 < lat < 19.5 and 98.5 < lon < 99.5) or depth <= 0:
skipped += 1
continue
marks.append([round(lon, 5), round(lat, 5), round(depth)])
marks.sort(key=lambda m: (m[1], m[0]))
doc = {
"source": SOURCE,
"p1_level": 5.30,
"date": "2024-10-05",
"fields": ["lon", "lat", "depth_cm_above_road"],
"marks": marks,
}
OUT.write_text(
json.dumps(doc, ensure_ascii=False, separators=(",", ":")) + "\n",
encoding="utf-8",
)
print(
f"wrote {OUT.relative_to(REPO)}: {len(marks)} marks, {skipped} skipped, "
f"{OUT.stat().st_size / 1024:.0f} KB"
)
if __name__ == "__main__":
main()
+151
View File
@@ -0,0 +1,151 @@
# /// script
# requires-python = ">=3.11"
# dependencies = ["shapely>=2.0"]
# ///
"""Build src/static/flood-zones-p1.geojson from CMU Water Center's flood map.
CMU's Chiang Mai flood hazard map (watercenter.cmu.ac.th, CMFlood, built from
the October 2024 flood) publishes the flooded area at five P.1 levels as KML:
/data/KML5Layer/L1.kml (P.1 = 4.30 m) to L5.kml (5.30 m, the 2024 record).
Each file is the whole extent at that level. The dashboard wants one ring per
stage instead: zone k is what floods first when P.1 reaches stage k's level
(the extent at level k minus everything flooded at a lower level).
uv run scripts/build_flood_zones.py # fetch the KML from CMU
uv run scripts/build_flood_zones.py --kml-dir D # or read L1..L5.kml from D
The levels must match P1_FLOOD_STAGES in src/ml/features.py.
"""
import argparse
import json
import re
import urllib.request
from pathlib import Path
import shapely
from shapely import make_valid
from shapely.geometry import MultiPolygon, Polygon, mapping
from shapely.ops import unary_union
REPO = Path(__file__).resolve().parents[1]
OUT = REPO / "src" / "static" / "flood-zones-p1.geojson"
BASE = "https://watercenter.cmu.ac.th/data/KML5Layer"
LEVELS = [4.30, 4.50, 4.70, 5.00, 5.30] # L1..L5, P.1 gauge level in m
# ~3 m at Chiang Mai: invisible at the zoom levels the map allows, and it
# keeps the file small. Slivers below MIN_AREA (about 300 m²) come from the
# layers' borders not lining up exactly and are dropped.
SIMPLIFY_DEG = 3e-5
MIN_AREA_DEG2 = 2.5e-8
GRID = 1e-5 # coordinates snapped to 5 decimals (~1 m) with valid geometry
SOURCE = (
"CMU Water Center (ศูนย์วิชาการสนับสนุนการบริหารจัดการน้ำ มหาวิทยาลัยเชียงใหม่), "
"Chiang Mai flood hazard map from the October 2024 flood, "
"https://watercenter.cmu.ac.th/chiangmai/cmflood/warning/floodmap"
)
def fetch(i: int, kml_dir: Path | None) -> str:
if kml_dir:
return (kml_dir / f"L{i}.kml").read_text(encoding="utf-8")
req = urllib.request.Request(
f"{BASE}/L{i}.kml", headers={"User-Agent": "Mozilla/5.0"}
)
with urllib.request.urlopen(req, timeout=60) as r:
return r.read().decode("utf-8")
def ring(text: str) -> list:
pts = []
for tok in text.split():
x, y, *_ = tok.split(",")
pts.append((float(x), float(y)))
return pts
def kml_extent(text: str):
polys = []
for body in re.findall(r"<Polygon>(.*?)</Polygon>", text, re.S):
outer = re.search(
r"<outerBoundaryIs>.*?<coordinates>(.*?)</coordinates>", body, re.S
)
inner = re.findall(
r"<innerBoundaryIs>.*?<coordinates>(.*?)</coordinates>", body, re.S
)
polys.append(
make_valid(Polygon(ring(outer.group(1)), [ring(s) for s in inner]))
)
return unary_union(polys)
def polygons(geom) -> list:
if isinstance(geom, Polygon):
return [geom]
return [
g for g in getattr(geom, "geoms", []) if isinstance(g, (Polygon, MultiPolygon))
]
def clean(geom):
parts = []
for g in polygons(geom):
for p in polygons(g):
if p.area >= MIN_AREA_DEG2:
parts.append(p)
return unary_union(parts)
def rounded(coords):
# the geometry is already on the grid; this only drops float noise
if isinstance(coords, (list, tuple)) and coords and isinstance(coords[0], float):
return [round(coords[0], 5), round(coords[1], 5)]
return [rounded(c) for c in coords]
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--kml-dir", type=Path, help="read L1..L5.kml from here")
args = ap.parse_args()
flooded = None
features = []
for i, level in enumerate(LEVELS, 1):
extent = kml_extent(fetch(i, args.kml_dir))
extent = shapely.set_precision(
make_valid(extent.simplify(SIMPLIFY_DEG, preserve_topology=True)), GRID
)
if flooded is None:
new, flooded = extent, extent
else:
new = shapely.difference(extent, flooded, grid_size=GRID)
flooded = shapely.union(flooded, extent, grid_size=GRID)
zone = shapely.set_precision(clean(new), GRID)
assert zone.is_valid, f"zone {i} is not a valid geometry"
geo = mapping(zone)
features.append(
{
"type": "Feature",
"properties": {"zone": i, "level": level},
"geometry": {
"type": geo["type"],
"coordinates": rounded(geo["coordinates"]),
},
}
)
print(f"zone {i} (P.1 {level:.2f} m): {len(polygons(zone))} polygons")
doc = {
"type": "FeatureCollection",
"source": SOURCE,
"note": "Zone k floods first when P.1 reaches its level; zones are disjoint.",
# highest zone first, so the zones that flood first are drawn on top
"features": features[::-1],
}
OUT.write_text(
json.dumps(doc, ensure_ascii=False, separators=(",", ":")) + "\n",
encoding="utf-8",
)
print(f"wrote {OUT.relative_to(REPO)}: {OUT.stat().st_size / 1024:.0f} KB")
if __name__ == "__main__":
main()
+25 -4
View File
@@ -1,9 +1,15 @@
"""Serve the working-copy dashboard locally with API calls proxied to the
live server, so browser-side changes can be checked against real data
before deploy. Usage: python scripts/dev_proxy.py [port]"""
before deploy. Usage: python scripts/dev_proxy.py [port]
/ is src/static/dashboard.html, /static/... any file in src/static (with HTTP
range requests, which the map's PMTiles basemap needs), everything else the
live server."""
import http.server
import mimetypes
import os
import re
import sys
import urllib.request
from pathlib import Path
@@ -30,9 +36,21 @@ class Handler(http.server.BaseHTTPRequestHandler):
ctype = (
"application/json"
if f.suffix in (".json", ".geojson")
else "application/octet-stream"
else mimetypes.guess_type(f.name)[0] or "application/octet-stream"
)
self._send(200, ctype, f.read_bytes())
if ctype.startswith("text/"):
ctype += "; charset=utf-8"
body = f.read_bytes()
# Byte ranges, like the server's ServeDir: PMTiles reads the
# basemap in pieces
m = re.fullmatch(r"bytes=(\d+)-(\d*)", self.headers.get("Range", ""))
if m:
start = int(m.group(1))
end = min(int(m.group(2) or len(body) - 1), len(body) - 1)
extra = {"Content-Range": f"bytes {start}-{end}/{len(body)}"}
self._send(206, ctype, body[start : end + 1], extra)
return
self._send(200, ctype, body)
return
try:
req = urllib.request.Request(
@@ -51,9 +69,12 @@ class Handler(http.server.BaseHTTPRequestHandler):
except urllib.error.HTTPError as e:
self._send(e.code, "application/json", e.read())
def _send(self, code, ctype, body):
def _send(self, code, ctype, body, extra=None):
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Accept-Ranges", "bytes")
for name, value in (extra or {}).items():
self.send_header(name, value)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
+28 -10
View File
@@ -20,9 +20,11 @@
# built beforehand as a normal user:
# cargo build --release --locked --manifest-path rust/Cargo.toml
# (Rust >= 1.88 from rustup, plus pkg-config and libssl-dev)
# 4. the Python training environment (.venv, `uv sync --frozen`): in place
# as the user running sudo (who pulled and built), otherwise as root;
# SKIP_UV=1 skips it
# 4. the Python training environment (.venv, `uv sync --frozen`), as the
# owner of .venv (in place; else the user running sudo) or as root for a
# copy. uv is looked up on PATH and in that user's ~/.local/bin and
# ~/.cargo/bin (sudo and root shells often lack them); UV=/path/to/uv
# overrides, SKIP_UV=1 skips the step
# 5. .env: created from .env.example on a fresh install (edit, then re-run);
# WEB_LISTEN and FORECAST_IN_COLLECTOR default to the production values
# when absent
@@ -104,16 +106,32 @@ log "Installing $BIN -> ${APP_DIR}/bin/ping-collector ($("$BIN" --version))"
install -D -m 0755 -o root -g root "$BIN" "${APP_DIR}/bin/ping-collector"
# 4. Python training environment ------------------------------------------------
find_uv() { # user -> path of a uv binary that user can run
local home c
home="$(getent passwd "$1" | cut -d: -f6)"
for c in "${UV:-}" "$(command -v uv 2>/dev/null || true)" "${home}/.local/bin/uv" \
"${home}/.cargo/bin/uv" /root/.local/bin/uv /usr/local/bin/uv /usr/bin/uv; do
[ -n "$c" ] && [ -x "$c" ] && runuser -u "$1" -- test -x "$c" && {
echo "$c"
return 0
}
done
return 1
}
if [ "${SKIP_UV:-0}" != 1 ]; then
uv_user=root # copy mode: handed to the service user below
[ "$in_place" = 1 ] && uv_user="${SUDO_USER:-root}"
log "Syncing the Python training environment (${APP_DIR}/.venv) as $uv_user"
if [ "$uv_user" = root ]; then
(cd "$APP_DIR" && uv sync --python 3.11 --frozen) ||
warn "uv sync failed: the monthly retrain needs ${APP_DIR}/.venv (uv sync --python 3.11 --frozen)"
if [ "$in_place" = 1 ]; then
uv_user="${SUDO_USER:-root}"
[ -d "${APP_DIR}/.venv" ] && uv_user="$(stat -c %U "${APP_DIR}/.venv")"
fi
if uv_bin="$(find_uv "$uv_user")"; then
log "Syncing the Python training environment (${APP_DIR}/.venv) as $uv_user with $uv_bin"
(cd "$APP_DIR" && runuser -u "$uv_user" -- env HOME="$(getent passwd "$uv_user" | cut -d: -f6)" \
"$uv_bin" sync --python 3.11 --frozen) ||
warn "uv sync failed: the monthly retrain needs ${APP_DIR}/.venv (uv sync --python 3.11 --frozen as $uv_user)"
else
runuser -u "$uv_user" -- bash -lc "cd '$APP_DIR' && uv sync --python 3.11 --frozen" ||
warn "uv sync as $uv_user failed: run 'uv sync --python 3.11 --frozen' in $APP_DIR as the owner of .venv"
warn "uv not found for $uv_user (pass UV=/path/to/uv): ${APP_DIR}/.venv not synced"
fi
fi
+17 -14
View File
@@ -22,9 +22,11 @@ logger = logging.getLogger(__name__)
# "*" is the default applied to any station without an explicit override.
# Calibrated 2026-08-10 from the DB's discharge_percent (RID % of channel
# capacity): warning = median level at 75-85% capacity, danger = median level
# at 95-105%. P.1 instead uses the official Chiang Mai inundation map keyed to
# the P.1 gauge: city flooding begins at 3.70 m (stage 1) and reaches most
# districts by 4.20 m (stage 5) — see P1_FLOOD_STAGES.
# at 95-105%. P.1 is keyed to its banks instead: CMU Water Center's flood map
# (2024 flood) has the Ping leave its banks above 4.20 m (the lower, right
# bank is 304.65 m MSL = 4.15 m on the gauge), so danger = 4.20 m and warning
# = 3.70 m, half a metre below the bank like CMU's own gauge page. The city
# floods in stages from 4.30 m, see P1_FLOOD_STAGES.
THRESHOLDS: Dict[str, Tuple[float, float]] = {
"*": (3.0, 4.5),
"P.1": (3.70, 4.20),
@@ -58,18 +60,19 @@ THRESHOLDS: Dict[str, Tuple[float, float]] = {
"P.92": (2.95, 3.60),
}
# Official Chiang Mai flood-onset stages at the P.1 gauge (Nawarat Bridge),
# from the municipal inundation map (พื้นที่ท่วมตัวเมืองเชียงใหม่, events of
# 2548/2554/2565 BE): gauge level in m, RID discharge in m³/s. Each stage
# floods progressively more city zones.
# Chiang Mai flood stages at the P.1 gauge (Nawarat Bridge), from CMU Water
# Center's flood hazard map built on the October 2024 flood (CMFlood,
# watercenter.cmu.ac.th; zones in src/static/flood-zones-p1.geojson, built by
# scripts/build_flood_zones.py): gauge level in m, RID discharge in m³/s on
# RID's 2024 rating. Each stage floods more of the city; stage 5 is the 2024
# record. (The older municipal map, 3.70-4.60 m, is out of date: the city's
# flood defences have changed since.)
P1_FLOOD_STAGES: List[Dict[str, float]] = [
{"stage": 1, "level": 3.70, "discharge_cms": 405},
{"stage": 2, "level": 3.90, "discharge_cms": 438},
{"stage": 3, "level": 4.00, "discharge_cms": 458},
{"stage": 4, "level": 4.10, "discharge_cms": 478},
{"stage": 5, "level": 4.20, "discharge_cms": 493},
{"stage": 6, "level": 4.30, "discharge_cms": 508},
{"stage": 7, "level": 4.60, "discharge_cms": 558},
{"stage": 1, "level": 4.30, "discharge_cms": 508},
{"stage": 2, "level": 4.50, "discharge_cms": 538},
{"stage": 3, "level": 4.70, "discharge_cms": 564},
{"stage": 4, "level": 5.00, "discharge_cms": 604},
{"stage": 5, "level": 5.30, "discharge_cms": 656},
]
FLOOD_STAGES: Dict[str, List[Dict[str, float]]] = {"P.1": P1_FLOOD_STAGES}
+20
View File
@@ -0,0 +1,20 @@
# Basemap
`ping-basin.pmtiles` is the map under the dashboard (`dashboard.html`): a
[Protomaps](https://protomaps.com) vector extract of the Ping basin, zoom 0-12
(drawn larger beyond that), 15 MB. The site serves it like any static file;
the browser reads it in pieces with HTTP range requests (ServeDir answers them,
and so does `scripts/dev_proxy.py`). No tile provider is involved.
Map data © OpenStreetMap contributors (ODbL), shown in the map attribution.
To refresh it (yearly is plenty), with [go-pmtiles](https://github.com/protomaps/go-pmtiles/releases)
and the newest daily build listed at <https://build-metadata.protomaps.dev/builds.json>:
```bash
pmtiles extract https://build.protomaps.com/20261019.pmtiles ping-basin.pmtiles \
--bbox=97.85,16.85,99.95,20.2 --maxzoom=12
```
The renderer (`protomaps-leaflet` 5.1.0 from unpkg, pinned with SRI) draws it
in the page's language (`name:th` / `name:en`) with its `light` and `dark` styles.
Binary file not shown.
+2339 -2124
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
{"source":"CMU Water Center (ศูนย์วิชาการสนับสนุนการบริหารจัดการน้ำ มหาวิทยาลัยเชียงใหม่), flood marks of October 2024, https://watercenter.cmu.ac.th/chiangmai/cmflood/warning/floodmark","p1_level":5.3,"date":"2024-10-05","fields":["lon","lat","depth_cm_above_road"],"marks":[[98.94555,18.58911,131],[98.94474,18.59004,80],[98.95254,18.59092,35],[98.95091,18.59101,112],[98.95117,18.59133,96],[98.945,18.59134,78],[98.95099,18.5914,114],[98.95584,18.59243,43],[98.95553,18.59256,43],[98.95149,18.59275,156],[98.94926,18.59309,90],[98.94829,18.59436,101],[98.94542,18.59467,65],[98.9414,18.5957,73],[98.94884,18.59594,48],[98.94196,18.59609,63],[98.94342,18.59615,40],[98.94203,18.59616,57],[98.94863,18.59685,68],[98.94599,18.59707,40],[98.93865,18.59717,28],[98.94922,18.5972,76],[98.94842,18.59727,50],[98.94964,18.59769,65],[98.94784,18.59786,50],[98.94939,18.59791,52],[98.94599,18.59823,40],[98.94839,18.59839,37],[98.94795,18.59841,70],[98.94994,18.5985,53],[98.946,18.5986,28],[98.94831,18.59893,37],[98.94721,18.59895,30],[98.94985,18.59897,34],[98.94546,18.59921,38],[98.9493,18.59921,41],[98.94614,18.5994,40],[98.94458,18.59948,36],[98.95012,18.59976,68],[98.94604,18.5998,25],[98.94869,18.59982,32],[98.93946,18.6,26],[98.93307,18.60005,30],[98.94893,18.60033,32],[98.93826,18.60035,26],[98.94578,18.60047,40],[98.96498,18.60066,115],[98.93305,18.60128,58],[98.9535,18.60188,72],[98.96283,18.60198,45],[98.95473,18.60223,81],[98.93342,18.60291,27],[98.93735,18.60399,54],[98.93613,18.60457,32],[98.96806,18.60471,92],[98.93583,18.60479,58],[98.9342,18.60505,32],[98.94897,18.60509,40],[98.94952,18.60523,46],[98.94795,18.60572,63],[98.93452,18.6058,33],[98.93398,18.60581,27],[98.95078,18.60626,60],[98.96559,18.60645,58],[98.96518,18.60728,64],[98.96215,18.61133,54],[98.96336,18.61148,54],[98.96375,18.61238,51],[98.94076,18.61313,15],[98.96247,18.61369,20],[98.98529,18.61605,80],[98.98479,18.61629,73],[98.98601,18.61663,45],[98.96084,18.617,25],[98.98441,18.61702,90],[98.98533,18.61703,120],[98.98649,18.61815,50],[98.95499,18.61817,33],[98.95889,18.61887,35],[99.02179,18.61888,33],[98.98757,18.61924,30],[98.98717,18.61935,63],[98.98659,18.61985,76],[99.0227,18.62004,65],[98.98653,18.62013,79],[98.98854,18.62029,35],[98.9509,18.62036,87],[98.95268,18.62046,62],[99.02302,18.62054,46],[99.02241,18.62074,90],[98.95322,18.62094,96],[98.98795,18.62105,68],[98.95306,18.62117,92],[98.95485,18.62136,34],[99.02224,18.62139,90],[99.02348,18.62149,47],[98.94931,18.6217,83],[98.96125,18.62181,33],[98.98974,18.6219,10],[99.02205,18.62209,110],[98.95239,18.62212,118],[99.02371,18.62217,48],[98.95701,18.62224,20],[98.95168,18.62259,111],[99.02178,18.62272,91],[98.95117,18.62296,115],[99.02411,18.62323,31],[98.95138,18.62404,116],[98.97146,18.62411,26],[98.95172,18.62481,102],[99.02475,18.62491,34],[99.02096,18.62497,87],[98.95873,18.62517,23],[99.02063,18.62561,85],[99.02519,18.62589,18],[99.02027,18.62627,90],[99.02005,18.62656,96],[98.9579,18.62667,39],[99.02561,18.6269,32],[99.01972,18.627,96],[99.02061,18.62716,82],[99.02114,18.62764,52],[99.0192,18.62775,95],[99.03071,18.62795,51],[99.0221,18.6282,44],[98.97386,18.62837,27],[99.01877,18.62841,66],[98.94845,18.6285,73],[99.02274,18.6286,46],[98.97247,18.62869,31],[99.02858,18.62871,72],[99.0264,18.62887,43],[99.02042,18.629,100],[99.02338,18.62904,42],[99.027,18.62927,67],[98.9769,18.62936,43],[99.01764,18.62936,42],[99.02406,18.62941,37],[98.97457,18.62951,47],[99.01819,18.62959,47],[99.02118,18.6296,89],[99.02193,18.63011,89],[99.01882,18.63015,57],[98.95574,18.63018,30],[99.02446,18.63022,26],[99.02145,18.63047,70],[99.02672,18.63061,63],[99.01935,18.63069,65],[99.02963,18.63094,47],[99.02071,18.63096,63],[99.03878,18.63102,13],[98.96853,18.63112,36],[99.02138,18.6313,80],[99.01906,18.63131,36],[99.01855,18.63161,40],[99.02381,18.63165,47],[99.02153,18.63204,56],[99.023,18.63204,46],[99.03029,18.63226,38],[99.02,18.63245,30],[99.02705,18.63245,50],[99.03472,18.63255,18],[99.02185,18.63258,35],[99.0252,18.63336,50],[99.03113,18.63384,30],[99.02535,18.63435,41],[98.97782,18.6344,20],[99.03162,18.63441,47],[99.02745,18.63481,43],[98.98087,18.63494,46],[98.98229,18.63499,20],[99.02544,18.63523,40],[99.03221,18.63524,25],[99.02766,18.6359,47],[99.03261,18.63593,42],[99.05652,18.6362,13],[99.03907,18.63682,52],[98.98704,18.63693,30],[98.96901,18.63711,36],[99.0545,18.63725,9],[98.96356,18.6373,30],[99.03327,18.63757,37],[99.02802,18.63761,36],[98.99439,18.63798,30],[99.02567,18.63811,30],[98.98859,18.63813,36],[98.95419,18.63822,80],[98.98457,18.63837,32],[99.02846,18.63857,24],[98.99412,18.63862,34],[99.0342,18.63895,10],[98.96941,18.63904,30],[99.02548,18.63943,23],[99.03287,18.63954,17],[99.02997,18.6398,25],[99.02907,18.63982,11],[99.03358,18.64016,13],[98.99,18.64047,30],[99.02353,18.64072,41],[99.02159,18.64077,12],[98.99208,18.64086,52],[98.97228,18.64092,25],[99.034Line truncated
+1 -1
View File
@@ -1 +1 @@
{"type": "FeatureCollection", "source": "Chiang Mai inundation zones traced by the project owner from the municipal flood map (stages keyed to the P.1 gauge)", "features": [{"type": "Feature", "properties": {"zone": 7, "level": 4.6}, "geometry": {"coordinates": [[[98.99880625810528, 18.784477298167957], [98.99461039320016, 18.784274422928206], [98.99414790361936, 18.781294057606146], [98.99338756491807, 18.77996508784213], [98.99256873862737, 18.77968821782146], [98.98692468597324, 18.779355973196743], [98.98619359107005, 18.778220799120035], [98.98405879395233, 18.777556303429193], [98.98297677349444, 18.776697992618082], [98.98233340997882, 18.77592274038571], [98.98180702164893, 18.77503673346787], [98.98104668294985, 18.774123033955036], [98.9778298653743, 18.769637528153936], [98.97973071834002, 18.767394731388947], [98.98049105704126, 18.766065652036048], [98.98049105704126, 18.765096525071655], [98.98242115331647, 18.762964427873897], [98.98338619858873, 18.76207835285959], [98.98440973145495, 18.761358413482498], [98.98546250811472, 18.760998442642233], [98.98616435919331, 18.760583090595347], [98.98695394168521, 18.76014004736058], [98.98724637964602, 18.759309338262582], [98.98715864825817, 18.757758669801362], [98.98724638064289, 18.755211123039246], [98.98721713684694, 18.753632726146023], [98.98745108721613, 18.75172201541686], [98.99393973813358, 18.748777676714795], [98.99866675103556, 18.747498759225508], [99.0030078853332, 18.746585240801167], [99.00614314912758, 18.74667659260706], [99.00918194313522, 18.74672226862053], [99.01386072121386, 18.74736173151757], [99.0182500903365, 18.74873200099759], [99.02210887637972, 18.749965234019825], [99.02673942254995, 18.751792228986318], [99.03040517274661, 18.75393895756561], [99.03098399049287, 18.757273130646723], [99.02919930154764, 18.76453500551959], [99.0288616577684, 18.766590196376086], [99.02866869279399, 18.770289471074904], [99.02804164057648, 18.774262682143842], [99.02770399693168, 18.776637430154793], [99.02669103598669, 18.780975818695225], [99.025822809126, 18.783761466324705], [99.02345930267506, 18.784355122980827], [99.02350753750017, 18.784948777544088], [99.02418282505863, 18.78526843682765], [99.02519575690206, 18.785725092291386], [99.02577457480851, 18.786410072570334], [99.0255334006813, 18.787277710257186], [99.02403811313752, 18.78942394572998], [99.02283224249948, 18.791204849547142], [99.02220518976799, 18.792163789955964], [99.0211922587178, 18.793442369230405], [99.02042050194001, 18.794720939297036], [99.01955227507938, 18.795953835929012], [99.01897345717293, 18.79682142444355], [99.01831436082307, 18.79972730702255], [99.01777863778688, 18.801147303888698], [99.01702862553361, 18.802871569690467], [99.0166536199107, 18.803581557567696], [99.01504645094093, 18.805356511465817], [99.01413572177813, 18.806167912570658], [99.01242140805891, 18.807182158449038], [99.01092040802666, 18.805891108789595], [99.00797426275983, 18.805985005956316], [99.00526904617476, 18.809089450292632], [99.00167351849208, 18.80961415378073], [99.00199808697452, 18.80540186486514], [99.00143684883221, 18.80078451832455], [98.99833449246728, 18.79662657839105], [98.99910609832347, 18.789740446568647], [98.99880625810528, 18.784477298167957]]], "type": "Polygon"}}, {"type": "Feature", "properties": {"zone": 6, "level": 4.3}, "geometry": {"coordinates": [[[98.99651377950943, 18.761493728814628], [98.99694656013548, 18.7613525083853], [98.99782672066647, 18.762019222629647], [98.99944621598775, 18.762919283242184], [99.00015034441276, 18.763319308389143], [99.00096009210193, 18.764119355836726], [99.00074885357509, 18.764919399489102], [99.00043199578278, 18.765919448717298], [98.99979828020088, 18.767219503847343], [98.99937580314582, 18.7679528638581], [98.99884770914343, 18.770686263907393], [98.9986012640993, 18.772386286222428], [98.9992701861035, 18.773619624795415], [99.00007993379262, 18.774552956095135], [99.00088968148879, 18.775919610459027], [99.00201628690667, 18.778286229521896], [99.00289644735096, 18.779419528368976], [99.00353016293286, 18.780719479383535], [99.0035301629087, 18.78101946665386], [99.00328371795962, 18.781586107819905], [99.00317809869364, 18.782252742043923], [99.00296686019078, 18.782719384448313], [99.00307247949615, 18.783152694110754], [99.0039174336062, 18.783552671243896], [99.0040582592913, 18.783919316114662], [99.00409346571223, 18.78501924594029], [99.00412867251362, 18.785719197922077], [99.00398784682858, 18.786752454497957], [99.00370619591746, 18.78721908506462], [99.00328371923854, 18.78805235017147], [99.00300206786852, 18.78905226213726], [99.00226273302252, 18.7902854787191], [99.00138257249154, 18.79071876888726], [99.00022076059048, 18.790018838061286], [98.99944621932224, 18.789318904325512], [98.99948142574317, 18.78675245574125], [98.99919977437315, 18.78525256508587], [98.99898853584631, 18.78388598641746], [98.99944015986506, 18.782701561531724], [98.99939250851816, 18.780942116278794Line truncated
{"type":"FeatureCollection","source":"CMU Water Center (ศูนย์วิชาการสนับสนุนการบริหารจัดการน้ำ มหาวิทยาลัยเชียงใหม่), Chiang Mai flood hazard map from the October 2024 flood, https://watercenter.cmu.ac.th/chiangmai/cmflood/warning/floodmap","note":"Zone k floods first when P.1 reaches its level; zones are disjoint.","features":[{"type":"Feature","properties":{"zone":5,"level":5.3},"geometry":{"type":"MultiPolygon","coordinates":[[[[98.95482,18.60229],[98.95482,18.60216],[98.95917,18.60088],[98.95459,18.60222],[98.95444,18.60154],[98.95346,18.60186],[98.95307,18.60177],[98.95178,18.60203],[98.94869,18.60221],[98.94905,18.60067],[98.94797,18.59868],[98.94385,18.5997],[98.94379,18.60101],[98.94408,18.60146],[98.94416,18.60196],[98.9446,18.60308],[98.94494,18.6034],[98.94503,18.60371],[98.94482,18.60484],[98.94354,18.60675],[98.94345,18.6078],[98.94461,18.60768],[98.94583,18.60748],[98.94662,18.60717],[98.94763,18.60687],[98.9484,18.60672],[98.94965,18.60677],[98.95044,18.60703],[98.95134,18.60699],[98.95201,18.60603],[98.95214,18.60414],[98.95287,18.60355],[98.95445,18.60278],[98.95475,18.60247],[98.95482,18.60229]]],[[[98.95961,18.59584],[98.95869,18.59578],[98.95763,18.59582],[98.95742,18.5955],[98.95711,18.59523],[98.9564,18.59495],[98.95614,18.59468],[98.95595,18.59438],[98.95586,18.59414],[98.95592,18.59392],[98.95592,18.59283],[98.9559,18.59242],[98.95579,18.59223],[98.95525,18.59174],[98.95481,18.59153],[98.95452,18.59129],[98.95342,18.58989],[98.95314,18.58965],[98.95287,18.58959],[98.95244,18.58932],[98.95038,18.5891],[98.94994,18.58913],[98.94938,18.58908],[98.94895,18.58896],[98.94816,18.5885],[98.94783,18.5885],[98.94749,18.58838],[98.94578,18.58828],[98.946,18.58864],[98.94507,18.58949],[98.94459,18.58978],[98.94296,18.58992],[98.94301,18.59051],[98.94297,18.59073],[98.94134,18.59123],[98.93999,18.59202],[98.93981,18.59204],[98.93855,18.59273],[98.93846,18.59284],[98.93875,18.59325],[98.93875,18.59346],[98.93782,18.59365],[98.93685,18.59446],[98.93651,18.59442],[98.93339,18.59661],[98.93269,18.59765],[98.93216,18.59832],[98.93213,18.59871],[98.93218,18.59913],[98.93195,18.59969],[98.93192,18.60016],[98.93202,18.60057],[98.93167,18.60063],[98.93166,18.60081],[98.9321,18.6013],[98.93213,18.60173],[98.93232,18.60248],[98.93222,18.60275],[98.93225,18.60291],[98.93255,18.60323],[98.93245,18.60353],[98.93247,18.6038],[98.93239,18.60396],[98.93212,18.6042],[98.93205,18.6045],[98.9319,18.60473],[98.93152,18.60505],[98.93145,18.60517],[98.93153,18.60567],[98.93142,18.60582],[98.931,18.60586],[98.93092,18.60601],[98.93086,18.60645],[98.93078,18.60657],[98.93054,18.60672],[98.93039,18.60692],[98.92976,18.60739],[98.92941,18.608],[98.92933,18.60834],[98.92939,18.60871],[98.92929,18.60897],[98.92925,18.60928],[98.92942,18.60957],[98.92945,18.60975],[98.92936,18.60995],[98.92906,18.61005],[98.92901,18.61025],[98.92926,18.61056],[98.92931,18.61069],[98.92902,18.611],[98.92862,18.61115],[98.9284,18.61128],[98.92808,18.61165],[98.92786,18.61201],[98.9276,18.61212],[98.92745,18.61234],[98.92723,18.61233],[98.92705,18.61242],[98.92693,18.61264],[98.92695,18.61305],[98.92658,18.61358],[98.92642,18.61409],[98.92629,18.61423],[98.92601,18.61432],[98.9256,18.61466],[98.92568,18.61499],[98.92563,18.61509],[98.92534,18.61532],[98.92535,18.61543],[98.9256,18.6157],[98.92535,18.61601],[98.92529,18.61636],[98.92502,18.61661],[98.92501,18.61697],[98.92489,18.61732],[98.9247,18.61757],[98.9247,18.61764],[98.92482,18.61764],[98.92497,18.61775],[98.9249,18.61809],[98.92509,18.61832],[98.92471,18.61858],[98.92475,18.61868],[98.92469,18.61875],[98.92492,18.61907],[98.92463,18.6192],[98.92463,18.6194],[98.92507,18.62013],[98.92517,18.62024],[98.92562,18.62043],[98.92566,18.62049],[98.92559,18.62065],[98.92581,18.62094],[98.92568,18.62113],[98.92566,18.62124],[98.9257,18.62135],[98.92584,18.62136],[98.92588,18.62141],[98.92596,18.62194],[98.92602,18.62203],[98.92584,18.62231],[98.92591,18.62242],[98.92591,18.62257],[98.93644,18.59744],[98.93965,18.59502],[98.94937,18.58922],[98.95029,18.58928],[98.95245,18.58954],[98.95313,18.59072],[98.95542,18.5928],[98.95579,18.59472],[98.95758,18.59591],[98.95997,18.59598],[98.96048,18.59634],[98.96162,18.59702],[98.96387,18.5978],[98.96508,18.59839],[98.96552,18.59906],[98.96601,18.59926],[98.96603,18.59925],[98.9653,18.59823],[98.96475,18.59772],[98.9642,18.59737],[98.96194,18.59652],[98.96116,18.59618],[98.96014,18.59556],[98.95961,18.59584]]],[[[98.96682,18.60731],[98.96707,18.60715],[98.96783,18.60633],[98.96682,18.60731]]],[[[98.96642,18.61255],[98.9664,18.61234],[98.96607,18.61181],[98.96568,18.61141],[98.9661,18.6119],[98.96639,18.6124],[98.96642,18.61255]]],[[[98.96632,18.61356],[98.96643,18.61329],[98.96584,18.61408],[98.96632,18.61356]]],[[[98.96374,18.61492],[98.9635,18.61506],[98.96365,18.61502],[98.96369,18.615],[98.96378,18.61499],[98.96426,18.61487],[98.96374,18.61Line truncated
+13
View File
@@ -0,0 +1,13 @@
# Fonts
Self-hosted so the dashboard makes no request to Google. Both fonts are under the
SIL Open Font License 1.1 (`*-OFL.txt` next to them).
| File | Font | Subset |
|---|---|---|
| `inter-latin.woff2` | Inter (variable, weight 400–700), <https://github.com/rsms/inter> | Latin |
| `noto-sans-thai-thai.woff2` | Noto Sans Thai (variable, weight 400–700), <https://github.com/notofonts/thai> | Thai |
Both files are Google Fonts' woff2 subsets (`fonts.googleapis.com/css2?family=Inter:wght@400..700`
and `family=Noto+Sans+Thai:wght@400..700`, fetched with a woff2-capable user agent).
The `unicode-range` of each `@font-face` in `dashboard.html` matches its subset.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/thai)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+151 -187
View File
@@ -1,47 +1,20 @@
"""The dashboard served at / (src/static/dashboard.html, docs/DASHBOARD_REDESIGN.md).
Rules: every fetched path is routed, every string has EN and TH (Thai first),
no emoji in the interface, and a replay or a simulation is never shown as
live data.
"""
import re
from pathlib import Path
DASHBOARD_PATH = Path(__file__).parents[1] / "src" / "static" / "dashboard.html"
ROUTER_PATH = (
Path(__file__).parents[1] / "rust" / "collector" / "src" / "web" / "mod.rs"
)
STATIC = Path(__file__).parents[1] / "src" / "static"
DASHBOARD = STATIC / "dashboard.html"
ROUTER = Path(__file__).parents[1] / "rust" / "collector" / "src" / "web" / "mod.rs"
def _rust_routes() -> set:
"""Paths the website serves (ping-collector's axum router)."""
source = ROUTER_PATH.read_text(encoding="utf-8")
return set(re.findall(r'\.route\(\s*"([^"]+)"', source))
def test_every_api_path_the_dashboard_fetches_is_served():
html = DASHBOARD_PATH.read_text(encoding="utf-8")
routes = _rust_routes()
patterns = [
re.compile("^" + re.sub(r"\\\{[^}]+\\\}", "[^/]+", re.escape(r)) + "$")
for r in routes
]
fetched = set(re.findall(r"fetch\(\s*[`'\"](/[^`'\"?$]*)", html))
assert fetched, "no fetch() calls found"
missing = [
path
for path in fetched
if not path.startswith("/static/")
and not any(p.match(path.rstrip("/") or "/") for p in patterns)
and not any(r.startswith(path) for r in routes)
]
assert not missing, f"dashboard fetches paths the server does not route: {missing}"
def test_dashboard_contains_live_map_and_flow_visualization():
html = DASHBOARD_PATH.read_text(encoding="utf-8")
assert 'id="station-map"' in html
assert 'id="river-flow"' in html
assert "fetch('/stations')" in html or 'fetch("/stations")' in html
assert (
"fetch('/measurements/latest" in html or 'fetch("/measurements/latest' in html
)
assert "leaflet" in html.lower()
def _html() -> str:
return DASHBOARD.read_text(encoding="utf-8")
def _body(html: str) -> str:
@@ -50,92 +23,15 @@ def _body(html: str) -> str:
return html[html.index("<body>") : html.index("<script src=")]
def test_dashboard_explains_flow_legend_and_refresh():
body = _body(DASHBOARD_PATH.read_text(encoding="utf-8"))
assert 'data-i18n="legend.flow"' in body
assert 'data-i18n="stat.updated"' in body
assert 'data-i18n="action.refresh"' in body
def _routes() -> set:
"""Paths the website serves (ping-collector's axum router)."""
return set(re.findall(r'\.route\(\s*"([^"]+)"', ROUTER.read_text("utf-8")))
def test_dashboard_uses_mapped_river_network_instead_of_station_connections():
html = DASHBOARD_PATH.read_text(encoding="utf-8")
river_network = DASHBOARD_PATH.with_name("ping-river-network.geojson")
assert river_network.exists()
assert "fetch('/static/ping-river-network.geojson')" in html
assert "mainBasin.map" not in html
def test_dashboard_loads_additional_thaiwater_sensors():
html = DASHBOARD_PATH.read_text(encoding="utf-8")
assert "fetch('/sensors/thaiwater')" in html
assert 'data-i18n="sensors.title"' in _body(html)
assert 'id="station-search"' in html
assert "applyStationSearch" in html
def test_dashboard_has_seo_and_indexing_files():
html = DASHBOARD_PATH.read_text(encoding="utf-8")
static_dir = DASHBOARD_PATH.parent
assert '<meta name="description"' in html
assert '<link rel="canonical" href="https://water.buildfor.life/">' in html
assert 'property="og:title"' in html
assert 'rel="icon"' in html
robots = (static_dir / "robots.txt").read_text(encoding="utf-8")
assert "Sitemap: https://water.buildfor.life/sitemap.xml" in robots
assert "<loc>https://water.buildfor.life/</loc>" in (
static_dir / "sitemap.xml"
).read_text(encoding="utf-8")
assert "Ping River Live Monitor" in (static_dir / "llms.txt").read_text(
encoding="utf-8"
)
assert {"/robots.txt", "/llms.txt", "/sitemap.xml"} <= _rust_routes()
def test_dashboard_shows_hii_rainfall_layer():
html = DASHBOARD_PATH.read_text(encoding="utf-8")
assert "fetch('/api/hii/rainfall/latest')" in html
assert "fetch('/api/hii/waterlevel/latest')" in html
assert "renderRainLayer" in html
assert "rain-toggle" in html
body = _body(html)
assert 'data-i18n="legend.rain"' in body
# TMD rain classes on the legend
assert 'data-i18n="legend.rain.heavy"' in body
assert 'data-i18n="legend.rain.extreme"' in body
def test_dashboard_loads_station_history_chart():
html = DASHBOARD_PATH.read_text(encoding="utf-8")
assert 'id="history-card"' in html
assert "/api/forecast/history/" in html
assert "'chart.model'" in html
assert "PostgreSQL" not in html
assert "/measurements/history/" in html
assert "history-chart" in html
# Date-range picker alongside the quick-range dropdown
assert 'id="history-start"' in html
assert 'id="history-end"' in html
assert 'data-i18n="range.7d"' in _body(html)
assert 'data-i18n="range.90d"' in _body(html)
def _extract_lang_tables(html: str) -> dict:
"""Pull the `en:` / `th:` key sets out of the STRINGS literal.
Parsing the JS with a regex is crude, but it is enough to catch the failure
that matters: a key added to one language and forgotten in the other, which
silently falls back to English for Thai readers.
"""
import re
def _tables(html: str) -> dict:
"""The `en:` / `th:` key sets of the STRINGS literal. Crude, but it
catches a key added to one language and forgotten in the other, which
silently falls back to English for Thai readers."""
start = html.index("const STRINGS = {")
end = html.index("// Thai unless the visitor's browser", start)
block = html[start:end]
@@ -147,86 +43,154 @@ def _extract_lang_tables(html: str) -> dict:
return tables
def test_dashboard_translations_cover_both_languages():
html = DASHBOARD_PATH.read_text(encoding="utf-8")
tables = _extract_lang_tables(html)
assert len(tables["en"]) > 100, "expected the full English string table"
missing_th = tables["en"] - tables["th"]
missing_en = tables["th"] - tables["en"]
assert not missing_th, f"keys missing a Thai translation: {sorted(missing_th)}"
assert (
not missing_en
), f"Thai-only keys with no English fallback: {sorted(missing_en)}"
def test_every_fetched_path_is_routed():
routes = _routes()
patterns = [
re.compile("^" + re.sub(r"\\\{[^}]+\\\}", "[^/]+", re.escape(r)) + "$")
for r in routes
]
fetched = set(re.findall(r"fetch\(\s*[`'\"](/[^`'\"?$]*)", _html()))
assert {"/stations", "/forecast", "/api/notifications"} <= fetched
missing = [
p
for p in fetched
if not p.startswith("/static/")
and not any(r.match(p.rstrip("/") or "/") for r in patterns)
and not any(r.startswith(p) for r in routes)
]
assert not missing, f"dashboard fetches paths the server does not route: {missing}"
def test_dashboard_i18n_markup_keys_exist():
"""Every data-i18n attribute must resolve to a real string key."""
import re
def test_static_files_it_loads_exist():
paths = re.findall(r"/static/([\w./-]+\.(?:geojson|json|woff2|pmtiles))", _html())
assert "ping-river-network.geojson" in paths # mapped rivers, not lines
assert "flood-2024-all.json" in paths
for path in paths:
assert (STATIC / path).is_file(), path
html = DASHBOARD_PATH.read_text(encoding="utf-8")
keys = _extract_lang_tables(html)["en"]
def test_translations_cover_both_languages():
tables = _tables(_html())
assert len(tables["en"]) > 100
assert not tables["en"] - tables["th"], sorted(tables["en"] - tables["th"])
assert not tables["th"] - tables["en"], sorted(tables["th"] - tables["en"])
def test_markup_keys_exist():
html = _html()
used = set(re.findall(r'data-i18n(?:-placeholder|-title|-aria)?="([^"]+)"', html))
unknown = used - keys
assert not unknown, f"markup references undefined string keys: {sorted(unknown)}"
assert not used - _tables(html)["en"], sorted(used - _tables(html)["en"])
def test_dashboard_is_mobile_portrait_safe():
html = DASHBOARD_PATH.read_text(encoding="utf-8")
# The header row overflowed a 412 px Android viewport by 64 px until it wrapped
assert "flex-wrap: wrap" in html
assert "overflow-x: hidden" in html
assert 'id="lang-toggle"' in html
assert "ping-monitor-lang" in html # remembered language choice
def test_dashboard_translates_user_visible_aria_labels():
def test_aria_labels_are_translated():
"""A Thai page must not hand screen-reader users English landmarks."""
import re
html = DASHBOARD_PATH.read_text(encoding="utf-8")
body = _body(html)
for match in re.finditer(r'<[^>]*\saria-label="[^"]+"[^>]*>', body):
tag = match.group(0)
if "data-i18n-aria" in tag or 'id="lang-toggle"' in tag:
continue # the toggle sets its own label per language in JS
raise AssertionError(f"aria-label without a translation key: {tag[:120]}")
for match in re.finditer(r'<[^>]*\saria-label="[^"]+"[^>]*>', _body(_html())):
assert "data-i18n-aria" in match.group(0), match.group(0)[:120]
def test_dashboard_keeps_simulation_and_replay_labels_on_language_switch():
"""Relabelling a pinned simulation as LIVE would present fake flood data as real."""
html = DASHBOARD_PATH.read_text(encoding="utf-8")
assert "setLiveIndicator(state.liveMode || 'live', state.liveLabelKey)" in html
assert "if (!state.replayTimer) setLiveIndicator('live');" not in html
def test_no_emoji_in_the_interface():
emoji = re.compile("[\U0001f000-\U0001faff\u2600-\u27bf\u2b00-\u2bff\ufe0f]")
found = sorted(set(emoji.findall(_html())))
assert not found, f"emoji in dashboard.html: {found}"
def test_dashboard_default_language_respects_browser_order():
"""navigator.languages = ['th-TH','en-US'] must resolve to Thai, not English."""
html = DASHBOARD_PATH.read_text(encoding="utf-8")
assert "langs.some" not in html # the old any-English-wins test
def test_thai_first_and_river_time():
"""navigator.languages = ['th-TH','en-US'] must resolve to Thai."""
html = _html()
assert "if (code.startsWith('th')) return 'th';" in html
assert "ping-monitor-lang" in html # remembered language choice
assert "const TZ = 'Asia/Bangkok';" in html
assert "'+07:00'" in html # naive API timestamps are pinned to Bangkok
def test_dashboard_shows_one_current_river_level():
"""The verdict banner and the P.1 outlook must not disagree about "now".
def test_phone_safe():
html = _html()
assert "overflow-x: hidden" in html
assert 'name="viewport"' in html
Production served 1.66 m in the banner and 1.52 m in the outlook at the
same moment: the banner used the latest measurement, the outlook used the
forecast payload's current_level from an older as_of.
def test_fonts_are_self_hosted_with_their_licence():
html = _html()
assert "fonts.googleapis.com" not in html
for name in ("inter-latin.woff2", "noto-sans-thai-thai.woff2"):
assert f"/static/fonts/{name}" in html
assert (STATIC / "fonts" / "inter-OFL.txt").is_file()
assert (STATIC / "fonts" / "noto-sans-thai-OFL.txt").is_file()
def test_basemap_is_self_hosted():
html = _html()
assert "/static/basemap/ping-basin.pmtiles" in html
assert "protomaps-leaflet@5.1.0" in html and 'integrity="sha384-' in html
# the attribution OpenStreetMap's licence requires
assert "openstreetmap.org/copyright" in html
def test_basin_stations_and_rain_have_their_feeds():
html = _html()
assert "fetch('/api/hii/waterlevel/latest')" in html
assert "fetch('/sensors/thaiwater')" in html # fallback feed
assert "fetch('/api/hii/rainfall/latest')" in html
def test_history_reaches_today():
"""/measurements/history returns the oldest rows first and stops at its
limit (50,000 by default): "All" must ask for more, or P.1 ends in 2024."""
html = _html()
assert "hours=${hours}&limit=100000" in html
assert "/api/forecast/history/" in html
# the issued line is the model's; a heuristic row (the level itself) dips
assert "r.predicted_max_level != null && r.source === 'model'" in html
def test_skill_loads_only_when_opened():
html = _html()
assert "fetch('/api/forecast/skill?station_code=P.1&horizon=24')" in html
assert "sk.addEventListener('toggle'" in html
def test_one_current_river_level():
"""The verdict and the P.1 outlook must not disagree about "now".
The old dashboard once showed 1.66 m in the banner and 1.52 m in the
outlook: the outlook used the forecast payload's current_level from an
older as_of. Every view reads the gauge's level from one station record.
"""
html = DASHBOARD_PATH.read_text(encoding="utf-8")
html = _html()
assert "current_level" not in html
assert "const p1 = state.byCode.get('P.1');" in html
# One arbitrated writer, freshest-wins
assert "function setP1Level(" in html
assert "state.p1NowAt" in html
# Neither feed may assign the level directly any more
assert "state.p1Now = Number(p1.water_level)" not in html
assert "state.p1Now = row.current_level" not in html
def test_replay_and_simulation_are_never_shown_as_live():
"""Relabelling a replay or a pinned simulation as live would present
2024 or invented flood data as today's."""
html = _html()
# the mode bar and the data-age KPI are drawn from state.mode alone, and
# redrawn on a language switch
assert "bar.hidden = state.mode === 'live';" in html
assert "if (newest && state.mode === 'live') {" in html
assert "renderStats();\n renderModebar();\n }" in html
# the 5-minute refresh would replace the replay frame with today's data
assert "if (state.mode !== 'replay') { load(); loadStats(); }" in html
# the demo hooks
assert "params.get('demo_level')" in html and "params.has('demo_rise')" in html
for key in ("mode.replay", "mode.sim", "sim.pinned", "sim.rising"):
assert f"'{key}':" in html
# The outlook renders the arbitrated level, and never a peak below it
assert "const shownNow = state.p1Now != null" in html
assert "Math.max(Number(row.predicted_max_level), shownNow)" in html
def test_seo_and_indexing_files():
html = _html()
assert '<meta name="description"' in html
assert '<link rel="canonical" href="https://water.buildfor.life/">' in html
assert 'property="og:title"' in html
assert "application/ld+json" in html
assert 'rel="icon"' in html
assert "noindex" not in html
robots = (STATIC / "robots.txt").read_text(encoding="utf-8")
assert "Sitemap: https://water.buildfor.life/sitemap.xml" in robots
sitemap = (STATIC / "sitemap.xml").read_text(encoding="utf-8")
assert "<loc>https://water.buildfor.life/</loc>" in sitemap
llms = (STATIC / "llms.txt").read_text(encoding="utf-8")
assert "Ping River Live Monitor" in llms
assert {"/robots.txt", "/llms.txt", "/sitemap.xml"} <= _routes()
Generated
+4 -4
View File
@@ -1013,7 +1013,7 @@ requires-dist = [
{ name = "numpy", specifier = ">=1.24,<2" },
{ name = "pandas", specifier = "==2.0.3" },
{ name = "psycopg2-binary", specifier = "==2.9.9" },
{ name = "python-dotenv", specifier = "==1.1.1" },
{ name = "python-dotenv", specifier = "==1.2.2" },
{ name = "requests", specifier = "==2.34.2" },
{ name = "scikit-learn", specifier = "==1.9.0" },
{ name = "sqlalchemy", specifier = "==2.0.23" },
@@ -1338,11 +1338,11 @@ wheels = [
[[package]]
name = "python-dotenv"
version = "1.1.1"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" },
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]