feat(investigations): refine report authoring and replay flushing
Generic Container CI/CD / test-build-publish (push) Successful in 4m31s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 4m31s

This commit is contained in:
2026-11-14 23:00:15 +08:00
parent 2e4f6cb93b
commit b45c5c6f46
27 changed files with 1125 additions and 94 deletions
+106 -2
View File
@@ -5,13 +5,16 @@ import re
from typing import Annotated, Literal
from uuid import UUID
from pydantic import Field, JsonValue, field_validator, model_serializer, model_validator
from pydantic import Field, JsonValue, StringConstraints, field_validator, model_serializer, model_validator
from app.domain.schemas.answer_ui import (
CHART_SAMPLE_TIME_PATTERN, ReportUiBlock, validate_chart_sample_time, validate_report_ui_blocks,
)
from app.domain.schemas.task_question import TaskQuestionRequest
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictBool, StrictModel
from app.domain.schemas.report_authoring import (
validate_presentation_command_text, validate_report_text, validate_report_ui_text,
)
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
from app.domain.schemas.workbench_runtime import (
WorkbenchDateTime,
@@ -349,12 +352,44 @@ class InvestigationReport(StrictModel):
return self
class InvestigationDataGapInput(InvestigationDataGap):
"""New gap descriptions use business prose; references remain opaque."""
missing_data: NonEmptyStr = Field(max_length=4000, description="缺失的业务资料,不写执行字段")
scope: NonEmptyStr = Field(max_length=4000, description="缺口涉及的对象、地点与当地时间范围")
impact: NonEmptyStr = Field(max_length=4000, description="资料缺项对当前判断的实际影响")
required_data: NonEmptyStr = Field(max_length=4000, description="需要补充的具体业务资料")
@field_validator("missing_data", "scope", "impact", "required_data")
@classmethod
def validate_reader_text(cls, value: str) -> str:
return validate_report_text(value)
class InvestigationReportInput(InvestigationReport):
"""Write model: every new report explicitly declares its business result."""
title: NonEmptyStr = Field(max_length=80, description="简短中文业务标题,写明对象和当前结果,不含执行轮次、流程版本或原始时间戳")
current_conclusion: NonEmptyStr = Field(max_length=300, description="先写当前业务判断,再写关键依据与下一步,建议 150 字以内;完整数值和对象比较使用 ui_blocks.Table")
conclusion_boundary: NonEmptyStr = Field(max_length=240, description="只写影响判断的证据范围、不确定性和适用限制,不复述结论、执行步骤或参数")
recommendations: list[Annotated[NonEmptyStr, Field(max_length=160)]] = Field(default_factory=list)
pending_items: list[Annotated[NonEmptyStr, Field(max_length=160)]] = Field(default_factory=list)
stop_reason: Annotated[NonEmptyStr, Field(max_length=160)] | None = None
stage: Literal["interim", "final"]
outcome: Literal["concluded", "insufficient_data"]
data_gaps: list[InvestigationDataGap]
data_gaps: list[InvestigationDataGapInput]
@field_validator("title", "current_conclusion", "conclusion_boundary", "stop_reason")
@classmethod
def validate_reader_text(cls, value: str | None) -> str | None:
return validate_report_text(value) if value is not None else value
@model_validator(mode="after")
def validate_authoring(self):
for value in [*self.recommendations, *self.pending_items]:
validate_report_text(value)
validate_report_ui_text(self.ui_blocks)
return self
class InvestigationProgress(StrictModel):
@@ -365,8 +400,14 @@ class InvestigationProgress(StrictModel):
class InvestigationProgressInput(InvestigationProgress):
summary: NonEmptyStr = Field(max_length=300, description="用简短中文说明本轮新增发现、当前依赖和下一步;不复制报告、执行字段、版本号或证据编号")
graph_update: InvestigationGraphUpdate | None = None
@field_validator("summary")
@classmethod
def validate_reader_summary(cls, value: str) -> str:
return validate_report_text(value)
@model_validator(mode="after")
def validate_graph_input(self):
if self.graph_update is not None and self.diagram is not None:
@@ -908,11 +949,72 @@ TaskPresentationCommand = Annotated[
]
class TaskPresentationNarration(StrictModel):
stage: Literal[
"anomaly_detection", "evidence_verification", "asset_localization",
"event_impact", "source_control", "isolation_plan", "flushing_plan",
"field_feedback", "clearance_verification", "recovery_verification",
]
summary: Annotated[str, Field(strict=True, min_length=1, max_length=160)]
evidence_refs: Annotated[list[TaskFeatureId], Field(min_length=1, max_length=100)]
next_step: Annotated[str, Field(strict=True, min_length=1, max_length=120)] | None = None
@model_validator(mode="after")
def validate_narration(self):
if not self.summary.strip() or (self.next_step is not None and not self.next_step.strip()):
raise ValueError("presentation narration must contain readable text")
if len(set(self.evidence_refs)) != len(self.evidence_refs):
raise ValueError("presentation narration evidence IDs must be unique")
return self
TaskPresentationPurpose = Annotated[
str, StringConstraints(strict=True, strip_whitespace=True, min_length=1, max_length=160),
]
class TaskPresentationNarrationInput(TaskPresentationNarration):
"""New live findings use business prose; saved narration remains readable."""
summary: Annotated[str, Field(strict=True, min_length=1, max_length=160,
description="本步已取得的关键发现,建议 80 字以内;区分观测、模型候选和现场反馈,不复述动作或核查目的")]
next_step: Annotated[str, Field(strict=True, min_length=1, max_length=120,
description="实际下一步或等待条件,建议 60 字以内;无后续时省略,不把展示当作现场执行")] | None = None
@field_validator("summary", "next_step")
@classmethod
def validate_reader_text(cls, value: str | None) -> str | None:
return validate_report_text(value) if value is not None else value
class TaskPresentationEventCommand(StrictModel):
request_id: UUID
run_id: UUID
source_session_id: NonEmptyStr
command: TaskPresentationCommand
narration: TaskPresentationNarrationInput | None = None
purpose: TaskPresentationPurpose | None = Field(default=None,
description="一句中文说明本步为何查看这些对象,建议 60 字以内;不堆砌编号、执行参数或复述当前发现")
@field_validator("purpose")
@classmethod
def validate_reader_purpose(cls, value: str | None) -> str | None:
return validate_report_text(value) if value is not None else value
@model_validator(mode="after")
def validate_visible_labels(self):
validate_presentation_command_text(self.command.model_dump())
return self
@model_serializer(mode="wrap")
def serialize_legacy_command(self, handler):
value = handler(self)
# Keep old idempotency digests stable when no narration was supplied.
if self.narration is None:
value.pop("narration", None)
if self.purpose is None:
value.pop("purpose", None)
return value
class TaskPresentationEventResult(StrictModel):
@@ -943,6 +1045,8 @@ class TaskPresentationHistoryItem(StrictModel):
# Stored records are validated against the current command contract by the
# reader; an obsolete record must not prevent reading the rest of the page.
command: dict[str, JsonValue]
narration: TaskPresentationNarration | None = None
purpose: TaskPresentationPurpose | None = None
class TaskPresentationHistoryPage(StrictModel):
+73
View File
@@ -0,0 +1,73 @@
"""Reader-facing text checks for new publications, never historical reads."""
from __future__ import annotations
import re
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from app.domain.schemas.answer_ui import ReportUiBlock
_execution_text = re.compile(
r"\b(?:execution_generation|handling_context|verification_ready|waiting_work_order|"
r"waiting_scada|returned_at|verified_through|required_samples|interval_seconds|"
r"knowledge_cutoff|batch_sequence|query_complete|evidence_id|action_id|run_id|"
r"task_id|source_session_id|approval_status|execution_status|verification_evidence_id|"
r"normal_day_count|baseline_method|selection_method|get_task_context|query_scada_readings|"
r"run_burst_location|publish_task_report|publish_task_progress)\b|"
r"\b(?:status|phase|interval)\s*[:=]|\b(?:mcp|recovery|sha256):|"
r"\bskill(?:\.(?:name|version))?\s*[:=]|\bskill\s+[\w-]+\s+v?\d|"
r"\b(?:final|interim)/concluded\b|"
r"[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}",
re.IGNORECASE | re.ASCII,
)
_source_time = re.compile(
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}|UTC\s*[+-]\s*\d|Asia/Shanghai|上海时间|"
r"\d{2}:\d{2}(?::\d{2})?\s*\+\d{2}:\d{2}",
re.IGNORECASE,
)
_unrendered_markup = re.compile(r"`|\*\*|</?[A-Za-z][^>]*>|^\s{0,3}#{1,6}\s", re.MULTILINE)
def validate_report_text(value: str) -> str:
if _execution_text.search(value):
raise ValueError("请将执行字段和状态码改写为中文业务说明,完整编号保留在结构化引用中")
if _source_time.search(value):
raise ValueError("请将来源时间转换为当地日期和时刻,正文省略时区名称、偏移和 ISO 时间戳")
if _unrendered_markup.search(value):
raise ValueError("可见文字使用纯文本;重点与明细使用对应结果组件,不写 Markdown 或 HTML")
return value
def validate_report_ui_text(blocks: list[ReportUiBlock]) -> None:
for item in blocks:
validate_report_text(item.block.fallback_text)
for element in item.block.spec.elements.values():
props = element.props
for key in ("title", "summary", "text", "description", "x_axis_name", "y_axis_name"):
if isinstance(props.get(key), str):
validate_report_text(props[key])
for value in props.get("columns", []):
validate_report_text(value)
for row in props.get("rows", []):
for value in row:
validate_report_text(value)
for value in props.get("x_data", []):
validate_report_text(value)
for series in props.get("series", []):
validate_report_text(series["name"])
def validate_presentation_command_text(command: dict[str, Any]) -> None:
"""Check visible labels without touching geometry, references or timestamps."""
if command["action"] == "set_overlay":
validate_report_text(command["title"])
elif command["action"] == "open_chart":
chart = command["chart"]
for key in ("title", "x_axis_name", "y_axis_name"):
if isinstance(chart.get(key), str):
validate_report_text(chart[key])
for value in chart["x_data"]:
validate_report_text(value)
for series in chart["series"]:
validate_report_text(series["name"])
+13 -2
View File
@@ -171,9 +171,20 @@ class ValveClosureInput(ScenarioInput):
class FlushingInput(ScenarioInput):
drainage_node_id: Id
drainage_node_id: Id = Field(description="已核实的冲洗排放节点;清洁水来源、流向及污染水处置须在方案中说明")
flushing_flow_m3h: float = Field(gt=0, allow_inf_nan=False, description="明确的冲洗流量,m³/h;提供流量来源与估算依据")
closed_valves: list[Id] = Field(default_factory=list, max_length=20)
closed_valves: list[Id] = Field(default_factory=list, max_length=20,
description="本次冲洗情景中保持关闭的完整阀门清单,用于隔离用户或约束流向")
opened_valves: list[Id] = Field(default_factory=list, max_length=20,
description="本次冲洗情景中明确打开的阀门,如清洁水进水和导流阀;不表示现场已开阀或恢复用户供水。未列阀门沿用当前模型状态,不继承上次独立模拟")
@model_validator(mode="after")
def distinct_valve_states(self):
if any(len(values) != len(set(values)) for values in (self.closed_valves, self.opened_valves)):
raise ValueError("flushing valve lists must contain distinct IDs")
if set(self.closed_valves) & set(self.opened_valves):
raise ValueError("a flushing valve cannot be both opened and closed")
return self
NonnegativeValue = Annotated[float, Field(ge=0, allow_inf_nan=False)]
+9
View File
@@ -132,3 +132,12 @@ async def calculate_replay_contaminant_simulation(args: BaseModel, *, network: s
args.model_dump(exclude={"context_handle", "timeout_seconds"}, exclude_none=True),
network=network, username=username, model_only=True,
)
async def calculate_replay_flushing(args: BaseModel, *, network: str, username: str) -> dict[str, Any]:
"""Evaluate a model-only flushing plan without live SCADA or field execution."""
return await bounded_calculation(
calculate_scenario, "analyze_flushing",
args.model_dump(exclude={"context_handle", "timeout_seconds"}, exclude_none=True),
network=network, username=username, model_only=True,
)
+1 -1
View File
@@ -66,7 +66,7 @@ _specs = [
("run_simulation", "按水力步长在在线业务模型上执行指定时间段的实时模拟;更新该时段的模型条件及实时基线,返回指定节点和管段结果;duration单位为秒,输出为在线模型结果,正式分析成果通过方案工具保存", ScenarioInput, "simulation.run", "analysis", False, True),
("run_contaminant_simulation", "在隔离模型中模拟假设污染源的浓度传播;include_impact_coverage=true时汇总全网节点整个模拟时段超过impact_threshold_mg_l的累计范围、首个超阈值报告时刻和峰值,并保存地图证据。阈值为情景展示条件,非饮用水安全限值;回放按当前模型独立估计,不读取实时SCADA。定位后先提现场核查/采样处置工单,工单审核派发与本计算并行,结果补入同一调查", ContaminantSimulationInput, "simulation.run", "analysis", False, True),
("analyze_valve_closure", "在隔离模型中模拟关阀的水力影响,返回指定节点/管段结果;现场阀门操作按业务审批执行", ValveClosureInput, "simulation.run", "analysis", False, True),
("analyze_flushing", "在隔离模型中评估指定排放节点、冲洗流量和关阀组合的水力结果;现场效果由执行回执和复测核验", FlushingInput, "simulation.run", "analysis", False, True),
("analyze_flushing", "在独立模型中评估指定排放节点、流量及明确开关阀组合的冲洗水力结果;未列阀门沿用当前模型,不继承上次模拟;不判定污染清除或恢复供水", FlushingInput, "simulation.run", "analysis", False, True),
("analyze_pressure_regulation", "在隔离模型中评估给定调泵序列和水箱初始水位的压力影响;输出为给定方案评估,现场调度按审批执行", PressureRegulationInput, "simulation.run", "analysis", False, True),
("optimize_sensor_placement", "使用灵敏度或K-means算法推荐压力监测点;返回候选节点,正式部署另行保存并核验观测能力", SensorPlacementInput, "optimization.run", "analysis", False, True),
("run_leakage_identification", "基于SCADA压力与模型进行DMA漏损分配识别,需有依据的总漏损流量和时间对齐;返回待现场核验的区域估计。内部执行遗传算法与水力计算", LeakageIdentificationInput, "burst.run", "analysis", False, True),
+2 -1
View File
@@ -189,11 +189,12 @@ def saved_scenario(network: str, username: str, args: BaseModel) -> Any:
modify_valve_opening={valve: 0.0 for valve in parameters["valves"]})
if args.scenario == "flushing":
from app.native.wndb.model.options import get_option
from app.mcp.scenario_service import flushing_valve_controls
if get_option(network).get("UNITS") not in {"LPS", "CMH"}:
raise ValueError("Flushing supports LPS or CMH models only")
return scenarios.flushing_analysis(**common, username=username,
drainage_node_ID=parameters["drainage_node_id"], flushing_flow=parameters["flushing_flow_m3h"],
modify_valve_opening={valve: 0.0 for valve in parameters["closed_valves"]})
valve_control=flushing_valve_controls(parameters))
mappings = query_corresponding_element_id_and_query_id(network)
controls = parameters["pump_control"]
if set(controls) - (set(mappings.fixed_pumps) | set(mappings.variable_pumps)):
+16 -2
View File
@@ -79,7 +79,7 @@ async def authorize_query(
"get_element_properties", "list_elements", "query_nearby_elements", "get_network_options",
"get_node_connections", "get_element_gis_properties", "query_network_extent",
"list_regions", "get_region", "get_region_nodes",
"analyze_valve_isolation", "generate_node_coverage", "run_burst_simulation", "run_contaminant_simulation",
"analyze_valve_isolation", "generate_node_coverage", "run_burst_simulation", "run_contaminant_simulation", "analyze_flushing",
"get_calculation_job", "get_calculation_result", "list_calculation_jobs"}
if tool.name not in allowed:
raise HTTPException(403, "回放调查仅开放冻结 SCADA、资产、拓扑分析及指定模型情景;时序观测请使用 query_scada_readings")
@@ -91,6 +91,8 @@ async def authorize_query(
if not args.include_impact_coverage:
raise HTTPException(422, "回放污染情景必须启用影响覆盖,按当前模型估计传播范围")
check_replay_window(replay_context, args.start_time, args.start_time)
if tool.name == "analyze_flushing":
check_replay_window(replay_context, args.start_time, args.start_time)
if tool.name == "query_scada_readings":
check_replay_window(replay_context, args.start, args.end)
if tool.name == "run_contaminant_source_trace":
@@ -233,6 +235,17 @@ async def execute_query(
data = {"parameters": args.model_dump(mode="json", exclude={"context_handle"}), "result": result}
origin = "synthetic"
gaps = ["回放污染传播为当前模型的独立假设情景,不读取实时SCADA,不代表冻结观测或历史模型重建。未来模拟样本仅为预测;浓度阈值仅用于情景展示,不是饮用水安全限值。"]
elif name == "analyze_flushing" and replay_context:
from app.mcp.analysis_service import calculate_replay_flushing
await conn.commit()
async with scope.simulation_routing():
result = await calculate_replay_flushing(
args, network=scope.project.project_code, username=scope.user.username,
)
data = {"parameters": args.model_dump(mode="json", exclude={"context_handle"}), "result": result}
origin = "synthetic"
gaps = ["回放冲洗为当前模型的独立水力情景,不读取实时SCADA,不继承上次模拟;未来模拟样本仅为预测,不验证污染清除、水质合格或现场操作。"]
elif name == "run_burst_location" and replay_context:
from app.mcp.analysis_service import calculate_replay_burst_location
from app.services.scada_replay_queries import replay_burst_location_inputs
@@ -540,7 +553,8 @@ async def execute_query(
"algorithm_status": "provisional",
"required_inputs": ["candidate_node_ids", "start", "end"],
"next_steps": ["present_node", "interim_report", "propose_task_action", "run_contaminant_simulation",
"analyze_valve_isolation", "generate_node_coverage", "report"],
"source_control", "analyze_valve_isolation", "generate_node_coverage",
"analyze_flushing", "field_feedback", "clearance_verification", "report"],
"work_order_wait_blocks_analysis": False,
"simulation_parameters": {"include_impact_coverage": True},
}
+27 -1
View File
@@ -10,6 +10,12 @@ SCENARIO_TOOLS = frozenset({
})
def flushing_valve_controls(parameters: dict[str, Any]) -> dict[str, dict[str, str]]:
"""Apply explicit states without changing regulating-valve settings."""
return {**{valve: {"status": "CLOSED"} for valve in parameters.get("closed_valves", [])},
**{valve: {"status": "OPEN"} for valve in parameters.get("opened_valves", [])}}
def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, username: str,
model_only: bool = False) -> dict[str, Any]:
from app.services import simulation_scenarios as scenarios
@@ -60,9 +66,16 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u
from app.native.wndb.model.options import get_option
if get_option(network).get("UNITS") not in {"LPS", "CMH"}:
raise ValueError("Flushing currently supports LPS or CMH models only")
boundary = {}
if model_only:
from app.infra.db.postgresql.scada import ScadaElementMappings
boundary["scada_mappings"] = ScadaElementMappings(
reservoirs={}, tanks={}, fixed_pumps={}, variable_pumps={},
pressure={}, demand={}, quality={},
)
result = scenarios.flushing_analysis(**common, username=username,
drainage_node_ID=parameters["drainage_node_id"], flushing_flow=parameters["flushing_flow_m3h"],
modify_valve_opening={valve: 0.0 for valve in parameters["closed_valves"]})
valve_control=flushing_valve_controls(parameters), **boundary)
elif name == "analyze_pressure_regulation":
from app.services.simulation import query_corresponding_element_id_and_query_id
mappings = query_corresponding_element_id_and_query_id(network)
@@ -102,4 +115,17 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u
threshold_mg_l=impact_threshold, model_only=model_only)
impact["coverage"] = {**output["coverage"], **impact.get("coverage", {})}
output.update(impact)
if name == "analyze_flushing":
output["flushing_plan"] = {
"opened_valves": parameters.get("opened_valves", []),
"closed_valves": parameters.get("closed_valves", []),
"drainage_node_id": parameters["drainage_node_id"],
"flushing_flow_m3h": parameters["flushing_flow_m3h"],
"estimated_discharge_volume_m3": parameters["flushing_flow_m3h"] * duration / 3600,
"state_basis": "independent_current_model_scenario",
"boundary_source": "model_only_no_live_scada" if model_only else "model_with_start_time_scada",
"result_scope": "hydraulic_only",
"field_execution_verified": False,
"water_quality_clearance_verified": False,
}
return output
+9
View File
@@ -1067,6 +1067,8 @@ class OperationalTaskService:
run_id=row["metadata"]["run_id"],
execution_generation=row["metadata"]["execution_generation"],
command=row["metadata"]["command"],
narration=row["metadata"].get("narration"),
purpose=row["metadata"].get("purpose"),
) for row in rows[:limit]]
return TaskPresentationHistoryPage(
task_id=task_id, event_cursor=boundary, items=items,
@@ -1097,6 +1099,9 @@ class OperationalTaskService:
now = self._now()
self._require_active_run_lease(run, now)
payload = _json(command.command)
if command.narration is not None:
known = {item["evidence_id"] for item in await self.repo.list_evidence(str(task_id))}
self._require_evidence_refs(command.narration.evidence_refs, known)
await self._record_scada_presentation(task, run, payload, now)
if command.command.action == "open_chart":
validate_time_comparison_chart(payload["chart"], allow_missing_current=bool(
@@ -1122,6 +1127,10 @@ class OperationalTaskService:
payload["data"] = resolved
metadata = {"task_id": str(task_id), "run_id": str(run["run_id"]),
"execution_generation": run["generation"], "command": payload}
if command.narration is not None:
metadata["narration"] = _json(command.narration)
if command.purpose is not None:
metadata["purpose"] = command.purpose
try:
size = len(json.dumps(metadata, ensure_ascii=False, allow_nan=False).encode("utf-8"))
except (ValueError, TypeError) as exc:
+12 -22
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from datetime import datetime, timedelta, timezone
import json
from uuid import NAMESPACE_URL, uuid4, uuid5
from zoneinfo import ZoneInfo
from fastapi import HTTPException
from app.core.config import settings
@@ -531,35 +532,24 @@ class ScadaReplayService:
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', []))} 个。"
"任一指标异常即启动调查,加权分用于评估爆管嫌疑;达到门槛后继续候选定位、隔离与影响分析。"
)
findings = []
if pressure.get("hit_device_ids"):
findings.append(f"{len(pressure['hit_device_ids'])} 个压力测点下降")
if flow.get("hit_device_ids"):
findings.append(f"{len(flow['hit_device_ids'])} 个出厂边界流量测点增加")
summary = f"SCADA 监测发现{'、'.join(findings) or '指标异常'},已启动调查。"
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}。"
summary += f"本次异常涉及 {len(devices)} 个测点。"
summary += ("现有监测结果提示爆管嫌疑,需结合管网与现场核验。"
if monitoring["status"] == "suspected_burst" else "异常原因及影响范围待核查。")
event_time = parse_time(monitoring["period_end"]).astimezone(ZoneInfo("Asia/Shanghai"))
command = OperationalTaskCreate(
request_id=uuid5(NAMESPACE_URL, external),
task_type="event_investigation",
workflow="scada_anomaly",
title=f"SCADA 异常调查 · {monitoring['period_end']}",
title=f"SCADA 异常调查 · {event_time:%Y-%m-%d %H:%M}",
summary=summary,
source=TaskSource(
kind="external_system",
+2
View File
@@ -316,6 +316,7 @@ def flushing_analysis(
valve_control: dict[str, dict] = None,
_temporary_project: str | None = None,
persist_results: bool = True,
scada_mappings: ScadaElementMappings | None = None,
) -> dict | str | None:
"""
管道冲洗模拟
@@ -445,6 +446,7 @@ def flushing_analysis(
modify_total_duration=modify_total_duration,
modify_valve_opening=modify_valve_opening,
valve_control=valve_control,
scada_mappings=scada_mappings,
scheme_type="flushing_analysis",
scheme_name=scheme_name,
result_db_name=name,