Spaces:
Sleeping
Sleeping
initial deploy
Browse files- .dockerignore +12 -0
- .gitignore +8 -0
- .pytest_cache/.gitignore +2 -0
- .pytest_cache/CACHEDIR.TAG +4 -0
- .pytest_cache/README.md +8 -0
- .pytest_cache/v/cache/lastfailed +3 -0
- .pytest_cache/v/cache/nodeids +35 -0
- .pytest_cache/v/cache/stepwise +1 -0
- Dockerfile +18 -0
- README.md +90 -5
- config.yaml +24 -0
- netlify.toml +8 -0
- pyproject.toml +19 -0
- requirements.txt +10 -0
- run.py +6 -0
- scripts/compare_baselines.py +225 -0
- scripts/tune_threshold.py +91 -0
- src/__init__.py +1 -0
- src/config.py +67 -0
- src/detector/__init__.py +5 -0
- src/detector/anomaly.py +81 -0
- src/detector/drift.py +22 -0
- src/detector/metrics.py +66 -0
- src/pipeline/__init__.py +3 -0
- src/pipeline/runner.py +158 -0
- src/server/__init__.py +1 -0
- src/server/app.py +249 -0
- src/server/static/dashboard.js +280 -0
- src/server/static/index.html +426 -0
- src/stream/__init__.py +4 -0
- src/stream/generator.py +151 -0
- src/stream/models.py +19 -0
- tests/__init__.py +1 -0
- tests/conftest.py +36 -0
- tests/test_baselines.py +52 -0
- tests/test_dashboard.py +90 -0
- tests/test_detector.py +207 -0
- tests/test_pipeline.py +259 -0
- tests/test_stream.py +129 -0
.dockerignore
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.venv
|
| 2 |
+
__pycache__
|
| 3 |
+
*.pyc
|
| 4 |
+
.pytest_cache
|
| 5 |
+
.git
|
| 6 |
+
.gitignore
|
| 7 |
+
*.md
|
| 8 |
+
.env
|
| 9 |
+
.env.*
|
| 10 |
+
fly.toml
|
| 11 |
+
Dockerfile
|
| 12 |
+
.dockerignore
|
.gitignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.venv/
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
*.pyo
|
| 5 |
+
.pytest_cache/
|
| 6 |
+
*.egg-info/
|
| 7 |
+
.eggs/
|
| 8 |
+
.env
|
.pytest_cache/.gitignore
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Created by pytest automatically.
|
| 2 |
+
*
|
.pytest_cache/CACHEDIR.TAG
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Signature: 8a477f597d28d172789f06886806bc55
|
| 2 |
+
# This file is a cache directory tag created by pytest.
|
| 3 |
+
# For information about cache directory tags, see:
|
| 4 |
+
# https://bford.info/cachedir/spec.html
|
.pytest_cache/README.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# pytest cache directory #
|
| 2 |
+
|
| 3 |
+
This directory contains data from the pytest's cache plugin,
|
| 4 |
+
which provides the `--lf` and `--ff` options, as well as the `cache` fixture.
|
| 5 |
+
|
| 6 |
+
**Do not** commit this to version control.
|
| 7 |
+
|
| 8 |
+
See [the docs](https://docs.pytest.org/en/stable/how-to/cache.html) for more information.
|
.pytest_cache/v/cache/lastfailed
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"tests/test_baselines.py::test_halfsspacetrees_not_worst": true
|
| 3 |
+
}
|
.pytest_cache/v/cache/nodeids
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
"tests/test_baselines.py::test_all_metrics_in_valid_range",
|
| 3 |
+
"tests/test_baselines.py::test_comparison_returns_three_methods",
|
| 4 |
+
"tests/test_baselines.py::test_halfsspacetrees_detects_some_anomalies",
|
| 5 |
+
"tests/test_baselines.py::test_halfsspacetrees_not_worst",
|
| 6 |
+
"tests/test_baselines.py::test_mahalanobis_beats_zscore_on_f1",
|
| 7 |
+
"tests/test_baselines.py::test_online_method_has_latency",
|
| 8 |
+
"tests/test_baselines.py::test_results_are_reproducible",
|
| 9 |
+
"tests/test_dashboard.py::test_dashboard_js_connects_via_websocket_and_updates",
|
| 10 |
+
"tests/test_dashboard.py::test_index_html_contains_dashboard_elements",
|
| 11 |
+
"tests/test_dashboard.py::test_root_serves_dashboard",
|
| 12 |
+
"tests/test_dashboard.py::test_single_command_starts_server",
|
| 13 |
+
"tests/test_dashboard.py::test_static_serves_dashboard_js",
|
| 14 |
+
"tests/test_detector.py::test_anomaly_scores_higher_for_injected_anomalies",
|
| 15 |
+
"tests/test_detector.py::test_detector_reset_clears_model_state",
|
| 16 |
+
"tests/test_detector.py::test_detector_scores_anomaly_after_freeze",
|
| 17 |
+
"tests/test_detector.py::test_detector_scores_zero_before_freeze",
|
| 18 |
+
"tests/test_detector.py::test_drift_detector_emits_reset_in_phase_b",
|
| 19 |
+
"tests/test_detector.py::test_f1_zero_when_no_positives",
|
| 20 |
+
"tests/test_detector.py::test_no_reset_during_phase_a",
|
| 21 |
+
"tests/test_detector.py::test_running_precision_recall_hand_labeled",
|
| 22 |
+
"tests/test_detector.py::test_specificity_all_false_positives",
|
| 23 |
+
"tests/test_detector.py::test_specificity_all_true_negatives",
|
| 24 |
+
"tests/test_pipeline.py::test_broadcast_message_schema",
|
| 25 |
+
"tests/test_pipeline.py::test_broadcast_phase_labels_correct",
|
| 26 |
+
"tests/test_pipeline.py::test_latency_ms_is_plausible",
|
| 27 |
+
"tests/test_pipeline.py::test_pipeline_no_blocking",
|
| 28 |
+
"tests/test_pipeline.py::test_stats_endpoint_returns_running_totals",
|
| 29 |
+
"tests/test_pipeline.py::test_websocket_receives_broadcast_messages_in_real_time",
|
| 30 |
+
"tests/test_stream.py::test_generator_is_async",
|
| 31 |
+
"tests/test_stream.py::test_phase_a_normality",
|
| 32 |
+
"tests/test_stream.py::test_phase_b_drift",
|
| 33 |
+
"tests/test_stream.py::test_phase_c_anomaly_count",
|
| 34 |
+
"tests/test_stream.py::test_phase_labels_match_boundaries"
|
| 35 |
+
]
|
.pytest_cache/v/cache/stepwise
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
[]
|
Dockerfile
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 6 |
+
PYTHONDONTWRITEBYTECODE=1 \
|
| 7 |
+
PIP_NO_CACHE_DIR=1
|
| 8 |
+
|
| 9 |
+
COPY requirements.txt .
|
| 10 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 11 |
+
|
| 12 |
+
COPY . .
|
| 13 |
+
|
| 14 |
+
# HF Spaces requires 7860; Fly.io overrides via [env] in fly.toml
|
| 15 |
+
ENV PORT=7860
|
| 16 |
+
EXPOSE 7860
|
| 17 |
+
|
| 18 |
+
CMD ["python", "run.py"]
|
README.md
CHANGED
|
@@ -1,10 +1,95 @@
|
|
| 1 |
---
|
| 2 |
-
title: Anomaly Detection
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Real-Time Anomaly Detection
|
| 3 |
+
emoji: 📡
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# Real-Time Anomaly Detection on Time-Series Streams
|
| 12 |
+
|
| 13 |
+
Online anomaly detection on a multivariate sensor stream with concept drift handling. Each observation is scored once as it arrives. No batch retraining.
|
| 14 |
+
|
| 15 |
+
## Problem
|
| 16 |
+
|
| 17 |
+
Sensor distributions shift over time. A static model will either miss anomalies in the new regime or start flagging normal post-drift readings as anomalous. This project fits a baseline during a stable warmup window, then scores every subsequent observation against it while monitoring for distribution shifts.
|
| 18 |
+
|
| 19 |
+
## Approach
|
| 20 |
+
|
| 21 |
+
- **Synthetic stream**: Three-phase generator (normal baseline, gradual drift, injected anomalies) with configurable rates and ground truth labels.
|
| 22 |
+
- **Online scorer**: Mahalanobis distance from a frozen Phase A baseline. Uses the full sensor covariance matrix, so it catches contextual anomalies (anti-correlated sensor readings) that per-feature z-score methods miss entirely.
|
| 23 |
+
- **Drift detection**: ADWIN watches a normalized composite sensor signal. When the distribution shifts (Phase B), ADWIN fires. The anomaly baseline is kept since it was calibrated before drift started.
|
| 24 |
+
- **Running metrics**: Precision, recall, and F1 are updated after every observation against ground truth labels.
|
| 25 |
+
- **Real-time UI**: FastAPI + WebSocket streams each scored observation to a dashboard with rolling charts, anomaly/drift markers, and live counters. New visitors get a replay of the last 200 points.
|
| 26 |
+
|
| 27 |
+
## Results (offline benchmark)
|
| 28 |
+
|
| 29 |
+
Evaluated on Phase C (300 obs, 10% anomaly rate, seed=42):
|
| 30 |
+
|
| 31 |
+
| Method | Precision | Recall | F1 | Mode |
|
| 32 |
+
|--------|-----------|--------|-----|------|
|
| 33 |
+
| Mahalanobis + ADWIN | 0.833 | 1.000 | **0.909** | online |
|
| 34 |
+
| Isolation Forest | 0.476 | 1.000 | 0.645 | batch |
|
| 35 |
+
| Z-Score (max \|z\|>2.5) | 0.450 | 0.600 | 0.514 | batch |
|
| 36 |
+
|
| 37 |
+
Z-Score's recall is limited by contextual anomalies where no single sensor exceeds the 2.5σ threshold. Mahalanobis catches these via the covariance term.
|
| 38 |
+
|
| 39 |
+
## Tech stack
|
| 40 |
+
|
| 41 |
+
- **Python 3.10+**
|
| 42 |
+
- **River** - online drift detection (ADWIN)
|
| 43 |
+
- **numpy / scikit-learn** - Mahalanobis scoring, Isolation Forest baseline
|
| 44 |
+
- **FastAPI** - REST + WebSocket, static dashboard
|
| 45 |
+
- **asyncio** - async stream and pipeline
|
| 46 |
+
- **pytest** - tests for stream, detector, pipeline, and server
|
| 47 |
+
|
| 48 |
+
## Setup and run
|
| 49 |
+
|
| 50 |
+
```bash
|
| 51 |
+
python -m venv .venv
|
| 52 |
+
.venv\Scripts\activate # Windows
|
| 53 |
+
# source .venv/bin/activate # Linux/macOS
|
| 54 |
+
pip install -r requirements.txt
|
| 55 |
+
python run.py
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
Open **http://localhost:8000/**. The pipeline starts on first connection and loops automatically.
|
| 59 |
+
|
| 60 |
+
## Deploy
|
| 61 |
+
|
| 62 |
+
**Backend (Hugging Face Spaces)**
|
| 63 |
+
|
| 64 |
+
Create a new Space (Docker SDK), link it to this repo, and push. HF Spaces sets `PORT` automatically and expects `7860` — the Dockerfile is already configured for it.
|
| 65 |
+
|
| 66 |
+
**Frontend (Netlify)**
|
| 67 |
+
|
| 68 |
+
Connect this repo to Netlify. It will pick up `netlify.toml` and serve `src/server/static/` as a static site. If your HF Space URL differs from the default in `dashboard.js`, update the `HF_SPACE` constant at the top of that file before deploying.
|
| 69 |
+
|
| 70 |
+
**Docker locally:** `docker build -t anomaly . && docker run -p 7860:7860 anomaly`
|
| 71 |
+
|
| 72 |
+
## Tests
|
| 73 |
+
|
| 74 |
+
```bash
|
| 75 |
+
pytest
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
All tests use the synthetic stream and need no external services.
|
| 79 |
+
|
| 80 |
+
## Project layout
|
| 81 |
+
|
| 82 |
+
| Path | Role |
|
| 83 |
+
|------|------|
|
| 84 |
+
| `src/stream/` | Observation model, async synthetic generator (phases A/B/C) |
|
| 85 |
+
| `src/detector/` | Anomaly scorer (Mahalanobis), drift detector (ADWIN), running metrics |
|
| 86 |
+
| `src/pipeline/` | Async pipeline: stream, score, broadcast, replay buffer |
|
| 87 |
+
| `src/server/` | FastAPI app, WebSocket, `/stats`, `/metrics` (Prometheus), static dashboard |
|
| 88 |
+
| `tests/` | Pytest for stream, detector, pipeline, dashboard |
|
| 89 |
+
|
| 90 |
+
## Design notes
|
| 91 |
+
|
| 92 |
+
- **Mahalanobis over Z-Score**: Z-Score tests each sensor independently. Mahalanobis uses the covariance matrix, so readings that look moderate in isolation but are unusual as a combination (e.g., temp up + pressure down when they normally move together) score high.
|
| 93 |
+
- **ADWIN on sensor signal, not anomaly scores**: Watching the normalized sensor deviation catches sustained distribution shifts without false-firing on isolated anomaly spikes.
|
| 94 |
+
- **Replay on connect**: New clients get the last 200 messages immediately, then live updates.
|
| 95 |
+
- **Start on first connect**: The pipeline only runs while at least one client is connected. Counters reset each cycle.
|
config.yaml
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
stream:
|
| 2 |
+
phase_a_length: 300
|
| 3 |
+
phase_b_length: 200
|
| 4 |
+
phase_c_length: 300
|
| 5 |
+
drift_magnitude: 0.3
|
| 6 |
+
anomaly_rate: 0.08
|
| 7 |
+
point_ratio: 0.6
|
| 8 |
+
delay: 0.05
|
| 9 |
+
seed: 42
|
| 10 |
+
|
| 11 |
+
detector:
|
| 12 |
+
n_trees: 25
|
| 13 |
+
height: 8
|
| 14 |
+
window_size: 150
|
| 15 |
+
threshold: 0.60
|
| 16 |
+
seed: 42
|
| 17 |
+
|
| 18 |
+
drift:
|
| 19 |
+
delta: 0.05
|
| 20 |
+
grace_period: 30
|
| 21 |
+
|
| 22 |
+
server:
|
| 23 |
+
port: 8000
|
| 24 |
+
replay_buffer_size: 200
|
netlify.toml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build]
|
| 2 |
+
publish = "src/server/static"
|
| 3 |
+
|
| 4 |
+
[[headers]]
|
| 5 |
+
for = "/*"
|
| 6 |
+
[headers.values]
|
| 7 |
+
X-Frame-Options = "DENY"
|
| 8 |
+
X-Content-Type-Options = "nosniff"
|
pyproject.toml
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "realtime-anomaly-detection"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "Real-Time Anomaly Detection on Time-Series Streams"
|
| 5 |
+
requires-python = ">=3.10"
|
| 6 |
+
dependencies = [
|
| 7 |
+
"river>=0.21.0",
|
| 8 |
+
"fastapi>=0.109.0",
|
| 9 |
+
"uvicorn[standard]>=0.27.0",
|
| 10 |
+
"websockets>=12.0",
|
| 11 |
+
"pyyaml>=6.0",
|
| 12 |
+
"scikit-learn>=1.3.0",
|
| 13 |
+
"numpy>=1.24.0",
|
| 14 |
+
]
|
| 15 |
+
|
| 16 |
+
[tool.pytest.ini_options]
|
| 17 |
+
asyncio_mode = "auto"
|
| 18 |
+
testpaths = ["tests"]
|
| 19 |
+
pythonpath = ["."]
|
requirements.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
river>=0.21.0
|
| 2 |
+
fastapi>=0.109.0
|
| 3 |
+
uvicorn[standard]>=0.27.0
|
| 4 |
+
websockets>=12.0
|
| 5 |
+
pyyaml>=6.0
|
| 6 |
+
scikit-learn>=1.3.0
|
| 7 |
+
numpy>=1.24.0
|
| 8 |
+
pytest>=7.0.0
|
| 9 |
+
pytest-asyncio>=0.23.0
|
| 10 |
+
httpx>=0.27.0
|
run.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Single command to start the server and dashboard. Dashboard at http://localhost:8000/"""
|
| 2 |
+
|
| 3 |
+
from src.server.app import main
|
| 4 |
+
|
| 5 |
+
if __name__ == "__main__":
|
| 6 |
+
main()
|
scripts/compare_baselines.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Offline baseline comparison: HalfSpaceTrees (online) vs Isolation Forest (batch) vs Z-Score (statistical).
|
| 3 |
+
|
| 4 |
+
Run from the project root:
|
| 5 |
+
python scripts/compare_baselines.py
|
| 6 |
+
|
| 7 |
+
The script generates the full 800-observation synthetic stream (same seed as the live demo),
|
| 8 |
+
trains each method on Phase A alone, scores Phase C, and prints a comparison table.
|
| 9 |
+
It also measures drift detection latency for ADWIN.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import sys
|
| 15 |
+
import time
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
# Make project root importable when run as a script
|
| 19 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 20 |
+
|
| 21 |
+
import numpy as np
|
| 22 |
+
from sklearn.ensemble import IsolationForest
|
| 23 |
+
from sklearn.metrics import f1_score, precision_score, recall_score, roc_auc_score
|
| 24 |
+
|
| 25 |
+
from src.config import settings
|
| 26 |
+
from src.detector.anomaly import AnomalyDetector
|
| 27 |
+
from src.detector.drift import DriftDetector
|
| 28 |
+
from src.pipeline.runner import _drift_signal
|
| 29 |
+
from src.stream.generator import _generate_sync
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _features(obs_list):
|
| 33 |
+
return np.array([[o.temperature, o.pressure, o.vibration] for o in obs_list])
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _binary_labels(obs_list):
|
| 37 |
+
return np.array([0 if o.label == "normal" else 1 for o in obs_list])
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def run_comparison(seed: int = 42, verbose: bool = True) -> dict:
|
| 41 |
+
cfg = settings.stream
|
| 42 |
+
cfg_d = settings.detector
|
| 43 |
+
cfg_dr = settings.drift
|
| 44 |
+
|
| 45 |
+
observations = list(
|
| 46 |
+
_generate_sync(
|
| 47 |
+
phase_a_length=cfg.phase_a_length,
|
| 48 |
+
phase_b_length=cfg.phase_b_length,
|
| 49 |
+
phase_c_length=cfg.phase_c_length,
|
| 50 |
+
drift_magnitude=cfg.drift_magnitude,
|
| 51 |
+
anomaly_rate=cfg.anomaly_rate,
|
| 52 |
+
point_ratio=cfg.point_ratio,
|
| 53 |
+
seed=seed,
|
| 54 |
+
)
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
pa_end = cfg.phase_a_length
|
| 58 |
+
pb_end = pa_end + cfg.phase_b_length
|
| 59 |
+
phase_a = observations[:pa_end]
|
| 60 |
+
phase_b = observations[pa_end:pb_end]
|
| 61 |
+
phase_c = observations[pb_end:]
|
| 62 |
+
|
| 63 |
+
X_a = _features(phase_a)
|
| 64 |
+
X_b = _features(phase_b)
|
| 65 |
+
X_c = _features(phase_c)
|
| 66 |
+
y_c = _binary_labels(phase_c)
|
| 67 |
+
|
| 68 |
+
results = {}
|
| 69 |
+
|
| 70 |
+
# ── 1. Z-Score (statistical, fit on Phase A) ─────────────────────────────────
|
| 71 |
+
mu = X_a.mean(axis=0)
|
| 72 |
+
std = X_a.std(axis=0) + 1e-9
|
| 73 |
+
t0 = time.perf_counter()
|
| 74 |
+
z_scores = np.abs((X_c - mu) / std).max(axis=1)
|
| 75 |
+
z_preds = (z_scores > 2.5).astype(int)
|
| 76 |
+
z_time = (time.perf_counter() - t0) * 1000
|
| 77 |
+
results["Z-Score (max |z|>2.5)"] = _metrics(y_c, z_preds, z_scores, z_time, batch=True)
|
| 78 |
+
|
| 79 |
+
# ── 2. Isolation Forest (batch, train on Phase A) ────────────────────────────
|
| 80 |
+
t0 = time.perf_counter()
|
| 81 |
+
iso = IsolationForest(
|
| 82 |
+
n_estimators=100,
|
| 83 |
+
contamination=cfg.anomaly_rate,
|
| 84 |
+
random_state=seed,
|
| 85 |
+
)
|
| 86 |
+
iso.fit(X_a)
|
| 87 |
+
# score_samples returns negative anomaly scores; negate for "higher = more anomalous"
|
| 88 |
+
iso_scores_raw = -iso.score_samples(X_c)
|
| 89 |
+
iso_preds = (iso.predict(X_c) == -1).astype(int)
|
| 90 |
+
iso_time = (time.perf_counter() - t0) * 1000
|
| 91 |
+
results["Isolation Forest (batch)"] = _metrics(y_c, iso_preds, iso_scores_raw, iso_time, batch=True)
|
| 92 |
+
|
| 93 |
+
# ── 3. HalfSpaceTrees + ADWIN (online, streaming) ───────────────────────────
|
| 94 |
+
detector = AnomalyDetector(
|
| 95 |
+
threshold=cfg_d.threshold,
|
| 96 |
+
n_trees=cfg_d.n_trees,
|
| 97 |
+
height=cfg_d.height,
|
| 98 |
+
window_size=cfg_d.window_size,
|
| 99 |
+
seed=cfg_d.seed,
|
| 100 |
+
)
|
| 101 |
+
drift_det = DriftDetector(delta=cfg_dr.delta, grace_period=cfg_dr.grace_period)
|
| 102 |
+
|
| 103 |
+
hst_scores: list[float] = []
|
| 104 |
+
hst_preds: list[int] = []
|
| 105 |
+
latencies: list[float] = []
|
| 106 |
+
|
| 107 |
+
t0 = time.perf_counter()
|
| 108 |
+
_frozen = False
|
| 109 |
+
for i, obs in enumerate(observations):
|
| 110 |
+
t_obs = time.perf_counter()
|
| 111 |
+
if obs.phase == "A":
|
| 112 |
+
detector.learn_scaler(obs)
|
| 113 |
+
elif not _frozen:
|
| 114 |
+
detector.freeze_baseline()
|
| 115 |
+
_frozen = True
|
| 116 |
+
score = detector.score(obs)
|
| 117 |
+
drift_det.update(_drift_signal(obs))
|
| 118 |
+
latencies.append((time.perf_counter() - t_obs) * 1000)
|
| 119 |
+
|
| 120 |
+
if i >= pb_end:
|
| 121 |
+
hst_scores.append(score)
|
| 122 |
+
hst_preds.append(1 if score > cfg_d.threshold else 0)
|
| 123 |
+
|
| 124 |
+
hst_time = (time.perf_counter() - t0) * 1000
|
| 125 |
+
results["Mahalanobis + ADWIN (online)"] = _metrics(
|
| 126 |
+
y_c, np.array(hst_preds), np.array(hst_scores), hst_time, batch=False
|
| 127 |
+
)
|
| 128 |
+
results["Mahalanobis + ADWIN (online)"]["mean_latency_us"] = np.mean(latencies) * 1000
|
| 129 |
+
|
| 130 |
+
# ── Drift detection latency ─────────────────────────────────────────────────
|
| 131 |
+
# Re-run to find first ADWIN fire in Phase B
|
| 132 |
+
detector2 = AnomalyDetector(threshold=cfg_d.threshold, n_trees=cfg_d.n_trees, height=cfg_d.height, window_size=cfg_d.window_size, seed=cfg_d.seed)
|
| 133 |
+
drift_det2 = DriftDetector(delta=cfg_dr.delta, grace_period=cfg_dr.grace_period)
|
| 134 |
+
first_drift_obs = None
|
| 135 |
+
_frozen2 = False
|
| 136 |
+
|
| 137 |
+
for i, obs in enumerate(observations):
|
| 138 |
+
if obs.phase == "A":
|
| 139 |
+
detector2.learn_scaler(obs)
|
| 140 |
+
elif not _frozen2:
|
| 141 |
+
detector2.freeze_baseline()
|
| 142 |
+
_frozen2 = True
|
| 143 |
+
detector2.score(obs)
|
| 144 |
+
drift_det2.update(_drift_signal(obs))
|
| 145 |
+
if drift_det2.drift_detected and first_drift_obs is None:
|
| 146 |
+
first_drift_obs = i
|
| 147 |
+
|
| 148 |
+
drift_latency = (first_drift_obs - pa_end) if first_drift_obs is not None else None
|
| 149 |
+
|
| 150 |
+
if verbose:
|
| 151 |
+
_print_report(results, y_c, phase_a, phase_b, phase_c, drift_latency, cfg)
|
| 152 |
+
|
| 153 |
+
return results
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def _metrics(y_true, y_pred, scores, elapsed_ms, *, batch: bool) -> dict:
|
| 157 |
+
# Guard against all-zero predictions for AUC
|
| 158 |
+
try:
|
| 159 |
+
auc = roc_auc_score(y_true, scores)
|
| 160 |
+
except ValueError:
|
| 161 |
+
auc = float("nan")
|
| 162 |
+
return {
|
| 163 |
+
"precision": precision_score(y_true, y_pred, zero_division=0),
|
| 164 |
+
"recall": recall_score(y_true, y_pred, zero_division=0),
|
| 165 |
+
"f1": f1_score(y_true, y_pred, zero_division=0),
|
| 166 |
+
"roc_auc": auc,
|
| 167 |
+
"elapsed_ms": elapsed_ms,
|
| 168 |
+
"batch": batch,
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _print_report(results, y_c, phase_a, phase_b, phase_c, drift_latency, cfg):
|
| 173 |
+
sep = "-" * 72
|
| 174 |
+
|
| 175 |
+
print()
|
| 176 |
+
print(" Offline Baseline Comparison — Real-Time Anomaly Detection")
|
| 177 |
+
print(sep)
|
| 178 |
+
print(f" Stream: {len(phase_a)} Phase-A | {len(phase_b)} Phase-B | {len(phase_c)} Phase-C")
|
| 179 |
+
print(f" Anomalies in Phase C: {int(y_c.sum())} / {len(y_c)} ({100*y_c.mean():.1f}%)")
|
| 180 |
+
print()
|
| 181 |
+
print(f" {'Method':<38} {'Prec':>6} {'Recall':>6} {'F1':>6} {'AUC':>6} {'Mode'}")
|
| 182 |
+
print(sep)
|
| 183 |
+
|
| 184 |
+
for name, m in results.items():
|
| 185 |
+
mode = "batch" if m["batch"] else "online"
|
| 186 |
+
auc = f"{m['roc_auc']:.3f}" if not (m['roc_auc'] != m['roc_auc']) else " n/a "
|
| 187 |
+
print(
|
| 188 |
+
f" {name:<38} {m['precision']:>6.3f} {m['recall']:>6.3f} {m['f1']:>6.3f} {auc:>6} {mode}"
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
print(sep)
|
| 192 |
+
print()
|
| 193 |
+
print(" Latency")
|
| 194 |
+
print(sep)
|
| 195 |
+
for name, m in results.items():
|
| 196 |
+
if not m["batch"]:
|
| 197 |
+
lat = m.get("mean_latency_us")
|
| 198 |
+
if lat is not None:
|
| 199 |
+
print(f" {name:<38} {lat:.2f} µs / observation (mean)")
|
| 200 |
+
else:
|
| 201 |
+
total = m["elapsed_ms"]
|
| 202 |
+
per = total / len(y_c)
|
| 203 |
+
print(f" {name:<38} {per:.3f} ms / observation ({total:.1f} ms total, amortised)")
|
| 204 |
+
|
| 205 |
+
print()
|
| 206 |
+
print(" Drift detection")
|
| 207 |
+
print(sep)
|
| 208 |
+
if drift_latency is not None:
|
| 209 |
+
print(f" ADWIN first fired at observation {cfg.phase_a_length + drift_latency}")
|
| 210 |
+
print(f" → {drift_latency} observations into Phase B ({100*drift_latency/cfg.phase_b_length:.1f}% of drift phase)")
|
| 211 |
+
else:
|
| 212 |
+
print(" ADWIN did not fire during Phase B (try lowering delta).")
|
| 213 |
+
print()
|
| 214 |
+
print(" Notes")
|
| 215 |
+
print(sep)
|
| 216 |
+
print(" Z-Score checks each sensor independently, so it misses contextual anomalies")
|
| 217 |
+
print(" where no single sensor looks extreme but the combination is unusual (e.g.,")
|
| 218 |
+
print(" high temp + low pressure when they normally move together).")
|
| 219 |
+
print(" Mahalanobis uses the full covariance matrix and catches both types.")
|
| 220 |
+
print(" The baseline is fit from Phase A data and stays frozen after that.")
|
| 221 |
+
print()
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
if __name__ == "__main__":
|
| 225 |
+
run_comparison()
|
scripts/tune_threshold.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Find the threshold that maximises F1 on Phase C for the current detector config."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
from sklearn.metrics import f1_score, precision_score, recall_score
|
| 12 |
+
|
| 13 |
+
from src.config import settings
|
| 14 |
+
from src.detector.anomaly import AnomalyDetector
|
| 15 |
+
from src.detector.drift import DriftDetector
|
| 16 |
+
from src.pipeline.runner import _drift_signal
|
| 17 |
+
from src.stream.generator import _generate_sync
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def sweep(seed: int = 42) -> None:
|
| 21 |
+
cfg = settings.stream
|
| 22 |
+
cfg_d = settings.detector
|
| 23 |
+
cfg_dr = settings.drift
|
| 24 |
+
|
| 25 |
+
observations = list(
|
| 26 |
+
_generate_sync(
|
| 27 |
+
phase_a_length=cfg.phase_a_length,
|
| 28 |
+
phase_b_length=cfg.phase_b_length,
|
| 29 |
+
phase_c_length=cfg.phase_c_length,
|
| 30 |
+
drift_magnitude=cfg.drift_magnitude,
|
| 31 |
+
anomaly_rate=cfg.anomaly_rate,
|
| 32 |
+
seed=seed,
|
| 33 |
+
)
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
pb_end = cfg.phase_a_length + cfg.phase_b_length
|
| 37 |
+
phase_c = observations[pb_end:]
|
| 38 |
+
y_c = np.array([0 if o.label == "normal" else 1 for o in phase_c])
|
| 39 |
+
|
| 40 |
+
detector = AnomalyDetector(
|
| 41 |
+
threshold=0.5,
|
| 42 |
+
n_trees=cfg_d.n_trees,
|
| 43 |
+
height=cfg_d.height,
|
| 44 |
+
window_size=cfg_d.window_size,
|
| 45 |
+
seed=cfg_d.seed,
|
| 46 |
+
)
|
| 47 |
+
drift_det = DriftDetector(delta=cfg_dr.delta, grace_period=cfg_dr.grace_period)
|
| 48 |
+
scores_c: list[float] = []
|
| 49 |
+
|
| 50 |
+
_frozen = False
|
| 51 |
+
for i, obs in enumerate(observations):
|
| 52 |
+
if obs.phase == "A":
|
| 53 |
+
detector.learn_scaler(obs)
|
| 54 |
+
elif not _frozen:
|
| 55 |
+
detector.freeze_baseline()
|
| 56 |
+
_frozen = True
|
| 57 |
+
score = detector.score(obs)
|
| 58 |
+
drift_det.update(_drift_signal(obs))
|
| 59 |
+
if i >= pb_end:
|
| 60 |
+
scores_c.append(score)
|
| 61 |
+
|
| 62 |
+
scores_arr = np.array(scores_c)
|
| 63 |
+
|
| 64 |
+
print(f"\nPhase C score distribution (n={len(scores_arr)}, anomalies={y_c.sum()}):")
|
| 65 |
+
print(f" Normal scores: mean={scores_arr[y_c==0].mean():.4f} std={scores_arr[y_c==0].std():.4f} max={scores_arr[y_c==0].max():.4f}")
|
| 66 |
+
print(f" Anomaly scores: mean={scores_arr[y_c==1].mean():.4f} std={scores_arr[y_c==1].std():.4f} min={scores_arr[y_c==1].min():.4f}")
|
| 67 |
+
print()
|
| 68 |
+
print(f" {'Threshold':>10} {'Prec':>6} {'Recall':>6} {'F1':>6} {'TP':>4} {'FP':>4} {'FN':>4}")
|
| 69 |
+
print(" " + "-" * 55)
|
| 70 |
+
|
| 71 |
+
best_f1, best_t = 0.0, 0.5
|
| 72 |
+
for t in np.arange(0.05, 0.95, 0.025):
|
| 73 |
+
preds = (scores_arr >= t).astype(int)
|
| 74 |
+
p = precision_score(y_c, preds, zero_division=0)
|
| 75 |
+
r = recall_score(y_c, preds, zero_division=0)
|
| 76 |
+
f1 = f1_score(y_c, preds, zero_division=0)
|
| 77 |
+
tp = int(((preds == 1) & (y_c == 1)).sum())
|
| 78 |
+
fp = int(((preds == 1) & (y_c == 0)).sum())
|
| 79 |
+
fn = int(((preds == 0) & (y_c == 1)).sum())
|
| 80 |
+
marker = " <-- best" if f1 > best_f1 else ""
|
| 81 |
+
if f1 > best_f1:
|
| 82 |
+
best_f1, best_t = f1, float(t)
|
| 83 |
+
print(f" {t:>10.3f} {p:>6.3f} {r:>6.3f} {f1:>6.3f} {tp:>4} {fp:>4} {fn:>4}{marker}")
|
| 84 |
+
|
| 85 |
+
print()
|
| 86 |
+
print(f" Best threshold: {best_t:.3f} -> F1={best_f1:.3f}")
|
| 87 |
+
print(f" Current config: {cfg_d.threshold:.3f}")
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
if __name__ == "__main__":
|
| 91 |
+
sweep()
|
src/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Real-Time Anomaly Detection on Time-Series Streams
|
src/config.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Load project configuration from config.yaml."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import yaml
|
| 9 |
+
|
| 10 |
+
_ROOT = Path(__file__).resolve().parent.parent
|
| 11 |
+
_CONFIG_PATH = _ROOT / "config.yaml"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass
|
| 15 |
+
class StreamConfig:
|
| 16 |
+
phase_a_length: int
|
| 17 |
+
phase_b_length: int
|
| 18 |
+
phase_c_length: int
|
| 19 |
+
drift_magnitude: float
|
| 20 |
+
anomaly_rate: float
|
| 21 |
+
point_ratio: float
|
| 22 |
+
delay: float
|
| 23 |
+
seed: int
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@dataclass
|
| 27 |
+
class DetectorConfig:
|
| 28 |
+
n_trees: int
|
| 29 |
+
height: int
|
| 30 |
+
window_size: int
|
| 31 |
+
threshold: float
|
| 32 |
+
seed: int
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@dataclass
|
| 36 |
+
class DriftConfig:
|
| 37 |
+
delta: float
|
| 38 |
+
grace_period: int
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@dataclass
|
| 42 |
+
class ServerConfig:
|
| 43 |
+
port: int
|
| 44 |
+
replay_buffer_size: int
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@dataclass
|
| 48 |
+
class Config:
|
| 49 |
+
stream: StreamConfig
|
| 50 |
+
detector: DetectorConfig
|
| 51 |
+
drift: DriftConfig
|
| 52 |
+
server: ServerConfig
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def load_config(path: Path = _CONFIG_PATH) -> Config:
|
| 56 |
+
with open(path) as f:
|
| 57 |
+
raw = yaml.safe_load(f)
|
| 58 |
+
return Config(
|
| 59 |
+
stream=StreamConfig(**raw["stream"]),
|
| 60 |
+
detector=DetectorConfig(**raw["detector"]),
|
| 61 |
+
drift=DriftConfig(**raw["drift"]),
|
| 62 |
+
server=ServerConfig(**raw["server"]),
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
# Module-level singleton loaded once at import time
|
| 67 |
+
settings: Config = load_config()
|
src/detector/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from src.detector.anomaly import AnomalyDetector
|
| 2 |
+
from src.detector.drift import DriftDetector
|
| 3 |
+
from src.detector.metrics import RunningMetrics
|
| 4 |
+
|
| 5 |
+
__all__ = ["AnomalyDetector", "DriftDetector", "RunningMetrics"]
|
src/detector/anomaly.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Online anomaly scorer using Mahalanobis distance from Phase A baseline."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import math
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
|
| 9 |
+
from src.stream.models import Observation
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _to_array(obs: Observation) -> np.ndarray:
|
| 13 |
+
return np.array([obs.temperature, obs.pressure, obs.vibration], dtype=float)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class AnomalyDetector:
|
| 17 |
+
"""Scores observations using Mahalanobis distance from a Phase A baseline.
|
| 18 |
+
|
| 19 |
+
Observations are buffered during Phase A to compute mean and covariance.
|
| 20 |
+
After freeze_baseline() is called, score() returns tanh(d2/SCALE) where
|
| 21 |
+
d2 is the squared Mahalanobis distance from the frozen baseline.
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
# tanh scale: chi2(3) 97.5th percentile (d2=9.348) maps to ~0.71
|
| 25 |
+
_TANH_SCALE: float = 15.0
|
| 26 |
+
|
| 27 |
+
def __init__(
|
| 28 |
+
self,
|
| 29 |
+
threshold: float = 0.60,
|
| 30 |
+
seed: int = 42,
|
| 31 |
+
n_trees: int = 25,
|
| 32 |
+
height: int = 8,
|
| 33 |
+
window_size: int = 150,
|
| 34 |
+
) -> None:
|
| 35 |
+
# n_trees / height / window_size kept for API compatibility (used by ADWIN tests)
|
| 36 |
+
self.threshold = threshold
|
| 37 |
+
self._phase_a_buffer: list[list[float]] = []
|
| 38 |
+
self._phase_a_mean: np.ndarray | None = None
|
| 39 |
+
self._inv_cov: np.ndarray | None = None
|
| 40 |
+
self._frozen: bool = False
|
| 41 |
+
|
| 42 |
+
# ------------------------------------------------------------------
|
| 43 |
+
# Phase A fitting
|
| 44 |
+
# ------------------------------------------------------------------
|
| 45 |
+
|
| 46 |
+
def learn_scaler(self, obs: Observation) -> None:
|
| 47 |
+
"""Buffer one Phase A observation. Call for every Phase A observation."""
|
| 48 |
+
self._phase_a_buffer.append(_to_array(obs).tolist())
|
| 49 |
+
|
| 50 |
+
def freeze_baseline(self) -> None:
|
| 51 |
+
"""Fit mean and inverse covariance from the Phase A buffer.
|
| 52 |
+
|
| 53 |
+
Call once when the first Phase B observation arrives.
|
| 54 |
+
"""
|
| 55 |
+
X = np.array(self._phase_a_buffer, dtype=float)
|
| 56 |
+
self._phase_a_mean = X.mean(axis=0)
|
| 57 |
+
cov = np.cov(X.T) + 1e-6 * np.eye(3) # regularize for numerical stability
|
| 58 |
+
self._inv_cov = np.linalg.inv(cov)
|
| 59 |
+
self._frozen = True
|
| 60 |
+
|
| 61 |
+
# ------------------------------------------------------------------
|
| 62 |
+
# Scoring
|
| 63 |
+
# ------------------------------------------------------------------
|
| 64 |
+
|
| 65 |
+
def score(self, obs: Observation) -> float:
|
| 66 |
+
"""Return anomaly score in [0, 1). Returns 0.0 before freeze_baseline() is called."""
|
| 67 |
+
if not self._frozen:
|
| 68 |
+
return 0.0
|
| 69 |
+
diff = _to_array(obs) - self._phase_a_mean # type: ignore[operator]
|
| 70 |
+
d_sq = float(diff @ self._inv_cov @ diff) # type: ignore[operator]
|
| 71 |
+
return math.tanh(d_sq / self._TANH_SCALE)
|
| 72 |
+
|
| 73 |
+
# ------------------------------------------------------------------
|
| 74 |
+
# No-ops kept for pipeline compatibility
|
| 75 |
+
# ------------------------------------------------------------------
|
| 76 |
+
|
| 77 |
+
def learn(self, obs: Observation) -> None:
|
| 78 |
+
"""No-op: Mahalanobis uses the frozen Phase A baseline only."""
|
| 79 |
+
|
| 80 |
+
def reset(self) -> None:
|
| 81 |
+
"""No-op: Phase A baseline is intentionally preserved across drift events."""
|
src/detector/drift.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Drift detector that monitors the anomaly score stream (not raw sensor data)."""
|
| 2 |
+
|
| 3 |
+
from river import drift
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class DriftDetector:
|
| 7 |
+
"""Wraps River ADWIN to monitor anomaly scores and emit reset signals."""
|
| 8 |
+
|
| 9 |
+
def __init__(self, delta: float = 0.002, grace_period: int = 30) -> None:
|
| 10 |
+
self._adwin = drift.ADWIN(delta=delta, grace_period=grace_period)
|
| 11 |
+
self._last_drift_detected = False
|
| 12 |
+
|
| 13 |
+
def update(self, anomaly_score: float) -> bool:
|
| 14 |
+
"""Update with one anomaly score. Returns True if drift was detected this step."""
|
| 15 |
+
self._adwin.update(anomaly_score)
|
| 16 |
+
self._last_drift_detected = self._adwin.drift_detected
|
| 17 |
+
return self._last_drift_detected
|
| 18 |
+
|
| 19 |
+
@property
|
| 20 |
+
def drift_detected(self) -> bool:
|
| 21 |
+
"""Whether drift was detected on the last update."""
|
| 22 |
+
return self._last_drift_detected
|
src/detector/metrics.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Running precision, recall, F1, and specificity against ground truth labels."""
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class RunningMetrics:
|
| 5 |
+
"""Running TP, FP, TN, FN; all metrics updated after every observation."""
|
| 6 |
+
|
| 7 |
+
__slots__ = ("_tp", "_fp", "_tn", "_fn")
|
| 8 |
+
|
| 9 |
+
def __init__(self) -> None:
|
| 10 |
+
self._tp = 0
|
| 11 |
+
self._fp = 0
|
| 12 |
+
self._tn = 0
|
| 13 |
+
self._fn = 0
|
| 14 |
+
|
| 15 |
+
def update(self, ground_truth: bool, predicted: bool) -> None:
|
| 16 |
+
"""Update counts with one observation. ground_truth/predicted: True = anomaly."""
|
| 17 |
+
if ground_truth and predicted:
|
| 18 |
+
self._tp += 1
|
| 19 |
+
elif not ground_truth and predicted:
|
| 20 |
+
self._fp += 1
|
| 21 |
+
elif ground_truth and not predicted:
|
| 22 |
+
self._fn += 1
|
| 23 |
+
else:
|
| 24 |
+
self._tn += 1
|
| 25 |
+
|
| 26 |
+
@property
|
| 27 |
+
def precision(self) -> float:
|
| 28 |
+
if self._tp + self._fp == 0:
|
| 29 |
+
return 0.0
|
| 30 |
+
return self._tp / (self._tp + self._fp)
|
| 31 |
+
|
| 32 |
+
@property
|
| 33 |
+
def recall(self) -> float:
|
| 34 |
+
if self._tp + self._fn == 0:
|
| 35 |
+
return 0.0
|
| 36 |
+
return self._tp / (self._tp + self._fn)
|
| 37 |
+
|
| 38 |
+
@property
|
| 39 |
+
def f1(self) -> float:
|
| 40 |
+
p, r = self.precision, self.recall
|
| 41 |
+
if p + r == 0:
|
| 42 |
+
return 0.0
|
| 43 |
+
return 2 * p * r / (p + r)
|
| 44 |
+
|
| 45 |
+
@property
|
| 46 |
+
def specificity(self) -> float:
|
| 47 |
+
"""True negative rate: of all normal observations, how many were correctly left unflagged."""
|
| 48 |
+
if self._tn + self._fp == 0:
|
| 49 |
+
return 0.0
|
| 50 |
+
return self._tn / (self._tn + self._fp)
|
| 51 |
+
|
| 52 |
+
@property
|
| 53 |
+
def tp(self) -> int:
|
| 54 |
+
return self._tp
|
| 55 |
+
|
| 56 |
+
@property
|
| 57 |
+
def fp(self) -> int:
|
| 58 |
+
return self._fp
|
| 59 |
+
|
| 60 |
+
@property
|
| 61 |
+
def fn(self) -> int:
|
| 62 |
+
return self._fn
|
| 63 |
+
|
| 64 |
+
@property
|
| 65 |
+
def tn(self) -> int:
|
| 66 |
+
return self._tn
|
src/pipeline/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from src.pipeline.runner import run_pipeline
|
| 2 |
+
|
| 3 |
+
__all__ = ["run_pipeline"]
|
src/pipeline/runner.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Async pipeline: generator -> scorer -> broadcaster, with structured logging."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import time
|
| 5 |
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
from src.detector.anomaly import AnomalyDetector
|
| 9 |
+
from src.detector.drift import DriftDetector
|
| 10 |
+
from src.detector.metrics import RunningMetrics
|
| 11 |
+
from src.stream.generator import (
|
| 12 |
+
BASELINE_PRESSURE,
|
| 13 |
+
BASELINE_TEMP,
|
| 14 |
+
BASELINE_VIBRATION,
|
| 15 |
+
NOISE_SIGMA,
|
| 16 |
+
)
|
| 17 |
+
from src.stream.models import Observation
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _drift_signal(obs: Observation) -> float:
|
| 23 |
+
"""Average normalized deviation of all three sensors from Phase A baselines."""
|
| 24 |
+
return (
|
| 25 |
+
(obs.temperature - BASELINE_TEMP) / NOISE_SIGMA
|
| 26 |
+
+ (obs.pressure - BASELINE_PRESSURE) / NOISE_SIGMA
|
| 27 |
+
+ (obs.vibration - BASELINE_VIBRATION) / NOISE_SIGMA
|
| 28 |
+
) / 3.0
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _broadcast_message(
|
| 32 |
+
obs: Observation,
|
| 33 |
+
anomaly_score: float,
|
| 34 |
+
alert: bool,
|
| 35 |
+
drift_event: bool,
|
| 36 |
+
running_precision: float,
|
| 37 |
+
running_recall: float,
|
| 38 |
+
running_f1: float,
|
| 39 |
+
latency_ms: float,
|
| 40 |
+
observation_index: int,
|
| 41 |
+
total_anomalies_detected: int,
|
| 42 |
+
total_drift_events: int,
|
| 43 |
+
) -> dict[str, Any]:
|
| 44 |
+
return {
|
| 45 |
+
"timestamp": obs.timestamp,
|
| 46 |
+
"temperature": obs.temperature,
|
| 47 |
+
"pressure": obs.pressure,
|
| 48 |
+
"vibration": obs.vibration,
|
| 49 |
+
"label": obs.label,
|
| 50 |
+
"phase": obs.phase,
|
| 51 |
+
"anomaly_score": anomaly_score,
|
| 52 |
+
"alert": alert,
|
| 53 |
+
"drift_event": drift_event,
|
| 54 |
+
"running_precision": running_precision,
|
| 55 |
+
"running_recall": running_recall,
|
| 56 |
+
"running_f1": running_f1,
|
| 57 |
+
"latency_ms": latency_ms,
|
| 58 |
+
"observation_index": observation_index,
|
| 59 |
+
"total_anomalies_detected": total_anomalies_detected,
|
| 60 |
+
"total_drift_events": total_drift_events,
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
async def run_pipeline(
|
| 65 |
+
stream: AsyncIterator[Observation],
|
| 66 |
+
broadcaster: Callable[[dict[str, Any]], Awaitable[None]],
|
| 67 |
+
*,
|
| 68 |
+
anomaly_threshold: float = 0.5,
|
| 69 |
+
n_trees: int = 10,
|
| 70 |
+
height: int = 6,
|
| 71 |
+
window_size: int = 100,
|
| 72 |
+
drift_delta: float = 0.002,
|
| 73 |
+
drift_grace_period: int = 30,
|
| 74 |
+
state: dict[str, Any] | None = None,
|
| 75 |
+
) -> None:
|
| 76 |
+
"""Run the detection pipeline: score each observation, detect drift, broadcast messages."""
|
| 77 |
+
detector = AnomalyDetector(
|
| 78 |
+
threshold=anomaly_threshold,
|
| 79 |
+
n_trees=n_trees,
|
| 80 |
+
height=height,
|
| 81 |
+
window_size=window_size,
|
| 82 |
+
)
|
| 83 |
+
drift_detector = DriftDetector(delta=drift_delta, grace_period=drift_grace_period)
|
| 84 |
+
metrics = RunningMetrics()
|
| 85 |
+
observation_index = 0
|
| 86 |
+
total_anomalies = 0
|
| 87 |
+
total_drifts = 0
|
| 88 |
+
|
| 89 |
+
logger.info("pipeline_start", extra={"threshold": anomaly_threshold, "n_trees": n_trees})
|
| 90 |
+
|
| 91 |
+
_baseline_frozen = False
|
| 92 |
+
|
| 93 |
+
async for obs in stream:
|
| 94 |
+
t0 = time.perf_counter()
|
| 95 |
+
|
| 96 |
+
if obs.phase == "A":
|
| 97 |
+
detector.learn_scaler(obs)
|
| 98 |
+
elif not _baseline_frozen:
|
| 99 |
+
detector.freeze_baseline()
|
| 100 |
+
_baseline_frozen = True
|
| 101 |
+
|
| 102 |
+
score = detector.score(obs)
|
| 103 |
+
drift_event_flag = drift_detector.update(_drift_signal(obs))
|
| 104 |
+
|
| 105 |
+
if drift_event_flag:
|
| 106 |
+
total_drifts += 1
|
| 107 |
+
logger.info(
|
| 108 |
+
"drift_detected",
|
| 109 |
+
extra={
|
| 110 |
+
"observation_index": observation_index,
|
| 111 |
+
"phase": obs.phase,
|
| 112 |
+
"drift_signal": round(_drift_signal(obs), 4),
|
| 113 |
+
},
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
alert = score > anomaly_threshold
|
| 117 |
+
if alert:
|
| 118 |
+
total_anomalies += 1
|
| 119 |
+
|
| 120 |
+
metrics.update(ground_truth=(obs.label != "normal"), predicted=alert)
|
| 121 |
+
|
| 122 |
+
latency_ms = (time.perf_counter() - t0) * 1000
|
| 123 |
+
|
| 124 |
+
if state is not None:
|
| 125 |
+
state["total_observations"] = observation_index + 1
|
| 126 |
+
state["total_anomalies_detected"] = total_anomalies
|
| 127 |
+
state["total_drift_events"] = total_drifts
|
| 128 |
+
state["current_precision"] = metrics.precision
|
| 129 |
+
state["current_recall"] = metrics.recall
|
| 130 |
+
state["current_f1"] = metrics.f1
|
| 131 |
+
|
| 132 |
+
msg = _broadcast_message(
|
| 133 |
+
obs=obs,
|
| 134 |
+
anomaly_score=score,
|
| 135 |
+
alert=alert,
|
| 136 |
+
drift_event=drift_event_flag,
|
| 137 |
+
running_precision=metrics.precision,
|
| 138 |
+
running_recall=metrics.recall,
|
| 139 |
+
running_f1=metrics.f1,
|
| 140 |
+
latency_ms=round(latency_ms, 3),
|
| 141 |
+
observation_index=observation_index,
|
| 142 |
+
total_anomalies_detected=total_anomalies,
|
| 143 |
+
total_drift_events=total_drifts,
|
| 144 |
+
)
|
| 145 |
+
await broadcaster(msg)
|
| 146 |
+
observation_index += 1
|
| 147 |
+
|
| 148 |
+
logger.info(
|
| 149 |
+
"pipeline_end",
|
| 150 |
+
extra={
|
| 151 |
+
"total_observations": observation_index,
|
| 152 |
+
"total_anomalies": total_anomalies,
|
| 153 |
+
"total_drifts": total_drifts,
|
| 154 |
+
"final_precision": round(metrics.precision, 4),
|
| 155 |
+
"final_recall": round(metrics.recall, 4),
|
| 156 |
+
"final_f1": round(metrics.f1, 4),
|
| 157 |
+
},
|
| 158 |
+
)
|
src/server/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# FastAPI server and dashboard
|
src/server/app.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI app: WebSocket broadcast, REST /stats, Prometheus /metrics, static dashboard."""
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import json
|
| 5 |
+
import logging
|
| 6 |
+
import logging.config
|
| 7 |
+
import time
|
| 8 |
+
from collections import deque
|
| 9 |
+
from contextlib import asynccontextmanager
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
| 13 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 14 |
+
from fastapi.responses import FileResponse, PlainTextResponse
|
| 15 |
+
from fastapi.staticfiles import StaticFiles
|
| 16 |
+
|
| 17 |
+
from src.config import settings
|
| 18 |
+
from src.pipeline.runner import run_pipeline
|
| 19 |
+
from src.stream.generator import generate_stream
|
| 20 |
+
|
| 21 |
+
# Structured JSON logging
|
| 22 |
+
logging.config.dictConfig({
|
| 23 |
+
"version": 1,
|
| 24 |
+
"disable_existing_loggers": False,
|
| 25 |
+
"formatters": {
|
| 26 |
+
"json": {
|
| 27 |
+
"()": "logging.Formatter",
|
| 28 |
+
"fmt": '{"time":"%(asctime)s","level":"%(levelname)s","logger":"%(name)s","message":"%(message)s"}',
|
| 29 |
+
"datefmt": "%Y-%m-%dT%H:%M:%S",
|
| 30 |
+
}
|
| 31 |
+
},
|
| 32 |
+
"handlers": {
|
| 33 |
+
"console": {"class": "logging.StreamHandler", "formatter": "json", "stream": "ext://sys.stdout"}
|
| 34 |
+
},
|
| 35 |
+
"root": {"level": "INFO", "handlers": ["console"]},
|
| 36 |
+
})
|
| 37 |
+
|
| 38 |
+
logger = logging.getLogger(__name__)
|
| 39 |
+
|
| 40 |
+
REPLAY_BUFFER_SIZE = settings.server.replay_buffer_size
|
| 41 |
+
|
| 42 |
+
# Shared state updated by the pipeline, read by /stats and /metrics
|
| 43 |
+
STATE: dict = {
|
| 44 |
+
"total_observations": 0,
|
| 45 |
+
"total_anomalies_detected": 0,
|
| 46 |
+
"total_drift_events": 0,
|
| 47 |
+
"current_precision": 0.0,
|
| 48 |
+
"current_recall": 0.0,
|
| 49 |
+
"current_f1": 0.0,
|
| 50 |
+
"cycle_start_time": 0.0,
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
replay_buffer: deque = deque(maxlen=REPLAY_BUFFER_SIZE)
|
| 54 |
+
|
| 55 |
+
_pipeline_task: asyncio.Task | None = None
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class ConnectionManager:
|
| 59 |
+
def __init__(self) -> None:
|
| 60 |
+
self._connections: list[WebSocket] = []
|
| 61 |
+
|
| 62 |
+
async def connect(self, websocket: WebSocket) -> None:
|
| 63 |
+
await websocket.accept()
|
| 64 |
+
self._connections.append(websocket)
|
| 65 |
+
|
| 66 |
+
def register(self, websocket: WebSocket) -> None:
|
| 67 |
+
self._connections.append(websocket)
|
| 68 |
+
|
| 69 |
+
def disconnect(self, websocket: WebSocket) -> None:
|
| 70 |
+
if websocket in self._connections:
|
| 71 |
+
self._connections.remove(websocket)
|
| 72 |
+
|
| 73 |
+
async def broadcast(self, message: dict) -> None:
|
| 74 |
+
text = json.dumps(message)
|
| 75 |
+
dead: list[WebSocket] = []
|
| 76 |
+
for ws in self._connections:
|
| 77 |
+
try:
|
| 78 |
+
await ws.send_text(text)
|
| 79 |
+
except Exception:
|
| 80 |
+
dead.append(ws)
|
| 81 |
+
for ws in dead:
|
| 82 |
+
self.disconnect(ws)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
manager = ConnectionManager()
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
async def _broadcast_with_replay(message: dict) -> None:
|
| 89 |
+
replay_buffer.append(message)
|
| 90 |
+
await manager.broadcast(message)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _reset_state() -> None:
|
| 94 |
+
STATE["total_observations"] = 0
|
| 95 |
+
STATE["total_anomalies_detected"] = 0
|
| 96 |
+
STATE["total_drift_events"] = 0
|
| 97 |
+
STATE["current_precision"] = 0.0
|
| 98 |
+
STATE["current_recall"] = 0.0
|
| 99 |
+
STATE["current_f1"] = 0.0
|
| 100 |
+
STATE["cycle_start_time"] = time.time()
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
async def _run_pipeline_loop() -> None:
|
| 104 |
+
cfg_s = settings.stream
|
| 105 |
+
cfg_d = settings.detector
|
| 106 |
+
cfg_dr = settings.drift
|
| 107 |
+
while True:
|
| 108 |
+
_reset_state()
|
| 109 |
+
logger.info("cycle_start", extra={"seed": cfg_s.seed})
|
| 110 |
+
stream = generate_stream(
|
| 111 |
+
phase_a_length=cfg_s.phase_a_length,
|
| 112 |
+
phase_b_length=cfg_s.phase_b_length,
|
| 113 |
+
phase_c_length=cfg_s.phase_c_length,
|
| 114 |
+
drift_magnitude=cfg_s.drift_magnitude,
|
| 115 |
+
anomaly_rate=cfg_s.anomaly_rate,
|
| 116 |
+
point_ratio=cfg_s.point_ratio,
|
| 117 |
+
delay=cfg_s.delay,
|
| 118 |
+
seed=cfg_s.seed,
|
| 119 |
+
)
|
| 120 |
+
await run_pipeline(
|
| 121 |
+
stream,
|
| 122 |
+
_broadcast_with_replay,
|
| 123 |
+
state=STATE,
|
| 124 |
+
anomaly_threshold=cfg_d.threshold,
|
| 125 |
+
n_trees=cfg_d.n_trees,
|
| 126 |
+
height=cfg_d.height,
|
| 127 |
+
window_size=cfg_d.window_size,
|
| 128 |
+
drift_delta=cfg_dr.delta,
|
| 129 |
+
drift_grace_period=cfg_dr.grace_period,
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _ensure_pipeline_running() -> None:
|
| 134 |
+
global _pipeline_task
|
| 135 |
+
if _pipeline_task is None or _pipeline_task.done():
|
| 136 |
+
_pipeline_task = asyncio.create_task(_run_pipeline_loop())
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
@asynccontextmanager
|
| 140 |
+
async def lifespan(app: FastAPI):
|
| 141 |
+
yield
|
| 142 |
+
global _pipeline_task
|
| 143 |
+
if _pipeline_task is not None and not _pipeline_task.done():
|
| 144 |
+
_pipeline_task.cancel()
|
| 145 |
+
try:
|
| 146 |
+
await _pipeline_task
|
| 147 |
+
except asyncio.CancelledError:
|
| 148 |
+
pass
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
app = FastAPI(title="Real-Time Anomaly Detection", lifespan=lifespan)
|
| 152 |
+
app.add_middleware(
|
| 153 |
+
CORSMiddleware,
|
| 154 |
+
allow_origins=["*"],
|
| 155 |
+
allow_methods=["GET"],
|
| 156 |
+
allow_headers=["*"],
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
| 160 |
+
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
@app.get("/")
|
| 164 |
+
async def root():
|
| 165 |
+
return FileResponse(STATIC_DIR / "index.html")
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
@app.get("/dashboard")
|
| 169 |
+
async def dashboard():
|
| 170 |
+
return FileResponse(STATIC_DIR / "index.html")
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
@app.websocket("/ws")
|
| 174 |
+
async def websocket_endpoint(websocket: WebSocket) -> None:
|
| 175 |
+
await websocket.accept()
|
| 176 |
+
for msg in replay_buffer:
|
| 177 |
+
try:
|
| 178 |
+
await websocket.send_text(json.dumps(msg))
|
| 179 |
+
except Exception:
|
| 180 |
+
break
|
| 181 |
+
manager.register(websocket)
|
| 182 |
+
_ensure_pipeline_running()
|
| 183 |
+
try:
|
| 184 |
+
while True:
|
| 185 |
+
await websocket.receive_text()
|
| 186 |
+
except WebSocketDisconnect:
|
| 187 |
+
pass
|
| 188 |
+
finally:
|
| 189 |
+
manager.disconnect(websocket)
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
@app.get("/stats")
|
| 193 |
+
async def stats():
|
| 194 |
+
return {
|
| 195 |
+
"total_observations": STATE["total_observations"],
|
| 196 |
+
"total_anomalies_detected": STATE["total_anomalies_detected"],
|
| 197 |
+
"total_drift_events": STATE["total_drift_events"],
|
| 198 |
+
"current_precision": STATE["current_precision"],
|
| 199 |
+
"current_recall": STATE["current_recall"],
|
| 200 |
+
"current_f1": STATE["current_f1"],
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
@app.get("/metrics", response_class=PlainTextResponse)
|
| 205 |
+
async def metrics():
|
| 206 |
+
"""Prometheus-compatible metrics endpoint."""
|
| 207 |
+
uptime = time.time() - STATE["cycle_start_time"] if STATE["cycle_start_time"] else 0.0
|
| 208 |
+
lines = [
|
| 209 |
+
"# HELP anomaly_observations_total Total observations processed in current cycle",
|
| 210 |
+
"# TYPE anomaly_observations_total counter",
|
| 211 |
+
f'anomaly_observations_total {STATE["total_observations"]}',
|
| 212 |
+
"",
|
| 213 |
+
"# HELP anomaly_alerts_total Total anomaly alerts fired in current cycle",
|
| 214 |
+
"# TYPE anomaly_alerts_total counter",
|
| 215 |
+
f'anomaly_alerts_total {STATE["total_anomalies_detected"]}',
|
| 216 |
+
"",
|
| 217 |
+
"# HELP anomaly_drift_events_total Total drift events detected in current cycle",
|
| 218 |
+
"# TYPE anomaly_drift_events_total counter",
|
| 219 |
+
f'anomaly_drift_events_total {STATE["total_drift_events"]}',
|
| 220 |
+
"",
|
| 221 |
+
"# HELP anomaly_precision Running precision (alerts that were true anomalies)",
|
| 222 |
+
"# TYPE anomaly_precision gauge",
|
| 223 |
+
f'anomaly_precision {STATE["current_precision"]:.6f}',
|
| 224 |
+
"",
|
| 225 |
+
"# HELP anomaly_recall Running recall (true anomalies that were detected)",
|
| 226 |
+
"# TYPE anomaly_recall gauge",
|
| 227 |
+
f'anomaly_recall {STATE["current_recall"]:.6f}',
|
| 228 |
+
"",
|
| 229 |
+
"# HELP anomaly_f1 Running F1 score",
|
| 230 |
+
"# TYPE anomaly_f1 gauge",
|
| 231 |
+
f'anomaly_f1 {STATE["current_f1"]:.6f}',
|
| 232 |
+
"",
|
| 233 |
+
"# HELP anomaly_cycle_uptime_seconds Seconds since current stream cycle started",
|
| 234 |
+
"# TYPE anomaly_cycle_uptime_seconds gauge",
|
| 235 |
+
f"anomaly_cycle_uptime_seconds {uptime:.3f}",
|
| 236 |
+
"",
|
| 237 |
+
]
|
| 238 |
+
return "\n".join(lines)
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def main() -> None:
|
| 242 |
+
import os
|
| 243 |
+
import uvicorn
|
| 244 |
+
port = int(os.environ.get("PORT", str(settings.server.port)))
|
| 245 |
+
uvicorn.run("src.server.app:app", host="0.0.0.0", port=port, reload=False)
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
if __name__ == "__main__":
|
| 249 |
+
main()
|
src/server/static/dashboard.js
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
(function () {
|
| 2 |
+
'use strict';
|
| 3 |
+
|
| 4 |
+
const HF_SPACE = 'wss://zeetay-anomaly-detection.hf.space';
|
| 5 |
+
const onNetlify = location.hostname.endsWith('.netlify.app') || location.hostname.endsWith('.netlify.com');
|
| 6 |
+
const WS_URL = (onNetlify ? HF_SPACE : (location.protocol === 'https:' ? 'wss:' : 'ws:') + '//' + location.host) + '/ws';
|
| 7 |
+
const MAX_POINTS = 200;
|
| 8 |
+
const THRESHOLD = 0.60;
|
| 9 |
+
|
| 10 |
+
// DOM refs
|
| 11 |
+
const statusEl = document.getElementById('status');
|
| 12 |
+
const phaseBadgeEl = document.getElementById('phase-badge');
|
| 13 |
+
const phaseDescEl = document.getElementById('phase-desc');
|
| 14 |
+
const obsCountEl = document.getElementById('obs-count');
|
| 15 |
+
const totalObsEl = document.getElementById('total-obs');
|
| 16 |
+
const totalAnomaliesEl = document.getElementById('total-anomalies');
|
| 17 |
+
const totalDriftEl = document.getElementById('total-drift');
|
| 18 |
+
const precisionEl = document.getElementById('precision');
|
| 19 |
+
const recallEl = document.getElementById('recall');
|
| 20 |
+
const f1El = document.getElementById('f1');
|
| 21 |
+
|
| 22 |
+
// Rolling buffer of the last MAX_POINTS messages
|
| 23 |
+
const buffer = [];
|
| 24 |
+
|
| 25 |
+
// ── helpers ─────────────────────────────────────────────────────────────────
|
| 26 |
+
|
| 27 |
+
function setConnected(ok) {
|
| 28 |
+
statusEl.textContent = ok ? 'Connected' : 'Disconnected';
|
| 29 |
+
statusEl.className = ok ? 'connected' : 'disconnected';
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function updatePhase(phase) {
|
| 33 |
+
const labels = { A: 'Phase A', B: 'Phase B', C: 'Phase C' };
|
| 34 |
+
const descs = { A: 'building baseline', B: 'sensors drifting', C: 'anomalies active' };
|
| 35 |
+
phaseBadgeEl.textContent = labels[phase] || phase;
|
| 36 |
+
phaseBadgeEl.className = 'phase-badge phase-' + phase;
|
| 37 |
+
if (phaseDescEl) phaseDescEl.textContent = descs[phase] || '';
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
function updateCounters(msg) {
|
| 41 |
+
if (msg.total_anomalies_detected != null) totalAnomaliesEl.textContent = msg.total_anomalies_detected;
|
| 42 |
+
if (msg.total_drift_events != null) totalDriftEl.textContent = msg.total_drift_events;
|
| 43 |
+
const obs = (msg.observation_index != null) ? msg.observation_index + 1 : 0;
|
| 44 |
+
totalObsEl.textContent = obs;
|
| 45 |
+
obsCountEl.textContent = obs + ' observations';
|
| 46 |
+
precisionEl.textContent = (msg.running_precision ?? 0).toFixed(2);
|
| 47 |
+
recallEl.textContent = (msg.running_recall ?? 0).toFixed(2);
|
| 48 |
+
f1El.textContent = (msg.running_f1 ?? 0).toFixed(2);
|
| 49 |
+
if (msg.phase) updatePhase(msg.phase);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
// ── shared drift-line plugin ─────────────────────────────────────────────────
|
| 53 |
+
|
| 54 |
+
function makeDriftPlugin() {
|
| 55 |
+
return {
|
| 56 |
+
id: 'driftLines',
|
| 57 |
+
afterDraw: function (chart) {
|
| 58 |
+
if (!chart.chartArea) return;
|
| 59 |
+
var driftPts = buffer.filter(function (p) { return p.drift_event; });
|
| 60 |
+
if (!driftPts.length) return;
|
| 61 |
+
var xScale = chart.scales && chart.scales.x;
|
| 62 |
+
if (!xScale) return;
|
| 63 |
+
var ctx = chart.ctx;
|
| 64 |
+
var area = chart.chartArea;
|
| 65 |
+
ctx.save();
|
| 66 |
+
ctx.setLineDash([4, 4]);
|
| 67 |
+
ctx.strokeStyle = '#eab308';
|
| 68 |
+
ctx.lineWidth = 1.5;
|
| 69 |
+
driftPts.forEach(function (p) {
|
| 70 |
+
var x = xScale.getPixelForValue(p.index);
|
| 71 |
+
if (x >= area.left && x <= area.right) {
|
| 72 |
+
ctx.beginPath();
|
| 73 |
+
ctx.moveTo(x, area.top);
|
| 74 |
+
ctx.lineTo(x, area.bottom);
|
| 75 |
+
ctx.stroke();
|
| 76 |
+
}
|
| 77 |
+
});
|
| 78 |
+
ctx.restore();
|
| 79 |
+
}
|
| 80 |
+
};
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
// ── sensor chart ─────────────────────────────────────────────────────────────
|
| 84 |
+
|
| 85 |
+
var sensorChart = new Chart(document.getElementById('chart-sensors').getContext('2d'), {
|
| 86 |
+
type: 'line',
|
| 87 |
+
data: {
|
| 88 |
+
labels: [],
|
| 89 |
+
datasets: [
|
| 90 |
+
{ label: 'Temperature', data: [], borderColor: '#38bdf8', backgroundColor: 'transparent', fill: false, tension: 0.15, pointRadius: 0, borderWidth: 2 },
|
| 91 |
+
{ label: 'Pressure', data: [], borderColor: '#a78bfa', backgroundColor: 'transparent', fill: false, tension: 0.15, pointRadius: 0, borderWidth: 2 },
|
| 92 |
+
{ label: 'Vibration', data: [], borderColor: '#34d399', backgroundColor: 'transparent', fill: false, tension: 0.15, pointRadius: 0, borderWidth: 2 }
|
| 93 |
+
]
|
| 94 |
+
},
|
| 95 |
+
options: {
|
| 96 |
+
responsive: true,
|
| 97 |
+
maintainAspectRatio: false,
|
| 98 |
+
interaction: { intersect: false, mode: 'index' },
|
| 99 |
+
animation: false,
|
| 100 |
+
scales: {
|
| 101 |
+
x: {
|
| 102 |
+
display: true,
|
| 103 |
+
title: { display: true, text: 'Observation index', color: '#a1a1aa', font: { family: "'DM Sans', system-ui", size: 11 } },
|
| 104 |
+
grid: { color: 'rgba(255,255,255,0.05)' },
|
| 105 |
+
ticks: { color: '#71717a', maxTicksLimit: 12 }
|
| 106 |
+
},
|
| 107 |
+
y: {
|
| 108 |
+
display: true,
|
| 109 |
+
grid: { color: 'rgba(255,255,255,0.05)' },
|
| 110 |
+
ticks: { color: '#71717a' }
|
| 111 |
+
}
|
| 112 |
+
},
|
| 113 |
+
plugins: {
|
| 114 |
+
legend: {
|
| 115 |
+
display: false
|
| 116 |
+
},
|
| 117 |
+
tooltip: {
|
| 118 |
+
callbacks: {
|
| 119 |
+
afterBody: function (items) {
|
| 120 |
+
var idx = items[0] && items[0].dataIndex;
|
| 121 |
+
var p = buffer[idx];
|
| 122 |
+
if (!p) return '';
|
| 123 |
+
var lines = [];
|
| 124 |
+
if (p.alert) lines.push('Alert: YES');
|
| 125 |
+
if (p.label && p.label !== 'normal') lines.push('Ground truth: ' + p.label.replace('_', ' '));
|
| 126 |
+
return lines;
|
| 127 |
+
}
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
}
|
| 131 |
+
},
|
| 132 |
+
plugins: [makeDriftPlugin()]
|
| 133 |
+
});
|
| 134 |
+
|
| 135 |
+
// ── anomaly score chart ───────────────────────────────────────────────────────
|
| 136 |
+
|
| 137 |
+
var thresholdPlugin = {
|
| 138 |
+
id: 'thresholdLine',
|
| 139 |
+
afterDraw: function (chart) {
|
| 140 |
+
if (!chart.chartArea) return;
|
| 141 |
+
var yScale = chart.scales && chart.scales.y;
|
| 142 |
+
if (!yScale) return;
|
| 143 |
+
var y = yScale.getPixelForValue(THRESHOLD);
|
| 144 |
+
var ctx = chart.ctx;
|
| 145 |
+
var area = chart.chartArea;
|
| 146 |
+
ctx.save();
|
| 147 |
+
ctx.setLineDash([6, 3]);
|
| 148 |
+
ctx.strokeStyle = '#ef4444';
|
| 149 |
+
ctx.lineWidth = 1.5;
|
| 150 |
+
ctx.beginPath();
|
| 151 |
+
ctx.moveTo(area.left, y);
|
| 152 |
+
ctx.lineTo(area.right, y);
|
| 153 |
+
ctx.stroke();
|
| 154 |
+
ctx.restore();
|
| 155 |
+
}
|
| 156 |
+
};
|
| 157 |
+
|
| 158 |
+
var scoreChart = new Chart(document.getElementById('chart-score').getContext('2d'), {
|
| 159 |
+
type: 'line',
|
| 160 |
+
data: {
|
| 161 |
+
labels: [],
|
| 162 |
+
datasets: [
|
| 163 |
+
{
|
| 164 |
+
label: 'Anomaly score',
|
| 165 |
+
data: [],
|
| 166 |
+
borderColor: '#6366f1',
|
| 167 |
+
backgroundColor: 'rgba(99,102,241,0.08)',
|
| 168 |
+
fill: true,
|
| 169 |
+
tension: 0.2,
|
| 170 |
+
pointRadius: 0,
|
| 171 |
+
borderWidth: 1.5
|
| 172 |
+
}
|
| 173 |
+
]
|
| 174 |
+
},
|
| 175 |
+
options: {
|
| 176 |
+
responsive: true,
|
| 177 |
+
maintainAspectRatio: false,
|
| 178 |
+
interaction: { intersect: false, mode: 'index' },
|
| 179 |
+
animation: false,
|
| 180 |
+
scales: {
|
| 181 |
+
x: {
|
| 182 |
+
display: true,
|
| 183 |
+
grid: { color: 'rgba(255,255,255,0.05)' },
|
| 184 |
+
ticks: { color: '#71717a', maxTicksLimit: 12 }
|
| 185 |
+
},
|
| 186 |
+
y: {
|
| 187 |
+
display: true,
|
| 188 |
+
min: 0,
|
| 189 |
+
max: 1,
|
| 190 |
+
grid: { color: 'rgba(255,255,255,0.05)' },
|
| 191 |
+
ticks: { color: '#71717a', stepSize: 0.25 }
|
| 192 |
+
}
|
| 193 |
+
},
|
| 194 |
+
plugins: {
|
| 195 |
+
legend: { display: false }
|
| 196 |
+
}
|
| 197 |
+
},
|
| 198 |
+
plugins: [makeDriftPlugin(), thresholdPlugin]
|
| 199 |
+
});
|
| 200 |
+
|
| 201 |
+
// ── buffer + redraw ───────────────────────────────────────────────────────────
|
| 202 |
+
|
| 203 |
+
function pushPoint(msg) {
|
| 204 |
+
buffer.push({
|
| 205 |
+
index: msg.observation_index,
|
| 206 |
+
temperature: msg.temperature,
|
| 207 |
+
pressure: msg.pressure,
|
| 208 |
+
vibration: msg.vibration,
|
| 209 |
+
anomaly_score: msg.anomaly_score,
|
| 210 |
+
alert: msg.alert,
|
| 211 |
+
drift_event: msg.drift_event,
|
| 212 |
+
label: msg.label,
|
| 213 |
+
phase: msg.phase
|
| 214 |
+
});
|
| 215 |
+
if (buffer.length > MAX_POINTS) buffer.shift();
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
function redrawCharts() {
|
| 219 |
+
if (!buffer.length) return;
|
| 220 |
+
|
| 221 |
+
var labels = buffer.map(function (p) { return p.index; });
|
| 222 |
+
var tempData = buffer.map(function (p) { return p.temperature; });
|
| 223 |
+
var pressData = buffer.map(function (p) { return p.pressure; });
|
| 224 |
+
var vibeData = buffer.map(function (p) { return p.vibration; });
|
| 225 |
+
var scoreData = buffer.map(function (p) { return p.anomaly_score; });
|
| 226 |
+
|
| 227 |
+
var alertRadius = buffer.map(function (p) { return p.alert ? 5 : 0; });
|
| 228 |
+
var alertColor = buffer.map(function (p) { return p.alert ? '#ef4444' : 'transparent'; });
|
| 229 |
+
|
| 230 |
+
// Sensor chart
|
| 231 |
+
sensorChart.data.labels = labels;
|
| 232 |
+
sensorChart.data.datasets[0].data = tempData;
|
| 233 |
+
sensorChart.data.datasets[1].data = pressData;
|
| 234 |
+
sensorChart.data.datasets[2].data = vibeData;
|
| 235 |
+
[0, 1, 2].forEach(function (i) {
|
| 236 |
+
sensorChart.data.datasets[i].pointRadius = alertRadius;
|
| 237 |
+
sensorChart.data.datasets[i].pointBackgroundColor = alertColor;
|
| 238 |
+
sensorChart.data.datasets[i].pointBorderColor = alertColor;
|
| 239 |
+
});
|
| 240 |
+
sensorChart.update('none');
|
| 241 |
+
|
| 242 |
+
// Score chart
|
| 243 |
+
scoreChart.data.labels = labels;
|
| 244 |
+
scoreChart.data.datasets[0].data = scoreData;
|
| 245 |
+
scoreChart.data.datasets[0].pointRadius = alertRadius;
|
| 246 |
+
scoreChart.data.datasets[0].pointBackgroundColor = alertColor;
|
| 247 |
+
scoreChart.update('none');
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
// ── WebSocket ─────────────────────────────────────────────────────────────────
|
| 251 |
+
|
| 252 |
+
function connect() {
|
| 253 |
+
var ws = new WebSocket(WS_URL);
|
| 254 |
+
|
| 255 |
+
ws.onopen = function () { setConnected(true); };
|
| 256 |
+
ws.onclose = function () { setConnected(false); setTimeout(connect, 2000); };
|
| 257 |
+
ws.onerror = function () { setConnected(false); };
|
| 258 |
+
|
| 259 |
+
ws.onmessage = function (ev) {
|
| 260 |
+
try {
|
| 261 |
+
var msg = JSON.parse(ev.data);
|
| 262 |
+
updateCounters(msg);
|
| 263 |
+
pushPoint(msg);
|
| 264 |
+
redrawCharts();
|
| 265 |
+
} catch (e) { /* ignore parse errors */ }
|
| 266 |
+
};
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
// ── accordion ────────────────────────────────────────────────────────────────
|
| 270 |
+
|
| 271 |
+
(function () {
|
| 272 |
+
var section = document.getElementById('explainer');
|
| 273 |
+
var toggle = document.getElementById('explainer-toggle');
|
| 274 |
+
if (section && toggle) {
|
| 275 |
+
toggle.addEventListener('click', function () { section.classList.toggle('open'); });
|
| 276 |
+
}
|
| 277 |
+
})();
|
| 278 |
+
|
| 279 |
+
connect();
|
| 280 |
+
})();
|
src/server/static/index.html
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
+
<title>Real-Time Anomaly Detection</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&display=swap" rel="stylesheet" />
|
| 10 |
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
| 11 |
+
<style>
|
| 12 |
+
:root {
|
| 13 |
+
--bg: #0c0c0f;
|
| 14 |
+
--surface: #141419;
|
| 15 |
+
--surface-elevated: #1a1a21;
|
| 16 |
+
--border: rgba(255, 255, 255, 0.06);
|
| 17 |
+
--text: #e4e4e7;
|
| 18 |
+
--text-muted: #a1a1aa;
|
| 19 |
+
--accent: #22c55e;
|
| 20 |
+
--accent-dim: rgba(34, 197, 94, 0.15);
|
| 21 |
+
--danger: #ef4444;
|
| 22 |
+
--danger-dim: rgba(239, 68, 68, 0.12);
|
| 23 |
+
--warn: #eab308;
|
| 24 |
+
--warn-dim: rgba(234, 179, 8, 0.12);
|
| 25 |
+
--info: #38bdf8;
|
| 26 |
+
--info-dim: rgba(56, 189, 248, 0.12);
|
| 27 |
+
--chart-temp: #38bdf8;
|
| 28 |
+
--chart-pressure: #a78bfa;
|
| 29 |
+
--chart-vibe: #34d399;
|
| 30 |
+
--radius: 12px;
|
| 31 |
+
--radius-sm: 8px;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
* { box-sizing: border-box; }
|
| 35 |
+
body {
|
| 36 |
+
font-family: 'DM Sans', system-ui, sans-serif;
|
| 37 |
+
margin: 0;
|
| 38 |
+
padding: 0;
|
| 39 |
+
background: var(--bg);
|
| 40 |
+
color: var(--text);
|
| 41 |
+
min-height: 100vh;
|
| 42 |
+
line-height: 1.5;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
.layout {
|
| 46 |
+
max-width: 1080px;
|
| 47 |
+
margin: 0 auto;
|
| 48 |
+
padding: 2rem 1.5rem 3rem;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
header { margin-bottom: 1.5rem; }
|
| 52 |
+
h1 {
|
| 53 |
+
font-size: 1.75rem;
|
| 54 |
+
font-weight: 700;
|
| 55 |
+
letter-spacing: -0.02em;
|
| 56 |
+
margin: 0 0 0.35rem 0;
|
| 57 |
+
color: var(--text);
|
| 58 |
+
}
|
| 59 |
+
.intro {
|
| 60 |
+
font-size: 0.9375rem;
|
| 61 |
+
color: var(--text-muted);
|
| 62 |
+
margin: 0;
|
| 63 |
+
max-width: 60ch;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
/* Status + phase row */
|
| 67 |
+
.status-row {
|
| 68 |
+
display: flex;
|
| 69 |
+
align-items: center;
|
| 70 |
+
gap: 0.625rem;
|
| 71 |
+
margin-bottom: 1.5rem;
|
| 72 |
+
flex-wrap: wrap;
|
| 73 |
+
}
|
| 74 |
+
#status {
|
| 75 |
+
font-size: 0.8125rem;
|
| 76 |
+
font-weight: 500;
|
| 77 |
+
padding: 0.3rem 0.75rem;
|
| 78 |
+
border-radius: 999px;
|
| 79 |
+
display: inline-flex;
|
| 80 |
+
align-items: center;
|
| 81 |
+
gap: 0.4rem;
|
| 82 |
+
}
|
| 83 |
+
#status::before {
|
| 84 |
+
content: '';
|
| 85 |
+
width: 6px;
|
| 86 |
+
height: 6px;
|
| 87 |
+
border-radius: 50%;
|
| 88 |
+
background: currentColor;
|
| 89 |
+
}
|
| 90 |
+
#status.connected { color: var(--accent); background: var(--accent-dim); }
|
| 91 |
+
#status.disconnected { color: var(--danger); background: var(--danger-dim); }
|
| 92 |
+
|
| 93 |
+
.phase-badge {
|
| 94 |
+
font-size: 0.8125rem;
|
| 95 |
+
font-weight: 600;
|
| 96 |
+
padding: 0.3rem 0.75rem;
|
| 97 |
+
border-radius: 999px;
|
| 98 |
+
display: inline-flex;
|
| 99 |
+
align-items: center;
|
| 100 |
+
gap: 0.35rem;
|
| 101 |
+
transition: background 0.25s, color 0.25s;
|
| 102 |
+
}
|
| 103 |
+
.phase-badge.phase-A { color: var(--accent); background: var(--accent-dim); }
|
| 104 |
+
.phase-badge.phase-B { color: var(--warn); background: var(--warn-dim); }
|
| 105 |
+
.phase-badge.phase-C { color: var(--danger); background: var(--danger-dim); }
|
| 106 |
+
|
| 107 |
+
.phase-desc {
|
| 108 |
+
font-size: 0.8125rem;
|
| 109 |
+
color: var(--text-muted);
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
.obs-count {
|
| 113 |
+
margin-left: auto;
|
| 114 |
+
font-size: 0.8125rem;
|
| 115 |
+
color: var(--text-muted);
|
| 116 |
+
font-variant-numeric: tabular-nums;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
/* Counter grids */
|
| 120 |
+
.counters-section { margin-bottom: 1.25rem; }
|
| 121 |
+
.counters-label {
|
| 122 |
+
font-size: 0.6875rem;
|
| 123 |
+
font-weight: 600;
|
| 124 |
+
text-transform: uppercase;
|
| 125 |
+
letter-spacing: 0.07em;
|
| 126 |
+
color: var(--text-muted);
|
| 127 |
+
margin: 0 0 0.5rem;
|
| 128 |
+
}
|
| 129 |
+
.counters {
|
| 130 |
+
display: grid;
|
| 131 |
+
gap: 0.875rem;
|
| 132 |
+
}
|
| 133 |
+
.counters.counts { grid-template-columns: repeat(3, 1fr); }
|
| 134 |
+
.counters.metrics { grid-template-columns: repeat(3, 1fr); }
|
| 135 |
+
@media (max-width: 600px) {
|
| 136 |
+
.counters.counts, .counters.metrics { grid-template-columns: repeat(2, 1fr); }
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
.counter {
|
| 140 |
+
background: var(--surface);
|
| 141 |
+
border: 1px solid var(--border);
|
| 142 |
+
border-radius: var(--radius);
|
| 143 |
+
padding: 1rem 1.25rem;
|
| 144 |
+
cursor: help;
|
| 145 |
+
transition: border-color 0.15s, background 0.15s;
|
| 146 |
+
}
|
| 147 |
+
.counter:hover {
|
| 148 |
+
background: var(--surface-elevated);
|
| 149 |
+
border-color: rgba(255, 255, 255, 0.09);
|
| 150 |
+
}
|
| 151 |
+
.counter label {
|
| 152 |
+
display: block;
|
| 153 |
+
font-size: 0.6875rem;
|
| 154 |
+
font-weight: 600;
|
| 155 |
+
text-transform: uppercase;
|
| 156 |
+
letter-spacing: 0.06em;
|
| 157 |
+
color: var(--text-muted);
|
| 158 |
+
margin-bottom: 0.35rem;
|
| 159 |
+
}
|
| 160 |
+
.counter .value {
|
| 161 |
+
font-size: 1.5rem;
|
| 162 |
+
font-weight: 700;
|
| 163 |
+
letter-spacing: -0.02em;
|
| 164 |
+
color: var(--text);
|
| 165 |
+
font-variant-numeric: tabular-nums;
|
| 166 |
+
}
|
| 167 |
+
.counter .sub {
|
| 168 |
+
font-size: 0.75rem;
|
| 169 |
+
color: var(--text-muted);
|
| 170 |
+
margin-top: 0.2rem;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
/* Charts */
|
| 174 |
+
.chart-section { margin-bottom: 1.25rem; }
|
| 175 |
+
.chart-card {
|
| 176 |
+
background: var(--surface);
|
| 177 |
+
border: 1px solid var(--border);
|
| 178 |
+
border-radius: var(--radius);
|
| 179 |
+
padding: 1.25rem 1.25rem 1rem;
|
| 180 |
+
}
|
| 181 |
+
.chart-title {
|
| 182 |
+
font-size: 0.8125rem;
|
| 183 |
+
font-weight: 600;
|
| 184 |
+
color: var(--text-muted);
|
| 185 |
+
text-transform: uppercase;
|
| 186 |
+
letter-spacing: 0.06em;
|
| 187 |
+
margin: 0 0 0.75rem;
|
| 188 |
+
}
|
| 189 |
+
.chart-wrap { position: relative; }
|
| 190 |
+
.chart-wrap.sensors { height: 320px; }
|
| 191 |
+
.chart-wrap.scores { height: 160px; }
|
| 192 |
+
.chart-caption {
|
| 193 |
+
font-size: 0.8125rem;
|
| 194 |
+
color: var(--text-muted);
|
| 195 |
+
margin: 0.625rem 0 0;
|
| 196 |
+
line-height: 1.5;
|
| 197 |
+
}
|
| 198 |
+
.chart-caption span { font-weight: 500; }
|
| 199 |
+
|
| 200 |
+
/* Legend pill for ground truth */
|
| 201 |
+
.legend-row {
|
| 202 |
+
display: flex;
|
| 203 |
+
flex-wrap: wrap;
|
| 204 |
+
gap: 0.75rem;
|
| 205 |
+
margin-bottom: 0.75rem;
|
| 206 |
+
}
|
| 207 |
+
.legend-item {
|
| 208 |
+
display: flex;
|
| 209 |
+
align-items: center;
|
| 210 |
+
gap: 0.35rem;
|
| 211 |
+
font-size: 0.8rem;
|
| 212 |
+
color: var(--text-muted);
|
| 213 |
+
}
|
| 214 |
+
.legend-dot {
|
| 215 |
+
width: 8px; height: 8px; border-radius: 50%;
|
| 216 |
+
flex-shrink: 0;
|
| 217 |
+
}
|
| 218 |
+
.legend-dash {
|
| 219 |
+
width: 18px; height: 2px; flex-shrink: 0;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
/* Explainer accordion */
|
| 223 |
+
.explainer {
|
| 224 |
+
border: 1px solid var(--border);
|
| 225 |
+
border-radius: var(--radius);
|
| 226 |
+
overflow: hidden;
|
| 227 |
+
background: var(--surface);
|
| 228 |
+
}
|
| 229 |
+
.explainer-header {
|
| 230 |
+
padding: 0.875rem 1.25rem;
|
| 231 |
+
font-weight: 600;
|
| 232 |
+
font-size: 0.9375rem;
|
| 233 |
+
cursor: pointer;
|
| 234 |
+
user-select: none;
|
| 235 |
+
color: var(--text);
|
| 236 |
+
transition: background 0.15s;
|
| 237 |
+
display: flex;
|
| 238 |
+
align-items: center;
|
| 239 |
+
justify-content: space-between;
|
| 240 |
+
}
|
| 241 |
+
.explainer-header:hover { background: var(--surface-elevated); }
|
| 242 |
+
.explainer-header .chevron {
|
| 243 |
+
font-size: 0.75rem;
|
| 244 |
+
color: var(--text-muted);
|
| 245 |
+
transition: transform 0.2s;
|
| 246 |
+
}
|
| 247 |
+
.explainer.open .explainer-header .chevron { transform: rotate(180deg); }
|
| 248 |
+
.explainer-body {
|
| 249 |
+
display: none;
|
| 250 |
+
padding: 0 1.25rem 1.25rem;
|
| 251 |
+
}
|
| 252 |
+
.explainer.open .explainer-body { display: block; }
|
| 253 |
+
.explainer-body p {
|
| 254 |
+
font-size: 0.9375rem;
|
| 255 |
+
color: var(--text-muted);
|
| 256 |
+
line-height: 1.65;
|
| 257 |
+
margin: 0.75rem 0 0;
|
| 258 |
+
}
|
| 259 |
+
.explainer-body strong { color: var(--text); }
|
| 260 |
+
.explainer-body ul {
|
| 261 |
+
margin: 0.5rem 0 0;
|
| 262 |
+
padding-left: 1.25rem;
|
| 263 |
+
}
|
| 264 |
+
.explainer-body li {
|
| 265 |
+
font-size: 0.9375rem;
|
| 266 |
+
color: var(--text-muted);
|
| 267 |
+
margin-bottom: 0.5rem;
|
| 268 |
+
line-height: 1.6;
|
| 269 |
+
}
|
| 270 |
+
.explainer-body .algo-grid {
|
| 271 |
+
display: grid;
|
| 272 |
+
grid-template-columns: 1fr 1fr;
|
| 273 |
+
gap: 1rem;
|
| 274 |
+
margin-top: 0.875rem;
|
| 275 |
+
}
|
| 276 |
+
@media (max-width: 600px) { .explainer-body .algo-grid { grid-template-columns: 1fr; } }
|
| 277 |
+
.algo-card {
|
| 278 |
+
background: var(--surface-elevated);
|
| 279 |
+
border: 1px solid var(--border);
|
| 280 |
+
border-radius: var(--radius-sm);
|
| 281 |
+
padding: 0.875rem 1rem;
|
| 282 |
+
}
|
| 283 |
+
.algo-card h4 {
|
| 284 |
+
margin: 0 0 0.35rem;
|
| 285 |
+
font-size: 0.875rem;
|
| 286 |
+
font-weight: 600;
|
| 287 |
+
color: var(--text);
|
| 288 |
+
}
|
| 289 |
+
.algo-card p {
|
| 290 |
+
margin: 0;
|
| 291 |
+
font-size: 0.8375rem;
|
| 292 |
+
color: var(--text-muted);
|
| 293 |
+
line-height: 1.55;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
.divider {
|
| 297 |
+
border: none;
|
| 298 |
+
border-top: 1px solid var(--border);
|
| 299 |
+
margin: 1.25rem 0;
|
| 300 |
+
}
|
| 301 |
+
</style>
|
| 302 |
+
</head>
|
| 303 |
+
<body>
|
| 304 |
+
<div class="layout">
|
| 305 |
+
<header>
|
| 306 |
+
<h1>Real-Time Anomaly Detection</h1>
|
| 307 |
+
<p class="intro">Live sensor stream scored observation by observation. Tracks precision, recall, and F1 against ground truth in real time.</p>
|
| 308 |
+
</header>
|
| 309 |
+
|
| 310 |
+
<div class="status-row">
|
| 311 |
+
<div id="status" class="disconnected">Disconnected</div>
|
| 312 |
+
<div id="phase-badge" class="phase-badge phase-A">Phase A</div>
|
| 313 |
+
<span id="phase-desc" class="phase-desc">building baseline</span>
|
| 314 |
+
<span class="obs-count" id="obs-count">0 observations</span>
|
| 315 |
+
</div>
|
| 316 |
+
|
| 317 |
+
<!-- Event counters -->
|
| 318 |
+
<div class="counters-section">
|
| 319 |
+
<p class="counters-label">Event counts, current cycle</p>
|
| 320 |
+
<div class="counters counts">
|
| 321 |
+
<div class="counter" title="Observations processed since last stream reset.">
|
| 322 |
+
<label>Observations</label>
|
| 323 |
+
<span class="value" id="total-obs">0</span>
|
| 324 |
+
</div>
|
| 325 |
+
<div class="counter" title="Number of times the anomaly score exceeded the detection threshold.">
|
| 326 |
+
<label>Anomalies detected</label>
|
| 327 |
+
<span class="value" id="total-anomalies">0</span>
|
| 328 |
+
</div>
|
| 329 |
+
<div class="counter" title="Times ADWIN detected a shift in the sensor drift signal.">
|
| 330 |
+
<label>Drift events</label>
|
| 331 |
+
<span class="value" id="total-drift">0</span>
|
| 332 |
+
</div>
|
| 333 |
+
</div>
|
| 334 |
+
</div>
|
| 335 |
+
|
| 336 |
+
<!-- Detection metrics -->
|
| 337 |
+
<div class="counters-section">
|
| 338 |
+
<p class="counters-label">Detection metrics vs ground truth, current cycle</p>
|
| 339 |
+
<div class="counters metrics">
|
| 340 |
+
<div class="counter" title="Of all alerts raised, how many were genuine anomalies.">
|
| 341 |
+
<label>Precision</label>
|
| 342 |
+
<span class="value" id="precision">0.00</span>
|
| 343 |
+
<p class="sub">alerts that were real</p>
|
| 344 |
+
</div>
|
| 345 |
+
<div class="counter" title="Of all true anomalies injected, how many were caught.">
|
| 346 |
+
<label>Recall</label>
|
| 347 |
+
<span class="value" id="recall">0.00</span>
|
| 348 |
+
<p class="sub">anomalies caught</p>
|
| 349 |
+
</div>
|
| 350 |
+
<div class="counter" title="Harmonic mean of precision and recall.">
|
| 351 |
+
<label>F1 Score</label>
|
| 352 |
+
<span class="value" id="f1">0.00</span>
|
| 353 |
+
<p class="sub">harmonic mean P&R</p>
|
| 354 |
+
</div>
|
| 355 |
+
</div>
|
| 356 |
+
</div>
|
| 357 |
+
|
| 358 |
+
<!-- Sensor chart -->
|
| 359 |
+
<div class="chart-section">
|
| 360 |
+
<div class="chart-card">
|
| 361 |
+
<p class="chart-title">Sensor readings, last 200 observations</p>
|
| 362 |
+
<div class="legend-row">
|
| 363 |
+
<span class="legend-item"><span class="legend-dot" style="background:#38bdf8"></span>Temperature</span>
|
| 364 |
+
<span class="legend-item"><span class="legend-dot" style="background:#a78bfa"></span>Pressure</span>
|
| 365 |
+
<span class="legend-item"><span class="legend-dot" style="background:#34d399"></span>Vibration</span>
|
| 366 |
+
<span class="legend-item"><span class="legend-dot" style="background:#ef4444"></span>Alert (score > threshold)</span>
|
| 367 |
+
<span class="legend-item"><span class="legend-dash" style="background:#eab308"></span>Drift event</span>
|
| 368 |
+
</div>
|
| 369 |
+
<div class="chart-wrap sensors">
|
| 370 |
+
<canvas id="chart-sensors"></canvas>
|
| 371 |
+
</div>
|
| 372 |
+
</div>
|
| 373 |
+
</div>
|
| 374 |
+
|
| 375 |
+
<!-- Anomaly score chart -->
|
| 376 |
+
<div class="chart-section">
|
| 377 |
+
<div class="chart-card">
|
| 378 |
+
<p class="chart-title">Anomaly score, last 200 observations</p>
|
| 379 |
+
<div class="chart-wrap scores">
|
| 380 |
+
<canvas id="chart-score"></canvas>
|
| 381 |
+
</div>
|
| 382 |
+
<p class="chart-caption">
|
| 383 |
+
Mahalanobis distance from the Phase A baseline, scaled to [0, 1).
|
| 384 |
+
<span style="color:var(--danger)">Red dashed line</span> = detection threshold (0.60).
|
| 385 |
+
<span style="color:var(--warn)">Yellow lines</span> = ADWIN drift events.
|
| 386 |
+
</p>
|
| 387 |
+
</div>
|
| 388 |
+
</div>
|
| 389 |
+
|
| 390 |
+
<!-- Algorithm explainer -->
|
| 391 |
+
<div class="explainer" id="explainer">
|
| 392 |
+
<div class="explainer-header" id="explainer-toggle">
|
| 393 |
+
How it works <span class="chevron">▼</span>
|
| 394 |
+
</div>
|
| 395 |
+
<div class="explainer-body">
|
| 396 |
+
<p>
|
| 397 |
+
Each observation is scored exactly once as it arrives. No batching, no retraining.
|
| 398 |
+
</p>
|
| 399 |
+
|
| 400 |
+
<div class="algo-grid">
|
| 401 |
+
<div class="algo-card">
|
| 402 |
+
<h4>Mahalanobis distance (anomaly scorer)</h4>
|
| 403 |
+
<p>During Phase A, the detector fits a multivariate Gaussian to the sensor readings. After Phase A, that baseline is frozen. Every new observation is scored as its Mahalanobis distance from the Phase A mean, using the full covariance matrix. This catches both large single-sensor spikes and unusual sensor combinations, like high temperature paired with low pressure when the two normally move together.</p>
|
| 404 |
+
</div>
|
| 405 |
+
<div class="algo-card">
|
| 406 |
+
<h4>ADWIN (drift detector)</h4>
|
| 407 |
+
<p>Watches a normalized average of all three sensor readings. When the distribution of that signal shifts in Phase B, ADWIN fires. The anomaly baseline is not reset since it was calibrated on the pre-drift data. Drift events are logged and shown as yellow markers on the charts.</p>
|
| 408 |
+
</div>
|
| 409 |
+
</div>
|
| 410 |
+
|
| 411 |
+
<p><strong>Stream phases</strong></p>
|
| 412 |
+
<ul>
|
| 413 |
+
<li><strong>Phase A (300 obs):</strong> Correlated Gaussian noise across three sensors, shared latent factor (weight 0.7). The detector fits the baseline here. Scores are 0 during this phase.</li>
|
| 414 |
+
<li><strong>Phase B (200 obs):</strong> All sensor means shift linearly. ADWIN fires when it detects the sustained shift.</li>
|
| 415 |
+
<li><strong>Phase C (300 obs):</strong> Anomalies are injected at ~8% rate. Two types: <em>point anomalies</em> (3-5σ spikes in one or more sensors) and <em>contextual anomalies</em> (high temp + low pressure, unusual given the positive correlation in normal data). Precision, recall, and F1 are measured here against ground truth labels.</li>
|
| 416 |
+
</ul>
|
| 417 |
+
|
| 418 |
+
<p>
|
| 419 |
+
The <code>/stats</code> and <code>/metrics</code> endpoints expose all counters as JSON and Prometheus text respectively.
|
| 420 |
+
</p>
|
| 421 |
+
</div>
|
| 422 |
+
</div>
|
| 423 |
+
</div>
|
| 424 |
+
<script src="/static/dashboard.js"></script>
|
| 425 |
+
</body>
|
| 426 |
+
</html>
|
src/stream/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from src.stream.models import Observation, Label
|
| 2 |
+
from src.stream.generator import generate_stream
|
| 3 |
+
|
| 4 |
+
__all__ = ["Observation", "Label", "generate_stream"]
|
src/stream/generator.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Synthetic multivariate stream generator with ground truth labels."""
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import itertools
|
| 5 |
+
import random
|
| 6 |
+
from collections.abc import AsyncIterator, Iterator
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from src.stream.models import Observation, Label
|
| 10 |
+
|
| 11 |
+
# Baseline means for Phase A
|
| 12 |
+
BASELINE_TEMP = 25.0
|
| 13 |
+
BASELINE_PRESSURE = 101.3
|
| 14 |
+
BASELINE_VIBRATION = 0.5
|
| 15 |
+
# Standard deviation for correlated noise
|
| 16 |
+
NOISE_SIGMA = 0.5
|
| 17 |
+
# Correlation: shared latent factor weight
|
| 18 |
+
LATENT_WEIGHT = 0.7
|
| 19 |
+
INDEPENDENT_WEIGHT = (1 - LATENT_WEIGHT**2) ** 0.5
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _correlated_noise(rng: random.Random) -> tuple[float, float, float]:
|
| 23 |
+
"""Generate correlated noise: one latent factor, then map to three sensors."""
|
| 24 |
+
z = rng.gauss(0, NOISE_SIGMA)
|
| 25 |
+
eps_t = rng.gauss(0, NOISE_SIGMA * INDEPENDENT_WEIGHT)
|
| 26 |
+
eps_p = rng.gauss(0, NOISE_SIGMA * INDEPENDENT_WEIGHT)
|
| 27 |
+
eps_v = rng.gauss(0, NOISE_SIGMA * INDEPENDENT_WEIGHT)
|
| 28 |
+
return (
|
| 29 |
+
LATENT_WEIGHT * z + eps_t,
|
| 30 |
+
LATENT_WEIGHT * z + eps_p,
|
| 31 |
+
LATENT_WEIGHT * z + eps_v,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _generate_sync(
|
| 36 |
+
*,
|
| 37 |
+
phase_a_length: int = 300,
|
| 38 |
+
phase_b_length: int = 200,
|
| 39 |
+
phase_c_length: int = 300,
|
| 40 |
+
drift_magnitude: float = 0.3,
|
| 41 |
+
anomaly_rate: float = 0.05,
|
| 42 |
+
point_ratio: float = 0.6,
|
| 43 |
+
seed: int = 42,
|
| 44 |
+
) -> Iterator[Observation]:
|
| 45 |
+
rng = random.Random(seed)
|
| 46 |
+
t = 0.0
|
| 47 |
+
# Phase A: normal baseline
|
| 48 |
+
for i in range(phase_a_length):
|
| 49 |
+
nt, np_, nv = _correlated_noise(rng)
|
| 50 |
+
yield Observation(
|
| 51 |
+
timestamp=t,
|
| 52 |
+
temperature=BASELINE_TEMP + nt,
|
| 53 |
+
pressure=BASELINE_PRESSURE + np_,
|
| 54 |
+
vibration=BASELINE_VIBRATION + nv,
|
| 55 |
+
label="normal",
|
| 56 |
+
phase="A",
|
| 57 |
+
)
|
| 58 |
+
t += 1.0
|
| 59 |
+
|
| 60 |
+
# Phase B: linear drift
|
| 61 |
+
for i in range(phase_b_length):
|
| 62 |
+
alpha = (i + 1) / phase_b_length
|
| 63 |
+
mt = BASELINE_TEMP + drift_magnitude * alpha
|
| 64 |
+
mp = BASELINE_PRESSURE + drift_magnitude * alpha
|
| 65 |
+
mv = BASELINE_VIBRATION + drift_magnitude * alpha
|
| 66 |
+
nt, np_, nv = _correlated_noise(rng)
|
| 67 |
+
yield Observation(
|
| 68 |
+
timestamp=t,
|
| 69 |
+
temperature=mt + nt,
|
| 70 |
+
pressure=mp + np_,
|
| 71 |
+
vibration=mv + nv,
|
| 72 |
+
label="normal",
|
| 73 |
+
phase="B",
|
| 74 |
+
)
|
| 75 |
+
t += 1.0
|
| 76 |
+
|
| 77 |
+
# Post-drift means
|
| 78 |
+
end_temp = BASELINE_TEMP + drift_magnitude
|
| 79 |
+
end_pressure = BASELINE_PRESSURE + drift_magnitude
|
| 80 |
+
end_vibration = BASELINE_VIBRATION + drift_magnitude
|
| 81 |
+
|
| 82 |
+
# Phase C: post-drift + anomalies
|
| 83 |
+
# Anomaly injection: every ~1/anomaly_rate observations on average
|
| 84 |
+
obs_count = 0
|
| 85 |
+
anomaly_choice = itertools.cycle(["point_anomaly", "contextual_anomaly"])
|
| 86 |
+
next_anomaly_step = max(1, int(rng.expovariate(anomaly_rate)))
|
| 87 |
+
|
| 88 |
+
for i in range(phase_c_length):
|
| 89 |
+
obs_count += 1
|
| 90 |
+
nt, np_, nv = _correlated_noise(rng)
|
| 91 |
+
|
| 92 |
+
if obs_count >= next_anomaly_step:
|
| 93 |
+
# Inject anomaly; pick type by point_ratio
|
| 94 |
+
if rng.random() < point_ratio:
|
| 95 |
+
label: Label = "point_anomaly"
|
| 96 |
+
# Spike in one or more sensors (3-5 sigma)
|
| 97 |
+
spike = rng.uniform(3.0, 5.0) * NOISE_SIGMA
|
| 98 |
+
which = rng.sample([0, 1, 2], k=rng.randint(1, 3))
|
| 99 |
+
if 0 in which:
|
| 100 |
+
nt += spike * rng.choice([-1, 1])
|
| 101 |
+
if 1 in which:
|
| 102 |
+
np_ += spike * rng.choice([-1, 1])
|
| 103 |
+
if 2 in which:
|
| 104 |
+
nv += spike * rng.choice([-1, 1])
|
| 105 |
+
else:
|
| 106 |
+
label = "contextual_anomaly"
|
| 107 |
+
# High temp + low pressure, opposite to the normal positive correlation.
|
| 108 |
+
# Max per-feature z-score ~2.4, below the 2.5 Z-Score cutoff.
|
| 109 |
+
nt = 1.8 * NOISE_SIGMA
|
| 110 |
+
np_ = -1.8 * NOISE_SIGMA
|
| 111 |
+
nv = 0.0
|
| 112 |
+
next_anomaly_step = obs_count + max(1, int(rng.expovariate(anomaly_rate)))
|
| 113 |
+
else:
|
| 114 |
+
label = "normal"
|
| 115 |
+
|
| 116 |
+
yield Observation(
|
| 117 |
+
timestamp=t,
|
| 118 |
+
temperature=end_temp + nt,
|
| 119 |
+
pressure=end_pressure + np_,
|
| 120 |
+
vibration=end_vibration + nv,
|
| 121 |
+
label=label,
|
| 122 |
+
phase="C",
|
| 123 |
+
)
|
| 124 |
+
t += 1.0
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
async def generate_stream(
|
| 128 |
+
*,
|
| 129 |
+
phase_a_length: int = 300,
|
| 130 |
+
phase_b_length: int = 200,
|
| 131 |
+
phase_c_length: int = 300,
|
| 132 |
+
drift_magnitude: float = 0.3,
|
| 133 |
+
anomaly_rate: float = 0.05,
|
| 134 |
+
point_ratio: float = 0.6,
|
| 135 |
+
delay: float = 0.0,
|
| 136 |
+
seed: int = 42,
|
| 137 |
+
) -> AsyncIterator[Observation]:
|
| 138 |
+
"""Async generator yielding one observation at a time with optional delay."""
|
| 139 |
+
sync_gen = _generate_sync(
|
| 140 |
+
phase_a_length=phase_a_length,
|
| 141 |
+
phase_b_length=phase_b_length,
|
| 142 |
+
phase_c_length=phase_c_length,
|
| 143 |
+
drift_magnitude=drift_magnitude,
|
| 144 |
+
anomaly_rate=anomaly_rate,
|
| 145 |
+
point_ratio=point_ratio,
|
| 146 |
+
seed=seed,
|
| 147 |
+
)
|
| 148 |
+
for obs in sync_gen:
|
| 149 |
+
if delay > 0:
|
| 150 |
+
await asyncio.sleep(delay)
|
| 151 |
+
yield obs
|
src/stream/models.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Data models for the synthetic stream."""
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import Literal
|
| 5 |
+
|
| 6 |
+
Label = Literal["normal", "point_anomaly", "contextual_anomaly"]
|
| 7 |
+
Phase = Literal["A", "B", "C"]
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@dataclass(frozen=True)
|
| 11 |
+
class Observation:
|
| 12 |
+
"""A single multivariate sensor observation with ground truth label and stream phase."""
|
| 13 |
+
|
| 14 |
+
timestamp: float
|
| 15 |
+
temperature: float
|
| 16 |
+
pressure: float
|
| 17 |
+
vibration: float
|
| 18 |
+
label: Label
|
| 19 |
+
phase: Phase = "A"
|
tests/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Tests for Real-Time Anomaly Detection
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared pytest fixtures."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
|
| 5 |
+
from src.stream.generator import generate_stream
|
| 6 |
+
from src.stream.models import Observation
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@pytest.fixture
|
| 10 |
+
def stream_params_short():
|
| 11 |
+
"""Short phase lengths for fast tests."""
|
| 12 |
+
return {
|
| 13 |
+
"phase_a_length": 100,
|
| 14 |
+
"phase_b_length": 80,
|
| 15 |
+
"phase_c_length": 100,
|
| 16 |
+
"drift_magnitude": 0.3,
|
| 17 |
+
"anomaly_rate": 0.08,
|
| 18 |
+
"point_ratio": 0.6,
|
| 19 |
+
"delay": 0.0,
|
| 20 |
+
"seed": 42,
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@pytest.fixture
|
| 25 |
+
def stream_params_phase_a_only():
|
| 26 |
+
"""Phase A only for normality tests."""
|
| 27 |
+
return {
|
| 28 |
+
"phase_a_length": 200,
|
| 29 |
+
"phase_b_length": 0,
|
| 30 |
+
"phase_c_length": 0,
|
| 31 |
+
"drift_magnitude": 0.3,
|
| 32 |
+
"anomaly_rate": 0.05,
|
| 33 |
+
"point_ratio": 0.6,
|
| 34 |
+
"delay": 0.0,
|
| 35 |
+
"seed": 123,
|
| 36 |
+
}
|
tests/test_baselines.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the offline baseline comparison script."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
|
| 5 |
+
from scripts.compare_baselines import run_comparison
|
| 6 |
+
|
| 7 |
+
ONLINE_KEY = "Mahalanobis + ADWIN (online)"
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_comparison_returns_three_methods():
|
| 11 |
+
"""run_comparison returns results for all three methods."""
|
| 12 |
+
results = run_comparison(seed=42, verbose=False)
|
| 13 |
+
assert "Z-Score (max |z|>2.5)" in results
|
| 14 |
+
assert "Isolation Forest (batch)" in results
|
| 15 |
+
assert ONLINE_KEY in results
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def test_all_metrics_in_valid_range():
|
| 19 |
+
"""All returned metrics are floats in [0, 1] (or nan for AUC edge cases)."""
|
| 20 |
+
results = run_comparison(seed=42, verbose=False)
|
| 21 |
+
for name, m in results.items():
|
| 22 |
+
for key in ("precision", "recall", "f1"):
|
| 23 |
+
assert 0.0 <= m[key] <= 1.0, f"{name} {key}={m[key]} out of range"
|
| 24 |
+
if m["roc_auc"] == m["roc_auc"]: # skip NaN
|
| 25 |
+
assert 0.0 <= m["roc_auc"] <= 1.0, f"{name} roc_auc={m['roc_auc']} out of range"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def test_online_method_has_latency():
|
| 29 |
+
"""The online method reports mean per-observation latency in microseconds."""
|
| 30 |
+
results = run_comparison(seed=42, verbose=False)
|
| 31 |
+
online = results[ONLINE_KEY]
|
| 32 |
+
assert "mean_latency_us" in online
|
| 33 |
+
assert online["mean_latency_us"] > 0
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_mahalanobis_beats_zscore_on_f1():
|
| 37 |
+
"""Mahalanobis + ADWIN should outperform Z-Score on F1 (catches contextual anomalies)."""
|
| 38 |
+
results = run_comparison(seed=42, verbose=False)
|
| 39 |
+
online_f1 = results[ONLINE_KEY]["f1"]
|
| 40 |
+
zscore_f1 = results["Z-Score (max |z|>2.5)"]["f1"]
|
| 41 |
+
assert online_f1 > zscore_f1, (
|
| 42 |
+
f"Mahalanobis F1={online_f1:.3f} should exceed Z-Score F1={zscore_f1:.3f}"
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_results_are_reproducible():
|
| 47 |
+
"""Two runs with the same seed produce identical results."""
|
| 48 |
+
r1 = run_comparison(seed=42, verbose=False)
|
| 49 |
+
r2 = run_comparison(seed=42, verbose=False)
|
| 50 |
+
for name in r1:
|
| 51 |
+
for key in ("precision", "recall", "f1"):
|
| 52 |
+
assert r1[name][key] == r2[name][key], f"{name} {key} not reproducible"
|
tests/test_dashboard.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Phase 4: Dashboard and single-command run tests."""
|
| 2 |
+
|
| 3 |
+
import subprocess
|
| 4 |
+
import sys
|
| 5 |
+
import time
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import pytest
|
| 9 |
+
from httpx import ASGITransport, AsyncClient
|
| 10 |
+
|
| 11 |
+
from src.server.app import app
|
| 12 |
+
|
| 13 |
+
STATIC_DIR = Path(__file__).resolve().parent.parent / "src" / "server" / "static"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def test_index_html_contains_dashboard_elements():
|
| 17 |
+
"""Dashboard HTML has chart, counters, and script for dashboard.js."""
|
| 18 |
+
index_path = STATIC_DIR / "index.html"
|
| 19 |
+
assert index_path.exists()
|
| 20 |
+
html = index_path.read_text()
|
| 21 |
+
assert "chart-sensors" in html
|
| 22 |
+
assert "chart-score" in html
|
| 23 |
+
assert "total-anomalies" in html
|
| 24 |
+
assert "total-drift" in html
|
| 25 |
+
assert "precision" in html
|
| 26 |
+
assert "recall" in html
|
| 27 |
+
assert "f1" in html
|
| 28 |
+
assert "phase-badge" in html
|
| 29 |
+
assert "dashboard.js" in html
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_dashboard_js_connects_via_websocket_and_updates():
|
| 33 |
+
"""Dashboard JS contains WebSocket connection and real-time update logic."""
|
| 34 |
+
js_path = STATIC_DIR / "dashboard.js"
|
| 35 |
+
assert js_path.exists()
|
| 36 |
+
js = js_path.read_text()
|
| 37 |
+
assert "WebSocket" in js
|
| 38 |
+
assert "/ws" in js
|
| 39 |
+
assert "observation_index" in js
|
| 40 |
+
assert "alert" in js
|
| 41 |
+
assert "drift_event" in js
|
| 42 |
+
assert "chart" in js or "Chart" in js
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@pytest.mark.asyncio
|
| 46 |
+
async def test_root_serves_dashboard():
|
| 47 |
+
"""GET / returns the dashboard HTML."""
|
| 48 |
+
transport = ASGITransport(app=app)
|
| 49 |
+
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
| 50 |
+
resp = await client.get("/")
|
| 51 |
+
assert resp.status_code == 200
|
| 52 |
+
assert "chart" in resp.text
|
| 53 |
+
assert "dashboard.js" in resp.text
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@pytest.mark.asyncio
|
| 57 |
+
async def test_static_serves_dashboard_js():
|
| 58 |
+
"""GET /static/dashboard.js returns the script."""
|
| 59 |
+
transport = ASGITransport(app=app)
|
| 60 |
+
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
| 61 |
+
resp = await client.get("/static/dashboard.js")
|
| 62 |
+
assert resp.status_code == 200
|
| 63 |
+
assert "WebSocket" in resp.text
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_single_command_starts_server():
|
| 67 |
+
"""The entire system starts with one command (run.py or python -m src.server.app)."""
|
| 68 |
+
import urllib.request
|
| 69 |
+
proc = subprocess.Popen(
|
| 70 |
+
[sys.executable, "-m", "src.server.app"],
|
| 71 |
+
cwd=str(Path(__file__).resolve().parent.parent),
|
| 72 |
+
stdout=subprocess.PIPE,
|
| 73 |
+
stderr=subprocess.PIPE,
|
| 74 |
+
env={**__import__("os").environ},
|
| 75 |
+
)
|
| 76 |
+
try:
|
| 77 |
+
for _ in range(15):
|
| 78 |
+
time.sleep(1)
|
| 79 |
+
try:
|
| 80 |
+
req = urllib.request.urlopen("http://127.0.0.1:8000/", timeout=3)
|
| 81 |
+
assert req.status == 200
|
| 82 |
+
req.close()
|
| 83 |
+
break
|
| 84 |
+
except OSError:
|
| 85 |
+
continue
|
| 86 |
+
else:
|
| 87 |
+
pytest.fail("Server did not respond within 15 seconds")
|
| 88 |
+
finally:
|
| 89 |
+
proc.terminate()
|
| 90 |
+
proc.wait(timeout=8)
|
tests/test_detector.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Online anomaly detector and drift handling tests."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
|
| 5 |
+
from src.detector.anomaly import AnomalyDetector
|
| 6 |
+
from src.detector.drift import DriftDetector
|
| 7 |
+
from src.detector.metrics import RunningMetrics
|
| 8 |
+
from src.pipeline.runner import _drift_signal
|
| 9 |
+
from src.stream.generator import generate_stream
|
| 10 |
+
from src.stream.models import Observation
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _run_detector(detector: AnomalyDetector, obs_list):
|
| 14 |
+
"""Run a detector over a pre-collected observation list, returning per-obs scores."""
|
| 15 |
+
frozen = False
|
| 16 |
+
scores = []
|
| 17 |
+
for obs in obs_list:
|
| 18 |
+
if obs.phase == "A":
|
| 19 |
+
detector.learn_scaler(obs)
|
| 20 |
+
elif not frozen:
|
| 21 |
+
detector.freeze_baseline()
|
| 22 |
+
frozen = True
|
| 23 |
+
scores.append(detector.score(obs))
|
| 24 |
+
return scores
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@pytest.mark.asyncio
|
| 28 |
+
async def test_anomaly_scores_higher_for_injected_anomalies():
|
| 29 |
+
"""Mahalanobis scorer assigns higher mean scores to injected anomalies than to normals."""
|
| 30 |
+
obs_list = []
|
| 31 |
+
async for obs in generate_stream(
|
| 32 |
+
phase_a_length=80,
|
| 33 |
+
phase_b_length=60,
|
| 34 |
+
phase_c_length=100,
|
| 35 |
+
anomaly_rate=0.12,
|
| 36 |
+
seed=42,
|
| 37 |
+
):
|
| 38 |
+
obs_list.append(obs)
|
| 39 |
+
|
| 40 |
+
detector = AnomalyDetector(threshold=0.5)
|
| 41 |
+
scores = _run_detector(detector, obs_list)
|
| 42 |
+
|
| 43 |
+
scores_normal = [s for s, o in zip(scores, obs_list) if o.label == "normal"]
|
| 44 |
+
scores_anomaly = [s for s, o in zip(scores, obs_list) if o.label != "normal"]
|
| 45 |
+
|
| 46 |
+
assert len(scores_anomaly) >= 5, "Need some anomalies in stream"
|
| 47 |
+
assert sum(scores_anomaly) / len(scores_anomaly) > sum(scores_normal) / len(scores_normal), (
|
| 48 |
+
"Mean Mahalanobis score for anomalies should exceed mean score for normals"
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@pytest.mark.asyncio
|
| 53 |
+
async def test_drift_detector_emits_reset_in_phase_b():
|
| 54 |
+
"""ADWIN on the sensor drift signal fires during or shortly after Phase B; not during Phase A.
|
| 55 |
+
|
| 56 |
+
Uses delta=0.05 (production config) and a strong drift. ADWIN may fire near the
|
| 57 |
+
end of Phase B or in the first few observations of Phase C — both are acceptable
|
| 58 |
+
since ADWIN is adaptive and needs sufficient evidence before committing.
|
| 59 |
+
"""
|
| 60 |
+
drift_det = DriftDetector(delta=0.05, grace_period=20)
|
| 61 |
+
phase_a_len = 150
|
| 62 |
+
phase_b_len = 200
|
| 63 |
+
fires_before_drift = 0
|
| 64 |
+
fires_from_drift = 0
|
| 65 |
+
idx = 0
|
| 66 |
+
# Accept fire up to 30 obs after Phase B ends (ADWIN lag tolerance)
|
| 67 |
+
detection_window_end = phase_a_len + phase_b_len + 30
|
| 68 |
+
|
| 69 |
+
async for obs in generate_stream(
|
| 70 |
+
phase_a_length=phase_a_len,
|
| 71 |
+
phase_b_length=phase_b_len,
|
| 72 |
+
phase_c_length=60,
|
| 73 |
+
drift_magnitude=0.5,
|
| 74 |
+
seed=123,
|
| 75 |
+
):
|
| 76 |
+
if drift_det.update(_drift_signal(obs)):
|
| 77 |
+
if idx < phase_a_len:
|
| 78 |
+
fires_before_drift += 1
|
| 79 |
+
elif idx <= detection_window_end:
|
| 80 |
+
fires_from_drift += 1
|
| 81 |
+
idx += 1
|
| 82 |
+
|
| 83 |
+
assert fires_before_drift == 0, "No ADWIN fire during Phase A (signal is stationary)"
|
| 84 |
+
assert fires_from_drift >= 1, "At least one ADWIN fire during/after Phase B (sustained drift)"
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
@pytest.mark.asyncio
|
| 88 |
+
async def test_no_reset_during_phase_a():
|
| 89 |
+
"""ADWIN does not fire during Phase A when monitoring the sensor drift signal."""
|
| 90 |
+
drift_det = DriftDetector(delta=0.05, grace_period=30)
|
| 91 |
+
phase_a_len = 120
|
| 92 |
+
any_fire_in_a = False
|
| 93 |
+
|
| 94 |
+
async for obs in generate_stream(
|
| 95 |
+
phase_a_length=phase_a_len,
|
| 96 |
+
phase_b_length=0,
|
| 97 |
+
phase_c_length=0,
|
| 98 |
+
seed=999,
|
| 99 |
+
):
|
| 100 |
+
if drift_det.update(_drift_signal(obs)):
|
| 101 |
+
any_fire_in_a = True
|
| 102 |
+
|
| 103 |
+
assert not any_fire_in_a, "ADWIN must not fire on stationary Phase A noise"
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
@pytest.mark.asyncio
|
| 107 |
+
async def test_running_precision_recall_hand_labeled():
|
| 108 |
+
"""Running precision and recall are computed correctly on a hand-labeled mini-stream."""
|
| 109 |
+
data = [
|
| 110 |
+
(True, True), # TP
|
| 111 |
+
(True, True), # TP
|
| 112 |
+
(True, False), # FN
|
| 113 |
+
(False, False), # TN
|
| 114 |
+
(False, True), # FP
|
| 115 |
+
(False, False), # TN
|
| 116 |
+
(True, True), # TP
|
| 117 |
+
(False, False), # TN
|
| 118 |
+
(False, True), # FP
|
| 119 |
+
(True, False), # FN
|
| 120 |
+
(True, True), # TP
|
| 121 |
+
(False, False), # TN
|
| 122 |
+
(False, False), # TN
|
| 123 |
+
(True, True), # TP
|
| 124 |
+
(False, True), # FP
|
| 125 |
+
(True, False), # FN
|
| 126 |
+
(False, False), # TN
|
| 127 |
+
(True, True), # TP
|
| 128 |
+
(False, False), # TN
|
| 129 |
+
(False, True), # FP
|
| 130 |
+
]
|
| 131 |
+
# TP=6, FP=4, FN=3, TN=7
|
| 132 |
+
expected_precision = 6 / 10
|
| 133 |
+
expected_recall = 6 / 9
|
| 134 |
+
expected_f1 = 2 * expected_precision * expected_recall / (expected_precision + expected_recall)
|
| 135 |
+
|
| 136 |
+
metrics = RunningMetrics()
|
| 137 |
+
for gt, pred in data:
|
| 138 |
+
metrics.update(ground_truth=gt, predicted=pred)
|
| 139 |
+
|
| 140 |
+
assert abs(metrics.precision - expected_precision) < 1e-9
|
| 141 |
+
assert abs(metrics.recall - expected_recall) < 1e-9
|
| 142 |
+
assert abs(metrics.f1 - expected_f1) < 1e-9
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def test_f1_zero_when_no_positives():
|
| 146 |
+
"""F1 is 0.0 when no positives have been predicted (avoids division by zero)."""
|
| 147 |
+
m = RunningMetrics()
|
| 148 |
+
for _ in range(10):
|
| 149 |
+
m.update(ground_truth=False, predicted=False)
|
| 150 |
+
assert m.f1 == 0.0
|
| 151 |
+
assert m.precision == 0.0
|
| 152 |
+
assert m.recall == 0.0
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def test_specificity_all_true_negatives():
|
| 156 |
+
"""Specificity is 1.0 when all normals are correctly left unflagged."""
|
| 157 |
+
m = RunningMetrics()
|
| 158 |
+
for _ in range(5):
|
| 159 |
+
m.update(ground_truth=False, predicted=False)
|
| 160 |
+
assert m.specificity == 1.0
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def test_specificity_all_false_positives():
|
| 164 |
+
"""Specificity is 0.0 when all normals are incorrectly flagged."""
|
| 165 |
+
m = RunningMetrics()
|
| 166 |
+
for _ in range(5):
|
| 167 |
+
m.update(ground_truth=False, predicted=True)
|
| 168 |
+
assert m.specificity == 0.0
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def test_detector_scores_zero_before_freeze():
|
| 172 |
+
"""Before freeze_baseline() is called, score() returns 0.0 (warmup phase)."""
|
| 173 |
+
detector = AnomalyDetector(threshold=0.5)
|
| 174 |
+
obs = Observation(timestamp=0.0, temperature=27.0, pressure=99.0, vibration=1.5, label="normal", phase="A")
|
| 175 |
+
assert detector.score(obs) == 0.0, "Score must be 0.0 before Phase A baseline is frozen"
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def test_detector_scores_anomaly_after_freeze():
|
| 179 |
+
"""After freeze_baseline(), a large-deviation observation scores higher than a normal one."""
|
| 180 |
+
import random
|
| 181 |
+
rng = random.Random(0)
|
| 182 |
+
# Use observations with realistic noise so the covariance matrix is non-degenerate.
|
| 183 |
+
phase_a_obs = [
|
| 184 |
+
Observation(
|
| 185 |
+
timestamp=float(i),
|
| 186 |
+
temperature=25.0 + rng.gauss(0, 0.5),
|
| 187 |
+
pressure=101.3 + rng.gauss(0, 0.5),
|
| 188 |
+
vibration=0.5 + rng.gauss(0, 0.5),
|
| 189 |
+
label="normal",
|
| 190 |
+
phase="A",
|
| 191 |
+
)
|
| 192 |
+
for i in range(100)
|
| 193 |
+
]
|
| 194 |
+
detector = AnomalyDetector(threshold=0.5)
|
| 195 |
+
for obs in phase_a_obs:
|
| 196 |
+
detector.learn_scaler(obs)
|
| 197 |
+
detector.freeze_baseline()
|
| 198 |
+
|
| 199 |
+
# Within-range observation (near Phase A baseline)
|
| 200 |
+
normal = Observation(timestamp=101.0, temperature=25.2, pressure=101.4, vibration=0.5, label="normal", phase="C")
|
| 201 |
+
# Large spike in temperature and anti-correlated pressure — far from Phase A density
|
| 202 |
+
anomaly = Observation(timestamp=102.0, temperature=28.5, pressure=98.0, vibration=0.5, label="point_anomaly", phase="C")
|
| 203 |
+
|
| 204 |
+
assert detector.score(anomaly) > detector.score(normal), (
|
| 205 |
+
"A large-deviation observation should score higher than a near-baseline one"
|
| 206 |
+
)
|
| 207 |
+
assert 0.0 <= detector.score(normal) < 1.0
|
tests/test_pipeline.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Phase 3: Real-time broadcasting pipeline and server tests."""
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
from httpx import ASGITransport, AsyncClient
|
| 8 |
+
|
| 9 |
+
from src.pipeline.runner import run_pipeline
|
| 10 |
+
from src.stream.generator import generate_stream
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
# Required broadcast message schema — all keys the pipeline must emit
|
| 14 |
+
BROADCAST_KEYS = {
|
| 15 |
+
"timestamp",
|
| 16 |
+
"temperature",
|
| 17 |
+
"pressure",
|
| 18 |
+
"vibration",
|
| 19 |
+
"label",
|
| 20 |
+
"phase",
|
| 21 |
+
"anomaly_score",
|
| 22 |
+
"alert",
|
| 23 |
+
"drift_event",
|
| 24 |
+
"running_precision",
|
| 25 |
+
"running_recall",
|
| 26 |
+
"running_f1",
|
| 27 |
+
"latency_ms",
|
| 28 |
+
"observation_index",
|
| 29 |
+
"total_anomalies_detected",
|
| 30 |
+
"total_drift_events",
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@pytest.mark.asyncio
|
| 35 |
+
async def test_websocket_receives_broadcast_messages_in_real_time():
|
| 36 |
+
"""A WebSocket client receives broadcast messages in real time as the stream runs."""
|
| 37 |
+
received: list[dict[str, Any]] = []
|
| 38 |
+
n_want = 15
|
| 39 |
+
|
| 40 |
+
async def broadcaster(msg: dict[str, Any]) -> None:
|
| 41 |
+
received.append(msg)
|
| 42 |
+
if len(received) >= n_want:
|
| 43 |
+
raise asyncio.CancelledError("enough")
|
| 44 |
+
|
| 45 |
+
stream = generate_stream(
|
| 46 |
+
phase_a_length=50,
|
| 47 |
+
phase_b_length=0,
|
| 48 |
+
phase_c_length=0,
|
| 49 |
+
delay=0,
|
| 50 |
+
seed=1,
|
| 51 |
+
)
|
| 52 |
+
task = asyncio.create_task(run_pipeline(stream, broadcaster))
|
| 53 |
+
try:
|
| 54 |
+
await asyncio.wait_for(task, timeout=30.0)
|
| 55 |
+
except asyncio.CancelledError:
|
| 56 |
+
task.cancel()
|
| 57 |
+
try:
|
| 58 |
+
await task
|
| 59 |
+
except asyncio.CancelledError:
|
| 60 |
+
pass
|
| 61 |
+
assert len(received) >= n_want
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@pytest.mark.asyncio
|
| 65 |
+
async def test_broadcast_message_schema():
|
| 66 |
+
"""Each broadcast message conforms to the documented schema."""
|
| 67 |
+
received: list[dict[str, Any]] = []
|
| 68 |
+
|
| 69 |
+
async def broadcaster(msg: dict[str, Any]) -> None:
|
| 70 |
+
received.append(msg)
|
| 71 |
+
if len(received) >= 5:
|
| 72 |
+
raise asyncio.CancelledError("enough")
|
| 73 |
+
|
| 74 |
+
stream = generate_stream(
|
| 75 |
+
phase_a_length=20,
|
| 76 |
+
phase_b_length=0,
|
| 77 |
+
phase_c_length=0,
|
| 78 |
+
delay=0,
|
| 79 |
+
seed=2,
|
| 80 |
+
)
|
| 81 |
+
task = asyncio.create_task(run_pipeline(stream, broadcaster))
|
| 82 |
+
try:
|
| 83 |
+
await asyncio.wait_for(task, timeout=15.0)
|
| 84 |
+
except asyncio.CancelledError:
|
| 85 |
+
task.cancel()
|
| 86 |
+
try:
|
| 87 |
+
await task
|
| 88 |
+
except asyncio.CancelledError:
|
| 89 |
+
pass
|
| 90 |
+
assert len(received) >= 5
|
| 91 |
+
for msg in received:
|
| 92 |
+
assert set(msg.keys()) == BROADCAST_KEYS, (
|
| 93 |
+
"Message keys mismatch. Extra: %s. Missing: %s"
|
| 94 |
+
% (set(msg.keys()) - BROADCAST_KEYS, BROADCAST_KEYS - set(msg.keys()))
|
| 95 |
+
)
|
| 96 |
+
assert isinstance(msg["timestamp"], (int, float))
|
| 97 |
+
assert isinstance(msg["temperature"], (int, float))
|
| 98 |
+
assert isinstance(msg["pressure"], (int, float))
|
| 99 |
+
assert isinstance(msg["vibration"], (int, float))
|
| 100 |
+
assert isinstance(msg["anomaly_score"],(int, float))
|
| 101 |
+
assert isinstance(msg["alert"], bool)
|
| 102 |
+
assert isinstance(msg["drift_event"], bool)
|
| 103 |
+
assert isinstance(msg["running_precision"], (int, float))
|
| 104 |
+
assert isinstance(msg["running_recall"], (int, float))
|
| 105 |
+
assert isinstance(msg["running_f1"], (int, float))
|
| 106 |
+
assert isinstance(msg["latency_ms"], (int, float))
|
| 107 |
+
assert isinstance(msg["observation_index"], int)
|
| 108 |
+
assert isinstance(msg["total_anomalies_detected"], int)
|
| 109 |
+
assert isinstance(msg["total_drift_events"], int)
|
| 110 |
+
assert 0 <= msg["anomaly_score"] <= 1
|
| 111 |
+
assert 0 <= msg["running_precision"] <= 1
|
| 112 |
+
assert 0 <= msg["running_recall"] <= 1
|
| 113 |
+
assert 0 <= msg["running_f1"] <= 1
|
| 114 |
+
assert msg["latency_ms"] >= 0
|
| 115 |
+
assert msg["label"] in ("normal", "point_anomaly", "contextual_anomaly")
|
| 116 |
+
assert msg["phase"] in ("A", "B", "C")
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
@pytest.mark.asyncio
|
| 120 |
+
async def test_broadcast_phase_labels_correct():
|
| 121 |
+
"""Phase field in broadcast messages matches the stream phase A/B/C boundaries."""
|
| 122 |
+
received: list[dict[str, Any]] = []
|
| 123 |
+
PA, PB, PC = 20, 15, 20
|
| 124 |
+
|
| 125 |
+
async def broadcaster(msg: dict[str, Any]) -> None:
|
| 126 |
+
received.append(msg)
|
| 127 |
+
|
| 128 |
+
stream = generate_stream(
|
| 129 |
+
phase_a_length=PA,
|
| 130 |
+
phase_b_length=PB,
|
| 131 |
+
phase_c_length=PC,
|
| 132 |
+
delay=0,
|
| 133 |
+
seed=7,
|
| 134 |
+
)
|
| 135 |
+
await run_pipeline(stream, broadcaster)
|
| 136 |
+
|
| 137 |
+
for i, msg in enumerate(received):
|
| 138 |
+
if i < PA:
|
| 139 |
+
assert msg["phase"] == "A", f"obs {i} should be Phase A"
|
| 140 |
+
elif i < PA + PB:
|
| 141 |
+
assert msg["phase"] == "B", f"obs {i} should be Phase B"
|
| 142 |
+
else:
|
| 143 |
+
assert msg["phase"] == "C", f"obs {i} should be Phase C"
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
@pytest.mark.asyncio
|
| 147 |
+
async def test_stats_endpoint_returns_running_totals():
|
| 148 |
+
"""The stats endpoint returns correct running totals after processing a known number of observations."""
|
| 149 |
+
state: dict[str, Any] = {
|
| 150 |
+
"total_observations": 0,
|
| 151 |
+
"total_anomalies_detected": 0,
|
| 152 |
+
"total_drift_events": 0,
|
| 153 |
+
"current_precision": 0.0,
|
| 154 |
+
"current_recall": 0.0,
|
| 155 |
+
"current_f1": 0.0,
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
async def broadcaster(msg: dict[str, Any]) -> None:
|
| 159 |
+
pass
|
| 160 |
+
|
| 161 |
+
stream = generate_stream(
|
| 162 |
+
phase_a_length=30,
|
| 163 |
+
phase_b_length=0,
|
| 164 |
+
phase_c_length=0,
|
| 165 |
+
delay=0,
|
| 166 |
+
seed=3,
|
| 167 |
+
)
|
| 168 |
+
await run_pipeline(stream, broadcaster, state=state)
|
| 169 |
+
assert state["total_observations"] == 30
|
| 170 |
+
assert "total_anomalies_detected" in state
|
| 171 |
+
assert "total_drift_events" in state
|
| 172 |
+
assert "current_precision" in state
|
| 173 |
+
assert "current_recall" in state
|
| 174 |
+
assert "current_f1" in state
|
| 175 |
+
|
| 176 |
+
# Hit the FastAPI /stats endpoint
|
| 177 |
+
from fastapi import FastAPI
|
| 178 |
+
from src.server.app import STATE
|
| 179 |
+
|
| 180 |
+
test_app = FastAPI()
|
| 181 |
+
|
| 182 |
+
@test_app.get("/stats")
|
| 183 |
+
async def _stats():
|
| 184 |
+
return {
|
| 185 |
+
"total_observations": STATE["total_observations"],
|
| 186 |
+
"total_anomalies_detected": STATE["total_anomalies_detected"],
|
| 187 |
+
"total_drift_events": STATE["total_drift_events"],
|
| 188 |
+
"current_precision": STATE["current_precision"],
|
| 189 |
+
"current_recall": STATE["current_recall"],
|
| 190 |
+
"current_f1": STATE["current_f1"],
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
saved = dict(STATE)
|
| 194 |
+
STATE.clear()
|
| 195 |
+
STATE.update(
|
| 196 |
+
total_observations=100,
|
| 197 |
+
total_anomalies_detected=7,
|
| 198 |
+
total_drift_events=2,
|
| 199 |
+
current_precision=0.6,
|
| 200 |
+
current_recall=0.5,
|
| 201 |
+
current_f1=0.545,
|
| 202 |
+
cycle_start_time=0.0,
|
| 203 |
+
)
|
| 204 |
+
try:
|
| 205 |
+
transport = ASGITransport(app=test_app)
|
| 206 |
+
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
| 207 |
+
resp = await client.get("/stats")
|
| 208 |
+
assert resp.status_code == 200
|
| 209 |
+
data = resp.json()
|
| 210 |
+
assert data["total_observations"] == 100
|
| 211 |
+
assert data["total_anomalies_detected"] == 7
|
| 212 |
+
assert data["total_drift_events"] == 2
|
| 213 |
+
assert abs(data["current_precision"] - 0.6) < 1e-9
|
| 214 |
+
assert abs(data["current_recall"] - 0.5) < 1e-9
|
| 215 |
+
assert abs(data["current_f1"] - 0.545) < 1e-9
|
| 216 |
+
finally:
|
| 217 |
+
STATE.clear()
|
| 218 |
+
STATE.update(saved)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
@pytest.mark.asyncio
|
| 222 |
+
async def test_pipeline_no_blocking():
|
| 223 |
+
"""No blocking calls in the async pipeline (runs without event loop warnings)."""
|
| 224 |
+
msgs: list[dict] = []
|
| 225 |
+
|
| 226 |
+
async def broadcaster(m: dict) -> None:
|
| 227 |
+
msgs.append(m)
|
| 228 |
+
await asyncio.sleep(0)
|
| 229 |
+
|
| 230 |
+
stream = generate_stream(
|
| 231 |
+
phase_a_length=10,
|
| 232 |
+
phase_b_length=0,
|
| 233 |
+
phase_c_length=0,
|
| 234 |
+
delay=0,
|
| 235 |
+
seed=4,
|
| 236 |
+
)
|
| 237 |
+
await asyncio.wait_for(run_pipeline(stream, broadcaster), timeout=20.0)
|
| 238 |
+
assert len(msgs) == 10
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
@pytest.mark.asyncio
|
| 242 |
+
async def test_latency_ms_is_plausible():
|
| 243 |
+
"""latency_ms in broadcast messages is non-negative and under 100 ms for simple stream."""
|
| 244 |
+
msgs: list[dict] = []
|
| 245 |
+
|
| 246 |
+
async def broadcaster(m: dict) -> None:
|
| 247 |
+
msgs.append(m)
|
| 248 |
+
|
| 249 |
+
stream = generate_stream(
|
| 250 |
+
phase_a_length=20,
|
| 251 |
+
phase_b_length=0,
|
| 252 |
+
phase_c_length=0,
|
| 253 |
+
delay=0,
|
| 254 |
+
seed=5,
|
| 255 |
+
)
|
| 256 |
+
await run_pipeline(stream, broadcaster)
|
| 257 |
+
latencies = [m["latency_ms"] for m in msgs]
|
| 258 |
+
assert all(lat >= 0 for lat in latencies)
|
| 259 |
+
assert all(lat < 100 for lat in latencies), "Unexpectedly slow observations"
|
tests/test_stream.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Phase 1: Stream simulator tests."""
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import statistics
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
from src.stream.generator import generate_stream
|
| 9 |
+
from src.stream.models import Observation
|
| 10 |
+
|
| 11 |
+
# Baseline from generator
|
| 12 |
+
BASELINE_TEMP = 25.0
|
| 13 |
+
BASELINE_PRESSURE = 101.3
|
| 14 |
+
BASELINE_VIBRATION = 0.5
|
| 15 |
+
NOISE_SIGMA = 0.5
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@pytest.mark.asyncio
|
| 19 |
+
async def test_generator_is_async():
|
| 20 |
+
"""The generator is async: confirmed by consuming it with async for."""
|
| 21 |
+
count = 0
|
| 22 |
+
async for obs in generate_stream(
|
| 23 |
+
phase_a_length=10,
|
| 24 |
+
phase_b_length=5,
|
| 25 |
+
phase_c_length=10,
|
| 26 |
+
seed=42,
|
| 27 |
+
):
|
| 28 |
+
assert isinstance(obs, Observation)
|
| 29 |
+
assert obs.timestamp >= 0
|
| 30 |
+
assert obs.label in ("normal", "point_anomaly", "contextual_anomaly")
|
| 31 |
+
assert obs.phase in ("A", "B", "C")
|
| 32 |
+
count += 1
|
| 33 |
+
assert count == 25
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@pytest.mark.asyncio
|
| 37 |
+
async def test_phase_a_normality(stream_params_phase_a_only):
|
| 38 |
+
"""Phase A observations are statistically normal: means and variances match baselines."""
|
| 39 |
+
obs_list: list[Observation] = []
|
| 40 |
+
async for obs in generate_stream(**stream_params_phase_a_only):
|
| 41 |
+
obs_list.append(obs)
|
| 42 |
+
assert len(obs_list) == 200
|
| 43 |
+
assert all(o.label == "normal" for o in obs_list)
|
| 44 |
+
|
| 45 |
+
temps = [o.temperature for o in obs_list]
|
| 46 |
+
pressures = [o.pressure for o in obs_list]
|
| 47 |
+
vibrations = [o.vibration for o in obs_list]
|
| 48 |
+
|
| 49 |
+
# Means within tolerance of baseline (allow for sampling variance)
|
| 50 |
+
assert abs(statistics.mean(temps) - BASELINE_TEMP) < 0.15
|
| 51 |
+
assert abs(statistics.mean(pressures) - BASELINE_PRESSURE) < 0.15
|
| 52 |
+
assert abs(statistics.mean(vibrations) - BASELINE_VIBRATION) < 0.15
|
| 53 |
+
|
| 54 |
+
# Variances in expected ballpark (noise sigma^2 ~ 0.25, sample var can vary)
|
| 55 |
+
var_t = statistics.variance(temps)
|
| 56 |
+
var_p = statistics.variance(pressures)
|
| 57 |
+
var_v = statistics.variance(vibrations)
|
| 58 |
+
assert 0.1 < var_t < 1.0
|
| 59 |
+
assert 0.1 < var_p < 1.0
|
| 60 |
+
assert 0.1 < var_v < 1.0
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@pytest.mark.asyncio
|
| 64 |
+
async def test_phase_b_drift(stream_params_short):
|
| 65 |
+
"""Phase B observations show a measurable distribution shift by end of phase."""
|
| 66 |
+
params = dict(stream_params_short)
|
| 67 |
+
phase_a_len = params["phase_a_length"]
|
| 68 |
+
phase_b_len = params["phase_b_length"]
|
| 69 |
+
drift_mag = params["drift_magnitude"]
|
| 70 |
+
|
| 71 |
+
obs_list: list[Observation] = []
|
| 72 |
+
async for obs in generate_stream(**params):
|
| 73 |
+
obs_list.append(obs)
|
| 74 |
+
|
| 75 |
+
phase_a_temps = [o.temperature for o in obs_list[:phase_a_len]]
|
| 76 |
+
phase_b_end_temps = [o.temperature for o in obs_list[phase_a_len + phase_b_len - 50 : phase_a_len + phase_b_len]]
|
| 77 |
+
|
| 78 |
+
mean_a = statistics.mean(phase_a_temps)
|
| 79 |
+
mean_b_end = statistics.mean(phase_b_end_temps)
|
| 80 |
+
# Allow for sampling variance: require a measurable positive shift
|
| 81 |
+
assert mean_b_end - mean_a >= drift_mag * 0.25, (
|
| 82 |
+
"End of Phase B mean should shift; got mean_a=%s mean_b_end=%s" % (mean_a, mean_b_end)
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@pytest.mark.asyncio
|
| 87 |
+
async def test_phase_c_anomaly_count(stream_params_short):
|
| 88 |
+
"""Phase C contains the configured number of injected anomalies at the configured rate."""
|
| 89 |
+
params = dict(stream_params_short)
|
| 90 |
+
phase_c_len = params["phase_c_length"]
|
| 91 |
+
anomaly_rate = params["anomaly_rate"]
|
| 92 |
+
phase_a_len = params["phase_a_length"]
|
| 93 |
+
phase_b_len = params["phase_b_length"]
|
| 94 |
+
|
| 95 |
+
obs_list: list[Observation] = []
|
| 96 |
+
async for obs in generate_stream(**params):
|
| 97 |
+
obs_list.append(obs)
|
| 98 |
+
|
| 99 |
+
phase_c = obs_list[phase_a_len + phase_b_len :]
|
| 100 |
+
assert len(phase_c) == phase_c_len
|
| 101 |
+
anomalies = [o for o in phase_c if o.label != "normal"]
|
| 102 |
+
expected_min = int(phase_c_len * anomaly_rate * 0.5)
|
| 103 |
+
expected_max = int(phase_c_len * anomaly_rate * 2) + 10
|
| 104 |
+
assert expected_min <= len(anomalies) <= expected_max, (
|
| 105 |
+
f"Expected roughly {phase_c_len * anomaly_rate:.0f} anomalies, got {len(anomalies)}"
|
| 106 |
+
)
|
| 107 |
+
point_count = sum(1 for o in phase_c if o.label == "point_anomaly")
|
| 108 |
+
contextual_count = sum(1 for o in phase_c if o.label == "contextual_anomaly")
|
| 109 |
+
assert point_count + contextual_count == len(anomalies)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
@pytest.mark.asyncio
|
| 113 |
+
async def test_phase_labels_match_boundaries(stream_params_short):
|
| 114 |
+
"""Phase field on each Observation matches the correct stream phase (A/B/C)."""
|
| 115 |
+
params = dict(stream_params_short)
|
| 116 |
+
PA = params["phase_a_length"]
|
| 117 |
+
PB = params["phase_b_length"]
|
| 118 |
+
|
| 119 |
+
obs_list: list[Observation] = []
|
| 120 |
+
async for obs in generate_stream(**params):
|
| 121 |
+
obs_list.append(obs)
|
| 122 |
+
|
| 123 |
+
for i, obs in enumerate(obs_list):
|
| 124 |
+
if i < PA:
|
| 125 |
+
assert obs.phase == "A", f"obs {i} should be Phase A, got {obs.phase}"
|
| 126 |
+
elif i < PA + PB:
|
| 127 |
+
assert obs.phase == "B", f"obs {i} should be Phase B, got {obs.phase}"
|
| 128 |
+
else:
|
| 129 |
+
assert obs.phase == "C", f"obs {i} should be Phase C, got {obs.phase}"
|