Voidly Atlas already runs a CenDTect-style DBSCAN anomaly detector at
/v1/anomaly/dbscan/{cc}. DBSCAN
is shape-anomaly detection: it takes a country-day's
12-feature vector and asks how far it sits from any DBSCAN core
cluster in the prior 45-day window. That catches days where the
signal-type mix or block-rate has an unusual shape.
STL catches a different kind of anomaly. Egypt may always have a high anomaly_rate on Fridays. DBSCAN sees that Friday spike as “normal” for Egypt because it's shape-typical. A non-Friday spike, however, breaks Egypt's own weekly rhythm — STL flags it; DBSCAN often won't.
The two detectors are orthogonal: they catch different failure modes from the same evidence. Running both gives us a wider catchment of weird country-days without trading off precision.
For each country with at least 60 days of evidence in the trailing 90-day window:
anomaly_rate = blocked / total measurements.statsmodels.tsa.seasonal.STL(period=7, robust=True)
— Cleveland, Cleveland, McRae & Terpenning (1990).
The robust=True variant downweights
outliers in the trend and seasonal fits so the residuals capture
real deviations, not just the outliers themselves.residual = observed − (trend + seasonal).|zscore| > 2.0.GET /v1/anomaly/seasonal/{cc}
— latest day's residual + z + decomposition. Supports
?date=YYYY-MM-DD for backtest and
?history=N for the last N days of
decomposition.GET /v1/anomaly/seasonal/leaderboard
— top-K today by |z|. Supports
?only_anomalies=true to filter to
|z| > 2.0 only.GET /v1/anomaly/seasonal/info
— sidecar transparency + honest caveats.period=7 assumes weekly seasonality.
Countries without weekly cycles (constant-state censors, regions
with shifted weekend equivalents) still get a fit, but their
seasonal component is small — the residual then captures
almost all the variance, and |z| collapses to a noise estimate.
Daily at 04:45 UTC, after the DBSCAN refresh and
before the atlas-digest builder.
scripts/build-stl-seasonal-anomaly.py
rebuilds the parquet at
/opt/voidly-ai/data/stl_seasonal_anomaly.parquet
and the sidecar at
/opt/voidly-ai/models/stl_seasonal_anomaly_v1.json.
The Flask endpoints mtime-cache the parquet so they pick up the
new fit on the next request without a service restart.