feat(collector): forecast and ntfy in ping-collector; ThaiWater keeps alerts current at night
CI / Format & lint (push) Successful in 22s
CI / Test suite (push) Successful in 59s
Docs / Validate documentation (push) Successful in 25s
Rust / fmt, clippy, test, build (push) Successful in 10m2s
Security / Dependency vulnerabilities (push) Successful in 2m11s
Security / Static analysis (push) Successful in 22s
Security / License report (push) Successful in 1m37s

Phase 2b/2c of docs/RUST_REWRITE.md. With FORECAST_IN_COLLECTOR=true (shared
.env) ping-collector, after each cycle that brought newer RID or HII readings
and at least hourly: loads the last 336 h (RID + HII mirror fill), fetches the
rain forecast, runs every exported model (rust/forecast), stores
forecast_history and forecast_current, and sends the ntfy notifications.
The Python API then serves forecast_current as /forecast (computing in-process
only when it is older than FORECAST_MAX_AGE_SECONDS) and neither forecasts nor
notifies.

ntfy (collector/src/notify.rs): a port of src/notify.py with the same topics,
texts, hysteresis, capacity guard and notification_state keys, checked word
for word on 751 messages generated by Python (testdata/gen_notify_cases.py).
The change: for stations whose ThaiWater twin is the same sensor, a reading
newer than RID's drives the level alerts (marked as ThaiWater telemetry), and
the feed counts as stale only when neither source has a reading for 3 h. RID
alone lagging is reported once after RID_STALE_ALERT_HOURS (18). This ends the
nightly "gauge feed stale" / "recovered" pairs caused by RID's late reports.

Commands: `forecast [--json] [--save] [--now] [--rain-file]`,
`notify --dry-run`. scripts/forecast_parity.py compares Python and the
collector on the live database (same moment, same rain); locally 48/48 rows
identical. install_collector.sh: models export + that check on every install,
--cutover-forecast (refuses on a failed check) and --rollback-forecast.

Trees are packed into 24-byte nodes: 70 MB peak with all 15 models (was 140).
Forecast values are written to NUMERIC as shortest round-trip text, so they
round as psycopg2's do. DB test schemas get a counter (parallel tests collided
on Windows' coarse clock).
This commit is contained in:
grabowski committed 2026-10-18 14:43:39 +07:00
1 parent 5c4f464383
commit 6ae67dec63
27 files changed
+2656 -118

No files matched your search

+12
View File
@@ -156,6 +156,17 @@ class Config:
"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")
@@ -306,6 +317,7 @@ class Config:
"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,
+57 -2
View File
@@ -217,10 +217,16 @@ async def lifespan(app: FastAPI):
)
app_state["notify"] = None
if app_state["leader_lock"]:
app_state["notify"] = _init_notifications()
if not Config.FORECAST_IN_COLLECTOR:
app_state["notify"] = _init_notifications()
if Config.COLLECTION_ENABLED:
app_state["scraping_task"] = asyncio.create_task(background_scraping_task())
logger.info("This worker is the background-collection leader")
elif Config.FORECAST_IN_COLLECTOR:
logger.info(
"COLLECTION_ENABLED=false, FORECAST_IN_COLLECTOR=true: ping-collector "
"collects, forecasts and notifies; this app serves its results"
)
else:
app_state["scraping_task"] = asyncio.create_task(forecast_notify_task())
logger.info(
@@ -353,7 +359,7 @@ def _init_notifications():
async def _notify_transitions():
"""Publish flood/outlook/feed transitions to ntfy (leader only, fail-safe)."""
cfg = app_state.get("notify")
if not cfg:
if not cfg or Config.FORECAST_IN_COLLECTOR:
return
publisher, state = cfg
try:
@@ -379,6 +385,8 @@ async def _notify_transitions():
async def _precompute_forecasts():
"""Refresh the forecast cache and persist the issued forecasts (leader only)."""
if Config.FORECAST_IN_COLLECTOR:
return # ping-collector forecasts and persists
try:
from .ml.predict import get_latest_forecasts
except ImportError:
@@ -1400,10 +1408,57 @@ async def get_postgres_history(
raise HTTPException(status_code=502, detail="Measurement history unavailable")
STORED_FORECAST_TTL = 60 # seconds; the collector refreshes at most every few minutes
def _stored_forecasts():
"""The collector's latest forecast (forecast_current), or None when it is
missing or older than FORECAST_MAX_AGE_SECONDS."""
from sqlalchemy import text
engine = getattr(app_state["scraper"].db_adapter, "engine", None)
if engine is None:
return None
try:
with engine.connect() as conn:
row = conn.execute(
text(
"SELECT computed_at, payload FROM forecast_current WHERE id = 'all'"
)
).fetchone()
except Exception as error:
logger.warning(f"stored forecast unavailable: {error}")
return None
if row is None:
return None
computed_at, payload = row[0], row[1]
if isinstance(computed_at, str):
computed_at = datetime.fromisoformat(computed_at)
age = (datetime.now() - computed_at).total_seconds()
if age > Config.FORECAST_MAX_AGE_SECONDS:
logger.warning(
f"stored forecast is {age / 3600:.1f} h old (ping-collector down?); "
"computing in-process"
)
return None
return json.loads(payload)
@app.get("/forecast")
async def get_flood_forecasts():
"""Flood-risk forecasts per station for the 6/12/24 h horizons."""
increment_counter("api_requests", labels={"endpoint": "forecast"})
if Config.FORECAST_IN_COLLECTOR:
cached = _cache_fresh(
FORECAST_CACHE, FORECAST_CACHE_LOCK, "stored", STORED_FORECAST_TTL
)
if cached is not None:
return cached
stored = await asyncio.to_thread(_stored_forecasts)
if stored:
with FORECAST_CACHE_LOCK:
FORECAST_CACHE["stored"] = (time.monotonic(), stored)
return stored
now = time.monotonic()
with FORECAST_CACHE_LOCK:
cached = FORECAST_CACHE.get("all")