142 lines
7.5 KiB
Python
142 lines
7.5 KiB
Python
"""Fit causal replay thresholds in bounded numeric arrays, before publishing history."""
|
|
from datetime import datetime, timedelta
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
from app.domain.schemas.project_settings import ScadaMonitoringSettings
|
|
from app.domain.schemas.scada_replay import ScadaReplayCreate
|
|
from app.services.scada_normal_profile import CALIBRATION_VERSION, DIAGNOSTIC_POLICY
|
|
|
|
|
|
def adaptive_score_threshold(scores: list[float]) -> dict:
|
|
"""Keep the cutoff below the normal lower tail, with a fixed noise margin."""
|
|
values = np.asarray(scores, dtype=float)
|
|
if len(values) < 5 or not np.isfinite(values).all():
|
|
return {"status": "insufficient_data", "reason": "normal_score_history_incomplete"}
|
|
center = float(np.median(values))
|
|
sigma = float(1.4826 * np.median(np.abs(values - center)))
|
|
lower_tail = float(np.quantile(values, 0.05))
|
|
return {
|
|
"status": "ready",
|
|
"threshold": min(-0.04, center - 3 * sigma, lower_tail - 0.02),
|
|
"normal_score_median": center,
|
|
"normal_score_min": float(values.min()),
|
|
"normal_score_max": float(values.max()),
|
|
"normal_score_robust_sigma": sigma,
|
|
"normal_score_lower_quantile": lower_tail,
|
|
"normal_score_count": len(values),
|
|
"method": "min(-0.04, median-3*robust_sigma, normal_q05-0.02)",
|
|
}
|
|
|
|
|
|
class MonitoringCalibrationBuilder:
|
|
"""Retain bounded numeric series, never full raw pages or device batches."""
|
|
|
|
def __init__(self, document: dict, config: ScadaReplayCreate):
|
|
self.settings = ScadaMonitoringSettings.model_validate(document["monitoring_config"])
|
|
self.config = config
|
|
self.cutoff = config.start_time
|
|
self.start = self.cutoff - timedelta(days=15)
|
|
self.interval = int(document["monitoring_interval_seconds"])
|
|
self.supported = self.interval % 60 == 0 and 86400 % self.interval == 0
|
|
self.count = 15 * (86400 // self.interval) if self.supported else 0
|
|
ids = dict.fromkeys(self.settings.pressure_device_ids + self.settings.outlet_flow_device_ids
|
|
+ [device for rule in config.rules if rule.kind == "relative_change"
|
|
for device in rule.device_ids])
|
|
self.values = {device: np.full(self.count, np.nan) for device in ids}
|
|
|
|
def add(self, rows: list[dict]) -> None:
|
|
for row in rows:
|
|
device = row["device_id"]
|
|
if device not in self.values:
|
|
continue
|
|
at = datetime.fromisoformat(row["time"])
|
|
if at >= self.cutoff:
|
|
raise ValueError("动态阈值拟合不能读取回放开始时刻及之后的观测")
|
|
offset = (at - self.start).total_seconds()
|
|
if (not self.supported or offset < 0 or offset % self.interval
|
|
or row.get("quality") not in {"valid", "corrected"}
|
|
or row.get("cleaned_value") is None):
|
|
continue
|
|
index = int(offset // self.interval)
|
|
if np.isfinite(self.values[device][index]):
|
|
raise ValueError("动态阈值拟合存在重复的设备时间点")
|
|
self.values[device][index] = float(row["cleaned_value"])
|
|
|
|
def fit(self) -> dict:
|
|
base = {
|
|
"version": CALIBRATION_VERSION,
|
|
"diagnostic_policy": DIAGNOSTIC_POLICY,
|
|
"knowledge_cutoff": self.cutoff.isoformat(),
|
|
"history_start": self.start.isoformat(),
|
|
"required_samples_per_device": self.count,
|
|
"pressure": {}, "outlet_flow": {}, "diagnostic": {},
|
|
}
|
|
if not self.supported:
|
|
return {**base, "status": "insufficient_data", "reason": "unsupported_sampling_interval"}
|
|
available = {device: int(np.isfinite(values).sum()) for device, values in self.values.items()}
|
|
# A regular diurnal rise/reversal can be a huge percentage of a small
|
|
# preceding flow. Judge its absolute value against causal normal readings
|
|
# at the same sampling phase before treating that percentage as abnormal.
|
|
points_per_day = 86400 // self.interval
|
|
for device, values in self.values.items():
|
|
if available[device] != self.count:
|
|
continue
|
|
days = values.reshape(15, points_per_day)
|
|
center = np.median(days, axis=0)
|
|
sigma = 1.4826 * np.median(np.abs(days - center), axis=0)
|
|
margin = np.maximum(3 * sigma, np.maximum(np.abs(center) * 1e-6, 1e-9))
|
|
base["diagnostic"][device] = {
|
|
"phase_origin": self.start.isoformat(), "interval_seconds": self.interval,
|
|
"lower": (days.min(axis=0) - margin).tolist(),
|
|
"upper": (days.max(axis=0) + margin).tolist(),
|
|
"method": "normal_same_phase_range_plus_3_robust_sigma", "normal_day_count": 15,
|
|
}
|
|
for kind, ids, direction, floor in (
|
|
("pressure", self.settings.pressure_device_ids, -1, 3.0),
|
|
("outlet_flow", self.settings.outlet_flow_device_ids, 1, 5.0),
|
|
):
|
|
for device in ids:
|
|
if available[device] != self.count:
|
|
continue
|
|
rule = next((rule for rule in self.config.rules
|
|
if rule.kind == "relative_change" and device in rule.device_ids), None)
|
|
if rule is None:
|
|
continue
|
|
series = pd.Series(self.values[device])
|
|
reference = series.rolling(rule.window_samples, min_periods=rule.minimum_samples).median().shift(1)
|
|
changes = (direction * 100 * (series - reference) / reference.abs()).to_numpy()
|
|
changes = changes[np.isfinite(changes)]
|
|
if not len(changes):
|
|
continue
|
|
center = float(np.median(changes))
|
|
sigma = float(1.4826 * np.median(np.abs(changes - center)))
|
|
base[kind][device] = {
|
|
"threshold_percent": max(floor, center + 3 * sigma),
|
|
"normal_change_median": center,
|
|
"normal_change_robust_sigma": sigma,
|
|
"normal_change_count": int(len(changes)),
|
|
"method": "normal_recent_window_change_median_plus_3_robust_sigma",
|
|
"normal_profile": base["diagnostic"].get(device),
|
|
}
|
|
pressure_ids = self.settings.pressure_device_ids
|
|
incomplete = {device: available[device] for device in pressure_ids if available[device] != self.count}
|
|
if len(pressure_ids) < 5 or incomplete:
|
|
return {**base, "status": "insufficient_data", "reason": "pressure_history_incomplete",
|
|
"available_samples": available}
|
|
from app.services import scada_replay_monitoring as monitoring
|
|
|
|
index = pd.date_range(start=self.start, periods=self.count, freq=pd.Timedelta(seconds=self.interval))
|
|
frame = pd.DataFrame({device: self.values[device] for device in pressure_ids}, index=index)
|
|
result = monitoring.run_burst_detection(
|
|
network="scada-replay-baseline", username="scada-replay",
|
|
observed_pressure_data=frame, points_per_day=86400 // self.interval,
|
|
mu=monitoring.TARGET_MU,
|
|
iforest_params={"n_estimators": monitoring.TARGET_N_ESTIMATORS,
|
|
"random_state": monitoring.TARGET_RANDOM_STATE, "contamination": "auto"},
|
|
sensor_nodes=pressure_ids,
|
|
)
|
|
score = adaptive_score_threshold([float(row["Score"]) for row in result["rows"]])
|
|
return {**base, "status": score["status"], "burst_detection": score}
|