601 lines
35 KiB
Python
601 lines
35 KiB
Python
"""Durable, project-scoped SCADA playback; raw is never inserted or rewritten."""
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime, timedelta, timezone
|
||
import json
|
||
from uuid import NAMESPACE_URL, uuid4, uuid5
|
||
|
||
from fastapi import HTTPException
|
||
from app.auth.permissions import permissions_for_context
|
||
|
||
from app.domain.schemas.project_settings import ScadaMonitoringSettings
|
||
from app.domain.schemas.scada_replay import ScadaReplayCreate, ScadaReplayCommand, ScadaReplaySnapshot
|
||
from app.infra.db.postgresql.scada import ScadaInfoRepository
|
||
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
|
||
from app.services.content_digest import digest
|
||
from app.services.scada_replay_processing import prepare_cleaning, detect_frame
|
||
from app.services.scada_replay_monitoring import evaluate_monitoring_period
|
||
from app.services.scada_normal_profile import current_calibration, diagnostic_signal, weighted_diagnosis
|
||
|
||
|
||
# 500 devices at 15-minute sampling need 720,000 rows for fifteen days.
|
||
# Leave headroom without allowing dense sources to make preparation unbounded.
|
||
BASELINE_PAGE_SIZE = 2000
|
||
BASELINE_MAX_ROWS = 1_000_000
|
||
BASELINE_MAX_BYTES = 512 * 1024 * 1024
|
||
|
||
|
||
class BaselineBudgetExceeded(RuntimeError):
|
||
"""The unpublished baseline exceeded its fixed preparation budget."""
|
||
|
||
|
||
def parse_time(value: str) -> datetime:
|
||
return datetime.fromisoformat(value)
|
||
|
||
|
||
def snapshot(document: dict) -> ScadaReplaySnapshot:
|
||
return ScadaReplaySnapshot(
|
||
**{key: document[key] for key in (
|
||
"replay_id", "project_id", "revision", "status", "start_time", "end_time",
|
||
"virtual_now", "created_at", "updated_at",
|
||
)},
|
||
delivered_until=document.get("delivered_until"), processed_until=document.get("processed_until"),
|
||
processed_count=document.get("processed_count", 0), anomaly_count=document.get("anomaly_count", 0),
|
||
devices=document["devices"], rules=document["config"]["rules"],
|
||
latest=list(document.get("latest", {}).values()),
|
||
active_events=[state["event"] for state in document.get("rule_states", {}).values() if state.get("event")],
|
||
error=document.get("error"),
|
||
monitoring_interval_seconds=document.get("monitoring_interval_seconds"),
|
||
step_pending=document.get("step_pending", False),
|
||
monitoring_count=document.get("monitoring_count", 0),
|
||
last_monitoring=document.get("last_monitoring"),
|
||
# Old persisted documents without a policy still use three-signal detection.
|
||
monitoring_config=({"decision_policy": "all_signals", **document["monitoring_config"]}
|
||
if document.get("monitoring_config") else None),
|
||
monitoring_calibration=document.get("monitoring_calibration"),
|
||
)
|
||
|
||
|
||
class ScadaReplayService:
|
||
def __init__(self, repository, context, timescale=None, *, clock=None):
|
||
self.repo, self.context, self.timescale = repository, context, timescale
|
||
self.clock = clock or (lambda: datetime.now(timezone.utc))
|
||
|
||
async def ensure_storage(self):
|
||
if not await self.repo.available():
|
||
raise HTTPException(503, "SCADA 回放存储未配置,需要应用 031_scada_replays.sql")
|
||
|
||
async def create(
|
||
self,
|
||
command: ScadaReplayCreate,
|
||
*,
|
||
monitoring_interval_seconds: int | None,
|
||
monitoring_config: ScadaMonitoringSettings | None = None,
|
||
):
|
||
await self.ensure_storage()
|
||
async with self.repo.control():
|
||
previous = await self.repo.by_request(command.request_id)
|
||
config = command.model_dump(mode="json")
|
||
if previous:
|
||
if previous["config"] != config:
|
||
raise HTTPException(409, "创建请求号已用于不同回放配置")
|
||
return snapshot(previous)
|
||
if monitoring_interval_seconds is None:
|
||
raise HTTPException(422, "请先配置项目 settings.scada_monitoring_interval_seconds,再创建演示")
|
||
replay_id = str(uuid4())
|
||
if await self.repo.active_other(replay_id):
|
||
raise HTTPException(409, "当前项目已有回放,请先停止原回放再新建")
|
||
metadata = {row["device_id"]: row for row in await ScadaInfoRepository.get_scadas(self.repo.connection)}
|
||
if not set(command.device_ids) <= metadata.keys():
|
||
raise HTTPException(422, "设备不属于当前项目")
|
||
for rule in command.rules:
|
||
if any(metadata[device]["measurement_unit"] != rule.unit for device in rule.device_ids):
|
||
raise HTTPException(422, "规则单位与设备单位不一致,请分组配置")
|
||
if any(metadata[device]["device_type"] == "quality" for device in rule.device_ids) and not rule.metric_name.strip():
|
||
raise HTTPException(422, "水质规则必须明确指标名称,不能仅凭单位解释读数")
|
||
if monitoring_config:
|
||
role_ids = set(monitoring_config.pressure_device_ids) | set(monitoring_config.outlet_flow_device_ids)
|
||
if not role_ids <= metadata.keys():
|
||
raise HTTPException(422, "项目 SCADA 监测角色包含不存在的设备")
|
||
if any(metadata[device]["device_type"] != "pressure" for device in monitoring_config.pressure_device_ids):
|
||
raise HTTPException(422, "压力监测角色必须引用 pressure 设备")
|
||
if any(metadata[device]["device_type"] not in {"pipe_flow", "flow"}
|
||
for device in monitoring_config.outlet_flow_device_ids):
|
||
raise HTTPException(422, "出厂边界流量角色必须引用流量设备")
|
||
now = self.clock().isoformat()
|
||
document = {
|
||
"replay_id": replay_id, "project_id": str(self.context.project_id),
|
||
"created_by": str(self.context.user_id), "config": config,
|
||
"revision": 1, "status": "ready", "start_time": config["start_time"],
|
||
"end_time": config["end_time"], "virtual_now": config["start_time"],
|
||
"created_at": now, "updated_at": now,
|
||
"monitoring_interval_seconds": monitoring_interval_seconds,
|
||
"monitoring_config": monitoring_config.model_dump(mode="json") if monitoring_config else None,
|
||
"devices": [metadata[device] for device in command.device_ids],
|
||
"batch_sequence": 0, "processed_count": 0, "anomaly_count": 0,
|
||
"monitoring_count": 0, "latest": {}, "histories": {}, "rule_states": {}, "receipts": {},
|
||
}
|
||
await self.repo.insert(document)
|
||
return snapshot(document)
|
||
|
||
async def command(self, replay_id, command: ScadaReplayCommand):
|
||
await self.ensure_storage()
|
||
async with self.repo.control():
|
||
document = await self.repo.get(replay_id)
|
||
key, body = str(command.request_id), command.model_dump(mode="json", exclude_none=True)
|
||
receipt = document.setdefault("receipts", {}).get(key)
|
||
if receipt:
|
||
if receipt != body:
|
||
raise HTTPException(409, "命令请求号已用于不同内容")
|
||
return snapshot(document)
|
||
if command.expected_revision != document["revision"]:
|
||
raise HTTPException(409, "回放控制状态已更新,请刷新后重试")
|
||
action, status = command.action, document["status"]
|
||
if (action in {"step", "monitor", "retry"} and document.get("warmed")
|
||
and weighted_diagnosis(document)
|
||
and current_calibration(document, parse_time(document["start_time"])) is None):
|
||
raise HTTPException(409, "旧回放缺少新版历史诊断校准,请停止后新建演示")
|
||
if document.get("step_pending") and action in {"step", "monitor"}:
|
||
raise HTTPException(409, "当前时间步正在处理,请完成后继续")
|
||
if action in {"step", "monitor", "retry"} and await self.repo.active_other(replay_id):
|
||
raise HTTPException(409, "当前项目已有另一条活动回放")
|
||
if action == "step" and status in {"ready", "paused"}:
|
||
interval = document.get("monitoring_interval_seconds")
|
||
if interval is None:
|
||
raise HTTPException(409, "旧回放未固定监测周期,请停止后新建演示")
|
||
start = parse_time(document["start_time"])
|
||
elapsed = (parse_time(document["virtual_now"]) - start).total_seconds()
|
||
target = start + timedelta(seconds=(int(elapsed // interval) + 1) * interval)
|
||
document["step_start"] = document["virtual_now"]
|
||
document["step_anomaly_signals"] = []
|
||
document["step_target"] = min(parse_time(document["end_time"]), target).isoformat()
|
||
document["status"], document["step_pending"] = "paused", True
|
||
elif action == "monitor" and status in {"ready", "paused", "completed"}:
|
||
target = command.at_time
|
||
if target is None:
|
||
raise HTTPException(422, "选定时刻监测需要提供 at_time")
|
||
start = parse_time(document["start_time"])
|
||
current = parse_time(document["virtual_now"])
|
||
if not start <= target <= parse_time(document["end_time"]):
|
||
raise HTTPException(422, "监测时刻必须在本轮演示区间内")
|
||
if target < current:
|
||
raise HTTPException(409, "监测历史时刻需要新建一轮回放,避免使用后续状态")
|
||
interval = document.get("monitoring_interval_seconds")
|
||
if interval is None:
|
||
raise HTTPException(409, "本轮未固定监测周期,请停止后新建演示")
|
||
previous = document.get("last_monitoring") or {}
|
||
# Reusing an immutable result must not count as another normal
|
||
# period, resolve an incident, or create another investigation.
|
||
if not previous.get("period_end") or parse_time(previous["period_end"]) != target:
|
||
document["step_start"] = (current if target > current
|
||
else max(start, target - timedelta(seconds=interval))).isoformat()
|
||
document["step_target"] = target.isoformat()
|
||
document["step_anomaly_signals"] = []
|
||
document["status"], document["step_pending"] = "paused", True
|
||
elif action == "stop" and status not in {"completed", "stopped"}:
|
||
document["status"], document["step_pending"] = "stopped", False
|
||
document.pop("step_target", None)
|
||
document.pop("step_start", None)
|
||
elif action == "retry" and status == "failed":
|
||
if "SOURCE_CHANGED" in (document.get("error") or ""):
|
||
raise HTTPException(409, "原始数据发生变化,需要停止并新建回放")
|
||
document["status"], document["error"] = "paused", None
|
||
document["step_pending"] = bool(document.get("step_target"))
|
||
else:
|
||
raise HTTPException(409, "当前回放状态不支持此操作")
|
||
document["revision"] += 1
|
||
document["receipts"][key] = body
|
||
if len(document["receipts"]) > 256:
|
||
del document["receipts"][next(iter(document["receipts"]))]
|
||
await self.repo.save(document)
|
||
return snapshot(document)
|
||
|
||
async def process_pending_step(self, replay_id):
|
||
await self.ensure_storage()
|
||
async with self.repo.processing() as owned:
|
||
if not owned:
|
||
raise HTTPException(409, "当前时间步正在由另一请求处理")
|
||
while True:
|
||
document = await self.repo.get(replay_id)
|
||
if not document.get("step_pending"):
|
||
return snapshot(document)
|
||
try:
|
||
await self._advance(document)
|
||
except Exception as exc:
|
||
# Failure never discards the prepared batch; retry resumes it.
|
||
document = await self.repo.get(replay_id)
|
||
document["status"], document["step_pending"] = "failed", False
|
||
if isinstance(exc, HTTPException):
|
||
document["error"] = str(exc.detail)
|
||
elif isinstance(exc, BaselineBudgetExceeded):
|
||
document["error"] = str(exc)
|
||
else:
|
||
document["error"] = (
|
||
"SOURCE_CHANGED: 原始观测已改变,需要新建回放"
|
||
if "SOURCE_CHANGED" in str(exc)
|
||
else "SCADA 处理失败,已保留批次与进度;请检查服务日志后重试"
|
||
)
|
||
async with self.repo.connection.transaction():
|
||
await self.repo.save(document)
|
||
raise
|
||
|
||
async def _advance(self, document):
|
||
config = ScadaReplayCreate.model_validate(document["config"])
|
||
replay_id = document["replay_id"]
|
||
if not document.get("warmed"):
|
||
await self._freeze_baseline(document, config)
|
||
if weighted_diagnosis(document) and current_calibration(document, config.start_time) is None:
|
||
raise HTTPException(409, "旧回放缺少新版历史诊断校准,请停止后新建演示")
|
||
batch = await self.repo.pending_batch(replay_id)
|
||
if batch is None:
|
||
step = document.get("step_pending", False)
|
||
virtual = parse_time(document["virtual_now"])
|
||
through = parse_time(document["step_target"]) if step else virtual
|
||
after = ((parse_time(document["cursor"][0]), document["cursor"][1])
|
||
if document.get("cursor") else None)
|
||
async with self.timescale.transaction():
|
||
rows = await ScadaRepository.get_raw_page(
|
||
self.timescale, config.device_ids, config.start_time, config.end_time,
|
||
through, 501, after, frame=True,
|
||
)
|
||
if len(rows) > 500:
|
||
raise ValueError("SCADA frame exceeds selected device bound")
|
||
at = rows[0]["time"] if rows else through
|
||
if step:
|
||
virtual = through
|
||
document["virtual_now"] = virtual.isoformat()
|
||
if not rows and document.get("processed_until") == at.isoformat() and not step:
|
||
async with self.repo.connection.transaction():
|
||
await self.repo.save(document)
|
||
return
|
||
sequence = document["batch_sequence"] + 1
|
||
refs = [{"time": row["time"].isoformat(), "device_id": row["device_id"],
|
||
"source_hash": row["source_hash"]} for row in rows]
|
||
batch = {"sequence": sequence, "stage": "selected", "at": at.isoformat(),
|
||
"refs": refs, "input_digest": digest(refs), "events": []}
|
||
async with self.repo.connection.transaction():
|
||
document["batch_sequence"] = sequence
|
||
document["delivered_until"] = at.isoformat()
|
||
await self.repo.save_batch(replay_id, batch)
|
||
await self.repo.save(document)
|
||
else:
|
||
rows = None
|
||
if batch["stage"] == "selected":
|
||
if rows is None:
|
||
# Only crash recovery reloads the selected raw frame.
|
||
at = parse_time(batch["at"])
|
||
async with self.timescale.transaction():
|
||
rows = await ScadaRepository.get_raw_page(
|
||
self.timescale, config.device_ids, at, config.end_time, at, 501,
|
||
) if batch["refs"] else []
|
||
actual = [{"time": row["time"].isoformat(), "device_id": row["device_id"],
|
||
"source_hash": row["source_hash"]} for row in rows]
|
||
if digest(actual) != batch["input_digest"]:
|
||
raise ValueError("SOURCE_CHANGED: selected raw frame differs")
|
||
batch["outputs"] = prepare_cleaning(rows, config)
|
||
batch["stage"] = "clean_prepared"
|
||
async with self.repo.connection.transaction():
|
||
await self.repo.save_batch(replay_id, batch)
|
||
if batch["stage"] == "clean_prepared":
|
||
await ScadaRepository.apply_replay_cleaning(self.timescale, batch["outputs"])
|
||
batch["stage"] = "cleaned"
|
||
async with self.repo.connection.transaction():
|
||
await self.repo.save_batch(replay_id, batch)
|
||
if batch["stage"] == "cleaned":
|
||
result, changes = detect_frame(document, batch["outputs"], config, parse_time(batch["at"]))
|
||
result["processed_until"] = batch["at"]
|
||
target = result.get("step_target")
|
||
was_step = bool(target)
|
||
result["step_pending"] = bool(target and parse_time(batch["at"]) < parse_time(target))
|
||
if was_step:
|
||
signals = {item["event_id"]: item for item in result.get("step_anomaly_signals", [])
|
||
if diagnostic_signal(result, item)}
|
||
for event in changes:
|
||
if event["change"] == "raised" and diagnostic_signal(result, event):
|
||
signals[event["event_id"]] = {key: event[key] for key in (
|
||
"event_id", "rule_id", "device_ids", "detail", "diagnostic_policy"
|
||
) if key in event}
|
||
result["step_anomaly_signals"] = list(signals.values())
|
||
monitoring = None
|
||
if was_step and not result["step_pending"]:
|
||
period_start = parse_time(result.get("step_start", result["start_time"]))
|
||
period_end = parse_time(target)
|
||
role = result.get("monitoring_config") or {}
|
||
role_ids = list(dict.fromkeys(
|
||
role.get("pressure_device_ids", []) + role.get("outlet_flow_device_ids", [])
|
||
))
|
||
previous = await self.repo.monitoring_outputs(
|
||
replay_id, role_ids, period_end - timedelta(days=15),
|
||
period_end, batch["sequence"],
|
||
) if role_ids else []
|
||
from app.mcp.analysis_service import bounded_calculation
|
||
monitoring = await bounded_calculation(
|
||
evaluate_monitoring_period,
|
||
result,
|
||
previous + batch["outputs"],
|
||
period_start=period_start,
|
||
period_end=period_end,
|
||
batch_sequence=batch["sequence"],
|
||
)
|
||
# Any configured measurement rule can enter diagnosis, including
|
||
# indicators outside the pressure/outlet roles used for burst scoring.
|
||
signals = {item["event_id"]: item for item in result.get("step_anomaly_signals", [])
|
||
if diagnostic_signal(result, item)}
|
||
for state in result.get("rule_states", {}).values():
|
||
event = state.get("event")
|
||
if event and diagnostic_signal(result, event):
|
||
signals[event["event_id"]] = {key: event[key] for key in (
|
||
"event_id", "rule_id", "device_ids", "detail", "diagnostic_policy"
|
||
) if key in event}
|
||
monitoring["anomaly_signals"] = list(signals.values())
|
||
if signals and monitoring["status"] == "normal":
|
||
monitoring["status"] = "attention"
|
||
monitoring["detail"] = "本周期曾检出测点异常,进入异常调查与爆管诊断"
|
||
result["monitoring_count"] = result.get("monitoring_count", 0) + 1
|
||
if not result["step_pending"]:
|
||
result.pop("step_target", None)
|
||
result.pop("step_start", None)
|
||
result.pop("step_anomaly_signals", None)
|
||
if batch["refs"]:
|
||
last = batch["refs"][-1]
|
||
result["cursor"] = [last["time"], last["device_id"]]
|
||
if parse_time(batch["at"]) >= config.end_time:
|
||
result["status"] = "completed"
|
||
batch["stage"], batch["events"] = "detected", changes
|
||
if monitoring is not None:
|
||
batch["monitoring"] = monitoring
|
||
async with self.repo.connection.transaction():
|
||
await self._create_investigations(result, changes, batch, create_tasks=not was_step)
|
||
if monitoring is not None:
|
||
await self._create_monitoring_task(result, monitoring, batch)
|
||
batch["monitoring"] = monitoring
|
||
result["last_monitoring"] = monitoring
|
||
await self.repo.save_batch(replay_id, batch)
|
||
await self.repo.save(result)
|
||
|
||
async def _freeze_baseline(self, document, config):
|
||
"""Stage bounded pages, then atomically publish one causal source snapshot."""
|
||
if document.get("warmed"):
|
||
return
|
||
start = config.start_time - timedelta(days=15)
|
||
batch_count, row_count, byte_count, after = 0, 0, 0, None
|
||
counts = {device_id: 0 for device_id in config.device_ids}
|
||
valid_counts = dict(counts)
|
||
histories = {device_id: [] for device_id in config.device_ids}
|
||
window = max(rule.window_samples for rule in config.rules)
|
||
calibration = None
|
||
if (document.get("monitoring_config") or {}).get("decision_policy") == "weighted":
|
||
from app.services.scada_monitoring_calibration import MonitoringCalibrationBuilder
|
||
calibration = MonitoringCalibrationBuilder(document, config)
|
||
async with self.repo.connection.transaction():
|
||
await self.repo.clear_unpublished_baseline(document["replay_id"])
|
||
# Keep only the current page in memory. Each BizDB write commits before
|
||
# the next source read; clean_prepared baseline pages remain invisible.
|
||
async with self.timescale.transaction():
|
||
await ScadaRepository.begin_replay_baseline_snapshot(self.timescale)
|
||
while True:
|
||
rows = await ScadaRepository.get_raw_page(
|
||
self.timescale, config.device_ids, start, config.start_time,
|
||
config.start_time, min(BASELINE_PAGE_SIZE, BASELINE_MAX_ROWS - row_count + 1), after,
|
||
)
|
||
if not rows:
|
||
break
|
||
if row_count + len(rows) > BASELINE_MAX_ROWS:
|
||
raise BaselineBudgetExceeded(
|
||
f"BASELINE_BUDGET_EXCEEDED: 十五天历史基线超过 {BASELINE_MAX_ROWS:,} 条准备预算,"
|
||
"尚未发布;请减少回放设备或核查历史采样密度后重试"
|
||
)
|
||
outputs = prepare_cleaning(rows, config)
|
||
if calibration is not None:
|
||
calibration.add(outputs)
|
||
batch = {
|
||
"sequence": -batch_count - 1, "stage": "clean_prepared", "kind": "baseline",
|
||
"at": outputs[-1]["time"], "outputs": outputs, "events": [],
|
||
}
|
||
batch_bytes = len(json.dumps(batch, ensure_ascii=False, allow_nan=False,
|
||
separators=(",", ":")).encode("utf-8"))
|
||
if byte_count + batch_bytes > BASELINE_MAX_BYTES:
|
||
raise BaselineBudgetExceeded(
|
||
f"BASELINE_BUDGET_EXCEEDED: 十五天历史基线超过 {BASELINE_MAX_BYTES // (1024 * 1024)} MiB 准备预算,"
|
||
"尚未发布;请减少回放设备或核查历史采样密度后重试"
|
||
)
|
||
async with self.repo.connection.transaction():
|
||
await self.repo.save_batch(document["replay_id"], batch)
|
||
batch_count += 1
|
||
row_count += len(outputs)
|
||
byte_count += batch_bytes
|
||
for row in outputs:
|
||
device_id = row["device_id"]
|
||
counts[device_id] += 1
|
||
if row["quality"] == "valid":
|
||
valid_counts[device_id] += 1
|
||
histories[device_id].append(row["cleaned_value"])
|
||
histories[device_id] = histories[device_id][-window:]
|
||
last = rows[-1]
|
||
after = (last["time"], last["device_id"])
|
||
document["baseline"] = {
|
||
"start_time": start.isoformat(), "end_time": config.start_time.isoformat(),
|
||
"end_exclusive": True, "sample_counts": counts, "valid_sample_counts": valid_counts,
|
||
"row_count": row_count, "batch_count": batch_count, "byte_count": byte_count,
|
||
}
|
||
document["histories"] = histories
|
||
if calibration is not None:
|
||
from app.mcp.analysis_service import bounded_calculation
|
||
document["monitoring_calibration"] = await bounded_calculation(calibration.fit)
|
||
document["warmed"] = True
|
||
async with self.repo.connection.transaction():
|
||
await self.repo.publish_baseline(document["replay_id"])
|
||
await self.repo.save(document)
|
||
|
||
async def _create_investigations(self, document, changes, batch, *, create_tasks=True):
|
||
from app.domain.schemas.operational_task import OperationalTaskCreate, TaskSource, TaskTrigger
|
||
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
|
||
from app.services.operational_tasks import OperationalTaskService
|
||
from app.domain.schemas.task_alert import TaskAlertEvent
|
||
from app.infra.db.postgresql.task_alerts import TaskAlertRepository
|
||
from app.services.task_alerts import TaskAlertService
|
||
from uuid import UUID
|
||
producer = "scada-replay:" + document["replay_id"]
|
||
alerts = TaskAlertService(TaskAlertRepository(self.repo.connection, self.context.project_id),
|
||
self.context, internal_producer_id=producer)
|
||
tasks = OperationalTaskService(OperationalTaskRepository(self.repo.connection, self.context.project_id),
|
||
self.context, permissions_for_context(self.context))
|
||
for event in changes:
|
||
# Period diagnosis owns measurement notifications during manual steps.
|
||
# Retain rule events as evidence; quality alerts remain independent.
|
||
if (not create_tasks and event.get("category") != "data_quality"
|
||
and (event["change"] == "raised" or event.get("notification_suppressed"))):
|
||
if event["change"] == "raised":
|
||
event["batch_sequence"] = batch["sequence"]
|
||
event["notification_suppressed"] = True
|
||
document["rule_states"][event.get("state_key", event["rule_id"])]["event"].update(
|
||
batch_sequence=batch["sequence"], notification_suppressed=True,
|
||
)
|
||
continue
|
||
if event["change"] == "raised":
|
||
event["batch_sequence"] = batch["sequence"]
|
||
document["rule_states"][event.get("state_key", event["rule_id"])]["event"]["batch_sequence"] = batch["sequence"]
|
||
await alerts.push(TaskAlertEvent(
|
||
event_id=uuid5(NAMESPACE_URL, event["event_id"] + ":" + event["change"]),
|
||
alert_id=event["event_id"], producer_id=producer, event_type=event["change"],
|
||
occurred_at=event["occurred_at"], condition_id=None,
|
||
source={"kind": "scada_replay", "replay_id": document["replay_id"],
|
||
"rule_id": event["rule_id"], "batch_sequence": event["batch_sequence"]},
|
||
title=f"SCADA 回放异常 · {event['rule_id']}", description=event["detail"], severity="warning",
|
||
))
|
||
if event["change"] != "raised":
|
||
if event.get("task_id"):
|
||
await tasks.advance_replay_investigation(UUID(event["task_id"]), {
|
||
"replay_id": document["replay_id"], "knowledge_cutoff": batch["at"],
|
||
"batch_sequence": batch["sequence"],
|
||
}, uuid5(NAMESPACE_URL, event["event_id"] + ":recovery"))
|
||
continue
|
||
if event.get("category") == "data_quality":
|
||
continue
|
||
if not create_tasks:
|
||
continue
|
||
external = f"scada_replay:{document['replay_id']}:event:{event['event_id']}"
|
||
command = OperationalTaskCreate(
|
||
request_id=uuid5(NAMESPACE_URL, external), task_type="event_investigation",
|
||
workflow="scada_anomaly", title=f"SCADA 回放调查 · {event['rule_id']}",
|
||
summary=event["detail"], source=TaskSource(
|
||
kind="external_system", external_id=external, external_revision=1,
|
||
event_kind="scada_anomaly",
|
||
replay_context={"replay_id": document["replay_id"],
|
||
"knowledge_cutoff": batch["at"], "batch_sequence": batch["sequence"]},
|
||
),
|
||
trigger=TaskTrigger(kind="event", occurred_at=event["occurred_at"], detail="已存 SCADA 历史的回放检测信号,原因尚待调查"),
|
||
)
|
||
task = await tasks.create(command)
|
||
event["task_id"] = str(task.task_id)
|
||
document["rule_states"][event.get("state_key", event["rule_id"])]["event"]["task_id"] = event["task_id"]
|
||
|
||
async def _create_monitoring_task(self, document, monitoring, batch):
|
||
from app.services.scada_monitoring_incidents import attach_incident, update_incident
|
||
incident, action = update_incident(document, monitoring, batch["sequence"])
|
||
if incident is None:
|
||
return
|
||
if incident.get("task_id"):
|
||
if monitoring["status"] == "suspected_burst" and not incident.get("burst_diagnosed"):
|
||
from uuid import UUID
|
||
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
|
||
from app.services.operational_tasks import OperationalTaskService
|
||
tasks = OperationalTaskService(
|
||
OperationalTaskRepository(self.repo.connection, self.context.project_id),
|
||
self.context, permissions_for_context(self.context),
|
||
)
|
||
await tasks.advance_replay_investigation(UUID(incident["task_id"]), {
|
||
"replay_id": document["replay_id"], "knowledge_cutoff": monitoring["knowledge_cutoff"],
|
||
"batch_sequence": batch["sequence"],
|
||
}, uuid5(NAMESPACE_URL, incident["incident_id"] + ":burst-evidence"))
|
||
incident["burst_diagnosed"] = True
|
||
if action == "resolved" and incident.get("alert"):
|
||
await self._publish_monitoring_alert(document, incident, "resolved")
|
||
attach_incident(monitoring, incident, action)
|
||
return
|
||
from app.domain.schemas.operational_task import OperationalTaskCreate, TaskSource, TaskTrigger
|
||
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
|
||
from app.services.operational_tasks import OperationalTaskService
|
||
tasks = OperationalTaskService(
|
||
OperationalTaskRepository(self.repo.connection, self.context.project_id),
|
||
self.context,
|
||
permissions_for_context(self.context),
|
||
)
|
||
external = incident["external_id"]
|
||
pressure = monitoring["pressure"]
|
||
flow = monitoring["outlet_flow"]
|
||
detector = monitoring["burst_detection"]
|
||
score = detector.get("target_score")
|
||
score_text = f"{score:.4f}" if score is not None else "数据不足"
|
||
summary = (
|
||
f"SCADA 数据诊断发现指标异常,进入异常调查与爆管诊断。"
|
||
f"爆管侦测得分 {score_text};"
|
||
f"压力下降测点 {len(pressure.get('hit_device_ids', []))} 个;"
|
||
f"出厂边界流量增加测点 {len(flow.get('hit_device_ids', []))} 个。"
|
||
"任一指标异常即启动调查,加权分用于评估爆管嫌疑;达到门槛后继续候选定位、隔离与影响分析。"
|
||
)
|
||
if monitoring.get("anomaly_signals"):
|
||
signals = monitoring["anomaly_signals"]
|
||
devices = {device for item in signals for device in item.get("device_ids", [])}
|
||
summary += f" 单项异常 {len(signals)} 项,涉及 {len(devices)} 个测点;明细通过冻结 SCADA 查询核验。"
|
||
if monitoring.get("weighted"):
|
||
weighted = monitoring["weighted"]
|
||
weights = weighted["weights"]
|
||
summary += (
|
||
f" 综合评分 {weighted['score']:.2f},爆管嫌疑门槛 {weighted['threshold']:.2f}"
|
||
f"(侦测 {weights['burst_detection']:.0%}、压力 {weights['pressure']:.0%}、"
|
||
f"流量 {weights['outlet_flow']:.0%})。"
|
||
)
|
||
if detector.get("score_threshold") is not None:
|
||
summary += f" 侦测动态阈值 {detector['score_threshold']:.4f}。"
|
||
command = OperationalTaskCreate(
|
||
request_id=uuid5(NAMESPACE_URL, external),
|
||
task_type="event_investigation",
|
||
workflow="scada_anomaly",
|
||
title=f"SCADA 异常调查 · {monitoring['period_end']}",
|
||
summary=summary,
|
||
source=TaskSource(
|
||
kind="external_system",
|
||
external_id=external,
|
||
external_revision=1,
|
||
event_kind="scada_anomaly",
|
||
replay_context={
|
||
"replay_id": document["replay_id"],
|
||
"knowledge_cutoff": monitoring["knowledge_cutoff"],
|
||
"batch_sequence": batch["sequence"],
|
||
},
|
||
),
|
||
trigger=TaskTrigger(
|
||
kind="event",
|
||
occurred_at=monitoring["period_end"],
|
||
detail="SCADA 任一指标异常启动调查,核验数据后进行爆管诊断;异常不等于已确诊爆管",
|
||
),
|
||
)
|
||
task = await tasks.create(command)
|
||
incident["task_id"] = str(task.task_id)
|
||
incident["burst_diagnosed"] = monitoring["status"] == "suspected_burst"
|
||
incident["alert"] = {
|
||
"alert_id": incident["incident_id"],
|
||
"producer_id": "scada-replay:" + document["replay_id"],
|
||
"occurred_at": incident["started_at"],
|
||
"condition_id": f"operational-task:{task.task_id}",
|
||
"source": {"kind": "scada_replay", "replay_id": document["replay_id"],
|
||
"rule_id": "scada_diagnosis", "batch_sequence": batch["sequence"]},
|
||
"title": "SCADA 指标异常", "description": "已启动调查,详细诊断见调查报告。", "severity": "warning",
|
||
}
|
||
await self._publish_monitoring_alert(document, incident, "raised")
|
||
attach_incident(monitoring, incident, action)
|
||
|
||
async def _publish_monitoring_alert(self, document, incident, change):
|
||
from app.domain.schemas.task_alert import TaskAlertEvent
|
||
from app.infra.db.postgresql.task_alerts import TaskAlertRepository
|
||
from app.services.task_alerts import TaskAlertService
|
||
alerts = TaskAlertService(
|
||
TaskAlertRepository(self.repo.connection, self.context.project_id), self.context,
|
||
internal_producer_id="scada-replay:" + document["replay_id"],
|
||
)
|
||
await alerts.push(TaskAlertEvent(
|
||
**incident["alert"], event_type=change,
|
||
event_id=uuid5(NAMESPACE_URL, incident["incident_id"] + ":" + change),
|
||
))
|