Compare commits

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

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

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

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

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

The dashboard draws only model rows on the issued line.

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

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

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

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

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

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

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

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

Removed (~17k lines): the FastAPI app (web_api, schemas, health_check,
metrics, postgres_history, forecast_history, thaiwater), the Python RID
collector and CLI (water_scraper_v3, main, run.py), the notifier and Matrix
alerting (notify, alerting), the multi-backend adapters (SQLite, MySQL,
InfluxDB, VictoriaMetrics) with demo databases, models/validators/exceptions/
logging/rate limiter, ml/skill.py (served by Rust now), the migration tooling
(api_parity, collector_parity, e2e_notify, install_collector.sh,
gen_notify_cases.py, spikes/rust-forecast), Docker/compose/Grafana files,
sql/, the release workflow and the docs for all of that. Their tests went
with them.

Kept in Python: src/ml (training, evaluation harness, export; predict.py as
the reference the Rust forecaster is tested against), the HII and RID-dam
clients/stores for the archive backfills (their live collectors removed),
the backfill scripts, retrain.sh + timer, forecast_parity.py.

- PostgreSQL only: src/config.py is database_url() from POSTGRES_* / .env;
  the stores lost their sqlite/mysql branches (and the 2026-08 column
  migration). The database tests run against a throwaway schema from
  TEST_DATABASE_URL (tests/conftest.py pg_url, skipped without it), as the
  Rust tests do; CI gets a PostgreSQL service. 100 tests (66 without a DB).
- The notifier settings moved from src/notify.py into src/ml/export.py;
  flood_config.json regenerates byte-identical.
- tests/test_dashboard.py checks every path the dashboard fetches against
  ping-collector's router.
- Dependencies: fastapi, uvicorn, pydantic, influxdb, pymysql, psutil,
  schedule, sphinx, pyinstaller, mypy, pytest-asyncio dropped; python-dotenv
  1.1.1 explicit (it came in through uvicorn[standard]).
- Formatting contract extended to scripts/ and tests/ (reformatted once).
- scripts/install.sh is the one installer (fresh host or update, in place in
  a checkout without touching its ownership): binary, units, uv sync, .env
  defaults, removes the retired water-monitor.service and any old
  --alert-check cron job, dry run + forecast check, restart, readiness wait.
- Docs, CLAUDE.md, CONTRIBUTING.md, .env.example, Makefile, unit comments
  and Rust doc comments updated to the Rust service.
2026-10-18 17:15:07 +07:00
151 changed files with 5777 additions and 22003 deletions

No files matched your search

+66 -119
View File
@@ -1,141 +1,88 @@
# Northern Thailand Ping River Monitor Configuration
# Copy this file to .env and customize for your environment
# Ping River Monitor configuration: copy to .env and fill in.
#
# Read by ping-collector (the service; rust/collector/src/config.rs) and by the
# Python training tools (src/config.py). Under systemd the variables come from
# EnvironmentFile= (systemd's parser); for manual runs both programs load
# ./.env with python-dotenv's rules. Unset keys take the defaults shown.
# Database Configuration
DB_TYPE=postgresql
# Options: sqlite, mysql, postgresql, influxdb, victoriametrics
# SQLite Configuration (default)
WATER_DB_PATH=water_levels.db
# VictoriaMetrics Configuration
VM_HOST=localhost
VM_PORT=8428
VM_URL=
# InfluxDB Configuration
INFLUX_HOST=localhost
INFLUX_PORT=8086
INFLUX_DATABASE=ping_river_monitoring
INFLUX_USERNAME=
INFLUX_PASSWORD=
# PostgreSQL Configuration (Remote Server)
# Option 1: Full connection string (URL encode special characters in password)
POSTGRES_CONNECTION_STRING=postgresql://username:url_encoded_password@your-postgres-host:5432/water_monitoring
# Option 2: Individual components (password will be automatically URL encoded)
POSTGRES_HOST=your-postgres-host
# --- Database (PostgreSQL only) ---------------------------------------------
# Either a full connection string (URL-encode special characters in the
# password: : %3A @ %40 # %23 ? %3F & %26 / %2F % %25) ...
POSTGRES_CONNECTION_STRING=
# ... or the parts (the password is encoded for you)
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_DB=water_monitoring
POSTGRES_USER=username
POSTGRES_PASSWORD=your:password@with!special#chars
POSTGRES_USER=postgres
POSTGRES_PASSWORD=
# Examples for connection string:
# - Local: postgresql://postgres:password@localhost:5432/water_monitoring
# - Remote: postgresql://user:pass@192.168.1.100:5432/water_monitoring
# - With special chars: postgresql://user:my%3Apass%40word@host:5432/db
# - With SSL: postgresql://user:pass@host:port/db?sslmode=require
# - Connection pooling: postgresql://user:pass@host:port/db?pool_size=20&max_overflow=0
# --- What the service runs ---------------------------------------------------
# Website and API: the address to listen on (empty = no website)
WEB_LISTEN=0.0.0.0:8000
# Forecast + ntfy/Matrix notifications after each collection (false = collect only)
FORECAST_IN_COLLECTOR=true
# Trained models (models/flood_<station>.json.gz from src/ml/export.py)
# MODELS_DIR=models
# Special character URL encoding:
# : → %3A @ → %40 # → %23 ? → %3F & → %26 / → %2F % → %25
# MySQL Configuration
MYSQL_CONNECTION_STRING=mysql://user:password@localhost:3306/ping_river_monitoring
# API Configuration
API_HOST=0.0.0.0
API_PORT=8000
API_WORKERS=1
# Public ThaiWater API key used to add Ping-basin water-level sensors.
THAIWATER_API_KEY=
# Data Collection Settings
SCRAPING_INTERVAL_HOURS=1
# RID poll minute past the hour; retry interval while RID is behind
# --- Collection ----------------------------------------------------------------
# RID is polled this many minutes past each hour and retried every
# RID_RETRY_MINUTES while the current hour is not published yet
RID_POLL_OFFSET_MINUTES=5
RID_RETRY_MINUTES=10
# Days of RID holes the leader re-fetches at startup (0 = off)
# RID reports with holes re-fetched at startup (0 = off); older: `ping-collector fill-gaps all`
GAP_FILL_DAYS_ON_START=14
# false when the Rust ping-collector service collects (rust/collector);
# share the status file between both services for /scraping/status
COLLECTION_ENABLED=true
# SCRAPING_STATUS_FILE=/opt/thailand-water-monitor/collector-status.json
# true: ping-collector also forecasts (models/flood_*.json.gz, rust/forecast)
# and sends the ntfy notifications; this app serves its stored forecast and
# computes itself only when that is older than FORECAST_MAX_AGE_SECONDS.
# Set by scripts/install_collector.sh --cutover-forecast.
FORECAST_IN_COLLECTOR=false
# FORECAST_MAX_AGE_SECONDS=7200
# MODELS_DIR=models
REQUEST_TIMEOUT=30
MAX_RETRIES=3
RETRY_DELAY_SECONDS=60
# Data Retention
DATA_RETENTION_DAYS=365
# Logging Configuration
# ENABLE_HII_COLLECTION=true
# ENABLE_RESERVOIR_COLLECTION=true
# ENABLE_RAIN_COLLECTION=true
# HII_BASIN_CODE=6
# REQUEST_TIMEOUT=30
# Runtime station list written by the admin API; falls back to src/data/stations.json
# STATION_CONFIG_PATH=stations.json
LOG_LEVEL=INFO
LOG_FILE=water_monitor.log
# Security (for production)
SECRET_KEY=your-secret-key-here
API_KEY=your-api-key-here
# Monitoring
ENABLE_METRICS=true
ENABLE_HEALTH_CHECKS=true
# Geographic Settings
TIMEZONE=Asia/Bangkok
DEFAULT_LATITUDE=18.7875
DEFAULT_LONGITUDE=99.0045
# External Services
NOTIFICATION_EMAIL=
SMTP_SERVER=
SMTP_PORT=587
SMTP_USERNAME=
SMTP_PASSWORD=
# Public push notifications via self-hosted ntfy (https://ntfy.sh, single binary).
# Leave NTFY_SERVER empty to disable. Topics published: <prefix>-<station>-warning,
# <prefix>-<station>-danger, <prefix>-warning, <prefix>-danger, <prefix>-p1-outlook,
# <prefix>-status. See docs/NOTIFICATIONS.md.
# --- Notifications (docs/NOTIFICATIONS.md) ------------------------------------
# Public push alerts via self-hosted ntfy; empty NTFY_SERVER = off. Topics:
# <prefix>-<station>-warning|danger, <prefix>-warning|danger,
# <prefix>-p1-outlook, <prefix>-status.
NTFY_SERVER=
# Where the monitor POSTs (defaults to NTFY_SERVER). Use the local ntfy
# address (loopback or Tailscale IP) so publishing does not depend on
# DNS / the reverse proxy being up.
# Where the service POSTs (default NTFY_SERVER): the local ntfy address, so
# publishing does not depend on DNS or the reverse proxy
NTFY_PUBLISH_URL=
NTFY_TOPIC_PREFIX=ping
NTFY_TOKEN=
PUBLIC_URL=https://water.buildfor.life/
# ping-collector notifier: report RID lagging this many hours while ThaiWater
# telemetry is current (0 = never)
# Report RID lagging this many hours while ThaiWater telemetry is current (0 = never)
RID_STALE_ALERT_HOURS=18
# Matrix: ping-collector sends the ntfy transitions to this room too
# (docs/MATRIX_QUICK_START.md); off while token or room is empty
# 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
MATRIX_ACCESS_TOKEN=
MATRIX_ROOM_ID=
# Website and API served by ping-collector (rust/collector/src/web): the
# address to listen on; empty = the Python app serves (port 8000). Set by
# scripts/install_collector.sh --cutover-api.
WEB_LISTEN=
# --- Website and API -------------------------------------------------------------
# X-API-Key for station editing, /config and POST /scrape/trigger (empty = those are off)
ADMIN_API_KEY=
# Comma-separated origins allowed cross-site with credentials (empty: any origin, no credentials)
CORS_ALLOW_ORIGINS=
# ThaiWater API key for /sensors/thaiwater (extra Ping-basin sensors on the map)
THAIWATER_API_KEY=
# Self-hosted Umami: server-side tracking of API calls
# UMAMI_API_URL=https://stats.buildfor.life/api/send
# UMAMI_WEBSITE_ID=00b2be73-8f5f-4400-9029-3be852eb08f7
# UMAMI_TRACK_API=true
# Response cache TTLs (seconds)
# LATEST_CACHE_TTL_SECONDS=45
# HII_CACHE_TTL_SECONDS=120
# HEALTH_CACHE_TTL_SECONDS=30
# A stored forecast older than this is served marked X-Data-Stale: true
# FORECAST_MAX_AGE_SECONDS=7200
# STATIC_DIR=src/static
# ADMIN_API_KEY= # X-API-Key for station editing, /config, /scrape/trigger
# Only for `ping-collector serve` running separately from `run`: the status file
# SCRAPING_STATUS_FILE=collector-status.json
# Grafana Integration
GRAFANA_URL=http://localhost:3000
# Alert Configuration
ALERT_MAX_AGE_HOURS=2
ALERT_CHECK_INTERVAL_MINUTES=15
# Development Settings
DEBUG=false
DEVELOPMENT_MODE=false
# --- Python tools ----------------------------------------------------------------
# Training/evaluation read the database above; this overrides it for them only
# FLOOD_ML_DB_URL=
+25 -15
View File
@@ -1,13 +1,13 @@
name: CI
# What this checks, on every push and PR to master:
# The Python side (src/ml training/evaluation stack, archive backfills), on
# every push and PR to master:
# 1. formatting contract (black + isort, config in pyproject.toml)
# 2. flake8 hard-error gate (syntax, undefined names)
# 3. the pytest suite (synthetic data, no DB/network; ~1 min)
# Docker build / staging / production / perf jobs from the original template
# were removed: there is no registry, no staging host, and production is a
# systemd unit deployed by `git pull` on the server (docs/FLOOD_FORECASTING.md
# section 6, scripts/install.sh). Re-add a job when the thing it deploys exists.
# 3. the pytest suite: synthetic data, no network; the database tests run
# against a throwaway PostgreSQL service (TEST_DATABASE_URL, as in rust.yml)
# The service itself (ping-collector) is checked by rust.yml. Production is a
# systemd unit deployed by `git pull` + scripts/install.sh on the server.
on:
push:
@@ -15,7 +15,7 @@ on:
pull_request:
branches: [master]
schedule:
# daily, catches dependency drift / upstream API changes in the tests
# daily, catches dependency drift
- cron: "0 2 * * *"
workflow_dispatch:
@@ -41,17 +41,30 @@ jobs:
pip install --root-user-action=ignore black==26.5.1 isort==5.12.0 flake8==6.1.0
- name: black
run: black --check --diff src/ *.py
run: black --check --diff src/ scripts/ tests/ rust/forecast/testdata rust/collector/testdata
- name: isort
run: isort --check-only --diff src/ *.py
run: isort --check-only --diff src/ scripts/ tests/
- name: flake8 (errors only)
run: flake8 src/ --count --select=E9,F63,F7,F82 --show-source --statistics
run: flake8 src/ scripts/ tests/ --count --select=E9,F63,F7,F82 --show-source --statistics
test:
name: Test suite
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: ping
POSTGRES_DB: ping
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 20
env:
TEST_DATABASE_URL: postgres://postgres:ping@postgres:5432/ping
steps:
- uses: actions/checkout@v4
@@ -66,10 +79,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip --root-user-action=ignore
pip install --root-user-action=ignore -r requirements.txt
pip install --root-user-action=ignore pytest==9.1.1 pytest-asyncio==0.21.1
pip install --root-user-action=ignore -r requirements.txt pytest==9.1.1
- name: pytest
env:
DB_TYPE: sqlite
run: pytest -q -p no:cacheprovider
run: pytest -q -p no:cacheprovider -rs
+10 -54
View File
@@ -1,36 +1,23 @@
name: Docs
# Checks that the documentation the project actually ships stays consistent:
# - every relative link / image path in docs/*.md and README.md resolves
# inside the repo (external URLs are NOT fetched: localhost examples,
# rate-limited hosts and the Tailscale-era links made that gate permanently
# red, and a 200 on a curl --head proves nothing about a doc anyway)
# - the FastAPI app imports and its OpenAPI schema is exportable (that is
# the reference at https://water.buildfor.life/docs)
# The previous Sphinx/apidoc jobs produced artifacts nobody read and were
# removed. Reference docs live in docs/*.md; the public overview is at
# https://buildfor.life/docs/tooling/ping-river-monitor/.
# Every relative link / image path in the shipped Markdown (README, CLAUDE,
# CONTRIBUTING, rust/README, docs/) resolves inside the repo. External URLs
# are NOT fetched: localhost examples and rate-limited hosts made that gate
# permanently red, and a 200 proves nothing about a doc anyway. The API
# reference (/docs, /openapi.json) is generated by ping-collector from its
# route table and tested in rust/collector/src/web/tests.rs.
on:
push:
branches: [master, develop]
paths:
- "docs/**"
- "README.md"
- "CONTRIBUTING.md"
- "src/web_api.py"
- "src/schemas.py"
- "**.md"
- ".gitea/workflows/docs.yml"
pull_request:
paths:
- "docs/**"
- "README.md"
- "CONTRIBUTING.md"
- "**.md"
workflow_dispatch:
env:
PYTHON_VERSION: "3.11"
jobs:
docs:
name: Validate documentation
@@ -43,7 +30,8 @@ jobs:
python3 - <<'PY'
import re, sys, pathlib
root = pathlib.Path(".")
files = [root / "README.md", root / "CONTRIBUTING.md", *root.glob("docs/**/*.md")]
files = [root / "README.md", root / "CLAUDE.md", root / "CONTRIBUTING.md",
root / "rust" / "README.md", *root.glob("docs/**/*.md")]
link = re.compile(r"!?\[[^\]]*\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
bad = []
for md in files:
@@ -65,35 +53,3 @@ jobs:
sys.exit(1)
print(f"checked {len(files)} files, all relative links resolve")
PY
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: pip
cache-dependency-path: requirements.txt
- name: Install dependencies
run: |
python -m pip install --upgrade pip --root-user-action=ignore
pip install --root-user-action=ignore -r requirements.txt
- name: OpenAPI schema exports
env:
DB_TYPE: sqlite
run: |
python - <<'PY'
import json
from src.web_api import app
spec = app.openapi()
paths = sorted(spec["paths"])
required = {"/forecast", "/measurements/latest", "/measurements/history/{station_code}", "/stations", "/api/stats", "/health"}
missing = required - set(paths)
assert not missing, f"documented endpoints missing from the app: {missing}"
json.dump(spec, open("openapi.json", "w"), indent=1)
print(f"{len(paths)} paths; schema written to openapi.json")
PY
- uses: actions/upload-artifact@v3
with:
name: openapi-${{ github.run_number }}
path: openapi.json
-314
View File
@@ -1,314 +0,0 @@
name: Release - Northern Thailand Ping River Monitor
on:
push:
tags:
- "v*.*.*"
workflow_dispatch:
inputs:
version:
description: "Release version (e.g., v3.1.3)"
required: true
type: string
env:
PYTHON_VERSION: "3.11"
REGISTRY: git.b4l.co.th
IMAGE_NAME: b4l/northern-thailand-ping-river-monitor
# GitHub token for better rate limits and authentication
GH_TOKEN: ${{ secrets.GH_TOKEN }}
jobs:
# Create release
create-release:
name: Create Release
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
token: ${{ secrets.GITEA_TOKEN }}
fetch-depth: 0
- name: Get version
id: version
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
else
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
fi
- name: Generate changelog
id: changelog
run: |
# Generate changelog from git commits
echo "## Changes" > CHANGELOG.md
git log --pretty=format:"- %s" $(git describe --tags --abbrev=0 HEAD^)..HEAD >> CHANGELOG.md || echo "- Initial release" >> CHANGELOG.md
echo "" >> CHANGELOG.md
echo "## Docker Images" >> CHANGELOG.md
echo "- \`${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}\`" >> CHANGELOG.md
echo "- \`${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest\`" >> CHANGELOG.md
- name: Create Release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }}
with:
tag_name: ${{ steps.version.outputs.version }}
release_name: Northern Thailand Ping River Monitor ${{ steps.version.outputs.version }}
body_path: CHANGELOG.md
draft: false
prerelease: false
# Build and test for release
test-release:
name: Test Release Build
runs-on: ubuntu-latest
needs: create-release
strategy:
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12"]
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
token: ${{ secrets.GITEA_TOKEN }}
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip --root-user-action=ignore
pip install --root-user-action=ignore -r requirements.txt
pip install --root-user-action=ignore -r requirements-dev.txt
- name: Run full test suite
run: |
python tests/test_integration.py
python tests/test_station_management.py
python run.py --test
- name: Build Python package
run: |
pip install --root-user-action=ignore build
python -m build
- name: Upload Python package
uses: actions/upload-artifact@v3
with:
name: python-package-${{ matrix.python-version }}
path: dist/
# Build release Docker images
build-release:
name: Build Release Images
runs-on: ubuntu-latest
needs: [create-release, test-release]
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
token: ${{ secrets.GITEA_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ vars.WORKER_USERNAME}}
password: ${{ secrets.CI_BOT_TOKEN }}
- name: Build and push release images
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.create-release.outputs.version }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
labels: |
org.opencontainers.image.title=Northern Thailand Ping River Monitor
org.opencontainers.image.description=Real-time water level monitoring for Ping River Basin
org.opencontainers.image.version=${{ needs.create-release.outputs.version }}
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
env:
GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }}
# Security scan for release
security-scan:
name: Security Scan
runs-on: ubuntu-latest
needs: build-release
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
token: ${{ secrets.GITEA_TOKEN}}
# Test release deployment locally
deploy-release:
name: Test Release Deployment
runs-on: ubuntu-latest
needs: [create-release, build-release, security-scan]
environment:
name: testing
url: http://localhost:8080
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
token: ${{ secrets.GITEA_TOKEN }}
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ vars.WORKER_USERNAME}}
password: ${{ secrets.CI_BOT_TOKEN }}
- name: Deploy to production (Local Test)
run: |
set -euo pipefail
echo "🚀 Testing ${{ needs.create-release.outputs.version }} deployment locally..."
# Create a dedicated network so we can resolve by container name
docker network create ci_net || true
# Pull the built image
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.create-release.outputs.version }}
# Stop & remove any existing container
docker rm -f ping-river-monitor-test 2>/dev/null || true
# Start the container on the user-defined network
docker run -d \
--name ping-river-monitor-test \
--network ci_net \
-p 8080:8000 \
-e LOG_LEVEL=INFO \
-e DB_TYPE=sqlite \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.create-release.outputs.version }}
echo "✅ Container started for testing"
- name: Health check after deployment
run: |
set -euo pipefail
echo "⏳ Waiting for application to start..."
# Pull a curl-only image for probing (keeps your app image slim)
docker pull curlimages/curl:8.10.1
# Helper: curl via a sibling container on the SAME Docker network
probe() {
local url="$1"
docker run --rm --network ci_net curlimages/curl:8.10.1 \
-sS --max-time 5 --connect-timeout 3 -w "HTTP_CODE:%{http_code}" "$url" || true
}
# Wait for /health (up to ~3m 45s)
for i in {1..15}; do
echo "🔍 Attempt $i/15: checking http://ping-river-monitor-test:8000/health"
resp="$(probe http://ping-river-monitor-test:8000/health)"
code="$(echo "$resp" | sed -n 's/.*HTTP_CODE:\([0-9]\+\).*/\1/p')"
body="$(echo "$resp" | sed 's/HTTP_CODE:[0-9]*$//')"
echo "HTTP: ${code:-<none>} | Body: ${body:-<empty>}"
if [ "${code:-}" = "200" ] && [ -n "${body:-}" ]; then
echo "✅ Health endpoint responding successfully"
break
fi
echo "❌ Not ready yet. Showing recent logs…"
docker logs --tail 20 ping-river-monitor-test || true
sleep 15
if [ "$i" -eq 15 ]; then
echo "❌ Health never reached 200. Failing."
exit 1
fi
done
echo "🧪 Testing API endpoints…"
endpoints=("health" "docs" "stations" "metrics")
for ep in "${endpoints[@]}"; do
url="http://ping-river-monitor-test:8000/$ep"
resp="$(probe "$url")"
code="$(echo "$resp" | sed -n 's/.*HTTP_CODE:\([0-9]\+\).*/\1/p')"
if [ "${code:-}" = "200" ]; then
echo "✅ /$ep: OK"
else
echo "❌ /$ep: FAILED (HTTP ${code:-<none>})"
echo "Response: $(echo "$resp" | sed 's/HTTP_CODE:[0-9]*$//')"
exit 1
fi
done
echo "✅ All health checks passed!"
- name: Container logs and cleanup
if: always()
run: |
echo "📋 Container logs:"
docker logs ping-river-monitor-test || true
echo "🧹 Cleaning up test container..."
docker stop ping-river-monitor-test || true
docker rm ping-river-monitor-test || true
echo "📊 Deployment Test Summary:"
echo "Version: ${{ needs.create-release.outputs.version }}"
echo "Image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.create-release.outputs.version }}"
echo "Status: Container tested successfully"
echo "Ready for production deployment"
# Notify stakeholders
notify:
name: Notify Release
runs-on: ubuntu-latest
needs: [create-release, deploy-release]
if: always()
steps:
- name: Notify success
if: needs.deploy-release.result == 'success'
run: |
echo "🎉 Release ${{ needs.create-release.outputs.version }} tested successfully!"
echo "🧪 Local Test: Passed all health checks"
echo "� GDocker Image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.create-release.outputs.version }}"
echo "✅ Ready for production deployment"
# Add notification to Slack, Discord, email, etc.
# curl -X POST -H 'Content-type: application/json' \
# --data '{"text":"🎉 Northern Thailand Ping River Monitor ${{ needs.create-release.outputs.version }} tested and ready for deployment!"}' \
# ${{ secrets.SLACK_WEBHOOK_URL }}
- name: Notify failure
if: needs.deploy-release.result == 'failure'
run: |
echo "❌ Release ${{ needs.create-release.outputs.version }} testing failed!"
echo "Please check the logs and fix issues before production deployment."
# Add failure notification
# curl -X POST -H 'Content-type: application/json' \
# --data '{"text":"❌ Northern Thailand Ping River Monitor ${{ needs.create-release.outputs.version }} testing failed!"}' \
# ${{ secrets.SLACK_WEBHOOK_URL }}
+47 -36
View File
@@ -5,16 +5,20 @@ Guidance for AI coding agents working in this repository.
## What this is
Flood monitoring and forecasting for the Ping River, Chiang Mai. Public dashboard and
API at https://water.buildfor.life/ (never publish the server's private/Tailscale IP).
Production: one systemd unit on a small VPS, `/opt/thailand-water-monitor`, user
`water-monitor`, interpreter `.venv/bin/python` (uv-managed), updated by `git pull`.
API at https://water.buildfor.life/ (never publish the server's private/mesh IP).
Production: one systemd unit, `ping-collector.service` (the Rust binary in `rust/`),
on a small VPS in `/opt/thailand-water-monitor` (a git checkout), user `water-monitor`;
plus the monthly `water-monitor-retrain.timer` (Python, `.venv`, uv-managed). Updated by
`git pull`, `cargo build --release`, `sudo bash scripts/install.sh`.
## Rules
- Python 3.11 only. `uv sync --python 3.11`; run everything as `uv run ...`.
- `make format` (black 88 / isort black profile, config in pyproject.toml) before
committing; CI fails on formatting. `make test` must stay green — tests are
synthetic-data only, never add one that needs the DB or network.
- `make format` (black 88 / isort black profile, config in pyproject.toml; cargo fmt)
before committing; CI fails on formatting. `make test` must stay green. Tests use
synthetic data, never the network; database tests (both languages) run against a
throwaway PostgreSQL schema from `TEST_DATABASE_URL` and skip without it.
- PostgreSQL only. No other database backends.
- Timestamps everywhere are Asia/Bangkok wall-clock with no offset. The dashboard
parses them with `parseTs()` and renders with `timeZone: TZ`; keep it that way.
- Model changes go through the rolling-origin harness (`scripts/evaluate_variants.py`)
@@ -26,38 +30,45 @@ Production: one systemd unit on a small VPS, `/opt/thailand-water-monitor`, user
- 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.
- 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` (database tests need `TEST_DATABASE_URL`). The Rust
collector writes the same tables as the Python collectors: a change to a table,
upsert or parser on one side must be made on the other until the Python side is
retired (check with a live run of both into two databases).
- The forecast is served by Rust (`rust/forecast`) from models trained in Python.
A change to `src/ml/features.py`, `predict.py`, the HII fill or the dam frame
must be ported in the same change: regenerate
`rust/forecast/testdata/gen_forecast_fixtures.py` and keep
`cargo test -p ping-forecast` green. Changing thresholds, stages, leads or
notify constants: `uv run python -m src.ml.export --config` (a test fails
otherwise) and rebuild the collector. Notify texts:
`rust/collector/testdata/gen_notify_cases.py`.
- API routes live in `rust/collector/src/web` (phase 3). Until the FastAPI app is
removed, a route change goes into both, and `scripts/api_parity.py` (both
servers on one database) must report no differences. A route's parameters are
declared once (`web/params.rs`); that list validates and documents (/docs).
-- -D warnings`, `cargo test`; minimum version 1.88
(`CARGO_TARGET_DIR=target/msrv cargo +1.88 check --all-targets --locked`). Read
`.env` only through `rust/collector/src/envfile.rs` (python-dotenv semantics, never
dotenvy: the Python tools read the same file).
- The forecast is served by Rust (`rust/forecast`) from models trained in Python;
`src/ml/predict.py` is the reference it is tested against. A change to
`src/ml/features.py`, `predict.py`, the HII fill or the dam frame must be ported in
the same change: regenerate `rust/forecast/testdata/gen_forecast_fixtures.py` and keep
`cargo test -p ping-forecast` green. Changing thresholds, stages, leads or the
notifier settings (`src/ml/export.py`): `uv run python -m src.ml.export --config`
(a test fails otherwise) and rebuild the collector.
- Notifier texts are pinned by `rust/collector/testdata/notify_python_cases.json.gz`;
a deliberate wording change updates that fixture in the same commit.
- API routes live in `rust/collector/src/web`. A route's parameters are declared once
(`web/params.rs`); that list validates and documents (/docs). Keep response formats
stable: external clients use them.
- The Python backfill stores (`src/hii_collector.py`, `src/rid_reservoir.py`,
`src/ml/rain.py`) write the same tables as `rust/collector/src/db.rs`: a table or
upsert change goes into both.
## Where things are
- `src/web_api.py` FastAPI app; `src/water_scraper_v3.py` RID collector;
`src/hii_collector.py` ThaiWater/HII; `src/ml/` features/train/evaluate/predict,
`rain.py` (Open-Meteo), `dam.py`, `hii_rain.py`.
- `scripts/retrain.sh` + `water-monitor-retrain.timer`: monthly retrain with staged
promote. `scripts/dev_proxy.py`: serve the working-copy dashboard against the live API.
- `rust/collector` (`ping-collector`): Rust data collector; with it deployed the
Python app runs `COLLECTION_ENABLED=false`. With `FORECAST_IN_COLLECTOR=true` it
also forecasts (`rust/forecast`) and sends ntfy + Matrix; with `WEB_LISTEN` it
serves the website and API (`rust/collector/src/web`, the port of
`src/web_api.py`; the dashboard stays `src/static/dashboard.html`). Rewrite
plan: `docs/RUST_REWRITE.md`.
- `rust/collector` (`ping-collector`): collection (`rid.rs`, `hii.rs`, `dams.rs`,
`rain.rs`), forecast run (`forecasting.rs`), ntfy/Matrix (`notify.rs`), website/API
(`web/`), tables (`db.rs`). `rust/forecast`: inference.
- `src/ml/`: features/train/evaluate/predict/export, `rain.py` (Open-Meteo), `dam.py`,
`hii_rain.py`. `src/hii_backfill.py`, `src/hii_collector.py`, `src/rid_reservoir.py`:
archive backfills (`scripts/backfill_*.py`).
- `scripts/install.sh`: install/update on the server. `scripts/retrain.sh` +
`water-monitor-retrain.timer`: monthly retrain with staged promote.
`scripts/forecast_parity.py`: Rust vs Python forecast on a live database.
`scripts/dev_proxy.py`: serve the working-copy dashboard against the live API.
- `docs/FLOOD_FORECASTING.md` is the authoritative model write-up; `docs/DATA_SOURCES.md`
the source catalog.
the source catalog; `docs/NOTIFICATIONS.md` the alerts; `docs/RUST_REWRITE.md` the
history of the Python-to-Rust move.
+43 -268
View File
@@ -1,277 +1,52 @@
# Contributing to Northern Thailand Ping River Monitor
# Contributing
Thank you for your interest in contributing to the Northern Thailand Ping River Monitor! This document provides guidelines and information for contributors.
Issues and merge requests on
[git.b4l.co.th](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor).
The repository has two halves: the service in Rust (`rust/`, see
[rust/README.md](rust/README.md)) and the model training/evaluation stack in Python
(`src/ml`, see [docs/FLOOD_FORECASTING.md](docs/FLOOD_FORECASTING.md)).
## 🚀 Getting Started
## Setup
### Prerequisites
- Python 3.9 or higher
- Git
- Basic knowledge of water monitoring systems
- Familiarity with FastAPI and time-series databases (optional)
- Python 3.11 and [uv](https://docs.astral.sh/uv/) (3.12+ has no pandas 2.0.3 wheels)
- Rust >= 1.88 from [rustup](https://rustup.rs), plus `pkg-config` and `libssl-dev` on Linux
- Docker (or any PostgreSQL 16) for the database tests
### Development Setup
```bash
git clone https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor.git
cd Northern-Thailand-Ping-River-Monitor
make install # uv sync + pre-commit hooks
docker run -d --name ping-pg -e POSTGRES_PASSWORD=ping -e POSTGRES_DB=ping -p 55432:5432 postgres:16
export TEST_DATABASE_URL=postgres://postgres:ping@localhost:55432/ping
make check # format check, flake8, clippy, pytest, cargo test, Rust 1.88
```
1. **Clone the repository:**
```bash
git clone https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor.git
cd Northern-Thailand-Ping-River-Monitor
```
Without `TEST_DATABASE_URL` the database tests are skipped, the rest still run.
2. **Create a virtual environment:**
```bash
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
## Before a merge request
3. **Install dependencies:**
```bash
pip install -r requirements.txt
```
- `make format`, then `make check` green. CI runs the same (`.gitea/workflows/`).
- 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), 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`.
- Model changes: evaluate with `scripts/evaluate_variants.py` (rolling origin, judged
on first-alert lead and false alarms) and record the result in
`docs/FLOOD_FORECASTING.md` section 5, whether it helped or not. A feature change
must be ported to `rust/forecast` in the same change (see CLAUDE.md for the
fixture and config regeneration steps).
- Never commit credentials, `.env`, trained models (`models/*.joblib`,
`models/*.json.gz`) or the server's private addresses.
4. **Run tests:**
```bash
python test_integration.py
```
## Useful commands
5. **Start the development server:**
```bash
python run.py --web-api
```
## 🔧 Development Guidelines
### Code Style
- Follow PEP 8 Python style guidelines
- Use type hints for all function parameters and return values
- Write descriptive docstrings for all classes and functions
- Keep functions focused and under 50 lines when possible
### Testing
- Write tests for new features
- Ensure all existing tests pass before submitting
- Test both success and error cases
- Include integration tests for API endpoints
### Documentation
- Update README.md for new features
- Add docstrings to all new functions and classes
- Update API documentation for new endpoints
- Include examples in documentation
## 📝 Contribution Process
### 1. Issue Creation
- Check existing issues before creating new ones
- Use clear, descriptive titles
- Provide detailed descriptions with steps to reproduce (for bugs)
- Include system information and logs when relevant
### 2. Pull Request Process
1. **Fork the repository**
2. **Create a feature branch:**
```bash
git checkout -b feature/your-feature-name
```
3. **Make your changes**
4. **Test thoroughly:**
```bash
python test_integration.py
python test_station_management.py
```
5. **Commit with clear messages:**
```bash
git commit -m "Add: New station validation feature"
```
6. **Push to your fork:**
```bash
git push origin feature/your-feature-name
```
7. **Create a pull request**
### Pull Request Guidelines
- Use clear, descriptive titles
- Provide detailed descriptions of changes
- Reference related issues
- Include screenshots for UI changes
- Ensure all tests pass
- Update documentation as needed
## 🎯 Areas for Contribution
### High Priority
- **Additional Database Support** - MongoDB, TimescaleDB
- **Authentication System** - User management and API keys
- **Real-time Alerts** - Email/SMS notifications for critical conditions
- **Mobile App** - React Native or Flutter interface
- **Advanced Analytics** - Trend analysis and forecasting
### Medium Priority
- **Bulk Station Operations** - Import/export multiple stations
- **Map Interface** - Visual station management
- **Data Export** - CSV, Excel format support
- **Performance Optimization** - Query optimization and caching
- **Internationalization** - Multi-language support
### Low Priority
- **Additional Visualizations** - Custom chart types
- **Report Generation** - Automated PDF reports
- **Integration APIs** - Third-party service connections
- **Advanced Logging** - Structured logging with ELK stack
## 🐛 Bug Reports
When reporting bugs, please include:
1. **Environment Information:**
- Operating system and version
- Python version
- Database type and version
- Browser (for web interface issues)
2. **Steps to Reproduce:**
- Clear, numbered steps
- Expected vs actual behavior
- Screenshots or error messages
3. **Additional Context:**
- Log files (sanitized of sensitive data)
- Configuration details
- Recent changes to the system
## 💡 Feature Requests
For feature requests, please provide:
1. **Use Case:** Why is this feature needed?
2. **Description:** What should the feature do?
3. **Acceptance Criteria:** How do we know it's complete?
4. **Priority:** How important is this feature?
5. **Alternatives:** Are there workarounds available?
## 🔒 Security
### Reporting Security Issues
- **DO NOT** create public issues for security vulnerabilities
- Email security issues to: [security contact]
- Include detailed information about the vulnerability
- Allow time for fixes before public disclosure
### Security Guidelines
- Never commit sensitive data (passwords, API keys)
- Use environment variables for configuration
- Validate all user inputs
- Follow OWASP security guidelines
- Keep dependencies updated
## 📊 Database Contributions
### Adding New Database Support
1. Create adapter in `src/database_adapters.py`
2. Implement all required methods
3. Add configuration options in `src/config.py`
4. Update documentation
5. Add integration tests
### Database Schema Changes
- Provide migration scripts
- Test with existing data
- Document breaking changes
- Update all database adapters
## 🌐 API Contributions
### Adding New Endpoints
1. Define Pydantic models in `src/models.py`
2. Implement endpoint in `src/web_api.py`
3. Add input validation
4. Include error handling
5. Write comprehensive tests
6. Update API documentation
### API Guidelines
- Use RESTful conventions
- Include proper HTTP status codes
- Provide clear error messages
- Support pagination for large datasets
- Include rate limiting considerations
## 📈 Performance Guidelines
### Optimization Areas
- Database query efficiency
- API response times
- Memory usage
- Concurrent request handling
- Data processing speed
### Monitoring
- Use built-in metrics collection
- Monitor response times
- Track error rates
- Watch resource usage
- Set up alerts for issues
## 🧪 Testing Guidelines
### Test Types
- **Unit Tests:** Individual function testing
- **Integration Tests:** Component interaction testing
- **API Tests:** Endpoint functionality testing
- **Performance Tests:** Load and stress testing
### Test Coverage
- Aim for >80% code coverage
- Test both success and failure paths
- Include edge cases
- Test with different database backends
## 📚 Documentation Standards
### Code Documentation
- Use clear, descriptive variable names
- Write comprehensive docstrings
- Include type hints
- Add inline comments for complex logic
### User Documentation
- Keep README.md updated
- Provide clear installation instructions
- Include usage examples
- Document configuration options
## 🤝 Community Guidelines
### Communication
- Be respectful and professional
- Help newcomers get started
- Share knowledge and experiences
- Provide constructive feedback
### Code Reviews
- Review code thoroughly
- Provide specific, actionable feedback
- Be open to suggestions
- Focus on code quality and maintainability
## 📞 Getting Help
### Resources
- **Documentation:** README.md and docs/
- **API Reference:** http://localhost:8000/docs
- **Issues:** GitHub Issues section
- **Discussions:** GitHub Discussions
### Contact
- **General Questions:** Create a GitHub issue
- **Security Issues:** [security contact]
- **Maintainer:** [maintainer contact]
## 🎉 Recognition
Contributors will be recognized in:
- README.md contributors section
- Release notes for significant contributions
- Special recognition for major features
Thank you for contributing to the Northern Thailand Ping River Monitor! Your contributions help improve water resource monitoring and management in the region.
```bash
make build && make serve # the website/API from your database on 127.0.0.1:8001
uv run python scripts/dev_proxy.py # the working-copy dashboard against the live API
uv run python scripts/evaluate_variants.py --stations P.1
uv run python -m src.ml.export --config # after changing thresholds or notifier settings
```
-58
View File
@@ -1,58 +0,0 @@
# Multi-stage Docker build for Northern Thailand Ping River Monitor
# Build stage
FROM python:3.11-slim as builder
# Set working directory
WORKDIR /app
# Install build dependencies
RUN apt-get update && apt-get install -y \
build-essential \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt
# Production stage
FROM python:3.11-slim
# Set working directory
WORKDIR /app
# Install runtime dependencies and create user
RUN apt-get update && apt-get install -y \
wget \
curl \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd -r appuser && useradd -r -g appuser appuser \
&& mkdir -p /home/appuser/.local
# Copy Python packages from builder stage
COPY --from=builder /root/.local /home/appuser/.local
# Copy application code
COPY . .
# Create logs directory and set permissions
RUN mkdir -p logs && chown -R appuser:appuser /app /home/appuser/.local
# Set environment variables
ENV PYTHONUNBUFFERED=1
ENV TZ=Asia/Bangkok
ENV PATH=/home/appuser/.local/bin:$PATH
# Switch to non-root user
USER appuser
# Health check for API mode
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import requests; requests.get('http://localhost:8000/health', timeout=5)" 2>/dev/null || exit 1
# Expose port for API
EXPOSE 8000
# Default command
CMD ["python", "run.py", "--web-api"]
-165
View File
@@ -1,165 +0,0 @@
# Migration to uv
This document describes the migration from traditional Python package management (pip + requirements.txt) to [uv](https://docs.astral.sh/uv/), a fast Python package installer and resolver.
## What Changed
### Files Added
- `pyproject.toml` - Modern Python project configuration combining dependencies and metadata
- `.python-version` - Specifies Python version for uv
- `scripts/setup_uv.sh` - Unix setup script for uv environment
- `scripts/setup_uv.bat` - Windows setup script for uv environment
- This migration guide
### Files Modified
- `Makefile` - Updated all commands to use `uv run` instead of direct Python execution
### Files That Can Be Removed (Optional)
- `requirements.txt` - Dependencies now in pyproject.toml
- `requirements-dev.txt` - Dev dependencies now in pyproject.toml
- `setup.py` - Configuration now in pyproject.toml
## Installation
### Install uv
**Unix/macOS:**
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```
**Windows (PowerShell):**
```powershell
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
```
### Setup Project
**Unix/macOS:**
```bash
# Run the setup script
chmod +x scripts/setup_uv.sh
./scripts/setup_uv.sh
# Or manually:
uv sync
uv run pre-commit install
```
**Windows:**
```batch
REM Run the setup script
scripts\setup_uv.bat
REM Or manually:
uv sync
uv run pre-commit install
```
## New Workflow
### Common Commands
| Old Command | New Command | Description |
|-------------|-------------|-------------|
| `pip install -r requirements.txt` | `uv sync --no-dev` | Install production dependencies |
| `pip install -r requirements-dev.txt` | `uv sync` | Install all dependencies (including dev) |
| `python run.py` | `uv run python run.py` | Run the application |
| `pytest` | `uv run pytest` | Run tests |
| `black src/` | `uv run black src/` | Format code |
### Using the Makefile
The Makefile has been updated to use uv, so all existing commands work the same:
```bash
make install-dev # Install dev dependencies with uv
make test # Run tests with uv
make run-api # Start API server with uv
make lint # Lint code with uv
make format # Format code with uv
```
### Adding Dependencies
**Production dependency:**
```bash
uv add requests
```
**Development dependency:**
```bash
uv add --dev pytest
```
**Specific version:**
```bash
uv add "fastapi==0.104.1"
```
### Managing Python Versions
uv can automatically manage Python versions:
```bash
# Install and use Python 3.11
uv python install 3.11
uv sync
# Use specific Python version
uv sync --python 3.11
```
## Benefits of uv
1. **Speed** - 10-100x faster than pip
2. **Reliability** - Better dependency resolution
3. **Simplicity** - Single tool for packages and Python versions
4. **Reproducibility** - Lock file ensures consistent environments
5. **Modern** - Built-in support for pyproject.toml
## Troubleshooting
### Command not found
Make sure uv is in your PATH after installation. Restart your terminal or run:
```bash
source ~/.bashrc # or ~/.zshrc
```
### Lock file conflicts
If you encounter lock file issues:
```bash
rm uv.lock
uv sync
```
### Python version issues
Ensure the Python version in `.python-version` is available:
```bash
uv python list
uv python install 3.11 # if needed
```
## Rollback (if needed)
If you need to rollback to the old system:
1. Use the original requirements files:
```bash
pip install -r requirements.txt
pip install -r requirements-dev.txt
```
2. Revert the Makefile changes to use `python` instead of `uv run python`
3. Remove uv-specific files:
```bash
rm pyproject.toml .python-version uv.lock
rm -rf .venv # if created by uv
```
## Additional Resources
- [uv Documentation](https://docs.astral.sh/uv/)
- [Migration Guide](https://docs.astral.sh/uv/guides/projects/)
- [pyproject.toml Reference](https://packaging.python.org/en/latest/specifications/pyproject-toml/)
+57 -168
View File
@@ -1,181 +1,70 @@
# Northern Thailand Ping River Monitor - Makefile
# Ping River Monitor. The service is Rust (rust/, ping-collector); Python is
# the model training/evaluation stack and the archive backfills (src/).
# Database tests (both sides) need TEST_DATABASE_URL, e.g.
# docker run -d --name ping-pg -e POSTGRES_PASSWORD=ping -e POSTGRES_DB=ping -p 55432:5432 postgres:16
# export TEST_DATABASE_URL=postgres://postgres:ping@localhost:55432/ping
.PHONY: help install install-dev test test-coverage lint format clean run run-api docker-build docker-run docs
PY_SRC := src/ scripts/ tests/ rust/forecast/testdata rust/collector/testdata
CARGO := cargo
MANIFEST := --manifest-path rust/Cargo.toml
.PHONY: help install format lint test test-py test-rust build check serve train evaluate export clean
# Default target
help:
@echo "Northern Thailand Ping River Monitor - Available Commands:"
@echo ""
@echo "Setup:"
@echo " install Install production dependencies"
@echo " install-dev Install development dependencies"
@echo ""
@echo "Development:"
@echo " test Run all tests"
@echo " test-cov Run tests with coverage report"
@echo " lint Run code linting"
@echo " format Format code with black and isort"
@echo " clean Clean up temporary files"
@echo ""
@echo "Running:"
@echo " run Run the monitor in continuous mode"
@echo " run-api Run the web API server"
@echo " run-test Run a single test cycle"
@echo " run-status Show system status"
@echo ""
@echo "Alerting:"
@echo " alert-check Check water levels and send alerts"
@echo " alert-test Send test Matrix message"
@echo ""
@echo "Distribution:"
@echo " build-exe Build standalone executable"
@echo " package Build and create distribution package"
@echo ""
@echo "Docker:"
@echo " docker-build Build Docker image"
@echo " docker-run Run with Docker Compose"
@echo " docker-stop Stop Docker services"
@echo ""
@echo "Database:"
@echo " setup-postgres Setup PostgreSQL database"
@echo " test-postgres Test PostgreSQL connection"
@echo " encode-password URL encode password for connection string"
@echo " migrate-sqlite Migrate SQLite data to PostgreSQL"
@echo " migrate-fast Fast migration with 10K batch size"
@echo " analyze-sqlite Analyze SQLite database structure (dry run)"
@echo ""
@echo "Documentation:"
@echo " docs Generate documentation"
@echo "make install uv sync (Python 3.11) + pre-commit hooks"
@echo "make format black + isort (Python), cargo fmt (Rust)"
@echo "make lint the CI checks without tests (format, flake8, clippy)"
@echo "make test pytest + cargo test"
@echo "make build release build of ping-collector (rust/target/release)"
@echo "make check lint + test + the Rust 1.88 minimum-version check"
@echo "make serve website/API from the local database on 127.0.0.1:8001"
@echo "make train retrain every station's model into models/"
@echo "make evaluate rolling-origin harness on P.1 (see scripts/evaluate_variants.py)"
@echo "make export export trained models + flood_config.json for the Rust forecaster"
# Installation
install:
uv sync --no-dev
install-dev:
uv sync
uv sync --python 3.11
uv run pre-commit install
# Testing
test:
format:
uv run black $(PY_SRC)
uv run isort src/ scripts/ tests/
$(CARGO) fmt --all $(MANIFEST)
lint:
uv run black --check $(PY_SRC)
uv run isort --check-only src/ scripts/ tests/
uv run flake8 src/ scripts/ tests/ --count --select=E9,F63,F7,F82 --show-source --statistics
$(CARGO) fmt --all --check $(MANIFEST)
$(CARGO) clippy --all-targets --locked $(MANIFEST) -- -D warnings
test: test-py test-rust
test-py:
uv run pytest -q
test-cov:
uv run pytest --cov=src --cov-report=html --cov-report=term
test-rust:
$(CARGO) test --locked $(MANIFEST)
# Code quality
lint:
uv run flake8 src/ --max-line-length=100
uv run mypy src/
build:
$(CARGO) build --release --locked $(MANIFEST)
format:
uv run black src/ *.py
uv run isort src/ *.py
check: lint test
CARGO_TARGET_DIR=rust/target/msrv $(CARGO) +1.88 check --all-targets --locked $(MANIFEST)
serve:
rust/target/release/ping-collector serve --listen 127.0.0.1:8001 --no-analytics
train:
uv run python scripts/train_flood_model.py --stations all
evaluate:
uv run python scripts/evaluate_variants.py --stations P.1
export:
uv run python -m src.ml.export
uv run python -m src.ml.export --config
# Cleanup
clean:
find . -type f -name "*.pyc" -delete
find . -type d -name "__pycache__" -delete
find . -type f -name "*.log" -delete
rm -rf .pytest_cache/
rm -rf .mypy_cache/
rm -rf htmlcov/
rm -rf dist/
rm -rf build/
rm -rf *.egg-info/
# Running
run:
uv run python run.py
run-api:
uv run python run.py --web-api
run-test:
uv run python run.py --test
run-status:
uv run python run.py --status
# Alerting
alert-check:
uv run python run.py --alert-check
alert-test:
uv run python run.py --alert-test
# Docker
docker-build:
docker build -t ping-river-monitor .
docker-run:
docker-compose -f docker-compose.victoriametrics.yml up -d
docker-stop:
docker-compose -f docker-compose.victoriametrics.yml down
docker-logs:
docker-compose -f docker-compose.victoriametrics.yml logs -f
# Documentation
docs:
cd docs && make html
# Monitoring
health-check:
curl -f http://localhost:8000/health || exit 1
metrics:
curl -s http://localhost:8000/metrics | jq .
# Development helpers
dev-setup: install-dev
cp .env.example .env
@echo "Development environment set up!"
@echo "Edit .env file with your configuration"
# Production deployment
deploy-check:
uv run python run.py --test
@echo "Deployment check passed!"
# Database management
setup-postgres:
uv run python scripts/setup_postgres.py
test-postgres:
uv run python -c "from scripts.setup_postgres import test_postgres_connection; from src.config import Config; config = Config.get_database_config(); test_postgres_connection(config['connection_string'])"
migrate-sqlite:
uv run python scripts/migrate_sqlite_to_postgres.py
migrate-fast:
uv run python scripts/migrate_sqlite_to_postgres.py --fast
analyze-sqlite:
uv run python scripts/migrate_sqlite_to_postgres.py --dry-run
# Git helpers
git-setup:
git remote add origin https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor.git
@echo "Git remote configured!"
# Quick start
quick-start: install dev-setup run-test
@echo "Quick start completed!"
@echo "Run 'make run-api' to start the web interface"
# Gitea Actions
validate-workflows:
@echo "Validating Gitea Actions workflows..."
@for file in .gitea/workflows/*.yml; do \
echo "Checking $$file..."; \
uv run python -c "import yaml; yaml.safe_load(open('$$file', encoding='utf-8'))" || exit 1; \
done
@echo "✅ All workflows are valid"
workflow-test:
@echo "Testing workflow components locally..."
make test
make lint
make format
@echo "✅ Workflow test completed"
find . -type d -name "__pycache__" -prune -exec rm -rf {} +
rm -rf .pytest_cache/ htmlcov/ dist/ build/ *.egg-info/
-287
View File
@@ -1,287 +0,0 @@
# PostgreSQL Setup for Northern Thailand Ping River Monitor
This guide helps you configure PostgreSQL as the database backend for the water monitoring system.
## Prerequisites
- PostgreSQL server running on a remote machine (already available)
- Network connectivity to the PostgreSQL server
- Database credentials (username, password, host, port)
## Quick Setup
### 1. Configure Environment
Copy the example environment file and configure it:
```bash
cp .env.example .env
```
Edit `.env` and update the PostgreSQL configuration:
```bash
# Database Configuration
DB_TYPE=postgresql
# PostgreSQL Configuration (Remote Server)
POSTGRES_CONNECTION_STRING=postgresql://username:password@your-postgres-host:5432/water_monitoring
```
### 2. Run Setup Script
Use the interactive setup script:
```bash
# Using uv
uv run python scripts/setup_postgres.py
# Or using make
make setup-postgres
```
The script will:
- Test your database connection
- Create the database if it doesn't exist
- Initialize the required tables and indexes
- Set up sample monitoring stations
### 3. Test Connection
Test your PostgreSQL connection:
```bash
make test-postgres
```
### 4. Run the Application
Start collecting data:
```bash
# Run a test cycle
make run-test
# Start the web API
make run-api
```
## Manual Configuration
If you prefer manual setup, here's what you need:
### Connection String Format
```
postgresql://username:password@host:port/database
```
**Examples:**
- Basic: `postgresql://postgres:mypassword@192.168.1.100:5432/water_monitoring`
- With SSL: `postgresql://user:pass@host:5432/db?sslmode=require`
- With connection pooling: `postgresql://user:pass@host:5432/db?pool_size=20&max_overflow=0`
### Environment Variables
| Variable | Description | Example |
|----------|-------------|---------|
| `DB_TYPE` | Database type | `postgresql` |
| `POSTGRES_CONNECTION_STRING` | Full connection string | See above |
### Database Schema
The application uses these main tables:
1. **stations** - Monitoring station information
2. **water_measurements** - Time series water level data
3. **alert_thresholds** - Warning/danger level definitions
4. **data_quality_log** - Data collection issue tracking
See `sql/init_postgres.sql` for the complete schema.
## Connection Options
### SSL Connection
For secure connections, add SSL parameters:
```bash
POSTGRES_CONNECTION_STRING=postgresql://user:pass@host:5432/db?sslmode=require
```
SSL modes:
- `disable` - No SSL
- `require` - Require SSL
- `prefer` - Use SSL if available
- `verify-ca` - Verify certificate authority
- `verify-full` - Full certificate verification
### Connection Pooling
For high-performance applications, configure connection pooling:
```bash
POSTGRES_CONNECTION_STRING=postgresql://user:pass@host:5432/db?pool_size=20&max_overflow=0
```
Parameters:
- `pool_size` - Number of connections to maintain
- `max_overflow` - Additional connections allowed
- `pool_timeout` - Seconds to wait for connection
- `pool_recycle` - Seconds before connection refresh
## Troubleshooting
### Common Issues
**1. Connection Refused**
```
psycopg2.OperationalError: could not connect to server
```
- Check if PostgreSQL server is running
- Verify host/port in connection string
- Check firewall settings
**2. Authentication Failed**
```
psycopg2.OperationalError: FATAL: password authentication failed
```
- Verify username/password in connection string
- Check PostgreSQL pg_hba.conf configuration
- Ensure user has database access permissions
**3. Database Does Not Exist**
```
psycopg2.OperationalError: FATAL: database "water_monitoring" does not exist
```
- Run the setup script to create the database
- Or manually create: `CREATE DATABASE water_monitoring;`
**4. Permission Denied**
```
psycopg2.ProgrammingError: permission denied for table
```
- Ensure user has appropriate permissions
- Grant access: `GRANT ALL PRIVILEGES ON DATABASE water_monitoring TO username;`
### Network Configuration
For remote PostgreSQL servers, ensure:
1. **PostgreSQL allows remote connections** (`postgresql.conf`):
```
listen_addresses = '*'
port = 5432
```
2. **Client authentication is configured** (`pg_hba.conf`):
```
# Allow connections from your application server
host water_monitoring username your.app.ip/32 md5
```
3. **Firewall allows PostgreSQL port**:
```bash
# On PostgreSQL server
sudo ufw allow 5432/tcp
```
### Performance Tuning
For optimal performance with time series data:
1. **Increase work_mem** for sorting operations
2. **Tune shared_buffers** for caching
3. **Configure maintenance_work_mem** for indexing
4. **Set up regular VACUUM and ANALYZE** for statistics
Example PostgreSQL configuration additions:
```
# postgresql.conf
shared_buffers = 256MB
work_mem = 16MB
maintenance_work_mem = 256MB
effective_cache_size = 1GB
```
## Monitoring
### Check Application Status
```bash
# View current configuration
uv run python -c "from src.config import Config; Config.print_settings()"
# Test database connection
make test-postgres
# Check latest data
psql "postgresql://user:pass@host:5432/water_monitoring" -c "SELECT COUNT(*) FROM water_measurements;"
```
### PostgreSQL Monitoring
Connect directly to check database status:
```bash
# Connect to database
psql "postgresql://username:password@host:5432/water_monitoring"
# Check table sizes
\dt+
# View latest measurements
SELECT * FROM latest_measurements LIMIT 10;
# Check data quality
SELECT issue_type, COUNT(*) FROM data_quality_log
WHERE created_at > NOW() - INTERVAL '24 hours'
GROUP BY issue_type;
```
## Backup and Maintenance
### Backup Database
```bash
# Full backup
pg_dump "postgresql://user:pass@host:5432/water_monitoring" > backup.sql
# Data only
pg_dump --data-only "postgresql://user:pass@host:5432/water_monitoring" > data_backup.sql
```
### Restore Database
```bash
# Restore full backup
psql "postgresql://user:pass@host:5432/water_monitoring" < backup.sql
# Restore data only
psql "postgresql://user:pass@host:5432/water_monitoring" < data_backup.sql
```
### Regular Maintenance
Set up regular maintenance tasks:
```sql
-- Update table statistics (run weekly)
ANALYZE;
-- Reclaim disk space (run monthly)
VACUUM;
-- Reindex tables (run quarterly)
REINDEX DATABASE water_monitoring;
```
## Next Steps
1. Set up monitoring and alerting
2. Configure data retention policies
3. Set up automated backups
4. Implement connection pooling if needed
5. Configure SSL for production use
For more advanced configuration, see the [PostgreSQL documentation](https://www.postgresql.org/docs/).
+118 -78
View File
@@ -2,18 +2,21 @@
Live water levels, discharge, rainfall and machine-learning flood forecasts for the
Ping River basin around Chiang Mai. Collects hourly gauge data from public sources,
keeps the full history in PostgreSQL, and serves a bilingual dashboard, an open REST
API, and 6/12/24-hour flood-risk forecasts per gauge.
keeps the full history in PostgreSQL, serves a bilingual dashboard and an open REST
API, forecasts the flood risk per gauge 6/12/24 hours ahead, and pushes alerts when
a gauge crosses its warning or danger level.
**Live: [water.buildfor.life](https://water.buildfor.life/)** · API reference at
[/docs](https://water.buildfor.life/docs) · built by [buildfor.life](https://buildfor.life)
after the [October 2024 flood](https://buildfor.life/blog/chiang-mai-flood-2024/) —
background in [Teaching a Model to See the Ping River Rise 13 Hours Early](https://buildfor.life/blog/ping-river-monitor/).
![The dashboard: Chiang Mai's flood verdict and the gauges in the top bar, the gauge list, the map and the panel for P.1 Nawarat Bridge with its history and forecast](docs/img/dashboard.jpg)
[![CI](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/actions/workflows/ci.yml/badge.svg)](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/actions)
[![Rust](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/actions/workflows/rust.yml/badge.svg)](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/actions)
[![Security](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/actions/workflows/security.yml/badge.svg)](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/actions)
[![Docs](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/actions/workflows/docs.yml/badge.svg)](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/actions)
[![Python 3.11](https://img.shields.io/badge/Python-3.11-blue.svg)](https://python.org)
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
## What it does
@@ -21,11 +24,12 @@ background in [Teaching a Model to See the Ping River Rise 13 Hours Early](https
- **Collects** hourly water level and discharge from 16 Royal Irrigation Department
(RID) telemetry gauges, Chiang Dao to the southern basin, since 2018-08; hourly
rainfall and water level from 400+ ThaiWater/HII stations; Open-Meteo catchment
rainfall (archive + 48 h forecast); daily Mae Ngat reservoir state. Every source and
its quirks: [docs/DATA_SOURCES.md](docs/DATA_SOURCES.md).
rainfall (archive + 48 h forecast); daily state of the large RID dams, Mae Ngat
above all. Every source and its quirks: [docs/DATA_SOURCES.md](docs/DATA_SOURCES.md).
- **Fills gaps.** The raw RID grid had readings for ~56 % of hours; a full-history
re-fetch plus HII cross-fill brought it to ~93 %. `GET /api/stats` reports the
current figure.
re-fetch plus HII cross-fill brought it to ~93 %. RID's report often arrives
hours late in the evening, so late readings are always stored and holes are
re-fetched. `GET /api/stats` reports the current coverage.
- **Forecasts.** Per gauge and horizon, a gradient-boosted model predicts the rise
within 6/12/24 h and the probability of crossing the station's warning and danger
levels. Trained on the monitor's own history plus catchment rain; evaluated
@@ -33,48 +37,46 @@ 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.
Subscribe from the free app, no account. Matrix room alerts for a team are
also supported.
Subscribe from the free app, no account. A Matrix room can get the same
messages for a team. [docs/NOTIFICATIONS.md](docs/NOTIFICATIONS.md).
## Quick start
## How it runs
Python **3.11** (3.13 breaks the pinned `psycopg2-binary`), PostgreSQL for anything
beyond a quick look, [uv](https://docs.astral.sh/uv/).
```bash
git clone https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor.git
cd Northern-Thailand-Ping-River-Monitor
uv sync --python 3.11
cp .env.example .env # DB_TYPE, POSTGRES_CONNECTION_STRING, optional MATRIX_*
uv run python run.py --web-api # dashboard + API on http://localhost:8000
```text
RID gauges · ThaiWater/HII · RID dams · Open-Meteo
│ hourly
▼
┌──────────────────────────────────────┐ ┌───────────────────────────┐
│ ping-collector (Rust, one process) │ │ monthly retrain (Python) │
│ collect → forecast → notify │◄───────│ scikit-learn, exports │
│ website + API on :8000 │ models │ models/flood_*.json.gz │
└──────┬──────────────┬────────────────┘ └─────────────┬─────────────┘
│ │ │
▼ ▼ │
ntfy topics, PostgreSQL ◄──────────── history ────────────┘
Matrix room (gauges, rain, dams, forecasts, alert state)
```
`DB_TYPE=sqlite` works for the dashboard and API; the forecasting path expects the
PostgreSQL history.
```bash
uv run python run.py --status # collector status
uv run python run.py --test # one collection cycle
uv run python run.py --fill-gaps 7 # re-fetch the last 7 days from RID
uv run python run.py --collect-hii # one ThaiWater/HII collection cycle
uv run python run.py --alert-check # evaluate thresholds, notify Matrix
uv run python scripts/train_flood_model.py --stations all # retrain (~12 min)
make test # pytest, synthetic data, no network
make format # black + isort (the CI contract)
```
RID collection in `--web-api` mode: polled at :05 past each hour
(`RID_POLL_OFFSET_MINUTES`); while RID has not published the current hour yet,
retried every `RID_RETRY_MINUTES` (10). Every poll stores all readings the report
has, and yesterday's report is re-fetched while it still has holes (RID's evening
hours often arrive 1-2 h late). On startup the leader re-fetches holes from the
last `GAP_FILL_DAYS_ON_START` (14) days; for older history run `--fill-gaps all`.
- **The service** is one Rust binary, `ping-collector` ([rust/README.md](rust/README.md)),
run by systemd. It polls RID at :05 past each hour and retries every 10 minutes
while the report is behind, fetches HII, the dams and the rain once an hour,
forecasts after every cycle that brought new readings, sends the notifications,
and serves the dashboard and API, in under 100 MB of memory with every model
loaded.
- **The models** are trained in Python (`src/ml`, scikit-learn
HistGradientBoosting) by a monthly timer and exported for the Rust forecaster,
which reproduces Python's features bit for bit and reloads new models without
a restart.
- Until October 2026 all of this was a Python/FastAPI app; how and why it moved:
[docs/RUST_REWRITE.md](docs/RUST_REWRITE.md).
## API
@@ -88,76 +90,114 @@ Asia/Bangkok wall-clock without an offset suffix.
| `GET /measurements/history/{code}?hours=N` | Hourly history; or `?start=YYYY-MM-DD&end=YYYY-MM-DD`; `limit` ≤ 100000 |
| `GET /forecast` | Current flood-risk forecast, every station × horizon, with thresholds and P.1 inundation-stage probabilities |
| `GET /api/forecast/history/{code}?hours=N&horizon=24` | Forecasts as issued, for auditing lead time after the fact |
| `GET /api/forecast/skill?station_code=P.1` | Issued forecasts vs what happened, per deployed model version |
| `GET /api/hii/rainfall/latest`, `/api/hii/waterlevel/latest` | Latest ThaiWater/HII gauge readings |
| `GET /api/hii/rainfall/catchment?days=N` | HII gauge catchment-mean rain next to the Open-Meteo series the model uses |
| `GET /api/forecast/skill?station_code=P.1` | Issued forecasts vs what happened, per deployed model version |
| `GET /api/notifications` | ntfy server and topic names for the subscribe panel |
| `GET /api/stats` | Row counts per source, date range, coverage |
| `GET /health` | DB / upstream / memory checks |
| `GET /health` | Database and data freshness (RID, ThaiWater as backup), the RID API, memory |
| `GET /scraping/status` | Collection runs, the last forecast run, service version |
Interactive reference with schemas: [water.buildfor.life/docs](https://water.buildfor.life/docs).
Responses are cached briefly server-side; poll no faster than once a minute — the data
changes hourly.
Interactive reference with schemas: [water.buildfor.life/docs](https://water.buildfor.life/docs)
(also `/redoc`, `/openapi.json`). Responses are cached briefly server-side; poll no
faster than once a minute — the data changes hourly. Station editing
(`POST/PUT/DELETE /stations`), `/config` and `POST /scrape/trigger` need the
server's `ADMIN_API_KEY` in an `X-API-Key` header.
## Run it locally
Needs Rust >= 1.88 ([rustup](https://rustup.rs); on Linux also `pkg-config` and
`libssl-dev`) and PostgreSQL. Python 3.11 with [uv](https://docs.astral.sh/uv/)
only for training models.
```bash
git clone https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor.git
cd Northern-Thailand-Ping-River-Monitor
docker run -d --name ping-pg -e POSTGRES_PASSWORD=ping -e POSTGRES_DB=ping -p 55432:5432 postgres:16
cp .env.example .env
# POSTGRES_CONNECTION_STRING=postgresql://postgres:ping@localhost:55432/ping
# WEB_LISTEN=127.0.0.1:8000
cargo build --release --manifest-path rust/Cargo.toml
rust/target/release/ping-collector check-db # create the tables
rust/target/release/ping-collector fill-gaps 30 # the last 30 days from RID
rust/target/release/ping-collector run # collect, forecast, serve http://127.0.0.1:8000
```
Notifications stay off until `NTFY_SERVER` or `MATRIX_*` is set. Without trained
models the forecast is the persistence heuristic, marked `source: "heuristic"`.
Training needs years of history (the server's database, or `FLOOD_ML_DB_URL`) and
the Open-Meteo archive:
```bash
uv sync --python 3.11
uv run python scripts/train_flood_model.py --stations all # writes models/flood_*
uv run python scripts/evaluate_variants.py --stations P.1 # the rolling-origin harness
```
`make help` lists the rest (`make check` runs every CI gate locally).
## Deployment
Production is a systemd unit on a small VPS behind Cloudflare, updated by `git pull`.
`scripts/install.sh` (run as root from a checkout) creates the `water-monitor` user,
deploys to `/opt/thailand-water-monitor`, runs `uv sync` into `.venv`, installs
`water-monitor.service` and the monthly `water-monitor-retrain.timer`.
Production is one systemd unit on a small VPS behind Cloudflare; the app directory
`/opt/thailand-water-monitor` is a git checkout. To update:
```bash
cd /opt/thailand-water-monitor && git pull
cargo build --release --locked --manifest-path rust/Cargo.toml
sudo bash scripts/install.sh
sudo systemctl start water-monitor.service
systemctl list-timers water-monitor-retrain.timer
```
The retrain timer runs `scripts/retrain.sh`, which trains into `models/.staging`,
refuses to promote anything that is not a rain-enabled (`hgb-v3+`) set covering the
expected stations, and renames the bundles into place. Details and the operations
runbook: [docs/FLOOD_FORECASTING.md](docs/FLOOD_FORECASTING.md) sections 6–8.
`scripts/install.sh` (idempotent, also for a fresh host) installs the binary and
`ping-collector.service`, syncs the Python environment for the monthly
`water-monitor-retrain.timer`, runs a dry run of every source and a forecast check
against the Python reference, restarts the service and waits for the website. The
retrain trains into `models/.staging`, refuses to promote anything that is not a
rain-enabled (`hgb-v3+`) set covering the expected stations, and renames the
bundles into place; the service picks them up on its next forecast. Details and
the operations runbook: [docs/FLOOD_FORECASTING.md](docs/FLOOD_FORECASTING.md)
sections 6–8. Configuration: [.env.example](.env.example).
## Repository layout
```
src/ collector, API (web_api.py), dashboard (static/dashboard.html)
src/ml/ features, training, evaluation harness, prediction, rain/dam/HII loaders
scripts/ train_flood_model.py, retrain.sh, evaluate_variants.py, install.sh, dev_proxy.py
tests/ pytest suite (synthetic data; no DB or network)
docs/ FLOOD_FORECASTING.md, DATA_SOURCES.md, deployment and station guides
models/ trained bundles + metrics.json (gitignored) and evaluation results (tracked)
.gitea/workflows/ ci (format/lint/tests), security (pip-audit/bandit), docs (link + OpenAPI checks)
rust/collector/ ping-collector: collection, forecast run, ntfy/Matrix, website + API (src/web)
rust/forecast/ forecast inference from the exported models; parity tests against Python
src/static/ the dashboard (single file, EN/TH) and its map data
src/data/ the bundled station list
src/ml/ features, training, evaluation harness, export; predict.py is the reference
src/*.py HII and RID-dam clients/stores for the archive backfills
scripts/ install.sh, retrain.sh, train/evaluate/backfill tools, systemd units
tests/ pytest (synthetic data; database tests need TEST_DATABASE_URL)
docs/ model write-up, data sources, notifications, Matrix, rewrite history
models/ trained bundles (gitignored) and evaluation results
```
## Documentation
- [docs/FLOOD_FORECASTING.md](docs/FLOOD_FORECASTING.md) — the model: data, features, evaluation, measured performance, negatives, deployment, retraining
- [docs/DATA_SOURCES.md](docs/DATA_SOURCES.md) — every ingested and candidate source, endpoints, quirks
- [docs/STATION_MANAGEMENT_GUIDE.md](docs/STATION_MANAGEMENT_GUIDE.md) — adding/editing gauges
- [docs/DATABASE_DEPLOYMENT_GUIDE.md](docs/DATABASE_DEPLOYMENT_GUIDE.md), [POSTGRESQL_SETUP.md](POSTGRESQL_SETUP.md) — database setup
- [docs/NOTIFICATIONS.md](docs/NOTIFICATIONS.md) — public push alerts: topics, semantics, ntfy deployment
- [docs/MATRIX_QUICK_START.md](docs/MATRIX_QUICK_START.md) — Matrix room alerts for a team
- [docs/GAP_FILLING_GUIDE.md](docs/GAP_FILLING_GUIDE.md) — data integrity tooling
- [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/)
Other database backends (VictoriaMetrics, InfluxDB, MySQL, SQLite) and the Grafana
dashboards under `grafana/` are supported by the adapters but not what production
runs; see [docs/VICTORIAMETRICS_SETUP.md](docs/VICTORIAMETRICS_SETUP.md) if you want them.
## Contributing
`make format` before committing (black 88 columns, isort black profile — the CI gate),
`make test` must stay green, tests use synthetic data only. See
`make format` before committing, `make check` must stay green; see
[CONTRIBUTING.md](CONTRIBUTING.md). Issues and merge requests on
[git.b4l.co.th](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor).
## Data sources and thanks
Royal Irrigation Department (RID) gauge telemetry; 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 gaps and forecast.
Royal Irrigation Department (RID) gauge telemetry and reservoir reports;
Hydro-Informatics Institute (HII) / ThaiWater open API; Open-Meteo; OpenStreetMap
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
-278
View File
@@ -1,278 +0,0 @@
# SQLite to PostgreSQL Migration Guide
This guide helps you migrate your existing SQLite water monitoring data to PostgreSQL.
## Quick Migration
### 1. Analyze Your SQLite Database (Optional)
First, check what's in your SQLite database:
```bash
# Analyze without migrating
make analyze-sqlite
# Or specify a specific SQLite file
uv run python scripts/migrate_sqlite_to_postgres.py --dry-run /path/to/your/database.db
```
### 2. Run the Migration
```bash
# Auto-detect SQLite file and migrate
make migrate-sqlite
# Or specify a specific SQLite file
uv run python scripts/migrate_sqlite_to_postgres.py /path/to/your/database.db
```
The migration tool will:
- ✅ Connect to both databases
- ✅ Analyze your SQLite schema automatically
- ✅ Migrate station information
- ✅ Migrate all measurement data in batches
- ✅ Handle different SQLite table structures
- ✅ Verify the migration results
- ✅ Generate a detailed log file
## What Gets Migrated
### Station Data
- Station IDs and codes
- Thai and English names
- Coordinates (latitude/longitude)
- Geohash data (if available)
- Creation/update timestamps
### Measurement Data
- Water level readings
- Discharge measurements
- Discharge percentages
- Timestamps
- Station associations
- Data quality status
## Supported SQLite Schemas
The migration tool automatically detects and handles various SQLite table structures:
### Modern Schema
```sql
-- Stations
stations: id, station_code, station_name_th, station_name_en, latitude, longitude, geohash
-- Measurements
water_measurements: timestamp, station_id, water_level, discharge, discharge_percent, status
```
### Legacy Schema
```sql
-- Stations
water_stations: station_id, station_code, station_name, lat, lon
-- Measurements
measurements: timestamp, station_id, water_level, discharge, discharge_percent
```
### Simple Schema
```sql
-- Any table with basic water level data
-- The tool will adapt and map columns automatically
```
## Migration Process
### Step 1: Database Connection
- Connects to your SQLite database
- Verifies PostgreSQL connection
- Validates configuration
### Step 2: Schema Analysis
- Scans SQLite tables and columns
- Reports data counts
- Identifies table structures
### Step 3: Station Migration
- Extracts station metadata
- Maps to PostgreSQL format
- Handles missing data gracefully
### Step 4: Measurement Migration
- Processes data in batches (1000 records at a time)
- Converts timestamps correctly
- Preserves all measurement values
- Shows progress during migration
### Step 5: Verification
- Compares record counts
- Validates data integrity
- Reports migration statistics
## Command Options
```bash
# Basic migration (auto-detects SQLite file)
uv run python scripts/migrate_sqlite_to_postgres.py
# Specify SQLite database path
uv run python scripts/migrate_sqlite_to_postgres.py /path/to/database.db
# Dry run (analyze only, no migration)
uv run python scripts/migrate_sqlite_to_postgres.py --dry-run
# Custom batch size for large databases
uv run python scripts/migrate_sqlite_to_postgres.py --batch-size 5000
```
## Auto-Detection
The tool automatically searches for SQLite files in common locations:
- `water_levels.db`
- `water_monitoring.db`
- `database.db`
- `../water_levels.db`
## Migration Output
The tool provides detailed logging:
```
========================================
SQLite to PostgreSQL Migration Tool
========================================
SQLite database: water_levels.db
PostgreSQL: postgresql
Step 1: Connecting to databases...
Connected to SQLite database: water_levels.db
Connected to PostgreSQL database
Step 2: Analyzing SQLite database structure...
Table 'stations': 8 columns, 25 rows
Table 'water_measurements': 7 columns, 15420 rows
Step 3: Migrating station data...
Migrated 25 stations
Step 4: Migrating measurement data...
Found 15420 measurements to migrate
Migrated 1000/15420 measurements
Migrated 2000/15420 measurements
...
Successfully migrated 15420 measurements
Step 5: Verifying migration...
SQLite stations: 25
SQLite measurements: 15420
PostgreSQL measurements retrieved: 15420
Migrated stations: 25
Migrated measurements: 15420
========================================
MIGRATION COMPLETED
========================================
Duration: 0:02:15
Stations migrated: 25
Measurements migrated: 15420
No errors encountered
```
## Error Handling
The migration tool is robust and handles:
- **Missing tables** - Tries alternative table names
- **Different column names** - Maps common variations
- **Missing data** - Uses sensible defaults
- **Invalid timestamps** - Attempts multiple date formats
- **Connection issues** - Provides clear error messages
- **Large datasets** - Processes in batches to avoid memory issues
## Log Files
Migration creates a detailed log file:
- `migration.log` - Complete migration log
- Shows all operations, errors, and statistics
- Useful for troubleshooting
## Troubleshooting
### Common Issues
**1. SQLite file not found**
```
SQLite database file not found. Please specify the path:
python migrate_sqlite_to_postgres.py /path/to/database.db
```
**Solution**: Specify the correct path to your SQLite file
**2. PostgreSQL not configured**
```
Error: PostgreSQL not configured. Set DB_TYPE=postgresql in your .env file
```
**Solution**: Ensure your .env file has `DB_TYPE=postgresql`
**3. Connection failed**
```
Database connection error: connection refused
```
**Solution**: Check your PostgreSQL connection settings
**4. No tables found**
```
Could not analyze SQLite database structure
```
**Solution**: Verify your SQLite file contains water monitoring data
### Performance Tips
- **Large databases**: Use `--batch-size 5000` for faster processing
- **Slow networks**: Reduce batch size to `--batch-size 100`
- **Memory issues**: Process smaller batches
## After Migration
Once migration is complete:
1. **Verify data**:
```bash
make run-test
make run-api
```
2. **Check the web interface**: Latest readings should show your migrated data
3. **Backup your SQLite**: Keep the original file as backup
4. **Update configurations**: Remove SQLite references from configs
## Rollback
If you need to rollback:
1. **Clear PostgreSQL data**:
```sql
DELETE FROM water_measurements;
DELETE FROM stations;
```
2. **Switch back to SQLite**:
```bash
# In .env file
DB_TYPE=sqlite
WATER_DB_PATH=water_levels.db
```
3. **Test the rollback**:
```bash
make run-test
```
The migration tool is designed to be safe and can be run multiple times - it handles duplicates appropriately.
## Next Steps
After successful migration:
- Set up automated backups for PostgreSQL
- Configure monitoring and alerting
- Consider data retention policies
- Update documentation references
-106
View File
@@ -1,106 +0,0 @@
version: '3.8'
services:
victoriametrics:
image: victoriametrics/victoria-metrics:latest
container_name: ping_river_victoriametrics
ports:
- "8428:8428"
volumes:
- vm_data:/victoria-metrics-data
- ./vm-config:/etc/victoriametrics
command:
- '--storageDataPath=/victoria-metrics-data'
- '--retentionPeriod=2y'
- '--httpListenAddr=:8428'
- '--maxConcurrentInserts=32'
- '--search.maxQueryDuration=60s'
- '--search.maxConcurrentRequests=16'
- '--dedup.minScrapeInterval=30s'
- '--memory.allowedPercent=80'
- '--loggerLevel=INFO'
- '--loggerFormat=json'
- '--search.maxSeries=1000000'
- '--search.maxPointsPerTimeseries=100000'
restart: unless-stopped
environment:
- TZ=Asia/Bangkok
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8428/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
labels:
- "com.victoriametrics.service=ping-river-monitoring"
grafana:
image: grafana/grafana:latest
container_name: ping_river_grafana
ports:
- "3000:3000"
volumes:
- grafana_data:/var/lib/grafana
- ./grafana/provisioning:/etc/grafana/provisioning
- ./grafana/dashboards:/var/lib/grafana/dashboards
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin_password
- GF_INSTALL_PLUGINS=grafana-clock-panel,grafana-worldmap-panel
- GF_USERS_ALLOW_SIGN_UP=false
- GF_SECURITY_ALLOW_EMBEDDING=true
- GF_AUTH_ANONYMOUS_ENABLED=false
- TZ=Asia/Bangkok
restart: unless-stopped
depends_on:
victoriametrics:
condition: service_healthy
labels:
- "com.victoriametrics.service=ping-river-monitoring"
ping-river-monitor:
build: .
container_name: ping_river_monitor
environment:
- DB_TYPE=victoriametrics
- VM_HOST=victoriametrics
- VM_PORT=8428
- TZ=Asia/Bangkok
restart: unless-stopped
depends_on:
victoriametrics:
condition: service_healthy
volumes:
- ./logs:/app/logs
labels:
- "com.victoriametrics.service=ping-river-monitoring"
ping-river-api:
build: .
container_name: ping_river_api
ports:
- "8000:8000"
environment:
- DB_TYPE=victoriametrics
- VM_HOST=victoriametrics
- VM_PORT=8428
- TZ=Asia/Bangkok
restart: unless-stopped
depends_on:
victoriametrics:
condition: service_healthy
volumes:
- ./logs:/app/logs
command: ["python", "-m", "src.main", "--web-api"]
labels:
- "com.victoriametrics.service=ping-river-monitoring"
volumes:
vm_data:
driver: local
grafana_data:
driver: local
networks:
default:
name: ping-river-monitoring
driver: bridge
+157
View File
@@ -0,0 +1,157 @@
# Dashboard redesign: the console
Status: **phase 4 done** (2026-10-20). The console is the dashboard: it replaced the
old page at `/` (`src/static/dashboard.html`; the server reads it at start, so a
change needs a restart). Phase 5 (polish) is open.
- **Locally:** `uv run python scripts/dev_proxy.py 8765`, then <http://localhost:8765/>.
The API is proxied to production.
- **Rules** (`tests/test_dashboard.py`):
- every path it fetches is a Rust route;
- every string exists in EN and TH, and Thai is the default;
- no emoji;
- timestamps are Bangkok wall-clock time;
- a replay or a simulation is never shown as live data.
## Why: the old page
Observed on 2026-10-19 at 1440 px and on a 390 px phone.
- **No clear first answer.** "Is Chiang Mai flooding?" is a thin green banner above
four KPI cards of equal weight. Two of them answer nothing for a resident: "Most
stressed gauge 16%" and "Chiang Mai river flow 53.9" (no unit in the number). At the
bottom there are five more (2,700,706 datapoints, 3,002 days, …).
- **The colours answer the wrong question.** Markers and river lines are coloured by
discharge class (m³/s), not by distance to the warning level, and the station list
shows discharge.
- **The map is noisy.** OSM terrain with Thai labels, numbered markers and animated
dashes. The legend covers about a third of the map, and more than half of the first
phone screen.
- **The header is busy.** It has six buttons with emoji (↻ 🌙 🔔 ▶); on a phone they
wrap into three rows.
- **Internal detail on the main page.** The model-skill table lists seven model hashes;
the history card is an empty 360 px box until a station is clicked.
- **Inconsistent styling.** A gradient background, several card and pill styles, three
greens.
## Decision
Two directions were sketched on 2026-10-19 against live data (throwaway HTML, not kept):
- **A:** a public information page.
- **B:** an operations console.
The user chose **B**.
## Layout
One screen, no page scroll on a desktop:
| Area | Content |
|---|---|
| Top bar | brand · **Chiang Mai verdict** (P.1 level + 24 h forecast, always visible, click selects P.1) · gauges reporting · highest 24 h flood chance · data age · EN/ไทย · light/dark · Get alerts |
| Left | two lists: **RID gauges** (status bar, code, name, margin to the warning level, 72 h sparkline, level and 6 h change; sort by river order, closest to the warning level, rising) and **Basin stations** (ThaiWater/HII: % of bank height, river and agency; sort fullest first or by name); filter; legend |
| Centre | map on a self-hosted vector basemap (labels in the page language): Ping mainstem and tributaries, gauges coloured by status with code + level labels, layers Basin stations (diamonds), Rain 24 h, Flood zones and Flood marks 2024, the team's markers from zoom 12, one legend |
| Right | selected RID gauge: level, 6 h change, discharge, warning/danger level with margins, capacity; level scale with now and 24 h peak; history (3 d / 7 d / 30 d / 90 d / 1 y / all or dates: level, discharge, the 24 h peaks as issued, warning/danger bands, hover values; up to 7 d it ends in the current forecast); forecast table (peak, chance of warning/danger at 6/12/24 h, model, issue time, disclaimer); for P.1 the five city flood stages, the zones toggle and "How accurate is the forecast?"; data links. A basin station shows level (m MSL), % of bank height with ThaiWater's class, distance to and height of the bank top, gauge reading, agency (latest reading only: no history is kept for them) |
| Status bar | data credits, "2.7M readings since 2018" (opens the data statistics), API, source code |
**Narrower screens:**
| Width | Layout |
|---|---|
| < 1280 px | The panel moves below list and map, in columns |
| < 860 px | One column: verdict, map, list, panel |
**Behaviour:**
- Selection is in the URL (`#P.84`, `#hii-1198299`); opening a basin station switches to its list.
- Arrow keys move through the list.
- Data refreshes every 5 minutes.
## Where every current element goes
| Old page (`dashboard.html` id) | Console | Phase |
|---|---|---|
| `flood-verdict`, `verdict-*` | Verdict in the top bar | 1 |
| `station-count`, `last-updated`, `data-age`, `live-pill` | Top-bar KPIs (reporting, data age) | 1 |
| `river-flow`, `peak-station` (P.1 flow, most stressed gauge) | Discharge and capacity in the panel; "Closest to warning level" sort | 1 |
| `station-map`, `map-legend`, `legend-toggle`, `rain-toggle`, `zones-toggle` | Map, layer buttons, one legend | 1 |
| side list, `station-search` | Gauge list with filter and sorts | 1 |
| `forecast-card`, `p1-outlook`, `p1-peak`, `p1-stages`, `forecast-grid` | Panel: forecast table for every gauge, stages for P.1 | 1 |
| `alerts-panel`, `alerts-*` | Alerts drawer | 1 |
| `refresh-button` | Removed: refresh every 5 min, age in the top bar | 1 |
| `lang-toggle`, `theme-toggle` | EN / ไทย segmented control, icon button | 1 |
| `error`, `loading` | Map overlay with retry; "Loading" in the list | 1 |
| `thaiwater-sensors`, `sensors-*` | Basin stations list + map layer | 2 |
| custom markers | Pins from zoom 12, popup with their flood zone | 2 |
| `history-card`, `history-*` | Panel history with ranges | 3 |
| `skill-panel`, `skill-*` | Panel section for P.1 | 3 |
| `db-stats`, `db-*` | Status-bar popover | 3 |
| `replay-2024`, `replay-*`, demo hooks | Replay/simulation mode with banner and mode pill | 4 |
## Design rules
- **Type:** Inter (Latin) and Noto Sans Thai, self-hosted in `src/static/fonts/` (75 KB, OFL). Numbers use tabular figures.
- **Colours:** CSS variables for light and dark, with dark the default; a saved choice wins (`ping-river-theme`, the old dashboard's key, so the choice carried over).
- **Accent:** `#1d6fd6` light, `#4c9bf0` dark.
- **Status:** colours are used only for status, each with an `-ink` variant for text (WCAG AA on white).
- **One status rule** (`statusOf`) for the map, list and panel. It is display-only; the notifier keeps its own transitions.
| Status | Rule |
|---|---|
| Normal | none of the rules below |
| Watch | within 0.5 m of the warning level, or 24 h warning chance ≥ 25 % |
| Warning | level ≥ `threshold_warning` |
| Danger | level ≥ `threshold_danger` |
| No data | no reading for 24 h |
- **City verdict:** the classes of `statusOf` for P.1 — flooding (the Ping over its banks) from 4.20 m, high water from 3.70 m; "flooding likely" at danger ≥ 50 %, "high water likely" at warning ≥ 70 %; watch at warning ≥ 20 % or level ≥ 3.20 m. (Until 2026-10-25 it said flooding from 3.70 m, after the older municipal map.)
- **Data age:** "feed stale" after 3 h, except during RID's overnight pause (21:00 to 09:00 with an evening reading). The pause shows as "overnight pause", the same rule as the notifier's `FEED_NIGHT_UNTIL_HOUR`.
- **Discharge:** comes with RID's report. When the newest hour is a level-only reading, the panel shows the last RID discharge with its time ("at 14:00").
- **Rain layer:** off by default unless any station has heavy rain (> 35 mm / 24 h); an explicit choice is remembered.
- **Flood zones:** `src/static/flood-zones-p1.geojson`, CMU Water Center's flood hazard map of the October 2024 flood (built by `scripts/build_flood_zones.py` from CMU's KML): five disjoint zones, zone k floods first when P.1 reaches stage k (4.30, 4.50, 4.70, 5.00, 5.30 m), in CMU's colours from deep red to yellow, credited in the map attribution. Shown automatically while P.1 is over its banks (danger level) or stage 1 is at least 50 % likely within 24 h, unless hidden by the user. A point's zone (team markers) honours the holes: dry islands inside a zone are outside it.
- **Basin stations:** ThaiWater's classes for the level as a share of the bank height (critically low ≤ 10 %, low ≤ 30 %, normal ≤ 70 %, high ≤ 100 %, over the bank). On the map and in the list over the bank is danger, high is watch, the rest normal; no data after 24 h. Gauges RID already lists are shown once, as RID gauges. The layer is off until the list is opened or the button pressed.
- **History:** above 2,500 readings (30 d and longer for most gauges) the chart shows the daily mean with the daily low–high band, so a flood peak is not averaged away; gaps longer than 3.5 h (2.5 days when daily) break the line rather than bridging RID's nightly pause. Short ranges reload with every refresh, long ones at most hourly. "All" asks for `limit=100000`: `/measurements/history` returns the oldest rows first and stops at its 50,000 default, which made the old dashboard's "All time" for P.1 end in October 2024.
- **Issued forecasts:** the history's violet line is the model's 24 h peak for every hour (`/api/forecast/history`, `source = model` only: a heuristic row is the level itself and drew a dip into the line). The nightly gaps were RID's overnight batch; the collector now forecasts those hours when they arrive (docs/FLOOD_FORECASTING.md, Serving).
- **Forecast accuracy:** loaded when the section is opened (a cold `/api/forecast/skill` query takes up to 20 s), cached for 15 minutes; bias and persistence error are in each row's tooltip.
- **Replay of October 2024:** `src/static/flood-2024-all.json` (hourly readings of the 16 gauges, 15 September – 12 October 2024, and the 24 h warning chance and peak of a model trained on data before 1 September 2024). The page's clock, verdict, list, markers, flood zones and panel follow the replay; the panel's chart shows the season on fixed axes up to the replay hour. It plays hour by hour while the model alerts or P.1 is above 3.50 m, faster through quiet days, and can be paused and dragged. Rain, the basin stations and the 5-minute refresh are off during the replay, since they are today's data. A violet bar (REPLAY, date, P.1 level, the model's message) and the top bar's "Replay time" replace the live data age.
- **Simulation:** `?demo_level=4.4` pins P.1 at 4.40 m; `?demo_rise=1` raises it by 5 cm every 0.7 s from the real level to the 2024 record (5.30 m). An amber SIMULATION bar names the real level; the top bar and the P.1 panel say "simulated". Only P.1 is simulated: the forecasts and other gauges stay live.
- **Flood marks 2024:** `src/static/flood-marks-2024.json`, CMU Water Center's ~4,900 marks of the October 2024 high-water line (depth above the road at P.1 5.30 m; `scripts/build_flood_marks.py`), on a canvas from zoom 12 in CMU's five depth classes; turning the layer on below zoom 12 flies to the city. A mark's popup gives the depth and its flood zone; zone and marker popups name the nearest mark within 250 m (the marks load with the zones). Off by default; also available in the replay, since it is 2024 data. Popups pan clear of the layer bar, which wraps to two rows on phones.
- **Basemap:** `src/static/basemap/ping-basin.pmtiles`, a Protomaps extract (bbox 97.85–99.95 E, 16.85–20.2 N, zoom 0–12, 15 MB, OpenStreetMap data), drawn by protomaps-leaflet 5.1.0 in its `light`/`dark` style with `name:th`/`name:en` labels. Refresh instructions in `src/static/basemap/README.md`. The map cannot be panned far outside the basin. OSM raster tiles are the fallback if the renderer does not load.
## Phases
Each phase is one commit, checked with screenshots: 1440, 1100 and 390 px; light and dark; EN and TH.
1. **Done — Console shell with live data.**
- Layout, design tokens, fonts, EN/TH.
- Verdict and KPIs, gauge list with sparklines and sorts.
- Map with status markers, rain and flood-zone layers.
- Station panel (readings, scale, 72 h chart, forecast table, P.1 stages).
- Alerts drawer (ntfy topics); URL selection; 5-minute refresh.
- `tests/test_console.py` (now `tests/test_dashboard.py`); `dev_proxy.py` serves `src/static` with proper content types.
2. **Done — Map and basin.**
- **Basemap:** the self-hosted Protomaps extract replaces the dimmed OSM raster tiles, whose baked-in labels were the noisiest part of the screen. Labels follow the page language; light and dark styles.
- **Server:** ServeDir answers HTTP range requests (checked: 206); `dev_proxy.py` now does too.
- **ThaiWater/HII stations:** the Basin stations list and map layer, from `/api/hii/waterlevel/latest`, falling back to `/sensors/thaiwater`.
- **Custom markers:** `custom-markers.json` as pins, with their flood-zone popup.
3. **Done — Station depth.**
- **History in the panel** (inline SVG, no chart library): ranges and dates, level and discharge, issued 24 h forecasts overlaid, warning/danger bands, hover/touch values.
- **P.1 forecast accuracy** as a collapsible panel section, from `/api/forecast/skill`.
- **Data statistics:** a status-bar popover, from `/api/stats`.
4. **Done — Modes and the swap.**
- **October 2024 replay:** banner, clock, markers and verdict follow the replay; zones auto-show.
- **Demo hooks:** `?demo_level=`, `?demo_rise=`. A mode pill (LIVE / REPLAY / SIMULATION) never relabels a replay or simulation as live.
- **The swap:** `console.html` became `dashboard.html`, with the old page's title, description, Open Graph and JSON-LD tags; the robots `noindex` is gone. `tests/test_dashboard.py` is the console's tests plus the old ones that still apply (one P.1 level, SEO and indexing files).
- **Docs:** README screenshot and the CLAUDE.md rule.
5. **Polish.**
- Phone layout pass.
- Native-speaker review of the Thai strings.
- Keyboard and screen reader pass.
- Lighthouse ≥ 90 (performance, accessibility).
- No console errors.
## Data notes from building phase 1
- **Negative levels.** P.4A (−1.28 m) and P.67 (−0.59 m) report negative levels: gauge datums, not errors. The panel's scale starts at the lowest of 0 and the station's 72 h readings.
- **P.1 discharge disagrees between endpoints.** At 15:00 on 2026-10-19, `/measurements/latest` had level 1.30 m with no discharge, while `/measurements/history` had 26.0 m³/s with no capacity. RID's 14:00 report said 53.9 m³/s. The rows without capacity (`discharge_percent`) are ThaiWater's copy of the gauge, which fills hours RID has not published yet (its night hours); their discharge comes from a different rating, about half of RID's at P.1. The panel shows the last RID discharge, and the history chart drops the discharge of those rows for gauges RID reports discharge for.
- **P.84 spike.** A one-hour spike at 13:00 the same day: 0.73 → 1.05 → 0.78 m.
-447
View File
@@ -1,447 +0,0 @@
# Database Deployment Guide for Thailand Water Monitor
This guide covers deployment options for storing water monitoring data in production environments.
## 🏆 Recommendation Summary
| Database | Best For | Performance | Complexity | Cost |
|----------|----------|-------------|------------|------|
| **InfluxDB** | Time-series data, dashboards | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ |
| **VictoriaMetrics** | High-performance metrics | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| **PostgreSQL** | Complex queries, reliability | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐ |
| **MySQL** | Familiar, existing infrastructure | ⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐ |
## 1. InfluxDB Deployment (Recommended for Time-Series)
### Why InfluxDB?
- **Purpose-built** for time-series data
- **Excellent compression** (10:1 typical ratio)
- **Built-in retention policies** and downsampling
- **Great Grafana integration** for dashboards
- **High write throughput** (100k+ points/second)
### Docker Deployment
```yaml
# docker-compose.yml
version: '3.8'
services:
influxdb:
image: influxdb:1.8
container_name: water_influxdb
ports:
- "8086:8086"
volumes:
- influxdb_data:/var/lib/influxdb
- ./influxdb.conf:/etc/influxdb/influxdb.conf:ro
environment:
- INFLUXDB_DB=water_monitoring
- INFLUXDB_ADMIN_USER=admin
- INFLUXDB_ADMIN_PASSWORD=your_secure_password
- INFLUXDB_USER=water_user
- INFLUXDB_USER_PASSWORD=water_password
restart: unless-stopped
grafana:
image: grafana/grafana:latest
container_name: water_grafana
ports:
- "3000:3000"
volumes:
- grafana_data:/var/lib/grafana
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin_password
restart: unless-stopped
volumes:
influxdb_data:
grafana_data:
```
### Environment Variables
```bash
# .env file
DB_TYPE=influxdb
INFLUX_HOST=localhost
INFLUX_PORT=8086
INFLUX_DATABASE=water_monitoring
INFLUX_USERNAME=water_user
INFLUX_PASSWORD=water_password
```
### InfluxDB Configuration
```toml
# influxdb.conf
[meta]
dir = "/var/lib/influxdb/meta"
[data]
dir = "/var/lib/influxdb/data"
wal-dir = "/var/lib/influxdb/wal"
# Optimize for time-series data
cache-max-memory-size = "1g"
cache-snapshot-memory-size = "25m"
cache-snapshot-write-cold-duration = "10m"
# Retention and compression
compact-full-write-cold-duration = "4h"
max-series-per-database = 1000000
max-values-per-tag = 100000
[coordinator]
write-timeout = "10s"
max-concurrent-queries = 0
query-timeout = "0s"
[retention]
enabled = true
check-interval = "30m"
[http]
enabled = true
bind-address = ":8086"
auth-enabled = true
max-body-size = "25000000"
max-concurrent-requests = 0
max-enqueued-requests = 0
```
### Production Setup Commands
```bash
# Start services
docker-compose up -d
# Create retention policies
docker exec -it water_influxdb influx -username admin -password your_secure_password -execute "
CREATE RETENTION POLICY \"raw_data\" ON \"water_monitoring\" DURATION 90d REPLICATION 1 DEFAULT;
CREATE RETENTION POLICY \"downsampled\" ON \"water_monitoring\" DURATION 730d REPLICATION 1;
"
# Create continuous queries for downsampling
docker exec -it water_influxdb influx -username admin -password your_secure_password -execute "
CREATE CONTINUOUS QUERY \"downsample_hourly\" ON \"water_monitoring\"
BEGIN
SELECT mean(water_level) AS water_level, mean(discharge) AS discharge, mean(discharge_percent) AS discharge_percent
INTO \"downsampled\".\"water_data_hourly\"
FROM \"water_data\"
GROUP BY time(1h), station_code, station_name_en, station_name_th
END
"
```
## 2. VictoriaMetrics Deployment (High Performance)
### Why VictoriaMetrics?
- **Extremely fast** and resource-efficient
- **Better compression** than InfluxDB
- **Prometheus-compatible** API
- **Lower memory usage**
- **Built-in clustering**
### Docker Deployment
```yaml
# docker-compose.yml
version: '3.8'
services:
victoriametrics:
image: victoriametrics/victoria-metrics:latest
container_name: water_victoriametrics
ports:
- "8428:8428"
volumes:
- vm_data:/victoria-metrics-data
command:
- '--storageDataPath=/victoria-metrics-data'
- '--retentionPeriod=2y'
- '--httpListenAddr=:8428'
- '--maxConcurrentInserts=16'
restart: unless-stopped
volumes:
vm_data:
```
### Environment Variables
```bash
# .env file
DB_TYPE=victoriametrics
VM_HOST=localhost
VM_PORT=8428
```
## 3. PostgreSQL Deployment (Relational + Time-Series)
### Why PostgreSQL?
- **Mature and reliable**
- **Excellent for complex queries**
- **TimescaleDB extension** for time-series optimization
- **Strong consistency guarantees**
- **Rich ecosystem**
### Docker Deployment with TimescaleDB
```yaml
# docker-compose.yml
version: '3.8'
services:
postgres:
image: timescale/timescaledb:latest-pg14
container_name: water_postgres
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
environment:
- POSTGRES_DB=water_monitoring
- POSTGRES_USER=water_user
- POSTGRES_PASSWORD=secure_password
restart: unless-stopped
volumes:
postgres_data:
```
### Database Initialization
```sql
-- init.sql
CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE;
-- Create hypertable for time-series optimization
CREATE TABLE water_measurements (
id BIGSERIAL PRIMARY KEY,
timestamp TIMESTAMPTZ NOT NULL,
station_id INT NOT NULL,
water_level NUMERIC(10,3),
discharge NUMERIC(10,2),
discharge_percent NUMERIC(5,2),
status VARCHAR(20) DEFAULT 'active',
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Convert to hypertable (TimescaleDB)
SELECT create_hypertable('water_measurements', 'timestamp', chunk_time_interval => INTERVAL '1 day');
-- Create indexes
CREATE INDEX idx_water_measurements_station_time ON water_measurements (station_id, timestamp DESC);
CREATE INDEX idx_water_measurements_timestamp ON water_measurements (timestamp DESC);
-- Create retention policy (keep raw data for 2 years)
SELECT add_retention_policy('water_measurements', INTERVAL '2 years');
-- Create continuous aggregates for performance
CREATE MATERIALIZED VIEW water_measurements_hourly
WITH (timescaledb.continuous) AS
SELECT
time_bucket('1 hour', timestamp) AS bucket,
station_id,
AVG(water_level) as avg_water_level,
MAX(water_level) as max_water_level,
MIN(water_level) as min_water_level,
AVG(discharge) as avg_discharge,
MAX(discharge) as max_discharge,
MIN(discharge) as min_discharge,
AVG(discharge_percent) as avg_discharge_percent
FROM water_measurements
GROUP BY bucket, station_id;
-- Refresh policy for continuous aggregates
SELECT add_continuous_aggregate_policy('water_measurements_hourly',
start_offset => INTERVAL '1 day',
end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '1 hour');
```
### Environment Variables
```bash
# .env file
DB_TYPE=postgresql
POSTGRES_CONNECTION_STRING=postgresql://water_user:secure_password@localhost:5432/water_monitoring
```
## 4. MySQL Deployment (Traditional Relational)
### Docker Deployment
```yaml
# docker-compose.yml
version: '3.8'
services:
mysql:
image: mysql:8.0
container_name: water_mysql
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
- ./mysql.cnf:/etc/mysql/conf.d/mysql.cnf
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
environment:
- MYSQL_ROOT_PASSWORD=root_password
- MYSQL_DATABASE=water_monitoring
- MYSQL_USER=water_user
- MYSQL_PASSWORD=water_password
restart: unless-stopped
volumes:
mysql_data:
```
### MySQL Configuration
```ini
# mysql.cnf
[mysqld]
# Optimize for time-series data
innodb_buffer_pool_size = 1G
innodb_log_file_size = 256M
innodb_flush_log_at_trx_commit = 2
innodb_flush_method = O_DIRECT
# Partitioning support
partition = ON
# Query cache
query_cache_type = 1
query_cache_size = 128M
# Connection settings
max_connections = 200
connect_timeout = 10
wait_timeout = 600
```
### Environment Variables
```bash
# .env file
DB_TYPE=mysql
MYSQL_CONNECTION_STRING=mysql://water_user:water_password@localhost:3306/water_monitoring
```
## 5. Installation and Dependencies
### Required Python Packages
```bash
# Base requirements
pip install requests schedule
# Database-specific packages
pip install influxdb # For InfluxDB
pip install sqlalchemy pymysql # For MySQL
pip install sqlalchemy psycopg2-binary # For PostgreSQL
# VictoriaMetrics uses HTTP API (no extra packages needed)
```
### Updated requirements.txt
```txt
requests>=2.28.0
schedule>=1.2.0
pandas>=1.5.0
# Database adapters (install as needed)
influxdb>=5.3.1
sqlalchemy>=1.4.0
pymysql>=1.0.2
psycopg2-binary>=2.9.0
```
## 6. Production Deployment Examples
### Using InfluxDB (Recommended)
```bash
# Set environment variables
export DB_TYPE=influxdb
export INFLUX_HOST=your-influx-server.com
export INFLUX_PORT=8086
export INFLUX_DATABASE=water_monitoring
export INFLUX_USERNAME=water_user
export INFLUX_PASSWORD=your_secure_password
# Run the scraper
python water_scraper_v3.py
```
### Using PostgreSQL with TimescaleDB
```bash
# Set environment variables
export DB_TYPE=postgresql
export POSTGRES_CONNECTION_STRING=postgresql://water_user:password@your-postgres-server.com:5432/water_monitoring
# Run the scraper
python water_scraper_v3.py
```
### Using VictoriaMetrics
```bash
# Set environment variables
export DB_TYPE=victoriametrics
export VM_HOST=your-vm-server.com
export VM_PORT=8428
# Run the scraper
python water_scraper_v3.py
```
## 7. Monitoring and Alerting
### Grafana Dashboard Setup
1. **Add Data Source**: Configure your database as a Grafana data source
2. **Import Dashboard**: Use pre-built water monitoring dashboards
3. **Set Alerts**: Configure alerts for abnormal water levels or discharge rates
### Example Grafana Queries
#### InfluxDB Queries
```sql
-- Current water levels
SELECT last("water_level") FROM "water_data" GROUP BY "station_code"
-- Discharge trends (last 24h)
SELECT mean("discharge") FROM "water_data" WHERE time >= now() - 24h GROUP BY time(1h), "station_code"
```
#### PostgreSQL/TimescaleDB Queries
```sql
-- Current water levels
SELECT DISTINCT ON (station_id)
station_id, water_level, discharge, timestamp
FROM water_measurements
ORDER BY station_id, timestamp DESC;
-- Hourly averages (last 24h)
SELECT
time_bucket('1 hour', timestamp) as hour,
station_id,
AVG(water_level) as avg_level,
AVG(discharge) as avg_discharge
FROM water_measurements
WHERE timestamp >= NOW() - INTERVAL '24 hours'
GROUP BY hour, station_id
ORDER BY hour DESC;
```
## 8. Performance Optimization Tips
### For All Databases
- **Batch inserts**: Insert multiple measurements at once
- **Connection pooling**: Reuse database connections
- **Indexing**: Ensure proper indexes on timestamp and station_id
- **Retention policies**: Automatically delete old data
### InfluxDB Specific
- Use **tags** for metadata (station codes, names)
- Use **fields** for numeric values (water levels, discharge)
- Configure **retention policies** and **continuous queries**
- Enable **compression** for long-term storage
### PostgreSQL/TimescaleDB Specific
- Use **hypertables** for automatic partitioning
- Create **continuous aggregates** for common queries
- Configure **compression** for older chunks
- Use **parallel queries** for large datasets
### VictoriaMetrics Specific
- Use **labels** efficiently (similar to Prometheus)
- Configure **retention periods** appropriately
- Use **downsampling** for long-term storage
- Enable **deduplication** if needed
This deployment guide provides production-ready configurations for all supported database backends. Choose the one that best fits your infrastructure and requirements.
+70 -14
View File
@@ -23,10 +23,29 @@ The only source persisted to the database and used by the ML pipeline.
| Params | `DW[UtokID]=1`, `DW[BasinID]=6` (Ping), `DW[TimeCurrent]=<Buddhist-calendar date>`, `rows=100` |
| Variables | Water level (m, gauge datum), discharge (m³/s, `'***'` = malformed), discharge % of channel capacity |
| Stations | 16 P-series gauges (P.1 anchor at Nawarat Bridge; see `src/data/stations.json`) |
| Client | `src/water_scraper_v3.py` |
| 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
@@ -117,9 +136,9 @@ GET .../rain_24h_graph?station_type=tele_rainfall&station_id=418&start_date=...&
### 2.2 `twa-api-public.thaiwater.net` — auth-gated (x-api-key / session) 🔒
The layer our existing `src/thaiwater.py` client uses
(`GET /v2/waterlevel` with `x-api-key: $THAIWATER_API_KEY`; wired to
`GET /sensors/thaiwater` in the web API, display-only, never persisted).
The layer the website's `GET /sensors/thaiwater` uses (`GET /v2/waterlevel`
with an `x-api-key` header from `THAIWATER_API_KEY`;
`rust/collector/src/web/hii.rs`, display-only, never persisted).
Without a key: HTTP 401/500. No public key-registration page was found —
obtain a sanctioned key from HII (<https://hii.or.th>).
@@ -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
@@ -242,21 +294,25 @@ Oct 2024 flood). Mae Kuang Udom Thara is the second upstream reservoir.
| Source | Status | Action |
|---|---|---|
| RID hourly gauges | ✅ Ingested (hourly → PostgreSQL) | — |
| ThaiWater `/v2/waterlevel` | 🟡 Display-only (`src/thaiwater.py`, needs `THAIWATER_API_KEY`, never persisted) | Optionally persist |
| HII `rain_24h` | ✅ Ingested hourly via `src/hii_collector.py` → `hii_rain_stations` + `hii_rainfall` (Ping-filtered; ~300 stations) | — |
| HII `waterlevel_load` | ✅ Ingested hourly via `src/hii_collector.py` → `hii_wl_stations` + `hii_waterlevel` (125 Ping stations, m MSL; `rid_code` column maps mirrors like `ridhydro_P.1` → `P.1`, `offset_msl` converts MSL → gauge datum) | — |
| ThaiWater `/v2/waterlevel` | 🟡 Display-only (`/sensors/thaiwater`, needs `THAIWATER_API_KEY`, never persisted) | Optionally persist |
| HII `rain_24h` | ✅ Ingested hourly by ping-collector (`rust/collector/src/hii.rs`) → `hii_rain_stations` + `hii_rainfall` (Ping-filtered; ~300 stations) | — |
| HII `waterlevel_load` | ✅ Ingested hourly by ping-collector (`rust/collector/src/hii.rs`) → `hii_wl_stations` + `hii_waterlevel` (125 Ping stations, m MSL; `rid_code` column maps mirrors like `ridhydro_P.1` → `P.1`, `offset_msl` converts MSL → gauge datum) | — |
| HII `waterlevel_graph` | ✅ Backfill via `scripts/backfill_hii_waterlevel.py` → `hii_waterlevel` (hourly MSL + discharge, archive ≥2019; full-year windows per request; upserts never overwrite live-snapshot columns) | Run once on the box: `python scripts/backfill_hii_waterlevel.py` (defaults: 2019-01-01 → today, RID-mirror + key stations; `--stations P.1,P.67`, `--all` for every Ping station) |
| Mae Ngat reservoir (app.rid.go.th) | ✅ Ingested (2026-08-13) | Daily storage/inflow/outflow for all large dams → `rid_reservoir_daily`; candidate model features for next retrain |
| Satellite QPE (GSMaP/IMERG) | ❌ | Backfill training rainfall (GEE) |
| Open-Meteo forecasts | ❌ | Add forecast features (live + 2021 archive for training) |
| 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** (`src/hii_collector.py`): runs automatically every
scraping cycle (hourly cadence, even while the RID scraper is in 1-minute retry
mode) from both `--web-api` and continuous-monitoring modes; one-shot via
`python -m src.main --collect-hii`. Env vars: `ENABLE_HII_COLLECTION`
(default `true`), `HII_BASIN_CODE` (default `6` = Ping). Requires a SQL
`DB_TYPE` (sqlite/postgresql/mysql); tables are created automatically.
**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
`ping-collector once`. Env vars: `ENABLE_HII_COLLECTION`,
`ENABLE_RESERVOIR_COLLECTION`, `ENABLE_RAIN_COLLECTION` (default `true`),
`HII_BASIN_CODE` (default `6` = Ping). Tables are created automatically
(PostgreSQL). The archive backfills stay in Python (`scripts/backfill_*.py`,
using `src/hii_collector.py` and `src/rid_reservoir.py`).
**Caveats**: `api-v3` is an undocumented backend — no SLA, no ToS, may change
without notice. Poll hourly at most, cache aggressively, and pursue official
-329
View File
@@ -1,329 +0,0 @@
# Debian/Linux Troubleshooting Guide
This guide addresses common issues when running the Thailand Water Monitor on Debian and other Linux distributions.
## Fixed Issues
### SQLAlchemy Connection Error (RESOLVED)
**Error Message:**
```
2025-07-24 19:48:31,920 - ERROR - Failed to connect to SQLITE: 'Connection' object has no attribute 'commit'
2025-07-24 19:48:32,740 - ERROR - Error saving to SQLITE: 'Connection' object has no attribute 'commit'
```
**Root Cause:**
This error occurred due to incompatibility between the database adapter code and newer versions of SQLAlchemy. The code was calling `conn.commit()` on a connection object that doesn't have a `commit()` method in newer SQLAlchemy versions.
**Solution Applied:**
Changed from `engine.connect()` to `engine.begin()` context manager, which automatically handles transactions:
```python
# OLD (problematic) code:
with self.engine.connect() as conn:
conn.execute(text(sql))
conn.commit() # This fails in newer SQLAlchemy
# NEW (fixed) code:
with self.engine.begin() as conn:
conn.execute(text(sql))
# Transaction automatically committed when context exits
```
**Status:** ✅ **FIXED** - The issue has been resolved in the current version.
## Installation on Debian/Ubuntu
### System Requirements
```bash
# Update package list
sudo apt update
# Install Python and pip
sudo apt install python3 python3-pip python3-venv
# Install system dependencies for database drivers
sudo apt install build-essential python3-dev
# For MySQL support (optional)
sudo apt install default-libmysqlclient-dev
# For PostgreSQL support (optional)
sudo apt install libpq-dev
```
### Python Environment Setup
```bash
# Create virtual environment
python3 -m venv water_monitor_env
# Activate virtual environment
source water_monitor_env/bin/activate
# Install requirements
pip install -r requirements.txt
```
### Running the Monitor
```bash
# Test run
python water_scraper_v3.py --test
# Run with specific database
export DB_TYPE=sqlite
python water_scraper_v3.py
# Run demo
python demo_databases.py
```
## Common Linux Issues
### 1. Permission Errors
**Error:**
```
PermissionError: [Errno 13] Permission denied: 'water_levels.db'
```
**Solution:**
```bash
# Check current directory permissions
ls -la
# Create data directory with proper permissions
mkdir -p data
chmod 755 data
# Set database path to data directory
export WATER_DB_PATH=data/water_levels.db
```
### 2. Missing System Dependencies
**Error:**
```
ImportError: No module named '_sqlite3'
```
**Solution:**
```bash
# Install SQLite development headers
sudo apt install libsqlite3-dev
# Reinstall Python if needed
sudo apt install python3-sqlite
```
### 3. Network/Firewall Issues
**Error:**
```
requests.exceptions.ConnectionError: HTTPSConnectionPool
```
**Solution:**
```bash
# Test network connectivity
curl -I https://hyd-app-db.rid.go.th/hydro1h.html
# Check firewall rules
sudo ufw status
# Allow outbound HTTPS if needed
sudo ufw allow out 443
```
### 4. Systemd Service Setup
Create service file `/etc/systemd/system/water-monitor.service`:
```ini
[Unit]
Description=Thailand Water Level Monitor
After=network.target
[Service]
Type=simple
User=water-monitor
Group=water-monitor
WorkingDirectory=/opt/water_level_monitor
Environment=PATH=/opt/water_level_monitor/venv/bin
Environment=DB_TYPE=sqlite
Environment=WATER_DB_PATH=/opt/water_level_monitor/data/water_levels.db
ExecStart=/opt/water_level_monitor/venv/bin/python water_scraper_v3.py
Restart=always
RestartSec=60
# Security settings
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/water_level_monitor/data
ReadWritePaths=/opt/water_level_monitor/logs
[Install]
WantedBy=multi-user.target
```
Enable and start:
```bash
sudo systemctl daemon-reload
sudo systemctl enable water-monitor.service
sudo systemctl start water-monitor.service
sudo systemctl status water-monitor.service
```
### 5. Log Rotation
Create `/etc/logrotate.d/water-monitor`:
```
/opt/water_level_monitor/water_monitor.log {
daily
missingok
rotate 30
compress
delaycompress
notifempty
create 644 water-monitor water-monitor
postrotate
systemctl reload water-monitor.service
endscript
}
```
## Database-Specific Issues
### SQLite
**Issue:** Database locked
```bash
# Check for processes using the database
sudo lsof /path/to/water_levels.db
# Kill processes if needed
sudo pkill -f water_scraper_v3.py
```
### VictoriaMetrics with HTTPS
**Configuration:**
```bash
export DB_TYPE=victoriametrics
export VM_HOST=https://your-vm-server.com
export VM_PORT=443
```
**Test connection:**
```bash
curl -k https://your-vm-server.com/health
```
## Performance Optimization
### 1. System Tuning
```bash
# Increase file descriptor limits
echo "* soft nofile 65536" >> /etc/security/limits.conf
echo "* hard nofile 65536" >> /etc/security/limits.conf
# Optimize network settings
echo "net.core.rmem_max = 16777216" >> /etc/sysctl.conf
echo "net.core.wmem_max = 16777216" >> /etc/sysctl.conf
sysctl -p
```
### 2. Database Optimization
```bash
# For SQLite
export SQLITE_CACHE_SIZE=10000
export SQLITE_SYNCHRONOUS=NORMAL
# Monitor database size
du -h data/water_levels.db
```
## Monitoring and Maintenance
### Health Check Script
Create `health_check.sh`:
```bash
#!/bin/bash
LOG_FILE="/opt/water_level_monitor/water_monitor.log"
SERVICE_NAME="water-monitor"
# Check if service is running
if ! systemctl is-active --quiet $SERVICE_NAME; then
echo "ERROR: $SERVICE_NAME is not running"
systemctl restart $SERVICE_NAME
exit 1
fi
# Check recent log entries
RECENT_ERRORS=$(tail -n 100 $LOG_FILE | grep -c "ERROR")
if [ $RECENT_ERRORS -gt 5 ]; then
echo "WARNING: $RECENT_ERRORS errors found in recent logs"
exit 1
fi
echo "OK: Service is healthy"
exit 0
```
### Cron Job for Health Checks
```bash
# Add to crontab
*/5 * * * * /opt/water_level_monitor/health_check.sh >> /var/log/water-monitor-health.log 2>&1
```
## Getting Help
### Debug Information
```bash
# System information
uname -a
python3 --version
pip list | grep -E "(sqlalchemy|requests|influxdb)"
# Service logs
journalctl -u water-monitor.service -f
# Application logs
tail -f water_monitor.log
# Database information
sqlite3 water_levels.db ".schema"
sqlite3 water_levels.db "SELECT COUNT(*) FROM water_measurements;"
```
### Common Commands
```bash
# Restart service
sudo systemctl restart water-monitor.service
# View logs
sudo journalctl -u water-monitor.service --since "1 hour ago"
# Test configuration
python config.py
# Test database connection
python demo_databases.py
# Manual data fetch
python water_scraper_v3.py --test
```
This troubleshooting guide should help resolve most common issues encountered when running the Thailand Water Monitor on Debian and other Linux distributions.
+96 -63
View File
@@ -62,8 +62,8 @@ P.5 before 2022-01-01; the rest are handled by the per-row coverage gates.
**How `src/ml/data.py` loads it.** `resolve_db_url()` picks a connection string in
priority order: an explicit `--db-url` argument, then the `FLOOD_ML_DB_URL`
environment variable, then `Config.get_database_config()` when `DB_TYPE` is
`postgresql`, else `None`. `load_measurements()` then tries three tiers:
environment variable, then the `POSTGRES_*` settings (`src/config.py`, the same
`.env` the service reads), else `None`. `load_measurements()` then tries three tiers:
1. **PostgreSQL** (`_fetch_from_db`) — the primary path. NULL discharge stays
NULL, which matters because the models must learn from the real missingness
@@ -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
@@ -614,46 +618,73 @@ fix, the served features match what this section evaluates.
## 6. Deployment
### API
### Serving
With `FORECAST_IN_COLLECTOR=true` the forecast is computed by `ping-collector`
(`rust/forecast`, a parity-tested port of this serving path) from
The forecast is computed by `ping-collector` (`rust/forecast`, a parity-tested
port of `src/ml/predict.py`, which stays as the reference implementation) from
`models/flood_<station>.json.gz`, which `train_all()` exports next to each
joblib bundle; `/forecast` serves its `forecast_current` row and computes
in-process only when that is older than `FORECAST_MAX_AGE_SECONDS` (2 h). See
`rust/README.md`. The rest of this section describes the in-process path.
joblib bundle. It runs after every collection cycle that brought newer
readings and at least hourly, stores the rows in `forecast_history` (section 9's
skill tracking) and `forecast_current`, and `GET /forecast` serves the stored
row: one JSON row per station × horizon with the fields listed in section 1.
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`.
`GET /forecast` (`src/web_api.py`) returns one JSON row per station × horizon with
the fields listed in section 1. Results are cached in-process for
`FORECAST_TTL = 900` seconds (15 minutes), which matches the data cadence — the
underlying readings do not update faster than hourly. Inference runs in a thread
via `asyncio.to_thread` so it never blocks the event loop.
**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`).
Failure modes: **503** if the `src.ml` package cannot be imported (missing
scikit-learn, say), **502** on any other exception.
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.
One behaviour worth knowing, because the code comments suggest otherwise: the
endpoint's `FileNotFoundError` ("No trained flood models found") and `RuntimeError`
handlers are unreachable — nothing in `src/ml/` raises either, and
`predict._forecast_station` checks `bundle_path.exists()` and falls back to the
heuristic instead. So **before the first training run `/forecast` returns 200 with
an all-heuristic payload**, not a 503, provided there is recent gauge data; you
get an empty `200 []` only when there is no recent data at all. Judge deployment
state by the `source` field, not the status code.
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
recent gauge data; you get an empty `200 []` only when there is no recent data
at all. Judge deployment state by the `source` field, not the status code.
The Python serving path (`predict.get_latest_forecasts`) is not called by the
service any more; `scripts/forecast_parity.py` runs both on the live database
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
@@ -661,13 +692,14 @@ The server already has the PostgreSQL connection configured, so no host override
is needed:
```bash
cd /path/to/Northern-Thailand-Ping-River-Monitor
python scripts/train_flood_model.py --stations all
cd /opt/thailand-water-monitor
uv run python scripts/train_flood_model.py --stations all
```
`resolve_db_url()` picks up `Config.get_database_config()` automatically when
`DB_TYPE=postgresql`. The run writes fifteen `models/flood_{station}.joblib`
bundles plus `models/metrics.json`.
`resolve_db_url()` picks up the `POSTGRES_*` settings from `.env`. The run writes
fifteen `models/flood_{station}.joblib` bundles, their `.json.gz` exports for the
service, and `models/metrics.json`. The monthly timer does the same through
`scripts/retrain.sh` with a staged promote (section 7).
### Artifacts and dependencies
@@ -733,7 +765,10 @@ gap between fetch and first model could not be reconstructed from the surviving
artifacts. Do not attribute it to per-station training cost. Either way the
conclusion holds: **retraining is minutes, not tens of minutes.**
**Inference** (15 bundles, 16 stations × 3 horizons = 48 rows):
**Inference in Python** (15 bundles, 16 stations × 3 horizons = 48 rows; the
retired in-process serving path, measured before the Rust forecaster took
over: there the whole run takes milliseconds, 0.25 ms per station against
158 ms, and the 15 exported models need about 70 MB resident):
| Measurement | Value |
|---|---|
@@ -753,9 +788,9 @@ one-time lazy `sklearn.ensemble` import — the remaining fourteen average 159 m
Of the ~640 ms warm compute, model prediction is ~525 ms, the hourly grid 47 ms,
and feature building 71 ms across all sixteen stations.
**Live-endpoint measurements** (a second, independent benchmark run against a real
uvicorn instance of the app, same day, same workstation, RSS summed over the
process tree):
**Live-endpoint measurements of the retired Python API** (a second, independent
benchmark run against a real uvicorn instance of the app, same day, same
workstation, RSS summed over the process tree):
| Measurement | Value |
|---|---|
@@ -775,14 +810,11 @@ Training shows the opposite (2.3× slower single-threaded), so set the variable
per process, never globally.
**Server sizing, in plain terms:** this is a small workload and almost any server
runs it. **RAM is the binding constraint, not CPU.** Budget about **1 GB for the
API process** so the ~335 MB steady state has headroom on top of the rest of the
app; training peaks at only ~210–315 MB and can share the same box. No GPU
anywhere. Pin thread counts per process — `OMP_NUM_THREADS=1` in the serving
unit, `OMP_NUM_THREADS=4` for retraining so it cannot monopolise every core while
the API is serving. And since a cold call costs seconds against a 2.4 ms cache
hit, consider warming `/forecast` once at startup rather than letting a user
absorb it.
runs it. The service (`ping-collector`, Rust) stays under 100 MB resident with
every model loaded (its unit caps it at 512 MB); the monthly retrain peaks at
~210–315 MB and shares the same box. No GPU anywhere. The retrain unit sets
`OMP_NUM_THREADS=4` so it cannot monopolise every core while the site is
serving.
## 7. Retraining policy
@@ -821,15 +853,15 @@ fires `water-monitor-retrain.service` on the 1st of every month at 03:30 server
time (`Persistent=true`, so a missed run catches up at boot). The unit runs
`scripts/retrain.sh` as the service user with `OMP_NUM_THREADS=4`, `Nice=15`:
1. trains all stations into `models/.staging/` (the API keeps serving the old
bundles throughout);
1. trains all stations into `models/.staging/` (the service keeps serving the
old bundles throughout);
2. refuses to promote unless `metrics.json` reports a `hgb-v3+` version and at
least 14 trained stations (exit 3, staging discarded, old models untouched);
3. renames the new bundles into `models/`, moving the previous generation to
`models/.previous/` for rollback.
No API restart: `predict.py` reloads bundles by mtime on the next hourly
precompute. `systemctl list-timers water-monitor-retrain.timer` shows the next
No restart: ping-collector reloads changed `.json.gz` exports by mtime on its
next forecast run. `systemctl list-timers water-monitor-retrain.timer` shows the next
run; `sudo systemctl start water-monitor-retrain.service` runs it now (after a
flood, say); `journalctl -u water-monitor-retrain` has the log. The installer
(`scripts/install.sh`) enables the timer.
@@ -912,9 +944,10 @@ non-model-backed, and all of them are visible in the payload:
horizon's head is in the bundle's `skipped_heads`, almost always because the
station had fewer than 30 positive examples for that threshold.
- A whole station flipping to heuristic after a code change — the feature-name
check in `_model_forecast` caught a mismatch between the live feature builder
and the stored `feature_names`. The fix is to retrain; the log line names the
missing columns.
check caught a mismatch between the feature builder and the stored
`feature_names` (in Python `_model_forecast`; in Rust a bundle needing a
feature `rust/forecast` cannot build). The fix is to retrain or to port the
feature; the log line names the missing columns.
A live example from the 2026-08-10 cache: of 48 forecast rows, 42 came from
models and 6 were heuristic — three for P.4A, which has no bundle by design, and
-275
View File
@@ -1,275 +0,0 @@
# Gap Filling and Data Integrity Guide
This guide explains the enhanced gap-filling functionality that addresses data gaps and missing timestamps in the Thailand Water Monitor.
## ✅ **Issues Resolved**
### **1. Data Gaps Problem**
- **Before**: Tool only fetched current day data, leaving gaps in historical records
- **After**: Automatically detects and fills missing timestamps for the last 7 days
### **2. Missing Midnight Timestamps**
- **Before**: Jump from 23:00 to 01:00 (missing 00:00 midnight data)
- **After**: Specifically checks for and fills midnight hour gaps
### **3. Changed Values**
- **Before**: No mechanism to update existing data if values changed on the server
- **After**: Compares existing data with fresh API data and updates changed values
## 🔧 **New Features**
### **Command Line Interface**
```bash
# Check for missing data gaps
python water_scraper_v3.py --check-gaps [days]
# Fill missing data gaps
python water_scraper_v3.py --fill-gaps [days]
# Update existing data with latest values
python water_scraper_v3.py --update-data [days]
# Run single test cycle
python water_scraper_v3.py --test
# Show help
python water_scraper_v3.py --help
```
### **Automatic Gap Detection**
The system now automatically:
- Generates expected hourly timestamps for the specified time range
- Compares with existing database records
- Identifies missing timestamps
- Groups missing data by date for efficient API calls
### **Intelligent Gap Filling**
- **Historical Data Fetching**: Retrieves data for specific dates to fill gaps
- **Selective Insertion**: Only inserts data for actually missing timestamps
- **API Rate Limiting**: Includes delays between API calls to be respectful
- **Error Handling**: Continues processing even if some dates fail
### **Data Update Mechanism**
- **Change Detection**: Compares water levels, discharge rates, and percentages
- **Precision Checking**: Uses appropriate thresholds (0.001m for water level, 0.1 cms for discharge)
- **Selective Updates**: Only updates records where values have actually changed
## 📊 **Test Results**
### **Before Enhancement**
```
Found 22 missing timestamps in the last 2 days:
2025-07-23: Missing hours [9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23]
2025-07-24: Missing hours [0, 20, 21, 22, 23]
2025-07-25: Missing hours [0, 9]
```
### **After Gap Filling**
```
Gap filling completed. Filled 96 missing data points
Remaining gaps:
2025-07-24: Missing hours [10]
2025-07-25: Missing hours [0, 10]
```
**Improvement**: Reduced from 22 missing timestamps to 3 (86% improvement)
## 🚀 **Enhanced Scraping Cycle**
The regular scraping cycle now includes three phases:
### **Phase 1: Current Data Collection**
```python
# Fetch and save current data
water_data = self.fetch_water_data()
success = self.save_to_database(water_data)
```
### **Phase 2: Gap Filling (Last 7 Days)**
```python
# Check for and fill missing data
filled_count = self.fill_data_gaps(days_back=7)
```
### **Phase 3: Data Updates (Last 2 Days)**
```python
# Update existing data with latest values
updated_count = self.update_existing_data(days_back=2)
```
## 🔧 **Technical Improvements**
### **Database Connection Handling**
- **SQLite Optimization**: Added timeout and thread safety parameters
- **Retry Logic**: Exponential backoff for database lock errors
- **Transaction Management**: Proper use of `engine.begin()` for automatic commits
### **Error Recovery**
```python
# Retry logic with exponential backoff
for attempt in range(max_retries):
try:
success = self.db_adapter.save_measurements(water_data)
if success:
return True
except Exception as e:
if "database is locked" in str(e).lower():
time.sleep(2 ** attempt) # 1s, 2s, 4s delays
continue
```
### **Memory Efficiency**
- **Selective Data Processing**: Only processes data for missing timestamps
- **Batch Processing**: Groups operations by date to minimize API calls
- **Resource Management**: Proper cleanup and connection handling
## 📋 **Usage Examples**
### **Daily Maintenance**
```bash
# Check for gaps in the last week
python water_scraper_v3.py --check-gaps 7
# Fill any found gaps
python water_scraper_v3.py --fill-gaps 7
# Update recent data for accuracy
python water_scraper_v3.py --update-data 2
```
### **Historical Data Recovery**
```bash
# Check for gaps in the last month
python water_scraper_v3.py --check-gaps 30
# Fill gaps for the last month (be patient, this takes time)
python water_scraper_v3.py --fill-gaps 30
```
### **Production Monitoring**
```bash
# Quick test to ensure system is working
python water_scraper_v3.py --test
# Check for recent gaps
python water_scraper_v3.py --check-gaps 1
```
## 🔍 **Monitoring and Alerts**
### **Gap Detection Output**
```
Found 22 missing timestamps:
2025-07-23: Missing hours [9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23]
2025-07-24: Missing hours [0, 20, 21, 22, 23]
2025-07-25: Missing hours [0, 9]
```
### **Gap Filling Progress**
```
Fetching data for 2025-07-24 to fill 5 missing timestamps
Successfully fetched 368 data points from API for 2025-07-24
Filled 80 data points for 2025-07-24
Gap filling completed. Filled 96 missing data points
```
### **Update Detection**
```
Checking for updates on 2025-07-24
Update needed for P.1 at 2025-07-24 15:00:00
Updated 5 measurements for 2025-07-24
Data update completed. Updated 5 measurements
```
## ⚙️ **Configuration Options**
### **Environment Variables**
```bash
# Database configuration
export DB_TYPE=sqlite
export WATER_DB_PATH=water_monitoring.db
# Gap filling settings (can be added to config.py)
export GAP_FILL_DAYS=7 # Days to check for gaps
export UPDATE_DAYS=2 # Days to check for updates
export API_DELAY=1 # Seconds between API calls
export MAX_RETRIES=3 # Database retry attempts
```
### **Customizable Parameters**
- **Gap Check Period**: Default 7 days, configurable via command line
- **Update Period**: Default 2 days, configurable via command line
- **API Rate Limiting**: 1-second delay between calls (configurable)
- **Retry Logic**: 3 attempts with exponential backoff (configurable)
## 🛠️ **Troubleshooting**
### **Common Issues**
#### **Database Locked Errors**
```
ERROR - Error saving to SQLITE: database is locked
```
**Solution**: The retry logic now handles this automatically with exponential backoff.
#### **API Rate Limiting**
```
WARNING - Too many requests to API
```
**Solution**: Increase delay between API calls or reduce the number of days processed at once.
#### **Missing Data Still Present**
```
Found X missing timestamps after gap filling
```
**Possible Causes**:
- Data not available on the Thai government server for those timestamps
- Network issues during API calls
- API returned empty data for those specific times
### **Debug Commands**
```bash
# Enable debug logging
export LOG_LEVEL=DEBUG
python water_scraper_v3.py --check-gaps 1
# Test specific date range
python water_scraper_v3.py --fill-gaps 1
# Check database directly
sqlite3 water_monitoring.db "SELECT COUNT(*) FROM water_measurements;"
sqlite3 water_monitoring.db "SELECT timestamp, COUNT(*) FROM water_measurements GROUP BY timestamp ORDER BY timestamp DESC LIMIT 10;"
```
## 📈 **Performance Metrics**
### **Gap Filling Efficiency**
- **API Calls**: Grouped by date to minimize requests
- **Processing Speed**: ~100-400 data points per API call
- **Success Rate**: 86% gap reduction in test case
- **Resource Usage**: Minimal memory footprint with selective processing
### **Database Performance**
- **SQLite Optimization**: Connection pooling and timeout handling
- **Transaction Efficiency**: Batch inserts with proper transaction management
- **Retry Success**: Automatic recovery from temporary lock conditions
## 🎯 **Best Practices**
### **Regular Maintenance**
1. **Daily**: Run `--check-gaps 1` to monitor recent data quality
2. **Weekly**: Run `--fill-gaps 7` to catch any missed data
3. **Monthly**: Run `--update-data 7` to ensure data accuracy
### **Production Deployment**
1. **Automated Scheduling**: Use cron or systemd timers for regular gap checks
2. **Monitoring**: Set up alerts for excessive missing data
3. **Backup**: Regular database backups before major gap-filling operations
### **Data Quality Assurance**
1. **Validation**: Check for reasonable value ranges after gap filling
2. **Comparison**: Compare filled data with nearby timestamps for consistency
3. **Documentation**: Log all gap-filling activities for audit trails
This enhanced gap-filling system ensures comprehensive and accurate water level monitoring with minimal data loss and automatic recovery capabilities.
-300
View File
@@ -1,300 +0,0 @@
# 🔄 Gitea Actions Workflows - Northern Thailand Ping River Monitor
## 📋 Overview
This document describes the Gitea Actions workflows configured for the Northern Thailand Ping River Monitor project. These workflows provide comprehensive CI/CD, security scanning, and documentation generation.
## 🚀 Available Workflows
### 1. **CI/CD Pipeline** (`.gitea/workflows/ci.yml`)
**Triggers:**
- Push to `main` or `develop` branches
- Pull requests to `main`
- Daily scheduled runs at 2 AM UTC
**Jobs:**
- **Test Suite**: Multi-version Python testing (3.9-3.12)
- **Code Quality**: Linting, formatting, and type checking
- **Build**: Docker image creation and testing
- **Integration Test**: Testing with VictoriaMetrics service
- **Deploy Staging**: Automatic deployment to staging (develop branch)
- **Deploy Production**: Manual deployment to production (main branch)
- **Performance Test**: Load testing after production deployment
**Key Features:**
- ✅ Multi-Python version testing
- ✅ Docker multi-architecture builds (amd64, arm64)
- ✅ Service integration testing
- ✅ Automatic staging deployment
- ✅ Manual production approval
- ✅ Performance validation
### 2. **Security & Dependency Updates** (`.gitea/workflows/security.yml`)
**Triggers:**
- Daily scheduled runs at 3 AM UTC
- Manual dispatch
- Changes to requirements files or Dockerfile
**Jobs:**
- **Dependency Scan**: Safety, Bandit, Semgrep security scans
- **Docker Security**: Trivy vulnerability scanning
- **License Check**: License compliance verification
- **Dependency Updates**: Automated update detection
- **Code Quality**: Complexity and maintainability analysis
**Key Features:**
- 🔒 Daily security scans
- 📦 Dependency vulnerability detection
- 📄 License compliance checking
- 🔄 Automated update notifications
- 📊 Code quality metrics
### 3. **Release Workflow** (`.gitea/workflows/release.yml`)
**Triggers:**
- Git tags matching `v*.*.*` pattern
- Manual dispatch with version input
**Jobs:**
- **Create Release**: Automated release creation with changelog
- **Test Release**: Comprehensive testing across Python versions
- **Build Release**: Multi-architecture Docker images with proper tags
- **Security Scan**: Trivy security scanning of release images
- **Deploy Release**: Production deployment with health checks
- **Validate Release**: Post-deployment validation and testing
**Key Features:**
- 🏷️ Automated release creation
- 📝 Changelog generation
- 🐳 Multi-architecture Docker builds
- 🔒 Security scanning
- ✅ Comprehensive validation
### 4. **Documentation** (`.gitea/workflows/docs.yml`)
**Triggers:**
- Changes to documentation files
- Changes to Python source files
- Manual dispatch
**Jobs:**
- **Validate Docs**: Link checking and structure validation
- **Generate API Docs**: OpenAPI specification generation
- **Build Sphinx Docs**: Comprehensive API documentation
- **Documentation Summary**: Build status and artifact summary
**Key Features:**
- 📚 Automated API documentation
- 🔗 Link validation
- 📖 Sphinx documentation generation
- ✅ Documentation completeness checking
## 🔧 Workflow Configuration
### **Required Secrets**
Configure these secrets in your Gitea repository settings:
```bash
GITEA_TOKEN # Gitea access token for container registry
GITHUB_TOKEN # GitHub personal access token for better rate limits
SLACK_WEBHOOK_URL # Optional: Slack notifications
STAGING_WEBHOOK_URL # Optional: Staging deployment webhook
PRODUCTION_WEBHOOK_URL # Optional: Production deployment webhook
```
**Setting up GitHub Token:**
1. Go to GitHub → Settings → Developer settings → Personal access tokens
2. Generate a new token with `public_repo` scope (for public repos) or `repo` scope (for private repos)
3. Copy the token
4. In your Gitea repository → Settings → Secrets → Add `GITHUB_TOKEN` with the copied value
### **Environment Variables**
Key environment variables used across workflows:
```yaml
PYTHON_VERSION: '3.11' # Default Python version
REGISTRY: git.b4l.co.th # Container registry
IMAGE_NAME: grabowski/northern-thailand-ping-river-monitor
```
## 📊 Workflow Status
### **CI/CD Pipeline Status**
- **Test Coverage**: Multi-version Python testing
- **Code Quality**: Automated linting and formatting
- **Security**: Integrated security scanning
- **Deployment**: Automated staging, manual production
### **Security Monitoring**
- **Daily Scans**: Automated vulnerability detection
- **Dependency Updates**: Proactive update notifications
- **License Compliance**: Automated license checking
- **Code Quality**: Continuous quality monitoring
### **Release Management**
- **Automated Releases**: Tag-based release creation
- **Multi-Architecture**: Support for amd64 and arm64
- **Security Validation**: Pre-deployment security checks
- **Health Monitoring**: Post-deployment validation
## 🚀 Usage Examples
### **Triggering Workflows**
**Manual CI/CD Run:**
```bash
# Push to trigger CI/CD
git push origin main
# Create pull request to trigger testing
git checkout -b feature/new-feature
git push origin feature/new-feature
# Create PR in Gitea UI
```
**Manual Security Scan:**
```bash
# Trigger via Gitea Actions UI
# Go to Actions → Security & Dependency Updates → Run workflow
```
**Creating a Release:**
```bash
# Create and push a tag
git tag v3.1.1
git push origin v3.1.1
# Or use manual dispatch in Gitea Actions UI
```
### **Monitoring Workflow Results**
**Check Workflow Status:**
1. Navigate to your repository in Gitea
2. Click on "Actions" tab
3. View workflow runs and their status
**Download Artifacts:**
1. Click on a completed workflow run
2. Scroll to "Artifacts" section
3. Download reports and logs
**View Security Reports:**
1. Go to Security workflow runs
2. Download security-reports artifacts
3. Review JSON reports for vulnerabilities
## 🔍 Troubleshooting
### **Common Issues**
**Workflow Fails on Dependencies:**
```bash
# Check requirements.txt for version conflicts
pip-compile requirements.in
```
**Docker Build Fails:**
```bash
# Test Docker build locally
make docker-build
docker run --rm ping-river-monitor python run.py --test
```
**Security Scan Failures:**
```bash
# Run security scans locally
safety check -r requirements.txt
bandit -r src/
```
**Test Failures:**
```bash
# Run tests locally
make test
python tests/test_integration.py
```
### **Debugging Workflows**
**Enable Debug Logging:**
Add to workflow file:
```yaml
env:
ACTIONS_STEP_DEBUG: true
ACTIONS_RUNNER_DEBUG: true
```
**Check Workflow Logs:**
1. Go to failed workflow run
2. Click on failed job
3. Expand failed step to see detailed logs
**Validate Workflow Syntax:**
```bash
# Validate YAML syntax
make validate-workflows
```
## 📈 Performance Optimization
### **Caching Strategy**
- **Pip Cache**: Cached across workflow runs
- **Docker Layer Cache**: GitHub Actions cache for faster builds
- **Dependency Cache**: Cached based on requirements.txt hash
### **Parallel Execution**
- **Matrix Builds**: Multiple Python versions tested in parallel
- **Independent Jobs**: Security scans run independently of tests
- **Conditional Execution**: Jobs skip when not needed
### **Resource Management**
- **Timeout Settings**: Prevent hanging workflows
- **Resource Limits**: Appropriate runner sizing
- **Artifact Cleanup**: Automatic cleanup of old artifacts
## 🔒 Security Best Practices
### **Secret Management**
- Use Gitea repository secrets for sensitive data
- Never commit secrets to repository
- Rotate secrets regularly
- Use least-privilege access tokens
### **Container Security**
- Multi-stage Docker builds for smaller images
- Non-root user in containers
- Regular base image updates
- Vulnerability scanning before deployment
### **Code Security**
- Automated security scanning in CI/CD
- Dependency vulnerability monitoring
- License compliance checking
- Code quality enforcement
## 📚 Additional Resources
### **Gitea Actions Documentation**
- [Gitea Actions Overview](https://docs.gitea.io/en-us/usage/actions/)
- [Workflow Syntax](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions)
- [Available Actions](https://github.com/marketplace?type=actions)
### **Project-Specific Resources**
- [Contributing Guide](../CONTRIBUTING.md)
### **Monitoring and Alerts**
- Workflow status badges in README
- Email notifications for failures
- Slack/Discord integration for team updates
- Grafana dashboards for deployment metrics
---
**Workflow Version**: v3.1.3
**Last Updated**: 2025-08-12
**Maintained By**: Ping River Monitor Team
+2 -6
View File
@@ -32,13 +32,9 @@ curl -X POST https://matrix.org/_matrix/client/v3/login \
## Step 4: Configure .env File
Add these to your `.env` file:
```bash
# Matrix Alerting Configuration
MATRIX_HOMESERVER=https://matrix.org
MATRIX_ACCESS_TOKEN=syt_your_access_token_here
MATRIX_ROOM_ID=!your_room_id:matrix.org
# Grafana Integration (optional)
GRAFANA_URL=http://localhost:3000
```
## Step 5: Restart and check
@@ -71,8 +67,8 @@ so a Matrix outage is retried on Matrix only and does not re-send ntfy.
The old `run.py --alert-check` cron job (every 15 minutes, repeating an alert
for as long as a level stayed high) is replaced by this;
`scripts/install_collector.sh` comments it out of the crontab once
ping-collector sends Matrix, keeping a backup next to the app.
`scripts/install.sh` comments it out of the crontab if it is still there,
keeping a backup next to the app.
## Troubleshooting
- **HTTP 401/403** in `journalctl -u ping-collector`: the access token is invalid
+52 -31
View File
@@ -26,7 +26,8 @@ seventy-two. In a quiet season a subscriber hears nothing.
Station slugs are the code lowercased without the dot: `p1`, `p103`, `p67`.
Thresholds are the ones in `src/ml/features.py` (`THRESHOLDS`): P.1 3.70 /
4.20 m, P.103 5.95 / 6.75 m, and so on.
4.20 m (half a metre below the banks / over the banks; the city floods in stages
from 4.30 m), P.103 5.95 / 6.75 m, and so on.
The outlook topic is opt-in for a reason: it is model output, and the message
says so. Observed-crossing topics only ever report a gauge reading.
@@ -36,28 +37,38 @@ the public dashboard.
## How it runs
Since phase 2 of the Rust rewrite the notifier runs in `ping-collector`
(`rust/collector/src/notify.rs`) when `FORECAST_IN_COLLECTOR=true`, after each
forecast run. It is a port of `src/notify.py` with the same topics, texts and
state keys (checked word for word against Python on 751 generated messages),
so switching between the two sends nothing twice. One difference: the Rust
notifier also uses ThaiWater (HII) telemetry. For the stations whose HII twin
The notifier runs in `ping-collector` (`rust/collector/src/notify.rs`) when
`FORECAST_IN_COLLECTOR=true`, after each forecast run. It is a port of the
Python notifier it replaced in 2026-10, with the same topics, texts and state
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.
With `FORECAST_IN_COLLECTOR=false`, `src/notify.py` is called once per collection cycle inside the API process
(leader only), right after the forecast precompute, so it sees exactly the
readings and forecasts the dashboard shows. Per-key last-sent state is stored
in the `notification_state` table of the monitor's own database, so a restart
or redeploy never re-sends and never misses a crossing that happened while
the service was down (the next cycle compares against the persisted state).
It runs in the same process that serves the dashboard, right after the
forecast, so it sees exactly the readings and forecasts the dashboard shows.
Per-key last-sent state is stored in the `notification_state` table of the
monitor's own database, so a restart or redeploy never re-sends and never
misses a crossing that happened while the service was down (the next cycle
compares against the persisted state).
If ntfy is unreachable the transition is **not** recorded, so it is retried
on the next cycle rather than silently lost. Any other failure in the notify
@@ -86,8 +97,8 @@ token, and appends `NTFY_SERVER` (public URL for subscribers),
and `NTFY_TOKEN` to `.env` if they are not there yet. Then:
```bash
systemctl restart water-monitor
journalctl -u water-monitor -n 20 | grep ntfy # "ntfy notifications: https://... topics ping-*"
systemctl restart ping-collector
journalctl -u ping-collector -n 20 | grep ntfy # "ntfy notifications: publish to ..., topics ping-*"
curl -s 'https://ntfy.buildfor.life/ping-status/json?poll=1' # anonymous read works
```
@@ -120,21 +131,31 @@ holds back an alert. Test the pipeline before the domain is live with
| `NTFY_TOKEN` | *(empty)* | bearer token if the server requires auth to publish (it does, see above) |
| `PUBLIC_URL` | `https://water.buildfor.life/` | click-through target in messages |
Tunables in `src/notify.py`: `CLEAR_MARGIN_M` (0.10), `OUTLOOK_ON` / `OUTLOOK_OFF`
(0.50 / 0.25), stale feed threshold (3 h, argument to `evaluate`).
| `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
`rust/forecast/flood_config.json`: regenerate with
`uv run python -m src.ml.export --config` and rebuild): `CLEAR_MARGIN_M`
(0.10), `OUTLOOK_ON` / `OUTLOOK_OFF` (0.50 / 0.25), `FEED_STALE_AFTER_H` (3),
the capacity guard and the station names in the messages.
## Testing
`tests/test_notify.py` covers the state machine: quiet river sends nothing;
crossing once, then silence while above, then all-clear; hysteresis on the way
down; escalation to danger and back; basin digest grouping; outlook on/off;
heuristic forecasts ignored; stale feed and recovery; state survives a restart
through sqlite; a failed publish is retried next cycle.
The tests in `rust/collector/src/notify.rs` cover the state machine: quiet
river sends nothing; crossing once, then silence while above, then all-clear;
hysteresis on the way down; escalation to danger and back; outlook on/off with
heuristic forecasts ignored; HII readings replacing a lagging RID reading;
stale feed only when both sources are; RID lagging reported once; a failed
publish retried next cycle; the Matrix rendering; and the 652-step replay of
the Python notifier's messages. `notification_state` round-trips against
PostgreSQL in `db_tests.rs`.
To exercise the real path against a real ntfy locally: run `ntfy serve` (any
platform, same binary), set `NTFY_SERVER`/`NTFY_TOKEN`, seed readings, and
poll the topic JSON. `scripts/e2e_notify.py` does exactly that if you want a
template.
`ping-collector notify --dry-run` shows the readings judged and what would be
sent right now, changing nothing. To exercise the real path against a real
ntfy locally: run `ntfy serve` (any platform, same binary), point
`NTFY_SERVER`/`NTFY_TOKEN` at it and poll the topic JSON.
## Why ntfy and not …
+25 -10
View File
@@ -1,9 +1,17 @@
# A Rust rewrite: assessment
# The Rust rewrite
Status: **in progress.** Phase 1 (collector) is in production since 2026-10-17,
phase 2 (forecast + ntfy) and phase 3 (website/API + Matrix) since
2026-10-18; see
[rust/README.md](../rust/README.md). Spike: [spikes/rust-forecast](../spikes/rust-forecast/README.md).
Status: **done** (2026-10-18). The service is `ping-collector`
([rust/README.md](../rust/README.md)); Python keeps model training,
evaluation and the archive backfills. Phases 1-3 replaced the Python
collector, the forecast + notifier and the website/API one at a time, each
checked against the Python side on production data before the switch;
phase 4 removed the Python service code. The migration tooling
(`scripts/collector_parity.py`, `scripts/api_parity.py`,
`scripts/install_collector.sh` with its cutover/rollback modes, the
`spikes/rust-forecast` spike) is in git history before the cleanup commit.
The assessment below was written before phase 1 and is kept as the record of
why and how.
## Short version
@@ -87,7 +95,7 @@ This slows model iteration, which is the most active part of the repo.
| 1 | `ping-collector`: RID, HII, dams, Open-Meteo | in production since 2026-10-17 23:09 (Python: `COLLECTION_ENABLED=false`) |
| 2 | Forecast crate + ntfy | in production since 2026-10-18 15:08 (`FORECAST_IN_COLLECTOR=true`); live check on production data: 48/48 rows identical, 96/96 probabilities bit-identical |
| 3 | Website + read API in `ping-collector`, Matrix like ntfy | in production since 2026-10-18 16:40 (`WEB_LISTEN`, `water-monitor.service` disabled); on production data 53 of 53 compared routes identical to FastAPI (`scripts/api_parity.py`) |
| 4 | Cleanup | |
| 4 | Cleanup: Python service code removed | done 2026-10-18 |
0. **Spike: done.** P.1 features + HGB inference, bit-identical.
1. **Collector daemon** (`rust/collector`, ~1.5k LOC incl. tests): RID with the
@@ -96,7 +104,7 @@ This slows model iteration, which is the most active part of the repo.
API runs with `COLLECTION_ENABLED=false` and re-forecasts when the newest
RID reading in the database advances. ntfy moved to phase 2: the outlook
alert needs the forecasts, which are still Python. Cutover and rollback
are one command each (`scripts/install_collector.sh`).
were one command each (`scripts/install_collector.sh`, since removed).
2. **Forecast crate + ntfy** (`rust/forecast`, run by `ping-collector` with
`FORECAST_IN_COLLECTOR=true`): all stations, HII/rain/dam inputs, heuristic
fallback, probabilities and stages, `forecast_history`, `forecast_current`
@@ -130,9 +138,16 @@ This slows model iteration, which is the most active part of the repo.
their own `matrix:` state keys seeded from ntfy's, replacing the
`run.py --alert-check` cron job that repeated alerts every 15 minutes.
Python keeps training, evaluation and the monthly retrain timer.
4. **Cleanup:** drop FastAPI/uvicorn, the Python collector, notifier, CLI and
the multi-backend adapters (SQLite, MySQL, InfluxDB, VictoriaMetrics) and
demo databases; the Python environment shrinks to the training stack.
4. **Cleanup** (done): FastAPI/uvicorn, the Python collector, notifier, Matrix
alerting, CLI (`run.py`), the multi-backend adapters (SQLite, MySQL,
InfluxDB, VictoriaMetrics), demo databases, Docker/Grafana files and the
migration scripts are gone, about 17k lines. The Python environment is the
training stack (pandas, scikit-learn, SQLAlchemy/psycopg2): `src/ml`, the
backfill clients/stores (`src/hii_collector.py`, `src/rid_reservoir.py`)
and `src/ml/predict.py` as the reference implementation the Rust forecaster
is tested against. PostgreSQL only; the Python database tests run against
a throwaway schema (`TEST_DATABASE_URL`, as the Rust ones). One installer,
`scripts/install.sh`.
Decided before phase 3 (2026-10-18): Matrix stays and behaves like ntfy;
`/docs` stays; SQLite dev mode goes; the monthly retrain stays on the same
-241
View File
@@ -1,241 +0,0 @@
# 🏔️ Station Management Guide - Northern Thailand Ping River Monitor
## 🎯 **Overview**
The Northern Thailand Ping River Monitor now includes comprehensive station management capabilities, allowing you to dynamically add, update, and remove monitoring stations through the web API.
## 🌊 **Current Coverage**
The system currently monitors **16 water stations** along the Ping River Basin:
### **Upper Ping River (Chiang Mai Province)**
- **P.20** - Ban Chiang Dao (บ้านเชียงดาว)
- **P.75** - Ban Chai Lat (บ้านช่อแล)
- **P.92** - Ban Muang Aut (บ้านเมืองกึ๊ด)
- **P.4A** - Ban Mae Taeng (บ้านแม่แตง)
- **P.67** - Ban Tae (บ้านแม่แต)
- **P.21** - Ban Rim Tai (บ้านริมใต้)
- **P.103** - Ring Bridge 3 (สะพานวงแหวนรอบ 3)
### **Middle Ping River**
- **P.1** - Nawarat Bridge (สะพานนวรัฐ) - *Main reference station*
- **P.82** - Ban Sob win (บ้านสบวิน)
- **P.84** - Ban Panton (บ้านพันตน)
- **P.81** - Ban Pong (บ้านโป่ง)
- **P.5** - Tha Nang Bridge (สะพานท่านาง)
### **Lower Ping River**
- **P.77** - Baan Sop Mae Sapuord (บ้านสบแม่สะป๊วด)
- **P.87** - Ban Pa Sang (บ้านป่าซาง)
- **P.76** - Banb Mae I Hai (บ้านแม่อีไฮ)
- **P.85** - Baan Lai Kaew (บ้านหล่ายแก้ว)
## 🔧 **Station Management API**
### **List All Stations**
```bash
GET /stations
```
**Response:**
```json
[
{
"station_id": 1,
"station_code": "P.20",
"thai_name": "บ้านเชียงดาว",
"english_name": "Ban Chiang Dao",
"latitude": 19.36731448032191,
"longitude": 98.9688487015384,
"geohash": null,
"status": "active"
}
]
```
### **Get Specific Station**
```bash
GET /stations/{station_id}
```
### **Add New Station**
```bash
POST /stations
Content-Type: application/json
{
"station_code": "P.NEW",
"thai_name": "สถานีใหม่",
"english_name": "New Station",
"latitude": 18.7875,
"longitude": 99.0045,
"geohash": "w5q6uuhvfcfp25",
"status": "active"
}
```
### **Update Station Information**
```bash
PUT /stations/{station_id}
Content-Type: application/json
{
"thai_name": "ชื่อใหม่",
"english_name": "Updated Name",
"latitude": 18.8000,
"longitude": 99.0100
}
```
### **Delete Station**
```bash
DELETE /stations/{station_id}
```
## 🧪 **Testing Station Management**
Use the provided test script to verify station management functionality:
```bash
# Test all station management endpoints
python test_station_management.py
```
This will:
1. List existing stations
2. Create a test station
3. Retrieve station details
4. Update station information
5. Verify changes
6. Delete the test station
7. Confirm deletion
## 📊 **Station Data Model**
### **Required Fields**
- `station_code`: Unique identifier (e.g., "P.1", "P.20")
- `thai_name`: Thai language name
- `english_name`: English language name
### **Optional Fields**
- `latitude`: GPS latitude coordinate (-90 to 90)
- `longitude`: GPS longitude coordinate (-180 to 180)
- `geohash`: Geohash string for location
- `status`: Station status ("active", "inactive", "maintenance", "error")
### **Validation Rules**
- Station codes must be unique
- Latitude must be between -90 and 90
- Longitude must be between -180 and 180
- Names cannot be empty
- Status must be valid enum value
## 🌐 **Web Interface**
Access the station management interface through the web dashboard:
1. **Start the API server:**
```bash
python run.py --web-api
```
2. **Open your browser:**
- Dashboard: http://localhost:8000
- API Documentation: http://localhost:8000/docs
3. **Use the interactive API docs** to test station management endpoints
## 🔄 **Integration with Data Collection**
- **Dynamic Station Discovery**: New stations are automatically included in data collection
- **Real-time Updates**: Station information changes are reflected immediately
- **Data Continuity**: Historical data is preserved when updating station details
- **Error Handling**: Invalid stations are skipped during data collection
## 📍 **Geographic Coverage**
The Ping River Basin monitoring network covers:
- **Total Distance**: ~400 km from Chiang Dao to Nakhon Sawan
- **Elevation Range**: 300m to 1,200m above sea level
- **Catchment Area**: ~25,000 km²
- **Major Cities**: Chiang Mai, Lamphun, Tak, Nakhon Sawan
## 🚀 **Usage Examples**
### **Add a New Upstream Station**
```bash
curl -X POST "http://localhost:8000/stations" \
-H "Content-Type: application/json" \
-d '{
"station_code": "P.UPSTREAM",
"thai_name": "สถานีต้นน้ำ",
"english_name": "Upstream Station",
"latitude": 19.5000,
"longitude": 98.9000,
"status": "active"
}'
```
### **Update Station Coordinates**
```bash
curl -X PUT "http://localhost:8000/stations/1" \
-H "Content-Type: application/json" \
-d '{
"latitude": 19.3700,
"longitude": 98.9700
}'
```
### **Mark Station for Maintenance**
```bash
curl -X PUT "http://localhost:8000/stations/5" \
-H "Content-Type: application/json" \
-d '{
"status": "maintenance"
}'
```
## 🔒 **Best Practices**
### **Station Naming**
- Use consistent code format (P.XX)
- Include both Thai and English names
- Use descriptive location names
### **Coordinate Accuracy**
- Use high-precision GPS coordinates (6+ decimal places)
- Verify coordinates match actual station location
- Include geohash for efficient spatial queries
### **Status Management**
- Set status to "maintenance" during repairs
- Use "inactive" for temporarily offline stations
- Use "error" for stations with data quality issues
### **Data Integrity**
- Test new stations before adding to production
- Backup station configuration before major changes
- Monitor data quality after station updates
## 🎯 **Future Enhancements**
Planned improvements for station management:
1. **Bulk Operations** - Import/export multiple stations
2. **Station Groups** - Organize stations by river section
3. **Automated Validation** - GPS coordinate verification
4. **Historical Tracking** - Track station configuration changes
5. **Alert Integration** - Notifications for station status changes
6. **Map Interface** - Visual station management on interactive map
## 📞 **Support**
For station management issues:
1. Check the API documentation at `/docs`
2. Run the test script: `python test_station_management.py`
3. Review logs for error details
4. Verify station data format and validation rules
The station management system provides flexible control over your monitoring network while maintaining data integrity and system reliability.
-443
View File
@@ -1,443 +0,0 @@
# VictoriaMetrics Setup Guide for Thailand Water Monitor
This guide provides comprehensive instructions for setting up VictoriaMetrics as the time-series database backend for the Thailand Water Monitor.
## Why VictoriaMetrics?
VictoriaMetrics is an excellent choice for water monitoring data because:
- **High Performance**: Up to 10x faster than InfluxDB
- **Low Resource Usage**: Uses 10x less RAM than Prometheus
- **Better Compression**: 70x better compression than Prometheus
- **Prometheus Compatible**: Drop-in replacement for Prometheus
- **Easy to Deploy**: Single binary, no dependencies
- **Cost Effective**: Open source with commercial support available
## Quick Start
### 1. Environment Variables
Set these environment variables to configure VictoriaMetrics:
```bash
# Windows (PowerShell)
$env:DB_TYPE="victoriametrics"
$env:VM_HOST="localhost"
$env:VM_PORT="8428"
# Linux/Mac
export DB_TYPE=victoriametrics
export VM_HOST=localhost
export VM_PORT=8428
```
### 2. Start VictoriaMetrics with Docker
```bash
# Simple setup
docker run -d \
--name victoriametrics \
-p 8428:8428 \
-v victoria-metrics-data:/victoria-metrics-data \
victoriametrics/victoria-metrics:latest \
--storageDataPath=/victoria-metrics-data \
--retentionPeriod=2y \
--httpListenAddr=:8428
# Verify it's running
curl http://localhost:8428/health
```
### 3. Run the Water Monitor
```bash
python water_scraper_v3.py
```
### 4. Access Grafana Dashboard
```bash
# Start with Docker Compose (includes Grafana)
docker-compose -f docker-compose.victoriametrics.yml up -d
# Access Grafana at http://localhost:3000
# Username: admin
# Password: admin_password
```
## Production Setup
### Docker Compose Configuration
Use the provided `docker-compose.victoriametrics.yml` file:
```bash
# Start the complete stack
docker-compose -f docker-compose.victoriametrics.yml up -d
# Check status
docker-compose -f docker-compose.victoriametrics.yml ps
# View logs
docker-compose -f docker-compose.victoriametrics.yml logs -f
```
### Manual VictoriaMetrics Configuration
#### High-Performance Configuration
```bash
docker run -d \
--name victoriametrics \
-p 8428:8428 \
-v victoria-metrics-data:/victoria-metrics-data \
victoriametrics/victoria-metrics:latest \
--storageDataPath=/victoria-metrics-data \
--retentionPeriod=2y \
--httpListenAddr=:8428 \
--maxConcurrentInserts=32 \
--search.maxQueryDuration=60s \
--search.maxConcurrentRequests=16 \
--dedup.minScrapeInterval=30s \
--memory.allowedPercent=80 \
--loggerLevel=INFO \
--loggerFormat=json \
--search.maxSeries=1000000 \
--search.maxPointsPerTimeseries=100000
```
#### Configuration Parameters Explained
| Parameter | Description | Recommended Value |
|-----------|-------------|-------------------|
| `--storageDataPath` | Data storage directory | `/victoria-metrics-data` |
| `--retentionPeriod` | How long to keep data | `2y` (2 years) |
| `--httpListenAddr` | HTTP listen address | `:8428` |
| `--maxConcurrentInserts` | Max concurrent inserts | `32` |
| `--search.maxQueryDuration` | Max query duration | `60s` |
| `--search.maxConcurrentRequests` | Max concurrent queries | `16` |
| `--dedup.minScrapeInterval` | Deduplication interval | `30s` |
| `--memory.allowedPercent` | Max memory usage | `80` |
| `--loggerLevel` | Log level | `INFO` |
| `--search.maxSeries` | Max time series | `1000000` |
### Kubernetes Deployment
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: victoriametrics
spec:
replicas: 1
selector:
matchLabels:
app: victoriametrics
template:
metadata:
labels:
app: victoriametrics
spec:
containers:
- name: victoriametrics
image: victoriametrics/victoria-metrics:latest
ports:
- containerPort: 8428
args:
- --storageDataPath=/victoria-metrics-data
- --retentionPeriod=2y
- --httpListenAddr=:8428
- --maxConcurrentInserts=32
volumeMounts:
- name: storage
mountPath: /victoria-metrics-data
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "2Gi"
cpu: "2000m"
volumes:
- name: storage
persistentVolumeClaim:
claimName: victoriametrics-pvc
---
apiVersion: v1
kind: Service
metadata:
name: victoriametrics
spec:
selector:
app: victoriametrics
ports:
- port: 8428
targetPort: 8428
type: ClusterIP
```
## Data Queries
### HTTP API Queries
VictoriaMetrics provides a Prometheus-compatible HTTP API:
```bash
# Current water levels for all stations
curl "http://localhost:8428/api/v1/query?query=water_level"
# Water levels for specific station
curl "http://localhost:8428/api/v1/query?query=water_level{station_code=\"P.1\"}"
# Average discharge over last hour
curl "http://localhost:8428/api/v1/query?query=avg_over_time(water_discharge[1h])"
# High discharge alerts (>80%)
curl "http://localhost:8428/api/v1/query?query=water_discharge_percent>80"
# Time range query (last 6 hours)
START=$(date -d '6 hours ago' +%s)
END=$(date +%s)
curl "http://localhost:8428/api/v1/query_range?query=water_level&start=${START}&end=${END}&step=300"
```
### PromQL Examples
```promql
# Current water levels
water_level
# Water level trends (last 24h)
water_level[24h]
# Discharge rates by station
water_discharge{station_code="P.1"}
# Average discharge across all stations
avg(water_discharge)
# Stations with high discharge (>80%)
water_discharge_percent > 80
# Rate of change in water level
rate(water_level[5m])
# Maximum water level in last hour
max_over_time(water_level[1h])
# Stations with increasing water levels
increase(water_level[1h]) > 0
```
## Grafana Integration
### Data Source Configuration
1. **Add VictoriaMetrics as Prometheus Data Source**:
- URL: `http://localhost:8428` (or `http://victoriametrics:8428` in Docker)
- Access: Server (default)
- HTTP Method: POST
2. **Import Dashboard**:
- Use the provided `water-monitoring-dashboard.json`
- Or create custom dashboards with the queries above
### Dashboard Panels
The included dashboard provides:
- **Time Series**: Water levels and discharge over time
- **Table**: Current status of all stations
- **Pie Chart**: Discharge percentage distribution
- **Gauge**: Average discharge percentage
- **Variables**: Filter by station
## Monitoring and Maintenance
### Health Checks
```bash
# Check VictoriaMetrics health
curl http://localhost:8428/health
# Check metrics endpoint
curl http://localhost:8428/metrics
# Check configuration
curl http://localhost:8428/api/v1/status/config
```
### Performance Monitoring
```bash
# Query performance stats
curl http://localhost:8428/api/v1/status/tsdb
# Memory usage
curl http://localhost:8428/api/v1/status/runtime
# Active queries
curl http://localhost:8428/api/v1/status/active_queries
```
### Backup and Restore
```bash
# Create backup
docker exec victoriametrics /usr/bin/vmbackup \
-storageDataPath=/victoria-metrics-data \
-dst=fs:///backup/$(date +%Y%m%d)
# Restore from backup
docker exec victoriametrics /usr/bin/vmrestore \
-src=fs:///backup/20250724 \
-storageDataPath=/victoria-metrics-data
```
### Log Analysis
```bash
# View logs
docker logs victoriametrics
# Follow logs
docker logs -f victoriametrics
# Search for errors
docker logs victoriametrics 2>&1 | grep ERROR
```
## Troubleshooting
### Common Issues
1. **Connection Refused**:
```bash
# Check if VictoriaMetrics is running
docker ps | grep victoriametrics
# Check port binding
netstat -tlnp | grep 8428
```
2. **High Memory Usage**:
```bash
# Reduce memory limit
docker run ... --memory.allowedPercent=60 ...
```
3. **Slow Queries**:
```bash
# Increase query timeout
docker run ... --search.maxQueryDuration=120s ...
```
4. **Data Not Appearing**:
```bash
# Check if data is being written
curl "http://localhost:8428/api/v1/query?query=up"
# Check water monitor logs
tail -f water_monitor.log
```
### Performance Tuning
1. **For High Write Load**:
```bash
--maxConcurrentInserts=64
--insert.maxQueueDuration=60s
```
2. **For High Query Load**:
```bash
--search.maxConcurrentRequests=32
--search.maxQueryDuration=120s
```
3. **For Large Datasets**:
```bash
--search.maxSeries=10000000
--search.maxPointsPerTimeseries=1000000
```
## Security
### Authentication
VictoriaMetrics doesn't have built-in authentication. Use a reverse proxy:
```nginx
server {
listen 80;
server_name victoriametrics.example.com;
auth_basic "VictoriaMetrics";
auth_basic_user_file /etc/nginx/.htpasswd;
location / {
proxy_pass http://localhost:8428;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
### TLS/SSL
```bash
# Use nginx or traefik for TLS termination
# Or use VictoriaMetrics with TLS:
docker run ... \
-v /path/to/cert.pem:/cert.pem \
-v /path/to/key.pem:/key.pem \
victoriametrics/victoria-metrics:latest \
--tls \
--tlsCertFile=/cert.pem \
--tlsKeyFile=/key.pem
```
## Scaling
### Cluster Setup
For high availability and horizontal scaling:
```bash
# Start multiple VictoriaMetrics instances
docker run -d --name vm1 -p 8428:8428 victoriametrics/victoria-metrics:latest
docker run -d --name vm2 -p 8429:8428 victoriametrics/victoria-metrics:latest
# Use load balancer to distribute queries
# Use vminsert/vmselect/vmstorage for true clustering
```
### Resource Requirements
| Data Points/Hour | RAM | CPU | Storage/Day |
|------------------|-----|-----|-------------|
| 1,000 | 100MB | 0.1 CPU | 10MB |
| 10,000 | 500MB | 0.5 CPU | 100MB |
| 100,000 | 2GB | 1 CPU | 1GB |
| 1,000,000 | 8GB | 2 CPU | 10GB |
## Migration
### From InfluxDB
```bash
# Export from InfluxDB
influx -database water_monitoring -execute "SELECT * FROM water_data" -format csv > data.csv
# Import to VictoriaMetrics (convert to Prometheus format first)
# Use vmctl tool for migration
```
### From Prometheus
```bash
# Use vmctl for direct migration
vmctl prometheus --prom-snapshot=/path/to/prometheus/data --vm-addr=http://localhost:8428
```
This comprehensive setup guide should help you configure VictoriaMetrics for optimal performance with the Thailand Water Monitor system.
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

@@ -1,543 +0,0 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"liveNow": false,
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "victoriametrics-uid"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"vis": false
},
"lineInterpolation": "linear",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "m"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "victoriametrics-uid"
},
"expr": "water_level",
"interval": "",
"legendFormat": "{{station_code}} - {{station_name_en}}",
"refId": "A"
}
],
"title": "Water Levels by Station",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "victoriametrics-uid"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"vis": false
},
"lineInterpolation": "linear",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "cms"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 0
},
"id": 2,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "victoriametrics-uid"
},
"expr": "water_discharge",
"interval": "",
"legendFormat": "{{station_code}} - {{station_name_en}}",
"refId": "A"
}
],
"title": "Water Discharge by Station",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "victoriametrics-uid"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "auto",
"displayMode": "auto",
"inspect": false
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "Water Level"
},
"properties": [
{
"id": "unit",
"value": "m"
}
]
},
{
"matcher": {
"id": "byName",
"options": "Discharge"
},
"properties": [
{
"id": "unit",
"value": "cms"
}
]
},
{
"matcher": {
"id": "byName",
"options": "Discharge %"
},
"properties": [
{
"id": "unit",
"value": "percent"
}
]
}
]
},
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 8
},
"id": 3,
"options": {
"showHeader": true,
"sortBy": [
{
"desc": false,
"displayName": "Station"
}
]
},
"pluginVersion": "8.5.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "victoriametrics-uid"
},
"expr": "water_level",
"format": "table",
"instant": true,
"interval": "",
"legendFormat": "",
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "victoriametrics-uid"
},
"expr": "water_discharge",
"format": "table",
"instant": true,
"interval": "",
"legendFormat": "",
"refId": "B"
},
{
"datasource": {
"type": "prometheus",
"uid": "victoriametrics-uid"
},
"expr": "water_discharge_percent",
"format": "table",
"instant": true,
"interval": "",
"legendFormat": "",
"refId": "C"
}
],
"title": "Current Station Status",
"transformations": [
{
"id": "merge",
"options": {}
},
{
"id": "organize",
"options": {
"excludeByName": {
"Time": true,
"__name__": true,
"job": true,
"instance": true
},
"indexByName": {
"station_code": 0,
"station_name_en": 1,
"station_name_th": 2,
"Value #A": 3,
"Value #B": 4,
"Value #C": 5
},
"renameByName": {
"Value #A": "Water Level",
"Value #B": "Discharge",
"Value #C": "Discharge %",
"station_code": "Station",
"station_name_en": "English Name",
"station_name_th": "Thai Name"
}
}
}
],
"type": "table"
},
{
"datasource": {
"type": "prometheus",
"uid": "victoriametrics-uid"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"hideFrom": {
"legend": false,
"tooltip": false,
"vis": false
}
},
"mappings": [],
"unit": "percent"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 16
},
"id": 4,
"options": {
"legend": {
"displayMode": "list",
"placement": "right"
},
"pieType": "pie",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "victoriametrics-uid"
},
"expr": "water_discharge_percent",
"interval": "",
"legendFormat": "{{station_code}}",
"refId": "A"
}
],
"title": "Discharge Percentage Distribution",
"type": "piechart"
},
{
"datasource": {
"type": "prometheus",
"uid": "victoriametrics-uid"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "yellow",
"value": 70
},
{
"color": "red",
"value": 90
}
]
},
"unit": "percent"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 16
},
"id": 5,
"options": {
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showThresholdLabels": false,
"showThresholdMarkers": true,
"text": {}
},
"pluginVersion": "8.5.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "victoriametrics-uid"
},
"expr": "avg(water_discharge_percent)",
"interval": "",
"legendFormat": "Average Discharge %",
"refId": "A"
}
],
"title": "Average Discharge Percentage",
"type": "gauge"
}
],
"refresh": "30s",
"schemaVersion": 36,
"style": "dark",
"tags": [
"water-monitoring",
"thailand",
"victoriametrics"
],
"templating": {
"list": [
{
"current": {
"selected": false,
"text": "All",
"value": "$__all"
},
"datasource": {
"type": "prometheus",
"uid": "victoriametrics-uid"
},
"definition": "label_values(water_level, station_code)",
"hide": 0,
"includeAll": true,
"label": "Station",
"multi": true,
"name": "station",
"options": [],
"query": {
"query": "label_values(water_level, station_code)",
"refId": "StandardVariableQuery"
},
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"sort": 1,
"type": "query"
}
]
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "Asia/Bangkok",
"title": "Thailand Water Level Monitoring",
"uid": "water-monitoring-dashboard",
"version": 1,
"weekStart": ""
}
@@ -1,12 +0,0 @@
apiVersion: 1
providers:
- name: 'water-monitoring'
orgId: 1
folder: 'Water Monitoring'
type: file
disableDeletion: false
updateIntervalSeconds: 10
allowUiUpdates: true
options:
path: /var/lib/grafana/dashboards
@@ -1,17 +0,0 @@
apiVersion: 1
datasources:
- name: VictoriaMetrics
type: prometheus
access: proxy
url: http://victoriametrics:8428
isDefault: true
editable: true
jsonData:
httpMethod: POST
queryTimeout: 60s
timeInterval: 30s
customQueryParameters: ''
secureJsonData: {}
version: 1
uid: victoriametrics-uid
+10 -75
View File
@@ -5,122 +5,57 @@ build-backend = "setuptools.build_meta"
[project]
name = "northern-thailand-ping-river-monitor"
version = "3.1.3"
description = "Real-time water level monitoring system for the Ping River Basin in Northern Thailand"
description = "Ping River Monitor: flood-model training, evaluation and archive backfills (the service, ping-collector, is in rust/)"
readme = "README.md"
license = {text = "MIT"}
authors = [
{name = "Ping River Monitor Team", email = "contact@example.com"}
{name = "buildfor.life"}
]
keywords = [
"water monitoring",
"hydrology",
"flood forecasting",
"thailand",
"ping river",
"environmental monitoring",
"time series",
"fastapi",
"real-time data"
]
classifiers = [
"Development Status :: 4 - Beta",
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Science/Research",
"Intended Audience :: System Administrators",
"Topic :: Scientific/Engineering :: Hydrology",
"Topic :: System :: Monitoring",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Operating System :: OS Independent",
"Environment :: Web Environment",
"Framework :: FastAPI"
]
# 3.11 only: pandas 2.0.3 ships no 3.12 wheels; psycopg2-binary 2.9.9 breaks on 3.13
requires-python = ">=3.11,<3.12"
dependencies = [
# Core dependencies
"requests==2.34.2",
"schedule==1.2.0",
"pandas==2.0.3",
"numpy>=1.24,<2",
# Flood forecasting (ML)
"numpy>=1.24,<2", # pandas 2.0.3 wheels are ABI-incompatible with numpy 2.x
"scikit-learn==1.9.0",
# Web API framework
"fastapi==0.141.1",
"uvicorn[standard]==0.52.4",
"pydantic==2.13.5",
# Database adapters
"sqlalchemy==2.0.23",
"influxdb==5.3.1",
"pymysql==1.2.0",
"psycopg2-binary==2.9.9",
# Monitoring and metrics
"psutil==5.9.6"
# .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.optional-dependencies]
dev = [
# Testing
"pytest==9.1.1",
"pytest-cov==4.1.0",
"pytest-asyncio==0.21.1",
# Code formatting and linting
"black==26.5.1",
"flake8==6.1.0",
"isort==5.12.0",
"mypy==1.7.1",
# Pre-commit hooks
"pre-commit==3.5.0",
# Development tools
"ipython==8.17.2",
"jupyter==1.0.0",
# Type stubs
"types-requests==2.33.0.20260906",
"types-python-dateutil==2.8.19.14"
]
docs = [
"sphinx==7.2.6",
"sphinx-rtd-theme==1.3.0",
"sphinx-autodoc-typehints==1.25.2"
]
all = [
"influxdb==5.3.1",
"pymysql==1.2.0",
"psycopg2-binary==2.9.9"
]
[project.scripts]
ping-river-monitor = "src.main:main"
ping-river-api = "src.web_api:main"
[project.urls]
Homepage = "https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor"
Homepage = "https://water.buildfor.life/"
Repository = "https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor"
Issues = "https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/issues"
Documentation = "https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/wiki"
[dependency-groups]
dev = [
# Testing
"pytest==9.1.1",
"pytest-cov==4.1.0",
"pytest-asyncio==0.21.1",
# Code formatting and linting
"black==26.5.1",
"flake8==6.1.0",
"isort==5.12.0",
"mypy==1.7.1",
# Pre-commit hooks
"pre-commit==3.5.0",
# Development tools
"ipython==8.17.2",
"jupyter==1.0.0",
# Type stubs
"types-requests==2.33.0.20260906",
"types-python-dateutil==2.8.19.14",
# Documentation
"sphinx==7.2.6",
"sphinx-rtd-theme==1.3.0",
"sphinx-autodoc-typehints==1.25.2",
"pyinstaller>=6.16.0",
]
[tool.setuptools.packages.find]
-21
View File
@@ -1,29 +1,8 @@
# Development dependencies
-r requirements.txt
# Testing
pytest==9.1.1
pytest-cov==4.1.0
pytest-asyncio==0.21.1
# Code formatting and linting
black==26.5.1
flake8==6.1.0
isort==5.12.0
mypy==1.7.1
# Pre-commit hooks
pre-commit==3.5.0
# Documentation
sphinx==7.2.6
sphinx-rtd-theme==1.3.0
sphinx-autodoc-typehints==1.25.2
# Development tools
ipython==8.17.2
jupyter==1.0.0
# Type stubs
types-requests==2.33.0.20260906
types-python-dateutil==2.8.19.14
+3 -28
View File
@@ -1,34 +1,9 @@
# Core dependencies
# Python side: flood-model training, evaluation, backfills (pyproject.toml is
# the source; uv.lock pins the rest). The service is ping-collector (rust/).
requests==2.34.2
schedule==1.2.0
pandas==2.0.3
numpy>=1.24,<2 # pandas 2.0.3 wheels are ABI-incompatible with numpy 2.x
# Flood forecasting (ML)
scikit-learn==1.9.0
# Web API framework
fastapi==0.141.1
uvicorn[standard]==0.52.4
pydantic==2.13.5
# Database adapters
sqlalchemy==2.0.23
influxdb==5.3.1
pymysql==1.2.0
psycopg2-binary==2.9.9
# Monitoring and metrics
psutil==5.9.6
# Development dependencies (optional)
pytest==9.1.1
pytest-cov==4.1.0
black==26.5.1
flake8==6.1.0
mypy==1.7.1
pre-commit==3.5.0
# Documentation
sphinx==7.2.6
sphinx-rtd-theme==1.3.0
python-dotenv==1.2.2
-15
View File
@@ -1,15 +0,0 @@
#!/usr/bin/env python3
"""
Simple startup script for Thailand Water Monitor
"""
import os
import sys
# Add src directory to Python path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
if __name__ == "__main__":
from src.main import main
main()
+90 -125
View File
@@ -1,163 +1,134 @@
# Rust services
# Rust: the service
Rewrite of the Ping River Monitor in Rust, one service at a time. The plan,
costs and the parity rules are in [docs/RUST_REWRITE.md](../docs/RUST_REWRITE.md).
Since October 2026 the whole Ping River Monitor service is one Rust binary,
`ping-collector`; Python is left with model training and evaluation
(`src/ml`) and the archive backfills. How it got here, and the parity rules
the rewrite followed: [docs/RUST_REWRITE.md](../docs/RUST_REWRITE.md).
| Crate | Replaces | Status |
| --- | --- | --- |
| `collector` (`ping-collector`) | RID, HII/ThaiWater, RID dams and Open-Meteo collection in the Python web app | phase 1: in production since 2026-10-17 |
| `forecast` (`ping-forecast`, library) | the flood forecast (`src/ml` serving path); run by `ping-collector` with ntfy | phase 2: in production since 2026-10-18 |
| `collector`: `src/web` | the FastAPI app (`src/web_api.py`): dashboard, read API, `/docs` | phase 3: in production since 2026-10-18 |
| Crate | What |
| --- | --- |
| `collector` (`ping-collector`) | collection (RID, HII/ThaiWater, RID dams, Open-Meteo), the forecast run, ntfy + Matrix notifications, the website and API (`collector/src/web`) |
| `forecast` (`ping-forecast`, library) | flood-forecast inference from the models trained in Python: features, HII gap fill, gradient-boosted trees, heuristic fallback |
## ping-collector
Collects into the same PostgreSQL tables as the Python app, so the two can be
swapped (and swapped back) with no data migration. The Python web app keeps
serving the API, forecasting and sending ntfy alerts with
`COLLECTION_ENABLED=false`, and re-forecasts whenever the newest RID reading in
the database advances.
```text
ping-collector run # the service: startup gap fill, RID at :05 (+10 min retries
# while RID is behind), HII/dams/rain once per clock hour;
# with FORECAST_IN_COLLECTOR=true also forecast + ntfy/Matrix;
# with WEB_LISTEN the website and API
ping-collector run # the service (systemd): startup gap fill, RID at :05 (+10 min
# retries while RID is behind), HII/dams/rain once per clock
# hour; FORECAST_IN_COLLECTOR=true: forecast + ntfy/Matrix
# after each cycle with newer readings; WEB_LISTEN: website/API
ping-collector serve [--listen A] [--no-analytics]
# the website and API only (default 127.0.0.1:8001)
# the website and API alone (default 127.0.0.1:8001)
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
```
Configuration is the Python app's `.env` (read from the working directory, or
`--env-file`) with python-dotenv's rules, verified on 614 generated inputs
(`collector/testdata/`): an unparsable line is skipped with a warning giving
only its line number, never its value. Variables already set (e.g. by systemd's
`EnvironmentFile=`) win. Used keys:
`POSTGRES_CONNECTION_STRING` (or `POSTGRES_HOST/PORT/DB/USER/PASSWORD`),
`RID_POLL_OFFSET_MINUTES` (5), `RID_RETRY_MINUTES` (10),
`GAP_FILL_DAYS_ON_START` (14), `ENABLE_HII_COLLECTION`,
`ENABLE_RESERVOIR_COLLECTION`, `ENABLE_RAIN_COLLECTION`, `HII_BASIN_CODE` (6),
`STATION_CONFIG_PATH` (falls back to the bundled `src/data/stations.json`),
`SCRAPING_STATUS_FILE` (status JSON for `/scraping/status` when the website runs elsewhere),
`LOG_LEVEL` / `RUST_LOG`; for phase 2 `FORECAST_IN_COLLECTOR` (false),
`MODELS_DIR` (`models`), `NTFY_SERVER` / `NTFY_PUBLISH_URL` / `NTFY_TOPIC_PREFIX`
/ `NTFY_TOKEN` / `PUBLIC_URL` (as the Python app), `RID_STALE_ALERT_HOURS` (18,
0 = off). All timestamps are Asia/Bangkok wall-clock, whatever the server's
time zone.
Configuration: the environment, or `.env` in the working directory
(`--env-file` for another), every key with its default in
[.env.example](../.env.example). `.env` is parsed with python-dotenv's rules
(`collector/src/envfile.rs`, verified on 614 generated inputs) because the
Python tools read the same file: an unparsable line is skipped with a warning
giving only its line number, never its value. Variables already set (e.g. by
systemd's `EnvironmentFile=`) win. All timestamps are Asia/Bangkok wall-clock,
whatever the server's time zone.
Not ported: the one-off archive backfills (`scripts/backfill_*.py`), which
stay in Python.
### Collection
### Verified
The tables, upserts and parsers are the ones the Python collectors had (the
DDL is in `collector/src/db.rs`; the Python backfill stores create the same).
RID's evening report often lags 1-2 h: every poll stores every reading the
report has, yesterday's report is re-fetched while it has holes, and the
startup gap fill repairs the last `GAP_FILL_DAYS_ON_START` days.
- 20 tests: parsers, gap/schedule logic, and database tests against a real
PostgreSQL (late reports, midnight recovery, upsert revisions, NUMERIC
rounding, dam metadata preservation).
- Live parity: the Python collectors and `ping-collector` run back-to-back
against the real upstream APIs into two empty databases produced identical
rows in all 9 tables (16 stations, 1,104 RID readings, 103 + 76 HII rows,
35 dams x 2 days, 168 rain hours). Rerun with
`scripts/collector_parity.py` after changing a parser, table or upsert on
either side.
- The Python API with `COLLECTION_ENABLED=false` served `/health`,
`/measurements/latest`, `/api/stats`, `/scraping/status` and model
forecasts from a database filled only by `ping-collector`.
### Forecast and notifications
## Forecast and notifications (phase 2)
After every cycle that brought newer RID or HII readings (and at least
hourly) the forecast runs over the last 336 h, is stored in `forecast_history`
(for skill tracking) and `forecast_current`, and the notifier compares the
readings and forecasts with `notification_state`.
With `FORECAST_IN_COLLECTOR=true` in the shared `.env`, `ping-collector` runs
the flood forecast after every cycle that brought newer RID or HII readings
(and at least hourly), stores it in `forecast_history` and `forecast_current`
(the Python API serves the latter as `/forecast`, falling back to computing
itself when it is older than `FORECAST_MAX_AGE_SECONDS`), and sends the ntfy
notifications. The Python app then neither forecasts nor notifies.
- **Models** stay trained in Python. `src/ml/export.py` writes
- **Models** are trained in Python. `src/ml/export.py` writes
`models/flood_<station>.json.gz` next to each joblib bundle (`train_all()`
does it, `retrain.sh` promotes it, `python -m src.ml.export` exports
existing bundles); the collector reloads changed files on its next run.
- **Shared constants** (thresholds, flood stages, upstream leads, notify
does it; `python -m src.ml.export` for existing bundles); the service
reloads changed files on its next run, so a retrain needs no restart.
- **Shared constants** (thresholds, flood stages, upstream leads, notifier
settings) are compiled in from `forecast/flood_config.json`, generated by
`uv run python -m src.ml.export --config`. `tests/test_model_export.py`
fails while it differs from Python, and a change needs a collector rebuild.
- **Notifications**: same topics, texts, hysteresis and `notification_state`
keys as `src/notify.py`, so switching sends nothing twice. One change: for
the stations whose ThaiWater twin is the same sensor, a reading newer than
RID's (RID's report lags 2-10 h at night) drives the level alerts, marked
"ThaiWater telemetry; the RID report is not in yet". "Gauge feed stale" now
means no source has a reading for 3 h; RID alone lagging is reported once
after `RID_STALE_ALERT_HOURS`.
- Memory: 70 MB peak with all 15 models loaded (24-byte tree nodes).
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 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
retried on Matrix only. [docs/MATRIX_QUICK_START.md](../docs/MATRIX_QUICK_START.md).
- Memory: under 100 MB resident in production, all 15 models loaded
(24-byte tree nodes); the unit allows 512 MB.
Verified:
### Website and API
- `forecast/tests/parity.rs` replays fixtures from the Python production path
(`forecast/testdata/gen_forecast_fixtures.py`: the 2024 flood rising and at
its peak with RID lagging behind HII, a quiet window with dam data, a
stale-feed window, moved thresholds). HII fill, every feature and every
level are bit-identical; with all 15 local models 377 of 384 probabilities
are bit-identical and the rest within 2 ulp (`exp`). CI checks the P.1 model.
- `scripts/forecast_parity.py` does the same on the live database: Python and
`ping-collector forecast --json` at the same moment with the same rain
series. The install script runs it before `--cutover-forecast`.
- `collector/testdata/gen_notify_cases.py`: 652 hourly steps through
`src/notify.py` (thresholds, capacity guard, outlook, feed gaps, ntfy
outages); the Rust notifier sends the same 751 messages word for word.
## Website and API (phase 3)
`src/web` serves what the FastAPI app served: the dashboard
(`STATIC_DIR`/dashboard.html, default `src/static`, loaded at start), the
crawler files, `/static`, `/docs` + `/redoc` + `/openapi.json`, and every API
route with the same parameters, JSON (field order included), status codes,
`{"detail": ...}` errors, Cache-Control, gzip, CORS (`CORS_ALLOW_ORIGINS`),
Umami API tracking (`UMAMI_*`) and response caches (`*_CACHE_TTL_SECONDS`).
Admin routes need `ADMIN_API_KEY`. With `WEB_LISTEN=0.0.0.0:8000` it runs
inside `ping-collector run` (one process collects, forecasts, notifies and
serves; `/scraping/status` and `POST /scrape/trigger` then talk to the
collection loop directly); `serve` runs it alone.
`collector/src/web` (axum) serves the dashboard (`STATIC_DIR`/dashboard.html,
default `src/static`, loaded at start), `/static`, the crawler files,
`/docs` + `/redoc` + `/openapi.json`, and the read API, with gzip,
Cache-Control per path, CORS (`CORS_ALLOW_ORIGINS`), Umami API tracking
(`UMAMI_*`) and stale-while-revalidate response caches with byte budgets.
Station editing, `/config` and `POST /scrape/trigger` need `ADMIN_API_KEY`
(`X-API-Key` header).
- Parameters are declared once per route (`web/params.rs`): the same list
validates the request (pydantic-style 422s) and builds the OpenAPI
document (`web/openapi.rs`), so `/docs` cannot drift from what is accepted.
The component schemas are FastAPI's (`web/openapi_components.json`).
- Python's output conventions are kept where they show: `isoformat()`
timestamps, `round()`, numpy's pairwise sums for the catchment means
(`web/pyfmt.rs`), the skill metrics' summation order.
- `scripts/api_parity.py --python URL --rust URL` compares the two live on
one database; `install_collector.sh --check-api` / `--cutover-api` run it
on the server against `ping-collector serve` on port 8001.
- The response formats are the retired FastAPI app's, kept so clients did
not notice the switch: field order, `isoformat()` timestamps, Python's
`round()`, numpy's pairwise sums for the catchment means (`web/pyfmt.rs`),
`{"detail": ...}` errors.
- An old stored forecast is served marked `X-Data-Stale: true`; `/health`
counts current ThaiWater telemetry of the same gauges while RID lags.
Matrix (`MATRIX_HOMESERVER`, `MATRIX_ACCESS_TOKEN`, `MATRIX_ROOM_ID`): the
notifier sends the room the same transitions as ntfy (per station, outlook,
feed; not ntfy's per-topic summaries), each once, with its own `matrix:` keys
in `notification_state` (seeded from the ntfy ones on first use, so nothing
already announced is repeated). It replaces the `run.py --alert-check` cron
job, which the install script comments out.
### Develop
## Develop
```bash
cd rust
cargo fmt --all && cargo clippy --all-targets -- -D warnings
docker run -d --name ping-pg -e POSTGRES_PASSWORD=ping -e POSTGRES_DB=ping -p 55432:5432 postgres:16
TEST_DATABASE_URL=postgres://postgres:ping@localhost:55432/ping cargo test
CARGO_TARGET_DIR=target/msrv cargo +1.88 check --all-targets --locked # the minimum version
```
Without `TEST_DATABASE_URL` the database tests skip. CI
(`.gitea/workflows/rust.yml`) runs them against a PostgreSQL service and
uploads the Linux binary as the `ping-collector-linux-x86_64` artifact.
- The forecast's parity with the Python reference: `forecast/tests/parity.rs`
replays fixtures from the Python production path
(`forecast/testdata/gen_forecast_fixtures.py`; regenerate after a change to
`src/ml/features.py`, `predict.py`, the HII fill or the dam frame). Features
and levels must stay bit-identical, probabilities within a few ulp.
`scripts/forecast_parity.py` compares the two on a live database.
- The notifier's texts are pinned by `collector/testdata/notify_python_cases.json.gz`
(751 messages recorded from the Python notifier before it was retired).
HTTP uses native-tls (OpenSSL on Linux), not rustls: app.rid.go.th (dams)
only offers TLS 1.2 CBC cipher suites, which rustls refuses. Building on
Linux needs `pkg-config` and `libssl-dev`.
### Deploy
## Deploy
Needs Rust >= 1.88. Distro `cargo` packages are older (`resolver 3 is not
valid` / `edition 2024` errors), so install the toolchain once with rustup:
@@ -169,21 +140,15 @@ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profil
cargo --version # >= 1.88
```
Then on the server, after `git pull` (the Python side of the switch ships in
the same commit):
Then on the server, in the checkout:
```bash
git pull
cargo build --release --locked --manifest-path rust/Cargo.toml # or BIN=<CI artifact>
sudo bash scripts/install_collector.sh # install + checks, nothing switched
sudo bash scripts/install_collector.sh --cutover # Rust collects from now on
sudo bash scripts/install_collector.sh --cutover-forecast # Rust also forecasts and notifies
sudo bash scripts/install_collector.sh --check-api # compare the Rust API with FastAPI
sudo bash scripts/install_collector.sh --cutover-api # Rust serves the website on :8000
sudo bash scripts/install_collector.sh --rollback-api # FastAPI serves the website again
sudo bash scripts/install_collector.sh --rollback-forecast # forecasts + ntfy back to Python
sudo bash scripts/install_collector.sh --rollback # Python does everything again
sudo bash scripts/install.sh
journalctl -u ping-collector -f
```
Every install exports the models and runs `scripts/forecast_parity.py`;
`--cutover-forecast` refuses to switch if it fails (`FORCE=1` overrides).
`scripts/install.sh` installs the binary and the units, syncs the Python
training environment, runs a dry run of every source and the forecast check,
restarts the service and waits for the website (details in its header).
+15 -6
View File
@@ -1,5 +1,7 @@
//! Configuration from the same environment / `.env` file as the Python app,
//! so both services can share `/opt/thailand-water-monitor/.env`.
//! Configuration from the environment / `.env` file
//! (`/opt/thailand-water-monitor/.env`), which the Python training tools
//! (src/config.py) read too. Names and defaults are the ones the Python app
//! used.
use anyhow::{Context, Result, bail};
use std::env;
@@ -33,10 +35,10 @@ pub struct Config {
/// Days of RID holes re-fetched at startup (0 = off).
pub gap_fill_days_on_start: u32,
pub station_config_path: PathBuf,
/// Status JSON read by the Python API's /scraping/status.
/// Status JSON for /scraping/status when the website runs in a separate
/// process (`ping-collector serve`).
pub status_file: Option<PathBuf>,
/// FORECAST_IN_COLLECTOR: forecast and notify here; the Python API then
/// serves the stored forecast and does neither.
/// FORECAST_IN_COLLECTOR: forecast and notify (off: collect only).
pub forecast_enabled: bool,
/// Exported bundles (`flood_<station>.json.gz`, src/ml/export.py).
pub models_dir: PathBuf,
@@ -48,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).
@@ -62,7 +67,7 @@ pub struct MatrixConfig {
pub room_id: String,
}
/// The website and read API (src/web_api.py's settings, same names).
/// The website and read API (setting names as the retired Python app had them).
#[derive(Clone, Debug)]
pub struct WebConfig {
/// WEB_LISTEN: `run` also serves the website on this address
@@ -209,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 {
+98 -7
View File
@@ -30,8 +30,9 @@ pub async fn connect(url: &str, max_connections: u32) -> Result<PgPool> {
.context("connecting to PostgreSQL")
}
/// Idempotent DDL, identical to what the Python app creates (so either side
/// can start first against an empty database).
/// Idempotent DDL, identical to what the Python app created and the Python
/// backfill stores (src/hii_collector.py, src/rid_reservoir.py) still create,
/// so either can start first against an empty database.
const SCHEMA: &[&str] = &[
"CREATE TABLE IF NOT EXISTS stations (
id SERIAL PRIMARY KEY,
@@ -145,12 +146,12 @@ const SCHEMA: &[&str] = &[
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (as_of, station_code, horizon_hours))",
"CREATE INDEX IF NOT EXISTS idx_forecast_history_station ON forecast_history(station_code, as_of)",
// src/notify.py
// notify.rs
"CREATE TABLE IF NOT EXISTS notification_state (
key VARCHAR(64) PRIMARY KEY, state VARCHAR(16) NOT NULL,
value NUMERIC(8,3), updated_at TIMESTAMP NOT NULL)",
// The current /forecast payload, read by the Python API when
// FORECAST_IN_COLLECTOR=true (one row, id 'all').
// The current /forecast payload (one row, id 'all'), served by
// `ping-collector serve` when the website runs as its own process.
"CREATE TABLE IF NOT EXISTS forecast_current (
id VARCHAR(16) PRIMARY KEY,
computed_at TIMESTAMP NOT NULL,
@@ -236,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,
@@ -266,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")
@@ -664,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],
@@ -682,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(
@@ -727,7 +818,7 @@ pub async fn save_forecast_history(
Ok(rows.len())
}
/// The payload the Python API serves as /forecast.
/// The payload /forecast serves (see `forecast_current` above).
pub async fn save_forecast_current(
pool: &PgPool,
computed_at: NaiveDateTime,
+253
View File
@@ -191,6 +191,156 @@ async fn yesterdays_late_hours_are_recovered_after_midnight() {
db.drop().await;
}
/// A report source with fixed P.1 levels per hour of one report date.
struct ScriptedRid(Mutex<HashMap<NaiveDate, Vec<(i64, f64)>>>);
impl RidSource for ScriptedRid {
async fn fetch_report(&self, date: NaiveDate) -> Result<Vec<Reading>> {
let script = self
.0
.lock()
.unwrap()
.get(&date)
.cloned()
.unwrap_or_default();
Ok(script
.into_iter()
.map(|(h, level)| Reading {
timestamp: date.and_time(NaiveTime::MIN) + Duration::hours(h),
station_num: 8,
water_level: level,
discharge: Some(45.0),
discharge_percent: None,
})
.collect())
}
}
async fn p1_levels(pool: &PgPool) -> Vec<(NaiveDateTime, f64, Option<f64>)> {
sqlx::query_as(
"SELECT timestamp, water_level::float8, discharge::float8 FROM water_measurements
WHERE station_id = 8 ORDER BY timestamp",
)
.fetch_all(pool)
.await
.unwrap()
}
#[tokio::test]
async fn spikes_are_screened_and_refetch_repairs_the_store() {
let Some(db) = TestDb::new().await else {
return;
};
let stations = Stations::bundled();
let day = d(2026, 10, 24);
// what the store held before screening existed: the slip, level-only
db::save_rid(
&db.pool,
&[
Reading {
timestamp: t(2026, 10, 24, 23, 0),
station_num: 8,
water_level: 1.04,
discharge: None,
discharge_percent: None,
},
Reading {
timestamp: t(2026, 10, 25, 0, 0),
station_num: 8,
water_level: 10.6,
discharge: None,
discharge_percent: None,
},
Reading {
timestamp: t(2026, 10, 25, 1, 0),
station_num: 8,
water_level: 1.07,
discharge: Some(45.0),
discharge_percent: None,
},
],
&stations,
)
.await
.unwrap();
// RID still serves the slip: refetch deletes it and fills the discharge
let src = ScriptedRid(Mutex::new(HashMap::from([(
day,
vec![(23, 1.04), (24, 10.6)],
)])));
let s = rid::refetch(&src, &db.pool, &stations, day, day)
.await
.unwrap();
assert_eq!((s.saved, s.spikes, s.deleted, s.changed), (1, 1, 1, 2));
assert_eq!(s.first_change, Some(t(2026, 10, 25, 0, 0)));
assert_eq!(
p1_levels(&db.pool).await,
vec![
(t(2026, 10, 24, 23, 0), 1.04, Some(45.0)),
(t(2026, 10, 25, 1, 0), 1.07, Some(45.0)),
]
);
// RID corrected it: the hour comes back with the right value
src.0
.lock()
.unwrap()
.insert(day, vec![(23, 1.04), (24, 1.06)]);
let s = rid::refetch(&src, &db.pool, &stations, day, day)
.await
.unwrap();
assert_eq!((s.saved, s.spikes, s.deleted, s.changed), (2, 0, 0, 1));
assert_eq!(s.first_change, None); // a new hour, nothing to redo
assert_eq!(p1_levels(&db.pool).await.len(), 3);
// RID revises a level: the forecasts from that hour on need a redo
src.0
.lock()
.unwrap()
.insert(day, vec![(23, 1.05), (24, 1.06)]);
let s = rid::refetch(&src, &db.pool, &stations, day, day)
.await
.unwrap();
assert_eq!(s.first_change, Some(t(2026, 10, 24, 23, 0)));
// a live cycle: the newest hour jumps and waits for the next one
let today = d(2026, 10, 25);
src.0
.lock()
.unwrap()
.insert(today, vec![(1, 1.07), (2, 9.9)]);
// (yesterday's report comes along: it still has holes)
let o = rid::run_cycle(&src, &db.pool, &stations, t(2026, 10, 25, 2, 10)).await;
assert_eq!((o.fetched, o.saved), (4, 3));
assert_eq!(
p1_levels(&db.pool).await.last().unwrap().0,
t(2026, 10, 25, 1, 0)
);
// 03:00 is back at 1.1: 02:00 was a spike and never stored
src.0
.lock()
.unwrap()
.insert(today, vec![(1, 1.07), (2, 9.9), (3, 1.1)]);
let o = rid::run_cycle(&src, &db.pool, &stations, t(2026, 10, 25, 3, 10)).await;
assert_eq!((o.fetched, o.saved), (5, 4));
let levels: Vec<f64> = p1_levels(&db.pool).await.iter().map(|r| r.1).collect();
assert_eq!(levels, vec![1.05, 1.06, 1.07, 1.1]);
// the forecasts issued from the changed hours can be redone
let mut f = forecast_row("P.1", 24, 0.9);
f.as_of = t(2026, 10, 25, 0, 0);
let mut g = forecast_row("P.1", 24, 0.0);
g.as_of = t(2026, 10, 24, 23, 0);
db::save_forecast_history(&db.pool, &[f, g]).await.unwrap();
assert_eq!(
db::delete_model_forecasts_since(&db.pool, t(2026, 10, 25, 0, 0))
.await
.unwrap(),
1
);
db.drop().await;
}
#[tokio::test]
async fn rid_upsert_revises_values_and_rounds_like_numeric() {
let Some(db) = TestDb::new().await else {
@@ -404,6 +554,109 @@ async fn forecast_outputs_round_trip() {
db.drop().await;
}
#[tokio::test]
async fn heuristic_never_replaces_a_model_forecast() {
let Some(db) = TestDb::new().await else {
return;
};
// overnight the run re-issues the 21:00 reading, after 6 h as heuristic
let model = forecast_row("P.1", 24, 0.2);
let heuristic = ping_forecast::Forecast {
p_warning: 0.0,
predicted_max_level: 1.23,
model_version: "heuristic-v1".into(),
source: "heuristic".into(),
..forecast_row("P.1", 24, 0.0)
};
let stored = async || -> (String, f64) {
sqlx::query_as("SELECT source::text, predicted_max_level::float8 FROM forecast_history")
.fetch_one(&db.pool)
.await
.unwrap()
};
db::save_forecast_history(&db.pool, std::slice::from_ref(&model))
.await
.unwrap();
db::save_forecast_history(&db.pool, std::slice::from_ref(&heuristic))
.await
.unwrap();
assert_eq!(stored().await, ("model".into(), 3.123));
// a model row does replace a heuristic one (and a newer model row)
sqlx::query("DELETE FROM forecast_history")
.execute(&db.pool)
.await
.unwrap();
db::save_forecast_history(&db.pool, &[heuristic])
.await
.unwrap();
assert_eq!(stored().await, ("heuristic".into(), 1.23));
db::save_forecast_history(&db.pool, &[model]).await.unwrap();
assert_eq!(stored().await, ("model".into(), 3.123));
db.drop().await;
}
#[tokio::test]
async fn hours_without_a_model_forecast() {
let Some(db) = TestDb::new().await else {
return;
};
let stations = Stations::bundled();
let r = |h: u32, num: u32| Reading {
timestamp: t(2026, 10, 18, h, 0),
station_num: num, // 8 = P.1, 1 = P.20
water_level: 1.1,
discharge: None,
discharge_percent: None,
};
// RID's morning batch: 11..13 arrive at once; only 13:00 (the newest)
// and P.20's 11:00 were forecast, and 12:00 only by the heuristic
db::save_rid(
&db.pool,
&[r(11, 8), r(12, 8), r(13, 8), r(11, 1), r(12, 1), r(13, 1)],
&stations,
)
.await
.unwrap();
let issued = |code: &str, h: u32, source: &str| ping_forecast::Forecast {
as_of: t(2026, 10, 18, h, 0),
source: source.into(),
..forecast_row(code, 24, 0.1)
};
db::save_forecast_history(
&db.pool,
&[
issued("P.1", 13, "model"),
issued("P.20", 13, "model"),
issued("P.20", 11, "model"),
issued("P.1", 12, "heuristic"),
],
)
.await
.unwrap();
let codes = vec!["P.1".to_string(), "P.20".to_string()];
let missing = db::hours_without_model_forecast(
&db.pool,
&codes,
t(2026, 10, 18, 0, 0),
t(2026, 10, 18, 14, 0),
)
.await
.unwrap();
let at = |h: u32, code: &str| (t(2026, 10, 18, h, 0), code.to_string());
assert_eq!(missing, vec![at(11, "P.1"), at(12, "P.1"), at(12, "P.20")]);
// stations without a model are not asked about
let only_p20 = db::hours_without_model_forecast(
&db.pool,
&codes[1..],
t(2026, 10, 18, 0, 0),
t(2026, 10, 18, 14, 0),
)
.await
.unwrap();
assert_eq!(only_p20, vec![at(12, "P.20")]);
db.drop().await;
}
#[tokio::test]
async fn notification_state_round_trip() {
let Some(db) = TestDb::new().await else {
+4 -2
View File
@@ -1,8 +1,10 @@
//! `.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).
//!
//! The Python app reads the same file, so the two must agree on every value.
//! The Python tools (src/config.py) read the same file, so the two must agree
//! on every value.
//! dotenvy (the usual crate) is stricter: a stray `'` or `"` inside an
//! unquoted value makes it swallow the rest of the file and abort, and its
//! error message echoes the line, password included. Here an unparsable
+111 -3
View File
@@ -1,11 +1,12 @@
//! Forecasting and notifications in the collector (FORECAST_IN_COLLECTOR=true),
//! replacing the Python API's forecast_notify_task.
//! Forecasting and notifications in the collector (FORECAST_IN_COLLECTOR=true);
//! the port of the retired Python app's forecast_notify_task.
//!
//! After each collection cycle that brought newer RID or HII readings, and at
//! 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(),
+65 -7
View File
@@ -1,11 +1,11 @@
//! ping-collector: the Ping River Monitor's data collection service.
//! ping-collector: the Ping River Monitor service.
//!
//! Replaces the collection half of the Python web app (RID hourly gauges,
//! HII/ThaiWater rain + water level, RID dams, Open-Meteo rain) and, with
//! FORECAST_IN_COLLECTOR=true, the flood forecast (rust/forecast) and the
//! ntfy/Matrix notifications, and with WEB_LISTEN the website and read API
//! (the port of the FastAPI app). Writes the same PostgreSQL tables as the
//! Python app did. See docs/RUST_REWRITE.md.
//! Collects RID hourly gauges, HII/ThaiWater rain + water level, RID dams
//! and Open-Meteo rain into PostgreSQL; with FORECAST_IN_COLLECTOR=true runs
//! the flood forecast (rust/forecast, models trained in Python) and the
//! ntfy/Matrix notifications; with WEB_LISTEN serves the website and API.
//! It replaced the Python app in 2026-10 (same tables, routes and messages);
//! see docs/RUST_REWRITE.md.
mod config;
mod dams;
@@ -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?;
+133 -23
View File
@@ -1,21 +1,22 @@
//! Public flood notifications over ntfy, and the same transitions to a
//! Matrix room: the port of src/notify.py, run by the collector when
//! FORECAST_IN_COLLECTOR=true. (Matrix replaces the old `run.py
//! --alert-check` cron job, which repeated an alert on every run while a
//! level stayed high.)
//! Matrix room, run by the collector when FORECAST_IN_COLLECTOR=true. A port
//! of the retired Python notifier (src/notify.py, removed after the 2026-10
//! cutover); its settings still come from src/ml/export.py via
//! flood_config.json. (Matrix replaced the old `run.py --alert-check` cron
//! job, which repeated an alert on every run while a level stayed high.)
//!
//! Same topics, texts, hysteresis, capacity guard, outlook and persisted
//! state keys (`notification_state`) as Python, so a cutover neither repeats
//! nor drops 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.
//! state keys (`notification_state`) as Python had, so the cutover neither
//! 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;
@@ -122,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 {
@@ -175,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);
@@ -200,6 +221,7 @@ pub fn evaluate(
);
continue;
}
river_high |= cur != "clear";
if cur == prev {
continue;
}
@@ -301,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"
}) {
@@ -312,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" {
@@ -347,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(
@@ -742,6 +775,7 @@ mod tests {
Settings {
prefix: "ping".into(),
rid_stale_alert_hours: 18.0,
feed_night_until_hour: 9,
}
}
@@ -995,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();
@@ -1011,8 +1120,10 @@ mod tests {
);
}
/// 652 hourly steps from src/notify.py itself (testdata/gen_notify_cases.py):
/// the same notifications, word for word, in the same order.
/// 652 hourly steps recorded from the retired Python notifier (generator:
/// testdata/gen_notify_cases.py, in git history before the cleanup): the
/// same notifications, word for word, in the same order. A deliberate
/// text change updates the fixture with the new expected messages.
#[test]
fn matches_python_notify_on_generated_sequences() {
use std::io::Read;
@@ -1064,13 +1175,12 @@ mod tests {
.map(|v| v.as_str().unwrap())
.collect();
let mut delivered = vec![];
for p in evaluate(
&readings,
&forecasts,
&states,
&settings(),
ts(&step["now"]),
) {
// the Python notifier had no night window
let python = Settings {
feed_night_until_hour: 0,
..settings()
};
for p in evaluate(&readings, &forecasts, &states, &python, ts(&step["now"])) {
if down.contains(&p.notification.topic.as_str()) {
continue;
}
+401 -8
View File
@@ -9,6 +9,12 @@
//! every reading it gets (not only "the current hour"), and re-fetches
//! yesterday's report while the store still has holes in it: hours 22-24 of a
//! day are reachable only through that day's report.
//!
//! Before storing, readings are screened for single-hour spikes (see
//! [`judge`]): RID's report sometimes carries a slipped decimal point, a
//! flipped sign or a placeholder for one hour (P.1 10.6 m between 1.04 and
//! 1.07 on 2026-10-25 00:00), which would raise a false alarm and
//! distort the forecast features for three days.
use crate::db;
use crate::stations::Stations;
@@ -17,7 +23,7 @@ use anyhow::{Context, Result};
use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime};
use serde_json::Value;
use sqlx::PgPool;
use std::collections::{BTreeSet, HashMap, HashSet};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::future::Future;
pub const N_STATIONS: u32 = 16;
@@ -96,6 +102,248 @@ pub fn parse_report(report_date: NaiveDate, payload: &Value) -> Vec<Reading> {
out
}
// ---- Screening -------------------------------------------------------------
/// Neighbours of a reading are looked for this many hours before and after it.
const SCREEN_WINDOW_H: i64 = 3;
/// Gauges on flashy tributaries: genuine 1-h changes of 1.3-3.1 m are on
/// record (P.76 0.09 -> 3.18 m on 2026-05-29). Elsewhere the largest genuine
/// 1-h change since 2018 is 1.1 m (P.1: 0.56 m).
const FLASHY: &[&str] = &["P.76", "P.77", "P.81", "P.82", "P.84", "P.85", "P.87"];
/// A change from the previous reading larger than this (m) needs the next
/// reading to tell a spike from a real change.
pub fn jump_limit(code: &str) -> f64 {
if FLASHY.contains(&code) { 2.0 } else { 1.0 }
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Verdict {
Keep,
/// The newest reading jumped more than twice the limit: wait for the next
/// one (an hour at most while RID is current) before storing it.
Hold,
/// Out and back within an hour or two: not stored.
Spike,
}
/// `prev`: the last accepted level within [`SCREEN_WINDOW_H`] before the
/// reading, `next`: the following reading within the window, if any.
///
/// A spike leaves the previous level by more than `limit` and the next
/// reading comes back, closer to the previous level than half of either
/// jump. A rising or falling limb (prev < level < next) or a step the next
/// reading confirms is kept. 2018-2026 history: 16 spikes (sign flips,
/// slipped decimal points, -5/-6/-9 m placeholders) and no genuine change
/// would have been rejected.
pub fn judge(limit: f64, prev: Option<f64>, level: f64, next: Option<f64>) -> Verdict {
let Some(p) = prev else {
return Verdict::Keep;
};
let out = (level - p).abs();
if out <= limit {
return Verdict::Keep;
}
let Some(n) = next else {
return if out > 2.0 * limit {
Verdict::Hold
} else {
Verdict::Keep
};
};
let back = (level - n).abs();
if back > limit && (n - p).abs() < out.min(back) / 2.0 {
Verdict::Spike
} else {
Verdict::Keep
}
}
/// A reading already in the database: (timestamp, report column, level, discharge).
pub type StoredReading = (NaiveDateTime, u32, f64, Option<f64>);
#[derive(Debug, Default)]
pub struct Screened {
pub keep: Vec<Reading>,
pub held: Vec<Reading>,
pub spikes: Vec<Reading>,
/// Stored rows at a spike's hour that are spikes themselves: delete.
pub delete: Vec<(NaiveDateTime, u32)>,
}
/// Screen a batch against itself and the stored readings around it. A batch
/// reading replaces the stored one at the same hour; stored readings count
/// as accepted. `code` maps a report column to its station code.
pub fn screen(
readings: &[Reading],
stored: &[StoredReading],
code: impl Fn(u32) -> String,
) -> Screened {
#[derive(Clone, Copy)]
enum Src {
Batch(usize),
Stored,
}
let mut out = Screened::default();
let mut by_station: BTreeMap<u32, BTreeMap<NaiveDateTime, (f64, Src)>> = BTreeMap::new();
for (t, n, level, _) in stored {
by_station
.entry(*n)
.or_default()
.insert(*t, (*level, Src::Stored));
}
let stored_at: HashMap<(NaiveDateTime, u32), f64> =
stored.iter().map(|(t, n, l, _)| ((*t, *n), *l)).collect();
// the last copy of an hour wins, as in the upsert
for (i, r) in readings.iter().enumerate() {
by_station
.entry(r.station_num)
.or_default()
.insert(r.timestamp, (r.water_level, Src::Batch(i)));
}
let window = Duration::hours(SCREEN_WINDOW_H);
for (n, series) in &by_station {
let limit = jump_limit(&code(*n));
let mut last: Option<(NaiveDateTime, f64)> = None;
let entries: Vec<_> = series.iter().map(|(t, v)| (*t, *v)).collect();
for (k, &(t, (level, src))) in entries.iter().enumerate() {
let prev = last.filter(|(pt, _)| t - *pt <= window).map(|(_, l)| l);
let i = match src {
Src::Stored => {
last = Some((t, level));
continue;
}
Src::Batch(i) => i,
};
let next = entries
.get(k + 1)
.filter(|(nt, _)| *nt - t <= window)
.map(|(_, (l, _))| *l);
match judge(limit, prev, level, next) {
Verdict::Keep => {
out.keep.push(readings[i].clone());
last = Some((t, level));
}
Verdict::Hold => out.held.push(readings[i].clone()),
Verdict::Spike => {
out.spikes.push(readings[i].clone());
if let Some(&old) = stored_at.get(&(t, *n)) {
if judge(limit, prev, old, next) == Verdict::Spike {
out.delete.push((t, *n));
} else {
last = Some((t, old));
}
}
}
}
}
}
out.keep.sort_by_key(|r| (r.timestamp, r.station_num));
out
}
#[derive(Debug, Default)]
pub struct StoreOutcome {
pub saved: usize,
pub held: usize,
pub spikes: usize,
pub deleted: usize,
/// Saved readings that were new or differ from the stored ones, plus deletions.
pub changed: usize,
/// The earliest hour whose stored level changed or was deleted: the
/// forecasts issued from then on came from a wrong level. (New hours and
/// added discharges do not count: the catch-up forecasts new hours, and
/// a forecast issued without the discharge is still what was issued.)
pub first_change: Option<NaiveDateTime>,
}
/// Screen `readings` against the stored ones around them, then save them and
/// delete stored spikes.
pub async fn store(
pool: &PgPool,
readings: &[Reading],
stations: &Stations,
) -> Result<StoreOutcome> {
let mut out = StoreOutcome::default();
let (Some(first), Some(last)) = (
readings.iter().map(|r| r.timestamp).min(),
readings.iter().map(|r| r.timestamp).max(),
) else {
return Ok(out);
};
let window = Duration::hours(SCREEN_WINDOW_H);
let nums: Vec<u32> = readings
.iter()
.map(|r| r.station_num)
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
let stored = db::rid_readings_between(pool, &nums, first - window, last + window).await?;
let code = |n: u32| stations.get(n).code;
let s = screen(readings, &stored, code);
for r in &s.spikes {
tracing::warn!(
"RID {} {}: {} m is a one-hour spike, not stored",
r.timestamp,
code(r.station_num),
r.water_level
);
}
for r in &s.held {
tracing::warn!(
"RID {} {}: {} m jumps from the previous reading; waiting for the next one",
r.timestamp,
code(r.station_num),
r.water_level
);
}
let before: HashMap<(NaiveDateTime, u32), (f64, Option<f64>)> = stored
.iter()
.map(|(t, n, l, q)| ((*t, *n), (*l, *q)))
.collect();
// NUMERIC(10,3) / (10,2): compare at the stored precision
let differs = |a: f64, b: f64, places: i32| {
let f = 10f64.powi(places);
(a * f).round() != (b * f).round()
};
let mut changed: Vec<NaiveDateTime> = s
.keep
.iter()
.filter(|r| match before.get(&(r.timestamp, r.station_num)) {
None => true,
Some((l, q)) => {
differs(*l, r.water_level, 3)
|| match (q, r.discharge) {
(Some(a), Some(b)) => differs(*a, b, 2),
(None, None) => false,
_ => true,
}
}
})
.map(|r| r.timestamp)
.collect();
changed.extend(s.delete.iter().map(|(t, _)| *t));
let level_changed = s
.keep
.iter()
.filter(|r| {
before
.get(&(r.timestamp, r.station_num))
.is_some_and(|(l, _)| differs(*l, r.water_level, 3))
})
.map(|r| r.timestamp)
.chain(s.delete.iter().map(|(t, _)| *t))
.min();
out.saved = db::save_rid(pool, &s.keep, stations).await?;
out.deleted = db::delete_rid_readings(pool, &s.delete).await?;
out.held = s.held.len();
out.spikes = s.spikes.len();
out.changed = changed.len();
out.first_change = level_changed;
Ok(out)
}
/// Anything that can produce a parsed RID report (HTTP in production,
/// canned rows in tests).
pub trait RidSource {
@@ -248,12 +496,14 @@ pub async fn run_cycle(
tracing::warn!("RID: no data fetched for {:?}", out.dates);
return out;
}
if let Err(e) = db::save_rid(pool, &readings, stations).await {
tracing::error!("RID save failed: {e:#}");
out.error = Some(format!("save failed: {e:#}"));
return out;
match store(pool, &readings, stations).await {
Ok(s) => out.saved = s.saved,
Err(e) => {
tracing::error!("RID save failed: {e:#}");
out.error = Some(format!("save failed: {e:#}"));
return out;
}
}
out.saved = readings.len();
let latest = readings
.iter()
.map(|r| r.timestamp)
@@ -308,8 +558,7 @@ pub async fn fill_gaps(
}
match src.fetch_report(*d).await {
Ok(readings) if !readings.is_empty() => {
db::save_rid(pool, &readings, stations).await?;
filled += readings.len();
filled += store(pool, &readings, stations).await?.saved;
}
Ok(_) => {
empty += 1;
@@ -332,6 +581,52 @@ pub async fn fill_gaps(
Ok(filled)
}
/// Re-fetch the reports of `from..=to` (report dates: day D's report holds
/// D 01:00 to D+1 00:00) whether or not they have holes, and store them
/// through the screening: corrects readings RID has revised since (and
/// fills the discharge of level-only hours), removes stored spikes.
pub async fn refetch(
src: &impl RidSource,
pool: &PgPool,
stations: &Stations,
from: NaiveDate,
to: NaiveDate,
) -> Result<StoreOutcome> {
let mut total = StoreOutcome::default();
let mut day = from;
while day <= to {
if day > from {
tokio::time::sleep(std::time::Duration::from_secs(1)).await; // be polite
}
match src.fetch_report(day).await {
Ok(readings) => {
let s = store(pool, &readings, stations).await?;
tracing::info!(
"refetch {day}: {} fetched, {} saved ({} new or changed), {} spikes rejected, {} stored spikes deleted, {} held",
readings.len(),
s.saved,
s.changed - s.deleted,
s.spikes,
s.deleted,
s.held
);
total.saved += s.saved;
total.held += s.held;
total.spikes += s.spikes;
total.deleted += s.deleted;
total.changed += s.changed;
total.first_change = match (total.first_change, s.first_change) {
(Some(a), Some(b)) => Some(a.min(b)),
(a, b) => a.or(b),
};
}
Err(e) => tracing::warn!("refetch {day}: {e:#}"),
}
day += Duration::days(1);
}
Ok(total)
}
/// Next RID poll: `offset` minutes past the next hour when current, else in
/// `retry` minutes (never later than that hourly poll).
pub fn next_poll_time(
@@ -407,6 +702,104 @@ mod tests {
assert!(!by.contains_key(&5)); // negative discharge drops the reading
}
#[test]
fn judge_spikes_steps_and_limbs() {
use Verdict::*;
// the P.1 slip of 2026-10-25 00:00, a sign flip, a placeholder
assert_eq!(judge(1.0, Some(1.04), 10.6, Some(1.07)), Spike);
assert_eq!(judge(1.0, Some(-1.18), 1.18, Some(-1.18)), Spike);
assert_eq!(judge(2.0, Some(-0.05), -5.0, Some(-0.05)), Spike);
// small changes, no neighbour, a confirmed step, rising limbs
assert_eq!(judge(1.0, Some(1.0), 1.9, Some(1.0)), Keep);
assert_eq!(judge(1.0, None, 10.6, Some(1.07)), Keep);
assert_eq!(judge(1.0, Some(1.0), 2.5, Some(2.6)), Keep);
assert_eq!(judge(2.0, Some(0.04), 1.61, Some(2.67)), Keep);
assert_eq!(judge(1.0, Some(0.82), 1.78, Some(2.82)), Keep);
// a peak that falls back less than halfway in the next hour is real
assert_eq!(judge(1.0, Some(1.0), 3.0, Some(1.8)), Keep);
// the newest reading: a jump over twice the limit waits
assert_eq!(judge(1.0, Some(1.04), 10.6, None), Hold);
assert_eq!(judge(1.0, Some(1.0), 2.5, None), Keep);
assert_eq!(judge(2.0, Some(0.09), 3.18, None), Keep);
assert_eq!(jump_limit("P.76"), 2.0);
assert_eq!(jump_limit("P.1"), 1.0);
}
fn rd(h: u32, n: u32, level: f64) -> Reading {
Reading {
timestamp: t(2026, 10, 25, h, 0),
station_num: n,
water_level: level,
discharge: None,
discharge_percent: None,
}
}
fn code(n: u32) -> String {
if n == 8 {
"P.1".into()
} else {
format!("P.{}", 19 + n)
}
}
#[test]
fn screen_rejects_a_spike_and_deletes_its_stored_copy() {
let stored = vec![
(t(2026, 10, 24, 23, 0), 8, 1.04, None),
(t(2026, 10, 25, 0, 0), 8, 10.6, None),
];
// the report re-fetched, RID still has the slip
let batch = vec![rd(0, 8, 10.6), rd(1, 8, 1.07), rd(2, 8, 1.09)];
let s = screen(&batch, &stored, code);
assert_eq!(s.spikes, vec![rd(0, 8, 10.6)]);
assert_eq!(s.delete, vec![(t(2026, 10, 25, 0, 0), 8)]);
assert_eq!(s.keep, vec![rd(1, 8, 1.07), rd(2, 8, 1.09)]);
// RID corrected it: the new value replaces the stored spike
let s = screen(&[rd(0, 8, 1.06), rd(1, 8, 1.07)], &stored, code);
assert!(s.spikes.is_empty() && s.delete.is_empty());
assert_eq!(s.keep, vec![rd(0, 8, 1.06), rd(1, 8, 1.07)]);
}
#[test]
fn screen_keeps_a_good_stored_copy_and_judges_the_next_hour_by_it() {
let stored = vec![
(t(2026, 10, 25, 0, 0), 8, 1.06, None),
(t(2026, 10, 25, 1, 0), 8, 1.07, None),
];
let s = screen(&[rd(1, 8, 10.7), rd(2, 8, 1.09)], &stored, code);
assert_eq!(s.spikes, vec![rd(1, 8, 10.7)]);
assert!(s.delete.is_empty());
assert_eq!(s.keep, vec![rd(2, 8, 1.09)]);
}
#[test]
fn screen_holds_the_newest_jump_until_the_next_reading() {
let stored = vec![(t(2026, 10, 25, 13, 0), 8, 1.04, None)];
let s = screen(&[rd(14, 8, 10.6)], &stored, code);
assert_eq!(s.held, vec![rd(14, 8, 10.6)]);
assert!(s.keep.is_empty() && s.spikes.is_empty());
// next poll: the report again, now with 15:00
let s = screen(&[rd(14, 8, 10.6), rd(15, 8, 1.06)], &stored, code);
assert_eq!(s.spikes, vec![rd(14, 8, 10.6)]);
assert_eq!(s.keep, vec![rd(15, 8, 1.06)]);
// a real jump the next reading confirms is stored then
let s = screen(&[rd(14, 8, 3.5), rd(15, 8, 3.6)], &stored, code);
assert_eq!(s.keep, vec![rd(14, 8, 3.5), rd(15, 8, 3.6)]);
}
#[test]
fn screen_window_and_stations_are_separate() {
// previous reading 4 h back: nothing to compare with
let stored = vec![(t(2026, 10, 25, 9, 0), 8, 1.0, None)];
let s = screen(&[rd(13, 8, 5.0), rd(13, 1, 0.5)], &stored, code);
assert_eq!(s.keep.len(), 2);
// a jump at one gauge does not involve the others
let stored = vec![(t(2026, 10, 25, 12, 0), 1, 0.1, None)];
let s = screen(&[rd(13, 8, 1.0), rd(13, 1, 0.12)], &stored, code);
assert_eq!(s.keep.len(), 2);
}
#[test]
fn missing_rows_key_is_empty() {
assert!(parse_report(d(2026, 7, 22), &json!({"unexpected": true})).is_empty());
+45 -1
View File
@@ -398,12 +398,56 @@ 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<()> {
if !dry_run && !self.cfg.forecast_enabled {
bail!(
"the Python app notifies while FORECAST_IN_COLLECTOR is not true; \
"notifications are off while FORECAST_IN_COLLECTOR is not true; \
use --dry-run"
);
}
+2 -1
View File
@@ -1,4 +1,5 @@
//! Stale-while-revalidate response cache, as `_cached_swr` in src/web_api.py:
//! Stale-while-revalidate response cache, as the retired FastAPI app's
//! `_cached_swr`:
//!
//! - fresh entry: served;
//! - expired entry (no older than `max_stale`): served at once, marked stale,
+4 -3
View File
@@ -1,8 +1,9 @@
//! The website and read API: the port of the FastAPI app (src/web_api.py).
//! The website and read API: the port of the retired FastAPI app.
//!
//! Same routes, parameters, JSON fields, status codes, cache headers and
//! response caches as the Python app, checked route by route against it by
//! scripts/api_parity.py. It runs inside `ping-collector run` (WEB_LISTEN)
//! response caches as the Python app had; before the cutover (2026-10-18) a
//! route-by-route comparison of the two on production data found no
//! difference. It runs inside `ping-collector run` (WEB_LISTEN)
//! or alone (`ping-collector serve`). /docs is the same Swagger UI, fed by an
//! OpenAPI document built from the route table that also validates the
//! parameters (openapi.rs, params.rs).
+2 -2
View File
@@ -58,8 +58,8 @@ fn check(status: &'static str, message: impl Into<String>) -> Check {
/// Data freshness. As the Python check it judges the newest reading of the
/// first station (`get_latest_measurements(limit=1)`: P.1) against 2 h. One
/// change, as in the notifier: while RID's report lags (2-10 h at night) but
/// the ThaiWater telemetry of the same gauges is current, the data is current.
/// change, as in the notifier: while RID's report lags but the ThaiWater
/// telemetry of the same gauges is current, the data is current.
async fn database_check(st: &Shared) -> Check {
let latest: Result<Option<(chrono::NaiveDateTime,)>, _> = sqlx::query_as(
"SELECT m.timestamp
+1 -1
View File
@@ -1,7 +1,7 @@
//! Route tests: the router as the server runs it, against a throwaway
//! schema (TEST_DATABASE_URL; see db_tests.rs) or, for the routes that do
//! not touch the database, a pool that never connects. Output formats are
//! the FastAPI app's (scripts/api_parity.py compares the two live).
//! the retired FastAPI app's (field order, isoformat, errors).
use super::{AppState, router};
use crate::config::Config;
-127
View File
@@ -1,127 +0,0 @@
"""Notification cases for rust/collector/src/notify.rs, from src/notify.py itself.
uv run python rust/collector/testdata/gen_notify_cases.py
Random hourly sequences around the thresholds (capacity guard, hysteresis,
danger escalation, the P.1 outlook, feed gaps, ntfy outages for some topics)
run through notify.evaluate with an in-memory state; every delivered
notification is recorded. The Rust test replays the same steps (RID readings
only, so the ThaiWater additions stay out of play) and must deliver exactly
the same messages.
"""
import datetime
import gzip
import json
import random
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parents[2]))
from src import notify # noqa: E402
class Publisher(notify.NtfyPublisher):
def __init__(self):
super().__init__("http://ntfy.invalid", prefix="ping")
self.down = set()
def publish(self, n):
return n.topic not in self.down
STATIONS = { # around (warning, danger)
"P.1": (3.7, 4.2),
"P.77": (4.3, 4.9),
"P.21": (3.2, 3.6),
"P.20": (2.35, 2.8),
}
def main():
rng = random.Random(20261018)
sequences = []
for _ in range(40):
state = notify.InMemoryState()
pub = Publisher()
t = datetime.datetime(2026, 9, 1) + datetime.timedelta(
hours=rng.randrange(2000)
)
codes = rng.sample(sorted(STATIONS), rng.randint(1, 4))
levels = {c: STATIONS[c][0] + rng.uniform(-0.6, 0.6) for c in codes}
p_out = rng.random()
steps = []
for _ in range(rng.randint(8, 25)):
gap = rng.choice([1, 1, 1, 1, 2, 4, 6])
t += datetime.timedelta(hours=gap)
# the clock sometimes runs ahead of the data (a stale feed)
now = t + datetime.timedelta(minutes=rng.choice([5, 5, 30, 200, 300]))
readings = []
for c in codes:
levels[c] += rng.uniform(-0.25, 0.25)
pct = rng.choice([None, rng.uniform(5, 120)])
level = round(levels[c], 2) if rng.random() > 0.05 else None
readings.append(
{
"station_code": c,
"timestamp": t,
"water_level": level,
"discharge_percent": None if pct is None else round(pct, 2),
}
)
p_out = min(1.0, max(0.0, p_out + rng.uniform(-0.3, 0.3)))
forecasts = [
{
"station_code": "P.1",
"horizon_hours": rng.choice([24, 24, 24, 12]),
"p_warning": p_out,
"predicted_max_level": round(rng.uniform(2.5, 5.0), 3),
"source": rng.choice(["model", "model", "model", "heuristic"]),
}
]
pub.down = set(
rng.sample(
[
"ping-p1-warning",
"ping-warning",
"ping-status",
"ping-p1-outlook",
],
rng.choice([0, 0, 0, 1]),
)
)
sent = notify.evaluate(readings, forecasts, state, pub, now=now)
steps.append(
{
"now": now.isoformat(),
"readings": [
{**r, "timestamp": r["timestamp"].isoformat()} for r in readings
],
"forecasts": forecasts,
"down": sorted(pub.down),
"sent": [
{
"topic": n.topic,
"title": n.title,
"message": n.message,
"priority": n.priority,
"tags": n.tags or [],
}
for n in sent
],
}
)
sequences.append(steps)
out = HERE / "notify_python_cases.json.gz"
with open(out, "wb") as raw, gzip.GzipFile(fileobj=raw, mode="wb", mtime=0) as gz:
gz.write(json.dumps({"sequences": sequences}).encode())
total = sum(len(s["sent"]) for seq in sequences for s in seq)
print(
f"{len(sequences)} sequences, {sum(map(len, sequences))} steps, {total} notifications"
)
if __name__ == "__main__":
main()
+18 -28
View File
@@ -73,38 +73,28 @@
"P.1": [
{
"stage": 1,
"level": 3.7,
"discharge_cms": 405.0
},
{
"stage": 2,
"level": 3.9,
"discharge_cms": 438.0
},
{
"stage": 3,
"level": 4.0,
"discharge_cms": 458.0
},
{
"stage": 4,
"level": 4.1,
"discharge_cms": 478.0
},
{
"stage": 5,
"level": 4.2,
"discharge_cms": 493.0
},
{
"stage": 6,
"level": 4.3,
"discharge_cms": 508.0
},
{
"stage": 7,
"level": 4.6,
"discharge_cms": 558.0
"stage": 2,
"level": 4.5,
"discharge_cms": 538.0
},
{
"stage": 3,
"level": 4.7,
"discharge_cms": 564.0
},
{
"stage": 4,
"level": 5.0,
"discharge_cms": 604.0
},
{
"stage": 5,
"level": 5.3,
"discharge_cms": 656.0
}
]
},
+3 -2
View File
@@ -2,8 +2,9 @@
//!
//! For stations whose HII twin is the same sensor, hours missing from the RID
//! series (or carrying no level) are filled from HII, shifted by the median
//! offset between the two series over their overlap. This is also what keeps
//! the forecast current at night while RID's report lags.
//! offset between the two series over their overlap. ThaiWater relays RID's
//! own telemetry, so this repairs holes in RID's report but cannot bridge
//! RID's overnight pause (the mirror arrives with the same delay).
use crate::config::config;
use crate::grid::{Row, floor_hour};
Binary file not shown.
Binary file not shown.
Binary file not shown.
-389
View File
@@ -1,389 +0,0 @@
#!/usr/bin/env python3
"""Compare the Rust website/API with the FastAPI app, route by route.
Phase 3 of docs/RUST_REWRITE.md: before the Rust service takes over port
8000, both answer the same requests against the same database and every
response is compared: status, content type, Cache-Control and the JSON body
(floats exactly; lists of database rows without ORDER BY as sets). Static
files and the dashboard byte for byte.
# the FastAPI app on :8000, `ping-collector serve --listen 127.0.0.1:8001`
uv run python scripts/api_parity.py \\
--python http://127.0.0.1:8000 --rust http://127.0.0.1:8001
Query parameters vary per run (limits, hours) so the FastAPI app's response
caches (up to 15 min) answer with fresh data, not an older copy. Data the
collector writes during the run can still make a route differ once: a
mismatch is retried after a pause before it counts.
Intended differences (the Rust side may answer differently):
/health a lagging RID report with current ThaiWater telemetry for
the same gauges is healthy (as the notifier, phase 2)
/scraping/status extra fields (collector version, forecast run)
/metrics process counters: compared by shape only
/sensors/thaiwater live upstream: compared, a difference only warns
/api/hii/rainfall/catchment correlation within 1e-9 (numpy uses BLAS)
/api/stats newer counts on the Rust side are accepted (the FastAPI app
caches this one for 5 minutes)
*.geojson content type application/geo+json (FastAPI: octet-stream)
Exit status 0 when every hard check passed.
"""
import argparse
import random
import sys
import time
from typing import Any, Callable, List, Optional, Tuple
import requests
SOFT = "soft" # a difference warns, does not fail
def get(base: str, path: str, method: str = "GET", **kw) -> requests.Response:
last = None
for _ in range(3):
try:
return requests.request(method, base + path, timeout=120, **kw)
except requests.RequestException as e: # connection reset etc.
last = e
time.sleep(1)
raise last
def first_diff(a: Any, b: Any, path: str = "$", rel: float = 0.0) -> Optional[str]:
"""Where two decoded JSON values differ (None if equal)."""
if isinstance(a, bool) or isinstance(b, bool):
return None if a is b else f"{path}: {a!r} != {b!r}"
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
if a == b:
return None
if rel and abs(a - b) <= rel * max(abs(a), abs(b)):
return None
return f"{path}: {a!r} != {b!r}"
if type(a) is not type(b):
return f"{path}: {type(a).__name__} {a!r} != {type(b).__name__} {b!r}"
if isinstance(a, dict):
if list(a.keys()) != list(b.keys()):
if set(a.keys()) != set(b.keys()):
return f"{path}: keys {sorted(set(a) ^ set(b))} only on one side"
for k in a:
d = first_diff(a[k], b[k], f"{path}.{k}", rel)
if d:
return d
return None
if isinstance(a, list):
if len(a) != len(b):
return f"{path}: {len(a)} items != {len(b)} items"
for i, (x, y) in enumerate(zip(a, b)):
d = first_diff(x, y, f"{path}[{i}]", rel)
if d:
return d
return None
return None if a == b else f"{path}: {a!r} != {b!r}"
def media_type(r: requests.Response) -> str:
return r.headers.get("content-type", "").split(";")[0].strip()
class Case:
def __init__(
self,
path: str,
method: str = "GET",
compare: Optional[Callable] = None,
sort_key: Optional[str] = None,
soft: bool = False,
body: Any = None,
headers: Optional[dict] = None,
):
self.path, self.method, self.compare = path, method, compare
self.sort_key, self.soft, self.body = sort_key, soft, body
self.headers = headers or {}
def run(self, py: str, rs: str) -> Tuple[Optional[str], Optional[str]]:
"""(hard difference, warning)"""
kw = {"headers": self.headers}
if self.body is not None:
kw["json"] = self.body
a = get(py, self.path, self.method, **kw)
b = get(rs, self.path, self.method, **kw)
if a.status_code != b.status_code:
return self._fail(
f"status {a.status_code} != {b.status_code} "
f"({a.text[:120]!r} / {b.text[:120]!r})"
)
if media_type(a) != media_type(b):
warn = f"content-type {media_type(a)!r} != {media_type(b)!r}"
if media_type(a).startswith("application/json") or media_type(b).startswith(
"application/json"
):
return self._fail(warn)
return None, warn
if self.method == "GET" and a.status_code == 200:
ca, cb = a.headers.get("cache-control"), b.headers.get("cache-control")
if ca != cb:
return self._fail(f"Cache-Control {ca!r} != {cb!r}")
if self.compare is not None:
d = self.compare(a, b)
elif media_type(a) == "application/json":
ja, jb = a.json(), b.json()
if a.status_code == 422:
# pydantic's messages are approximated; type and location must agree
ja = [(e["type"], e["loc"]) for e in ja["detail"]]
jb = [(e["type"], e["loc"]) for e in jb["detail"]]
if any(t.startswith("date") for t, _ in ja):
ja, jb = [loc for _, loc in ja], [loc for _, loc in jb]
elif self.sort_key and isinstance(ja, list):
ja = sorted(ja, key=lambda r: str(r.get(self.sort_key)))
jb = sorted(jb, key=lambda r: str(r.get(self.sort_key)))
d = first_diff(ja, jb)
else:
d = (
None
if a.content == b.content
else f"bodies differ ({len(a.content)} / {len(b.content)} bytes)"
)
return self._fail(d) if d else (None, None)
def _fail(self, d: str) -> Tuple[Optional[str], Optional[str]]:
return (None, d) if self.soft else (d, None)
def compare_health(a, b) -> Optional[str]:
ja, jb = a.json(), b.json()
if set(ja["checks"]) != set(jb["checks"]):
return f"checks {sorted(ja['checks'])} != {sorted(jb['checks'])}"
for name, ca in ja["checks"].items():
cb = jb["checks"][name]
if ca["status"] == cb["status"]:
continue
# the intended change: RID lagging, ThaiWater current
if (
name == "database"
and ca["status"] == "degraded"
and cb["status"] == "healthy"
and "ThaiWater telemetry current" in cb["message"]
):
continue
if name in ("api", "memory"):
continue # probes of the RID API / own process, compared loosely
return f"{name}: {ca} != {cb}"
return None
def compare_metrics(a, b) -> Optional[str]:
ja, jb = a.json(), b.json()
return None if set(ja) == set(jb) else f"keys {sorted(ja)} != {sorted(jb)}"
def compare_status(a, b) -> Optional[str]:
ja, jb = a.json(), b.json()
for k, v in ja.items():
if jb.get(k) != v:
return f"{k}: {v!r} != {jb.get(k)!r}"
return None
def compare_skill(a, b) -> Optional[str]:
ja, jb = a.json(), b.json()
va, vb = ja.pop("verified_until"), jb.pop("verified_until")
gap = abs(
time.mktime(time.strptime(va[:19], "%Y-%m-%dT%H:%M:%S"))
- time.mktime(time.strptime(vb[:19], "%Y-%m-%dT%H:%M:%S"))
)
if gap > 300:
return f"verified_until {va} != {vb}"
return first_diff(ja, jb)
def compare_stats(a, b) -> Optional[str]:
"""Exact, except that the FastAPI app may answer from its 5-minute cache
while the collector has written newer rows since."""
ja, jb = a.json(), b.json()
if ja == jb:
return None
for k, v in ja.items():
w = jb.get(k)
if k.endswith("_measurements") or k == "days_spanned":
if not isinstance(w, int) or w < v:
return f"{k}: {v!r} > {w!r}"
elif k == "last_timestamp":
if not isinstance(w, str) or w < v:
return f"{k}: {v!r} > {w!r}"
elif k == "coverage_percent":
if not isinstance(w, (int, float)) or abs(w - v) > 0.2:
return f"{k}: {v!r} != {w!r}"
elif v != w:
return f"{k}: {v!r} != {w!r}"
return None
def compare_catchment(a, b) -> Optional[str]:
return first_diff(a.json(), b.json(), rel=1e-9)
def compare_openapi(a, b) -> Optional[str]:
ja, jb = a.json(), b.json()
if ja["info"]["title"] != jb["info"]["title"]:
return "title differs"
ops = lambda spec: { # noqa: E731
(m, p): op for p, item in spec["paths"].items() for m, op in item.items()
}
oa, ob = ops(ja), ops(jb)
if set(oa) != set(ob):
return f"operations only on one side: {sorted(set(oa) ^ set(ob))}"
for k, op in oa.items():
other = ob[k]
for field in ("operationId", "summary", "parameters", "requestBody"):
d = first_diff(op.get(field), other.get(field), f"{k}.{field}")
if d:
return d
if set(op["responses"]) != set(other["responses"]):
return f"{k}: responses {sorted(op['responses'])} != {sorted(other['responses'])}"
for name in ja["components"]["schemas"]:
if name not in jb["components"]["schemas"]:
return f"schema {name} missing"
return None
def compare_html_docs(a, b) -> Optional[str]:
return (
None
if "swagger-ui" in b.text and "/openapi.json" in b.text
else "no Swagger UI"
)
def cases(admin_key: Optional[str]) -> List[Case]:
r = random.Random(time.time())
hours = r.randint(150, 190) # fresh keys for the Python app's caches
limit = r.randint(40000, 49999)
feed_hours = r.randint(20, 30)
skill_station = r.choice(
["P.67", "P.103", "P.20", "P.75", "P.92", "P.21", "P.5", "P.82"]
)
day = time.strftime("%Y-%m-%d", time.localtime(time.time() - 86400 * 3))
day2 = time.strftime("%Y-%m-%d", time.localtime(time.time() - 86400))
out = [
Case("/"),
Case("/robots.txt"),
Case("/llms.txt"),
Case("/sitemap.xml"),
Case("/static/ping-river-network.geojson", soft=True),
Case("/static/custom-markers.json"),
Case("/static/flood-zones-p1.geojson", soft=True),
Case("/static/flood-2024-all.json"),
Case("/static/nope.json"),
Case("/openapi.json", compare=compare_openapi),
Case("/docs", compare=compare_html_docs),
Case("/health", compare=compare_health),
Case("/metrics", compare=compare_metrics),
Case("/stations"),
Case("/stations/8"),
Case("/stations/1"),
Case("/stations/999"),
Case("/stations/abc"),
Case(f"/measurements/latest?limit={r.randint(200, 499)}"),
Case(f"/measurements/latest?limit={r.randint(1, 15)}"),
Case(f"/measurements/history/P.1?hours={hours}&limit={limit}"),
Case(f"/measurements/history/P.67?hours={hours + 1}"),
Case(f"/measurements/history/P.1?start={day}&end={day2}&limit={limit - 1}"),
Case(f"/measurements/history/P.1?start={day}&limit={r.randint(5, 50)}"),
Case(f"/measurements/history/P.1?start={day2}&end={day}"),
Case(f"/measurements/history/NOPE?hours={hours}"),
Case("/measurements/history/P.1?hours=0"),
Case("/measurements/history/P.1?limit=100001"),
Case("/measurements/history/P.1?start=yesterday"),
Case(f"/measurements/station/P.1?hours={r.randint(20, 48)}"),
Case("/measurements/station/P.103?hours=6&limit=2"),
Case(f"/api/hii/rainfall/latest?hours={feed_hours}", sort_key="station_id"),
Case(
f"/api/hii/waterlevel/latest?hours={feed_hours + 1}", sort_key="station_id"
),
Case("/api/hii/waterlevel/latest?hours=500"),
Case(
f"/api/hii/rainfall/catchment?days={r.randint(15, 30)}",
compare=compare_catchment,
),
Case("/sensors/thaiwater", sort_key="id", soft=True),
Case("/forecast"),
Case(f"/api/forecast/history/P.1?hours={hours}&horizon=24"),
Case(f"/api/forecast/history/P.103?hours={feed_hours}"),
Case(f"/api/forecast/history/P.1?start={day}&end={day2}&horizon=6"),
Case("/api/forecast/history/P.1?horizon=99"),
Case("/api/notifications"),
# a station/horizon pair the dashboard does not ask for: not in the
# FastAPI app's 15-minute cache; the dashboard's own one may be
Case(
f"/api/forecast/skill?station_code={skill_station}"
f"&horizon={r.randint(1, 48)}",
compare=compare_skill,
),
Case(
"/api/forecast/skill?station_code=P.1&horizon=24",
compare=compare_skill,
soft=True,
),
Case("/api/forecast/skill?horizon=0"),
Case("/api/stats", compare=compare_stats),
Case("/scraping/status", compare=compare_status),
Case("/nope"),
Case("/forecast", method="DELETE"),
Case("/config"),
Case("/scrape/trigger", method="POST"),
Case("/stations", method="POST", body={"station_code": "X"}),
Case("/stations/8", method="DELETE"),
]
if admin_key:
key = {"X-API-Key": admin_key}
out += [
Case("/config", headers={"X-API-Key": "wrong"}),
Case("/stations", method="POST", body={"thai_name": 1}, headers=key),
Case("/stations/999", method="PUT", body={}, headers=key),
]
return out
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("--python", default="http://127.0.0.1:8000")
ap.add_argument("--rust", default="http://127.0.0.1:8001")
ap.add_argument(
"--admin-key", help="ADMIN_API_KEY, to compare the admin errors too"
)
ap.add_argument("--retries", type=int, default=2)
ap.add_argument("--pause", type=float, default=30.0, help="seconds before a retry")
args = ap.parse_args()
failed, warned, passed = [], [], 0
for case in cases(args.admin_key):
label = f"{case.method} {case.path}"
for attempt in range(args.retries + 1):
try:
hard, warn = case.run(args.python, args.rust)
except Exception as e: # noqa: BLE001 - report and go on
hard, warn = f"{type(e).__name__}: {e}", None
if not hard or attempt == args.retries:
break
print(f" retry {label}: {hard}", flush=True)
time.sleep(args.pause)
if hard:
failed.append((label, hard))
print(f"FAIL {label}: {hard}", flush=True)
elif warn:
warned.append((label, warn))
print(f"warn {label}: {warn}", flush=True)
else:
passed += 1
print(
f"{passed} identical, {len(warned)} warnings, {len(failed)} different "
f"({args.python} vs {args.rust})"
)
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())
+9 -13
View File
@@ -2,7 +2,7 @@
"""Backfill the openmeteo_rain table with the full 2021+ catchment history.
Usage:
uv run scripts/backfill_rain_db.py # DB from Config/.env
uv run scripts/backfill_rain_db.py # DB from POSTGRES_* / .env
uv run scripts/backfill_rain_db.py --db-url postgresql://...
"""
@@ -13,7 +13,7 @@ import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from src.config import Config
from src.config import database_url
from src.ml.rain import backfill_db
@@ -25,21 +25,17 @@ def main(argv=None) -> int:
logging.basicConfig(
level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s"
)
if args.db_url:
# postgresql+psycopg2://... -> postgresql (driver suffix is not a dialect)
connection_string = args.db_url
db_type = args.db_url.split(":", 1)[0].split("+", 1)[0]
else:
cfg = Config.get_database_config()
if cfg["type"] not in ("sqlite", "postgresql", "mysql"):
print(f"requires a SQL database, got {cfg['type']}", file=sys.stderr)
return 1
connection_string, db_type = cfg["connection_string"], cfg["type"]
connection_string = args.db_url or database_url()
if not connection_string:
print(
"no database: set POSTGRES_CONNECTION_STRING or POSTGRES_*", file=sys.stderr
)
return 1
from sqlalchemy import create_engine
engine = create_engine(connection_string, pool_pre_ping=True)
saved = backfill_db(engine, db_type)
saved = backfill_db(engine)
print(f"backfilled {saved} hourly rows into openmeteo_rain")
return 0 if saved else 1
+10 -19
View File
@@ -3,7 +3,7 @@
Two paths, both idempotent and both skipping what is already stored, so a
rerun repairs holes left by transient failures and is safe alongside the
hourly live collector:
live collector (ping-collector, today + yesterday):
--dam-id (default: Mae Ngat) one dam, whole range, via api/dam — a handful
of requests for the entire 2009-today archive
@@ -26,13 +26,8 @@ import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from src.config import Config
from src.rid_reservoir import (
MAE_NGAT_DAM_ID,
RidReservoirStore,
backfill,
backfill_dam,
)
from src.config import database_url
from src.rid_reservoir import MAE_NGAT_DAM_ID, RidReservoirStore, backfill, backfill_dam
DEFAULT_START = datetime.date(2018, 8, 1) # start of the water_measurements grid
@@ -71,18 +66,14 @@ def main(argv=None) -> int:
logging.basicConfig(
level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s"
)
if args.db_url:
# postgresql+psycopg2://... -> postgresql (driver suffix is not a dialect)
connection_string = args.db_url
db_type = args.db_url.split(":", 1)[0].split("+", 1)[0]
else:
cfg = Config.get_database_config()
if cfg["type"] not in ("sqlite", "postgresql", "mysql"):
print(f"requires a SQL database, got {cfg['type']}", file=sys.stderr)
return 1
connection_string, db_type = cfg["connection_string"], cfg["type"]
connection_string = args.db_url or database_url()
if not connection_string:
print(
"no database: set POSTGRES_CONNECTION_STRING or POSTGRES_*", file=sys.stderr
)
return 1
store = RidReservoirStore(connection_string, db_type)
store = RidReservoirStore(connection_string)
if not store.connect():
print(
"database connection failed — check the connection string",
+188 -75
View File
@@ -14,7 +14,7 @@ non-zero exit if the model gives less than 12 h of warning before the first
3.70 m crossing of the 2024 event.
Usage:
python scripts/backtest_render.py # uses FLOOD_ML_DB_URL/Config
python scripts/backtest_render.py # uses FLOOD_ML_DB_URL / POSTGRES_*
python scripts/backtest_render.py --db-url postgresql://...
"""
@@ -60,7 +60,11 @@ def fit_backtest_model(df_long: pd.DataFrame, train_end: str, use_dam: bool = Fa
rain_series = rain_mod.catchment_mean(rain_mod.load_history())
dam_frame = dam_mod.load_history() if use_dam else None
X, Y, _meta = features.build_matrix(
df_long, STATION, (HORIZON,), stats_end=train_end, rain=rain_series,
df_long,
STATION,
(HORIZON,),
stats_end=train_end,
rain=rain_series,
dam=dam_frame,
)
train_mask = X.index <= pd.Timestamp(train_end)
@@ -92,6 +96,7 @@ def event_series(df_long, X, reg, clf, window_start: str, window_end: str):
# whichever is more alarmed. The classifier alone proved unreliable on
# out-of-distribution extremes (silent on the 2024 record flood).
import numpy as np
p_clf = clf.predict_proba(Xw)[:, 1]
p_sig = 1.0 / (1.0 + np.exp(-(forecasts["pred_max"] - STAGE1) / 0.15))
forecasts["p_flood"] = np.maximum(p_clf, p_sig)
@@ -109,55 +114,107 @@ def _style_axes(ax):
ax.set_axisbelow(True)
def render(observed, forecasts, flood_start, first_alert, out_path, *,
title, subtitle, detail=False, show_stage7=False, peak_note=None):
def render(
observed,
forecasts,
flood_start,
first_alert,
out_path,
*,
title,
subtitle,
detail=False,
show_stage7=False,
peak_note=None,
):
fig, (ax, axp) = plt.subplots(
2, 1, figsize=(12.6, 7.6), sharex=True,
2,
1,
figsize=(12.6, 7.6),
sharex=True,
gridspec_kw={"height_ratios": [2.2, 1], "hspace": 0.12},
)
fig.patch.set_facecolor("white")
marker = dict(marker="o", markersize=3) if detail else {}
ax.plot(observed.index, observed.values, color=BLUE, linewidth=2.2,
label="Observed level" + (" (hourly)" if detail else ""), **marker)
ax.plot(
observed.index,
observed.values,
color=BLUE,
linewidth=2.2,
label="Observed level" + (" (hourly)" if detail else ""),
**marker,
)
marker = dict(marker="s", markersize=3) if detail else {}
ax.plot(forecasts.index, forecasts["pred_max"], color=AMBER, linewidth=2,
linestyle="--", label="Predicted 24 h peak (issued at that hour)", **marker)
ax.plot(
forecasts.index,
forecasts["pred_max"],
color=AMBER,
linewidth=2,
linestyle="--",
label="Predicted 24 h peak (issued at that hour)",
**marker,
)
ax.axhline(STAGE1, color=RED, linewidth=1, alpha=0.65)
ax.annotate(f"{STAGE1:.2f} m · stage 1 · flooding begins", xy=(0.06, STAGE1),
xycoords=("axes fraction", "data"), xytext=(0, 5),
textcoords="offset points", color=RED, fontsize=10.5)
ax.annotate(
f"{STAGE1:.2f} m · stage 1 · flooding begins",
xy=(0.06, STAGE1),
xycoords=("axes fraction", "data"),
xytext=(0, 5),
textcoords="offset points",
color=RED,
fontsize=10.5,
)
if show_stage7:
ax.axhline(STAGE7, color=RED, linewidth=1, alpha=0.65)
ax.annotate(f"{STAGE7:.2f} m · stage 7 · widespread", xy=(0.06, STAGE7),
xycoords=("axes fraction", "data"), xytext=(0, 5),
textcoords="offset points", color=RED, fontsize=10.5)
ax.annotate(
f"{STAGE7:.2f} m · stage 7 · widespread",
xy=(0.06, STAGE7),
xycoords=("axes fraction", "data"),
xytext=(0, 5),
textcoords="offset points",
color=RED,
fontsize=10.5,
)
if peak_note:
peak_ts = observed.idxmax()
ax.annotate(peak_note, xy=(peak_ts, observed.max()),
xytext=(12, 10), textcoords="offset points",
color=BLUE, fontsize=11.5, fontweight="bold")
ax.annotate(
peak_note,
xy=(peak_ts, observed.max()),
xytext=(12, 10),
textcoords="offset points",
color=BLUE,
fontsize=11.5,
fontweight="bold",
)
ax.set_ylabel("P.1 water level (m)", color=INK, fontsize=11.5)
ax.legend(loc="upper left", frameon=False, fontsize=10.5)
_style_axes(ax)
axp.plot(forecasts.index, forecasts["p_flood"], color=AMBER, linewidth=1.8)
axp.fill_between(forecasts.index, 0, forecasts["p_flood"],
color=AMBER, alpha=0.28)
axp.fill_between(forecasts.index, 0, forecasts["p_flood"], color=AMBER, alpha=0.28)
axp.axhline(0.5, color=INK, linewidth=0.9, linestyle=":", alpha=0.6)
axp.set_ylim(-0.02, 1.1)
axp.set_ylabel(f"P(flooding within {HORIZON} h)", color=INK, fontsize=11.5)
_style_axes(axp)
if first_alert is not None:
lead_h = None if flood_start is None else \
int((flood_start - first_alert).total_seconds() // 3600)
lead_txt = "" if lead_h is None else (
f"\n({lead_h} h before flooding began)" if lead_h >= 0
else f"\n({-lead_h} h after flooding began)"
lead_h = (
None
if flood_start is None
else int((flood_start - first_alert).total_seconds() // 3600)
)
lead_txt = (
""
if lead_h is None
else (
f"\n({lead_h} h before flooding began)"
if lead_h >= 0
else f"\n({-lead_h} h after flooding began)"
)
)
if detail and flood_start is not None:
for a in (ax, axp):
@@ -166,34 +223,61 @@ def render(observed, forecasts, flood_start, first_alert, out_path, *,
# Anchor labels away from each other in chronological order so a
# late alert (alert AFTER crossing) cannot overprint the labels.
events = sorted(
[(first_alert, "model alert", AMBER), (flood_start, "flooding begins", BLUE)]
[
(first_alert, "model alert", AMBER),
(flood_start, "flooding begins", BLUE),
]
)
for (ts, label, color), (offset, align) in zip(events, ((-8, "right"), (8, "left"))):
ax.annotate(f"{label}\n{ts:%d %b %H:%M}",
xy=(ts, observed.min()), xytext=(offset, 18),
textcoords="offset points", ha=align,
color=color, fontsize=11, fontweight="bold")
for (ts, label, color), (offset, align) in zip(
events, ((-8, "right"), (8, "left"))
):
ax.annotate(
f"{label}\n{ts:%d %b %H:%M}",
xy=(ts, observed.min()),
xytext=(offset, 18),
textcoords="offset points",
ha=align,
color=color,
fontsize=11,
fontweight="bold",
)
mid_y = observed.min() + (observed.max() - observed.min()) * 0.28
ax.annotate("", xy=(flood_start, mid_y), xytext=(first_alert, mid_y),
arrowprops=dict(arrowstyle="<->", color=INK, lw=1.3))
ax.annotate(
"",
xy=(flood_start, mid_y),
xytext=(first_alert, mid_y),
arrowprops=dict(arrowstyle="<->", color=INK, lw=1.3),
)
arrow_label = (
f"{lead_h} h warning" if lead_h >= 0 else f"alert {-lead_h} h late"
)
ax.annotate(arrow_label,
xy=(first_alert + (flood_start - first_alert) / 2, mid_y),
xytext=(0, 8), textcoords="offset points", ha="center",
color=INK, fontsize=11.5, fontweight="bold")
ax.annotate(
arrow_label,
xy=(first_alert + (flood_start - first_alert) / 2, mid_y),
xytext=(0, 8),
textcoords="offset points",
ha="center",
color=INK,
fontsize=11.5,
fontweight="bold",
)
else:
axp.annotate(f"first alert · {first_alert:%d %b %H:%M}{lead_txt}",
xy=(first_alert, 0.62), xytext=(10, 0),
textcoords="offset points", color=RED, fontsize=10.5,
bbox=dict(facecolor="white", alpha=0.75, edgecolor="none"))
axp.annotate(
f"first alert · {first_alert:%d %b %H:%M}{lead_txt}",
xy=(first_alert, 0.62),
xytext=(10, 0),
textcoords="offset points",
color=RED,
fontsize=10.5,
bbox=dict(facecolor="white", alpha=0.75, edgecolor="none"),
)
locator = mdates.DayLocator(interval=1 if detail else 3)
axp.xaxis.set_major_locator(locator)
axp.xaxis.set_major_formatter(mdates.DateFormatter("%d %b"))
fig.suptitle(f"{title}\n{subtitle}", x=0.07, y=0.985, ha="left",
fontsize=15, color=INK)
fig.suptitle(
f"{title}\n{subtitle}", x=0.07, y=0.985, ha="left", fontsize=15, color=INK
)
fig.subplots_adjust(top=0.885, left=0.07, right=0.97, bottom=0.07)
fig.savefig(out_path, dpi=110)
plt.close(fig)
@@ -204,16 +288,20 @@ def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--db-url", default=None)
parser.add_argument("--out-dir", default=os.path.join("docs", "img"))
parser.add_argument("--dam", action="store_true",
help="ablation: include Mae Ngat reservoir features "
"(2026-08 result: costs 1-3 h of alert lead)")
parser.add_argument("--no-hii-fill", action="store_true",
help="ablation: load without the HII gap-fill merge")
parser.add_argument(
"--dam",
action="store_true",
help="ablation: include Mae Ngat reservoir features "
"(2026-08 result: costs 1-3 h of alert lead)",
)
parser.add_argument(
"--no-hii-fill",
action="store_true",
help="ablation: load without the HII gap-fill merge",
)
args = parser.parse_args(argv)
df = data.load_measurements(
db_url=args.db_url, hii_fill=not args.no_hii_fill
)
df = data.load_measurements(db_url=args.db_url, hii_fill=not args.no_hii_fill)
if df.empty:
print("no measurement data available", file=sys.stderr)
return 1
@@ -222,41 +310,66 @@ def main(argv=None) -> int:
# --- October 2024 record flood: trained only on data before 1 Sep 2024 ---
X, reg, clf = fit_backtest_model(df, "2024-08-31", use_dam=args.dam)
obs, fc, flood_start, first_alert = event_series(
df, X, reg, clf, "2024-09-10", "2024-10-14 23:00")
df, X, reg, clf, "2024-09-10", "2024-10-14 23:00"
)
peak = float(obs.max())
render(obs, fc, flood_start, first_alert,
os.path.join(args.out_dir, "backtest-2024-p1.png"),
title="October 2024 flood: what the model saw coming",
subtitle="P.1 Nawarat Bridge — model trained only on data before 1 Sep 2024",
show_stage7=True, peak_note=f"record peak {peak:.2f} m")
render(
obs,
fc,
flood_start,
first_alert,
os.path.join(args.out_dir, "backtest-2024-p1.png"),
title="October 2024 flood: what the model saw coming",
subtitle="P.1 Nawarat Bridge — model trained only on data before 1 Sep 2024",
show_stage7=True,
peak_note=f"record peak {peak:.2f} m",
)
obs_d, fc_d, flood_d, alert_d = event_series(
df, X, reg, clf, "2024-09-21 18:00", "2024-09-28 06:00")
df, X, reg, clf, "2024-09-21 18:00", "2024-09-28 06:00"
)
lead_h = None
if alert_d is not None and flood_d is not None:
lead_h = int((flood_d - alert_d).total_seconds() // 3600)
render(obs_d, fc_d, flood_d, alert_d,
os.path.join(args.out_dir, "backtest-2024-p1-detail.png"),
title="Detection in detail: 22–28 September 2024, hour by hour",
subtitle=(
f"the model alerts {lead_h} h before the river crosses the flooding line"
if lead_h is not None and lead_h > 0
else "model alert vs the river crossing the flooding line"
),
detail=True)
render(
obs_d,
fc_d,
flood_d,
alert_d,
os.path.join(args.out_dir, "backtest-2024-p1-detail.png"),
title="Detection in detail: 22–28 September 2024, hour by hour",
subtitle=(
f"the model alerts {lead_h} h before the river crosses the flooding line"
if lead_h is not None and lead_h > 0
else "model alert vs the river crossing the flooding line"
),
detail=True,
)
# --- September 2025 flood: the deployed configuration (trained <= 2024) ---
X25, reg25, clf25 = fit_backtest_model(df, "2024-12-31", use_dam=args.dam)
obs25, fc25, flood25, alert25 = event_series(
df, X25, reg25, clf25, "2025-09-22", "2025-10-02 12:00")
pred_at_alert = float(fc25.loc[alert25:, "pred_max"].iloc[:24].max()) if alert25 is not None else None
df, X25, reg25, clf25, "2025-09-22", "2025-10-02 12:00"
)
pred_at_alert = (
float(fc25.loc[alert25:, "pred_max"].iloc[:24].max())
if alert25 is not None
else None
)
note = f"peak {float(obs25.max()):.2f} m" + (
f" (predicted {pred_at_alert:.2f} m)" if pred_at_alert is not None else "")
render(obs25, fc25, flood25, alert25,
os.path.join(args.out_dir, "backtest-2025-p1.png"),
title="The September 2025 flood — as forecast by the deployed configuration",
subtitle="model trained only on data through 2024; this event was never seen in training",
detail=True, peak_note=note)
f" (predicted {pred_at_alert:.2f} m)" if pred_at_alert is not None else ""
)
render(
obs25,
fc25,
flood25,
alert25,
os.path.join(args.out_dir, "backtest-2025-p1.png"),
title="The September 2025 flood — as forecast by the deployed configuration",
subtitle="model trained only on data through 2024; this event was never seen in training",
detail=True,
peak_note=note,
)
print(f"2024: flooding began {flood_start}, first alert {first_alert}")
print(f"2025: flooding began {flood25}, first alert {alert25}")
+71
View File
@@ -0,0 +1,71 @@
"""Build src/static/flood-marks-2024.json from CMU Water Center's flood marks.
After the October 2024 flood (P.1 peaked at 5.30 m on 5 October), CMU Water
Center marked the high-water line at about 5,000 places in Chiang Mai and
Lamphun (CMFlood, "Flood marks 2024"): the depth above the road surface, in
cm. This keeps position and depth only, in a compact array form:
uv run python scripts/build_flood_marks.py # fetch from CMU
uv run python scripts/build_flood_marks.py --geojson F # or a saved copy
"""
import argparse
import json
import urllib.request
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
OUT = REPO / "src" / "static" / "flood-marks-2024.json"
URL = "https://watercenter.cmu.ac.th/data/New_floodMark_2025_fixed_like_pole.geojson"
SOURCE = (
"CMU Water Center (ศูนย์วิชาการสนับสนุนการบริหารจัดการน้ำ มหาวิทยาลัยเชียงใหม่), "
"flood marks of October 2024, "
"https://watercenter.cmu.ac.th/chiangmai/cmflood/warning/floodmark"
)
def load(path: Path | None) -> dict:
if path:
return json.loads(path.read_text(encoding="utf-8"))
req = urllib.request.Request(URL, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=120) as r:
return json.loads(r.read().decode("utf-8"))
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--geojson", type=Path, help="read CMU's GeoJSON from here")
args = ap.parse_args()
marks, skipped = [], 0
for f in load(args.geojson)["features"]:
p = f.get("properties") or {}
lon, lat, depth = p.get("longitude"), p.get("latitude"), p.get("water_level")
if lon is None or lat is None or depth is None:
skipped += 1
continue
# 18-19.5 N, 98.5-99.5 E: a swapped or empty coordinate is skipped
if not (18.0 < lat < 19.5 and 98.5 < lon < 99.5) or depth <= 0:
skipped += 1
continue
marks.append([round(lon, 5), round(lat, 5), round(depth)])
marks.sort(key=lambda m: (m[1], m[0]))
doc = {
"source": SOURCE,
"p1_level": 5.30,
"date": "2024-10-05",
"fields": ["lon", "lat", "depth_cm_above_road"],
"marks": marks,
}
OUT.write_text(
json.dumps(doc, ensure_ascii=False, separators=(",", ":")) + "\n",
encoding="utf-8",
)
print(
f"wrote {OUT.relative_to(REPO)}: {len(marks)} marks, {skipped} skipped, "
f"{OUT.stat().st_size / 1024:.0f} KB"
)
if __name__ == "__main__":
main()
+151
View File
@@ -0,0 +1,151 @@
# /// script
# requires-python = ">=3.11"
# dependencies = ["shapely>=2.0"]
# ///
"""Build src/static/flood-zones-p1.geojson from CMU Water Center's flood map.
CMU's Chiang Mai flood hazard map (watercenter.cmu.ac.th, CMFlood, built from
the October 2024 flood) publishes the flooded area at five P.1 levels as KML:
/data/KML5Layer/L1.kml (P.1 = 4.30 m) to L5.kml (5.30 m, the 2024 record).
Each file is the whole extent at that level. The dashboard wants one ring per
stage instead: zone k is what floods first when P.1 reaches stage k's level
(the extent at level k minus everything flooded at a lower level).
uv run scripts/build_flood_zones.py # fetch the KML from CMU
uv run scripts/build_flood_zones.py --kml-dir D # or read L1..L5.kml from D
The levels must match P1_FLOOD_STAGES in src/ml/features.py.
"""
import argparse
import json
import re
import urllib.request
from pathlib import Path
import shapely
from shapely import make_valid
from shapely.geometry import MultiPolygon, Polygon, mapping
from shapely.ops import unary_union
REPO = Path(__file__).resolve().parents[1]
OUT = REPO / "src" / "static" / "flood-zones-p1.geojson"
BASE = "https://watercenter.cmu.ac.th/data/KML5Layer"
LEVELS = [4.30, 4.50, 4.70, 5.00, 5.30] # L1..L5, P.1 gauge level in m
# ~3 m at Chiang Mai: invisible at the zoom levels the map allows, and it
# keeps the file small. Slivers below MIN_AREA (about 300 m²) come from the
# layers' borders not lining up exactly and are dropped.
SIMPLIFY_DEG = 3e-5
MIN_AREA_DEG2 = 2.5e-8
GRID = 1e-5 # coordinates snapped to 5 decimals (~1 m) with valid geometry
SOURCE = (
"CMU Water Center (ศูนย์วิชาการสนับสนุนการบริหารจัดการน้ำ มหาวิทยาลัยเชียงใหม่), "
"Chiang Mai flood hazard map from the October 2024 flood, "
"https://watercenter.cmu.ac.th/chiangmai/cmflood/warning/floodmap"
)
def fetch(i: int, kml_dir: Path | None) -> str:
if kml_dir:
return (kml_dir / f"L{i}.kml").read_text(encoding="utf-8")
req = urllib.request.Request(
f"{BASE}/L{i}.kml", headers={"User-Agent": "Mozilla/5.0"}
)
with urllib.request.urlopen(req, timeout=60) as r:
return r.read().decode("utf-8")
def ring(text: str) -> list:
pts = []
for tok in text.split():
x, y, *_ = tok.split(",")
pts.append((float(x), float(y)))
return pts
def kml_extent(text: str):
polys = []
for body in re.findall(r"<Polygon>(.*?)</Polygon>", text, re.S):
outer = re.search(
r"<outerBoundaryIs>.*?<coordinates>(.*?)</coordinates>", body, re.S
)
inner = re.findall(
r"<innerBoundaryIs>.*?<coordinates>(.*?)</coordinates>", body, re.S
)
polys.append(
make_valid(Polygon(ring(outer.group(1)), [ring(s) for s in inner]))
)
return unary_union(polys)
def polygons(geom) -> list:
if isinstance(geom, Polygon):
return [geom]
return [
g for g in getattr(geom, "geoms", []) if isinstance(g, (Polygon, MultiPolygon))
]
def clean(geom):
parts = []
for g in polygons(geom):
for p in polygons(g):
if p.area >= MIN_AREA_DEG2:
parts.append(p)
return unary_union(parts)
def rounded(coords):
# the geometry is already on the grid; this only drops float noise
if isinstance(coords, (list, tuple)) and coords and isinstance(coords[0], float):
return [round(coords[0], 5), round(coords[1], 5)]
return [rounded(c) for c in coords]
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--kml-dir", type=Path, help="read L1..L5.kml from here")
args = ap.parse_args()
flooded = None
features = []
for i, level in enumerate(LEVELS, 1):
extent = kml_extent(fetch(i, args.kml_dir))
extent = shapely.set_precision(
make_valid(extent.simplify(SIMPLIFY_DEG, preserve_topology=True)), GRID
)
if flooded is None:
new, flooded = extent, extent
else:
new = shapely.difference(extent, flooded, grid_size=GRID)
flooded = shapely.union(flooded, extent, grid_size=GRID)
zone = shapely.set_precision(clean(new), GRID)
assert zone.is_valid, f"zone {i} is not a valid geometry"
geo = mapping(zone)
features.append(
{
"type": "Feature",
"properties": {"zone": i, "level": level},
"geometry": {
"type": geo["type"],
"coordinates": rounded(geo["coordinates"]),
},
}
)
print(f"zone {i} (P.1 {level:.2f} m): {len(polygons(zone))} polygons")
doc = {
"type": "FeatureCollection",
"source": SOURCE,
"note": "Zone k floods first when P.1 reaches its level; zones are disjoint.",
# highest zone first, so the zones that flood first are drawn on top
"features": features[::-1],
}
OUT.write_text(
json.dumps(doc, ensure_ascii=False, separators=(",", ":")) + "\n",
encoding="utf-8",
)
print(f"wrote {OUT.relative_to(REPO)}: {OUT.stat().st_size / 1024:.0f} KB")
if __name__ == "__main__":
main()
-150
View File
@@ -1,150 +0,0 @@
"""Live parity check: Python collectors vs the Rust ping-collector.
Runs one collection of every source with the Python code into PY_DB and with
`ping-collector` into RS_DB (both should start empty), back to back against the
real upstream APIs, then diffs all nine collector tables row by row (ids and
created/updated timestamps ignored). Exit status 1 on any difference.
uv run python scripts/collector_parity.py \
--py-db postgresql://postgres:ping@localhost:55432/py \
--rs-db postgresql://postgres:ping@localhost:55432/rs \
--collector rust/target/debug/ping-collector
Upstream snapshots can move between the two runs (a new HII hour, a revised
RID value); rerun before treating a small difference as a bug.
"""
import argparse
import os
import subprocess
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO))
TABLES = {
"stations": (
"id",
"id, station_code, thai_name, english_name, latitude, longitude, geohash",
),
"water_measurements": (
"timestamp, station_id",
"timestamp, station_id, water_level, discharge, discharge_percent, status",
),
"hii_rain_stations": (
"id",
"id, oldcode, name_th, name_en, latitude, longitude, sub_basin_id, agency",
),
"hii_rainfall": (
"station_id, timestamp",
"station_id, timestamp, rain_1h, rain_24h",
),
"hii_wl_stations": (
"id",
"id, oldcode, rid_code, name_th, name_en, latitude, longitude, agency, "
"river_name, offset_msl, ground_level_msl, min_bank_msl, "
"critical_level_msl, critical_level_m, qmax, is_key_station",
),
"hii_waterlevel": (
"station_id, timestamp",
"station_id, timestamp, wl_msl, wl_m, discharge, flow_rate, "
"storage_percent, situation_level, diff_wl_bank",
),
"rid_dams": (
"dam_id",
"dam_id, region, name_th, latitude, longitude, capacity_max_mcm, "
"capacity_normal_mcm",
),
"rid_reservoir_daily": (
"dam_id, date",
"dam_id, date, storage_mcm, storage_pct, inflow_mcm, outflow_mcm, level_msl",
),
"openmeteo_rain": (
"timestamp",
"timestamp, chiang_dao, mae_taeng, mae_ngat, mae_rim, chiang_mai, "
"catchment_mean",
),
}
def run_python(db_url: str, gap_days: int):
os.environ.update(
DB_TYPE="postgresql", POSTGRES_CONNECTION_STRING=db_url, LOG_LEVEL="WARNING"
)
from sqlalchemy import create_engine
from src.config import Config
from src.hii_collector import HiiCollector
from src.ml import rain
from src.rid_reservoir import RidReservoirCollector
from src.water_scraper_v3 import EnhancedWaterMonitorScraper
cfg = Config.get_database_config()
scraper = EnhancedWaterMonitorScraper(cfg)
scraper.fill_data_gaps(gap_days)
scraper.run_scraping_cycle()
HiiCollector(cfg).run_cycle()
RidReservoirCollector(cfg).run_cycle()
rain.save_to_db(rain.fetch_forecast(), create_engine(db_url), "postgresql")
def run_rust(db_url: str, collector: str, gap_days: int):
env = {**os.environ, "POSTGRES_CONNECTION_STRING": db_url, "DB_TYPE": "postgresql"}
for args in (["fill-gaps", str(gap_days)], ["once"]):
subprocess.run([collector, *args], env=env, check=True)
def table_rows(db_url: str, table: str, key: str, cols: str) -> dict:
import psycopg2
with psycopg2.connect(db_url) as conn:
cur = conn.cursor()
cur.execute(f"SELECT {cols} FROM {table} ORDER BY {key}")
nkey = len(key.split(","))
return {row[:nkey]: row for row in cur.fetchall()}
def diff(py_db: str, rs_db: str) -> bool:
identical = True
for table, (key, cols) in TABLES.items():
a = table_rows(py_db, table, key, cols)
b = table_rows(rs_db, table, key, cols)
only_py, only_rs = sorted(set(a) - set(b)), sorted(set(b) - set(a))
differ = sorted(k for k in set(a) & set(b) if a[k] != b[k])
same = not (only_py or only_rs or differ)
identical &= same
print(
f"{table:<20} py {len(a):>6} rs {len(b):>6} only-py {len(only_py):>4} "
f"only-rs {len(only_rs):>4} differing {len(differ):>4} "
f"{'identical' if same else 'DIFFERENT'}"
)
for k in only_py[:3]:
print(" only py:", a[k])
for k in only_rs[:3]:
print(" only rs:", b[k])
for k in differ[:3]:
print(" py:", a[k], "\n rs:", b[k])
return identical
def main():
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--py-db", required=True)
parser.add_argument("--rs-db", required=True)
parser.add_argument(
"--collector", default=str(REPO / "rust/target/debug/ping-collector")
)
parser.add_argument("--gap-days", type=int, default=2)
parser.add_argument("--diff-only", action="store_true")
args = parser.parse_args()
if not args.diff_only:
run_python(args.py_db, args.gap_days)
run_rust(args.rs_db, args.collector, args.gap_days)
ok = diff(args.py_db, args.rs_db)
print("ALL IDENTICAL" if ok else "differences found")
sys.exit(0 if ok else 1)
if __name__ == "__main__":
main()
+39 -7
View File
@@ -1,9 +1,15 @@
"""Serve the working-copy dashboard locally with API calls proxied to the
live server, so browser-side changes can be checked against real data
before deploy. Usage: python scripts/dev_proxy.py [port]"""
before deploy. Usage: python scripts/dev_proxy.py [port]
/ is src/static/dashboard.html, /static/... any file in src/static (with HTTP
range requests, which the map's PMTiles basemap needs), everything else the
live server."""
import http.server
import mimetypes
import os
import re
import sys
import urllib.request
from pathlib import Path
@@ -25,24 +31,50 @@ class Handler(http.server.BaseHTTPRequestHandler):
self._send(200, "application/json", Path(file).read_bytes())
return
if self.path.startswith("/static/"):
f = STATIC / self.path[len("/static/"):].split("?")[0]
f = STATIC / self.path[len("/static/") :].split("?")[0]
if f.is_file():
ctype = "application/json" if f.suffix in (".json", ".geojson") else "application/octet-stream"
self._send(200, ctype, f.read_bytes())
ctype = (
"application/json"
if f.suffix in (".json", ".geojson")
else mimetypes.guess_type(f.name)[0] or "application/octet-stream"
)
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(
UPSTREAM + self.path,
headers={"User-Agent": "Mozilla/5.0 (dev_proxy; +https://buildfor.life)", "Accept": "application/json"},
headers={
"User-Agent": "Mozilla/5.0 (dev_proxy; +https://buildfor.life)",
"Accept": "application/json",
},
)
with urllib.request.urlopen(req, timeout=60) as r:
self._send(r.status, r.headers.get("Content-Type", "application/json"), r.read())
self._send(
r.status,
r.headers.get("Content-Type", "application/json"),
r.read(),
)
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()
-132
View File
@@ -1,132 +0,0 @@
"""Drive the production notify path in-process: startup init -> seeded readings
-> forecast cache -> _notify_transitions -> sqlite state -> real ntfy."""
import asyncio
import datetime
import json
import os
import sys
import requests
os.environ.update(
DB_TYPE="sqlite",
WATER_DB_PATH=os.path.join(os.environ["LOCALAPPDATA"], "Temp", "smoke3.db"),
NTFY_SERVER="http://127.0.0.1:2586",
NTFY_TOKEN=os.environ.get("NTFY_TOKEN", ""),
NTFY_TOPIC_PREFIX="ping",
)
for f in ("smoke3.db",):
p = os.path.join(os.environ["LOCALAPPDATA"], "Temp", f)
if os.path.exists(p):
os.remove(p)
from src import web_api # noqa: E402
from src.config import Config # noqa: E402
assert Config.NTFY_SERVER
async def main():
# what the lifespan does at startup, minus the scheduler
from src import notify as notify_mod
from src.forecast_history import ForecastHistoryStore
from src.water_scraper_v3 import EnhancedWaterMonitorScraper
db_config = Config.get_database_config()
web_api.app_state["scraper"] = EnhancedWaterMonitorScraper(db_config)
store = ForecastHistoryStore(db_config["connection_string"], db_config["type"])
store.connect()
web_api.app_state["forecast_store"] = store
state = notify_mod.NotificationState(store.engine, store.db_type)
pub = notify_mod.NtfyPublisher(
Config.NTFY_SERVER, prefix=Config.NTFY_TOPIC_PREFIX, token=Config.NTFY_TOKEN
)
web_api.app_state["notify"] = (pub, state)
scraper = web_api.app_state["scraper"]
now = datetime.datetime.now().replace(minute=0, second=0, microsecond=0)
def seed(level_p1, level_p103, ts):
rows = [
{
"station_code": "P.1",
"station_id": 1,
"timestamp": ts,
"water_level": level_p1,
"discharge": 400.0,
"station_name_en": "Nawarat Bridge",
"station_name_th": "สะพานนวรัฐ",
"discharge_percent": 30.0,
"status": "active",
},
{
"station_code": "P.103",
"station_id": 2,
"timestamp": ts,
"water_level": level_p103,
"discharge": 300.0,
"station_name_en": "Ring Road 3",
"station_name_th": "วงแหวน 3",
"discharge_percent": 20.0,
"status": "active",
},
]
scraper.db_adapter.save_measurements(rows)
def forecast(p):
with web_api.FORECAST_CACHE_LOCK:
web_api.FORECAST_CACHE["all"] = (
0,
[
{
"station_code": "P.1",
"horizon_hours": 24,
"p_warning": p,
"predicted_max_level": 3.9,
"source": "model",
}
],
)
def poll(topic):
out = []
for line in (
requests.get(f"{Config.NTFY_SERVER}/{topic}/json?poll=1", timeout=5)
.text.strip()
.splitlines()
):
m = json.loads(line)
if m.get("event") == "message":
out.append(m.get("title") or m.get("message", "")[:40])
return out
# cycle 1: quiet
seed(1.6, 3.2, now - datetime.timedelta(hours=2))
forecast(0.02)
await web_api._notify_transitions()
# cycle 2: P.1 crosses warning, model outlook on
seed(3.75, 3.3, now - datetime.timedelta(hours=1))
forecast(0.7)
await web_api._notify_transitions()
# cycle 3: same state -> silence
seed(3.80, 3.3, now)
forecast(0.65)
await web_api._notify_transitions()
print("ping-p1-warning:", poll("ping-p1-warning"))
print("ping-warning: ", poll("ping-warning"))
print("ping-p1-outlook:", poll("ping-p1-outlook"))
print("ping-p103-warning:", poll("ping-p103-warning"))
from sqlalchemy import text
with store.engine.connect() as c:
print(
"state table:",
c.execute(
text("SELECT key, state, value FROM notification_state ORDER BY key")
).fetchall(),
)
asyncio.run(main())
+5 -3
View File
@@ -1,8 +1,10 @@
#!/usr/bin/env python3
"""Compare the Python forecast with ping-collector's on the live database.
"""Compare ping-collector's forecast with the Python reference on the live database.
Run before setting FORECAST_IN_COLLECTOR=true, and after any change to src/ml
or rust/forecast. On the server, as the service user from the app dir:
src/ml/predict.py is the reference implementation the Rust forecaster
(rust/forecast) was ported from. Run after any change to src/ml or
rust/forecast; scripts/install.sh runs it on every install. On the server, as
the service user from the app dir:
sudo -u water-monitor .venv/bin/python scripts/forecast_parity.py
+190 -97
View File
@@ -1,127 +1,220 @@
#!/usr/bin/env bash
# Install or update the Ping River Monitor on a systemd host.
#
# Install the Thailand Water Level Monitor as a hardened systemd service.
# sudo bash scripts/install.sh
#
# Creates a dedicated system user, deploys the code to /opt, builds a uv-managed
# virtualenv, installs the systemd unit, and enables the service. Idempotent:
# safe to re-run to update an existing install.
#
# Usage (as root, from a checkout of the repo):
# sudo bash scripts/install.sh
#
# Override defaults via environment variables:
# APP_DIR=/opt/thailand-water-monitor SERVICE_USER=water-monitor sudo -E bash scripts/install.sh
# What runs:
# ping-collector.service the service (rust/collector): RID/HII/dam/rain
# collection, forecast, ntfy + Matrix
# notifications, website + API on WEB_LISTEN
# water-monitor-retrain.timer monthly model retrain (Python, scripts/retrain.sh)
#
# Re-run after every update (git pull + build). Steps, all idempotent:
# 1. system user/group (water-monitor)
# 2. code: run from a checkout other than APP_DIR, it is rsynced there (the
# trained models, .env, stations.json and bin/ are kept) and handed to
# the service user. Production runs in place (APP_DIR is the checkout):
# then nothing is copied and no ownership changes.
# 3. the binary: BIN=/path/to/ping-collector (e.g. the CI artifact
# "ping-collector-linux-x86_64") or rust/target/release/ping-collector,
# 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`), 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
# 6. systemd units; the retired Python web app (water-monitor.service) and
# the old `run.py --alert-check` Matrix cron job are removed
# 7. checks (SKIP_CHECKS=1 skips them): a dry run of every source against
# the database, and the forecast compared with the Python reference on
# the live data (scripts/forecast_parity.py)
# 8. (re)start, then wait for the website to answer
set -euo pipefail
APP_DIR="${APP_DIR:-/opt/thailand-water-monitor}"
SERVICE_USER="${SERVICE_USER:-water-monitor}"
SERVICE_USER="${SERVICE_USER:-water-monitor}" # as in the units
SERVICE_GROUP="${SERVICE_GROUP:-${SERVICE_USER}}"
SERVICE_NAME="water-monitor.service"
RETRAIN_NAME="water-monitor-retrain"
# Resolve the repo root (parent of this scripts/ directory).
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
ENV_FILE="${APP_DIR}/.env"
PYTHON="${APP_DIR}/.venv/bin/python"
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RETRAIN="water-monitor-retrain"
log() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31m[error]\033[0m %s\n' "$*" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || die "This script must be run as root (use sudo)."
[ "$(id -u)" -eq 0 ] || die "run as root (sudo)"
# 1. Dedicated system user/group (no login, no home) --------------------------
if ! getent group "${SERVICE_GROUP}" >/dev/null; then
log "Creating group ${SERVICE_GROUP}"
groupadd --system "${SERVICE_GROUP}"
fi
if ! id "${SERVICE_USER}" >/dev/null 2>&1; then
log "Creating system user ${SERVICE_USER}"
set_default() { # key value: append when the key is not set at all
grep -q "^$1=" "$ENV_FILE" || printf '%s=%s\n' "$1" "$2" >>"$ENV_FILE"
}
# A command the way the services run it: same user, working directory and
# EnvironmentFile parsing (systemd's).
as_service() {
if systemd-run --version >/dev/null 2>&1; then
systemd-run --quiet --pipe --wait --collect \
-p User="$SERVICE_USER" -p Group="$SERVICE_GROUP" \
-p WorkingDirectory="$APP_DIR" -p EnvironmentFile="$ENV_FILE" "$@"
else
(cd "$APP_DIR" && runuser -u "$SERVICE_USER" -- "$@")
fi
}
wait_http() { # url seconds
local i
for i in $(seq "$2"); do
curl -fs -o /dev/null --max-time 5 "$1" 2>/dev/null && return 0
sleep 1
done
return 1
}
# 1. user ---------------------------------------------------------------------
getent group "$SERVICE_GROUP" >/dev/null || groupadd --system "$SERVICE_GROUP"
if ! id "$SERVICE_USER" >/dev/null 2>&1; then
log "Creating system user $SERVICE_USER"
useradd --system --no-create-home --shell /usr/sbin/nologin \
--gid "${SERVICE_GROUP}" "${SERVICE_USER}"
--gid "$SERVICE_GROUP" "$SERVICE_USER"
fi
# 2. Deploy code to APP_DIR ----------------------------------------------------
log "Deploying code to ${APP_DIR}"
mkdir -p "${APP_DIR}"
if command -v rsync >/dev/null 2>&1; then
# 2. code ---------------------------------------------------------------------
in_place=0
[ -d "$APP_DIR" ] && [ "$(cd "$APP_DIR" && pwd -P)" = "$(cd "$REPO_DIR" && pwd -P)" ] && in_place=1
if [ "$in_place" = 0 ]; then
command -v rsync >/dev/null || die "rsync is needed to copy $REPO_DIR to $APP_DIR"
log "Copying $REPO_DIR to $APP_DIR"
mkdir -p "$APP_DIR"
rsync -a --delete \
--exclude '.git' --exclude '.venv' --exclude 'venv' \
--exclude '__pycache__' --exclude '*.pyc' \
--exclude '*.db' --exclude '.env' --exclude 'stations.json' \
"${REPO_DIR}/" "${APP_DIR}/"
else
warn "rsync not found; falling back to cp (will not prune deleted files)"
cp -r "${REPO_DIR}/." "${APP_DIR}/"
--exclude '.git' --exclude '.venv' --exclude '__pycache__' --exclude '/rust/target' \
--exclude '/.env' --exclude '/stations.json' --exclude '/bin' \
--exclude '/collector-status.json' --exclude '/crontab-*.bak' \
--exclude '/models/flood_*' --exclude '/models/metrics.json' \
--exclude '/models/cache' --exclude '/models/.staging' --exclude '/models/.previous' \
"$REPO_DIR/" "$APP_DIR/"
fi
# 3. Build the uv-managed virtualenv ------------------------------------------
# Prefer an already-installed uv. For stricter supply-chain control install uv
# ahead of time via your distro / package manager; this script only fetches the
# upstream installer (piped to a root shell) when AUTO_INSTALL_UV=1 is set, and
# pins the version so the fetched script is reproducible.
UV_VERSION="${UV_VERSION:-0.5.11}"
if ! command -v uv >/dev/null 2>&1; then
if [ "${AUTO_INSTALL_UV:-0}" = "1" ]; then
warn "uv not found; installing pinned uv ${UV_VERSION} from astral.sh (runs as root)"
curl -LsSf "https://astral.sh/uv/${UV_VERSION}/install.sh" \
| env UV_INSTALL_DIR=/usr/local/bin sh
else
die "uv not found. Install it (e.g. your package manager, or 'pipx install uv'),
or re-run with AUTO_INSTALL_UV=1 to fetch the pinned upstream installer."
# 3. binary -------------------------------------------------------------------
BIN="${BIN:-${REPO_DIR}/rust/target/release/ping-collector}"
[ -x "$BIN" ] || die "no binary at $BIN: build it (see the header) or pass BIN=/path/to/ping-collector"
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
if [ "$in_place" = 1 ]; then
uv_user="${SUDO_USER:-root}"
[ -d "${APP_DIR}/.venv" ] && uv_user="$(stat -c %U "${APP_DIR}/.venv")"
fi
fi
UV="$(command -v uv)"
log "Syncing uv-managed virtualenv at ${APP_DIR}/.venv"
cd "${APP_DIR}"
# ONE environment: uv sync owns .venv/ (from pyproject.toml + uv.lock, so the
# ML extras such as scikit-learn/joblib are present) and both systemd units
# run its interpreter directly. Never create a second env by another name --
# a stale 'venv/' once coexisted here and broke manual retrains with
# ModuleNotFoundError while the service itself ran fine.
"${UV}" sync --python 3.11 --frozen
if [ -d "${APP_DIR}/venv" ]; then
warn "Removing stale ${APP_DIR}/venv (superseded by .venv)"
rm -rf "${APP_DIR}/venv"
fi
# 4. Environment file ----------------------------------------------------------
if [ ! -f "${APP_DIR}/.env" ]; then
if [ -f "${REPO_DIR}/.env" ]; then
log "Copying .env from checkout"
cp "${REPO_DIR}/.env" "${APP_DIR}/.env"
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
warn "No .env found. Copy .env.example to ${APP_DIR}/.env and fill in"
warn "MATRIX_ACCESS_TOKEN / MATRIX_ROOM_ID and DB settings before starting."
warn "uv not found for $uv_user (pass UV=/path/to/uv): ${APP_DIR}/.venv not synced"
fi
fi
# 5. Ownership and permissions -------------------------------------------------
# Service user needs write access for logs / stations.json.
log "Setting ownership to ${SERVICE_USER}:${SERVICE_GROUP}"
chown -R "${SERVICE_USER}:${SERVICE_GROUP}" "${APP_DIR}"
# Restrict traversal to root + the service user, and lock down the secrets file
# (contains the Matrix token and DB credentials).
chmod 0750 "${APP_DIR}"
if [ -f "${APP_DIR}/.env" ]; then
chmod 0600 "${APP_DIR}/.env"
# 5. .env ---------------------------------------------------------------------
fresh_env=0
if [ ! -f "$ENV_FILE" ]; then
cp "${APP_DIR}/.env.example" "$ENV_FILE"
chmod 0600 "$ENV_FILE"
fresh_env=1
fi
set_default WEB_LISTEN 0.0.0.0:8000
set_default FORECAST_IN_COLLECTOR true
if [ "$in_place" = 0 ]; then
# a copy belongs to the service (it writes models/, stations.json and the
# status file); an in-place checkout keeps its owner (git pull, builds)
chown -R "${SERVICE_USER}:${SERVICE_GROUP}" "$APP_DIR"
chown root:root "${APP_DIR}/bin/ping-collector"
chmod 0750 "$APP_DIR"
fi
# 6. Install and enable the systemd units -------------------------------------
# 6. units --------------------------------------------------------------------
log "Installing systemd units"
install -m 0644 "${SCRIPT_DIR}/${SERVICE_NAME}" "/etc/systemd/system/${SERVICE_NAME}"
install -m 0644 "${SCRIPT_DIR}/${RETRAIN_NAME}.service" "/etc/systemd/system/${RETRAIN_NAME}.service"
install -m 0644 "${SCRIPT_DIR}/${RETRAIN_NAME}.timer" "/etc/systemd/system/${RETRAIN_NAME}.timer"
install -m 0644 "${APP_DIR}/scripts/ping-collector.service" /etc/systemd/system/ping-collector.service
install -m 0644 "${APP_DIR}/scripts/${RETRAIN}.service" "/etc/systemd/system/${RETRAIN}.service"
install -m 0644 "${APP_DIR}/scripts/${RETRAIN}.timer" "/etc/systemd/system/${RETRAIN}.timer"
if [ -f /etc/systemd/system/water-monitor.service ]; then
log "Removing the retired Python web app (water-monitor.service)"
systemctl disable --now water-monitor.service 2>/dev/null || true
rm -f /etc/systemd/system/water-monitor.service
fi
systemctl daemon-reload
systemctl enable "${SERVICE_NAME}"
systemctl enable --now "${RETRAIN_NAME}.timer"
systemctl enable ping-collector.service
systemctl enable --now "${RETRAIN}.timer"
log "Done."
# The old Matrix cron job (`run.py --alert-check`, repeating an alert every
# 15 minutes while a level stayed high): ping-collector sends Matrix now.
for user in root "$SERVICE_USER"; do
crontab -l -u "$user" 2>/dev/null | grep -v '^\s*#' | grep -q -- '--alert-check' || continue
backup="${APP_DIR}/crontab-${user}.$(date +%Y%m%d%H%M%S).bak"
crontab -l -u "$user" >"$backup"
sed '/--alert-check/ s|^\([^#]\)|# retired: Matrix is sent by ping-collector # \1|' "$backup" |
crontab -u "$user" -
log "Commented out the old --alert-check cron job of $user (backup: $backup)"
done
if grep -rlqs -- '--alert-check' /etc/cron.d /etc/crontab 2>/dev/null; then
warn "an --alert-check job is still in $(grep -rls -- '--alert-check' /etc/cron.d /etc/crontab | tr '\n' ' '): remove it"
fi
if [ "$fresh_env" = 1 ]; then
warn "created $ENV_FILE from .env.example: set the database (POSTGRES_*), NTFY_* and MATRIX_*, then re-run"
exit 0
fi
# 7. checks -------------------------------------------------------------------
if [ "${SKIP_CHECKS:-0}" != 1 ]; then
log "Dry run of every source against the configured database (writes nothing)"
as_service "${APP_DIR}/bin/ping-collector" once --dry-run
if compgen -G "${APP_DIR}/models/flood_*.joblib" >/dev/null && [ -x "$PYTHON" ]; then
log "Exporting trained models for the forecaster (models/flood_*.json.gz)"
as_service "$PYTHON" -m src.ml.export
log "Forecast check: ping-collector against the Python reference, same data, moment and rain"
as_service "$PYTHON" scripts/forecast_parity.py ||
warn "the forecast check found differences (see above); the service runs regardless"
else
warn "no trained models or no ${PYTHON}: forecast check skipped (the forecast falls back to the heuristic)"
fi
fi
# 8. (re)start ----------------------------------------------------------------
systemctl restart ping-collector.service
listen="$(grep '^WEB_LISTEN=' "$ENV_FILE" | tail -1 | cut -d= -f2- | tr -d '"'"'"' ')"
if [ -n "$listen" ]; then
port="${listen##*:}"
# readiness: a route that answers 200 whatever the data (/health may be 503)
if wait_http "http://127.0.0.1:${port}/api/notifications" 60; then
log "Website and API answer on :${port} ($(curl -s --max-time 5 "http://127.0.0.1:${port}/scraping/status" | grep -o '"collector":"[^"]*"'))"
else
warn "no answer on port ${port} yet: journalctl -u ping-collector"
fi
fi
sleep 3
systemctl --no-pager --lines=0 status ping-collector.service || true
journalctl -u ping-collector -n 25 --no-pager || true
echo
echo "Next steps:"
echo " sudo systemctl start ${SERVICE_NAME}"
echo " systemctl status ${SERVICE_NAME}"
echo " sudo journalctl -u ${SERVICE_NAME} -f"
echo " systemctl list-timers ${RETRAIN_NAME}.timer # monthly flood-model retrain"
echo " sudo systemctl start ${RETRAIN_NAME}.service # retrain now"
log "Done. Logs: journalctl -u ping-collector -f; retrain: systemctl list-timers ${RETRAIN}.timer"
-286
View File
@@ -1,286 +0,0 @@
#!/usr/bin/env bash
# Install the Rust service (rust/collector, `ping-collector`) and move work
# from the Python app to it, or back.
#
# sudo bash scripts/install_collector.sh # install binary + unit; checks only
# sudo bash scripts/install_collector.sh --cutover # Rust collects (COLLECTION_ENABLED=false)
# sudo bash scripts/install_collector.sh --cutover-forecast # Rust also forecasts and notifies:
# # ntfy + Matrix (FORECAST_IN_COLLECTOR=true)
# sudo bash scripts/install_collector.sh --check-api # compare the Rust website/API with the
# # Python app's, change nothing
# sudo bash scripts/install_collector.sh --cutover-api # Rust serves the website and API on
# # port 8000 (WEB_LISTEN); the Python app stops
# sudo bash scripts/install_collector.sh --rollback-api # the Python app serves the website again
# sudo bash scripts/install_collector.sh --rollback-forecast # forecasts + notifications back to Python
# sudo bash scripts/install_collector.sh --rollback # Python does everything again
#
# Every install also exports the trained models for the Rust forecaster and
# compares its forecast with Python's on the live data
# (scripts/forecast_parity.py); --cutover-forecast refuses to switch when they
# differ. --cutover-api first runs `ping-collector serve` next to the Python
# app and compares every route (scripts/api_parity.py); it refuses to switch
# when they differ. FORCE=1 overrides either check.
#
# Once ping-collector sends the Matrix messages (FORECAST_IN_COLLECTOR=true
# and MATRIX_* set), the old `run.py --alert-check` cron job is commented out
# (backup next to the app); it repeated an alert on every run.
#
# The binary comes from BIN=/path/to/ping-collector (e.g. the "ping-collector-
# linux-x86_64" CI artifact) or from rust/target/release/ping-collector, built
# beforehand as a normal user:
# cargo build --release --locked --manifest-path rust/Cargo.toml
# (needs Rust >= 1.88 from rustup -- distro cargo is too old -- plus pkg-config
# and libssl-dev; see rust/README.md).
set -euo pipefail
APP_DIR="${APP_DIR:-/opt/thailand-water-monitor}"
SERVICE_USER="${SERVICE_USER:-water-monitor}" # as in scripts/ping-collector.service
ENV_FILE="${APP_DIR}/.env"
STATUS_FILE="${APP_DIR}/collector-status.json"
PYTHON="${APP_DIR}/.venv/bin/python"
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WEB_ADDR="${WEB_ADDR:-0.0.0.0:8000}" # where the Python app listens today
CHECK_PORT="${CHECK_PORT:-8001}" # the Rust side during --check-api
MODE="${1:-install}"
log() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31m[error]\033[0m %s\n' "$*" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || die "run as root (sudo)"
[ -f "$ENV_FILE" ] || die "$ENV_FILE not found"
set_env() {
local key="$1" value="$2"
if grep -q "^${key}=" "$ENV_FILE"; then
sed -i "s|^${key}=.*|${key}=${value}|" "$ENV_FILE"
else
printf '%s=%s\n' "$key" "$value" >>"$ENV_FILE"
fi
}
# Run a command the way the services run: same user, working directory and
# EnvironmentFile parsing (systemd's, not a .env library's).
as_service() {
if systemd-run --version >/dev/null 2>&1; then
systemd-run --quiet --pipe --wait --collect \
-p User="$SERVICE_USER" -p Group="$(id -gn "$SERVICE_USER")" \
-p WorkingDirectory="$APP_DIR" -p EnvironmentFile="$ENV_FILE" "$@"
else
(cd "$APP_DIR" && runuser -u "$SERVICE_USER" -- "$@")
fi
}
show() {
sleep "$1"
systemctl --no-pager --lines=0 status ping-collector.service || true
journalctl -u ping-collector -n 25 --no-pager || true
}
env_is() { grep -q "^$1=$2\s*$" "$ENV_FILE"; }
wait_http() { # url seconds
local i
for i in $(seq "$2"); do
curl -fs -o /dev/null --max-time 5 "$1" 2>/dev/null && return 0
sleep 1
done
return 1
}
# The Matrix messages come from ping-collector once it notifies; the old
# cron job (`run.py --alert-check`, every 15 min, repeating while a level
# stays high) is commented out, with a backup of the crontab.
retire_matrix_cron() {
env_is FORECAST_IN_COLLECTOR true || return 0
grep -q '^MATRIX_ACCESS_TOKEN=..*' "$ENV_FILE" || return 0
local user backup
for user in root "$SERVICE_USER"; do
crontab -l -u "$user" 2>/dev/null | grep -v '^\s*#' | grep -q -- '--alert-check' || continue
backup="${APP_DIR}/crontab-${user}.$(date +%Y%m%d%H%M%S).bak"
crontab -l -u "$user" >"$backup"
sed '/--alert-check/ s|^\([^#]\)|# retired: Matrix is sent by ping-collector # \1|' "$backup" |
crontab -u "$user" -
log "Matrix: commented out the old --alert-check cron job of $user (backup: $backup)"
done
if grep -rlqs -- '--alert-check' /etc/cron.d /etc/crontab 2>/dev/null; then
warn "an --alert-check job is still in $(grep -rls -- '--alert-check' /etc/cron.d /etc/crontab | tr '\n' ' '): remove it, ping-collector sends Matrix now"
fi
if systemctl list-timers --all --no-pager 2>/dev/null | grep -qi alert; then
warn "a systemd timer mentions 'alert' (systemctl list-timers --all): if it runs --alert-check, disable it"
fi
}
# ping-collector serve next to the Python app; every route compared.
api_check() {
local unit=ping-api-check ok=0
systemctl stop "$unit" 2>/dev/null || true
systemd-run --quiet --unit="$unit" --collect \
-p User="$SERVICE_USER" -p Group="$(id -gn "$SERVICE_USER")" \
-p WorkingDirectory="$APP_DIR" -p EnvironmentFile="$ENV_FILE" \
"${APP_DIR}/bin/ping-collector" serve --listen "127.0.0.1:${CHECK_PORT}" --no-analytics
if wait_http "http://127.0.0.1:${CHECK_PORT}/api/notifications" 30; then
as_service "$PYTHON" scripts/api_parity.py \
--python "http://127.0.0.1:${WEB_ADDR##*:}" --rust "http://127.0.0.1:${CHECK_PORT}" && ok=1
else
warn "ping-collector serve did not answer on port ${CHECK_PORT}:"
journalctl -u "$unit" -n 20 --no-pager || true
fi
systemctl stop "$unit" 2>/dev/null || true
[ "$ok" = 1 ]
}
rollback_api() {
if env_is WEB_LISTEN '..*'; then
log "The Python app serves the website and API again"
set_env WEB_LISTEN ""
systemctl restart ping-collector.service
fi
systemctl enable water-monitor.service
systemctl restart water-monitor.service
}
matrix_backups() {
ls "${APP_DIR}"/crontab-*.bak 2>/dev/null | sed 's/^/ /' || true
}
case "$MODE" in
--rollback)
log "Stopping ping-collector; the Python app collects, serves, forecasts and notifies again"
systemctl disable --now ping-collector.service 2>/dev/null || true
set_env WEB_LISTEN ""
set_env FORECAST_IN_COLLECTOR false
set_env COLLECTION_ENABLED true
systemctl enable water-monitor.service
systemctl restart water-monitor.service
if [ -n "$(matrix_backups)" ]; then
warn "Matrix alerts came from ping-collector; to restore the old cron job: crontab -u USER BACKUP"
matrix_backups
fi
log "Done. Check: journalctl -u water-monitor -n 50"
exit 0
;;
--rollback-api)
rollback_api
log "Done. Check: journalctl -u water-monitor -n 50; curl -s http://127.0.0.1:${WEB_ADDR##*:}/health"
exit 0
;;
--rollback-forecast)
# the Python app forecasts in its own process: it must serve again
rollback_api
log "Forecasts and notifications back to the Python app; ping-collector keeps collecting"
set_env FORECAST_IN_COLLECTOR false
systemctl restart ping-collector.service water-monitor.service
if [ -n "$(matrix_backups)" ]; then
warn "Matrix alerts stop with this (the Python app sends ntfy only); old cron job: crontab -u USER BACKUP"
matrix_backups
fi
log "Done. Check: journalctl -u water-monitor -n 50"
exit 0
;;
--check-api)
[ -x "${APP_DIR}/bin/ping-collector" ] || die "install first"
if env_is WEB_LISTEN '..*'; then
die "ping-collector already serves the website (WEB_LISTEN is set)"
fi
api_check && log "The Rust API answers as the Python app" || die "the API check found differences"
exit 0
;;
install | --cutover | --cutover-forecast | --cutover-api) ;;
*) die "unknown option: $MODE" ;;
esac
BIN="${BIN:-${REPO_DIR}/rust/target/release/ping-collector}"
[ -x "$BIN" ] || die "no binary at $BIN: build it (see header) or pass BIN=/path/to/ping-collector"
log "Installing $BIN -> ${APP_DIR}/bin/ping-collector ($("$BIN" --version))"
install -D -m 0755 -o root -g root "$BIN" "${APP_DIR}/bin/ping-collector"
install -m 0644 "${REPO_DIR}/scripts/ping-collector.service" /etc/systemd/system/ping-collector.service
systemctl daemon-reload
# Both services read and write /scraping/status through this file (inside
# the app dir: the units use PrivateTmp).
set_env SCRAPING_STATUS_FILE "$STATUS_FILE"
touch "$STATUS_FILE"
chown "${SERVICE_USER}:$(id -gn "$SERVICE_USER")" "$STATUS_FILE"
log "Dry run against the configured database (writes nothing)"
as_service "${APP_DIR}/bin/ping-collector" once --dry-run
forecast_ok=1
if compgen -G "${APP_DIR}/models/flood_*.joblib" >/dev/null; then
log "Exporting the trained models for the Rust forecaster (models/flood_*.json.gz)"
as_service "$PYTHON" -m src.ml.export
log "Forecast check: Python and ping-collector on the same data, moment and rain"
as_service "$PYTHON" scripts/forecast_parity.py || forecast_ok=0
else
warn "no trained models in ${APP_DIR}/models: forecast check skipped"
forecast_ok=0
fi
case "$MODE" in
--cutover)
log "Cutover: ping-collector collects; the Python app forecasts and notifies"
set_env COLLECTION_ENABLED false
systemctl enable ping-collector.service
systemctl restart ping-collector.service water-monitor.service
show 20
log "Next step: --cutover-forecast. Rollback: sudo bash scripts/install_collector.sh --rollback"
;;
--cutover-forecast)
grep -q '^COLLECTION_ENABLED=false' "$ENV_FILE" ||
die "ping-collector must collect first: run --cutover"
if [ "$forecast_ok" != 1 ] && [ "${FORCE:-0}" != 1 ]; then
die "the forecast check did not pass; not switching (FORCE=1 overrides)"
fi
log "Cutover: ping-collector also forecasts and notifies; the Python app serves its forecast"
set_env FORECAST_IN_COLLECTOR true
retire_matrix_cron
systemctl enable ping-collector.service
systemctl restart ping-collector.service water-monitor.service
show 30
log "Next step: --cutover-api. Rollback: sudo bash scripts/install_collector.sh --rollback-forecast"
;;
--cutover-api)
env_is FORECAST_IN_COLLECTOR true || die "ping-collector must forecast first: run --cutover-forecast"
if env_is WEB_LISTEN '..*'; then
log "ping-collector already serves the website; restarting it on the new binary"
retire_matrix_cron
systemctl restart ping-collector.service
show 10
exit 0
fi
api_ok=1
if [ "$(systemctl is-active water-monitor.service 2>/dev/null)" = active ]; then
log "API check: ping-collector serve on :${CHECK_PORT} next to the Python app, every route compared"
api_check || api_ok=0
else
warn "the Python app is not running: nothing to compare the API with"
api_ok=0
fi
if [ "$api_ok" != 1 ] && [ "${FORCE:-0}" != 1 ]; then
die "the API check did not pass; not switching (FORCE=1 overrides)"
fi
log "Cutover: ping-collector serves the website and API on ${WEB_ADDR}; the Python app stops"
set_env WEB_LISTEN "$WEB_ADDR"
retire_matrix_cron
systemctl disable --now water-monitor.service
systemctl restart ping-collector.service
if wait_http "http://127.0.0.1:${WEB_ADDR##*:}/health" 60; then
log "The website answers from ping-collector: $(curl -s --max-time 5 "http://127.0.0.1:${WEB_ADDR##*:}/scraping/status" | grep -o '"collector":"[^"]*"')"
else
warn "no answer on port ${WEB_ADDR##*:} yet; check journalctl -u ping-collector"
fi
show 5
log "Rollback if needed: sudo bash scripts/install_collector.sh --rollback-api"
;;
*)
retire_matrix_cron
if [ "$(systemctl is-active ping-collector.service 2>/dev/null)" = active ]; then
systemctl restart ping-collector.service
log "Installed; ping-collector restarted on the new binary"
else
log "Installed, not started. Switch over with: sudo bash scripts/install_collector.sh --cutover"
fi
;;
esac
+1 -1
View File
@@ -92,7 +92,7 @@ if [[ -f "$env_file" ]] && ! grep -q '^NTFY_SERVER=' "$env_file"; then
echo "NTFY_TOPIC_PREFIX=ping"
echo "NTFY_TOKEN=${token}"
} >> "$env_file"
echo "wrote NTFY_* to ${env_file}; restart water-monitor to enable"
echo "wrote NTFY_* to ${env_file}; restart ping-collector to enable"
else
echo "NTFY_TOKEN=${token}"
fi
+2 -4
View File
@@ -9,7 +9,7 @@ and errors per stage plus the slowest endpoints.
Run against a LOCAL instance for full stress (never full-stress production —
it hosts live flood monitoring):
python -m uvicorn src.web_api:app --port 8125 # separate shell
rust/target/release/ping-collector serve --listen 127.0.0.1:8125 --no-analytics # separate shell
python scripts/load_test.py http://localhost:8125
A gentle production baseline (low, fixed concurrency):
@@ -98,9 +98,7 @@ def run_stage(base, clients, seconds):
by_endpoint = {}
for path, elapsed in results:
by_endpoint.setdefault(path, []).append(elapsed)
slowest = sorted(
by_endpoint.items(), key=lambda kv: -statistics.median(kv[1])
)[:4]
slowest = sorted(by_endpoint.items(), key=lambda kv: -statistics.median(kv[1]))[:4]
for path, values in slowest:
print(
f" slow: {path:45} n={len(values):5} "
+7 -4
View File
@@ -7,7 +7,8 @@ the page makes, polling like the auto-refresh does) and API consumers
Full stress against a LOCAL instance (never full-stress production — it hosts
live flood monitoring):
# separate shell: python -m uvicorn src.web_api:app --port 8125
# separate shell:
# rust/target/release/ping-collector serve --listen 127.0.0.1:8125 --no-analytics
.venv/Scripts/python.exe -m locust -f scripts/locustfile.py \
--host http://localhost:8125 --headless \
--users 200 --spawn-rate 20 --run-time 2m \
@@ -77,9 +78,11 @@ class ApiConsumer(FastHttpUser):
@task(3)
def hii_feeds(self):
self.client.get(random.choice(
["/api/hii/waterlevel/latest", "/api/hii/rainfall/latest"]
), headers=GZIP, name="/api/hii/[feed]/latest")
self.client.get(
random.choice(["/api/hii/waterlevel/latest", "/api/hii/rainfall/latest"]),
headers=GZIP,
name="/api/hii/[feed]/latest",
)
@task(2)
def forecast(self):
-619
View File
@@ -1,619 +0,0 @@
#!/usr/bin/env python3
"""
SQLite to PostgreSQL Migration Tool
Migrates all data from SQLite database to PostgreSQL
"""
import os
import sys
import logging
import sqlite3
from datetime import datetime, timezone
from typing import Dict, List, Optional, Tuple, Any
from dataclasses import dataclass
# Add src to path for imports
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
@dataclass
class MigrationStats:
stations_migrated: int = 0
measurements_migrated: int = 0
errors: List[str] = None
start_time: Optional[datetime] = None
end_time: Optional[datetime] = None
def __post_init__(self):
if self.errors is None:
self.errors = []
class SQLiteToPostgresMigrator:
def __init__(self, sqlite_path: str, postgres_config: Dict[str, Any]):
self.sqlite_path = sqlite_path
self.postgres_config = postgres_config
self.sqlite_conn = None
self.postgres_adapter = None
self.stats = MigrationStats()
# Setup logging with UTF-8 encoding
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(),
logging.FileHandler('migration.log', encoding='utf-8')
]
)
self.logger = logging.getLogger(__name__)
def connect_databases(self) -> bool:
"""Connect to both SQLite and PostgreSQL databases"""
try:
# Connect to SQLite
if not os.path.exists(self.sqlite_path):
self.logger.error(f"SQLite database not found: {self.sqlite_path}")
return False
self.sqlite_conn = sqlite3.connect(self.sqlite_path)
self.sqlite_conn.row_factory = sqlite3.Row # For dict-like access
self.logger.info(f"Connected to SQLite database: {self.sqlite_path}")
# Connect to PostgreSQL
from database_adapters import create_database_adapter
self.postgres_adapter = create_database_adapter(
self.postgres_config['type'],
connection_string=self.postgres_config['connection_string']
)
if not self.postgres_adapter.connect():
self.logger.error("Failed to connect to PostgreSQL")
return False
self.logger.info("Connected to PostgreSQL database")
return True
except Exception as e:
self.logger.error(f"Database connection error: {e}")
return False
def analyze_sqlite_schema(self) -> Dict[str, List[str]]:
"""Analyze SQLite database structure"""
try:
cursor = self.sqlite_conn.cursor()
# Get all tables
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
tables = [row[0] for row in cursor.fetchall()]
schema_info = {}
for table in tables:
cursor.execute(f"PRAGMA table_info({table})")
columns = [row[1] for row in cursor.fetchall()]
schema_info[table] = columns
# Get row count
cursor.execute(f"SELECT COUNT(*) FROM {table}")
count = cursor.fetchone()[0]
self.logger.info(f"Table '{table}': {len(columns)} columns, {count} rows")
return schema_info
except Exception as e:
self.logger.error(f"Schema analysis error: {e}")
return {}
def migrate_stations(self) -> bool:
"""Migrate station data"""
try:
cursor = self.sqlite_conn.cursor()
# Try different possible table names and structures
station_queries = [
# Modern structure
"""SELECT id, station_code, station_name_th as thai_name, station_name_en as english_name,
latitude, longitude, geohash, created_at, updated_at
FROM stations""",
# Alternative structure 1
"""SELECT id, station_code, thai_name, english_name,
latitude, longitude, geohash, created_at, updated_at
FROM stations""",
# Legacy structure
"""SELECT station_id as id, station_code, station_name as thai_name,
station_name as english_name, lat as latitude, lon as longitude,
NULL as geohash, datetime('now') as created_at, datetime('now') as updated_at
FROM water_stations""",
# Simple structure
"""SELECT rowid as id, station_code, name as thai_name, name as english_name,
NULL as latitude, NULL as longitude, NULL as geohash,
datetime('now') as created_at, datetime('now') as updated_at
FROM stations""",
]
stations_data = []
for query in station_queries:
try:
cursor.execute(query)
rows = cursor.fetchall()
if rows:
self.logger.info(f"Found {len(rows)} stations using query variant")
for row in rows:
station = {
'station_id': row[0],
'station_code': row[1] or f"STATION_{row[0]}",
'station_name_th': row[2] or f"Station {row[0]}",
'station_name_en': row[3] or f"Station {row[0]}",
'latitude': row[4],
'longitude': row[5],
'geohash': row[6],
'status': 'active'
}
stations_data.append(station)
break
except sqlite3.OperationalError as e:
if "no such table" in str(e).lower() or "no such column" in str(e).lower():
continue
else:
raise
if not stations_data:
self.logger.warning("No stations found in SQLite database")
return True
# Insert stations into PostgreSQL using raw SQL
# Since the adapter is designed for measurements, we'll use direct SQL
try:
from sqlalchemy import create_engine, text
engine = create_engine(self.postgres_config['connection_string'])
# Process stations individually to avoid transaction rollback issues
for station in stations_data:
try:
with engine.begin() as conn:
# Use PostgreSQL UPSERT syntax with correct column names
station_sql = """
INSERT INTO stations (id, station_code, thai_name, english_name, latitude, longitude, geohash)
VALUES (:station_id, :station_code, :thai_name, :english_name, :latitude, :longitude, :geohash)
ON CONFLICT (id) DO UPDATE SET
thai_name = EXCLUDED.thai_name,
english_name = EXCLUDED.english_name,
latitude = EXCLUDED.latitude,
longitude = EXCLUDED.longitude,
geohash = EXCLUDED.geohash,
updated_at = CURRENT_TIMESTAMP
"""
conn.execute(text(station_sql), {
'station_id': station['station_id'],
'station_code': station['station_code'],
'thai_name': station['station_name_th'],
'english_name': station['station_name_en'],
'latitude': station.get('latitude'),
'longitude': station.get('longitude'),
'geohash': station.get('geohash')
})
self.stats.stations_migrated += 1
except Exception as e:
error_msg = f"Error migrating station {station.get('station_code', 'unknown')}: {str(e)[:100]}..."
self.logger.warning(error_msg)
self.stats.errors.append(error_msg)
self.logger.info(f"Migrated {self.stats.stations_migrated} stations")
except Exception as e:
self.logger.error(f"Station migration failed: {e}")
return False
self.logger.info(f"Migrated {self.stats.stations_migrated} stations")
return True
except Exception as e:
self.logger.error(f"Station migration error: {e}")
return False
def migrate_measurements(self, batch_size: int = 5000) -> bool:
"""Migrate measurement data in batches"""
try:
cursor = self.sqlite_conn.cursor()
# Try different possible measurement table structures
measurement_queries = [
# Modern structure
"""SELECT w.timestamp, w.station_id, s.station_code, s.station_name_th, s.station_name_en,
w.water_level, w.discharge, w.discharge_percent, w.status
FROM water_measurements w
JOIN stations s ON w.station_id = s.id
ORDER BY w.timestamp""",
# Alternative with different join
"""SELECT w.timestamp, w.station_id, s.station_code, s.thai_name, s.english_name,
w.water_level, w.discharge, w.discharge_percent, 'active' as status
FROM water_measurements w
JOIN stations s ON w.station_id = s.id
ORDER BY w.timestamp""",
# Legacy structure
"""SELECT timestamp, station_id, station_code, station_name, station_name,
water_level, discharge, discharge_percent, 'active' as status
FROM measurements
ORDER BY timestamp""",
# Simple structure without joins
"""SELECT timestamp, station_id, 'UNKNOWN' as station_code, 'Unknown' as station_name_th, 'Unknown' as station_name_en,
water_level, discharge, discharge_percent, 'active' as status
FROM water_measurements
ORDER BY timestamp""",
]
measurements_processed = 0
for query in measurement_queries:
try:
# Get total count first
count_query = query.replace("SELECT", "SELECT COUNT(*) FROM (SELECT").replace("ORDER BY w.timestamp", "") + ")"
cursor.execute(count_query)
total_measurements = cursor.fetchone()[0]
if total_measurements == 0:
continue
self.logger.info(f"Found {total_measurements} measurements to migrate")
# Process in batches
offset = 0
while True:
batch_query = f"{query} LIMIT {batch_size} OFFSET {offset}"
cursor.execute(batch_query)
rows = cursor.fetchall()
if not rows:
break
# Convert to measurement format
measurements = []
for row in rows:
try:
# Parse timestamp
timestamp_str = row[0]
if isinstance(timestamp_str, str):
try:
timestamp = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00'))
except:
# Try other common formats
for fmt in ['%Y-%m-%d %H:%M:%S', '%Y-%m-%d %H:%M:%S.%f', '%Y-%m-%dT%H:%M:%S']:
try:
timestamp = datetime.strptime(timestamp_str, fmt)
break
except:
continue
else:
timestamp = datetime.now()
else:
timestamp = timestamp_str
measurement = {
'timestamp': timestamp,
'station_id': row[1] or 999,
'station_code': row[2] or 'UNKNOWN',
'station_name_th': row[3] or 'Unknown',
'station_name_en': row[4] or 'Unknown',
'water_level': float(row[5]) if row[5] is not None else None,
'discharge': float(row[6]) if row[6] is not None else None,
'discharge_percent': float(row[7]) if row[7] is not None else None,
'status': row[8] or 'active'
}
measurements.append(measurement)
except Exception as e:
error_msg = f"Error processing measurement row: {e}"
self.logger.warning(error_msg)
continue
# Save batch to PostgreSQL using fast bulk insert
if measurements:
try:
self._fast_bulk_insert(measurements)
measurements_processed += len(measurements)
self.stats.measurements_migrated += len(measurements)
self.logger.info(f"Migrated {measurements_processed}/{total_measurements} measurements")
except Exception as e:
error_msg = f"Error saving measurement batch: {e}"
self.logger.error(error_msg)
self.stats.errors.append(error_msg)
offset += batch_size
# If we processed measurements, we're done
if measurements_processed > 0:
break
except sqlite3.OperationalError as e:
if "no such table" in str(e).lower() or "no such column" in str(e).lower():
continue
else:
raise
if measurements_processed == 0:
self.logger.warning("No measurements found in SQLite database")
else:
self.logger.info(f"Successfully migrated {measurements_processed} measurements")
return True
except Exception as e:
self.logger.error(f"Measurement migration error: {e}")
return False
def _fast_bulk_insert(self, measurements: List[Dict]) -> bool:
"""Super fast bulk insert using PostgreSQL COPY or VALUES clause"""
try:
import psycopg2
from urllib.parse import urlparse
import io
# Parse connection string for direct psycopg2 connection
parsed = urlparse(self.postgres_config['connection_string'])
# Try super fast COPY method first
try:
conn = psycopg2.connect(
host=parsed.hostname,
port=parsed.port or 5432,
database=parsed.path[1:],
user=parsed.username,
password=parsed.password
)
with conn:
with conn.cursor() as cur:
# Prepare data for COPY
data_buffer = io.StringIO()
null_val = '\\N'
for m in measurements:
data_buffer.write(f"{m['timestamp']}\t{m['station_id']}\t{m['water_level'] or null_val}\t{m['discharge'] or null_val}\t{m['discharge_percent'] or null_val}\t{m['status']}\n")
data_buffer.seek(0)
# Use COPY for maximum speed
cur.copy_from(
data_buffer,
'water_measurements',
columns=('timestamp', 'station_id', 'water_level', 'discharge', 'discharge_percent', 'status'),
sep='\t'
)
conn.close()
return True
except Exception as copy_error:
# Fallback to SQLAlchemy bulk insert
self.logger.debug(f"COPY failed, using bulk VALUES: {copy_error}")
from sqlalchemy import create_engine, text
engine = create_engine(self.postgres_config['connection_string'])
with engine.begin() as conn:
# Use PostgreSQL's fast bulk insert with ON CONFLICT
values_list = []
for m in measurements:
timestamp = m['timestamp'].isoformat() if hasattr(m['timestamp'], 'isoformat') else str(m['timestamp'])
values_list.append(
f"('{timestamp}', {m['station_id']}, {m['water_level'] or 'NULL'}, "
f"{m['discharge'] or 'NULL'}, {m['discharge_percent'] or 'NULL'}, '{m['status']}')"
)
# Build bulk insert query with ON CONFLICT handling
bulk_sql = f"""
INSERT INTO water_measurements (timestamp, station_id, water_level, discharge, discharge_percent, status)
VALUES {','.join(values_list)}
ON CONFLICT (timestamp, station_id) DO UPDATE SET
water_level = EXCLUDED.water_level,
discharge = EXCLUDED.discharge,
discharge_percent = EXCLUDED.discharge_percent,
status = EXCLUDED.status
"""
conn.execute(text(bulk_sql))
return True
except Exception as e:
self.logger.warning(f"Fast bulk insert failed: {e}")
# Final fallback to original method
try:
success = self.postgres_adapter.save_measurements(measurements)
return success
except Exception as fallback_e:
self.logger.error(f"All insert methods failed: {fallback_e}")
return False
def verify_migration(self) -> bool:
"""Verify the migration by comparing counts"""
try:
# Get SQLite counts
cursor = self.sqlite_conn.cursor()
sqlite_stations = 0
sqlite_measurements = 0
# Try to get station count
for table in ['stations', 'water_stations']:
try:
cursor.execute(f"SELECT COUNT(*) FROM {table}")
sqlite_stations = cursor.fetchone()[0]
break
except:
continue
# Try to get measurement count
for table in ['water_measurements', 'measurements']:
try:
cursor.execute(f"SELECT COUNT(*) FROM {table}")
sqlite_measurements = cursor.fetchone()[0]
break
except:
continue
# Get PostgreSQL counts
postgres_measurements = self.postgres_adapter.get_latest_measurements(limit=999999)
postgres_count = len(postgres_measurements)
self.logger.info("Migration Verification:")
self.logger.info(f"SQLite stations: {sqlite_stations}")
self.logger.info(f"SQLite measurements: {sqlite_measurements}")
self.logger.info(f"PostgreSQL measurements retrieved: {postgres_count}")
self.logger.info(f"Migrated stations: {self.stats.stations_migrated}")
self.logger.info(f"Migrated measurements: {self.stats.measurements_migrated}")
return True
except Exception as e:
self.logger.error(f"Verification error: {e}")
return False
def run_migration(self, sqlite_path: str = None) -> bool:
"""Run the complete migration process"""
self.stats.start_time = datetime.now()
if sqlite_path:
self.sqlite_path = sqlite_path
self.logger.info("=" * 60)
self.logger.info("SQLite to PostgreSQL Migration Tool")
self.logger.info("=" * 60)
self.logger.info(f"SQLite database: {self.sqlite_path}")
self.logger.info(f"PostgreSQL: {self.postgres_config['type']}")
try:
# Step 1: Connect to databases
self.logger.info("Step 1: Connecting to databases...")
if not self.connect_databases():
return False
# Step 2: Analyze SQLite schema
self.logger.info("Step 2: Analyzing SQLite database structure...")
schema_info = self.analyze_sqlite_schema()
if not schema_info:
self.logger.error("Could not analyze SQLite database structure")
return False
# Step 3: Migrate stations
self.logger.info("Step 3: Migrating station data...")
if not self.migrate_stations():
self.logger.error("Station migration failed")
return False
# Step 4: Migrate measurements
self.logger.info("Step 4: Migrating measurement data...")
if not self.migrate_measurements():
self.logger.error("Measurement migration failed")
return False
# Step 5: Verify migration
self.logger.info("Step 5: Verifying migration...")
self.verify_migration()
self.stats.end_time = datetime.now()
duration = self.stats.end_time - self.stats.start_time
# Final report
self.logger.info("=" * 60)
self.logger.info("MIGRATION COMPLETED")
self.logger.info("=" * 60)
self.logger.info(f"Duration: {duration}")
self.logger.info(f"Stations migrated: {self.stats.stations_migrated}")
self.logger.info(f"Measurements migrated: {self.stats.measurements_migrated}")
if self.stats.errors:
self.logger.warning(f"Errors encountered: {len(self.stats.errors)}")
for error in self.stats.errors[:10]: # Show first 10 errors
self.logger.warning(f" - {error}")
if len(self.stats.errors) > 10:
self.logger.warning(f" ... and {len(self.stats.errors) - 10} more errors")
else:
self.logger.info("No errors encountered")
return True
except Exception as e:
self.logger.error(f"Migration failed: {e}")
return False
finally:
# Cleanup
if self.sqlite_conn:
self.sqlite_conn.close()
def main():
"""Main entry point"""
import argparse
parser = argparse.ArgumentParser(description="Migrate SQLite data to PostgreSQL")
parser.add_argument("sqlite_path", nargs="?", help="Path to SQLite database file")
parser.add_argument("--batch-size", type=int, default=5000, help="Batch size for processing measurements")
parser.add_argument("--fast", action="store_true", help="Use maximum speed mode (batch-size 10000)")
parser.add_argument("--dry-run", action="store_true", help="Analyze only, don't migrate")
args = parser.parse_args()
# Set fast mode
if args.fast:
args.batch_size = 10000
# Get SQLite path
sqlite_path = args.sqlite_path
if not sqlite_path:
# Try to find common SQLite database files
possible_paths = [
"water_levels.db",
"water_monitoring.db",
"database.db",
"../water_levels.db"
]
for path in possible_paths:
if os.path.exists(path):
sqlite_path = path
break
if not sqlite_path:
print("SQLite database file not found. Please specify the path:")
print(" python migrate_sqlite_to_postgres.py /path/to/database.db")
return False
# Get PostgreSQL configuration
try:
from config import Config
postgres_config = Config.get_database_config()
if postgres_config['type'] != 'postgresql':
print("Error: PostgreSQL not configured. Set DB_TYPE=postgresql in your .env file")
return False
except Exception as e:
print(f"Error loading PostgreSQL configuration: {e}")
return False
# Run migration
migrator = SQLiteToPostgresMigrator(sqlite_path, postgres_config)
if args.dry_run:
print("DRY RUN MODE - Analyzing SQLite database structure only")
if migrator.connect_databases():
schema_info = migrator.analyze_sqlite_schema()
print("\nSQLite database structure analysis complete.")
print("Run without --dry-run to perform the actual migration.")
return True
success = migrator.run_migration()
return success
if __name__ == "__main__":
success = main()
sys.exit(0 if success else 1)
+3 -4
View File
@@ -14,10 +14,9 @@ Restart=always
RestartSec=30
TimeoutStopSec=20
# Same .env as water-monitor.service (POSTGRES_*, RID_*, GAP_FILL_*,
# SCRAPING_STATUS_FILE, FORECAST_IN_COLLECTOR, NTFY_*). The Python app must run
# with COLLECTION_ENABLED=false. Models are read from models/ (relative to
# WorkingDirectory).
# Every key with its default: .env.example (POSTGRES_*, WEB_LISTEN,
# FORECAST_IN_COLLECTOR, NTFY_*, MATRIX_*, ...). Models are read from models/
# (relative to WorkingDirectory); installed by scripts/install.sh.
EnvironmentFile=/opt/thailand-water-monitor/.env
# Security settings
+7 -7
View File
@@ -5,15 +5,15 @@
#
# Why a script rather than ExecStart=train_flood_model.py:
# * train.py writes each station's bundle straight into models/ over ~12 min,
# and the API's hourly precompute reloads bundles by mtime. Training into
# a staging dir and mv-ing (atomic on one filesystem) means the API never
# sees a half-written joblib file or a mixed old/new set.
# and ping-collector reloads bundles by mtime on every forecast run.
# Training into a staging dir and mv-ing (atomic on one filesystem) means
# it never sees a half-written bundle or a mixed old/new set.
# * A run that produced gauge-only (v2) bundles, or trained too few stations,
# must NOT replace the deployed models. train.py already aborts on a
# missing rain series; this script re-checks the written metrics anyway.
# * No restart is needed: predict.py (Python) and ping-collector (Rust,
# FORECAST_IN_COLLECTOR=true) reload changed bundles on their next
# forecast run, so the new models are live within an hour.
# * No restart is needed: ping-collector reloads changed bundles
# (models/flood_*.json.gz, written by train.py next to each .joblib) on
# its next forecast run, so the new models are live within an hour.
#
# Exit codes: 0 ok, 2 training refused (see log), 3 verification failed.
set -euo pipefail
@@ -88,4 +88,4 @@ for f in "${STAGE_DIR}"/flood_*.joblib "${STAGE_DIR}"/flood_*.json.gz "${STAGE_D
mv -f "${f}" "${MODELS_DIR}/${name}"
done
rm -rf "${STAGE_DIR}"
log "deployed ${VERSION} (${TRAINED} stations); previous generation in models/.previous. The API picks it up on its next hourly precompute."
log "deployed ${VERSION} (${TRAINED} stations); previous generation in models/.previous. ping-collector loads it on its next forecast run."
-175
View File
@@ -1,175 +0,0 @@
#!/usr/bin/env python3
"""
PostgreSQL setup script for Northern Thailand Ping River Monitor
This script helps you configure and test your PostgreSQL connection
"""
import os
import sys
import logging
from typing import Optional
from urllib.parse import urlparse
def setup_logging():
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
def test_postgres_connection(connection_string: str) -> bool:
"""Test connection to PostgreSQL database"""
try:
from sqlalchemy import create_engine, text
# Test connection
engine = create_engine(connection_string, pool_pre_ping=True)
with engine.connect() as conn:
result = conn.execute(text("SELECT version()"))
version = result.fetchone()[0]
logging.info(f"✅ Connected to PostgreSQL successfully!")
logging.info(f"Database version: {version}")
return True
except ImportError:
logging.error("❌ psycopg2-binary not installed. Run: uv add psycopg2-binary")
return False
except Exception as e:
logging.error(f"❌ Connection failed: {e}")
return False
def parse_connection_string(connection_string: str) -> dict:
"""Parse PostgreSQL connection string into components"""
try:
parsed = urlparse(connection_string)
return {
'host': parsed.hostname,
'port': parsed.port or 5432,
'database': parsed.path[1:] if parsed.path else None,
'username': parsed.username,
'password': parsed.password,
}
except Exception as e:
logging.error(f"Failed to parse connection string: {e}")
return {}
def create_database_if_not_exists(connection_string: str, database_name: str) -> bool:
"""Create database if it doesn't exist"""
try:
from sqlalchemy import create_engine, text
# Connect to default postgres database to create our database
parsed = urlparse(connection_string)
admin_connection = connection_string.replace(f"/{parsed.path[1:]}", "/postgres")
engine = create_engine(admin_connection, pool_pre_ping=True)
with engine.connect() as conn:
# Check if database exists
result = conn.execute(text(
"SELECT 1 FROM pg_database WHERE datname = :db_name"
), {"db_name": database_name})
if result.fetchone():
logging.info(f"✅ Database '{database_name}' already exists")
return True
else:
# Create database
conn.execute(text("COMMIT")) # End transaction
conn.execute(text(f'CREATE DATABASE "{database_name}"'))
logging.info(f"✅ Created database '{database_name}'")
return True
except Exception as e:
logging.error(f"❌ Failed to create database: {e}")
return False
def initialize_tables(connection_string: str) -> bool:
"""Initialize database tables"""
try:
# Import the database adapter to create tables
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
from database_adapters import SQLAdapter
adapter = SQLAdapter(connection_string=connection_string, db_type='postgresql')
if adapter.connect():
logging.info("✅ Database tables initialized successfully")
return True
else:
logging.error("❌ Failed to initialize tables")
return False
except Exception as e:
logging.error(f"❌ Failed to initialize tables: {e}")
return False
def interactive_setup():
"""Interactive setup wizard"""
print("🐘 PostgreSQL Setup Wizard for Ping River Monitor")
print("=" * 50)
# Get connection details
host = input("PostgreSQL host (e.g., 192.168.1.100): ").strip()
port = input("PostgreSQL port [5432]: ").strip() or "5432"
database = input("Database name [water_monitoring]: ").strip() or "water_monitoring"
username = input("Username: ").strip()
password = input("Password: ").strip()
# Optional SSL
use_ssl = input("Use SSL connection? (y/N): ").strip().lower() == 'y'
ssl_params = "?sslmode=require" if use_ssl else ""
connection_string = f"postgresql://{username}:{password}@{host}:{port}/{database}{ssl_params}"
print(f"\nGenerated connection string:")
print(f"POSTGRES_CONNECTION_STRING={connection_string}")
return connection_string
def main():
setup_logging()
print("🚀 Northern Thailand Ping River Monitor - PostgreSQL Setup")
print("=" * 60)
# Check if connection string is provided via environment
connection_string = os.getenv('POSTGRES_CONNECTION_STRING')
if not connection_string:
print("No POSTGRES_CONNECTION_STRING found in environment.")
print("Starting interactive setup...\n")
connection_string = interactive_setup()
# Suggest adding to .env file
print(f"\n💡 Add this to your .env file:")
print(f"DB_TYPE=postgresql")
print(f"POSTGRES_CONNECTION_STRING={connection_string}")
# Parse connection details
config = parse_connection_string(connection_string)
if not config.get('host'):
logging.error("Invalid connection string format")
return False
print(f"\n🔗 Connecting to PostgreSQL at {config['host']}:{config['port']}")
# Test connection
if not test_postgres_connection(connection_string):
return False
# Try to create database
database_name = config.get('database', 'water_monitoring')
if database_name:
create_database_if_not_exists(connection_string, database_name)
# Initialize tables
if not initialize_tables(connection_string):
return False
print("\n🎉 PostgreSQL setup completed successfully!")
print("\nNext steps:")
print("1. Update your .env file with the connection string")
print("2. Run: make run-test")
print("3. Run: make run-api")
return True
if __name__ == "__main__":
success = main()
sys.exit(0 if success else 1)
-48
View File
@@ -1,48 +0,0 @@
@echo off
REM Setup script for uv-based development environment on Windows
echo 🚀 Setting up Northern Thailand Ping River Monitor with uv...
REM Check if uv is installed
uv --version >nul 2>&1
if %errorlevel% neq 0 (
echo ❌ uv is not installed. Please install it first:
echo powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
exit /b 1
)
echo ✅ uv found
uv --version
REM Initialize uv project if not already initialized
if not exist "uv.lock" (
echo 🔧 Initializing uv project...
uv sync
) else (
echo 📦 Syncing dependencies with uv...
uv sync
)
REM Install pre-commit hooks
echo 🎣 Installing pre-commit hooks...
uv run pre-commit install
REM Create .env file if it doesn't exist
if not exist ".env" (
if exist ".env.example" (
echo 📝 Creating .env file from template...
copy .env.example .env
echo ⚠️ Please edit .env file with your configuration
)
)
echo ✅ Setup complete!
echo.
echo 📚 Quick start commands:
echo make install-dev # Install all dependencies
echo make run-test # Run a test cycle
echo make run-api # Start the web API
echo make test # Run tests
echo make lint # Check code quality
echo.
echo 🎉 Happy monitoring!
-46
View File
@@ -1,46 +0,0 @@
#!/bin/bash
# Setup script for uv-based development environment
set -e
echo "🚀 Setting up Northern Thailand Ping River Monitor with uv..."
# Check if uv is installed
if ! command -v uv &> /dev/null; then
echo "❌ uv is not installed. Please install it first:"
echo " curl -LsSf https://astral.sh/uv/install.sh | sh"
exit 1
fi
echo "✅ uv found: $(uv --version)"
# Initialize uv project if not already initialized
if [ ! -f "uv.lock" ]; then
echo "🔧 Initializing uv project..."
uv sync
else
echo "📦 Syncing dependencies with uv..."
uv sync
fi
# Install pre-commit hooks
echo "🎣 Installing pre-commit hooks..."
uv run pre-commit install
# Create .env file if it doesn't exist
if [ ! -f ".env" ] && [ -f ".env.example" ]; then
echo "📝 Creating .env file from template..."
cp .env.example .env
echo "⚠️ Please edit .env file with your configuration"
fi
echo "✅ Setup complete!"
echo ""
echo "📚 Quick start commands:"
echo " make install-dev # Install all dependencies"
echo " make run-test # Run a test cycle"
echo " make run-api # Start the web API"
echo " make test # Run tests"
echo " make lint # Check code quality"
echo ""
echo "🎉 Happy monitoring!"
+18 -5
View File
@@ -25,7 +25,8 @@ def summarize(paths):
for fold in station["folds"]:
for name, m in fold["variants"].items():
a = agg.setdefault(
name, {"mae": [], "mae_hi": [], "brier": [], "fa": 0, "events": []}
name,
{"mae": [], "mae_hi": [], "brier": [], "fa": 0, "events": []},
)
a["mae"].append(m["mae"])
if m.get("mae_above_2p5") is not None:
@@ -39,8 +40,18 @@ def summarize(paths):
if e["peak_pred_24h_before"] is None
else e["peak_pred_24h_before"] - e["peak_level"]
)
a["events"].append((fold["year"], e["crossing"][:10], e["lead_h"], e["peak_level"], err))
print(f"{'variant':22} {'MAE':>6} {'MAE_hi':>7} {'Brier':>7} {'FA':>3} events: year crossing lead_h peak(err24h)")
a["events"].append(
(
fold["year"],
e["crossing"][:10],
e["lead_h"],
e["peak_level"],
err,
)
)
print(
f"{'variant':22} {'MAE':>6} {'MAE_hi':>7} {'Brier':>7} {'FA':>3} events: year crossing lead_h peak(err24h)"
)
for name, a in agg.items():
ev = " ".join(
f"{y} {d} {'—' if l is None else format(l, '+.0f')}h {p:.2f}({'—' if err is None else format(err, '+.2f')})"
@@ -54,8 +65,10 @@ def summarize(paths):
f"{a['fa']:>3} {ev}"
)
if leads:
print(f"{'':22} lead: mean {statistics.mean(leads):+.1f} h, min {min(leads):+.0f} h, "
f"missed {sum(1 for *_, l, _, _ in a['events'] if l is None)}/{len(a['events'])}")
print(
f"{'':22} lead: mean {statistics.mean(leads):+.1f} h, min {min(leads):+.0f} h, "
f"missed {sum(1 for *_, l, _, _ in a['events'] if l is None)}/{len(a['events'])}"
)
if __name__ == "__main__":
+3 -3
View File
@@ -10,11 +10,11 @@ User=water-monitor
Group=water-monitor
WorkingDirectory=/opt/thailand-water-monitor
EnvironmentFile=/opt/thailand-water-monitor/.env
# Same interpreter as water-monitor.service -- the uv-managed .venv.
# The uv-managed .venv (Python training stack; scripts/install.sh syncs it).
# scripts/retrain.sh trains into models/.staging, refuses to promote anything
# that is not a rain-enabled (hgb-v3) set covering the expected stations, then
# renames the bundles into place. The API reloads them on its next hourly
# precompute; no restart, so a failed run leaves the old models serving.
# renames the bundles into place. ping-collector reloads them on its next
# forecast run; no restart, so a failed run leaves the old models serving.
ExecStart=/bin/bash /opt/thailand-water-monitor/scripts/retrain.sh
# HistGradientBoosting is CPU-bound; cap threads so training cannot starve
# the API (docs/FLOOD_FORECASTING.md section 6 measured 4 as the sweet spot).
-41
View File
@@ -1,41 +0,0 @@
[Unit]
Description=Thailand Water Level Monitor
Documentation=https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor
After=network.target
Wants=network-online.target
[Service]
Type=simple
User=water-monitor
Group=water-monitor
WorkingDirectory=/opt/thailand-water-monitor
# The uv-managed env (uv sync -> .venv). Same interpreter for water-monitor-retrain.service.
ExecStart=/opt/thailand-water-monitor/.venv/bin/python run.py --web-api
ExecReload=/bin/kill -HUP $MAINPID
Restart=always
RestartSec=60
TimeoutStopSec=30
# DB_TYPE / POSTGRES_CONNECTION_STRING / MATRIX_* come from the .env file.
EnvironmentFile=/opt/thailand-water-monitor/.env
Environment=PYTHONPATH=/opt/thailand-water-monitor
# Serving path is latency-bound; single-threaded BLAS is 2.6x faster per call
# (docs/FLOOD_FORECASTING.md section 6). Training sets its own value.
Environment=OMP_NUM_THREADS=1
Environment=PYTHONUNBUFFERED=1
# Security settings
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/thailand-water-monitor
CapabilityBoundingSet=
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=water-monitor
[Install]
WantedBy=multi-user.target
-2
View File
@@ -1,2 +0,0 @@
target/
data/
-132
View File
@@ -1,132 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "chrono"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"num-traits",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "ping-forecast-spike"
version = "0.1.0"
dependencies = [
"chrono",
"serde",
"serde_json",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
-15
View File
@@ -1,15 +0,0 @@
[package]
name = "ping-forecast-spike"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
serde = { version = "1", features = ["derive"] }
# float_roundtrip: exact float parsing. Tree thresholds often equal real data
# values, so a 1-ulp parse error flips `x <= threshold` (default parser is not exact).
serde_json = { version = "1", features = ["float_roundtrip"] }
chrono = { version = "0.4", default-features = false, features = ["std"] }
[profile.release]
debug = false
-43
View File
@@ -1,43 +0,0 @@
# Rust forecast spike
Answers one question: can the flood forecast be served from Rust without
train/serve skew, while training stays in Python (scikit-learn)?
Assessment and migration plan: [docs/RUST_REWRITE.md](../../docs/RUST_REWRITE.md).
- `export/export_fixture.py`: dumps `models/flood_P.1.joblib` (all 9 HGB heads) as
flat tree arrays, plus two fixtures from `models/cache` (Aug-Oct 2024 record
flood, Jun-Aug 2026): the observed hourly grid of P.1 and its 7 upstream
gauges, rain, the pandas feature matrix and every head's sklearn prediction.
- `src/features.rs`: port of `src/ml/features.py::build_features` (P.1 path:
lags, rises, rolling stats, upstream leads, season, rain).
- `src/hgb.rs`: HistGradientBoosting inference (~90 lines).
- `src/main.rs`: compares bit-for-bit and times serving-shaped work.
```bash
cd spikes/rust-forecast/export
uv run --project ../../.. python export_fixture.py # writes ../data/*.json (gitignored)
cd .. && cargo run --release
uv run --project ../.. python export/time_python.py # Python timing for comparison
```
Result (model hgb-v3+cbb3bf7, 3,600 hourly rows):
| | Result |
| --- | --- |
| Features, 68 columns x 3,600 rows | 244,800 / 244,800 cells bit-identical |
| Regression heads (max_6/12/24) | bit-identical on every row |
| Classifier heads (warn/danger) | equal to the last ulp (<= 2.2e-16, `exp` vs scipy `expit`) |
| One station: 336 h window -> features + 9 heads | Rust 0.25 ms, Python 158 ms |
| Model load | Rust 16 ms (JSON), Python 199 ms (joblib) |
Two traps the spike had to get past, both of which flip tree branches because
HGB thresholds often equal real data values exactly:
1. `serde_json` parses floats best-effort (can be 1 ulp off) unless the
`float_roundtrip` feature is on.
2. pandas `rolling().sum()/.mean()` are incremental with Kahan compensation,
a "run of identical values" shortcut and a sign clamp. A plain window sum
differs in the last bits; `rolling_kahan()` replicates pandas exactly.
Not covered yet: dam features (Mae Ngat), HII gap fill, Open-Meteo catchment
mean, the heuristic fallback, stage probabilities, the other 14 stations.
@@ -1,132 +0,0 @@
"""Export a trained flood bundle + a feature/prediction fixture for the Rust spike.
Run from the monitor repo's environment:
uv run --project ../../.. python export_fixture.py (from this directory)
Writes ../data/model_<station>.json (all heads as flat tree arrays) and
../data/fixture_<name>.json (observed hourly grid + rain, the Python feature
matrix, and every head's Python prediction for every row).
"""
import json
import math
import sys
from pathlib import Path
import joblib
import numpy as np
import pandas as pd
REPO = Path(__file__).resolve().parents[3] # repo root
sys.path.insert(0, str(REPO))
from src.ml import features # noqa: E402
OUT = Path(__file__).resolve().parent.parent / "data"
OUT.mkdir(exist_ok=True)
STATION = "P.1"
WINDOWS = {
"flood2024": ("2024-08-15", "2024-10-31"), # record flood, high-level paths
"recent2026": ("2026-06-01", "2026-08-13"), # latest cached data
}
def clean(values):
return [None if (v is None or (isinstance(v, float) and math.isnan(v))) else float(v) for v in values]
def export_head(est):
preds = est._predictors
assert all(len(it) == 1 for it in preds), "multiclass not expected"
trees = []
for (tp,) in preds:
n = tp.nodes
assert not n["is_categorical"].any()
assert np.isfinite(n["value"]).all() and not np.isneginf(n["num_threshold"]).any()
assert not np.isnan(n["num_threshold"]).any()
trees.append(
{
"feature": n["feature_idx"].astype(int).tolist(),
# +inf thresholds (a "missing vs everything" split) are not
# valid JSON: written as null, read back as +inf in Rust
"threshold": [
None if np.isposinf(t) else float(t) for t in n["num_threshold"]
],
"missing_left": n["missing_go_to_left"].astype(bool).tolist(),
"left": n["left"].astype(int).tolist(),
"right": n["right"].astype(int).tolist(),
"leaf": n["is_leaf"].astype(bool).tolist(),
"value": n["value"].astype(float).tolist(),
}
)
kind = "classifier" if hasattr(est, "predict_proba") else "regressor"
return {
"kind": kind,
"baseline": float(np.ravel(est._baseline_prediction)[0]),
"trees": trees,
}
bundle = joblib.load(REPO / "models" / f"flood_{STATION}.joblib")
model = {
"station_code": STATION,
"model_version": bundle["model_version"],
"regression_target": bundle.get("regression_target"),
"feature_names": bundle["feature_names"],
"horizons": bundle["horizons"],
"sigma": {str(k): float(v) for k, v in bundle["sigma"].items()},
"thresholds": bundle["thresholds"],
"heads": {name: export_head(est) for name, est in bundle["heads"].items()},
}
(OUT / f"model_{STATION}.json").write_text(json.dumps(model))
n_nodes = sum(len(t["value"]) for h in model["heads"].values() for t in h["trees"])
print(f"model {model['model_version']}: {len(model['heads'])} heads, {n_nodes} nodes")
stations = [STATION] + [u for u, _ in features.UPSTREAM_LEADS[STATION]]
long = pd.concat(
pd.read_csv(REPO / "models" / "cache" / f"{s}.csv.gz", parse_dates=["timestamp"])
for s in stations
)
rain_all = pd.read_csv(REPO / "models" / "cache" / "rain_openmeteo.csv.gz")
print("rain columns:", list(rain_all.columns))
ts_col = rain_all.columns[0]
rain_all[ts_col] = pd.to_datetime(rain_all[ts_col])
rain_all = rain_all.set_index(ts_col)[rain_all.columns[1]].astype(float)
for name, (start, end) in WINDOWS.items():
df = long[(long.timestamp >= start) & (long.timestamp < end)]
grid = features.make_hourly_grid(df)
idx = grid.observed.index
rain = rain_all.reindex(idx)
# punch deterministic holes so NaN handling of the rolling sums is exercised
rain.iloc[::97] = np.nan
feats = features.build_features(grid, STATION, rain=rain)[model["feature_names"]]
preds = {}
for head_name, est in bundle["heads"].items():
if hasattr(est, "predict_proba"):
preds[head_name] = est.predict_proba(feats)[:, 1].tolist()
else:
preds[head_name] = est.predict(feats).tolist()
fixture = {
"station": STATION,
"timestamps": [t.strftime("%Y-%m-%dT%H:%M:%S") for t in idx],
"observed": {
s: {
"water_level": clean(grid.observed[(s, "water_level")].tolist()),
"discharge": clean(grid.observed[(s, "discharge")].tolist()),
}
for s in stations
if (s, "water_level") in grid.observed.columns
},
"rain": clean(rain.tolist()),
"feature_names": model["feature_names"],
"features": [clean(row) for row in feats.to_numpy().tolist()],
"predictions": preds,
}
(OUT / f"fixture_{name}.json").write_text(json.dumps(fixture))
print(
f"{name}: {len(idx)} hours, {len(fixture['observed'])} stations, "
f"P.1 max {np.nanmax(grid.observed[(STATION, 'water_level')]):.2f} m"
)
@@ -1,38 +0,0 @@
"""Python timing for the same serving-shaped work as the Rust spike."""
import sys
import time
from pathlib import Path
import joblib
import numpy as np
import pandas as pd
REPO = Path(__file__).resolve().parents[3] # repo root
sys.path.insert(0, str(REPO))
from src.ml import features # noqa: E402
bundle = joblib.load(REPO / "models" / "flood_P.1.joblib")
stations = ["P.1"] + [u for u, _ in features.UPSTREAM_LEADS["P.1"]]
long = pd.concat(
pd.read_csv(REPO / "models" / "cache" / f"{s}.csv.gz", parse_dates=["timestamp"])
for s in stations
)
df = long[(long.timestamp >= "2026-07-30") & (long.timestamp < "2026-08-13")]
rain_all = pd.read_csv(REPO / "models" / "cache" / "rain_openmeteo.csv.gz", parse_dates=["timestamp"])
rain_all = rain_all.set_index("timestamp")["chiang_dao"].astype(float)
reps = 20
t = time.perf_counter()
for _ in range(reps):
grid = features.make_hourly_grid(df)
rain = rain_all.reindex(grid.observed.index)
row = features.build_features(grid, "P.1", rain=rain)[bundle["feature_names"]].iloc[[-1]]
out = {
k: (h.predict_proba(row)[0, 1] if hasattr(h, "predict_proba") else h.predict(row)[0])
for k, h in bundle["heads"].items()
}
print(f"python: grid + features + 9 heads for the last hour: {(time.perf_counter() - t) / reps * 1000:.1f} ms per station")
t = time.perf_counter()
joblib.load(REPO / "models" / "flood_P.1.joblib")
print(f"python: bundle load {(time.perf_counter() - t) * 1000:.0f} ms")
-318
View File
@@ -1,318 +0,0 @@
//! Port of `src/ml/features.py::build_features` (pandas) to plain Rust.
//!
//! Pandas semantics reproduced: `ffill(limit)`, `shift(k)` (NaN-padded),
//! `rolling(w, min_periods=1)` aggregations that skip NaN and yield NaN for an
//! all-NaN window, NaN propagation through subtraction.
use chrono::{Datelike, NaiveDateTime};
use std::collections::HashMap;
use std::f64::consts::PI;
pub const FFILL_LIMIT_H: usize = 3;
pub const BASIN_ANCHOR: &str = "P.1";
const MONSOON_MONTHS: [u32; 5] = [6, 7, 8, 9, 10];
pub fn upstream_leads(station: &str) -> &'static [(&'static str, i64)] {
match station {
"P.1" => &[
("P.103", 1),
("P.67", 7),
("P.21", 9),
("P.75", 12),
("P.4A", 12),
("P.92", 15),
("P.20", 17),
],
"P.21" => &[
("P.67", 1),
("P.75", 3),
("P.4A", 3),
("P.92", 6),
("P.20", 8),
],
"P.67" => &[("P.75", 5), ("P.4A", 5), ("P.92", 8), ("P.20", 10)],
"P.75" | "P.4A" => &[("P.92", 3), ("P.20", 5)],
"P.92" => &[("P.20", 2)],
"P.5" => &[("P.1", 12), ("P.103", 13)],
"P.81" => &[("P.1", 4), ("P.103", 5)],
_ => &[], // spike: P.103's table omitted
}
}
pub type Series = Vec<f64>;
pub struct Grid {
pub times: Vec<NaiveDateTime>,
/// station -> observed (unfilled) hourly series, NaN where missing
pub level: HashMap<String, Series>,
pub discharge: HashMap<String, Series>,
}
fn nan_series(n: usize) -> Series {
vec![f64::NAN; n]
}
pub fn ffill(v: &[f64], limit: usize) -> Series {
let mut out = v.to_vec();
let mut last = f64::NAN;
let mut run = 0;
for x in out.iter_mut() {
if x.is_nan() {
run += 1;
if !last.is_nan() && run <= limit {
*x = last;
}
} else {
last = *x;
run = 0;
}
}
out
}
/// pandas `shift(k)`: positive k looks back, negative k looks ahead.
pub fn shift(v: &[f64], k: i64) -> Series {
let n = v.len() as i64;
(0..n)
.map(|i| {
let j = i - k;
if (0..n).contains(&j) {
v[j as usize]
} else {
f64::NAN
}
})
.collect()
}
fn sub(a: &[f64], b: &[f64]) -> Series {
a.iter().zip(b).map(|(x, y)| x - y).collect()
}
#[derive(Clone, Copy, PartialEq)]
enum Agg {
Mean,
Sum,
Max,
Min,
}
/// pandas `rolling(w, min_periods=1).<agg>()` over the trailing window [i-w+1, i].
///
/// Sum/Mean replicate pandas' `roll_sum`/`roll_mean` (window/aggregations.pyx)
/// operation for operation: an incremental window with separate Kahan
/// compensations for adds and removes, the "run of identical values" shortcut
/// and the sign clamp of the mean. A plain per-window sum differs in the last
/// bits, and HGB thresholds often sit exactly on real data values, so last-bit
/// differences flip tree branches. Max/Min involve no arithmetic: exact as is.
fn rolling(v: &[f64], w: usize, agg: Agg) -> Series {
match agg {
Agg::Sum | Agg::Mean => rolling_kahan(v, w, agg == Agg::Mean),
Agg::Max | Agg::Min => (0..v.len())
.map(|i| {
let lo = (i + 1).saturating_sub(w);
v[lo..=i]
.iter()
.copied()
.filter(|x| !x.is_nan())
.fold(f64::NAN, |acc, x| {
if acc.is_nan() {
x
} else if agg == Agg::Max {
acc.max(x)
} else {
acc.min(x)
}
})
})
.collect(),
}
}
fn rolling_kahan(v: &[f64], w: usize, mean: bool) -> Series {
let (mut sum, mut comp_add, mut comp_remove) = (0.0f64, 0.0f64, 0.0f64);
let (mut nobs, mut neg_ct, mut same_run) = (0i64, 0i64, 0i64);
let mut prev = if v.is_empty() { f64::NAN } else { v[0] };
let mut out = Vec::with_capacity(v.len());
for i in 0..v.len() {
// window [s, e) for row i; previous window was [i-w, i) clipped
if i >= w {
let val = v[i - w];
if !val.is_nan() {
nobs -= 1;
let y = -val - comp_remove;
let t = sum + y;
comp_remove = t - sum - y;
sum = t;
if val.is_sign_negative() {
neg_ct -= 1;
}
}
}
let val = v[i];
if !val.is_nan() {
nobs += 1;
let y = val - comp_add;
let t = sum + y;
comp_add = t - sum - y;
sum = t;
if val.is_sign_negative() {
neg_ct += 1;
}
same_run = if val == prev { same_run + 1 } else { 1 };
prev = val;
}
out.push(if nobs < 1 {
f64::NAN
} else if mean {
if same_run >= nobs {
prev
} else {
let r = sum / nobs as f64;
if neg_ct == 0 && r < 0.0 || neg_ct == nobs && r > 0.0 {
0.0
} else {
r
}
}
} else if same_run >= nobs {
prev * nobs as f64
} else {
sum
});
}
out
}
/// Hours since the last observed value (0 when observed; NaN before the first).
fn hours_since_observed(mask: &[bool]) -> Series {
let mut last: Option<usize> = None;
mask.iter()
.enumerate()
.map(|(i, &m)| {
if m {
last = Some(i);
}
last.map_or(f64::NAN, |l| (i - l) as f64)
})
.collect()
}
/// Feature columns by name, in pandas' construction order.
pub fn build_features(grid: &Grid, station: &str, rain: Option<&[f64]>) -> Vec<(String, Series)> {
let n = grid.times.len();
let filled = |s: &str| -> Series {
grid.level
.get(s)
.map_or_else(|| nan_series(n), |v| ffill(v, FFILL_LIMIT_H))
};
let mut cols: Vec<(String, Series)> = Vec::new();
let mut put = |name: String, s: Series| cols.push((name, s));
let level = filled(station);
let discharge = grid
.discharge
.get(station)
.cloned()
.unwrap_or_else(|| nan_series(n));
let mask: Vec<bool> = grid.level.get(station).map_or_else(
|| vec![false; n],
|v| v.iter().map(|x| !x.is_nan()).collect(),
);
put("level".into(), level.clone());
for k in [1, 2, 3, 6, 12, 24, 48, 72] {
put(format!("level_lag_{k}"), shift(&level, k));
}
for k in [1, 3, 6, 12, 24] {
put(format!("rise_{k}"), sub(&level, &shift(&level, k)));
}
put("roll_mean_6".into(), rolling(&level, 6, Agg::Mean));
put("roll_mean_24".into(), rolling(&level, 24, Agg::Mean));
put("roll_max_6".into(), rolling(&level, 6, Agg::Max));
put("roll_max_24".into(), rolling(&level, 24, Agg::Max));
put("roll_max_72".into(), rolling(&level, 72, Agg::Max));
put("roll_min_24".into(), rolling(&level, 24, Agg::Min));
put("discharge".into(), discharge.clone());
put("discharge_lag_6".into(), shift(&discharge, 6));
put("discharge_lag_24".into(), shift(&discharge, 24));
put(
"discharge_rise_6".into(),
sub(&discharge, &shift(&discharge, 6)),
);
let age = hours_since_observed(&mask);
put(
"obs_age_h".into(),
age.iter()
.map(|&a| {
if a <= FFILL_LIMIT_H as f64 {
a
} else {
f64::NAN
}
})
.collect(),
);
let mask_f: Series = mask.iter().map(|&m| if m { 1.0 } else { 0.0 }).collect();
put("cov_24h".into(), rolling(&mask_f, 24, Agg::Mean));
for &(up, lead) in upstream_leads(station) {
let u = filled(up);
let u_rise_6 = sub(&u, &shift(&u, 6));
let u_rollmax_24 = rolling(&u, 24, Agg::Max);
let near = (lead - 3).max(0);
put(format!("{up}_level_lag_{near}"), shift(&u, near));
put(format!("{up}_level_lag_{lead}"), shift(&u, lead));
put(format!("{up}_level_lag_{}", lead + 3), shift(&u, lead + 3));
put(format!("{up}_rise_6_lag_{lead}"), shift(&u_rise_6, lead));
put(
format!("{up}_rollmax_24_lag_{near}"),
shift(&u_rollmax_24, near),
);
}
if station != BASIN_ANCHOR {
let p1 = filled(BASIN_ANCHOR);
put("P1_level".into(), p1.clone());
put("P1_rollmax_24".into(), rolling(&p1, 24, Agg::Max));
put("P1_rise_24".into(), sub(&p1, &shift(&p1, 24)));
}
let doy: Vec<f64> = grid.times.iter().map(|t| t.ordinal() as f64).collect();
put(
"doy_sin".into(),
doy.iter().map(|d| (2.0 * PI * d / 365.25).sin()).collect(),
);
put(
"doy_cos".into(),
doy.iter().map(|d| (2.0 * PI * d / 365.25).cos()).collect(),
);
put(
"is_monsoon".into(),
grid.times
.iter()
.map(|t| {
if MONSOON_MONTHS.contains(&t.month()) {
1.0
} else {
0.0
}
})
.collect(),
);
if let Some(r) = rain {
put("rain_6h".into(), rolling(r, 6, Agg::Sum));
put("rain_24h".into(), rolling(r, 24, Agg::Sum));
put("rain_72h".into(), rolling(r, 72, Agg::Sum));
// forward sum over (t, t+24]
let mut ahead = shift(r, -1);
ahead.reverse();
let mut fc = rolling(&ahead, 24, Agg::Sum);
fc.reverse();
put("rain_fc24".into(), fc);
}
cols
}
-85
View File
@@ -1,85 +0,0 @@
//! Inference for scikit-learn HistGradientBoosting models exported as flat
//! tree arrays (see export/export_fixture.py). Mirrors sklearn's
//! `_predictor.pyx`: NaN follows `missing_go_to_left`, otherwise
//! `x <= threshold` goes left; raw = baseline + sum of leaf values, summed in
//! tree order (same order as sklearn, so results are bit-identical).
use serde::Deserialize;
use std::collections::BTreeMap;
fn thresholds<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<f64>, D::Error> {
let raw: Vec<Option<f64>> = Deserialize::deserialize(d)?;
Ok(raw
.into_iter()
.map(|t| t.unwrap_or(f64::INFINITY))
.collect())
}
#[derive(Deserialize)]
pub struct Tree {
feature: Vec<usize>,
#[serde(deserialize_with = "thresholds")]
threshold: Vec<f64>,
missing_left: Vec<bool>,
left: Vec<usize>,
right: Vec<usize>,
leaf: Vec<bool>,
value: Vec<f64>,
}
impl Tree {
#[inline]
fn predict(&self, x: &[f64]) -> f64 {
let mut n = 0;
while !self.leaf[n] {
let v = x[self.feature[n]];
let go_left = if v.is_nan() {
self.missing_left[n]
} else {
v <= self.threshold[n]
};
n = if go_left { self.left[n] } else { self.right[n] };
}
self.value[n]
}
}
#[derive(Deserialize, PartialEq, Clone, Copy)]
#[serde(rename_all = "lowercase")]
pub enum Kind {
Regressor,
Classifier,
}
#[derive(Deserialize)]
pub struct Head {
pub kind: Kind,
baseline: f64,
trees: Vec<Tree>,
}
impl Head {
/// Regressor: the prediction. Classifier: P(class 1), like predict_proba[:, 1].
pub fn predict(&self, x: &[f64]) -> f64 {
let mut raw = self.baseline;
for t in &self.trees {
raw += t.predict(x);
}
match self.kind {
Kind::Regressor => raw,
Kind::Classifier => 1.0 / (1.0 + (-raw).exp()),
}
}
}
#[derive(Deserialize)]
pub struct Bundle {
pub station_code: String,
pub model_version: String,
pub regression_target: Option<String>,
pub feature_names: Vec<String>,
pub horizons: Vec<u32>,
pub sigma: BTreeMap<String, f64>,
pub thresholds: BTreeMap<String, f64>,
pub heads: BTreeMap<String, Head>,
}
-211
View File
@@ -1,211 +0,0 @@
//! Parity + timing check: Rust features and HGB inference vs the Python output
//! recorded in data/fixture_*.json.
mod features;
mod hgb;
use chrono::NaiveDateTime;
use serde::Deserialize;
use std::collections::HashMap;
use std::time::Instant;
#[derive(Deserialize)]
struct StationObs {
water_level: Vec<Option<f64>>,
discharge: Vec<Option<f64>>,
}
#[derive(Deserialize)]
struct Fixture {
station: String,
timestamps: Vec<String>,
observed: HashMap<String, StationObs>,
rain: Vec<Option<f64>>,
feature_names: Vec<String>,
features: Vec<Vec<Option<f64>>>,
predictions: HashMap<String, Vec<f64>>,
}
fn nanify(v: &[Option<f64>]) -> Vec<f64> {
v.iter().map(|x| x.unwrap_or(f64::NAN)).collect()
}
fn same(a: f64, b: f64, tol: f64) -> bool {
(a.is_nan() && b.is_nan()) || (a - b).abs() <= tol * (1.0 + b.abs())
}
fn matrix(cols: &[(String, Vec<f64>)], names: &[String]) -> Vec<Vec<f64>> {
let by_name: HashMap<&str, &Vec<f64>> = cols.iter().map(|(k, v)| (k.as_str(), v)).collect();
let picked: Vec<&Vec<f64>> = names
.iter()
.map(|n| {
*by_name
.get(n.as_str())
.unwrap_or_else(|| panic!("missing feature {n}"))
})
.collect();
let rows = picked[0].len();
(0..rows)
.map(|i| picked.iter().map(|c| c[i]).collect())
.collect()
}
fn main() {
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("data");
let t = Instant::now();
let bundle: hgb::Bundle =
serde_json::from_slice(&std::fs::read(data.join("model_P.1.json")).unwrap()).unwrap();
println!(
"model {} ({}): {} heads, {} features, target={:?}, horizons {:?}, sigma {:?}, thresholds {:?}; loaded in {:?}",
bundle.station_code,
bundle.model_version,
bundle.heads.len(),
bundle.feature_names.len(),
bundle.regression_target,
bundle.horizons,
bundle.sigma,
bundle.thresholds,
t.elapsed()
);
for name in ["flood2024", "recent2026"] {
let fx: Fixture = serde_json::from_slice(
&std::fs::read(data.join(format!("fixture_{name}.json"))).unwrap(),
)
.unwrap();
assert_eq!(fx.feature_names, bundle.feature_names);
let grid = features::Grid {
times: fx
.timestamps
.iter()
.map(|s| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S").unwrap())
.collect(),
level: fx
.observed
.iter()
.map(|(k, v)| (k.clone(), nanify(&v.water_level)))
.collect(),
discharge: fx
.observed
.iter()
.map(|(k, v)| (k.clone(), nanify(&v.discharge)))
.collect(),
};
let rain = nanify(&fx.rain);
// 1. feature parity
let t = Instant::now();
let cols = features::build_features(&grid, &fx.station, Some(&rain));
let feat_time = t.elapsed();
let rust_x = matrix(&cols, &bundle.feature_names);
let py_x: Vec<Vec<f64>> = fx.features.iter().map(|r| nanify(r)).collect();
let (mut cells, mut exact, mut close, mut worst) =
(0usize, 0usize, 0usize, (0.0f64, String::new()));
for (r, p) in rust_x.iter().zip(&py_x) {
for (j, (a, b)) in r.iter().zip(p).enumerate() {
cells += 1;
if a.to_bits() == b.to_bits() || (a.is_nan() && b.is_nan()) {
exact += 1;
} else if same(*a, *b, 1e-9) {
close += 1;
let d = (a - b).abs();
if d > worst.0 {
worst = (d, bundle.feature_names[j].clone());
}
} else {
panic!(
"{name}: feature {} differs: rust {a} vs python {b}",
bundle.feature_names[j]
);
}
}
}
println!(
"\n[{name}] {} rows x {} features: {exact} bit-identical, {close} within 1e-9 (max |diff| {:.1e} in {}), 0 mismatches; Rust feature build {:?}",
rust_x.len(),
bundle.feature_names.len(),
worst.0,
if worst.1.is_empty() { "-" } else { &worst.1 },
feat_time
);
let _ = cells;
// 2. inference parity, on Python's features and on Rust's own features
let t = Instant::now();
let mut report = Vec::new();
for (head_name, head) in &bundle.heads {
let want = &fx.predictions[head_name];
let (mut max_py, mut max_rs, mut ident) = (0.0f64, 0.0f64, 0usize);
for i in 0..rust_x.len() {
let on_py = head.predict(&py_x[i]);
let on_rs = head.predict(&rust_x[i]);
if on_py.to_bits() == want[i].to_bits() {
ident += 1;
}
max_py = max_py.max((on_py - want[i]).abs());
max_rs = max_rs.max((on_rs - want[i]).abs());
}
report.push(format!(
" {head_name:<10} bit-identical {ident}/{} max|diff| on py-features {max_py:.1e}, on rust-features {max_rs:.1e}",
rust_x.len()
));
}
let infer_time = t.elapsed();
println!("{}", report.join("\n"));
println!(
" {} heads x {} rows x 2 inference passes in {:?}",
bundle.heads.len(),
rust_x.len(),
infer_time
);
// 3. serving-shaped work: 14-day window, forecast for the last hour
let tail = 336.min(grid.times.len());
let start = grid.times.len() - tail;
let window = features::Grid {
times: grid.times[start..].to_vec(),
level: grid
.level
.iter()
.map(|(k, v)| (k.clone(), v[start..].to_vec()))
.collect(),
discharge: grid
.discharge
.iter()
.map(|(k, v)| (k.clone(), v[start..].to_vec()))
.collect(),
};
let w_rain = rain[start..].to_vec();
let reps = 200;
let t = Instant::now();
let mut last = Vec::new();
for _ in 0..reps {
let cols = features::build_features(&window, &fx.station, Some(&w_rain));
let x = matrix(&cols, &bundle.feature_names);
let row = x.last().unwrap();
last = bundle
.heads
.iter()
.map(|(k, h)| (k.clone(), h.predict(row)))
.collect();
}
let per = t.elapsed() / reps;
let level_now = window.level[&fx.station]
.iter()
.rev()
.find(|x| !x.is_nan())
.copied()
.unwrap();
let rise24 = last.iter().find(|(k, _)| k == "max_24").unwrap().1;
let warn24 = last.iter().find(|(k, _)| k == "warn_24").unwrap().1;
println!(
" serving: 336 h window -> features + {} heads for the last hour: {:?} per station; at {} level {:.2} m, predicted 24 h max {:.2} m, warn_24 head p={:.3}",
bundle.heads.len(),
per,
window.times.last().unwrap(),
level_now,
(rise24 + level_now).max(level_now),
warn24
);
}
}
-162
View File
@@ -1,162 +0,0 @@
-- Northern Thailand Ping River Monitor - PostgreSQL Database Schema
-- This script initializes the database tables for water monitoring data
-- Enable required extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Create schema for better organization
CREATE SCHEMA IF NOT EXISTS water_monitor;
SET search_path TO water_monitor, public;
-- Stations table - stores monitoring station information
CREATE TABLE IF NOT EXISTS stations (
id SERIAL PRIMARY KEY,
station_code VARCHAR(10) UNIQUE NOT NULL,
thai_name VARCHAR(255) NOT NULL,
english_name VARCHAR(255) NOT NULL,
latitude DECIMAL(10,8),
longitude DECIMAL(11,8),
geohash VARCHAR(20),
elevation DECIMAL(8,2), -- meters above sea level
river_basin VARCHAR(100),
province VARCHAR(100),
district VARCHAR(100),
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Water measurements table - stores time series data
CREATE TABLE IF NOT EXISTS water_measurements (
id BIGSERIAL PRIMARY KEY,
timestamp TIMESTAMP NOT NULL,
station_id INTEGER NOT NULL,
water_level NUMERIC(10,3), -- meters
discharge NUMERIC(10,2), -- cubic meters per second
discharge_percent NUMERIC(5,2), -- percentage of normal discharge
status VARCHAR(20) DEFAULT 'active',
data_quality VARCHAR(20) DEFAULT 'good', -- good, fair, poor, missing
remarks TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (station_id) REFERENCES stations(id) ON DELETE CASCADE,
UNIQUE(timestamp, station_id)
);
-- Alert thresholds table - stores warning/danger levels for each station
CREATE TABLE IF NOT EXISTS alert_thresholds (
id SERIAL PRIMARY KEY,
station_id INTEGER NOT NULL,
threshold_type VARCHAR(20) NOT NULL, -- 'warning', 'danger', 'critical'
water_level_min NUMERIC(10,3),
water_level_max NUMERIC(10,3),
discharge_min NUMERIC(10,2),
discharge_max NUMERIC(10,2),
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (station_id) REFERENCES stations(id) ON DELETE CASCADE
);
-- Data quality log - tracks data collection issues
CREATE TABLE IF NOT EXISTS data_quality_log (
id BIGSERIAL PRIMARY KEY,
timestamp TIMESTAMP NOT NULL,
station_id INTEGER,
issue_type VARCHAR(50) NOT NULL, -- 'connection_failed', 'invalid_data', 'missing_data'
description TEXT,
severity VARCHAR(20) DEFAULT 'info', -- 'info', 'warning', 'error', 'critical'
resolved_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (station_id) REFERENCES stations(id) ON DELETE SET NULL
);
-- Create indexes for better query performance
CREATE INDEX IF NOT EXISTS idx_water_measurements_timestamp ON water_measurements(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_water_measurements_station_id ON water_measurements(station_id);
CREATE INDEX IF NOT EXISTS idx_water_measurements_station_timestamp ON water_measurements(station_id, timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_water_measurements_status ON water_measurements(status);
CREATE INDEX IF NOT EXISTS idx_stations_code ON stations(station_code);
CREATE INDEX IF NOT EXISTS idx_stations_active ON stations(is_active);
CREATE INDEX IF NOT EXISTS idx_data_quality_timestamp ON data_quality_log(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_data_quality_station ON data_quality_log(station_id);
-- Create a view for latest measurements per station
CREATE OR REPLACE VIEW latest_measurements AS
SELECT
s.id as station_id,
s.station_code,
s.english_name,
s.thai_name,
s.latitude,
s.longitude,
s.province,
s.river_basin,
m.timestamp,
m.water_level,
m.discharge,
m.discharge_percent,
m.status,
m.data_quality,
CASE
WHEN m.timestamp > CURRENT_TIMESTAMP - INTERVAL '2 hours' THEN 'online'
WHEN m.timestamp > CURRENT_TIMESTAMP - INTERVAL '24 hours' THEN 'delayed'
ELSE 'offline'
END as station_status
FROM stations s
LEFT JOIN LATERAL (
SELECT * FROM water_measurements
WHERE station_id = s.id
ORDER BY timestamp DESC
LIMIT 1
) m ON true
WHERE s.is_active = true
ORDER BY s.station_code;
-- Create a function to update the updated_at timestamp
CREATE OR REPLACE FUNCTION update_modified_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
-- Create triggers to automatically update updated_at
DROP TRIGGER IF EXISTS update_stations_modtime ON stations;
CREATE TRIGGER update_stations_modtime
BEFORE UPDATE ON stations
FOR EACH ROW
EXECUTE FUNCTION update_modified_column();
-- Insert sample stations (Northern Thailand Ping River stations)
INSERT INTO stations (id, station_code, thai_name, english_name, latitude, longitude, province, river_basin) VALUES
(1, 'P.1', 'เชียงใหม่', 'Chiang Mai', 18.7883, 98.9853, 'Chiang Mai', 'Ping River'),
(2, 'P.4A', 'ท่าแพ', 'Tha Phae', 18.7875, 99.0045, 'Chiang Mai', 'Ping River'),
(3, 'P.12', 'สันป่าตอง', 'San Pa Tong', 18.6167, 98.9500, 'Chiang Mai', 'Ping River'),
(4, 'P.20', 'ลำพูน', 'Lamphun', 18.5737, 99.0081, 'Lamphun', 'Ping River'),
(5, 'P.30', 'ลี้', 'Li', 17.4833, 99.3000, 'Lamphun', 'Ping River'),
(6, 'P.35', 'ป่าซาง', 'Pa Sang', 18.5444, 98.9397, 'Lamphun', 'Ping River'),
(7, 'P.67', 'ตาก', 'Tak', 16.8839, 99.1267, 'Tak', 'Ping River'),
(8, 'P.75', 'สามเงา', 'Sam Ngao', 17.1019, 99.4644, 'Tak', 'Ping River')
ON CONFLICT (id) DO NOTHING;
-- Insert sample alert thresholds
INSERT INTO alert_thresholds (station_id, threshold_type, water_level_min, water_level_max) VALUES
(1, 'warning', 4.5, NULL),
(1, 'danger', 6.0, NULL),
(1, 'critical', 7.5, NULL),
(2, 'warning', 4.0, NULL),
(2, 'danger', 5.5, NULL),
(2, 'critical', 7.0, NULL)
ON CONFLICT DO NOTHING;
-- Grant permissions (adjust as needed for your setup)
GRANT USAGE ON SCHEMA water_monitor TO postgres;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA water_monitor TO postgres;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA water_monitor TO postgres;
-- Optional: Create a read-only user for reporting
-- CREATE USER water_monitor_readonly WITH PASSWORD 'readonly_password';
-- GRANT USAGE ON SCHEMA water_monitor TO water_monitor_readonly;
-- GRANT SELECT ON ALL TABLES IN SCHEMA water_monitor TO water_monitor_readonly;
COMMIT;
+3 -36
View File
@@ -1,38 +1,5 @@
#!/usr/bin/env python3
"""
Northern Thailand Ping River Monitor Package
A comprehensive real-time water level monitoring system for the Ping River Basin
in Northern Thailand, covering Royal Irrigation Department (RID) stations.
"""
"""Ping River Monitor: the Python side, i.e. the flood-model training and
evaluation stack (src/ml) and the archive backfills. The service (collection,
forecast, notifications, website/API) is ping-collector in rust/."""
__version__ = "3.1.3"
__author__ = "Ping River Monitor Team"
__description__ = "Northern Thailand Ping River Monitoring System"
from .config import Config
from .database_adapters import DatabaseAdapter, create_database_adapter
from .exceptions import (
APIConnectionError,
ConfigurationError,
DatabaseConnectionError,
DataValidationError,
WaterMonitorException,
)
from .models import DatabaseConfig, StationInfo, WaterMeasurement
from .water_scraper_v3 import EnhancedWaterMonitorScraper
__all__ = [
"EnhancedWaterMonitorScraper",
"create_database_adapter",
"DatabaseAdapter",
"Config",
"WaterMeasurement",
"StationInfo",
"DatabaseConfig",
"WaterMonitorException",
"DatabaseConnectionError",
"APIConnectionError",
"DataValidationError",
"ConfigurationError",
]
-602
View File
@@ -1,602 +0,0 @@
#!/usr/bin/env python3
"""
Water Level Alerting System with Matrix Integration
"""
import datetime
import html
import os
import re
from dataclasses import dataclass
from enum import Enum
from typing import Dict, List, Optional
import requests
try:
from .config import Config
from .database_adapters import create_database_adapter
from .logging_config import get_logger
except ImportError:
import logging
from config import Config
from database_adapters import create_database_adapter
def get_logger(name):
return logging.getLogger(name)
logger = get_logger(__name__)
_BOLD_RE = re.compile(r"\*\*(.+?)\*\*")
_URL_RE = re.compile(r"(https?://[^\s<]+)")
def markdown_to_matrix_html(text: str) -> str:
"""Convert the small Markdown subset we emit into Matrix-compatible HTML.
Matrix clients do NOT render Markdown in the plain ``body`` field; formatting
only shows when an HTML ``formatted_body`` is sent alongside it. We only use
``**bold**``, bare URLs and newlines, so a minimal converter is sufficient and
avoids adding a Markdown dependency.
"""
# Escape HTML special chars first so station/message data can't inject markup.
result = html.escape(text, quote=False)
result = _BOLD_RE.sub(r"<strong>\1</strong>", result)
result = _URL_RE.sub(r'<a href="\1">\1</a>', result)
result = result.replace("\n", "<br/>")
return result
def strip_markdown(text: str) -> str:
"""Produce a clean plain-text fallback for the Matrix ``body`` field."""
return _BOLD_RE.sub(r"\1", text)
class AlertLevel(Enum):
INFO = "info"
WARNING = "warning"
CRITICAL = "critical"
EMERGENCY = "emergency"
@dataclass
class WaterAlert:
station_code: str
station_name: str
alert_type: str
level: AlertLevel
water_level: float
threshold: float
discharge: Optional[float] = None
timestamp: Optional[datetime.datetime] = None
message: Optional[str] = None
class MatrixNotifier:
def __init__(self, homeserver: str, access_token: str, room_id: str):
self.homeserver = homeserver.rstrip("/")
self.access_token = access_token
self.room_id = room_id
self.session = requests.Session()
def send_message(
self, message: str, msgtype: str = "m.text", markdown: bool = True
) -> bool:
"""Send a message to the Matrix room.
When ``markdown`` is True (default) the ``message`` is treated as Markdown:
a rendered HTML ``formatted_body`` is sent so clients show real formatting,
with a plain-text ``body`` fallback for clients that ignore HTML.
"""
try:
# Add transaction ID to prevent duplicates
txn_id = datetime.datetime.now().strftime("%Y%m%d_%H%M%S_%f")
url = f"{self.homeserver}/_matrix/client/v3/rooms/{self.room_id}/send/m.room.message/{txn_id}"
headers = {
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json",
}
if markdown:
data = {
"msgtype": msgtype,
"body": strip_markdown(message),
"format": "org.matrix.custom.html",
"formatted_body": markdown_to_matrix_html(message),
}
else:
data = {"msgtype": msgtype, "body": message}
# Matrix API requires PUT when transaction ID is in the URL path
response = self.session.put(url, headers=headers, json=data, timeout=10)
response.raise_for_status()
logger.info(
f"Matrix message sent successfully: {response.json().get('event_id')}"
)
return True
except Exception as e:
logger.error(f"Failed to send Matrix message: {e}")
return False
def send_alert(self, alert: WaterAlert) -> bool:
"""Send formatted water alert to Matrix"""
emoji_map = {
AlertLevel.INFO: "ℹ️",
AlertLevel.WARNING: "⚠️",
AlertLevel.CRITICAL: "🚨",
AlertLevel.EMERGENCY: "🆘",
}
emoji = emoji_map.get(alert.level, "📊")
message = f"""{emoji} **WATER LEVEL ALERT**
**Station:** {alert.station_code} ({alert.station_name})
**Alert Type:** {alert.alert_type}
**Severity:** {alert.level.value.upper()}
**Current Level:** {alert.water_level:.2f}m
**Threshold:** {alert.threshold:.2f}m
**Difference:** {(alert.water_level - alert.threshold):+.2f}m
"""
if alert.discharge:
message += f"**Discharge:** {alert.discharge:.1f} cms\n"
if alert.timestamp:
message += f"**Time:** {alert.timestamp.strftime('%Y-%m-%d %H:%M:%S')}\n"
if alert.message:
message += f"\n**Details:** {alert.message}\n"
# Add live dashboard link
dashboard_url = os.getenv("ALERT_DASHBOARD_URL", "https://water.buildfor.life/")
message += f"\n📈 **View Dashboard:** {dashboard_url}"
return self.send_message(message)
class WaterLevelAlertSystem:
# Stations upstream of Chiang Mai (and CNX itself) to monitor
UPSTREAM_STATIONS = {
"P.20", # Ban Chiang Dao
"P.75", # Ban Chai Lat
"P.92", # Ban Muang Aut
"P.4A", # Ban Mae Taeng
"P.67", # Ban Tae
"P.21", # Ban Rim Tai
"P.103", # Ring Bridge 3
"P.1", # Nawarat Bridge (Chiang Mai)
}
def __init__(self):
self.db_adapter = None
self.matrix_notifier = None
self.thresholds = self._load_thresholds()
# Matrix configuration from environment
matrix_homeserver = os.getenv("MATRIX_HOMESERVER", "https://matrix.org")
matrix_token = os.getenv("MATRIX_ACCESS_TOKEN")
matrix_room = os.getenv("MATRIX_ROOM_ID")
if matrix_token and matrix_room:
self.matrix_notifier = MatrixNotifier(
matrix_homeserver, matrix_token, matrix_room
)
logger.info("Matrix notifications enabled")
else:
logger.warning("Matrix configuration missing - notifications disabled")
def _load_thresholds(self) -> Dict[str, Dict[str, float]]:
"""Load alert thresholds from config or database"""
# Default thresholds for Northern Thailand stations
return {
"P.1": {
# Zone-based thresholds for Nawarat Bridge (P.1)
"zone_1": 3.7,
"zone_2": 3.9,
"zone_3": 4.0,
"zone_4": 4.1,
"zone_5": 4.2,
"zone_6": 4.3,
"zone_7": 4.6,
"zone_8": 4.8,
"newedge": 4.8, # Same as zone 8 or adjust as needed
# Keep legacy thresholds for compatibility
"warning": 3.7,
"critical": 4.3,
"emergency": 4.8,
},
"P.4A": {"warning": 4.5, "critical": 6.0, "emergency": 7.5},
"P.20": {"warning": 3.0, "critical": 4.5, "emergency": 6.0},
"P.21": {"warning": 4.0, "critical": 5.5, "emergency": 7.0},
"P.67": {"warning": 6.0, "critical": 8.0, "emergency": 10.0},
"P.75": {"warning": 5.5, "critical": 7.5, "emergency": 9.5},
"P.103": {"warning": 7.0, "critical": 9.0, "emergency": 11.0},
# Default for unknown stations
"default": {"warning": 4.0, "critical": 6.0, "emergency": 8.0},
}
def connect_database(self):
"""Initialize database connection"""
try:
db_config = Config.get_database_config()
self.db_adapter = create_database_adapter(
db_config["type"], connection_string=db_config["connection_string"]
)
if self.db_adapter.connect():
logger.info("Database connection established for alerting")
return True
else:
logger.error("Failed to connect to database")
return False
except Exception as e:
logger.error(f"Database connection error: {e}")
return False
def check_water_levels(self) -> List[WaterAlert]:
"""Check current water levels against thresholds"""
alerts = []
if not self.db_adapter:
logger.error("Database not connected")
return alerts
try:
# Get latest measurements
measurements = self.db_adapter.get_latest_measurements(limit=50)
for measurement in measurements:
station_code = measurement.get("station_code", "UNKNOWN")
water_level = measurement.get("water_level")
if not water_level:
continue
# Only alert for upstream stations and Chiang Mai
if station_code not in self.UPSTREAM_STATIONS:
continue
# Get thresholds for this station
station_thresholds = self.thresholds.get(
station_code, self.thresholds["default"]
)
# Check each threshold level
alert_level = None
threshold_value = None
alert_type = None
# Special handling for P.1 with zone-based thresholds
if station_code == "P.1" and "zone_1" in station_thresholds:
# Check all zones in reverse order (highest to lowest)
zones = [
("zone_8", 4.8, AlertLevel.EMERGENCY, "Zone 8 - Emergency"),
("newedge", 4.8, AlertLevel.EMERGENCY, "NewEdge Alert Level"),
("zone_7", 4.6, AlertLevel.CRITICAL, "Zone 7 - Critical"),
("zone_6", 4.3, AlertLevel.CRITICAL, "Zone 6 - Critical"),
("zone_5", 4.2, AlertLevel.WARNING, "Zone 5 - Warning"),
("zone_4", 4.1, AlertLevel.WARNING, "Zone 4 - Warning"),
("zone_3", 4.0, AlertLevel.WARNING, "Zone 3 - Warning"),
("zone_2", 3.9, AlertLevel.INFO, "Zone 2 - Info"),
("zone_1", 3.7, AlertLevel.INFO, "Zone 1 - Info"),
]
for (
zone_name,
zone_threshold,
zone_alert_level,
zone_description,
) in zones:
if water_level >= zone_threshold:
alert_level = zone_alert_level
threshold_value = zone_threshold
alert_type = zone_description
break
else:
# Standard threshold checking for other stations
if water_level >= station_thresholds.get("emergency", float("inf")):
alert_level = AlertLevel.EMERGENCY
threshold_value = station_thresholds["emergency"]
alert_type = "Emergency Water Level"
elif water_level >= station_thresholds.get(
"critical", float("inf")
):
alert_level = AlertLevel.CRITICAL
threshold_value = station_thresholds["critical"]
alert_type = "Critical Water Level"
elif water_level >= station_thresholds.get("warning", float("inf")):
alert_level = AlertLevel.WARNING
threshold_value = station_thresholds["warning"]
alert_type = "High Water Level"
if alert_level:
alert = WaterAlert(
station_code=station_code,
station_name=measurement.get(
"station_name_th", f"Station {station_code}"
),
alert_type=alert_type,
level=alert_level,
water_level=water_level,
threshold=threshold_value,
discharge=measurement.get("discharge"),
timestamp=measurement.get("timestamp"),
)
alerts.append(alert)
except Exception as e:
logger.error(f"Error checking water levels: {e}")
return alerts
def check_data_freshness(self, max_age_hours: int = 12) -> List[WaterAlert]:
"""Check if data is fresh enough"""
alerts = []
if not self.db_adapter:
return alerts
try:
measurements = self.db_adapter.get_latest_measurements(limit=20)
cutoff_time = datetime.datetime.now() - datetime.timedelta(
hours=max_age_hours
)
for measurement in measurements:
timestamp = measurement.get("timestamp")
if timestamp and timestamp < cutoff_time:
station_code = measurement.get("station_code", "UNKNOWN")
age_hours = (
datetime.datetime.now() - timestamp
).total_seconds() / 3600
alert = WaterAlert(
station_code=station_code,
station_name=measurement.get(
"station_name_th", f"Station {station_code}"
),
alert_type="Stale Data",
level=AlertLevel.WARNING,
water_level=measurement.get("water_level", 0),
threshold=max_age_hours,
timestamp=timestamp,
message=f"No fresh data for {age_hours:.1f} hours",
)
alerts.append(alert)
except Exception as e:
logger.error(f"Error checking data freshness: {e}")
return alerts
def check_rate_of_change(self, lookback_hours: int = 3) -> List[WaterAlert]:
"""Check for rapid water level changes over recent hours"""
alerts = []
if not self.db_adapter:
return alerts
try:
# Define rate-of-change thresholds (meters per hour)
rate_thresholds = {
"P.1": {
"warning": 0.15, # 15cm/hour - moderate rise
"critical": 0.25, # 25cm/hour - rapid rise
"emergency": 0.40, # 40cm/hour - very rapid rise
},
"default": {"warning": 0.20, "critical": 0.35, "emergency": 0.50},
}
# Get recent measurements for each station
cutoff_time = datetime.datetime.now() - datetime.timedelta(
hours=lookback_hours
)
# Get unique stations from latest data
latest = self.db_adapter.get_latest_measurements(limit=20)
station_codes = set(
m.get("station_code") for m in latest if m.get("station_code")
)
for station_code in station_codes:
try:
# Only alert for upstream stations and Chiang Mai
if station_code not in self.UPSTREAM_STATIONS:
continue
# Get measurements for this station in the time window
current_time = datetime.datetime.now()
measurements = self.db_adapter.get_measurements_by_timerange(
start_time=cutoff_time,
end_time=current_time,
station_codes=[station_code],
)
if len(measurements) < 2:
continue # Need at least 2 points to calculate rate
# Sort by timestamp
measurements = sorted(
measurements, key=lambda m: m.get("timestamp")
)
# Get oldest and newest measurements
oldest = measurements[0]
newest = measurements[-1]
oldest_time = oldest.get("timestamp")
oldest_level = oldest.get("water_level")
newest_time = newest.get("timestamp")
newest_level = newest.get("water_level")
# Convert timestamp strings to datetime if needed
if isinstance(oldest_time, str):
oldest_time = datetime.datetime.fromisoformat(oldest_time)
if isinstance(newest_time, str):
newest_time = datetime.datetime.fromisoformat(newest_time)
# Calculate rate of change
time_diff_hours = (newest_time - oldest_time).total_seconds() / 3600
if time_diff_hours == 0:
continue
level_change = newest_level - oldest_level
rate_per_hour = level_change / time_diff_hours
# Only alert on rising water (positive rate)
if rate_per_hour <= 0:
continue
# Get station info from latest data
station_info = next(
(m for m in latest if m.get("station_code") == station_code), {}
)
station_name = station_info.get("station_name_th", station_code)
# Get thresholds for this station
station_rate_threshold = rate_thresholds.get(
station_code, rate_thresholds["default"]
)
alert_level = None
threshold_value = None
alert_type = None
if rate_per_hour >= station_rate_threshold["emergency"]:
alert_level = AlertLevel.EMERGENCY
threshold_value = station_rate_threshold["emergency"]
alert_type = "Very Rapid Water Level Rise"
elif rate_per_hour >= station_rate_threshold["critical"]:
alert_level = AlertLevel.CRITICAL
threshold_value = station_rate_threshold["critical"]
alert_type = "Rapid Water Level Rise"
elif rate_per_hour >= station_rate_threshold["warning"]:
alert_level = AlertLevel.WARNING
threshold_value = station_rate_threshold["warning"]
alert_type = "Moderate Water Level Rise"
if alert_level:
message = (
f"Rising at {rate_per_hour:.2f}m/h over last {time_diff_hours:.1f}h "
f"(change: {level_change:+.2f}m)"
)
alert = WaterAlert(
station_code=station_code,
station_name=station_name or f"Station {station_code}",
alert_type=alert_type,
level=alert_level,
water_level=newest_level,
threshold=threshold_value,
timestamp=newest_time,
message=message,
)
alerts.append(alert)
except Exception as station_error:
logger.debug(
f"Error checking rate of change for station {station_code}: {station_error}"
)
continue
except Exception as e:
logger.error(f"Error checking rate of change: {e}")
return alerts
def send_alerts(self, alerts: List[WaterAlert]) -> int:
"""Send alerts via configured channels"""
sent_count = 0
if not alerts:
return sent_count
if self.matrix_notifier:
for alert in alerts:
if self.matrix_notifier.send_alert(alert):
sent_count += 1
# Could add other notification channels here:
# - Email
# - Discord
# - Telegram
# - SMS
return sent_count
def run_alert_check(self) -> Dict[str, int]:
"""Run complete alert check cycle"""
if not self.connect_database():
return {"error": 1}
# Check water levels
water_alerts = self.check_water_levels()
# Check data freshness
data_alerts = self.check_data_freshness()
# Check rate of change (rapid rises)
rate_alerts = self.check_rate_of_change()
# Combine alerts
all_alerts = water_alerts + rate_alerts
# Send alerts
sent_count = self.send_alerts(all_alerts)
logger.info(
f"Alert check complete: {len(all_alerts)} alerts, {sent_count} sent"
)
return {
"water_alerts": len(water_alerts),
"data_alerts": len(data_alerts),
"rate_alerts": len(rate_alerts),
"total_alerts": len(all_alerts),
"sent": sent_count,
}
def main():
"""Standalone alerting check"""
import argparse
parser = argparse.ArgumentParser(description="Water Level Alert System")
parser.add_argument("--check", action="store_true", help="Run alert check")
parser.add_argument("--test", action="store_true", help="Send test message")
args = parser.parse_args()
alerting = WaterLevelAlertSystem()
if args.test:
if alerting.matrix_notifier:
test_message = (
"🧪 **Test Alert**\n\nThis is a test message from the Water Level Alert System.\n\n"
"If you received this, Matrix notifications are working correctly!"
)
success = alerting.matrix_notifier.send_message(test_message)
print(f"Test message sent: {success}")
else:
print("Matrix notifier not configured")
elif args.check:
results = alerting.run_alert_check()
print(f"Alert check results: {results}")
else:
print("Use --check or --test")
if __name__ == "__main__":
main()
+31 -342
View File
@@ -1,354 +1,43 @@
import os
from typing import Any, Dict
"""Database settings for the Python tools (training, evaluation, backfills).
The service itself is ping-collector (rust/collector); it reads the same
environment / `.env` file (rust/collector/src/config.rs). PostgreSQL only:
POSTGRES_CONNECTION_STRING, or POSTGRES_HOST / POSTGRES_PORT / POSTGRES_DB /
POSTGRES_USER / POSTGRES_PASSWORD with the same defaults as ping-collector.
"""
import os
import urllib.parse
from typing import Optional
# Load environment variables from .env file
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
# python-dotenv not installed, continue without it
except ImportError: # the systemd units pass the variables in directly
pass
try:
from .exceptions import ConfigurationError
from .models import DatabaseType
except ImportError:
# Handle case when running as standalone script
class ConfigurationError(Exception):
pass
from enum import Enum
class DatabaseType(Enum):
SQLITE = "sqlite"
MYSQL = "mysql"
POSTGRESQL = "postgresql"
INFLUXDB = "influxdb"
VICTORIAMETRICS = "victoriametrics"
class Config:
"""Configuration class for the Water Level Monitor"""
# Database settings
DATABASE_PATH = os.getenv("WATER_DB_PATH", "water_levels.db")
# Website settings
TARGET_URL = "https://hyd-app-db.rid.go.th/hydro1h.html"
API_URL = "https://hyd-app-db.rid.go.th/webservice/getGroupHourlyWaterLevelReportAllHL.ashx"
THAIWATER_API_KEY = os.getenv("THAIWATER_API_KEY")
# Public flood notifications (ntfy). Off unless NTFY_SERVER is set.
# NTFY_SERVER is what subscribers use (public https URL, shown on the
# dashboard). NTFY_PUBLISH_URL is where the monitor POSTs; defaults to
# NTFY_SERVER, set it to http://127.0.0.1:2586 when ntfy runs on the same
# host so publishing never depends on DNS/proxy/tunnel being up.
NTFY_SERVER = os.getenv("NTFY_SERVER", "").strip()
NTFY_PUBLISH_URL = os.getenv("NTFY_PUBLISH_URL", "").strip() or NTFY_SERVER
NTFY_TOPIC_PREFIX = os.getenv("NTFY_TOPIC_PREFIX", "ping").strip()
NTFY_TOKEN = os.getenv("NTFY_TOKEN", "").strip() # publish token if ACL enabled
PUBLIC_URL = os.getenv("PUBLIC_URL", "https://water.buildfor.life/").strip()
REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT", "30"))
USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
def database_url() -> Optional[str]:
"""The PostgreSQL connection string, or None when none is configured."""
url = os.getenv("POSTGRES_CONNECTION_STRING", "").strip()
if url:
return url
password = os.getenv("POSTGRES_PASSWORD")
if not password:
return None
user = urllib.parse.quote(os.getenv("POSTGRES_USER", "postgres"), safe="")
host = os.getenv("POSTGRES_HOST", "localhost")
port = os.getenv("POSTGRES_PORT", "5432")
name = os.getenv("POSTGRES_DB", "water_monitoring")
return (
f"postgresql://{user}:{urllib.parse.quote(password, safe='')}"
f"@{host}:{port}/{name}"
)
# Database configuration
# When DB_TYPE is not set explicitly, a configured Postgres connection wins over the sqlite default
DB_TYPE = os.getenv(
"DB_TYPE",
"postgresql" if os.getenv("POSTGRES_CONNECTION_STRING") else "sqlite",
).lower()
# VictoriaMetrics settings
# Default to localhost; set VM_HOST in the environment for real deployments
# (avoids committing infrastructure hostnames to the repo).
VM_HOST = os.getenv("VM_HOST", "localhost")
VM_PORT = int(os.getenv("VM_PORT", "443"))
# Support for HTTPS URLs (e.g., behind reverse proxy)
VM_URL = os.getenv("VM_URL") # Full URL override (e.g., https://vm.example.com)
# InfluxDB settings
INFLUX_HOST = os.getenv("INFLUX_HOST", "localhost")
INFLUX_PORT = int(os.getenv("INFLUX_PORT", "8086"))
INFLUX_DATABASE = os.getenv("INFLUX_DATABASE", "water_monitoring")
INFLUX_USERNAME = os.getenv("INFLUX_USERNAME")
INFLUX_PASSWORD = os.getenv("INFLUX_PASSWORD")
# PostgreSQL settings
POSTGRES_CONNECTION_STRING = os.getenv("POSTGRES_CONNECTION_STRING")
POSTGRES_HOST = os.getenv("POSTGRES_HOST", "localhost")
POSTGRES_PORT = int(os.getenv("POSTGRES_PORT", "5432"))
POSTGRES_DB = os.getenv("POSTGRES_DB", "water_monitoring")
POSTGRES_USER = os.getenv("POSTGRES_USER", "postgres")
POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD")
# MySQL settings
MYSQL_CONNECTION_STRING = os.getenv("MYSQL_CONNECTION_STRING")
# HII/ThaiWater open api-v3 collection (rainfall + backup water level)
# See docs/DATA_SOURCES.md. Requires a SQL DB_TYPE (sqlite/postgresql/mysql).
ENABLE_HII_COLLECTION = os.getenv("ENABLE_HII_COLLECTION", "true").lower() in (
"1",
"true",
"yes",
)
HII_BASIN_CODE = int(os.getenv("HII_BASIN_CODE", "6")) # 6 = Ping Basin
# RID large-dam daily status (app.rid.go.th/reservoir) — Mae Ngat et al.
ENABLE_RESERVOIR_COLLECTION = os.getenv(
"ENABLE_RESERVOIR_COLLECTION", "true"
).lower() in ("1", "true", "yes")
# TTL for the /api/hii/*/latest response cache; source data changes hourly
HII_CACHE_TTL_SECONDS = int(os.getenv("HII_CACHE_TTL_SECONDS", "120"))
# TTL for the /measurements/latest response cache (hottest endpoint)
LATEST_CACHE_TTL_SECONDS = int(os.getenv("LATEST_CACHE_TTL_SECONDS", "45"))
# TTL for /health check results (includes an external RID-API probe)
HEALTH_CACHE_TTL_SECONDS = int(os.getenv("HEALTH_CACHE_TTL_SECONDS", "30"))
# Thread-pool size for blocking work in the web process (DB queries,
# inference, health probes). Waiting threads are cheap; starving the pool
# stalls every endpoint that needs a thread.
EXECUTOR_THREADS = int(os.getenv("EXECUTOR_THREADS", "48"))
# Web server worker processes. Above 1, uvicorn forks workers and a
# localhost lock port elects a single background-collection leader.
WEB_WORKERS = int(os.getenv("WEB_WORKERS", "2"))
COLLECTION_LEADER_PORT = int(os.getenv("COLLECTION_LEADER_PORT", "8901"))
# Umami analytics (self-hosted). The website id is public (it ships in the
# dashboard <script> tag); server-side API tracking posts to /api/send.
UMAMI_API_URL = os.getenv("UMAMI_API_URL", "https://stats.buildfor.life/api/send")
UMAMI_WEBSITE_ID = os.getenv(
"UMAMI_WEBSITE_ID", "00b2be73-8f5f-4400-9029-3be852eb08f7"
)
UMAMI_TRACK_API = os.getenv("UMAMI_TRACK_API", "true").lower() in (
"1",
"true",
"yes",
)
# Scheduler settings
SCRAPING_INTERVAL_HOURS = int(os.getenv("SCRAPING_INTERVAL_HOURS", "1"))
# RID is polled this many minutes past each hour; while the current hour is
# not published yet (the evening report often lags 1-2 h) it is retried
# every RID_RETRY_MINUTES. Late readings are stored either way.
RID_POLL_OFFSET_MINUTES = int(os.getenv("RID_POLL_OFFSET_MINUTES", "5"))
RID_RETRY_MINUTES = int(os.getenv("RID_RETRY_MINUTES", "10"))
# On startup the collection leader re-fetches RID reports with holes from
# the last N days (0 = off). Older history: `run.py --fill-gaps all`.
GAP_FILL_DAYS_ON_START = int(os.getenv("GAP_FILL_DAYS_ON_START", "14"))
# Leader -> other workers hand-off for /scraping/status (default: temp dir).
# With ping-collector, point both services at the same file inside the app
# dir (the systemd units use PrivateTmp).
SCRAPING_STATUS_FILE = os.getenv("SCRAPING_STATUS_FILE", "")
# false: data collection runs in the Rust ping-collector service
# (rust/collector); this app then only serves, forecasts and notifies,
# re-forecasting when the newest RID reading advances (polled every
# FORECAST_POLL_SECONDS) and at least once per clock hour.
COLLECTION_ENABLED = os.getenv("COLLECTION_ENABLED", "true").lower() in (
"1",
"true",
"yes",
)
FORECAST_POLL_SECONDS = int(os.getenv("FORECAST_POLL_SECONDS", "60"))
# true: ping-collector also forecasts (rust/forecast) and sends the ntfy
# notifications; this app serves its stored forecast (forecast_current)
# and does neither. Read by both services from the shared .env.
FORECAST_IN_COLLECTOR = os.getenv("FORECAST_IN_COLLECTOR", "false").lower() in (
"1",
"true",
"yes",
)
# A stored forecast older than this is not served; /forecast then computes
# in-process as before (the collector is down or has no models).
FORECAST_MAX_AGE_SECONDS = int(os.getenv("FORECAST_MAX_AGE_SECONDS", "7200"))
# Logging settings
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
LOG_FILE = os.getenv("LOG_FILE", "water_monitor.log")
LOG_FORMAT = "%(asctime)s - %(levelname)s - %(message)s"
# Data retention
DATA_RETENTION_DAYS = int(os.getenv("DATA_RETENTION_DAYS", "365"))
# Retry settings
MAX_RETRIES = int(os.getenv("MAX_RETRIES", "3"))
RETRY_DELAY_SECONDS = int(os.getenv("RETRY_DELAY_SECONDS", "60"))
# Station configuration
# Runtime-writable JSON file that persists the station mapping across restarts
# (station CRUD via the API writes here). If it does not exist, the bundled
# defaults in src/data/stations.json are used to seed it.
STATION_CONFIG_PATH = os.getenv("STATION_CONFIG_PATH", "stations.json")
# Web API / CORS settings
# Comma-separated list of allowed origins. Defaults to none (same-origin only);
# set CORS_ALLOW_ORIGINS to a specific list of front-end origins in production.
# Credentials are only enabled when explicit (non-wildcard) origins are set,
# because "*" + credentials is rejected by browsers and unsafe.
CORS_ALLOW_ORIGINS = [
origin.strip()
for origin in os.getenv("CORS_ALLOW_ORIGINS", "").split(",")
if origin.strip()
]
@classmethod
def validate_config(cls) -> bool:
"""Validate configuration settings"""
errors = []
# Validate database type
try:
DatabaseType(cls.DB_TYPE)
except ValueError:
errors.append(f"Invalid DB_TYPE: {cls.DB_TYPE}")
# Validate database-specific settings
if cls.DB_TYPE == "victoriametrics":
if not cls.VM_HOST:
errors.append("VM_HOST is required for VictoriaMetrics")
if not isinstance(cls.VM_PORT, int) or cls.VM_PORT <= 0:
errors.append("VM_PORT must be a positive integer")
elif cls.DB_TYPE == "influxdb":
if not cls.INFLUX_HOST:
errors.append("INFLUX_HOST is required for InfluxDB")
if not cls.INFLUX_DATABASE:
errors.append("INFLUX_DATABASE is required for InfluxDB")
elif cls.DB_TYPE in ["postgresql", "mysql"]:
if cls.DB_TYPE == "postgresql":
# Check if either connection string or individual components are provided
if not cls.POSTGRES_CONNECTION_STRING:
# If no connection string, check individual components
if not cls.POSTGRES_HOST:
errors.append("POSTGRES_HOST is required for PostgreSQL")
if not cls.POSTGRES_USER:
errors.append("POSTGRES_USER is required for PostgreSQL")
if not cls.POSTGRES_PASSWORD:
errors.append("POSTGRES_PASSWORD is required for PostgreSQL")
if not cls.POSTGRES_DB:
errors.append("POSTGRES_DB is required for PostgreSQL")
else: # mysql
if not cls.MYSQL_CONNECTION_STRING:
errors.append("MYSQL_CONNECTION_STRING is required for MySQL")
# Validate numeric settings
if cls.SCRAPING_INTERVAL_HOURS <= 0:
errors.append("SCRAPING_INTERVAL_HOURS must be positive")
if not 0 <= cls.RID_POLL_OFFSET_MINUTES < 60:
errors.append("RID_POLL_OFFSET_MINUTES must be between 0 and 59")
if cls.RID_RETRY_MINUTES <= 0:
errors.append("RID_RETRY_MINUTES must be positive")
if cls.DATA_RETENTION_DAYS <= 0:
errors.append("DATA_RETENTION_DAYS must be positive")
if errors:
raise ConfigurationError(f"Configuration errors: {'; '.join(errors)}")
return True
@classmethod
def get_database_config(cls) -> Dict[str, Any]:
"""Returns database configuration based on DB_TYPE"""
if cls.DB_TYPE == "victoriametrics":
return {"type": "victoriametrics", "host": cls.VM_HOST, "port": cls.VM_PORT}
elif cls.DB_TYPE == "influxdb":
return {
"type": "influxdb",
"host": cls.INFLUX_HOST,
"port": cls.INFLUX_PORT,
"database": cls.INFLUX_DATABASE,
"username": cls.INFLUX_USERNAME,
"password": cls.INFLUX_PASSWORD,
}
elif cls.DB_TYPE == "postgresql":
# Use individual components if POSTGRES_CONNECTION_STRING is not provided
if cls.POSTGRES_CONNECTION_STRING:
return {
"type": "postgresql",
"connection_string": cls.POSTGRES_CONNECTION_STRING,
}
else:
# Build connection string from components (automatically URL-encodes password)
import urllib.parse
if not cls.POSTGRES_PASSWORD:
raise ConfigurationError(
"POSTGRES_PASSWORD is required for PostgreSQL (no default is provided)"
)
password = urllib.parse.quote(cls.POSTGRES_PASSWORD, safe="")
connection_string = (
f"postgresql://{cls.POSTGRES_USER}:{password}"
f"@{cls.POSTGRES_HOST}:{cls.POSTGRES_PORT}/{cls.POSTGRES_DB}"
)
return {"type": "postgresql", "connection_string": connection_string}
elif cls.DB_TYPE == "mysql":
if not cls.MYSQL_CONNECTION_STRING:
raise ConfigurationError(
"MYSQL_CONNECTION_STRING is required for MySQL (no default is provided)"
)
return {"type": "mysql", "connection_string": cls.MYSQL_CONNECTION_STRING}
else: # sqlite
return {
"type": "sqlite",
"connection_string": f"sqlite:///{cls.DATABASE_PATH}",
}
@classmethod
def get_all_settings(cls) -> Dict[str, Any]:
"""Returns all configuration settings"""
return {
"DB_TYPE": cls.DB_TYPE,
"DATABASE_PATH": cls.DATABASE_PATH,
"TARGET_URL": cls.TARGET_URL,
"API_URL": cls.API_URL,
"REQUEST_TIMEOUT": cls.REQUEST_TIMEOUT,
"SCRAPING_INTERVAL_HOURS": cls.SCRAPING_INTERVAL_HOURS,
"RID_POLL_OFFSET_MINUTES": cls.RID_POLL_OFFSET_MINUTES,
"RID_RETRY_MINUTES": cls.RID_RETRY_MINUTES,
"GAP_FILL_DAYS_ON_START": cls.GAP_FILL_DAYS_ON_START,
"COLLECTION_ENABLED": cls.COLLECTION_ENABLED,
"FORECAST_IN_COLLECTOR": cls.FORECAST_IN_COLLECTOR,
"LOG_LEVEL": cls.LOG_LEVEL,
"LOG_FILE": cls.LOG_FILE,
"DATA_RETENTION_DAYS": cls.DATA_RETENTION_DAYS,
"MAX_RETRIES": cls.MAX_RETRIES,
"RETRY_DELAY_SECONDS": cls.RETRY_DELAY_SECONDS,
"VM_HOST": cls.VM_HOST,
"VM_PORT": cls.VM_PORT,
"INFLUX_HOST": cls.INFLUX_HOST,
"INFLUX_PORT": cls.INFLUX_PORT,
"INFLUX_DATABASE": cls.INFLUX_DATABASE,
}
@classmethod
def print_settings(cls):
"""Prints all current settings"""
print("=== Water Level Monitor Configuration ===")
for key, value in cls.get_all_settings().items():
# Hide sensitive information
if "PASSWORD" in key and value:
value = "*" * len(str(value))
print(f"{key}: {value}")
print("=" * 45)
print("\nDatabase Configuration:")
db_config = cls.get_database_config()
for key, value in db_config.items():
if "password" in key and value:
value = "*" * len(str(value))
print(f" {key}: {value}")
print("=" * 45)
if __name__ == "__main__":
Config.print_settings()
url = database_url()
if url and "@" in url:
url = url.split("://", 1)[0] + "://***@" + url.rsplit("@", 1)[1]
print(url or "no database configured")
File diff suppressed because it is too large. Load diff
-352
View File
@@ -1,352 +0,0 @@
#!/usr/bin/env python3
"""
Demo script showing different database backend options for water monitoring
"""
import datetime
import os
import sys
from water_scraper_v3 import EnhancedWaterMonitorScraper
def demo_sqlite():
"""Demo with SQLite (local development)"""
print("=" * 60)
print("🗄️ SQLite Demo (Local Development)")
print("=" * 60)
config = {"type": "sqlite", "connection_string": "sqlite:///demo_water_sqlite.db"}
try:
scraper = EnhancedWaterMonitorScraper(config)
# Fetch and save data
print("Fetching data from API...")
data = scraper.fetch_water_data()
if data:
print(f"✓ Fetched {len(data)} data points")
success = scraper.save_to_database(data)
if success:
print("✓ Data saved to SQLite database")
# Show latest data
latest = scraper.get_latest_data(5)
print(f"\nLatest 5 measurements:")
for measurement in latest:
print(
f" • {measurement['station_code']} ({measurement['station_name_en']}): "
f"{measurement['water_level']:.2f}m, {measurement['discharge']:.1f} cms"
)
else:
print("✗ Failed to save data")
else:
print("✗ No data fetched")
except Exception as e:
print(f"Error: {e}")
def demo_influxdb():
"""Demo with InfluxDB (requires InfluxDB running)"""
print("\n" + "=" * 60)
print("📊 InfluxDB Demo (Time-Series Database)")
print("=" * 60)
config = {
"type": "influxdb",
"host": "localhost",
"port": 8086,
"database": "water_monitoring_demo",
"username": None, # Set if authentication is enabled
"password": None,
}
try:
scraper = EnhancedWaterMonitorScraper(config)
if scraper.db_adapter and scraper.db_adapter.client:
print("✓ Connected to InfluxDB")
# Fetch and save data
print("Fetching data from API...")
data = scraper.fetch_water_data()
if data:
print(f"✓ Fetched {len(data)} data points")
success = scraper.save_to_database(data)
if success:
print("✓ Data saved to InfluxDB")
print("💡 You can now query this data in Grafana or InfluxDB CLI")
print(
" Example query: SELECT * FROM water_data ORDER BY time DESC LIMIT 10"
)
else:
print("✗ Failed to save data")
else:
print("✗ No data fetched")
else:
print("✗ Could not connect to InfluxDB")
print(
"💡 Make sure InfluxDB is running: docker run -p 8086:8086 influxdb:1.8"
)
except Exception as e:
print(f"Error: {e}")
print("💡 InfluxDB might not be running or accessible")
def demo_postgresql():
"""Demo with PostgreSQL (requires PostgreSQL running)"""
print("\n" + "=" * 60)
print("🐘 PostgreSQL Demo (Relational Database)")
print("=" * 60)
config = {
"type": "postgresql",
"connection_string": "postgresql://postgres:password@localhost:5432/water_monitoring",
}
try:
scraper = EnhancedWaterMonitorScraper(config)
if scraper.db_adapter and scraper.db_adapter.engine:
print("✓ Connected to PostgreSQL")
# Fetch and save data
print("Fetching data from API...")
data = scraper.fetch_water_data()
if data:
print(f"✓ Fetched {len(data)} data points")
success = scraper.save_to_database(data)
if success:
print("✓ Data saved to PostgreSQL")
print("💡 You can now query this data with SQL")
print(
" Example: SELECT * FROM water_measurements ORDER BY timestamp DESC LIMIT 10;"
)
else:
print("✗ Failed to save data")
else:
print("✗ No data fetched")
else:
print("✗ Could not connect to PostgreSQL")
print("💡 Make sure PostgreSQL is running with correct credentials")
except Exception as e:
print(f"Error: {e}")
print("💡 PostgreSQL might not be running or credentials might be wrong")
def demo_mysql():
"""Demo with MySQL (requires MySQL running)"""
print("\n" + "=" * 60)
print("🐬 MySQL Demo (Relational Database)")
print("=" * 60)
config = {
"type": "mysql",
"connection_string": "mysql://root:password@localhost:3306/water_monitoring",
}
try:
scraper = EnhancedWaterMonitorScraper(config)
if scraper.db_adapter and scraper.db_adapter.engine:
print("✓ Connected to MySQL")
# Fetch and save data
print("Fetching data from API...")
data = scraper.fetch_water_data()
if data:
print(f"✓ Fetched {len(data)} data points")
success = scraper.save_to_database(data)
if success:
print("✓ Data saved to MySQL")
print("💡 You can now query this data with SQL")
print(
" Example: SELECT * FROM water_measurements ORDER BY timestamp DESC LIMIT 10;"
)
else:
print("✗ Failed to save data")
else:
print("✗ No data fetched")
else:
print("✗ Could not connect to MySQL")
print("💡 Make sure MySQL is running with correct credentials")
except Exception as e:
print(f"Error: {e}")
print("💡 MySQL might not be running or credentials might be wrong")
def demo_victoriametrics():
"""Demo with VictoriaMetrics (supports both local and HTTPS configurations)"""
print("\n" + "=" * 60)
print("⚡ VictoriaMetrics Demo (High-Performance Metrics)")
print("=" * 60)
# Use configuration from environment or config.py
from config import Config
db_config = Config.get_database_config()
if db_config["type"] != "victoriametrics":
# Fallback to default local configuration
config = {"type": "victoriametrics", "host": "vm.newedge.house", "port": 443}
else:
config = db_config
print(f"Connecting to: {config['host']}:{config['port']}")
try:
scraper = EnhancedWaterMonitorScraper(config)
if scraper.db_adapter:
# Test connection using the adapter's connect method
if scraper.db_adapter.connect():
print("✓ Connected to VictoriaMetrics")
# Fetch and save data
print("Fetching data from API...")
data = scraper.fetch_water_data()
if data:
print(f"✓ Fetched {len(data)} data points")
success = scraper.save_to_database(data)
if success:
print("✓ Data saved to VictoriaMetrics")
print("💡 You can now query this data via Prometheus API")
# Show appropriate query URL based on configuration
base_url = scraper.db_adapter.base_url
print(f" Example: {base_url}/api/v1/query?query=water_level")
print(f" Health check: {base_url}/health")
else:
print("✗ Failed to save data")
else:
print("✗ No data fetched")
else:
print("✗ Could not connect to VictoriaMetrics")
if config["host"] == "localhost":
print("💡 Make sure VictoriaMetrics is running locally:")
print(" docker run -p 8428:8428 victoriametrics/victoria-metrics")
else:
print(
f"💡 Check if VictoriaMetrics is accessible at {config['host']}:{config['port']}"
)
print("💡 Verify HTTPS configuration and network connectivity")
else:
print("✗ Failed to initialize VictoriaMetrics adapter")
except Exception as e:
print(f"Error: {e}")
print("💡 Check your VictoriaMetrics configuration and network connectivity")
def show_recommendations():
"""Show database recommendations"""
print("\n" + "=" * 60)
print("🏆 Database Recommendations")
print("=" * 60)
recommendations = [
{
"name": "InfluxDB",
"best_for": "Time-series data, Grafana dashboards",
"pros": [
"Purpose-built for time-series",
"Great compression",
"Built-in retention",
],
"cons": ["Learning curve", "Less flexible for complex queries"],
"use_case": "Recommended for most water monitoring deployments",
},
{
"name": "PostgreSQL + TimescaleDB",
"best_for": "Complex queries, existing PostgreSQL infrastructure",
"pros": ["Mature ecosystem", "SQL compatibility", "ACID compliance"],
"cons": ["More complex setup", "Higher resource usage"],
"use_case": "Best for organizations already using PostgreSQL",
},
{
"name": "VictoriaMetrics",
"best_for": "High-performance metrics, Prometheus compatibility",
"pros": ["Extremely fast", "Low resource usage", "Better compression"],
"cons": ["Newer ecosystem", "Less tooling"],
"use_case": "Best for high-volume, performance-critical deployments",
},
{
"name": "MySQL",
"best_for": "Existing MySQL infrastructure, familiar SQL",
"pros": ["Familiar", "Mature", "Wide support"],
"cons": ["Not optimized for time-series", "Manual optimization needed"],
"use_case": "Good for organizations with existing MySQL expertise",
},
]
for rec in recommendations:
print(f"\n📊 {rec['name']}")
print(f" Best for: {rec['best_for']}")
print(f" Pros: {', '.join(rec['pros'])}")
print(f" Cons: {', '.join(rec['cons'])}")
print(f" 💡 {rec['use_case']}")
def main():
"""Main demo function"""
print("🌊 Thailand Water Monitor - Database Backend Demo")
print("This demo shows how to use different database backends")
# Always run SQLite demo (no external dependencies)
demo_sqlite()
# Check for command line arguments to run specific demos
if len(sys.argv) > 1:
db_type = sys.argv[1].lower()
if db_type == "influxdb":
demo_influxdb()
elif db_type == "postgresql":
demo_postgresql()
elif db_type == "mysql":
demo_mysql()
elif db_type == "victoriametrics":
demo_victoriametrics()
elif db_type == "all":
demo_influxdb()
demo_postgresql()
demo_mysql()
demo_victoriametrics()
else:
print(f"\nUnknown database type: {db_type}")
print(
"Available options: influxdb, postgresql, mysql, victoriametrics, all"
)
else:
print("\n💡 To test other databases, run:")
print(" python demo_databases.py influxdb")
print(" python demo_databases.py postgresql")
print(" python demo_databases.py mysql")
print(" python demo_databases.py victoriametrics")
print(" python demo_databases.py all")
# Show recommendations
show_recommendations()
print("\n" + "=" * 60)
print("✅ Demo completed!")
print("📖 See DATABASE_DEPLOYMENT_GUIDE.md for production setup instructions")
print("=" * 60)
if __name__ == "__main__":
main()
-46
View File
@@ -1,46 +0,0 @@
#!/usr/bin/env python3
"""
Custom exceptions for water monitoring system
"""
class WaterMonitorException(Exception):
"""Base exception for water monitoring system"""
pass
class DatabaseConnectionError(WaterMonitorException):
"""Raised when database connection fails"""
pass
class APIConnectionError(WaterMonitorException):
"""Raised when API connection fails"""
pass
class DataValidationError(WaterMonitorException):
"""Raised when data validation fails"""
pass
class ConfigurationError(WaterMonitorException):
"""Raised when configuration is invalid"""
pass
class DataParsingError(WaterMonitorException):
"""Raised when data parsing fails"""
pass
class RetryExhaustedError(WaterMonitorException):
"""Raised when all retry attempts are exhausted"""
pass
-172
View File
@@ -1,172 +0,0 @@
"""Persistence for issued flood forecasts.
Every background precompute stores what the deployed model predicted at that
moment — predicted 24/12/6 h peak, warning/danger probabilities, model
version. Keyed by (as_of, station, horizon), so hourly data yields one row
per station-horizon per hour regardless of how often the precompute runs.
This is the operational record that lets "predicted vs actual" be graphed
later without retraining historical models.
"""
import datetime
import logging
from typing import Dict, List, Optional
logger = logging.getLogger(__name__)
class ForecastHistoryStore:
"""SQL store (sqlite / postgresql / mysql), same pattern as HiiStore."""
def __init__(self, connection_string: str, db_type: str):
self.db_type = db_type.lower()
if self.db_type not in ("sqlite", "postgresql", "mysql"):
raise ValueError(
f"Forecast history requires a SQL database, got '{db_type}'"
)
self.connection_string = connection_string
self.engine = None
def connect(self) -> bool:
try:
from sqlalchemy import create_engine, text
self.engine = create_engine(self.connection_string, pool_pre_ping=True)
ddl = """
CREATE TABLE IF NOT EXISTS forecast_history (
as_of TIMESTAMP NOT NULL,
station_code VARCHAR(10) NOT NULL,
horizon_hours INTEGER NOT NULL,
predicted_max_level NUMERIC(8,3),
p_warning NUMERIC(7,5),
p_danger NUMERIC(7,5),
current_level NUMERIC(8,3),
model_version VARCHAR(64),
source VARCHAR(16),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (as_of, station_code, horizon_hours)
)
"""
index = (
"CREATE INDEX IF NOT EXISTS idx_forecast_history_station "
"ON forecast_history(station_code, as_of)"
)
with self.engine.begin() as conn:
conn.execute(text(ddl))
if self.db_type != "mysql": # MySQL lacks IF NOT EXISTS for indexes
conn.execute(text(index))
return True
except Exception as error:
logger.error(f"ForecastHistoryStore failed to connect: {error}")
self.engine = None
return False
def save_rows(self, rows: List[Dict]) -> int:
"""Upsert forecast rows as returned by ml.predict (idempotent)."""
if not rows:
return 0
if not self.engine and not self.connect():
return 0
from sqlalchemy import text
cols = (
"(as_of, station_code, horizon_hours, predicted_max_level, "
"p_warning, p_danger, current_level, model_version, source)"
)
values = (
"(:as_of, :station_code, :horizon_hours, :predicted_max_level, "
":p_warning, :p_danger, :current_level, :model_version, :source)"
)
update_cols = (
"predicted_max_level",
"p_warning",
"p_danger",
"current_level",
"model_version",
"source",
)
if self.db_type == "mysql":
updates = ", ".join(f"{c} = VALUES({c})" for c in update_cols)
sql = (
f"INSERT INTO forecast_history {cols} VALUES {values} "
f"ON DUPLICATE KEY UPDATE {updates}"
)
else:
updates = ", ".join(f"{c} = EXCLUDED.{c}" for c in update_cols)
sql = (
f"INSERT INTO forecast_history {cols} VALUES {values} "
f"ON CONFLICT (as_of, station_code, horizon_hours) "
f"DO UPDATE SET {updates}"
)
params = []
for row in rows:
as_of = row.get("as_of")
if isinstance(as_of, str):
as_of = datetime.datetime.fromisoformat(as_of)
if as_of is None or row.get("station_code") is None:
continue
params.append(
{
"as_of": as_of,
"station_code": row["station_code"],
"horizon_hours": row.get("horizon_hours"),
"predicted_max_level": row.get("predicted_max_level"),
"p_warning": row.get("p_warning"),
"p_danger": row.get("p_danger"),
"current_level": row.get("current_level"),
"model_version": row.get("model_version"),
"source": row.get("source"),
}
)
if not params:
return 0
try:
with self.engine.begin() as conn:
conn.execute(text(sql), params)
return len(params)
except Exception as error:
logger.error(f"ForecastHistoryStore save failed: {error}")
return 0
def fetch(
self,
station_code: str,
start: Optional[datetime.datetime] = None,
end: Optional[datetime.datetime] = None,
horizon_hours: Optional[int] = None,
limit: int = 5000,
) -> List[Dict]:
"""Issued forecasts for one station, ascending by as_of."""
if not self.engine and not self.connect():
return []
from sqlalchemy import text
clauses = ["station_code = :code"]
params: Dict = {"code": station_code, "limit": limit}
if start is not None:
clauses.append("as_of >= :start")
params["start"] = start
if end is not None:
clauses.append("as_of <= :end")
params["end"] = end
if horizon_hours is not None:
clauses.append("horizon_hours = :horizon")
params["horizon"] = horizon_hours
sql = (
"SELECT as_of, station_code, horizon_hours, predicted_max_level, "
"p_warning, p_danger, current_level, model_version, source "
f"FROM forecast_history WHERE {' AND '.join(clauses)} "
"ORDER BY as_of ASC, horizon_hours ASC LIMIT :limit"
)
try:
with self.engine.connect() as conn:
rows = [dict(r._mapping) for r in conn.execute(text(sql), params)]
for row in rows:
for key, value in row.items():
if hasattr(value, "is_finite"): # Decimal -> float
row[key] = float(value)
return rows
except Exception as error:
logger.error(f"ForecastHistoryStore fetch failed: {error}")
return []
-290
View File
@@ -1,290 +0,0 @@
#!/usr/bin/env python3
"""
Health check system for water monitoring application
"""
import logging
import threading
import time
from dataclasses import dataclass
from datetime import datetime, timedelta
from enum import Enum
from typing import Any, Callable, Dict, List, Optional
logger = logging.getLogger(__name__)
class HealthStatus(Enum):
HEALTHY = "healthy"
DEGRADED = "degraded"
UNHEALTHY = "unhealthy"
@dataclass
class HealthCheckResult:
"""Result of a health check"""
name: str
status: HealthStatus
message: str
timestamp: datetime
response_time_ms: Optional[float] = None
details: Optional[Dict[str, Any]] = None
class HealthCheck:
"""Base health check class"""
def __init__(self, name: str, timeout_seconds: int = 30):
self.name = name
self.timeout_seconds = timeout_seconds
def check(self) -> HealthCheckResult:
"""Perform the health check"""
start_time = time.time()
try:
result = self._perform_check()
response_time = (time.time() - start_time) * 1000
return HealthCheckResult(
name=self.name,
status=result.get("status", HealthStatus.HEALTHY),
message=result.get("message", "OK"),
timestamp=datetime.now(),
response_time_ms=response_time,
details=result.get("details"),
)
except Exception as e:
response_time = (time.time() - start_time) * 1000
logger.error(f"Health check {self.name} failed: {e}")
return HealthCheckResult(
name=self.name,
status=HealthStatus.UNHEALTHY,
message=f"Check failed: {str(e)}",
timestamp=datetime.now(),
response_time_ms=response_time,
)
def _perform_check(self) -> Dict[str, Any]:
"""Override this method to implement the actual check"""
raise NotImplementedError
class DatabaseHealthCheck(HealthCheck):
"""Health check for database connectivity"""
def __init__(self, db_adapter, name: str = "database"):
super().__init__(name)
self.db_adapter = db_adapter
def _perform_check(self) -> Dict[str, Any]:
if not self.db_adapter:
return {
"status": HealthStatus.UNHEALTHY,
"message": "Database adapter not initialized",
}
try:
# Connect only when there is no live engine yet: connect() re-runs
# the CREATE TABLE DDL suite, which is far too heavy per probe.
if getattr(self.db_adapter, "engine", None) is None and hasattr(
self.db_adapter, "connect"
):
connected = self.db_adapter.connect()
if not connected:
return {
"status": HealthStatus.UNHEALTHY,
"message": "Database connection failed",
}
# Try to get latest data
latest_data = self.db_adapter.get_latest_measurements(limit=1)
if latest_data:
latest_timestamp = latest_data[0].get("timestamp")
if isinstance(latest_timestamp, str):
latest_timestamp = datetime.fromisoformat(
latest_timestamp.replace("Z", "+00:00")
)
# Check if data is recent (within last 2 hours)
if datetime.now() - latest_timestamp.replace(tzinfo=None) > timedelta(
hours=2
):
return {
"status": HealthStatus.DEGRADED,
"message": f"Latest data is old: {latest_timestamp}",
"details": {"latest_data_timestamp": str(latest_timestamp)},
}
return {
"status": HealthStatus.HEALTHY,
"message": "Database connection OK",
"details": {
"latest_data_count": len(latest_data),
"latest_timestamp": (
str(latest_data[0].get("timestamp")) if latest_data else None
),
},
}
except Exception as e:
return {
"status": HealthStatus.UNHEALTHY,
"message": f"Database check failed: {str(e)}",
}
class APIHealthCheck(HealthCheck):
"""Health check for external API connectivity"""
def __init__(self, api_url: str, session, name: str = "api"):
super().__init__(name)
# A liveness probe should fail fast: the default 30s timeout meant a
# slow upstream pinned executor threads for longer than the /health
# cache TTL, so the pool never drained under load.
self.timeout_seconds = 5
self.api_url = api_url
self.session = session
def _perform_check(self) -> Dict[str, Any]:
try:
# Simple GET request to check API availability
response = self.session.get(self.api_url, timeout=self.timeout_seconds)
if response.status_code == 200:
return {
"status": HealthStatus.HEALTHY,
"message": "API connection OK",
"details": {
"status_code": response.status_code,
"response_size": len(response.content),
},
}
else:
return {
"status": HealthStatus.DEGRADED,
"message": f"API returned status {response.status_code}",
"details": {"status_code": response.status_code},
}
except Exception as e:
return {
"status": HealthStatus.UNHEALTHY,
"message": f"API check failed: {str(e)}",
}
class MemoryHealthCheck(HealthCheck):
"""Health check for memory usage"""
def __init__(self, max_memory_mb: int = 1000, name: str = "memory"):
super().__init__(name)
self.max_memory_mb = max_memory_mb
def _perform_check(self) -> Dict[str, Any]:
try:
import psutil
process = psutil.Process()
memory_info = process.memory_info()
memory_mb = memory_info.rss / 1024 / 1024
if memory_mb > self.max_memory_mb:
return {
"status": HealthStatus.DEGRADED,
"message": f"High memory usage: {memory_mb:.1f}MB",
"details": {
"memory_mb": memory_mb,
"max_memory_mb": self.max_memory_mb,
},
}
return {
"status": HealthStatus.HEALTHY,
"message": f"Memory usage OK: {memory_mb:.1f}MB",
"details": {"memory_mb": memory_mb},
}
except ImportError:
return {
"status": HealthStatus.HEALTHY,
"message": "Memory check skipped (psutil not available)",
}
except Exception as e:
return {
"status": HealthStatus.UNHEALTHY,
"message": f"Memory check failed: {str(e)}",
}
class HealthCheckManager:
"""Manages multiple health checks"""
def __init__(self):
self.checks: List[HealthCheck] = []
self.last_results: Dict[str, HealthCheckResult] = {}
self._lock = threading.Lock()
def add_check(self, health_check: HealthCheck):
"""Add a health check"""
with self._lock:
self.checks.append(health_check)
def run_all_checks(self) -> Dict[str, HealthCheckResult]:
"""Run all health checks"""
results = {}
for check in self.checks:
try:
result = check.check()
results[check.name] = result
with self._lock:
self.last_results[check.name] = result
except Exception as e:
logger.error(f"Error running health check {check.name}: {e}")
results[check.name] = HealthCheckResult(
name=check.name,
status=HealthStatus.UNHEALTHY,
message=f"Check execution failed: {str(e)}",
timestamp=datetime.now(),
)
return results
def get_overall_status(self) -> HealthStatus:
"""Get overall system health status"""
if not self.last_results:
return HealthStatus.UNHEALTHY
statuses = [result.status for result in self.last_results.values()]
if any(status == HealthStatus.UNHEALTHY for status in statuses):
return HealthStatus.UNHEALTHY
elif any(status == HealthStatus.DEGRADED for status in statuses):
return HealthStatus.DEGRADED
else:
return HealthStatus.HEALTHY
def get_health_summary(self) -> Dict[str, Any]:
"""Get a summary of system health"""
overall_status = self.get_overall_status()
return {
"overall_status": overall_status.value,
"timestamp": datetime.now().isoformat(),
"checks": {
name: {
"status": result.status.value,
"message": result.message,
"response_time_ms": result.response_time_ms,
"timestamp": result.timestamp.isoformat(),
}
for name, result in self.last_results.items()
},
}
+5 -5
View File
@@ -198,13 +198,13 @@ def main(argv: Optional[List[str]] = None) -> bool:
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
)
from .config import Config
from .config import database_url
db_config = Config.get_database_config()
if db_config["type"] not in ("sqlite", "postgresql", "mysql"):
logger.error(f"Backfill requires a SQL DB_TYPE, got '{db_config['type']}'")
url = database_url()
if not url:
logger.error("no database: set POSTGRES_CONNECTION_STRING or POSTGRES_*")
return False
store = HiiStore(db_config["connection_string"], db_config["type"])
store = HiiStore(url)
if not store.connect():
return False
+19 -95
View File
@@ -1,7 +1,10 @@
"""Collector for HII/ThaiWater open api-v3 feeds (rainfall + water level).
"""HII/ThaiWater open api-v3 client and PostgreSQL store (rainfall + water level).
Polls the unauthenticated api-v3.thaiwater.net public endpoints, filters to the
Ping basin, and persists to dedicated tables alongside the RID data:
The hourly collection runs in ping-collector (rust/collector/src/hii.rs, same
parsing and tables); this module is used by the history backfill
(src/hii_backfill.py, scripts/backfill_hii_waterlevel.py) and the tests.
Endpoints: the unauthenticated api-v3.thaiwater.net public API, filtered to the
Ping basin. Tables:
- hii_rain_stations / hii_rainfall (rain_1h / rain_24h gauge telemetry)
- hii_wl_stations / hii_waterlevel (independent water-level source, m MSL)
@@ -175,16 +178,10 @@ class HiiClient:
class HiiStore:
"""SQL persistence for HII feeds (sqlite / postgresql / mysql).
"""PostgreSQL persistence for the HII feeds: the hii_* tables next to the
RID data in the same database (the DDL matches rust/collector/src/db.rs)."""
Reuses the app's main relational database (same connection string as
the RID tables) but writes to its own hii_* tables.
"""
def __init__(self, connection_string: str, db_type: str):
self.db_type = db_type.lower()
if self.db_type not in ("sqlite", "postgresql", "mysql"):
raise ValueError(f"HII collection requires a SQL database, got '{db_type}'")
def __init__(self, connection_string: str):
self.connection_string = connection_string
self.engine = None
@@ -203,7 +200,6 @@ class HiiStore:
def _create_tables(self):
from sqlalchemy import text
bool_type = "BOOLEAN" if self.db_type != "mysql" else "TINYINT(1)"
ddl = [
"""
CREATE TABLE IF NOT EXISTS hii_rain_stations (
@@ -230,7 +226,7 @@ class HiiStore:
PRIMARY KEY (station_id, timestamp)
)
""",
f"""
"""
CREATE TABLE IF NOT EXISTS hii_wl_stations (
id INTEGER PRIMARY KEY,
oldcode VARCHAR(60),
@@ -247,7 +243,7 @@ class HiiStore:
critical_level_msl NUMERIC(8,3),
critical_level_m NUMERIC(8,3),
qmax NUMERIC(10,2),
is_key_station {bool_type},
is_key_station BOOLEAN,
updated_at TIMESTAMP
)
""",
@@ -269,10 +265,6 @@ class HiiStore:
"CREATE INDEX IF NOT EXISTS idx_hii_rainfall_ts ON hii_rainfall(timestamp)",
"CREATE INDEX IF NOT EXISTS idx_hii_waterlevel_ts ON hii_waterlevel(timestamp)",
]
# MySQL (<8.0.13 semantics) lacks CREATE INDEX IF NOT EXISTS; the unique
# constraints already cover the hot (station_id, timestamp) lookups there.
if self.db_type == "mysql":
ddl = ddl[:4]
with self.engine.begin() as conn:
for statement in ddl:
conn.execute(text(statement))
@@ -281,19 +273,11 @@ class HiiStore:
cols = key_cols + value_cols
col_list = ", ".join(cols)
params = ", ".join(f":{c}" for c in cols)
if self.db_type == "sqlite":
return f"INSERT OR REPLACE INTO {table} ({col_list}) VALUES ({params})"
if self.db_type == "postgresql":
updates = ", ".join(f"{c} = EXCLUDED.{c}" for c in value_cols)
conflict = ", ".join(key_cols)
return (
f"INSERT INTO {table} ({col_list}) VALUES ({params}) "
f"ON CONFLICT ({conflict}) DO UPDATE SET {updates}"
)
updates = ", ".join(f"{c} = VALUES({c})" for c in value_cols)
updates = ", ".join(f"{c} = EXCLUDED.{c}" for c in value_cols)
conflict = ", ".join(key_cols)
return (
f"INSERT INTO {table} ({col_list}) VALUES ({params}) "
f"ON DUPLICATE KEY UPDATE {updates}"
f"ON CONFLICT ({conflict}) DO UPDATE SET {updates}"
)
def save_rain(self, records: List[Dict]) -> int:
@@ -360,18 +344,11 @@ class HiiStore:
cols = "(station_id, timestamp, wl_msl, discharge)"
values = "(:station_id, :timestamp, :wl_msl, :discharge)"
if self.db_type == "mysql":
sql = (
f"INSERT INTO hii_waterlevel {cols} VALUES {values} "
"ON DUPLICATE KEY UPDATE wl_msl = VALUES(wl_msl), "
"discharge = VALUES(discharge)"
)
else: # sqlite (>=3.24) and postgresql share upsert syntax
sql = (
f"INSERT INTO hii_waterlevel {cols} VALUES {values} "
"ON CONFLICT (station_id, timestamp) DO UPDATE SET "
"wl_msl = EXCLUDED.wl_msl, discharge = EXCLUDED.discharge"
)
sql = (
f"INSERT INTO hii_waterlevel {cols} VALUES {values} "
"ON CONFLICT (station_id, timestamp) DO UPDATE SET "
"wl_msl = EXCLUDED.wl_msl, discharge = EXCLUDED.discharge"
)
params = [{**row, "station_id": station_id} for row in rows]
try:
with self.engine.begin() as conn:
@@ -423,56 +400,3 @@ class HiiStore:
except Exception as e:
logger.error(f"HiiStore save to {measurement_table} failed: {e}")
return 0
class HiiCollector:
"""Fetch + persist one snapshot of both HII feeds."""
def __init__(
self,
db_config: Dict,
basin_code: int = PING_BASIN_CODE,
client: Optional[HiiClient] = None,
):
self.client = client or HiiClient()
self.basin_code = basin_code
self.store = HiiStore(
connection_string=db_config["connection_string"],
db_type=db_config["type"],
)
def run_cycle(self) -> Dict[str, int]:
"""Collect both feeds; each is independent and failure-isolated."""
counts = {"rainfall": 0, "waterlevel": 0}
try:
counts["rainfall"] = self.store.save_rain(
self.client.fetch_rain(self.basin_code)
)
except Exception as e:
logger.error(f"HII rainfall collection failed: {e}")
try:
counts["waterlevel"] = self.store.save_waterlevel(
self.client.fetch_waterlevel(self.basin_code)
)
except Exception as e:
logger.error(f"HII waterlevel collection failed: {e}")
logger.info(
f"HII collection: {counts['rainfall']} rainfall, "
f"{counts['waterlevel']} waterlevel rows saved"
)
return counts
def create_collector_from_config() -> Optional[HiiCollector]:
"""Build a collector from app Config; None when disabled or non-SQL DB."""
from .config import Config
if not Config.ENABLE_HII_COLLECTION:
return None
db_config = Config.get_database_config()
if db_config["type"] not in ("sqlite", "postgresql", "mysql"):
logger.warning(
f"HII collection skipped: DB_TYPE '{db_config['type']}' is not SQL"
)
return None
return HiiCollector(db_config, basin_code=Config.HII_BASIN_CODE)
-135
View File
@@ -1,135 +0,0 @@
#!/usr/bin/env python3
"""
Centralized logging configuration for water monitoring system
"""
import logging
import logging.handlers
import os
from datetime import datetime
from typing import Optional
class ColoredFormatter(logging.Formatter):
"""Colored console formatter"""
COLORS = {
"DEBUG": "\033[36m", # Cyan
"INFO": "\033[32m", # Green
"WARNING": "\033[33m", # Yellow
"ERROR": "\033[31m", # Red
"CRITICAL": "\033[35m", # Magenta
"RESET": "\033[0m", # Reset
}
def format(self, record):
if hasattr(record, "levelname"):
color = self.COLORS.get(record.levelname, self.COLORS["RESET"])
record.levelname = f"{color}{record.levelname}{self.COLORS['RESET']}"
return super().format(record)
def setup_logging(
log_level: str = "INFO",
log_file: Optional[str] = None,
max_file_size: int = 10 * 1024 * 1024, # 10MB
backup_count: int = 5,
enable_console: bool = True,
enable_colors: bool = True,
) -> logging.Logger:
"""
Setup comprehensive logging configuration
Args:
log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
log_file: Path to log file (optional)
max_file_size: Maximum size of log file before rotation
backup_count: Number of backup files to keep
enable_console: Whether to enable console logging
enable_colors: Whether to enable colored console output
Returns:
Configured logger instance
"""
# Create logs directory if it doesn't exist
if log_file:
log_dir = os.path.dirname(log_file)
if log_dir and not os.path.exists(log_dir):
os.makedirs(log_dir)
# Configure root logger
logger = logging.getLogger()
logger.setLevel(getattr(logging, log_level.upper()))
# Clear existing handlers
logger.handlers.clear()
# Create formatters
detailed_formatter = logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
simple_formatter = logging.Formatter(
"%(asctime)s - %(levelname)s - %(message)s", datefmt="%H:%M:%S"
)
# Console handler
if enable_console:
console_handler = logging.StreamHandler()
if enable_colors and os.name != "nt": # Don't use colors on Windows
console_formatter = ColoredFormatter(
"%(asctime)s - %(levelname)s - %(message)s", datefmt="%H:%M:%S"
)
else:
console_formatter = simple_formatter
console_handler.setFormatter(console_formatter)
console_handler.setLevel(getattr(logging, log_level.upper()))
logger.addHandler(console_handler)
# File handler with rotation
if log_file:
file_handler = logging.handlers.RotatingFileHandler(
log_file, maxBytes=max_file_size, backupCount=backup_count, encoding="utf-8"
)
file_handler.setFormatter(detailed_formatter)
file_handler.setLevel(logging.DEBUG) # Always log everything to file
logger.addHandler(file_handler)
# Add performance logger for metrics
perf_logger = logging.getLogger("performance")
if log_file:
perf_file = log_file.replace(".log", "_performance.log")
perf_handler = logging.handlers.RotatingFileHandler(
perf_file,
maxBytes=max_file_size,
backupCount=backup_count,
encoding="utf-8",
)
perf_formatter = logging.Formatter(
"%(asctime)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
)
perf_handler.setFormatter(perf_formatter)
perf_logger.addHandler(perf_handler)
perf_logger.setLevel(logging.INFO)
perf_logger.propagate = False
return logger
def log_performance_metric(
operation: str, duration: float, additional_info: Optional[str] = None
):
"""Log performance metrics"""
perf_logger = logging.getLogger("performance")
message = f"PERF: {operation} took {duration:.3f}s"
if additional_info:
message += f" - {additional_info}"
perf_logger.info(message)
def get_logger(name: str) -> logging.Logger:
"""Get a logger with the specified name"""
return logging.getLogger(name)
Loaded 100 of 151 files, more files were not shown because too many files have changed in this diff. Show more