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
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:
1 parent
5c4f464383
commit
6ae67dec63
27 files changed
+2656
-118
No files matched your search
@@ -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
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user