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.
This commit is contained in:
1 parent
330fdf7804
commit
7e642555b4
125 files changed
+1300
-19558
No files matched your search
+81
-125
@@ -1,29 +1,24 @@
|
||||
# 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
|
||||
@@ -33,131 +28,98 @@ ping-collector forecast [--json] [--save] [--now T] [--rain-file F]
|
||||
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 (RID lags 2-10 h at night) drives the level alerts, marked as
|
||||
ThaiWater telemetry. "Gauge feed stale" means no source has a reading for
|
||||
3 h; RID alone lagging is reported once after `RID_STALE_ALERT_HOURS`.
|
||||
- **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 +131,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).
|
||||
@@ -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,
|
||||
@@ -62,7 +64,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
|
||||
|
||||
@@ -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,
|
||||
@@ -727,7 +728,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,
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//! 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
//! 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
|
||||
//! state keys (`notification_state`) as Python had, so the cutover neither
|
||||
//! repeated nor dropped a message. One change: for stations whose HII/ThaiWater twin is
|
||||
//! the same sensor, a reading newer than RID's (RID's report often lags 2-10 h
|
||||
//! at night) is used for the level alerts and the feed check. The feed is
|
||||
//! "stale" only when neither source has a reading for 3 h; RID lagging alone
|
||||
@@ -1011,8 +1012,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;
|
||||
|
||||
@@ -403,7 +403,7 @@ impl Collector {
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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()
|
||||
Reference in new issue
Block a user