feat(investigations): add event impact and work-order feedback flows

Add isolated burst and pollution impact evidence, parallel work-order workflows, persisted notifications and graph updates.

Checkpoint current code. Known follow-ups remain for large pollution result previews and graceful geometry failure.
This commit is contained in:
2026-11-14 15:14:02 +08:00
parent 471ad92a47
commit 2e4f6cb93b
49 changed files with 1826 additions and 93 deletions
+147
View File
@@ -0,0 +1,147 @@
"""Estimate pre-intervention burst pressure loss against a read-only online snapshot."""
from datetime import datetime
from math import isfinite
from typing import Any
from app.infra.db.timescaledb.internal_queries import InternalQueries
from app.native.wndb.model.options import get_option, get_option_v3
from app.services.node_coverage import MAX_COVERAGE_NODES, generate_node_coverage
GRADES = (
("drop_1_3", "压力下降 1≤Δ<3 m", 1, 3),
("drop_3_5", "压力下降 3≤Δ<5 m", 3, 5),
("drop_5_plus", "压力下降 Δ≥5 m", 5, None),
)
def _head_factor(unit: Any) -> float | None:
# Standard water head; unsupported or absent units must not become zero loss.
return {"m": 1.0, "meters": 1.0, "metres": 1.0,
"kpa": 1 / 9.80665, "psi": 6894.757293168 / 9806.65,
"bar": 100000 / 9806.65, "mpa": 1000000 / 9806.65,
"ft": 0.3048}.get(str(unit).strip().lower())
def _number(value: Any) -> float | None:
if isinstance(value, bool):
return None
try:
result = float(value)
except (ValueError, TypeError, OverflowError):
return None
return result if isfinite(result) else None
def _instant(value: Any) -> datetime | None:
try:
result = value if isinstance(value, datetime) else datetime.fromisoformat(value)
except (ValueError, TypeError):
return None
return result if result.tzinfo is not None else None
def estimate_burst_impact(network: str, start: datetime, simulation: dict[str, Any], *,
model_only: bool = False) -> dict[str, Any]:
"""Compare every native node at t=0, before the adapter filters requested outputs.
The online snapshot is a model reference read at calculation time, not frozen
SCADA or a reconstructed historical model. All numerical inputs are retained
in task evidence so playback does not repeat this mutable reference query.
"""
impact: dict[str, Any] = {
"status": "unavailable", "phase": "after_burst_before_intervention",
"comparison_time": start.isoformat(), "pressure_drop_unit": "m",
"baseline_source": "existing_online_simulation",
"baseline_read_at": datetime.now().astimezone().isoformat(),
"baseline_units_source": "current_model_settings",
"model_temporality": "current_model", "baseline_frozen": False,
"boundary_source": "model_only_no_live_scada" if model_only else "model_with_start_time_scada",
"boundary": "假设破口情景与同刻在线模拟的节点压力差估计;覆盖面为节点空间包络,非现场确认停水或用户服务边界。在线基线及其单位按计算时可用数据和模型设置读取。",
"grades": [{"group_id": key, "title": title, "min_inclusive_m": low,
"max_exclusive_m": high, "node_ids": []} for key, title, low, high in GRADES],
}
output = {"impact": impact}
# Never silently shift t=0 to an unknown/delayed report sample or another day.
times = simulation.get("times", {})
sample_times = times.get("sample_times")
if ((sample_times and _instant(sample_times[0]) != start)
or (not sample_times and times.get("report_start") != 0)):
impact["reason"] = "simulation_has_no_verified_start_time_sample"
return output
options = get_option(network)
options_v3 = get_option_v3(network)
baseline_unit = options_v3.get("PRESSURE_UNITS") or options.get("PRESSURE")
impact["baseline_units_source"] = (
"current_v3_model_settings" if options_v3.get("PRESSURE_UNITS") else "current_legacy_model_settings"
)
impact["model_conditions"] = {
"baseline_demand_model": options_v3.get("DEMAND_MODEL") or options.get("DEMAND MODEL"),
"burst_demand_model": "PDA", "burst_required_pressure_model_units": 10,
"note": "爆管模拟采用压力驱动需水;与在线模型的需水方式差异也可能影响压力差。",
}
scenario_unit = simulation.get("units", {}).get("pressure")
baseline_factor, scenario_factor = _head_factor(baseline_unit), _head_factor(scenario_unit)
impact["input_pressure_units"] = {"baseline": baseline_unit, "burst": scenario_unit}
if baseline_factor is None or scenario_factor is None:
impact["reason"] = "unsupported_or_missing_pressure_units"
return output
nodes = simulation.get("node_results", [])
ids = [str(row["node"]) for row in nodes]
if not ids or len(ids) != len(set(ids)):
impact["reason"] = "missing_or_duplicate_simulation_nodes"
return output
baseline = InternalQueries.query_realtime_simulation_by_ids_timerange(
ids, start, start, "node", "pressure", db_name=network,
)
comparisons, missing = [], []
for row in nodes:
node_id = str(row["node"])
samples = row.get("result") or []
burst = _number(samples[0].get("pressure")) if samples and isinstance(samples[0], dict) else None
exact = [item for item in baseline.get(node_id, []) if _instant(item.get("time")) == start]
normal = _number(exact[0].get("value")) if len(exact) == 1 else None
if burst is None or normal is None:
missing.append(node_id)
continue
normal_m, burst_m = normal * baseline_factor, burst * scenario_factor
drop = normal_m - burst_m
if not all(isfinite(value) for value in (normal_m, burst_m, drop)):
missing.append(node_id)
continue
comparisons.append({"node_id": node_id, "baseline_m": normal_m,
"burst_m": burst_m, "pressure_drop_m": drop})
for group in impact["grades"]:
if drop >= group["min_inclusive_m"] and (
group["max_exclusive_m"] is None or drop < group["max_exclusive_m"]
):
group["node_ids"].append(node_id)
break
impact.update(status="partial" if missing else "ready", node_count=len(ids),
compared_node_count=len(comparisons), unavailable_node_ids=missing,
comparisons=comparisons)
affected = sum(len(group["node_ids"]) for group in impact["grades"])
impact["affected_node_count"] = affected
impact["below_threshold_node_count"] = len(comparisons) - affected
if not comparisons:
impact.update(status="unavailable", reason="no_exact_comparable_online_snapshot")
return output
if affected > MAX_COVERAGE_NODES:
impact["coverage_status"] = "unavailable"
impact["coverage_reason"] = "affected_nodes_exceed_10000_coverage_limit"
return output
groups = [{key: group[key] for key in ("group_id", "title", "node_ids")}
for group in impact["grades"]]
geometry = generate_node_coverage(network, groups)
impact["coverage_status"] = geometry["coverage"]["status"]
geometry["coverage"]["boundary"] = impact["boundary"]
for feature in geometry["map_payload"]["features"]:
feature["properties"].update(
impact_phase=impact["phase"], pressure_drop_unit="m",
pressure_drop_grade=feature["properties"]["coverage_group"],
comparison_time=start.isoformat(), model_estimate=True,
)
if geometry["coverage"]["status"] == "partial":
impact["status"] = "partial"
output.update(geometry)
return output
+112
View File
@@ -0,0 +1,112 @@
"""Estimate a scenario's time-cumulative concentration coverage before output filtering."""
from datetime import datetime, timedelta
from math import isfinite
from typing import Any
from app.services.node_coverage import MAX_COVERAGE_NODES, generate_node_coverage
def _sample_times(start: datetime, duration: int, metadata: dict) -> list[datetime]:
explicit = metadata.get("sample_times")
if explicit is not None:
times = [datetime.fromisoformat(value) for value in explicit]
else:
offset, step, count = (metadata.get(key) for key in ("report_start", "report_step", "num_periods"))
if (any(type(value) is not int for value in (offset, step, count))
or offset < 0 or step <= 0 or not 0 < count <= 86401):
raise ValueError("missing or invalid report grid")
times = [start + timedelta(seconds=offset + index * step) for index in range(count)]
if (not times or len(times) > 86401 or any(at.tzinfo is None for at in times)
or any(not start <= at <= start + timedelta(seconds=duration) for at in times)
or any(left >= right for left, right in zip(times, times[1:]))):
raise ValueError("invalid simulation sample times")
return times
def estimate_contaminant_impact(network: str, start: datetime, duration: int,
simulation: dict[str, Any], *, threshold_mg_l: float = 0,
model_only: bool = False) -> dict[str, Any]:
"""Use every native node and report sample; never infer propagation from requested nodes."""
impact: dict[str, Any] = {
"status": "unavailable", "phase": "after_contamination_before_intervention",
"window_start": start.isoformat(), "window_end": (start + timedelta(seconds=duration)).isoformat(),
"threshold_mg_l": threshold_mg_l, "threshold_operator": ">", "concentration_unit": "mg/L",
"threshold_purpose": "scenario_display_not_drinking_water_standard",
"model_temporality": "current_model", "quantity": "total_model_concentration",
"boundary_source": "model_only_no_live_scada" if model_only else "model_with_start_time_scada",
"boundary": "给定源浓度、模型初始水质及反应条件下,整个模拟时段内超过展示阈值的节点覆盖;"
"首达时间为首个超过阈值的报告采样时刻。不是污染确诊、源独立贡献、现场污染边界或饮用水合格判定。",
}
output = {"impact": impact}
unit = simulation.get("units", {}).get("quality")
factor = {"mgl": 1.0, "mg/l": 1.0, "ugl": 0.001, "ug/l": 0.001}.get(
str(unit).strip().lower().replace("μ", "u").replace("µ", "u"))
impact["input_concentration_unit"] = unit
if factor is None:
impact["reason"] = "unsupported_or_missing_concentration_unit"
return output
try:
times = _sample_times(start, duration, simulation.get("times", {}))
except (TypeError, ValueError, OverflowError):
impact["reason"] = "simulation_has_no_verified_sample_times"
return output
rows = simulation.get("node_results", [])
ids = [str(row["node"]) for row in rows]
if not ids or len(ids) != len(set(ids)):
impact["reason"] = "missing_or_duplicate_simulation_nodes"
return output
affected, unavailable, incomplete = [], [], []
for row in rows:
node = str(row["node"])
samples = row.get("result") or []
if len(samples) > len(times):
impact["reason"] = "simulation_sample_count_mismatch"
return output
valid = []
for at, sample in zip(times, samples):
value = sample.get("quality") if isinstance(sample, dict) else None
if isinstance(value, bool) or value is None:
continue
try:
concentration = float(value) * factor
except (TypeError, ValueError, OverflowError):
continue
if isfinite(concentration) and concentration >= 0:
valid.append((at, concentration))
if not valid:
unavailable.append(node)
continue
if len(valid) != len(times):
incomplete.append(node)
exceedances = [(at, value) for at, value in valid if value > threshold_mg_l]
if exceedances:
peak_at, peak = max(valid, key=lambda item: item[1])
affected.append({"node_id": node, "first_exceedance_sample_at": exceedances[0][0].isoformat(),
"peak_mg_l": peak, "peak_at": peak_at.isoformat(),
"valid_sample_count": len(valid), "complete": len(valid) == len(times)})
impact.update(status="partial" if unavailable or incomplete else "ready",
node_count=len(ids), report_sample_count=len(times),
reported_from=times[0].isoformat(), reported_through=times[-1].isoformat(),
unavailable_node_ids=unavailable, incomplete_node_ids=incomplete,
affected_node_count=len(affected), nodes=affected,
affected_node_ids=[item["node_id"] for item in affected])
if len(unavailable) == len(ids):
impact.update(status="unavailable", reason="no_valid_concentration_samples")
return output
if len(affected) > MAX_COVERAGE_NODES:
impact.update(coverage_status="unavailable", coverage_reason="affected_nodes_exceed_10000_coverage_limit")
return output
geometry = generate_node_coverage(network, [{
"group_id": "contaminant_exposure", "title": f"模拟浓度>{threshold_mg_l:g} mg/L的累计节点范围",
"node_ids": impact["affected_node_ids"],
}])
impact["coverage_status"] = geometry["coverage"]["status"]
geometry["coverage"]["boundary"] = impact["boundary"]
for feature in geometry["map_payload"]["features"]:
feature["properties"].update(impact_phase=impact["phase"], concentration_unit="mg/L",
threshold_mg_l=threshold_mg_l, model_estimate=True,
window_start=impact["window_start"], window_end=impact["window_end"])
if geometry["coverage"]["status"] == "partial":
impact["status"] = "partial"
output.update(geometry)
return output
+34
View File
@@ -0,0 +1,34 @@
"""Temporary deterministic candidate selection behind the source-tracing contract.
Replace this selection with source inversion when the algorithm is available.
Only project-owned nodes supplied from the investigation are eligible. No
observation fit, confidence or contamination extent is inferred here.
"""
from psycopg import AsyncConnection
from app.infra.db.postgresql.read_queries import read_rows
from app.mcp.analysis_models import ContaminantSourceTraceInput
async def trace_contaminant_source(connection: AsyncConnection, args: ContaminantSourceTraceInput) -> dict:
rows = await read_rows(connection, """
SELECT id, node_type::text AS kind FROM network.nodes
WHERE id = ANY(%s) ORDER BY id
""", (args.candidate_node_ids,))
# Sort in Python as well so selection does not depend on DB collation.
nodes = sorted({str(row["id"]) for row in rows})
selected = nodes[0] if nodes else None
return {
"located_node": selected,
"top_candidates": [{"node_id": selected}] if selected else [],
"exit_condition": "candidate_selected" if selected else "no_candidate",
"method": "deterministic_candidate_selection",
"algorithm_status": "provisional",
"selection_basis": "lexicographically_first_existing_candidate_node",
"validated_node_ids": nodes,
"missing_node_ids": sorted(set(args.candidate_node_ids) - set(nodes)),
"notice": ("已返回待现场核验的候选节点;先展示节点、保存阶段结论并提出现场核查/采样处置工单,"
"工单审核派发与污染传播模拟、节点隔离及影响分析并行,结果补入同一调查。"
"正式污染反演算法尚未接入,本次选择不代表污染源确诊或污染传播范围。" if selected else
"提交的候选节点均不在当前项目模型中;核对事件位置及资产映射后重新提交,继续不依赖源节点的调查。"),
}
+10 -5
View File
@@ -23,10 +23,11 @@ def adopt_legacy_investigation_graph(diagram: InvestigationDiagram) -> Investiga
def update_investigation_graph(
previous: InvestigationDiagram | None, update: InvestigationGraphUpdate,
) -> InvestigationDiagram:
"""Upsert nodes and append explicit edges; never delete a path or erase a failed attempt.
"""Patch saved nodes or add labelled nodes and explicit edges; never erase a path.
A retry uses a new node id. Relationships describe activity for readers and
impose no ordering or completion requirements on the Agent.
Unspecified fields retain their saved values. A failed attempt remains
failed, and a retry uses a new node id. Relationships impose no execution
ordering or completion requirements on the Agent.
"""
if previous is not None:
previous = adopt_legacy_investigation_graph(previous)
@@ -47,15 +48,19 @@ def update_investigation_graph(
if existing is None:
require(not in_source(node.node_id), "GRAPH_LEGACY_NODE_CONFLICT",
"This id belongs to the original Mermaid; use a new node id", status=409)
require(node.label is not None, "GRAPH_NODE_LABEL_REQUIRED",
"A new graph node requires a label; use an existing node_id for a partial update", status=422)
nodes[node.node_id] = InvestigationDiagramNodeRef.model_validate(node.model_dump(exclude_unset=True))
continue
require(not in_source(node.node_id) or existing.label == node.label,
require(not in_source(node.node_id) or node.label is None or existing.label == node.label,
"GRAPH_LEGACY_NODE_CONFLICT", "Keep the original node label; use a new node for a new step", status=409)
require(existing.status != "failed" or (
("status" not in node.model_fields_set or node.status == "failed")
and existing.label == node.label
and (node.label is None or existing.label == node.label)
), "GRAPH_FAILED_ATTEMPT_IMMUTABLE", "Keep the failed attempt; use a new node for a retry", status=409)
changes = node.model_dump(exclude_unset=True)
if node.label is None:
changes.pop("label", None)
if existing.status == "failed" and existing.detail and not changes.get("detail"):
changes.pop("detail", None)
for field in ("evidence_refs", "action_refs", "map_targets"):
+10 -1
View File
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
from app.services.operational_tasks import OperationalTaskService
RECOVERY_WORKFLOWS = {"burst_report", "scada_anomaly"}
RECOVERY_WORKFLOWS = {"burst_report", "scada_anomaly", "water_pollution"}
def track_work_order(task: dict, action: dict) -> bool:
@@ -60,6 +60,8 @@ def accept_completion(task: dict, feedback: dict, *, recorded_at: datetime | Non
# edits. A revised field receipt can explicitly correct the verification start.
if feedback["status"] == "completed":
order["returned_at"] = feedback["occurred_at"]
if task.get("workflow") == "water_pollution":
order["feedback_evidence_id"] = (feedback.get("evidence") or {}).get("evidence_id")
else:
order.pop("returned_at", None)
if order.get("superseded_by"):
@@ -67,6 +69,13 @@ def accept_completion(task: dict, feedback: dict, *, recorded_at: datetime | Non
orders = [item for item in context["work_orders"].values() if not item.get("superseded_by")]
if orders and all(item["status"] == "completed" for item in orders):
returned_at = max((item["returned_at"] for item in orders), key=datetime.fromisoformat)
if task.get("workflow") == "water_pollution":
# A completion receipt resumes review, never the pressure-based
# automatic recovery calculation. Source revisions may carry new
# sampling or retest facts even when the field time is unchanged.
context.update(phase="field_review", title="水污染处置反馈复核", returned_at=returned_at)
context.pop("verification_evidence_id", None)
return True
previous = context.get("returned_at")
if (previous is None or datetime.fromisoformat(previous) != datetime.fromisoformat(returned_at)
or context.get("phase") == "waiting_work_order"):
+1 -1
View File
@@ -7,7 +7,7 @@ from app.domain.domain_errors import require
def require_complete_scada_evidence(task: dict, evidence: list[dict], refs: list[str]) -> None:
replay = task.get("source", {}).get("replay_context")
if task.get("workflow") != "scada_anomaly" and not replay:
if task.get("workflow") not in {"scada_anomaly", "water_pollution"} and not replay:
return
target = datetime.fromisoformat(
replay["knowledge_cutoff"] if replay else task["trigger"]["occurred_at"]
+46 -5
View File
@@ -56,6 +56,7 @@ from app.domain.schemas.operational_task import (
OperationalTaskCreate,
OperationalTaskDetail,
OperationalTaskNotification,
WorkOrderNotificationReference,
OperationalTaskPermissions,
OperationalTaskSchedule,
OperationalTaskScheduleCreate,
@@ -874,7 +875,7 @@ class OperationalTaskService:
diagram = update_investigation_graph(saved_diagram, command.progress.graph_update)
elif saved_diagram is not None:
require(diagram is None or saved_diagram.edges is None, "GRAPH_UPDATE_REQUIRED",
"Use graph_update to extend the saved investigation graph", status=422)
"Use graph_update to patch saved nodes or extend the investigation graph", status=422)
if diagram is None:
diagram = adopt_legacy_investigation_graph(saved_diagram)
if diagram:
@@ -1258,6 +1259,7 @@ class OperationalTaskService:
task_id, "action_approval_pending", task["title"], command.reason, now,
required_permission=approval_permission,
subject_type="action", subject_id=action.action_id,
work_order_action=action if action.kind == "create_work_order" else None,
)
await self._save_request(
scope, command.request_id, body, {"action_id": str(action.action_id)}
@@ -1743,6 +1745,29 @@ class OperationalTaskService:
task_id, "task_failed",
task["title"], command.detail, now,
)
if command.source_revision > 1 and task["task_type"] in {"external_work_order", "event_investigation"}:
# The feedback, its notification and outbox event commit together.
# Duplicate request/source versions return above, before publishing.
work_order_action = next((item for item in await self.repo.list_actions(str(task_id))
if item["kind"] == "create_work_order" and item.get("receipt")
and command.source_event_id == f"{item['receipt']['adapter']}:{item['receipt']['receipt_id']}"), None)
is_work_order_feedback = (task["task_type"] == "external_work_order"
or work_order_action is not None
or (task["task_type"] == "event_investigation" and matching_source))
if is_work_order_feedback:
await self._notify(
task_id, "work_order_feedback", "收到工单反馈",
command.evidence.summary if command.evidence else command.detail, now,
subject_type="action" if work_order_action else "task",
subject_id=UUID(str(work_order_action["action_id"])) if work_order_action else task_id,
work_order_action=HumanApprovalAction.model_validate(work_order_action) if work_order_action else None,
work_order_reference=WorkOrderNotificationReference(
source_event_id=command.source_event_id,
source_revision=command.source_revision,
evidence_id=command.evidence.evidence_id if command.evidence else None,
status=command.status,
),
)
response = {
"source_event_id": command.source_event_id,
"source_revision": command.source_revision,
@@ -2161,12 +2186,24 @@ class OperationalTaskService:
lifecycle = self._investigation_status(actions, task)
recovery = task.get("handling_context") or {}
if lifecycle == "completed" and recovery:
require(recovery.get("verification_evidence_id") in report.report.evidence_refs,
"RECOVERY_EVIDENCE_REQUIRED", "最终复核报告必须引用恢复监测证据", status=409)
if task.get("workflow") == "water_pollution":
orders = [item for item in recovery.get("work_orders", {}).values()
if not item.get("superseded_by")]
feedback_refs = [item.get("feedback_evidence_id") for item in orders]
require(recovery.get("phase") in {"field_review", "completed"} and bool(orders)
and all(item["status"] == "completed" for item in orders)
and all(ref and ref in report.report.evidence_refs for ref in feedback_refs),
"POLLUTION_FEEDBACK_REQUIRED", "水污染最终报告必须复核并引用全部最新现场反馈证据", status=409)
verification_id = feedback_refs[-1]
else:
verification_id = recovery.get("verification_evidence_id")
require(verification_id in report.report.evidence_refs,
"RECOVERY_EVIDENCE_REQUIRED", "最终复核报告必须引用恢复监测证据", status=409)
require(report.report.outcome == "concluded", "RECOVERY_EVIDENCE_REQUIRED",
"恢复复核通过后才能结束事件", status=409)
recovery.update(phase="completed", title="恢复供水复核成功", completed_at=now.isoformat())
await self._resolve_investigation_alerts(str(task_id), recovery["verification_evidence_id"])
recovery.update(phase="completed", title=("水污染处置反馈已复核" if task.get("workflow") == "water_pollution"
else "恢复供水复核成功"), completed_at=now.isoformat())
await self._resolve_investigation_alerts(str(task_id), verification_id)
elif report.report.outcome == "insufficient_data":
terminal = all(node.status in {"completed", "skipped", "cancelled"}
for node in plan.plan.nodes)
@@ -2519,6 +2556,8 @@ class OperationalTaskService:
*, required_permission: str | None = None,
subject_type: str = "task", subject_id: UUID | None = None,
report_reference: InvestigationReportReference | None = None,
work_order_reference: WorkOrderNotificationReference | None = None,
work_order_action: HumanApprovalAction | None = None,
) -> None:
notification = OperationalTaskNotification(
notification_id=uuid4(), task_id=task_id, kind=kind,
@@ -2527,6 +2566,8 @@ class OperationalTaskService:
lifecycle_status="active", subject_type=subject_type,
subject_id=subject_id or task_id,
report_reference=report_reference,
work_order_reference=work_order_reference,
work_order_action=work_order_action,
)
await self.repo.insert_notification(_json(notification))
await self._append_event(
+12 -1
View File
@@ -19,9 +19,20 @@ def current_calibration(document: dict, start: datetime) -> dict | None:
return None
def pollution_signal(document: dict, event: dict) -> bool:
"""Water-quality measurements have their own incident investigation workflow."""
if event.get("category") == "data_quality":
return False
devices = {item["device_id"]: item.get("device_type") for item in document.get("devices", [])}
ids = event.get("device_ids") or next((item.get("device_ids", [])
for item in (document.get("config") or {}).get("rules", [])
if item["rule_id"] == event.get("rule_id")), [])
return bool(ids) and all(devices.get(device) in {"quality", "water_quality"} for device in ids)
def diagnostic_signal(document: dict, event: dict) -> bool:
"""Never revive a persisted fixed-percentage event as new diagnosis evidence."""
if event.get("category") == "data_quality":
if event.get("category") == "data_quality" or pollution_signal(document, event):
return False
if not weighted_diagnosis(document):
return True
+3 -3
View File
@@ -10,14 +10,14 @@ from app.domain.domain_errors import require
DISPLAY_TIMEZONE = timezone(timedelta(hours=8))
METRICS = {"pressure": "pressure", "pipe_flow": "flow", "flow": "flow"}
METRICS = {"pressure": "pressure", "pipe_flow": "flow", "flow": "flow", "quality": "quality", "water_quality": "quality"}
def applies(task: dict) -> bool:
if task.get("workflow") != "scada_anomaly" and not task.get("source", {}).get("replay_context"):
if task.get("workflow") not in {"scada_anomaly", "water_pollution"} and not task.get("source", {}).get("replay_context"):
return False
# Recovery has its own Server-verified post-repair evidence and finalization gate.
return (task.get("handling_context") or {}).get("phase") not in {"verification_ready", "completed"}
return (task.get("handling_context") or {}).get("phase") not in {"verification_ready", "field_review", "completed"}
def cutoff(task: dict) -> datetime:
+27 -24
View File
@@ -429,6 +429,7 @@ class ScadaReplayService:
await self.repo.save(document)
async def _create_investigations(self, document, changes, batch, *, create_tasks=True):
from app.services.scada_normal_profile import pollution_signal
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
@@ -443,9 +444,10 @@ class ScadaReplayService:
self.context, permissions_for_context(self.context),
execution_scope=settings.operational_task_execution_scope)
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"
pollution = pollution_signal(document, event)
# Pressure/flow rules share period diagnosis during manual steps.
# Water-quality incidents and telemetry-quality alerts stay independent.
if (not create_tasks and not pollution and event.get("category") != "data_quality"
and (event["change"] == "raised" or event.get("notification_suppressed"))):
if event["change"] == "raised":
event["batch_sequence"] = batch["sequence"]
@@ -457,13 +459,33 @@ class ScadaReplayService:
if event["change"] == "raised":
event["batch_sequence"] = batch["sequence"]
document["rule_states"][event.get("state_key", event["rule_id"])]["event"]["batch_sequence"] = batch["sequence"]
if event["change"] == "raised" and event.get("category") != "data_quality" and (create_tasks or pollution):
external = f"scada_replay:{document['replay_id']}:event:{event['event_id']}"
command = OperationalTaskCreate(
request_id=uuid5(NAMESPACE_URL, external), task_type="event_investigation",
workflow="water_pollution" if pollution else "scada_anomaly",
title=f"{'水污染事件调查' if pollution else 'SCADA 回放调查'} · {event['rule_id']}",
summary=event["detail"], source=TaskSource(
kind="external_system", external_id=external, external_revision=1,
event_kind="water_pollution" if pollution else "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=(
"水质监测规则检出异常,启动水污染事件调查;污染源及影响待核验" if pollution else
"已存 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"]
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,
occurred_at=event["occurred_at"],
condition_id=f"operational-task:{event['task_id']}" if pollution and event.get("task_id") else 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",
title=f"{'水污染侦测异常' if pollution else 'SCADA 回放异常'} · {event['rule_id']}", description=event["detail"], severity="warning",
))
if event["change"] != "raised":
if event.get("task_id"):
@@ -472,25 +494,6 @@ class ScadaReplayService:
"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
+6
View File
@@ -29,6 +29,7 @@ from app.native.wndb.model.sources import (
)
from app.native.wndb.model.times import get_time, set_time
from app.domain.time import parse_clock_duration_seconds
from app.infra.db.postgresql.scada import ScadaElementMappings
def _isolated_analysis(purpose: str):
@@ -79,6 +80,7 @@ def burst_analysis(
username: str | None = None,
persist_results: bool = True,
_temporary_project: str | None = None,
scada_mappings: ScadaElementMappings | None = None,
) -> dict | None:
"""
爆管模拟
@@ -123,6 +125,7 @@ def burst_analysis(
name=new_name,
simulation_type="manually_temporary",
modify_pattern_start_time=modify_pattern_start_time,
scada_mappings=scada_mappings,
)
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
@@ -203,6 +206,7 @@ def burst_analysis(
modify_variable_pump_pattern=modify_variable_pump_pattern,
modify_valve_opening=modify_valve_opening,
scheme_type="burst_analysis",
scada_mappings=scada_mappings,
scheme_name=scheme_name,
result_db_name=name,
scheme_username=username,
@@ -469,6 +473,7 @@ def contaminant_simulation(
username: str | None = None,
_temporary_project: str | None = None,
persist_results: bool = True,
scada_mappings: ScadaElementMappings | None = None,
) -> dict | str | None:
"""
污染模拟
@@ -591,6 +596,7 @@ def contaminant_simulation(
scheme_username=username,
scheme_detail=scheme_detail,
persist_results=persist_results,
scada_mappings=scada_mappings,
)
return result if not persist_results else None