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.
102 lines
3.0 KiB
Python
102 lines
3.0 KiB
Python
"""Locust load profile for the Ping River Monitor API + dashboard.
|
|
|
|
Two user types mirror real traffic: dashboard visitors (page + the API calls
|
|
the page makes, polling like the auto-refresh does) and API consumers
|
|
(direct endpoint hits, including heavy history queries).
|
|
|
|
Full stress against a LOCAL instance (never full-stress production — it hosts
|
|
live flood monitoring):
|
|
|
|
# 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 \
|
|
--html load-report.html
|
|
|
|
Interactive UI instead: drop --headless and open http://localhost:8089.
|
|
"""
|
|
|
|
import random
|
|
|
|
from locust import FastHttpUser, between, task
|
|
|
|
# Explicit so measurements reflect compressed transfer (browsers always send this)
|
|
GZIP = {"Accept-Encoding": "gzip, deflate"}
|
|
|
|
|
|
class DashboardVisitor(FastHttpUser):
|
|
"""A browser session: initial page load, then periodic refresh polling."""
|
|
|
|
weight = 3
|
|
wait_time = between(2, 6)
|
|
|
|
def on_start(self):
|
|
# What one real page load requests
|
|
self.client.get("/", headers=GZIP)
|
|
self.client.get("/stations", headers=GZIP)
|
|
self.client.get("/measurements/latest?limit=500", headers=GZIP)
|
|
self.client.get("/api/hii/waterlevel/latest", headers=GZIP)
|
|
self.client.get("/api/hii/rainfall/latest", headers=GZIP)
|
|
|
|
@task(4)
|
|
def poll_latest(self):
|
|
self.client.get("/measurements/latest?limit=500", headers=GZIP)
|
|
|
|
@task(2)
|
|
def poll_forecast(self):
|
|
self.client.get("/forecast", headers=GZIP)
|
|
|
|
@task(2)
|
|
def poll_rain(self):
|
|
self.client.get("/api/hii/rainfall/latest", headers=GZIP)
|
|
|
|
@task(1)
|
|
def view_history(self):
|
|
station = random.choice(["P.1", "P.67", "P.103", "P.75", "P.20"])
|
|
hours = random.choice([24, 168, 720])
|
|
self.client.get(
|
|
f"/measurements/history/{station}?hours={hours}",
|
|
headers=GZIP,
|
|
name="/measurements/history/[station]",
|
|
)
|
|
|
|
@task(1)
|
|
def stats(self):
|
|
self.client.get("/api/stats", headers=GZIP)
|
|
|
|
|
|
class ApiConsumer(FastHttpUser):
|
|
"""A script/integration hitting the JSON API directly, no think time."""
|
|
|
|
weight = 1
|
|
wait_time = between(0.1, 1)
|
|
|
|
@task(3)
|
|
def latest(self):
|
|
self.client.get("/measurements/latest?limit=100", headers=GZIP)
|
|
|
|
@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",
|
|
)
|
|
|
|
@task(2)
|
|
def forecast(self):
|
|
self.client.get("/forecast", headers=GZIP)
|
|
|
|
@task(2)
|
|
def heavy_history(self):
|
|
self.client.get(
|
|
"/measurements/history/P.1?hours=8760",
|
|
headers=GZIP,
|
|
name="/measurements/history/P.1 [heavy]",
|
|
)
|
|
|
|
@task(1)
|
|
def health(self):
|
|
self.client.get("/health", headers=GZIP)
|