140 lines
7.4 KiB
Python
140 lines
7.4 KiB
Python
"""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
|