zeetay commited on
Commit
b0add2b
·
verified ·
1 Parent(s): cfc021d

initial deploy

Browse files
.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: pink
5
- colorTo: red
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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&amp;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 &gt; 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&sigma; 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}"