zeetay's picture
initial deploy
b0add2b verified
Raw
History Blame Contribute Delete
4.99 kB
"""Async pipeline: generator -> scorer -> broadcaster, with structured logging."""
import logging
import time
from collections.abc import AsyncIterator, Awaitable, Callable
from typing import Any
from src.detector.anomaly import AnomalyDetector
from src.detector.drift import DriftDetector
from src.detector.metrics import RunningMetrics
from src.stream.generator import (
BASELINE_PRESSURE,
BASELINE_TEMP,
BASELINE_VIBRATION,
NOISE_SIGMA,
)
from src.stream.models import Observation
logger = logging.getLogger(__name__)
def _drift_signal(obs: Observation) -> float:
"""Average normalized deviation of all three sensors from Phase A baselines."""
return (
(obs.temperature - BASELINE_TEMP) / NOISE_SIGMA
+ (obs.pressure - BASELINE_PRESSURE) / NOISE_SIGMA
+ (obs.vibration - BASELINE_VIBRATION) / NOISE_SIGMA
) / 3.0
def _broadcast_message(
obs: Observation,
anomaly_score: float,
alert: bool,
drift_event: bool,
running_precision: float,
running_recall: float,
running_f1: float,
latency_ms: float,
observation_index: int,
total_anomalies_detected: int,
total_drift_events: int,
) -> dict[str, Any]:
return {
"timestamp": obs.timestamp,
"temperature": obs.temperature,
"pressure": obs.pressure,
"vibration": obs.vibration,
"label": obs.label,
"phase": obs.phase,
"anomaly_score": anomaly_score,
"alert": alert,
"drift_event": drift_event,
"running_precision": running_precision,
"running_recall": running_recall,
"running_f1": running_f1,
"latency_ms": latency_ms,
"observation_index": observation_index,
"total_anomalies_detected": total_anomalies_detected,
"total_drift_events": total_drift_events,
}
async def run_pipeline(
stream: AsyncIterator[Observation],
broadcaster: Callable[[dict[str, Any]], Awaitable[None]],
*,
anomaly_threshold: float = 0.5,
n_trees: int = 10,
height: int = 6,
window_size: int = 100,
drift_delta: float = 0.002,
drift_grace_period: int = 30,
state: dict[str, Any] | None = None,
) -> None:
"""Run the detection pipeline: score each observation, detect drift, broadcast messages."""
detector = AnomalyDetector(
threshold=anomaly_threshold,
n_trees=n_trees,
height=height,
window_size=window_size,
)
drift_detector = DriftDetector(delta=drift_delta, grace_period=drift_grace_period)
metrics = RunningMetrics()
observation_index = 0
total_anomalies = 0
total_drifts = 0
logger.info("pipeline_start", extra={"threshold": anomaly_threshold, "n_trees": n_trees})
_baseline_frozen = False
async for obs in stream:
t0 = time.perf_counter()
if obs.phase == "A":
detector.learn_scaler(obs)
elif not _baseline_frozen:
detector.freeze_baseline()
_baseline_frozen = True
score = detector.score(obs)
drift_event_flag = drift_detector.update(_drift_signal(obs))
if drift_event_flag:
total_drifts += 1
logger.info(
"drift_detected",
extra={
"observation_index": observation_index,
"phase": obs.phase,
"drift_signal": round(_drift_signal(obs), 4),
},
)
alert = score > anomaly_threshold
if alert:
total_anomalies += 1
metrics.update(ground_truth=(obs.label != "normal"), predicted=alert)
latency_ms = (time.perf_counter() - t0) * 1000
if state is not None:
state["total_observations"] = observation_index + 1
state["total_anomalies_detected"] = total_anomalies
state["total_drift_events"] = total_drifts
state["current_precision"] = metrics.precision
state["current_recall"] = metrics.recall
state["current_f1"] = metrics.f1
msg = _broadcast_message(
obs=obs,
anomaly_score=score,
alert=alert,
drift_event=drift_event_flag,
running_precision=metrics.precision,
running_recall=metrics.recall,
running_f1=metrics.f1,
latency_ms=round(latency_ms, 3),
observation_index=observation_index,
total_anomalies_detected=total_anomalies,
total_drift_events=total_drifts,
)
await broadcaster(msg)
observation_index += 1
logger.info(
"pipeline_end",
extra={
"total_observations": observation_index,
"total_anomalies": total_anomalies,
"total_drifts": total_drifts,
"final_precision": round(metrics.precision, 4),
"final_recall": round(metrics.recall, 4),
"final_f1": round(metrics.f1, 4),
},
)