Files
next-tjwater-server/app/services/operational_recovery.py
T
jiang dc83cf5c88
Generic Container CI/CD / test-build-publish (push) Failing after 47s
Server CI/CD v2 / build-test-publish-and-deploy (push) Failing after 47s
fix(scada): verify first sample after field completion
2026-11-07 19:50:31 +08:00

202 lines
11 KiB
Python

"""Work-order completion and causal SCADA recovery evidence for incident tasks."""
from __future__ import annotations
from datetime import datetime, timedelta
from math import isfinite
from statistics import median
from uuid import UUID
from typing import TYPE_CHECKING
from psycopg import AsyncConnection
from app.domain.schemas.project_settings import ProjectSettings
from app.services.content_digest import digest
if TYPE_CHECKING:
from app.services.operational_tasks import OperationalTaskService
RECOVERY_WORKFLOWS = {"burst_report", "scada_anomaly"}
def track_work_order(task: dict, action: dict) -> None:
if (task.get("workflow") not in RECOVERY_WORKFLOWS or action["kind"] != "create_work_order"
or task.get("source", {}).get("replay_context")):
return
receipt = action.get("receipt") or {}
if action.get("execution_status") != "succeeded" or receipt.get("adapter") != "peripheral_sample":
return
context = task.setdefault("handling_context", {})
orders = context.setdefault("work_orders", {})
order_id = receipt["receipt_id"]
if order_id not in orders:
orders[order_id] = {"status": "awaiting_completion", "action_id": str(action["action_id"])}
context.update(phase="waiting_work_order", title="等待现场工单返回")
context.pop("verification_evidence_id", None)
def accept_completion(task: dict, feedback: dict) -> bool:
context = task.get("handling_context") or {}
order_id = feedback["source_event_id"].removeprefix("peripheral_sample:")
order = context.get("work_orders", {}).get(order_id)
if order is None:
return False
order.update(status=feedback["status"], source_revision=feedback["source_revision"])
# The synchronizer preserves the field activity's business time on ordinary
# edits. A revised field receipt can explicitly correct the verification start.
if feedback["status"] == "completed":
order["returned_at"] = feedback["occurred_at"]
else:
order.pop("returned_at", None)
orders = list(context["work_orders"].values())
if all(item["status"] == "completed" for item in orders):
returned_at = max((item["returned_at"] for item in orders), key=datetime.fromisoformat)
previous = context.get("returned_at")
if (previous is None or datetime.fromisoformat(previous) != datetime.fromisoformat(returned_at)
or context.get("phase") == "waiting_work_order"):
context.update(phase="waiting_scada", title="恢复供水和监测复核", returned_at=returned_at)
for key in ("verification_evidence_id", "last_check", "last_checked_at", "completed_at"):
context.pop(key, None)
else:
context.update(phase="waiting_work_order", title="等待现场工单返回")
context.pop("verification_evidence_id", None)
return True
def evaluate_recovery(rows: list[dict], *, device_ids: list[str], returned_at: datetime,
incident_at: datetime, interval_seconds: int) -> dict:
"""Verify the first SCADA sample strictly after the field feedback time T."""
base = {"status": "waiting", "device_ids": device_ids, "returned_at": returned_at.isoformat(),
"required_samples": 1, "interval_seconds": interval_seconds,
"selection_method": "first_sample_after_feedback",
"baseline_method": "normal_same_phase_range_plus_3_robust_sigma"}
if not device_ids:
return {**base, "reason": "monitoring_configuration_missing"}
if returned_at < incident_at:
return {**base, "reason": "completion_precedes_incident"}
selected = set(device_ids)
baseline: dict[tuple[str, int], list[float]] = {}
observations: dict[datetime, dict[str, float]] = {}
origin = incident_at - timedelta(days=15)
for row in rows:
if row["device_id"] not in selected:
continue
at = row["time"] if isinstance(row["time"], datetime) else datetime.fromisoformat(row["time"])
if at > returned_at:
# Keep the nearest frame even when it contains null/invalid values;
# never skip it in order to find a later normal-looking sample.
observations.setdefault(at, {})
if row.get("quality") not in {None, "valid", "corrected"}:
continue
value = row.get("cleaned_value")
if value is None:
value = row.get("monitored_value")
if value is None or not isfinite(float(value)):
continue
value = float(value)
phase = int(at.timestamp()) % 86400
if origin <= at < incident_at:
baseline.setdefault((row["device_id"], phase), []).append(value)
elif at > returned_at:
observations[at][row["device_id"]] = value
if not observations:
return {**base, "reason": "post_completion_reading_missing"}
at = min(observations)
values = observations[at]
phase = int(at.timestamp()) % 86400
checks = []
for device in device_ids:
reference = baseline.get((device, phase), [])
if device not in values or len(reference) < 15:
checks.append({"device_id": device, "status": "missing_data"})
continue
center = median(reference)
sigma = 1.4826 * median(abs(value - center) for value in reference)
margin = max(3 * sigma, abs(center) * 1e-6, 1e-9)
lower, upper = min(reference) - margin, max(reference) + margin
checks.append({"device_id": device, "value": values[device], "lower": lower, "upper": upper,
"normal_day_count": len(reference),
"status": "normal" if lower <= values[device] <= upper else "abnormal"})
sample = {"at": at.isoformat(), "devices": checks}
if all(item["status"] == "normal" for item in checks):
return {**base, "status": "passed", "verified_through": at.isoformat(), "samples": [sample]}
return {**base, "reason": "nearest_post_completion_reading_not_normal_or_incomplete", "sample": sample}
async def reconcile_recoveries(service: OperationalTaskService, timescale: AsyncConnection,
settings: ProjectSettings, *, limit: int = 20,
created_from: datetime | None = None) -> int:
"""Poll only persisted recovery waits; publish evidence before waking the Agent."""
from app.domain.schemas.operational_task import OperationalTaskSummary, StoredEvidenceRecord
from app.domain.schemas.operational_data import TaskDataSnapshot
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
async with service.repo.transaction():
candidates = await service.repo.recovery_candidates(limit, created_from)
resumed = 0
for task in candidates:
context = task["handling_context"]
returned_at = datetime.fromisoformat(context["returned_at"])
incident_at = datetime.fromisoformat(task["trigger"]["occurred_at"])
interval = settings.scada_monitoring_interval_seconds
roles = settings.scada_monitoring
devices = list(dict.fromkeys(roles.pressure_device_ids + roles.outlet_flow_device_ids)) if roles else []
source = "scada_readings"
through = service._now()
if context.get("device_ids"):
devices = context["device_ids"]
interval = context.get("interval_seconds", interval)
else:
# Retain the points the Agent actually inspected, including water
# quality or local incident sensors outside the project role lists.
for evidence in await service.repo.list_evidence(task["task_id"]):
saved = evidence.get("data_snapshot") or {}
if saved.get("source") not in {"query_scada_readings", "scada_readings"}:
continue
data = saved.get("data")
if isinstance(data, dict):
data = data.get("data", data.get("items", []))
if isinstance(data, list):
devices.extend(row["device_id"] for row in data if isinstance(row, dict) and isinstance(row.get("device_id"), str))
devices = list(dict.fromkeys(devices))
async with timescale.transaction():
rows = await ScadaRepository.get_recovery_scada_readings(
timescale, devices, incident_at, returned_at, through, baseline_limit=100_001,
) if through > returned_at and devices else []
result = ({"status": "waiting", "reason": "reading_budget_exceeded"} if len(rows) > 100_000 else
evaluate_recovery(rows, device_ids=devices, returned_at=returned_at,
incident_at=incident_at, interval_seconds=interval or 0))
async with service.repo.transaction():
await service.repo.lock(f"task:{task['task_id']}")
current = await service._task(UUID(task["task_id"]))
# A new receipt, cancellation or another poll may have won the race.
if current.get("handling_context") != context or current["status"] in {"cancelled", "failed"}:
continue
now = service._now()
if devices:
current["handling_context"].update(device_ids=devices, interval_seconds=interval or 0)
current["handling_context"]["last_checked_at"] = now.isoformat()
current["handling_context"]["last_check"] = result
if result["status"] == "passed":
evidence_id = "recovery:" + digest({"task_id": task["task_id"], "returned_at": context["returned_at"], "result": result})
evidence = StoredEvidenceRecord(
evidence_id=evidence_id, kind="system_data",
summary="完工反馈时间之后最近时刻的 SCADA 已恢复正常,等待 Agent 完成复核报告",
source_label="恢复供水监测复核", source_ref=f"operational-task:{task['task_id']}",
observed_at=datetime.fromisoformat(result["verified_through"]), recorded_at=now,
recorded_by=service._actor(), has_snapshot=True,
data_snapshot=TaskDataSnapshot(source=source, source_ref=evidence_id, queried_at=now,
origin="system_data", data=result, truncated=False),
)
if not await service.repo.get_evidence(task["task_id"], evidence_id):
await service.repo.insert_evidence(task["task_id"], evidence.model_dump(mode="json"))
current["handling_context"].update(phase="verification_ready", verification_evidence_id=evidence_id)
await service._requeue_task(current, now)
resumed += 1
else:
current.update(revision=current["revision"] + 1, updated_at=now)
await service.repo.update_task(OperationalTaskSummary.model_validate(current).model_dump(
mode="json", exclude={"active_run", "latest_run"}))
await service._task_event(current, now)
return resumed