Compare commits
12
Commits
6fe2e287e5
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a40faf3102 | ||
|
|
9bf904b23c | ||
|
|
a610abcdb8 | ||
|
|
701bb87d34 | ||
|
|
180d95c6bd | ||
|
|
cf10f2e4c8 | ||
|
|
ffd3030352 | ||
|
|
71cc1b0ca7 | ||
|
|
301a629b42 | ||
|
|
0e30ddbcb6 | ||
|
|
7f29b1831e | ||
|
|
24998b033c |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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/).
|
||||
|
||||

|
||||
|
||||
[](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/actions)
|
||||
[](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/actions)
|
||||
[](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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
//!
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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
@@ -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());
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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.
BIN
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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 @@
|
||||
{"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
|
||||
@@ -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.
|
||||
@@ -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.
@@ -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
@@ -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()
|
||||
@@ -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]]
|
||||
|
||||
Reference in new issue
Block a user