feat(scada): add calibrated monitoring and incident recovery

This commit is contained in:
2026-11-07 19:44:58 +08:00
parent 26fa5d1909
commit ff8aba4964
109 changed files with 9165 additions and 629 deletions
+93 -16
View File
@@ -9,6 +9,8 @@ from uuid import NAMESPACE_URL, uuid5
import pandas as pd
from app.domain.schemas.scada_replay import ScadaReplayCreate
from app.domain.schemas.project_settings import ScadaMonitoringSettings
from app.services.scada_normal_profile import current_calibration, normal_profile_bounds
from app.services.burst_detection import (
TARGET_MU,
TARGET_N_ESTIMATORS,
@@ -42,6 +44,7 @@ def _relative_indicator(
period_end: datetime,
*,
direction: str,
calibration: dict | None = None,
) -> dict[str, Any]:
details: list[dict[str, Any]] = []
gaps: list[dict[str, str]] = []
@@ -54,30 +57,51 @@ def _relative_indicator(
if rule is None:
gaps.append({"device_id": device_id, "reason": "relative_change_rule_missing"})
continue
valid = _valid_rows(rows, device_id)
baseline = [row for row in valid if _time(row["time"]) < period_start][-rule.window_samples:]
current = [row for row in valid if period_start <= _time(row["time"]) <= period_end]
if len(baseline) < rule.minimum_samples:
gaps.append({"device_id": device_id, "reason": "baseline_not_ready"})
profile = calibration.get(device_id) if calibration is not None else None
if calibration is not None and profile is None:
gaps.append({"device_id": device_id, "reason": "adaptive_history_not_ready"})
continue
threshold = float(profile["threshold_percent"]) if profile else rule.threshold
valid = _valid_rows(rows, device_id)
current = [row for row in valid if period_start < _time(row["time"]) <= period_end]
latest = current[-1] if current else None
if not current:
gaps.append({"device_id": device_id, "reason": "period_reading_missing"})
continue
baseline = ([row for row in valid if _time(row["time"]) < _time(latest["time"])]
[-rule.window_samples:]) if latest else []
if len(baseline) < rule.minimum_samples:
gaps.append({"device_id": device_id, "reason": "baseline_not_ready"})
continue
reference = median(float(row["cleaned_value"]) for row in baseline)
if reference == 0:
gaps.append({"device_id": device_id, "reason": "zero_baseline"})
continue
latest = current[-1]
value = float(latest["cleaned_value"])
change = (value - reference) / abs(reference) * 100
hit = change <= -rule.threshold if direction == "decrease" else change >= rule.threshold
hit = change <= -threshold if direction == "decrease" else change >= threshold
bounds = normal_profile_bounds(profile.get("normal_profile"), _time(latest["time"])) if profile else None
if profile and bounds is None:
gaps.append({"device_id": device_id, "reason": "normal_phase_history_not_ready"})
continue
if bounds is not None:
hit = hit and not bounds[0] <= value <= bounds[1]
details.append({
"device_id": device_id,
"observed_at": _time(latest["time"]).isoformat(),
"baseline": reference,
"baseline_method": "recent_samples_median",
"baseline_start": _time(baseline[0]["time"]).isoformat(),
"baseline_end": _time(baseline[-1]["time"]).isoformat(),
"baseline_sample_count": len(baseline),
"value": value,
"value_change": value - reference,
"unit": rule.unit,
"change_percent": change,
"threshold_percent": rule.threshold,
"threshold_percent": threshold,
**({"normal_phase_lower": bounds[0], "normal_phase_upper": bounds[1]} if bounds else {}),
**({"threshold_method": profile["method"],
"configured_threshold_percent": rule.threshold} if profile else {}),
"hit": hit,
})
return {
@@ -94,6 +118,8 @@ def _burst_detection(
pressure_device_ids: list[str],
interval_seconds: int,
period_end: datetime,
*,
score_threshold: float = TARGET_SCORE_THRESHOLD,
) -> dict[str, Any]:
if len(pressure_device_ids) < 5:
return {"status": "sensor_coverage_insufficient", "hit": False,
@@ -112,7 +138,10 @@ def _burst_detection(
common_times = set(values) if common_times is None else common_times & set(values)
if not common_times:
return {"status": "baseline_not_ready", "hit": False, "required_samples": count}
target = max(common_times)
target = period_end
if target not in common_times:
return {"status": "current_reading_missing", "hit": False,
"target_time": target.isoformat(), "latest_common_time": max(common_times).isoformat()}
expected = [target - timedelta(seconds=interval_seconds * offset)
for offset in range(count - 1, -1, -1)]
incomplete = [device_id for device_id, values in series_by_device.items()
@@ -142,10 +171,11 @@ def _burst_detection(
score = float(latest["Score"])
return {
"status": "ready",
"hit": score <= TARGET_SCORE_THRESHOLD,
"hit": score <= score_threshold,
"target_time": target.isoformat(),
"target_score": score,
"score_threshold": TARGET_SCORE_THRESHOLD,
"score_interpretation": "lower_is_more_anomalous_not_probability",
"score_threshold": score_threshold,
"sensor_count": len(pressure_device_ids),
"reference_day_count": 14,
"latest_sensor_rankings": result["summary"].get("latest_sensor_rankings", []),
@@ -185,17 +215,64 @@ def evaluate_monitoring_period(
flow_ids = monitoring.get("outlet_flow_device_ids", [])
selected = set(config.device_ids)
role_gaps = sorted((set(pressure_ids) | set(flow_ids)) - selected)
if not pressure_ids or not flow_ids or role_gaps:
weighted_policy = monitoring.get("decision_policy") == "weighted"
if not weighted_policy and (not pressure_ids or not flow_ids or role_gaps):
return {**base, "status": "insufficient_data", "detail": "监测角色为空或不属于本轮回放设备",
"role_gaps": role_gaps,
"pressure": {"status": "unconfigured", "hit": False},
"outlet_flow": {"status": "unconfigured", "hit": False},
"burst_detection": {"status": "not_run", "hit": False}}
pressure = _relative_indicator(rows, config, pressure_ids, period_start, period_end, direction="decrease")
outlet_flow = _relative_indicator(rows, config, flow_ids, period_start, period_end, direction="increase")
detector = _burst_detection(
rows, pressure_ids, int(document["monitoring_interval_seconds"]), period_end,
calibration = current_calibration(document, config.start_time) if weighted_policy else None
if config.start_time > period_end:
calibration = None
base["role_gaps"] = role_gaps
pressure = _relative_indicator(
rows, config, pressure_ids, period_start, period_end, direction="decrease",
calibration=(calibration or {}).get("pressure", {}) if weighted_policy else None,
)
outlet_flow = _relative_indicator(
rows, config, flow_ids, period_start, period_end, direction="increase",
calibration=(calibration or {}).get("outlet_flow", {}) if weighted_policy else None,
) if flow_ids else {"status": "unconfigured", "hit": False, "hit_device_ids": [], "devices": [], "gaps": []}
detector = {"status": "baseline_not_ready", "hit": False} if weighted_policy and (
not calibration or calibration.get("status") != "ready"
) else _burst_detection(
rows, pressure_ids, int(document["monitoring_interval_seconds"]), period_end,
score_threshold=calibration["burst_detection"]["threshold"] if calibration else TARGET_SCORE_THRESHOLD,
)
if weighted_policy:
settings = ScadaMonitoringSettings.model_validate(monitoring)
weights = settings.weights.model_dump()
indicators = {"burst_detection": detector, "pressure": pressure, "outlet_flow": outlet_flow}
contributions = {key: weights[key] if indicator["status"] == "ready" and indicator["hit"] else 0.0
for key, indicator in indicators.items()}
score = round(sum(contributions.values()), 10)
complete = pressure["status"] == detector["status"] == "ready"
diagnostic_missing = sorted({device for rule in config.rules if rule.kind == "relative_change"
for device in rule.device_ids}
- set((calibration or {}).get("diagnostic", {})))
if not complete:
status, detail = "insufficient_data", (
"旧回放缺少新版历史诊断校准,请停止后新建演示" if calibration is None
else "压力数据或爆管侦测基线不足,尚不能作出组合判定"
)
elif score >= settings.alert_threshold:
status, detail = "suspected_burst", "爆管相关指标加权达到疑似爆管门槛"
elif any(indicator["hit"] for indicator in indicators.values()):
status, detail = "attention", "部分指标异常,进入调查;综合结果尚未达到爆管嫌疑门槛"
elif diagnostic_missing:
status, detail = "insufficient_data", "部分测点缺少完整历史同期基线,不能判定全部指标正常"
else:
status, detail = "normal", "本周期爆管相关指标均未命中"
detail += f"(综合 {score:.2f} / 门槛 {settings.alert_threshold:.2f},流量权重 {weights['outlet_flow']:.0%})"
return {**base, "status": status, "detail": detail, "pressure": pressure,
"outlet_flow": outlet_flow, "burst_detection": detector,
"weighted": {"score": score, "threshold": settings.alert_threshold,
"weights": weights, "contributions": contributions,
"unavailable_indicators": [key for key, value in indicators.items() if value["status"] != "ready"],
"calibration_cutoff": calibration["knowledge_cutoff"] if calibration else None,
"diagnostic_unavailable_device_ids": diagnostic_missing,
"missing_weight_policy": "zero_contribution_without_renormalization"}}
complete = pressure["status"] == outlet_flow["status"] == detector["status"] == "ready"
hits = (bool(detector["hit"]), bool(pressure["hit"]), bool(outlet_flow["hit"]))
if not complete: