feat: codified backtests, honest docs, belt-and-braces serving, perf fixes
CI/CD Pipeline - Northern Thailand Ping River Monitor / Code Quality (push) Successful in 13s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Test Suite (3.11) (push) Failing after 23s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Build Docker Image (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Integration Test with Services (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Deploy to Staging (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Deploy to Production (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Performance Test (push) Skipped
Documentation / Validate Documentation (push) Failing after 7s
Documentation / Generate API Documentation (push) Successful in 8s
Documentation / Build Sphinx Documentation (push) Successful in 15s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Cleanup (push) Successful in 1s
Documentation / Documentation Summary (push) Successful in 3s

Retrained on the gap-filled DB (592k -> 976k rows) and re-examined the
flood backtests, now reproducible via scripts/backtest_render.py (renders
the three docs/img charts and gates on a >=12h 2024 first-alert lead —
currently failing by design and documented as such).

Findings, all documented in FLOOD_FORECASTING.md: the true 2024 crossing
was 24 Sep 17:00 (8h earlier than recorded; confirmed against the
independent HII sensor), the historical 24h-warning claim was partly a
missing-data artifact, and retrained warn classifiers collapse on the
filled grid (P.1 24h PR-AUC 0.900 -> 0.288) while regression MAE improves
(11.3 -> 10.5 cm). Serving therefore becomes max(classifier,
sigmoid(regression)) so alerting is never worse than the regression path;
metrics table, head-gating tiers, honest-limits and runbook expectations
all updated to the current model (hgb-v1+d2d0e65).

Perf, from Locust load testing (scripts/locustfile.py + load_test.py):
single-flight lock around /forecast inference (concurrent cache misses
previously each ran ~18s inference and starved the shared thread pool;
200-user run after: 105 rps, 0.01% errors), and /measurements/latest +
/health moved off the event loop (synchronous DB/network calls in async
handlers were stalling every request under load).
This commit is contained in:
2026-08-12 10:46:00 +07:00
parent d2d0e655aa
commit 0005f7dce1
9 changed files with 647 additions and 75 deletions
+123 -66
View File
@@ -203,16 +203,17 @@ section 6).
The system degrades in tiers rather than failing: The system degrades in tiers rather than failing:
1. **Classifier head**, when the training span contains at least 1. **Belt-and-braces probability** *(since 2026-08-11 — see the re-examination
`MIN_POSITIVES_FOR_CLASSIFIER = 30` positive examples. Below that, a note in section 7)*: the sigmoid-of-regression probability
classifier would be fitting noise, and the head is recorded in `p = 1/(1 + exp((predicted_max threshold)/σ))` is always computed (σ =
`skipped_heads` with its reason. the regressor's test-residual std, floor `MIN_SIGMA = 0.15` m), and when a
2. **Sigmoid on the regression head**, when the classifier is absent. classifier head exists — trained only if the span had at least
`p = 1/(1 + exp((predicted_max threshold)/σ))`, where σ is the standard `MIN_POSITIVES_FOR_CLASSIFIER = 30` positives — the served probability is
deviation of the regressor's test residuals (floor `MIN_SIGMA = 0.15` m). This `max(classifier, sigmoid)`. The classifier can raise the alarm but never
turns the peak-level prediction into a calibrated-ish probability that widens silence it: on the gap-filled data a trained classifier stayed near zero
correctly when the regressor is less accurate at that horizon — at P.1, σ is through the 2024 record crossing while the regression tracked it.
0.15 m at 6 and 12 h but 0.166 m at 24 h. 2. **Sigmoid only**, when the classifier head is absent or skipped
(recorded in `skipped_heads` with its reason).
3. **Persistence heuristic** (`predict._heuristic_forecast`), when there is no 3. **Persistence heuristic** (`predict._heuristic_forecast`), when there is no
model file at all, or the station's newest reading is more than model file at all, or the station's newest reading is more than
`STALE_AFTER_H = 6` hours old. It extrapolates the last 3 h rate of rise `STALE_AFTER_H = 6` hours old. It extrapolates the last 3 h rate of rise
@@ -255,47 +256,92 @@ invalidates the cache without a restart.
### Holdout metrics (`models/metrics.json`) ### Holdout metrics (`models/metrics.json`)
Model version `hgb-v1+49a3de0`, generated 2026-08-10. Train ≤ 2024-12-31, test Two evaluations exist and they differ sharply — the re-examination note in the
2025-01-01 → 2026-08-10 — the test span is entirely unseen future data relative next section explains why (the hourly grid was gap-filled from ~56% to ~93%
to training. between them, roughly doubling the test rows and collapsing the warning base
rates).
P.1 (Nawarat Bridge), the station that matters most: **Current model** `hgb-v1+d2d0e65`, generated 2026-08-12 on the gap-filled DB
(~976k rows). Train ≤ 2024-12-31, test 2025-01-01 → 2026-08-12. P.1:
| Horizon | Warning PR-AUC | Recall @1% FAR | Recall @5% FAR | MAE | MAE above 2 m | Test rows | Base rate | | Horizon | Warning PR-AUC | MAE | MAE above 2 m | Test rows | Base rate |
|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|
| 6 h | 0.974 | 98.3% | 100% | 6.1 cm | 9.2 cm | 8,536 | 1.36% | | 6 h | 0.783 | 5.5 cm | 7.9 cm | 14,034 | 0.12% |
| 12 h | 0.904 | 93.8% | 97.7% | 9.0 cm | 15.0 cm | 7,932 | 1.61% | | 12 h | 0.508 | 8.1 cm | 14.6 cm | 14,028 | 0.16% |
| 24 h | 0.900 | 90.1% | 93.4% | 11.3 cm | 24.5 cm | 8,572 | 1.77% | | 24 h | 0.288 | 10.5 cm | 24.0 cm | 14,034 | 0.25% |
Read PR-AUC against the base rate — 0.974 versus a 1.36% positive rate is a wide Level accuracy improved; standalone classifier discrimination did not survive
margin over chance. "Recall at 1% false-alarm rate" is the operationally honest the data change (which is why serving is now `max(classifier, sigmoid)` — see
number: at a threshold that fires on 1% of quiet hours, the 6 h model still "Head gating"). Recall-at-FAR is null at all horizons on this run. Across
catches 98.3% of warning exceedances. stations the 6 h warning PR-AUC now spans 0.987 (P.77) / 0.982 (P.5) / 0.956
(P.85) / 0.950 (P.67) down to 0.436 (P.84), and danger heads are now evaluable
at nine stations — strongest P.5 (0.958/0.883/0.811 at 6/12/24 h) and P.77
(0.942/0.863/0.786); P.103's danger metrics, previously the highlight, are null
on this span.
P.103 (Ring Bridge 3) is the only station with enough danger-level events to **Historical evaluation** (`hgb-v1+49a3de0`, 2026-08-10, pre-gap-fill DB —
evaluate a danger head on the 202526 span (base rate 5.77.4%): PR-AUC 0.979 / kept for the record; these numbers described the sparser 56%-filled grid and do
0.953 / 0.892 and recall at 1% FAR of 97.9% / 89.9% / 79.5% at 6 / 12 / 24 h. not reproduce on today's data):
Across the other stations the 6 h warning PR-AUC spans 0.996 (P.5) down to 0.302 | Horizon | Warning PR-AUC | Recall @1% FAR | MAE | Test rows | Base rate |
(P.82), and tracks almost exactly with how many exceedances that station saw. The |---|---|---|---|---|---|
strong ones are the frequently-flooded gauges — P.5 0.996, P.81 0.992, P.77 0.968, | 6 h | 0.974 | 98.3% | 6.1 cm | 8,536 | 1.36% |
P.85 0.953, P.75 0.927 — and the weak ones are un-routed western tributaries with | 12 h | 0.904 | 93.8% | 9.0 cm | 7,932 | 1.61% |
almost no positives (P.84 0.570, P.82 0.302 on 0.22% of test hours). P.92 and P.20 | 24 h | 0.900 | 90.1% | 11.3 cm | 8,572 | 1.77% |
have no evaluable warning metric at all: neither crossed 3.0 m often enough in the
test span (P.92 not once, P.20 in 0.09% of hours) to score. The dramatic PR-AUC difference is mostly the base rate: the filled grid adds
~5,500 quiet test hours per horizon while the number of positive hours barely
changes, so the same ranking quality scores far lower — and the classifier's
genuine out-of-distribution weakness (see the backtest sections) does the rest.
### 2026-08-11 re-examination: fuller data changes the backtest story
> **Read this before the two backtest sections below.** On 2026-08-11 the
> backtests were codified into `scripts/backtest_render.py` (previously they
> were one-off runs) and re-run after the database grew from 592k to ~976k
> rows (a `--fill-gaps all` pass repaired most of the missing 44% of the
> hourly grid). Three things changed:
>
> 1. **The 2024 crossing was 8 hours earlier than documented.** The recovered
> hours show P.1 crossing 3.70 m at **17:00 on 24 September 2024**, not
> 01:00 on 25 September — confirmed independently by the HII sensor at
> Nawarat Bridge (hii_waterlevel, station 3226: 3.73 m at 17:00). The
> originally celebrated "24-hour warning" was therefore ~16 hours measured
> against the real river.
> 2. **Retraining on the fuller data improves level accuracy but degrades the
> warning classifiers.** P.1 24 h MAE improved (11.3 → 10.5 cm), but the
> warning-head PR-AUC collapsed (0.900 → 0.288 at 24 h): with the filled
> grid the classifier trains on many more dry-season rows and now stays
> silent through the September 2024 record crossing while the regression
> head tracks it. Serving was changed to belt-and-braces —
> `max(classifier, sigmoid(regression))` — so alerting can never be worse
> than the regression path.
> 3. **Honest current lead times, from the regenerated charts below:** the
> retrained configuration first alerts ~18 h *after* the true 24 Sep 2024
> crossing and roughly *at* the 27 Sep 2025 crossing. The earlier, better
> numbers came from models trained and evaluated on the sparser data. The
> conclusion is not that the old system was better — it is that gauge-only
> features fundamentally lack lead time for fast rises, which is exactly
> the rainfall-input and rise-target work now queued (see "Honest limits").
>
> `scripts/backtest_render.py` regenerates all three charts and fails its
> acceptance gate while the 2024 lead stays under 12 h — keeping this page
> honest is now automatic.
### The September 2025 flood, as the deployed configuration saw it ### The September 2025 flood, as the deployed configuration saw it
![Observed vs predicted through the September 2025 flood — model trained only ![Observed vs predicted through the September 2025 flood — model trained only
through 2024](img/backtest-2025-p1.png) through 2024](img/backtest-2025-p1.png)
This is the strongest single piece of evidence, because it uses the exact This uses the deployed configuration (train ≤ 2024-12-31) on an event it never
deployed configuration (train ≤ 2024-12-31) on an event it never saw: the saw. *(Chart regenerated 2026-08-11 on the gap-filled data — see the
model's **first alert came 26 September 2025 at 18:00, exactly 24 hours before re-examination note above; the original one-off render, trained on the sparser
the river crossed 3.70 m**, and it predicted a 4.00 m peak against an actual data, alerted 24 h ahead and predicted the 3.93 m peak within 7 cm.)* On
3.93 m — within 7 cm. Note the discrimination: the near-miss crest of 3.51 m on today's fuller dataset the retrained equivalent first alerts at **18:00 on
26 September never triggered an alert, the probability fires only for the real 27 September 2025 — as the river crosses 3.70 m**, not a day ahead. The
event, and it stands down as the water recedes. discrimination remains good: the near-miss 3.51 m crest on 26 September never
triggers, the probability fires only for the real event, and it stands down as
the water recedes.
### Headline validation: the October 2024 record flood ### Headline validation: the October 2024 record flood
@@ -307,27 +353,33 @@ followed. This is the closest thing to a real operational test available.
![Observed P.1 level vs the model's 24 h-ahead predicted peak through the ![Observed P.1 level vs the model's 24 h-ahead predicted peak through the
October 2024 flood, with the warning probability below](img/backtest-2024-p1.png) October 2024 flood, with the warning probability below](img/backtest-2024-p1.png)
The render above shows the whole event hour by hour. Top: the observed level The render above shows the whole event hour by hour *(regenerated 2026-08-11
(blue) against the 24 h-ahead predicted peak the model issued at each hour on the gap-filled data)*. Top: the observed level (blue) against the 24 h-ahead
(amber, dashed) — the amber line leads the blue one into both flood waves, predicted peak the model issued at each hour (amber, dashed) — the amber line
which is the entire point of the system. Bottom: the model's probability of tracks both flood waves but no longer clearly leads the first one. Bottom: the
flooding within 24 h; it fires its **first alert at 01:00 on 24 September, a belt-and-braces probability of flooding within 24 h; on the fuller data its
full day before the river crossed the 3.70 m flooding line**, stays pinned near **first alert comes at 11:00 on 25 September, ~18 hours after the true 17:00
1.0 through both waves, and stands down between and after them. Also visible, 24 September crossing**, then stays correctly alarmed through the October
honestly: the predicted peak tops out ~0.4 m short of the actual 5.30 m record record wave. Also visible, honestly: the predicted peak tops out well short of
(the extreme-compression limitation discussed below), and the prediction is the actual 5.30 m record (the extreme-compression limitation discussed below).
noisier on the recession limbs. The same model track drives the dashboard's The same historic model track drives the dashboard's "Replay Oct 2024 flood"
"Replay Oct 2024 flood" feature, so this chart can be watched live on the map. feature.
![Hour-by-hour detail of the detection window, 2228 September ![Hour-by-hour detail of the detection window, 2228 September
2024](img/backtest-2024-p1-detail.png) 2024](img/backtest-2024-p1-detail.png)
The hour-by-hour detail of the detection window shows the sequence exactly: the The hour-by-hour detail of the detection window *(regenerated 2026-08-11)*
predicted 24 h peak (amber) starts pulling away from the observed level late on shows the corrected sequence: the river crosses 3.70 m at **17:00 on
23 September as upstream gauges rise, the warning probability snaps from ~0 to 24 September** (the hours recovered by gap-filling; independently confirmed by
1.0 at **01:00 on 24 September**, and the river crosses 3.70 m at **01:00 on the HII sensor at the same bridge), while the retrained model's probability
25 September** — a clean 24-hour warning, delivered while the river in town only crosses 0.5 at **11:00 on 25 September**. The original render — sparser
still looked normal at 2.8 m. data, different trained model — alerted at 01:00 on 24 September against an
apparent 01:00 25 September crossing. Closing this real gap is what the
rainfall features and rise-target work are for.
The event bullets below quote the original (pre-gap-fill) evaluation of the
deployed model and are kept for the historical record — see the re-examination
note above for why the lead times no longer reproduce:
- **25 September cold start.** P.1's first warning crossing of the episode was - **25 September cold start.** P.1's first warning crossing of the episode was
alerted **2426 hours ahead**. This is the genuinely impressive case: the river alerted **2426 hours ahead**. This is the genuinely impressive case: the river
@@ -353,11 +405,14 @@ still looked normal at 2.8 m.
time into P.1 is 17 h (P.20), and the strongest predictors are much closer: time into P.1 is 17 h (P.20), and the strongest predictors are much closer:
P.103 at 1 h, P.67 at 7 h, P.21 at 9 h. Once a 24 h forecast reaches past roughly P.103 at 1 h, P.67 at 7 h, P.21 at 9 h. Once a 24 h forecast reaches past roughly
17 h, there is no observation that has "already happened" to inform it — the model 17 h, there is no observation that has "already happened" to inform it — the model
is extrapolating basin state and season, not routing a wave. The 202526 test is extrapolating basin state and season, not routing a wave. The regenerated
events bear this out: the 25 September 2025 cold-start crossing was called 7 h backtests bear this out — harder than first documented (see the re-examination
ahead by the 12 h model and 9 h ahead by the 24 h model. **Practical lead for P.1 note in section 7): on the gap-filled data the retrained configuration alerts
is ~717 h.** Extending it requires rainfall forecasts and Mae Ngat/Mae Kuang dam the 27 September 2025 crossing *as it happens* and the 24 September 2024
release data, neither of which this system currently ingests. crossing ~18 h *late*. **Genuine gauge-only lead for P.1 is at best ~717 h,
and for fast rises can be zero.** Extending it requires rainfall inputs and
Mae Ngat/Mae Kuang dam release data, plus the rise-target/quantile modelling
work — rainfall collection began 2026-08-11 (see `docs/DATA_SOURCES.md`).
**Danger-level skill at P.1 is unproven.** P.1 never crossed 4.5 m in the **Danger-level skill at P.1 is unproven.** P.1 never crossed 4.5 m in the
2025-01-01 → 2026-08-10 test span (`base_rate_danger` is 0.0, so every danger 2025-01-01 → 2026-08-10 test span (`base_rate_danger` is 0.0, so every danger
@@ -617,10 +672,12 @@ print({h: (d.get('pr_auc_warn'), d.get('mae')) for h, d in m['stations']['P.1'][
``` ```
Expect fifteen `trained` and one `heuristic` (P.4A). A station that reports Expect fifteen `trained` and one `heuristic` (P.4A). A station that reports
`failed` names its reason in the same payload. If P.1's 6 h warning PR-AUC has `failed` names its reason in the same payload. Compare against the *previous
dropped materially below ~0.97 or its MAE has risen well above ~6 cm, investigate run's* `metrics.json`, not an absolute bar: after the 2026-08-11 gap-fill the
before deploying — that usually means a data problem (a gauge that went quiet, or expected baseline is P.1 6 h warning PR-AUC ≈ 0.78 and MAE ≈ 5.5 cm (the
a bad backfill) rather than a modelling one. historical ~0.97 figure belonged to the sparse pre-fill grid — see section 5).
A *material drop from the previous run* usually means a data problem (a gauge
that went quiet, or a bad backfill) rather than a modelling one.
**Run the tests** (synthetic data only, no database or network required): **Run the tests** (synthetic data only, no database or network required):
Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 141 KiB

+253
View File
@@ -0,0 +1,253 @@
#!/usr/bin/env python3
"""Regenerate the documented P.1 flood-backtest charts in docs/img/.
For each chart an eval-only model (regression 24 h peak + warning classifier)
is trained on data STRICTLY BEFORE the event, then the event window is walked
hour by hour exactly as the live system would have seen it:
backtest-2024-p1.png Oct 2024 record flood, trained < 1 Sep 2024
backtest-2024-p1-detail.png 22-28 Sep 2024 zoom of the first crossing
backtest-2025-p1.png Sep 2025 flood, deployed config (trained <= 2024)
This codifies the previously prose-only acceptance test: the run fails with a
non-zero exit if the model gives less than 12 h of warning before the first
3.70 m crossing of the 2024 event.
Usage:
python scripts/backtest_render.py # uses FLOOD_ML_DB_URL/Config
python scripts/backtest_render.py --db-url postgresql://...
"""
import argparse
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import matplotlib
matplotlib.use("Agg")
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
import pandas as pd
from src.ml import data, features
from src.ml.train import _make_classifier, _make_regressor
STATION = "P.1"
STAGE1 = 3.70 # official Chiang Mai stage 1 - city flooding begins
STAGE7 = 4.60 # stage 7 - widespread
HORIZON = 24
INK = "#132b35"
BLUE = "#1c6ea4"
AMBER = "#c07d10"
RED = "#d9534f"
def fit_backtest_model(df_long: pd.DataFrame, train_end: str):
"""Train the 24 h regression + warning heads on rows <= train_end only."""
X, Y, _meta = features.build_matrix(df_long, STATION, (HORIZON,))
train_mask = X.index <= pd.Timestamp(train_end)
X_train, Y_train = X.loc[train_mask], Y.loc[train_mask]
max_col, warn_col = f"max_level_{HORIZON}", f"exceed_warn_{HORIZON}"
reg_rows = Y_train[max_col].notna()
reg = _make_regressor().fit(X_train.loc[reg_rows], Y_train.loc[reg_rows, max_col])
warn_rows = Y_train[warn_col].notna()
clf = _make_classifier().fit(
X_train.loc[warn_rows], Y_train.loc[warn_rows, warn_col].astype(int)
)
return X, reg, clf
def event_series(df_long, X, reg, clf, window_start: str, window_end: str):
"""Observed level plus the forecasts the model would have issued hourly."""
grid = features.make_hourly_grid(df_long)
# observed has MultiIndex columns (station_code, field)
observed = grid.observed[(STATION, "water_level")]
observed = observed.loc[window_start:window_end].dropna().astype(float)
Xw = X.loc[window_start:window_end]
forecasts = pd.DataFrame(index=Xw.index)
forecasts["pred_max"] = reg.predict(Xw)
# Belt-and-braces probability: the classifier OR the regression-sigmoid,
# whichever is more alarmed. The classifier alone proved unreliable on
# out-of-distribution extremes (silent on the 2024 record flood).
import numpy as np
p_clf = clf.predict_proba(Xw)[:, 1]
p_sig = 1.0 / (1.0 + np.exp(-(forecasts["pred_max"] - STAGE1) / 0.15))
forecasts["p_flood"] = np.maximum(p_clf, p_sig)
flood_start = observed[observed >= STAGE1].index.min()
alerts = forecasts[forecasts["p_flood"] >= 0.5].index
first_alert = alerts.min() if len(alerts) else None
return observed, forecasts, flood_start, first_alert
def _style_axes(ax):
ax.spines[["top", "right"]].set_visible(False)
ax.tick_params(colors=INK, labelsize=11)
ax.grid(axis="y", color="#dfe9e7", linewidth=0.8)
ax.set_axisbelow(True)
def render(observed, forecasts, flood_start, first_alert, out_path, *,
title, subtitle, detail=False, show_stage7=False, peak_note=None):
fig, (ax, axp) = plt.subplots(
2, 1, figsize=(12.6, 7.6), sharex=True,
gridspec_kw={"height_ratios": [2.2, 1], "hspace": 0.12},
)
fig.patch.set_facecolor("white")
marker = dict(marker="o", markersize=3) if detail else {}
ax.plot(observed.index, observed.values, color=BLUE, linewidth=2.2,
label="Observed level" + (" (hourly)" if detail else ""), **marker)
marker = dict(marker="s", markersize=3) if detail else {}
ax.plot(forecasts.index, forecasts["pred_max"], color=AMBER, linewidth=2,
linestyle="--", label="Predicted 24 h peak (issued at that hour)", **marker)
ax.axhline(STAGE1, color=RED, linewidth=1, alpha=0.65)
ax.annotate(f"{STAGE1:.2f} m · stage 1 · flooding begins", xy=(0.06, STAGE1),
xycoords=("axes fraction", "data"), xytext=(0, 5),
textcoords="offset points", color=RED, fontsize=10.5)
if show_stage7:
ax.axhline(STAGE7, color=RED, linewidth=1, alpha=0.65)
ax.annotate(f"{STAGE7:.2f} m · stage 7 · widespread", xy=(0.06, STAGE7),
xycoords=("axes fraction", "data"), xytext=(0, 5),
textcoords="offset points", color=RED, fontsize=10.5)
if peak_note:
peak_ts = observed.idxmax()
ax.annotate(peak_note, xy=(peak_ts, observed.max()),
xytext=(12, 10), textcoords="offset points",
color=BLUE, fontsize=11.5, fontweight="bold")
ax.set_ylabel("P.1 water level (m)", color=INK, fontsize=11.5)
ax.legend(loc="upper left", frameon=False, fontsize=10.5)
_style_axes(ax)
axp.plot(forecasts.index, forecasts["p_flood"], color=AMBER, linewidth=1.8)
axp.fill_between(forecasts.index, 0, forecasts["p_flood"],
color=AMBER, alpha=0.28)
axp.axhline(0.5, color=INK, linewidth=0.9, linestyle=":", alpha=0.6)
axp.set_ylim(-0.02, 1.1)
axp.set_ylabel(f"P(flooding within {HORIZON} h)", color=INK, fontsize=11.5)
_style_axes(axp)
if first_alert is not None:
lead_h = None if flood_start is None else \
int((flood_start - first_alert).total_seconds() // 3600)
lead_txt = "" if lead_h is None else (
f"\n({lead_h} h before flooding began)" if lead_h >= 0
else f"\n({-lead_h} h after flooding began)"
)
if detail and flood_start is not None:
for a in (ax, axp):
a.axvline(first_alert, color=AMBER, linewidth=1.4, alpha=0.85)
a.axvline(flood_start, color=BLUE, linewidth=1.4, alpha=0.85)
# Anchor labels away from each other in chronological order so a
# late alert (alert AFTER crossing) cannot overprint the labels.
events = sorted(
[(first_alert, "model alert", AMBER), (flood_start, "flooding begins", BLUE)]
)
for (ts, label, color), (offset, align) in zip(events, ((-8, "right"), (8, "left"))):
ax.annotate(f"{label}\n{ts:%d %b %H:%M}",
xy=(ts, observed.min()), xytext=(offset, 18),
textcoords="offset points", ha=align,
color=color, fontsize=11, fontweight="bold")
mid_y = observed.min() + (observed.max() - observed.min()) * 0.28
ax.annotate("", xy=(flood_start, mid_y), xytext=(first_alert, mid_y),
arrowprops=dict(arrowstyle="<->", color=INK, lw=1.3))
arrow_label = (
f"{lead_h} h warning" if lead_h >= 0 else f"alert {-lead_h} h late"
)
ax.annotate(arrow_label,
xy=(first_alert + (flood_start - first_alert) / 2, mid_y),
xytext=(0, 8), textcoords="offset points", ha="center",
color=INK, fontsize=11.5, fontweight="bold")
else:
axp.annotate(f"first alert · {first_alert:%d %b %H:%M}{lead_txt}",
xy=(first_alert, 0.62), xytext=(10, 0),
textcoords="offset points", color=RED, fontsize=10.5,
bbox=dict(facecolor="white", alpha=0.75, edgecolor="none"))
locator = mdates.DayLocator(interval=1 if detail else 3)
axp.xaxis.set_major_locator(locator)
axp.xaxis.set_major_formatter(mdates.DateFormatter("%d %b"))
fig.suptitle(f"{title}\n{subtitle}", x=0.07, y=0.985, ha="left",
fontsize=15, color=INK)
fig.subplots_adjust(top=0.885, left=0.07, right=0.97, bottom=0.07)
fig.savefig(out_path, dpi=110)
plt.close(fig)
print(f"wrote {out_path}")
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--db-url", default=None)
parser.add_argument("--out-dir", default=os.path.join("docs", "img"))
args = parser.parse_args(argv)
df = data.load_measurements(db_url=args.db_url)
if df.empty:
print("no measurement data available", file=sys.stderr)
return 1
os.makedirs(args.out_dir, exist_ok=True)
# --- October 2024 record flood: trained only on data before 1 Sep 2024 ---
X, reg, clf = fit_backtest_model(df, "2024-08-31")
obs, fc, flood_start, first_alert = event_series(
df, X, reg, clf, "2024-09-10", "2024-10-14 23:00")
peak = float(obs.max())
render(obs, fc, flood_start, first_alert,
os.path.join(args.out_dir, "backtest-2024-p1.png"),
title="October 2024 flood: what the model saw coming",
subtitle="P.1 Nawarat Bridge — model trained only on data before 1 Sep 2024",
show_stage7=True, peak_note=f"record peak {peak:.2f} m")
obs_d, fc_d, flood_d, alert_d = event_series(
df, X, reg, clf, "2024-09-21 18:00", "2024-09-28 06:00")
lead_h = None
if alert_d is not None and flood_d is not None:
lead_h = int((flood_d - alert_d).total_seconds() // 3600)
render(obs_d, fc_d, flood_d, alert_d,
os.path.join(args.out_dir, "backtest-2024-p1-detail.png"),
title="Detection in detail: 2228 September 2024, hour by hour",
subtitle=(
f"the model alerts {lead_h} h before the river crosses the flooding line"
if lead_h is not None and lead_h > 0
else "model alert vs the river crossing the flooding line"
),
detail=True)
# --- September 2025 flood: the deployed configuration (trained <= 2024) ---
X25, reg25, clf25 = fit_backtest_model(df, "2024-12-31")
obs25, fc25, flood25, alert25 = event_series(
df, X25, reg25, clf25, "2025-09-22", "2025-10-02 12:00")
pred_at_alert = float(fc25.loc[alert25:, "pred_max"].iloc[:24].max()) if alert25 is not None else None
note = f"peak {float(obs25.max()):.2f} m" + (
f" (predicted {pred_at_alert:.2f} m)" if pred_at_alert is not None else "")
render(obs25, fc25, flood25, alert25,
os.path.join(args.out_dir, "backtest-2025-p1.png"),
title="The September 2025 flood — as forecast by the deployed configuration",
subtitle="model trained only on data through 2024; this event was never seen in training",
detail=True, peak_note=note)
print(f"2024: flooding began {flood_start}, first alert {first_alert}")
print(f"2025: flooding began {flood25}, first alert {alert25}")
# Acceptance gate: the flagship 2024 event must keep a >= 12 h warning
if first_alert is None or flood_start is None:
print("FAIL: 2024 event alert or crossing not found", file=sys.stderr)
return 1
lead = (flood_start - first_alert).total_seconds() / 3600
if lead < 12:
print(f"FAIL: 2024 first-alert lead {lead:.0f} h < 12 h", file=sys.stderr)
return 1
print(f"PASS: 2024 first-alert lead {lead:.0f} h")
return 0
if __name__ == "__main__":
sys.exit(main())
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Staged load / client-stress test for the Ping River Monitor API + dashboard.
Simulates a realistic traffic mix (dashboard page loads, the API calls the
dashboard itself makes, heavy history queries, external API consumers) at
increasing concurrency stages, and reports throughput, latency percentiles,
and errors per stage plus the slowest endpoints.
Run against a LOCAL instance for full stress (never full-stress production —
it hosts live flood monitoring):
python -m uvicorn src.web_api:app --port 8125 # separate shell
python scripts/load_test.py http://localhost:8125
A gentle production baseline (low, fixed concurrency):
python scripts/load_test.py https://water.buildfor.life --gentle
"""
import argparse
import random
import statistics
import threading
import time
from collections import Counter
import requests
# Weighted endpoint mix: dashboard session + API consumers
ENDPOINTS = [
("/", 10),
("/measurements/latest?limit=500", 20),
("/stations", 10),
("/api/hii/rainfall/latest", 15),
("/api/hii/waterlevel/latest", 15),
("/forecast", 10),
("/api/stats", 5),
("/measurements/history/P.1?hours=168", 10),
("/measurements/history/P.67?hours=720", 5),
("/health", 5),
]
POOL = [endpoint for endpoint, weight in ENDPOINTS for _ in range(weight)]
FULL_STAGES = [(10, 20), (50, 20), (200, 25)] # (clients, seconds)
GENTLE_STAGES = [(3, 15), (8, 15)]
def _worker(base, stop_at, results, errors):
session = requests.Session()
while time.time() < stop_at:
path = random.choice(POOL)
start = time.perf_counter()
try:
response = session.get(f"{base}{path}", timeout=30)
elapsed = time.perf_counter() - start
if response.status_code == 200:
results.append((path, elapsed))
else:
errors.append((path, response.status_code))
except Exception as error:
errors.append((path, type(error).__name__))
def _pct(values, p):
if len(values) >= 100:
return statistics.quantiles(values, n=100)[p - 1]
return max(values)
def run_stage(base, clients, seconds):
results, errors = [], []
stop_at = time.time() + seconds
threads = [
threading.Thread(
target=_worker, args=(base, stop_at, results, errors), daemon=True
)
for _ in range(clients)
]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=seconds + 35)
latencies = [elapsed for _, elapsed in results]
total = len(results) + len(errors)
print(f"\n== {clients} clients x {seconds}s ==")
print(
f"requests: {total} ok: {len(results)} errors: {len(errors)} "
f"rps: {total / seconds:.1f}"
)
if latencies:
print(
f"latency ms p50: {statistics.median(latencies) * 1000:.0f} "
f"p95: {_pct(latencies, 95) * 1000:.0f} "
f"p99: {_pct(latencies, 99) * 1000:.0f} "
f"max: {max(latencies) * 1000:.0f}"
)
by_endpoint = {}
for path, elapsed in results:
by_endpoint.setdefault(path, []).append(elapsed)
slowest = sorted(
by_endpoint.items(), key=lambda kv: -statistics.median(kv[1])
)[:4]
for path, values in slowest:
print(
f" slow: {path:45} n={len(values):5} "
f"p50={statistics.median(values) * 1000:6.0f}ms "
f"max={max(values) * 1000:7.0f}ms"
)
if errors:
top = Counter(f"{path} {code}" for path, code in errors).most_common(5)
print(f" errors: {top}")
return {"clients": clients, "total": total, "errors": len(errors)}
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("base", nargs="?", default="http://localhost:8125")
parser.add_argument(
"--gentle",
action="store_true",
help="low fixed concurrency (safe for the production instance)",
)
args = parser.parse_args(argv)
base = args.base.rstrip("/")
# Warm caches first so stage 1 doesn't measure cold-start work
for path in ("/forecast", "/api/stats", "/measurements/latest?limit=500"):
try:
requests.get(f"{base}{path}", timeout=60)
except Exception:
pass
print(f"target: {base} mode: {'gentle' if args.gentle else 'full'}")
stages = GENTLE_STAGES if args.gentle else FULL_STAGES
summary = [run_stage(base, clients, seconds) for clients, seconds in stages]
worst = max(
(stage["errors"] / stage["total"] for stage in summary if stage["total"]),
default=1.0,
)
print(f"\nworst-stage error rate: {worst:.1%}")
return 0 if worst < 0.05 else 1
if __name__ == "__main__":
import sys
sys.exit(main())
+93
View File
@@ -0,0 +1,93 @@
"""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: python -m uvicorn src.web_api:app --port 8125
.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
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("/")
self.client.get("/stations")
self.client.get("/measurements/latest?limit=500")
self.client.get("/api/hii/waterlevel/latest")
self.client.get("/api/hii/rainfall/latest")
@task(4)
def poll_latest(self):
self.client.get("/measurements/latest?limit=500")
@task(2)
def poll_forecast(self):
self.client.get("/forecast")
@task(2)
def poll_rain(self):
self.client.get("/api/hii/rainfall/latest")
@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}",
name="/measurements/history/[station]",
)
@task(1)
def stats(self):
self.client.get("/api/stats")
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")
@task(3)
def hii_feeds(self):
self.client.get(random.choice(
["/api/hii/waterlevel/latest", "/api/hii/rainfall/latest"]
), name="/api/hii/[feed]/latest")
@task(2)
def forecast(self):
self.client.get("/forecast")
@task(2)
def heavy_history(self):
self.client.get(
"/measurements/history/P.1?hours=8760",
name="/measurements/history/P.1 [heavy]",
)
@task(1)
def health(self):
self.client.get("/health")
+9 -6
View File
@@ -164,21 +164,24 @@ def _model_forecast(
predicted_max = max(float(reg.predict(feature_row)[0]), current_level) predicted_max = max(float(reg.predict(feature_row)[0]), current_level)
sigma_h = bundle["sigma"].get(horizon_h, HEURISTIC_SIGMA) sigma_h = bundle["sigma"].get(horizon_h, HEURISTIC_SIGMA)
# Belt-and-braces: the classifier head OR the regression-sigmoid path,
# whichever is more alarmed. The 2026-08-11 backtest showed a trained
# classifier staying silent through the 2024 record flood while the
# regression head tracked it — alerting must never be worse than the
# regression fallback.
warn_head = ( warn_head = (
None if thresholds_stale else bundle["heads"].get(f"warn_{horizon_h}") None if thresholds_stale else bundle["heads"].get(f"warn_{horizon_h}")
) )
p_warning = _sigmoid_probability(predicted_max, warn_thr, sigma_h)
if warn_head is not None: if warn_head is not None:
p_warning = float(warn_head.predict_proba(feature_row)[0][1]) p_warning = max(p_warning, float(warn_head.predict_proba(feature_row)[0][1]))
else:
p_warning = _sigmoid_probability(predicted_max, warn_thr, sigma_h)
danger_head = ( danger_head = (
None if thresholds_stale else bundle["heads"].get(f"danger_{horizon_h}") None if thresholds_stale else bundle["heads"].get(f"danger_{horizon_h}")
) )
p_danger = _sigmoid_probability(predicted_max, danger_thr, sigma_h)
if danger_head is not None: if danger_head is not None:
p_danger = float(danger_head.predict_proba(feature_row)[0][1]) p_danger = max(p_danger, float(danger_head.predict_proba(feature_row)[0][1]))
else:
p_danger = _sigmoid_probability(predicted_max, danger_thr, sigma_h)
p_warning = _clip_probability(p_warning) p_warning = _clip_probability(p_warning)
p_danger = min(_clip_probability(p_danger), p_warning) p_danger = min(_clip_probability(p_danger), p_warning)
+21 -3
View File
@@ -50,6 +50,7 @@ HISTORY_TTL = 300 # 5 minutes
FORECAST_CACHE: Dict[str, tuple] = {} FORECAST_CACHE: Dict[str, tuple] = {}
FORECAST_CACHE_LOCK = Lock() FORECAST_CACHE_LOCK = Lock()
FORECAST_COMPUTE_LOCK = asyncio.Lock() # single-flight for expensive inference
FORECAST_TTL = 900 # 15 minutes FORECAST_TTL = 900 # 15 minutes
DB_STATS_CACHE: Dict[str, tuple] = {} DB_STATS_CACHE: Dict[str, tuple] = {}
@@ -359,8 +360,10 @@ async def get_health():
if not health_manager: if not health_manager:
raise HTTPException(status_code=503, detail="Health manager not initialized") raise HTTPException(status_code=503, detail="Health manager not initialized")
# Run health checks (populates state read by get_health_summary) # Run health checks (populates state read by get_health_summary).
health_manager.run_all_checks() # In a thread: DatabaseHealthCheck and APIHealthCheck do blocking I/O and
# would otherwise stall the event loop for every other request.
await asyncio.to_thread(health_manager.run_all_checks)
summary = health_manager.get_health_summary() summary = health_manager.get_health_summary()
return HealthResponse(**summary) return HealthResponse(**summary)
@@ -747,6 +750,19 @@ async def get_flood_forecasts():
from .ml.predict import get_latest_forecasts from .ml.predict import get_latest_forecasts
except ImportError as error: except ImportError as error:
raise HTTPException(status_code=503, detail=f"Forecasting unavailable: {error}") raise HTTPException(status_code=503, detail=f"Forecasting unavailable: {error}")
# Single-flight: inference takes seconds; without this, N concurrent cache
# misses ran N full inferences and starved the thread pool (load test:
# /forecast timeouts at 10 concurrent clients rippled into every endpoint).
async with FORECAST_COMPUTE_LOCK:
with FORECAST_CACHE_LOCK:
cached = FORECAST_CACHE.get("all")
if cached and time.monotonic() - cached[0] < FORECAST_TTL:
return cached[1]
return await _compute_forecasts(get_latest_forecasts)
async def _compute_forecasts(get_latest_forecasts):
now = time.monotonic()
try: try:
data = await asyncio.to_thread(get_latest_forecasts) data = await asyncio.to_thread(get_latest_forecasts)
except FileNotFoundError: except FileNotFoundError:
@@ -771,7 +787,9 @@ async def get_latest_measurements(limit: int = 100):
raise HTTPException(status_code=503, detail="Database not available") raise HTTPException(status_code=503, detail="Database not available")
try: try:
measurements = scraper.get_latest_data(limit=limit) # In a thread: this is a synchronous DB query, and this is the most
# frequently hit endpoint — inline it would block the event loop.
measurements = await asyncio.to_thread(scraper.get_latest_data, limit)
return [_to_measurement_response(m) for m in measurements] return [_to_measurement_response(m) for m in measurements]