feat(scada): add project monitoring replay
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
"""Causal batch quality checks and grouped anomaly episodes, no database IO."""
|
||||
from copy import deepcopy
|
||||
from datetime import datetime
|
||||
from math import isfinite
|
||||
from statistics import median
|
||||
from uuid import NAMESPACE_URL, uuid5
|
||||
import re
|
||||
from app.domain.schemas.scada_replay import ScadaReplayCreate
|
||||
|
||||
|
||||
def prepare_cleaning(rows: list[dict], config: ScadaReplayCreate) -> list[dict]:
|
||||
result = []
|
||||
for row in rows:
|
||||
value = row["monitored_value"]
|
||||
quality, reason = "valid", "原始值通过数值与已配置量程校验"
|
||||
if value is None:
|
||||
quality, reason = "missing", "原始读数缺失"
|
||||
elif not isfinite(float(value)):
|
||||
quality, reason = "invalid", "原始读数不是有限数值"
|
||||
else:
|
||||
bounds = config.quality_ranges.get(row["device_id"])
|
||||
if bounds and ((bounds.lower is not None and value < bounds.lower)
|
||||
or (bounds.upper is not None and value > bounds.upper)):
|
||||
quality, reason = "invalid", "超出本次明确配置的仪表量程"
|
||||
result.append({
|
||||
"time": row["time"].isoformat() if isinstance(row["time"], datetime) else row["time"],
|
||||
"device_id": row["device_id"], "source_hash": row["source_hash"],
|
||||
"monitored_value": value if value is not None and isfinite(float(value)) else None,
|
||||
"cleaned_value": value if quality == "valid" else None,
|
||||
"quality": quality, "reason": reason,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def detect_frame(document: dict, rows: list[dict], config: ScadaReplayCreate, at: datetime) -> tuple[dict, list[dict]]:
|
||||
"""Consume a complete timestamp; never treat missing data as recovery."""
|
||||
result = deepcopy(document)
|
||||
histories = result.setdefault("histories", {})
|
||||
states = result.setdefault("rule_states", {})
|
||||
latest = result.setdefault("latest", {})
|
||||
for row in rows:
|
||||
latest[row["device_id"]] = row
|
||||
by_id = {row["device_id"]: row for row in rows}
|
||||
changes = []
|
||||
devices = {device["device_id"]: device for device in result.get("devices", [])}
|
||||
for device_id in config.device_ids:
|
||||
row = by_id.get(device_id)
|
||||
invalid = row is not None and row["quality"] != "valid"
|
||||
previous = latest.get(device_id)
|
||||
frequency = str(devices.get(device_id, {}).get("transmission_frequency", ""))
|
||||
match = re.fullmatch(r"(\d+):(\d+):(\d+)", frequency)
|
||||
if match:
|
||||
seconds = int(match[1]) * 3600 + int(match[2]) * 60 + int(match[3])
|
||||
else:
|
||||
minutes = re.fullmatch(r"(\d+)\s*(?:min|分钟)", frequency)
|
||||
seconds = int(minutes[1]) * 60 if minutes else 0
|
||||
prior_time = datetime.fromisoformat(previous["time"]) if previous else config.start_time
|
||||
missing = not row and seconds > 0 and (at - prior_time).total_seconds() >= seconds * 2
|
||||
key = "quality-" + device_id
|
||||
state = states.setdefault(key, {"event": None, "recoveries": 0})
|
||||
if invalid or missing:
|
||||
state["recoveries"] = 0
|
||||
if state["event"] is None:
|
||||
event = {"event_id": str(uuid5(NAMESPACE_URL, f"{result['replay_id']}:{key}:{at.isoformat()}")),
|
||||
"rule_id": key, "device_ids": [device_id], "occurred_at": at.isoformat(),
|
||||
"last_observed_at": at.isoformat(), "status": "active", "category": "data_quality",
|
||||
"detail": row["reason"] if invalid else "连续两个设备周期未读取到观测",
|
||||
"task_id": None}
|
||||
state["event"] = event
|
||||
changes.append({**event, "change": "raised"})
|
||||
result["anomaly_count"] = result.get("anomaly_count", 0) + 1
|
||||
elif row and row["quality"] == "valid" and state["event"]:
|
||||
state["recoveries"] += 1
|
||||
if state["recoveries"] >= 2:
|
||||
changes.append({**state["event"], "status": "resolved", "resolved_at": at.isoformat(), "change": "resolved"})
|
||||
state["event"] = None
|
||||
for rule in config.rules:
|
||||
state = states.setdefault(rule.rule_id, {"hits": 0, "recoveries": 0, "event": None})
|
||||
hits, usable, reasons = [], [], []
|
||||
for device_id in rule.device_ids:
|
||||
row = by_id.get(device_id)
|
||||
if not row or row["quality"] != "valid":
|
||||
continue
|
||||
value = row["cleaned_value"]
|
||||
if rule.kind == "bounds":
|
||||
triggered = ((rule.lower is not None and value < rule.lower)
|
||||
or (rule.upper is not None and value > rule.upper))
|
||||
reason = f"{device_id}={value} {rule.unit},超出配置范围"
|
||||
else:
|
||||
previous = histories.get(device_id, [])[-rule.window_samples:]
|
||||
if len(previous) < rule.minimum_samples:
|
||||
continue
|
||||
baseline = median(previous)
|
||||
if baseline == 0:
|
||||
continue
|
||||
change = (value - baseline) / abs(baseline) * 100
|
||||
triggered = (abs(change) >= rule.threshold if rule.direction == "both"
|
||||
else change >= rule.threshold if rule.direction == "increase"
|
||||
else change <= -rule.threshold)
|
||||
reason = f"{device_id} 较历史中位值变化 {change:.1f}%,阈值 {rule.threshold}%"
|
||||
usable.append(device_id)
|
||||
if triggered:
|
||||
hits.append(device_id)
|
||||
reasons.append(reason)
|
||||
if len(hits) >= rule.minimum_devices:
|
||||
state["hits"] += 1
|
||||
state["recoveries"] = 0
|
||||
if state["event"] is None and state["hits"] >= rule.consecutive_samples:
|
||||
event_id = str(uuid5(NAMESPACE_URL, f"{result['replay_id']}:{rule.rule_id}:{at.isoformat()}"))
|
||||
event = {"event_id": event_id, "rule_id": rule.rule_id, "device_ids": hits,
|
||||
"occurred_at": at.isoformat(), "last_observed_at": at.isoformat(),
|
||||
"status": "active", "detail": ";".join(reasons)[:2000], "task_id": None}
|
||||
state["event"] = event
|
||||
changes.append({**event, "change": "raised"})
|
||||
result["anomaly_count"] = result.get("anomaly_count", 0) + 1
|
||||
elif state["event"]:
|
||||
state["event"]["last_observed_at"] = at.isoformat()
|
||||
elif len(usable) == len(rule.device_ids):
|
||||
state["hits"] = 0
|
||||
if state["event"]:
|
||||
state["recoveries"] += 1
|
||||
if state["recoveries"] >= rule.recovery_samples:
|
||||
event = {**state["event"], "status": "resolved", "resolved_at": at.isoformat(),
|
||||
"change": "resolved"}
|
||||
changes.append(event)
|
||||
state["event"] = None
|
||||
state["recoveries"] = 0
|
||||
# Incomplete frames cannot count as a consecutive hit/recovery.
|
||||
elif not usable and any(device in by_id for device in rule.device_ids):
|
||||
state["hits"] = state["recoveries"] = 0
|
||||
frozen = {device for rule in config.rules if states[rule.rule_id]["event"]
|
||||
for device in rule.device_ids}
|
||||
for row in rows:
|
||||
if row["quality"] == "valid" and row["device_id"] not in frozen:
|
||||
history = histories.setdefault(row["device_id"], [])
|
||||
history.append(row["cleaned_value"])
|
||||
histories[row["device_id"]] = history[-96:]
|
||||
result["processed_count"] = result.get("processed_count", 0) + len(rows)
|
||||
return result, changes
|
||||
Reference in New Issue
Block a user