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:
grabowski committed 2026-10-18 17:15:07 +07:00
1 parent 330fdf7804
commit 7e642555b4
125 files changed
+1300 -19558

No files matched your search

+81 -125
View File
@@ -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).
+8 -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,
@@ -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
+7 -6
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,
@@ -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 -1
View File
@@ -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
+2 -2
View File
@@ -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
+7 -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;
+11 -8
View File
@@ -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;
+1 -1
View File
@@ -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"
);
}
+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).
+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()