Compare commits

..
Author SHA1 Message Date
jiang cac43ad734 fix(api): synchronize hydraulic playback binary contract
Generic Container CI/CD / test-build-publish (push) Failing after 2m11s
Server CI/CD v2 / build-test-publish-and-deploy (push) Failing after 2m11s
2026-11-15 23:43:13 +08:00
jiang f5f972bbec feat(api): add compact hydraulic playback endpoints
Generic Container CI/CD / test-build-publish (push) Failing after 34s
Server CI/CD v2 / build-test-publish-and-deploy (push) Failing after 34s
2026-11-15 23:36:29 +08:00
jiang dd31f7c159 docs: update burst impact coverage palette 2026-11-15 20:57:51 +08:00
jiang 105c1ca8ab fix: make burst impact coverage envelopes exclusive
Generic Container CI/CD / test-build-publish (push) Successful in 4m21s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 4m21s
2026-11-15 16:06:41 +08:00
jiang 4b47ae00d1 fix(investigation): persist large results and require graph links
Generic Container CI/CD / test-build-publish (push) Successful in 4m15s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 4m15s
Full-network pressure comparisons exceeded the worker limit before saved-result previews could run. Preserve complete calculation results and keep read-time summaries and pagination.

Incremental graph publication accepted isolated nodes after flow-authoring rules were lost. Require new steps to connect explicitly while retaining historical snapshots and status-only patches. Regression tests reproduce both failures on the previous code.
2026-11-15 13:51:43 +08:00
jiang d3a4433386 test(replay): cover exploratory simulations without impact maps
Generic Container CI/CD / test-build-publish (push) Successful in 4m10s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 4m10s
2026-11-15 12:44:06 +08:00
jiang 747e9e53e0 fix(investigation): allow autonomous model baseline recovery
Generic Container CI/CD / test-build-publish (push) Failing after 2m11s
Server CI/CD v2 / build-test-publish-and-deploy (push) Failing after 2m11s
2026-11-15 12:37:08 +08:00
jiang 58a907bfbe fix(recovery): scope incident verification to relevant devices
Generic Container CI/CD / test-build-publish (push) Successful in 4m33s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 4m33s
Full-network browsing expanded persisted recovery gates to unrelated devices, while full fifteen-day reference reads exhausted the row budget. Rebuild legacy cached scopes from report devices and project monitoring roles, and query only the nearest post-feedback frame and its historical phase.

Regression tests fail before the fix for both new and cached scopes and verify automatic Agent requeue after recovery.
2026-11-15 03:47:55 +08:00
43 changed files with 1355 additions and 125 deletions
@@ -0,0 +1,73 @@
"""Project-authorized compact hydraulic playback transport."""
from collections import OrderedDict
import asyncio
import time
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from psycopg import AsyncConnection
from app.auth.project_dependencies import ProjectContext, get_project_context, get_project_pg_connection
from app.services.hydraulic_playback import MAX_FRAME_COUNT, PlaybackGeometry, read_frames, read_geometry
from app.domain.time import parse_utc_time
from .dependencies import get_timescale_connection
router = APIRouter(prefix="/timeseries/realtime/playback")
BINARY_RESPONSE = {200: {"description": "Versioned little-endian Float32 packet",
"content": {"application/octet-stream": {"schema": {"type": "string", "format": "binary"}}}}}
# Bounded per-process cache, private to the authorized project and database route.
_cache: OrderedDict[tuple, tuple[float, PlaybackGeometry]] = OrderedDict()
_load_lock = asyncio.Lock()
async def geometry_for_project(ctx: ProjectContext, conn: AsyncConnection) -> PlaybackGeometry:
key = (str(ctx.project_id), conn.info.host, conn.info.port, conn.info.dbname)
cached = _cache.get(key)
if cached and time.monotonic() - cached[0] < 60:
_cache.move_to_end(key)
return cached[1]
async with _load_lock:
cached = _cache.get(key)
if cached and time.monotonic() - cached[0] < 60:
return cached[1]
geometry = await read_geometry(conn)
_cache[key] = (time.monotonic(), geometry)
_cache.move_to_end(key)
while len(_cache) > 4:
_cache.popitem(last=False)
return geometry
@router.get("/geometry", summary="读取带版本的水力专题层静态几何",
response_class=Response, responses=BINARY_RESPONSE)
async def playback_geometry(ctx: ProjectContext = Depends(get_project_context),
conn: AsyncConnection = Depends(get_project_pg_connection)):
try:
geometry = await geometry_for_project(ctx, conn)
except ValueError as exc:
raise HTTPException(422, str(exc)) from exc
return Response(geometry.content, media_type="application/octet-stream",
headers={"Cache-Control": "private, no-store", "ETag": f'"{geometry.version}"'})
@router.get("/frames", summary="读取索引一致的 Float32 水力时间块",
response_class=Response, responses=BINARY_RESPONSE)
async def playback_frames(
start_time: str,
model_version: str = Query(min_length=24, max_length=24),
frame_count: int = Query(default=4, ge=1, le=MAX_FRAME_COUNT),
step_seconds: int = Query(default=900, ge=60, le=3600),
ctx: ProjectContext = Depends(get_project_context),
pg_conn: AsyncConnection = Depends(get_project_pg_connection),
ts_conn: AsyncConnection = Depends(get_timescale_connection),
):
try:
start = parse_utc_time(start_time, field_name="start_time")
geometry = await geometry_for_project(ctx, pg_conn)
if geometry.version != model_version:
raise HTTPException(409, "管网模型已更新,请重新加载专题层")
content = await read_frames(ts_conn, geometry, start, frame_count, step_seconds)
except ValueError as exc:
raise HTTPException(422, str(exc)) from exc
return Response(content, media_type="application/octet-stream",
headers={"Cache-Control": "private, no-store"})
+2
View File
@@ -47,6 +47,7 @@ from app.api.v1.endpoints.timeseries import (
analysis as ts_analysis, analysis as ts_analysis,
composite as ts_composite, composite as ts_composite,
realtime as ts_realtime, realtime as ts_realtime,
playback as ts_playback,
scada as ts_scada, scada as ts_scada,
) )
from app.auth.permissions import ( from app.auth.permissions import (
@@ -191,6 +192,7 @@ api_router.include_router(
# TimescaleDB data # TimescaleDB data
for endpoint_router, tag in ( for endpoint_router, tag in (
(ts_realtime.router, "TimescaleDB - Realtime"), (ts_realtime.router, "TimescaleDB - Realtime"),
(ts_playback.router, "TimescaleDB - Hydraulic Playback"),
(ts_analysis.router, "TimescaleDB - Analysis"), (ts_analysis.router, "TimescaleDB - Analysis"),
): ):
api_router.include_router( api_router.include_router(
@@ -143,9 +143,12 @@ class ScadaRepository:
await cur.execute( await cur.execute(
"WITH next_frame AS (SELECT min(time) AS time FROM scada.measurements " "WITH next_frame AS (SELECT min(time) AS time FROM scada.measurements "
"WHERE device_id=ANY(%s) AND time>%s AND time<=%s), " "WHERE device_id=ANY(%s) AND time>%s AND time<=%s), "
"reference AS (SELECT time,device_id,monitored_value,cleaned_value " "reference AS (SELECT m.time,m.device_id,m.monitored_value,m.cleaned_value "
"FROM scada.measurements WHERE device_id=ANY(%s) AND time>=%s AND time<%s " "FROM scada.measurements m CROSS JOIN next_frame f "
"ORDER BY time,device_id LIMIT %s) " "WHERE m.device_id=ANY(%s) AND m.time>=%s AND m.time<%s "
"AND mod(extract(epoch FROM m.time)::bigint,86400)="
"mod(extract(epoch FROM f.time)::bigint,86400) "
"ORDER BY m.time,m.device_id LIMIT %s) "
"SELECT time,device_id,monitored_value,cleaned_value FROM reference UNION ALL " "SELECT time,device_id,monitored_value,cleaned_value FROM reference UNION ALL "
"SELECT m.time,m.device_id,m.monitored_value,m.cleaned_value FROM scada.measurements m " "SELECT m.time,m.device_id,m.monitored_value,m.cleaned_value FROM scada.measurements m "
"JOIN next_frame f ON m.time=f.time WHERE m.device_id=ANY(%s) ORDER BY time,device_id", "JOIN next_frame f ON m.time=f.time WHERE m.device_id=ANY(%s) ORDER BY time,device_id",
+9
View File
@@ -116,6 +116,15 @@ async def calculate_replay_burst_location(
) )
async def calculate_replay_hydraulic_scenario(name: str, args: BaseModel, *, network: str, username: str) -> dict[str, Any]:
"""Evaluate model-based hydraulic alternatives without live SCADA substitution."""
return await bounded_calculation(
calculate_scenario, name,
args.model_dump(exclude={"context_handle", "timeout_seconds"}, exclude_none=True),
network=network, username=username, model_only=True,
)
async def calculate_replay_burst_simulation(args: BaseModel, *, network: str, username: str) -> dict[str, Any]: async def calculate_replay_burst_simulation(args: BaseModel, *, network: str, username: str) -> dict[str, Any]:
"""An independent current-model scenario, with no live SCADA substitution.""" """An independent current-model scenario, with no live SCADA substitution."""
return await bounded_calculation( return await bounded_calculation(
+1 -1
View File
@@ -63,7 +63,7 @@ class QueryTool:
_specs = [ _specs = [
("run_contaminant_source_trace", "水污染候选溯源:提交由事件位置或监测映射取得的候选模型节点及事件窗,快速返回确定的已核验节点。当前为临时稳定选择,正式反演算法后续接入;不生成置信度。取得located_node后先展示节点、保存阶段结论并提现场核查/采样处置工单;工单与污染传播模拟、accident_element_type=node的隔离和影响分析并行,结果补入同一调查,不因算法来源停止", ContaminantSourceTraceInput, "simulation.run", "analysis", False, True), ("run_contaminant_source_trace", "水污染候选溯源:提交由事件位置或监测映射取得的候选模型节点及事件窗,快速返回确定的已核验节点。当前为临时稳定选择,正式反演算法后续接入;不生成置信度。取得located_node后先展示节点、保存阶段结论并提现场核查/采样处置工单;工单与污染传播模拟、accident_element_type=node的隔离和影响分析并行,结果补入同一调查,不因算法来源停止", ContaminantSourceTraceInput, "simulation.run", "analysis", False, True),
("run_simulation", "按水力步长在在线业务模型上执行指定时间段的实时模拟;更新该时段的模型条件及实时基线,返回指定节点和管段结果;duration单位为秒,输出为在线模型结果,正式分析成果通过方案工具保存", ScenarioInput, "simulation.run", "analysis", False, True), ("run_simulation", "按水力步长在在线业务模型上执行指定时间段的实时模拟,更新全网实时基线,返回指定节点和管段结果;调查发现同刻基线缺失时可自主补算,等待成功并读取结果后重试依赖分析。duration单位为秒,补单帧可用60秒;回放按当前模型补算,不读取实时SCADA,不替代冻结观测;正式分析成果通过方案工具保存", ScenarioInput, "simulation.run", "analysis", False, True),
("run_contaminant_simulation", "在隔离模型中模拟假设污染源的浓度传播;include_impact_coverage=true时汇总全网节点整个模拟时段超过impact_threshold_mg_l的累计范围、首个超阈值报告时刻和峰值,并保存地图证据。阈值为情景展示条件,非饮用水安全限值;回放按当前模型独立估计,不读取实时SCADA。定位后先提现场核查/采样处置工单,工单审核派发与本计算并行,结果补入同一调查", ContaminantSimulationInput, "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_valve_closure", "在隔离模型中模拟关阀的水力影响,返回指定节点/管段结果;现场阀门操作按业务审批执行", ValveClosureInput, "simulation.run", "analysis", False, True),
("analyze_flushing", "在独立模型中评估指定排放节点、流量及明确开关阀组合的冲洗水力结果;未列阀门沿用当前模型,不继承上次模拟;不判定污染清除或恢复供水", FlushingInput, "simulation.run", "analysis", False, True), ("analyze_flushing", "在独立模型中评估指定排放节点、流量及明确开关阀组合的冲洗水力结果;未列阀门沿用当前模型,不继承上次模拟;不判定污染清除或恢复供水", FlushingInput, "simulation.run", "analysis", False, True),
-3
View File
@@ -15,7 +15,6 @@ from app.mcp.models import BindInput, BoundContext
from app.mcp.scope import QueryScope from app.mcp.scope import QueryScope
from app.mcp.config import McpSettings from app.mcp.config import McpSettings
from mcp.server.auth.provider import AccessToken from mcp.server.auth.provider import AccessToken
from app.mcp.work_order_skills import skill_name_for_task
def task_repository(scope: QueryScope) -> OperationalTaskRepository: def task_repository(scope: QueryScope) -> OperationalTaskRepository:
@@ -32,8 +31,6 @@ async def verify_task(scope: QueryScope, binding: BoundContext) -> None:
if not task: if not task:
raise HTTPException(404, "Task is not in this project") raise HTTPException(404, "Task is not in this project")
binding.replay_context = task.get("source", {}).get("replay_context") binding.replay_context = task.get("source", {}).get("replay_context")
if skill_name_for_task(task.get("workflow"), binding.replay_context) == "burst-event-handling":
binding.tool_cap = [name for name in binding.tool_cap if name != "run_burst_location"]
if binding.purpose == "task_collaboration": if binding.purpose == "task_collaboration":
return return
if OPERATIONAL_TASK_EXECUTE not in permissions: if OPERATIONAL_TASK_EXECUTE not in permissions:
+24 -11
View File
@@ -79,20 +79,20 @@ async def authorize_query(
"get_element_properties", "list_elements", "query_nearby_elements", "get_network_options", "get_element_properties", "list_elements", "query_nearby_elements", "get_network_options",
"get_node_connections", "get_element_gis_properties", "query_network_extent", "get_node_connections", "get_element_gis_properties", "query_network_extent",
"list_regions", "get_region", "get_region_nodes", "list_regions", "get_region", "get_region_nodes",
"analyze_valve_isolation", "generate_node_coverage", "run_burst_simulation", "run_contaminant_simulation", "analyze_flushing", "analyze_valve_isolation", "generate_node_coverage", *SCENARIO_TOOLS,
"calculate_demand_allocation", "optimize_sensor_placement",
"query_model_baseline_series", "get_model_baseline_at_time", "query_model_baseline_field",
"get_calculation_job", "get_calculation_result", "list_calculation_jobs"} "get_calculation_job", "get_calculation_result", "list_calculation_jobs"}
if tool.name not in allowed: asset_query = tool.semantics == "asset" and not tool.mutating and not tool.computation
if tool.name not in allowed and not asset_query:
raise HTTPException(403, "回放调查仅开放冻结 SCADA、资产、拓扑分析及指定模型情景;时序观测请使用 query_scada_readings") raise HTTPException(403, "回放调查仅开放冻结 SCADA、资产、拓扑分析及指定模型情景;时序观测请使用 query_scada_readings")
if tool.name == "run_burst_simulation": if tool.name in SCENARIO_TOOLS:
if not args.include_impact_coverage:
raise HTTPException(422, "回放爆管情景必须启用影响覆盖,按当前模型估计并保存同刻在线基线")
check_replay_window(replay_context, args.start_time, args.start_time)
if tool.name == "run_contaminant_simulation":
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) check_replay_window(replay_context, args.start_time, args.start_time)
if tool.semantics == "model_baseline":
if tool.name == "query_model_baseline_series":
check_replay_window(replay_context, args.start, args.end)
else:
check_replay_window(replay_context, args.time, args.time)
if tool.name == "query_scada_readings": if tool.name == "query_scada_readings":
check_replay_window(replay_context, args.start, args.end) check_replay_window(replay_context, args.start, args.end)
if tool.name == "run_contaminant_source_trace": if tool.name == "run_contaminant_source_trace":
@@ -213,6 +213,17 @@ async def execute_query(
args.device_ids, args.start, args.end, args.limit + 1, args.offset) args.device_ids, args.start, args.end, args.limit + 1, args.offset)
data, page, truncated = page_result(rows, args) data, page, truncated = page_result(rows, args)
origin = "synthetic" origin = "synthetic"
elif name in {"run_simulation", "analyze_valve_closure", "analyze_pressure_regulation"} and replay_context:
from app.mcp.analysis_service import calculate_replay_hydraulic_scenario
await conn.commit()
async with scope.simulation_routing():
result = await calculate_replay_hydraulic_scenario(
name, 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_simulation" and replay_context: elif name == "run_burst_simulation" and replay_context:
from app.mcp.analysis_service import calculate_replay_burst_simulation from app.mcp.analysis_service import calculate_replay_burst_simulation
@@ -632,6 +643,8 @@ async def execute_query(
truncated = snapshot.truncated truncated = snapshot.truncated
else: else:
raise ValueError("query handler is not registered") raise ValueError("query handler is not registered")
if replay_context and tool.semantics == "model_baseline":
gaps.append("在线模型参考按本次查询时可用数据读取,非冻结SCADA观测;历史报告复用已保存证据。")
window = ( window = (
{"start": args.start, "end": args.end} {"start": args.start, "end": args.end}
if hasattr(args, "start") and args.start is not None if hasattr(args, "start") and args.start is not None
+13 -26
View File
@@ -29,50 +29,36 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u
impact_threshold = parameters.pop("impact_threshold_mg_l", 0) impact_threshold = parameters.pop("impact_threshold_mg_l", 0)
common = dict(name=network, modify_pattern_start_time=start.isoformat(), common = dict(name=network, modify_pattern_start_time=start.isoformat(),
modify_total_duration=duration, persist_results=False, scheme_name=None) modify_total_duration=duration, persist_results=False, scheme_name=None)
boundary = {}
if model_only:
from app.infra.db.postgresql.scada import ScadaElementMappings
# Model scenarios may support replay decisions without consuming mutable
# live SCADA in place of the task's frozen observations.
boundary["scada_mappings"] = ScadaElementMappings(
reservoirs={}, tanks={}, fixed_pumps={}, variable_pumps={},
pressure={}, demand={}, quality={},
)
if name == "run_simulation": if name == "run_simulation":
from app.services.simulation import run_online_simulation from app.services.simulation import run_online_simulation
result = run_online_simulation( result = run_online_simulation(
network, start, duration, output_node_ids=nodes, output_link_ids=links, network, start, duration, output_node_ids=nodes, output_link_ids=links, **boundary,
) )
elif name == "run_burst_simulation": elif name == "run_burst_simulation":
boundary = {}
if model_only:
from app.infra.db.postgresql.scada import ScadaElementMappings
# Replay scenarios use current model assumptions, never mutable live
# SCADA readings in place of the investigation's frozen observations.
boundary["scada_mappings"] = ScadaElementMappings(
reservoirs={}, tanks={}, fixed_pumps={}, variable_pumps={},
pressure={}, demand={}, quality={},
)
result = scenarios.burst_analysis(**common, username=username, result = scenarios.burst_analysis(**common, username=username,
burst_ID=parameters["burst_pipe_ids"], burst_size=parameters["orifice_areas_cm2"], **boundary) burst_ID=parameters["burst_pipe_ids"], burst_size=parameters["orifice_areas_cm2"], **boundary)
elif name == "run_contaminant_simulation": elif name == "run_contaminant_simulation":
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.contaminant_simulation(**common, username=username, result = scenarios.contaminant_simulation(**common, username=username,
source=parameters["source"], concentration=parameters["concentration"], source_pattern=parameters.get("pattern"), **boundary) source=parameters["source"], concentration=parameters["concentration"], source_pattern=parameters.get("pattern"), **boundary)
elif name == "analyze_valve_closure": elif name == "analyze_valve_closure":
result = scenarios.valve_close_analysis(**common, result = scenarios.valve_close_analysis(**common,
modify_valve_opening={valve: 0.0 for valve in parameters["valves"]}) modify_valve_opening={valve: 0.0 for valve in parameters["valves"]}, **boundary)
elif name == "analyze_flushing": elif name == "analyze_flushing":
# The service consumes m³/h (the older REST label incorrectly says L/s). # The service consumes m³/h (the older REST label incorrectly says L/s).
# Reject unsupported conversion rather than silently ignore the flow. # Reject unsupported conversion rather than silently ignore the flow.
from app.native.wndb.model.options import get_option from app.native.wndb.model.options import get_option
if get_option(network).get("UNITS") not in {"LPS", "CMH"}: if get_option(network).get("UNITS") not in {"LPS", "CMH"}:
raise ValueError("Flushing currently supports LPS or CMH models only") 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, result = scenarios.flushing_analysis(**common, username=username,
drainage_node_ID=parameters["drainage_node_id"], flushing_flow=parameters["flushing_flow_m3h"], drainage_node_ID=parameters["drainage_node_id"], flushing_flow=parameters["flushing_flow_m3h"],
valve_control=flushing_valve_controls(parameters), **boundary) valve_control=flushing_valve_controls(parameters), **boundary)
@@ -87,7 +73,8 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u
result = scenarios.pressure_regulation(**common, result = scenarios.pressure_regulation(**common,
modify_fixed_pump_pattern={key: value for key, value in controls.items() if key in mappings.fixed_pumps}, modify_fixed_pump_pattern={key: value for key, value in controls.items() if key in mappings.fixed_pumps},
modify_variable_pump_pattern={key: value for key, value in controls.items() if key in mappings.variable_pumps}, modify_variable_pump_pattern={key: value for key, value in controls.items() if key in mappings.variable_pumps},
modify_tank_initial_level=parameters["tank_init_level"], scada_mappings=mappings) modify_tank_initial_level=parameters["tank_init_level"],
scada_mappings=boundary.get("scada_mappings", mappings))
else: else:
raise ValueError("Scenario is not registered") raise ValueError("Scenario is not registered")
if not isinstance(result, dict): if not isinstance(result, dict):
+11 -1
View File
@@ -125,6 +125,13 @@ def estimate_burst_impact(network: str, start: datetime, simulation: dict[str, A
impact["below_threshold_node_count"] = len(comparisons) - affected impact["below_threshold_node_count"] = len(comparisons) - affected
if not comparisons: if not comparisons:
impact.update(status="unavailable", reason="no_exact_comparable_online_snapshot") impact.update(status="unavailable", reason="no_exact_comparable_online_snapshot")
impact["baseline_recovery"] = {
"tool": "run_simulation",
"parameters": {
"start_time": start.isoformat(), "duration": 60, "output_node_ids": ids[:1],
},
"next_step": "自主补算同刻在线基线;等待作业成功并读取结果后,按原参数重跑爆管影响计算,再发布降压覆盖。返回节点仅用于核验,在线基线按全网写入。",
}
return output return output
if affected > MAX_COVERAGE_NODES: if affected > MAX_COVERAGE_NODES:
impact["coverage_status"] = "unavailable" impact["coverage_status"] = "unavailable"
@@ -132,9 +139,12 @@ def estimate_burst_impact(network: str, start: datetime, simulation: dict[str, A
return output return output
groups = [{key: group[key] for key in ("group_id", "title", "node_ids")} groups = [{key: group[key] for key in ("group_id", "title", "node_ids")}
for group in impact["grades"]] for group in impact["grades"]]
geometry = generate_node_coverage(network, groups) # GRADES are ordered from low to high. Keep the numerical classes intact
# while removing overlap between their independently derived envelopes.
geometry = generate_node_coverage(network, groups, exclusive_groups=True)
impact["coverage_status"] = geometry["coverage"]["status"] impact["coverage_status"] = geometry["coverage"]["status"]
geometry["coverage"]["boundary"] = impact["boundary"] geometry["coverage"]["boundary"] = impact["boundary"]
geometry["coverage"]["boundary"] += "分档包络互斥展示,重叠区域优先显示较高降压档,不改变节点分档或表示精确水力等值边界。"
for feature in geometry["map_payload"]["features"]: for feature in geometry["map_payload"]["features"]:
feature["properties"].update( feature["properties"].update(
impact_phase=impact["phase"], pressure_drop_unit="m", impact_phase=impact["phase"], pressure_drop_unit="m",
+2 -4
View File
@@ -93,8 +93,8 @@ async def execute_child(input_path: str, output_path: str):
category = 'EXECUTION_CONTEXT_UNAVAILABLE' category = 'EXECUTION_CONTEXT_UNAVAILABLE'
payload = {'status': 'failed', 'error': {'code': category}} payload = {'status': 'failed', 'error': {'code': category}}
encoded = json.dumps(payload, allow_nan=False) encoded = json.dumps(payload, allow_nan=False)
if len(encoded.encode()) > 8 * 1024 * 1024: # Keep the complete calculation for persistence. MCP result reads apply
encoded = json.dumps({'status': 'failed', 'error': {'code': 'RESULT_TOO_LARGE'}}) # their own summary/page budgets after the result has been saved.
Path(output_path).write_text(encoded) Path(output_path).write_text(encoded)
@@ -212,8 +212,6 @@ class CalculationWorker:
terminal = {'status': 'timed_out', 'error': {'code': 'EXECUTION_TIMEOUT'}} terminal = {'status': 'timed_out', 'error': {'code': 'EXECUTION_TIMEOUT'}}
elif process.returncode is not None: elif process.returncode is not None:
try: try:
if output_path.stat().st_size > 8 * 1024 * 1024:
raise ValueError('Result exceeds persistence limit')
terminal = json.loads(output_path.read_text()) terminal = json.loads(output_path.read_text())
except (OSError, ValueError): except (OSError, ValueError):
terminal = {'status': 'failed', 'error': {'code': 'CHILD_PROCESS_FAILED'}} terminal = {'status': 'failed', 'error': {'code': 'CHILD_PROCESS_FAILED'}}
+184
View File
@@ -0,0 +1,184 @@
"""Versioned static geometry and compact, indexed hydraulic playback frames."""
from array import array
import asyncio
from dataclasses import dataclass
from datetime import datetime, timedelta
import hashlib
import json
import math
import struct
import sys
from typing import Any
from psycopg import AsyncConnection
from app.infra.db.postgresql.network_settings import NetworkSettingsRepository
WORLD_METERS = 40075016.68557849
MAX_FRAME_COUNT = 8
def packet(header: dict[str, Any], values: array) -> bytes:
"""Four-byte aligned JSON header followed by little-endian Float32 values."""
encoded = json.dumps(header, separators=(",", ":"), ensure_ascii=False).encode()
encoded += b" " * (-len(encoded) % 4)
if sys.byteorder != "little":
values = array("f", values)
values.byteswap()
return struct.pack("<I", len(encoded)) + encoded + values.tobytes()
def mercator(longitude: float, latitude: float) -> tuple[float, float]:
latitude = max(-85.05112878, min(85.05112878, latitude))
return ((longitude + 180) / 360,
(1 - math.asinh(math.tan(math.radians(latitude))) / math.pi) / 2)
@dataclass(frozen=True)
class PlaybackGeometry:
version: str
node_ids: tuple[str, ...]
pipe_ids: tuple[str, ...]
node_index: dict[str, int]
pipe_index: dict[str, int]
content: bytes
flow_factor: float
pressure_factor: float
velocity_factor: float
def result_factors(units: dict[str, str]) -> tuple[float, float, float]:
"""Match EPANET outputs to m³/h, metres of water, and m/s."""
flow = units["flow"].upper()
flow_factors = {"LPS": 3.6, "LPM": .06, "MLD": 1000 / 24,
"CMH": 1, "CMD": 1 / 24, "CFS": .028316846592 * 3600,
"GPM": .003785411784 * 60, "MGD": 3785.411784 / 24,
"IMGD": 4546.09 / 24, "AFD": 1233.48183754752 / 24}
pressure = units["pressure"].upper()
pressure_factors = {"M": 1, "METERS": 1, "PSI": .7032496149, "KPA": .1019716213}
if flow not in flow_factors or pressure not in pressure_factors:
raise ValueError("不支持的模拟结果单位")
return (flow_factors[flow], pressure_factors[pressure],
.3048 if flow in {"CFS", "GPM", "MGD", "IMGD", "AFD"} else 1)
def line_paths(geometry: dict) -> list:
if geometry["type"] == "LineString":
return [geometry["coordinates"]]
if geometry["type"] == "MultiLineString":
return geometry["coordinates"]
if geometry["type"] == "GeometryCollection":
# PostGIS LineMerge returns an empty collection for zero-length lines.
return [path for child in geometry["geometries"] for path in line_paths(child)]
raise ValueError("管线几何必须为线类型")
def build_geometry(nodes: list[dict], pipes: list[dict], units: dict[str, str]) -> PlaybackGeometry:
if not nodes or not pipes:
raise ValueError("管网没有可发布的节点或管线几何")
node_ids = tuple(str(row["id"]) for row in nodes)
pipe_ids = tuple(str(row["id"]) for row in pipes)
node_positions = [mercator(float(row["longitude"]), float(row["latitude"])) for row in nodes]
positions_by_id = dict(zip(node_ids, node_positions))
origin = [(min(p[i] for p in node_positions) + max(p[i] for p in node_positions)) / 2 for i in (0, 1)]
values = array("f", (coordinate - origin[axis] for position in node_positions for axis, coordinate in enumerate(position)))
segment_count = 0
for index, row in enumerate(pipes):
geometry = row["geometry"]
if isinstance(geometry, str):
geometry = json.loads(geometry)
paths = line_paths(geometry)
start_node = positions_by_id.get(str(row.get("start_node_id", "")))
if start_node and paths and paths[0] and paths[-1]:
first = mercator(*paths[0][0][:2])
last = mercator(*paths[-1][-1][:2])
if math.dist(last, start_node) < math.dist(first, start_node):
paths = [list(reversed(path)) for path in reversed(paths)]
distance = 0.0
for path in paths:
positions = [mercator(float(p[0]), float(p[1])) for p in path]
for start, end in zip(positions, positions[1:]):
length = math.hypot(end[0] - start[0], end[1] - start[1])
if length <= 0:
continue
# Instanced segment: endpoints relative to origin, pipe index,
# cumulative distance, segment length, physical diameter (mm).
values.extend((start[0] - origin[0], start[1] - origin[1],
end[0] - origin[0], end[1] - origin[1],
index, distance, length, float(row["diameter"] or 0)))
distance += length
segment_count += 1
factors = result_factors(units)
descriptor = json.dumps([node_ids, pipe_ids, origin, units], separators=(",", ":")).encode()
version = hashlib.sha256(descriptor + values.tobytes()).hexdigest()[:24]
header = {"kind": "geometry", "protocol": 1, "modelVersion": version,
"nodeIds": node_ids, "pipeIds": pipe_ids, "origin": origin,
"segmentCount": segment_count, "units": {"pressure": "m", "flow": "m³/h", "velocity": "m/s"}}
return PlaybackGeometry(version, node_ids, pipe_ids,
{key: i for i, key in enumerate(node_ids)},
{key: i for i, key in enumerate(pipe_ids)},
packet(header, values), *factors)
async def read_geometry(conn: AsyncConnection) -> PlaybackGeometry:
async with conn.cursor() as cursor:
await cursor.execute("""
SELECT n.id, ST_X(ST_Transform(g.geom,4326)) AS longitude,
ST_Y(ST_Transform(g.geom,4326)) AS latitude
FROM gis.network_nodes n JOIN gis.node_geometries g ON g.node_id=n.id
WHERE g.geom IS NOT NULL AND NOT ST_IsEmpty(g.geom) ORDER BY n.id
""")
nodes = await cursor.fetchall()
await cursor.execute("""
SELECT id, start_node_id, diameter, ST_AsGeoJSON(ST_Transform(ST_LineMerge(geom),4326))::json AS geometry
FROM gis.pipes WHERE geom IS NOT NULL AND NOT ST_IsEmpty(geom) ORDER BY id
""")
pipes = await cursor.fetchall()
units = await NetworkSettingsRepository.get_result_units(conn)
return await asyncio.to_thread(build_geometry, nodes, pipes, units)
def finite(value: Any, factor: float) -> float:
if value is None:
return math.nan
result = float(value) * factor
return result if math.isfinite(result) and abs(result) <= 3.402823466e38 else math.nan
async def read_frames(conn: AsyncConnection, geometry: PlaybackGeometry, start: datetime,
frame_count: int, step_seconds: int) -> bytes:
times = [start + timedelta(seconds=step_seconds * i) for i in range(frame_count)]
node_count, pipe_count = len(geometry.node_ids), len(geometry.pipe_ids)
stride = node_count + 2 * pipe_count
values = array("f", [math.nan]) * (stride * frame_count)
time_index = {value: i for i, value in enumerate(times)}
valid_counts = {metric: [0] * frame_count for metric in ("pressure", "flow", "velocity")}
async with conn.cursor() as cursor:
await cursor.execute("""
SELECT time, node_id, pressure FROM realtime.node_results
WHERE time = ANY(%s) ORDER BY time, node_id
""", (times,))
async for row in cursor:
index = geometry.node_index.get(str(row["node_id"]))
if index is not None:
frame = time_index[row["time"]]
values[frame * stride + index] = finite(row["pressure"], geometry.pressure_factor)
valid_counts["pressure"][frame] += math.isfinite(values[frame * stride + index])
await cursor.execute("""
SELECT time, link_id, flow, velocity FROM realtime.link_results
WHERE time = ANY(%s) ORDER BY time, link_id
""", (times,))
async for row in cursor:
index = geometry.pipe_index.get(str(row["link_id"]))
if index is not None:
frame = time_index[row["time"]]
offset = frame * stride + node_count
values[offset + index] = finite(row["flow"], geometry.flow_factor)
values[offset + pipe_count + index] = finite(row["velocity"], geometry.velocity_factor)
valid_counts["flow"][frame] += math.isfinite(values[offset + index])
valid_counts["velocity"][frame] += math.isfinite(values[offset + pipe_count + index])
return packet({"kind": "frames", "protocol": 1, "modelVersion": geometry.version,
"nodeCount": node_count, "pipeCount": pipe_count,
"times": [int(t.timestamp() * 1000) for t in times], "validCounts": valid_counts,
"available": [valid_counts["pressure"][i] > 0 and valid_counts["flow"][i] > 0 for i in range(frame_count)]}, values)
+21
View File
@@ -39,6 +39,7 @@ def update_investigation_graph(
)) ))
nodes = {node.node_id: node.model_copy(deep=True) for node in previous.node_refs} if previous else {} nodes = {node.node_id: node.model_copy(deep=True) for node in previous.node_refs} if previous else {}
saved_ids = set(nodes)
require(len({node.node_id for node in update.nodes}) == len(update.nodes), require(len({node.node_id for node in update.nodes}) == len(update.nodes),
"GRAPH_NODE_DUPLICATE", "Graph update contains duplicate node ids", status=422) "GRAPH_NODE_DUPLICATE", "Graph update contains duplicate node ids", status=422)
for node in update.nodes: for node in update.nodes:
@@ -81,6 +82,26 @@ def update_investigation_graph(
edges.append(edge.model_copy(deep=True)) edges.append(edge.model_copy(deep=True))
require(len(nodes) <= 100 and len(edges) <= 200, "GRAPH_CAPACITY_EXCEEDED", require(len(nodes) <= 100 and len(edges) <= 200, "GRAPH_CAPACITY_EXCEEDED",
"Investigation graph exceeds 100 nodes or 200 edges; existing paths were retained", status=422) "Investigation graph exceeds 100 nodes or 200 edges; existing paths were retained", status=422)
# Validate only newly added steps. Historical snapshots and status-only
# patches stay valid, even if an earlier publisher omitted relationships.
new_ids = set(nodes) - saved_ids
if new_ids and len(nodes) > 1:
neighbours = {node_id: set() for node_id in nodes}
for edge in edges:
neighbours[edge.source].add(edge.target)
neighbours[edge.target].add(edge.source)
connected = set(saved_ids) or {next(iter(nodes))}
pending = list(connected)
while pending:
for node_id in neighbours[pending.pop()] - connected:
connected.add(node_id)
pending.append(node_id)
missing = sorted(new_ids - connected)
require(not missing, "GRAPH_RELATION_REQUIRED",
"Add explicit edges connecting these new steps to the investigation narrative: "
+ ", ".join(missing)
+ ". Describe branches, parallel findings or retries; edges do not schedule execution.",
status=422)
lines = [source or "flowchart TD"] lines = [source or "flowchart TD"]
for node in nodes.values(): for node in nodes.values():
if in_source(node.node_id): if in_source(node.node_id):
+51 -2
View File
@@ -6,7 +6,7 @@ from typing import Any
from pyproj import CRS, Transformer from pyproj import CRS, Transformer
from shapely import MultiPoint, concave_hull, get_num_coordinates from shapely import MultiPoint, concave_hull, get_num_coordinates
from shapely.geometry import mapping from shapely.geometry import GeometryCollection, mapping, shape
from shapely.ops import transform, unary_union from shapely.ops import transform, unary_union
from app.native.wndb.gis.network_views import get_node_coverage_links, get_node_geographic_positions from app.native.wndb.gis.network_views import get_node_coverage_links, get_node_geographic_positions
@@ -17,6 +17,47 @@ SIMPLIFY_METERS = 2.0
MAX_COVERAGE_NODES = 10000 MAX_COVERAGE_NODES = 10000
def exclusive_coverage_features(features: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Clip display envelopes, with later groups taking precedence; retain input order.
This changes only derived geometry, never node membership or model values.
No post-clipping buffer or simplification may reintroduce an overlap.
"""
occupied = GeometryCollection()
result = []
summaries = {}
for feature in features:
properties = feature["properties"]
summaries.update(properties.get("coverage_group_summary", {}))
for feature in features:
properties = feature["properties"]
summaries[properties["coverage_group"]] = {
key: properties.get(key) for key in ("node_count", "mapped_node_count", "missing_node_count")
}
for feature in reversed(features):
original = shape(feature["geometry"])
visible = original.difference(occupied)
occupied = unary_union([occupied, original])
if visible.is_empty:
continue
if (visible.geom_type not in {"Polygon", "MultiPolygon"} or not visible.is_valid
or get_num_coordinates(visible) > 20000):
raise ValueError("Invalid exclusive derived coverage")
result.append({
**feature,
"properties": {**feature["properties"], "overlap_policy": "higher_priority_group_first"},
"geometry": mapping(visible),
})
visible_groups = {feature["properties"]["coverage_group"] for feature in result}
summaries = {key: {**value, "display_area_available": key in visible_groups}
for key, value in summaries.items()}
for feature in result:
# Retain counts even when that group's entire display envelope was
# clipped away. Overlay transport deliberately sends only GeoJSON.
feature["properties"]["coverage_group_summary"] = summaries
return list(reversed(result))
def _components(node_ids: list[str], links: list[dict[str, Any]], closed: set[str]) -> list[list[str]]: def _components(node_ids: list[str], links: list[dict[str, Any]], closed: set[str]) -> list[list[str]]:
parent = {node_id: node_id for node_id in node_ids} parent = {node_id: node_id for node_id in node_ids}
@@ -41,6 +82,7 @@ def build_node_coverage(
links: list[dict[str, Any]], *, split_by_connectivity: bool = False, links: list[dict[str, Any]], *, split_by_connectivity: bool = False,
excluded_link_ids: list[str] | None = None, excluded_link_ids: list[str] | None = None,
concave_ratio: float = CONCAVE_RATIO, buffer_m: float = BUFFER_METERS, concave_ratio: float = CONCAVE_RATIO, buffer_m: float = BUFFER_METERS,
exclusive_groups: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Keep caller-defined comparison groups and derive geometry without assigning business meaning.""" """Keep caller-defined comparison groups and derive geometry without assigning business meaning."""
coordinates = {} coordinates = {}
@@ -99,6 +141,12 @@ def build_node_coverage(
} }
features.append(feature) features.append(feature)
if exclusive_groups:
features = exclusive_coverage_features(features)
visible_groups = {feature["properties"]["coverage_group"] for feature in features}
for group_id, metadata in groups_metadata.items():
metadata["display_area_available"] = group_id in visible_groups
return { return {
"status": "partial" if any(group["missing_node_ids"] for group in groups_metadata.values()) "status": "partial" if any(group["missing_node_ids"] for group in groups_metadata.values())
else "ready" if features else "empty", else "ready" if features else "empty",
@@ -106,7 +154,8 @@ def build_node_coverage(
"parameters": {"concave_ratio": concave_ratio, "buffer_m": buffer_m, "parameters": {"concave_ratio": concave_ratio, "buffer_m": buffer_m,
"split_by_connectivity": split_by_connectivity, "split_by_connectivity": split_by_connectivity,
"excluded_link_ids": sorted(closed), "excluded_link_ids": sorted(closed),
"simplify_m": simplify_m, "projection": "local_azimuthal_equidistant"}, "simplify_m": simplify_m, "projection": "local_azimuthal_equidistant",
"exclusive_groups": exclusive_groups},
"output_crs": "EPSG:4326", "display_only": True, "output_crs": "EPSG:4326", "display_only": True,
"position_source": "gis.node_geometries", "position_source": "gis.node_geometries",
"boundary": "节点空间覆盖示意;不推断故障、污染、停水、水力影响或用户服务边界。", "boundary": "节点空间覆盖示意;不推断故障、污染、停水、水力影响或用户服务边界。",
+13 -6
View File
@@ -170,13 +170,18 @@ async def reconcile_recoveries(service: OperationalTaskService, timescale: Async
devices = list(dict.fromkeys(roles.pressure_device_ids + roles.outlet_flow_device_ids)) if roles else [] devices = list(dict.fromkeys(roles.pressure_device_ids + roles.outlet_flow_device_ids)) if roles else []
source = "scada_readings" source = "scada_readings"
through = service._now() through = service._now()
if context.get("device_ids"): report_revision = task.get("current_report_revision")
if (context.get("device_ids") and context.get("device_selection_version") == 2
and context.get("source_report_revision") == report_revision):
devices = context["device_ids"] devices = context["device_ids"]
interval = context.get("interval_seconds", interval) interval = context.get("interval_seconds", interval)
else: else:
# Retain the points the Agent actually inspected, including water # Full-network browsing is discovery, not a declaration that every
# quality or local incident sensors outside the project role lists. # device must recover for this incident. Prefer the report's scope.
for evidence in await service.repo.list_evidence(task["task_id"]): report = await service.repo.get_latest_report(task["task_id"])
declared = ((report or {}).get("report") or {}).get("scada_device_ids") or []
devices.extend(declared)
for evidence in ([] if declared else await service.repo.list_evidence(task["task_id"])):
saved = evidence.get("data_snapshot") or {} saved = evidence.get("data_snapshot") or {}
if saved.get("source") not in {"query_scada_readings", "scada_readings"}: if saved.get("source") not in {"query_scada_readings", "scada_readings"}:
continue continue
@@ -200,11 +205,13 @@ async def reconcile_recoveries(service: OperationalTaskService, timescale: Async
await service.repo.lock(f"task:{task['task_id']}") await service.repo.lock(f"task:{task['task_id']}")
current = await service._task(UUID(task["task_id"])) current = await service._task(UUID(task["task_id"]))
# A new receipt, cancellation or another poll may have won the race. # A new receipt, cancellation or another poll may have won the race.
if current.get("handling_context") != context or current["status"] in {"cancelled", "failed"}: if (current.get("handling_context") != context or current["status"] in {"cancelled", "failed"}
or current.get("current_report_revision") != report_revision):
continue continue
now = service._now() now = service._now()
if devices: if devices:
current["handling_context"].update(device_ids=devices, interval_seconds=interval or 0) current["handling_context"].update(device_ids=devices, interval_seconds=interval or 0,
device_selection_version=2, source_report_revision=report_revision)
current["handling_context"]["last_checked_at"] = now.isoformat() current["handling_context"]["last_checked_at"] = now.isoformat()
current["handling_context"]["last_check"] = result current["handling_context"]["last_check"] = result
if result["status"] == "passed": if result["status"] == "passed":
+7 -3
View File
@@ -157,6 +157,7 @@ def run_online_simulation(
*, *,
output_node_ids: list[str] | None = None, output_node_ids: list[str] | None = None,
output_link_ids: list[str] | None = None, output_link_ids: list[str] | None = None,
scada_mappings: ScadaElementMappings | None = None,
) -> dict: ) -> dict:
"""Recalculate online baseline snapshots without cloning a model database.""" """Recalculate online baseline snapshots without cloning a model database."""
if start_time.tzinfo is None or duration_seconds <= 0: if start_time.tzinfo is None or duration_seconds <= 0:
@@ -166,7 +167,10 @@ def run_online_simulation(
) )
if step <= 0: if step <= 0:
raise ValueError("HYDRAULIC TIMESTEP must be greater than 0.") raise ValueError("HYDRAULIC TIMESTEP must be greater than 0.")
mappings = query_corresponding_element_id_and_query_id(name) mappings = (
scada_mappings if scada_mappings is not None
else query_corresponding_element_id_and_query_id(name)
)
selected = { selected = {
"node": {node: [] for node in output_node_ids or []}, "node": {node: [] for node in output_node_ids or []},
"link": {link: [] for link in output_link_ids or []}, "link": {link: [] for link in output_link_ids or []},
@@ -179,13 +183,13 @@ def run_online_simulation(
output = run_simulation( output = run_simulation(
name=name, name=name,
simulation_type="realtime", simulation_type="realtime",
modify_pattern_start_time=current.isoformat(timespec="seconds"), modify_pattern_start_time=current.isoformat(),
scada_mappings=mappings, scada_mappings=mappings,
) )
if not isinstance(output, dict): if not isinstance(output, dict):
raise RuntimeError("Online simulation did not return calculation results") raise RuntimeError("Online simulation did not return calculation results")
units = output.get("units", {}) units = output.get("units", {})
sample_times.append(current.isoformat(timespec="seconds")) sample_times.append(current.isoformat())
for kind, elements in selected.items(): for kind, elements in selected.items():
if not elements: if not elements:
continue continue
+2
View File
@@ -231,6 +231,7 @@ def valve_close_analysis(
_temporary_project: str | None = None, _temporary_project: str | None = None,
persist_results: bool = True, persist_results: bool = True,
username: str = "system", username: str = "system",
scada_mappings: ScadaElementMappings | None = None,
) -> dict | str | None: ) -> dict | str | None:
""" """
关阀模拟 关阀模拟
@@ -291,6 +292,7 @@ def valve_close_analysis(
result_db_name=name, result_db_name=name,
persist_results=persist_results, persist_results=persist_results,
scheme_username=username, scheme_username=username,
scada_mappings=scada_mappings,
) )
return result if not persist_results else None return result if not persist_results else None
# step 3. restore the base model # step 3. restore the base model
+1 -1
View File
@@ -3,7 +3,7 @@
"contracts": { "contracts": {
"server": { "server": {
"file": "server-v1.openapi.json", "file": "server-v1.openapi.json",
"sha256": "9801087d40ac0e1f67871f9a1db850cbcce47614cceec0a7e8771cdf58385f53" "sha256": "04aeed6f1a7414b37267d84141d036d00d2f8579ee28af37590bd4b92137e712"
} }
} }
} }
+240
View File
@@ -50912,6 +50912,246 @@
] ]
} }
}, },
"/api/v1/timeseries/realtime/playback/frames": {
"get": {
"operationId": "get_timeseries_realtime_playback_frames",
"parameters": [
{
"in": "query",
"name": "start_time",
"required": true,
"schema": {
"title": "Start Time",
"type": "string"
}
},
{
"in": "query",
"name": "model_version",
"required": true,
"schema": {
"maxLength": 24,
"minLength": 24,
"title": "Model Version",
"type": "string"
}
},
{
"in": "query",
"name": "frame_count",
"required": false,
"schema": {
"default": 4,
"maximum": 8,
"minimum": 1,
"title": "Frame Count",
"type": "integer"
}
},
{
"in": "query",
"name": "step_seconds",
"required": false,
"schema": {
"default": 900,
"maximum": 3600,
"minimum": 60,
"title": "Step Seconds",
"type": "integer"
}
},
{
"in": "header",
"name": "X-Project-Id",
"required": true,
"schema": {
"title": "X-Project-Id",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/octet-stream": {
"schema": {
"format": "binary",
"type": "string"
}
}
},
"description": "Versioned little-endian Float32 packet"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Authentication required"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Insufficient permission"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Resource not found"
},
"409": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Resource conflict"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Validation error"
},
"503": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Dependency unavailable"
}
},
"security": [
{
"OAuth2PasswordBearer": []
}
],
"summary": "读取索引一致的 Float32 水力时间块",
"tags": [
"TimescaleDB - Hydraulic Playback"
]
}
},
"/api/v1/timeseries/realtime/playback/geometry": {
"get": {
"operationId": "get_timeseries_realtime_playback_geometry",
"parameters": [
{
"in": "header",
"name": "X-Project-Id",
"required": true,
"schema": {
"title": "X-Project-Id",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/octet-stream": {
"schema": {
"format": "binary",
"type": "string"
}
}
},
"description": "Versioned little-endian Float32 packet"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Authentication required"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Insufficient permission"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Resource not found"
},
"409": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Resource conflict"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Validation error"
},
"503": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Dependency unavailable"
}
},
"security": [
{
"OAuth2PasswordBearer": []
}
],
"summary": "读取带版本的水力专题层静态几何",
"tags": [
"TimescaleDB - Hydraulic Playback"
]
}
},
"/api/v1/timeseries/realtime/records": { "/api/v1/timeseries/realtime/records": {
"get": { "get": {
"description": "查询指定时间点的实时属性值。query_time 必须显式带时区;允许传 UTC+8,服务端会先归一化为 UTC 再执行查询。", "description": "查询指定时间点的实时属性值。query_time 必须显式带时区;允许传 UTC+8,服务端会先归一化为 UTC 再执行查询。",
+14 -7
View File
@@ -2,7 +2,9 @@
工单触发的 `workflow=burst_report` 使用 [爆管事件处理 Skill](../resources/work-order-skills/burst-event-handling/SKILL.md)。Server 按持久化来源选择流程,Agent 接收同一任务的核验上下文;SCADA 异常和历史回放使用 [SCADA Skill](../resources/work-order-skills/scada-anomaly/SKILL.md)。 工单触发的 `workflow=burst_report` 使用 [爆管事件处理 Skill](../resources/work-order-skills/burst-event-handling/SKILL.md)。Server 按持久化来源选择流程,Agent 接收同一任务的核验上下文;SCADA 异常和历史回放使用 [SCADA Skill](../resources/work-order-skills/scada-anomaly/SKILL.md)。
## 固定处理路线 ## 参考处理路线
Agent围绕事件核实、影响评估和处置复核自主组织分析。图示说明常见依赖,可按证据补查、补算、调整方法或并行独立步骤;阶段名称与展示顺序不限制工具选择。
```mermaid ```mermaid
flowchart TD flowchart TD
@@ -12,8 +14,9 @@ flowchart TD
interim --> approval[立即发起工单草稿、人工审核派发] interim --> approval[立即发起工单草稿、人工审核派发]
interim --> burst[爆管模拟与同刻在线压力差] interim --> burst[爆管模拟与同刻在线压力差]
burst --> coverage[保存并展示爆管后、处置前影响覆盖] burst --> coverage[保存并展示爆管后、处置前影响覆盖]
coverage --> isolation[分析隔离边界与阀门方案] interim --> isolation[分析隔离边界与阀门方案]
isolation --> report[补充阶段报告与处置建议] coverage --> report[补充阶段报告与处置建议]
isolation --> report
approval --> feedback[接收实际处置与恢复反馈] approval --> feedback[接收实际处置与恢复反馈]
feedback --> verify[复核影响与结案条件] feedback --> verify[复核影响与结案条件]
verify --> close[Server 完成任务并解除告警] verify --> close[Server 完成任务并解除告警]
@@ -24,7 +27,7 @@ flowchart TD
2. **资产关联**:优先核对上报资产编号。坐标或地址通过地理编码、附近资产、属性和连接关系形成有依据的关联;位置歧义列为具体待确认项。 2. **资产关联**:优先核对上报资产编号。坐标或地址通过地理编码、附近资产、属性和连接关系形成有依据的关联;位置歧义列为具体待确认项。
3. **并行工单与影响估计**:定位到管道后保存 interim/concluded 阶段报告,随即提出现场核查/抢修 general 工单草稿;复用同一 action_id,人工审核、确认派发保持现有权限和版本校验。待审核或现场处理期间继续独立的影响模拟,不等待模型完成才发起工单。 3. **并行工单与影响估计**:定位到管道后保存 interim/concluded 阶段报告,随即提出现场核查/抢修 general 工单草稿;复用同一 action_id,人工审核、确认派发保持现有权限和版本校验。待审核或现场处理期间继续独立的影响模拟,不等待模型完成才发起工单。
4. **隔离方案**:用 analyze_valve_isolation 分析关联资产,保留必关阀、可选阀、直接隔离与供水失源节点;已知失效阀门用于替代方案分析,现场可操作性列为执行条件。 4. **隔离方案**:用 analyze_valve_isolation 分析关联资产,保留必关阀、可选阀、直接隔离与供水失源节点;已知失效阀门用于替代方案分析,现场可操作性列为执行条件。
5. **影响说明**:先保留爆管后、处置前的压力下降覆盖步骤,再展示隔离方案的拓扑范围;结合可用SCADA、水力与用户关联说明影响。 5. **影响说明**:分别保留事件降压与隔离失源范围,按证据就绪顺序展示;结合SCADA、水力与用户关联说明影响。缺口先评估可行补救,仍不可得再说明限制。
6. **报告与处置**:定位阶段已发起现场工单,后续复用原动作;更新报告后按新增需要提出通知、保障或恢复等动作草稿;关联现场任务采用 general 工单。审批、派发和现场完成分别核对回执。 6. **报告与处置**:定位阶段已发起现场工单,后续复用原动作;更新报告后按新增需要提出通知、保障或恢复等动作草稿;关联现场任务采用 general 工单。审批、派发和现场完成分别核对回执。
7. **反馈复核**:已派发的关联工单保持 `waiting_work_order`,全部收到有效 `completed` 反馈后进入 `waiting_scada`。以现场反馈业务时间为 T,取严格晚于 T 的最近一个 SCADA 采样时刻进行复核;正常范围使用事件前15天同采样相位的正常包络。数据未到或仍异常时保持等待,后台工作进程定期复核;通过后持久保存恢复证据并接续 Agent。Agent 最终报告必须引用该证据,运行完成后才能完成任务并解除对应告警。现场资料缺项仅限定未核实细节,不新增本任务的关闭门槛。来源系统另有明确行政结案流程时,按其实际状态另记。 7. **反馈复核**:已派发的关联工单保持 `waiting_work_order`,全部收到有效 `completed` 反馈后进入 `waiting_scada`。以现场反馈业务时间为 T,取严格晚于 T 的最近一个 SCADA 采样时刻进行复核;正常范围使用事件前15天同采样相位的正常包络。数据未到或仍异常时保持等待,后台工作进程定期复核;通过后持久保存恢复证据并接续 Agent。Agent 最终报告必须引用该证据,运行完成后才能完成任务并解除对应告警。现场资料缺项仅限定未核实细节,不新增本任务的关闭门槛。来源系统另有明确行政结案流程时,按其实际状态另记。
@@ -48,12 +51,14 @@ SCADA作为事件影响的辅助证据,按目标测点和事件/对照窗口
## 工具与入口约束 ## 工具与入口约束
工单事件从上报位置开展资产关联。Agent会话策略隐藏 run_burst_location,Server在绑定与后续解析时同步移除其调用能力;普通聊天和SCADA候选定位沿用各自策略。持久计算在执行前重新校验任务绑定。 工单事件通常从上报位置关联资产;监测及模型证据需要时可自主调用run_burst_location补充定位,不按workflow隐藏分析工具。输入、角色、项目权限及任务绑定仍在执行前校验。
历史 burst_report 任务携带 replay_context 时,Server分发scada-anomaly,时序查询保持 knowledge_cutoff 与 batch_sequence,demo能力提示按冻结窗口返回。 历史 burst_report 任务携带 replay_context 时,Server分发scada-anomaly,时序查询保持 knowledge_cutoff 与 batch_sequence,demo能力提示按冻结窗口返回。
业务阶段和工单、返回时间、复核回执保存在任务文档 `handling_context` JSON 中,不增加数据库列。外围样板更新工单时可提供带时区的 `occurred_at`,同时必须填写现场反馈;该时间写入活动记录,系统的 `updated_at` 仍是实际记录时间。同步器将现场反馈时间传给调查,避免历史演示误用电脑当前时间。 业务阶段和工单、返回时间、复核回执保存在任务文档 `handling_context` JSON 中,不增加数据库列。外围样板更新工单时可提供带时区的 `occurred_at`,同时必须填写现场反馈;该时间写入活动记录,系统的 `updated_at` 仍是实际记录时间。同步器将现场反馈时间传给调查,避免历史演示误用电脑当前时间。
恢复复核范围为项目配置的压力、出厂流量监测点与最新调查报告声明的 `scada_device_ids` 的并集;全网浏览查询不自动扩大本事件的复核范围。未声明测点的旧报告保留按已有查询证据补充测点的兼容路径。读取完工时间后最近一帧,并只取该帧时刻对应的事件前 15 天同期参考,不扫描完整 15 天曲线。旧等待任务保存的测点范围会按新选择版本自动重算;相关测点缺测或仍异常时继续等待,恢复通过后才唤醒 Agent。
预约时间 `scheduled_at` 可在派单前的工单草稿和已创建工单的编辑表单中调整。实际完工反馈时间与预约安排分别记录;Agent 以实际反馈时间为恢复复核起点。新的有效现场反馈修订完工时间后,旧复核证据保留为历史记录,当前复核起点更新,重新读取严格晚于新反馈时间的最近一个 SCADA 采样时刻。只修改预约安排或其他工单信息,不改写已有现场反馈时间。 预约时间 `scheduled_at` 可在派单前的工单草稿和已创建工单的编辑表单中调整。实际完工反馈时间与预约安排分别记录;Agent 以实际反馈时间为恢复复核起点。新的有效现场反馈修订完工时间后,旧复核证据保留为历史记录,当前复核起点更新,重新读取严格晚于新反馈时间的最近一个 SCADA 采样时刻。只修改预约安排或其他工单信息,不改写已有现场反馈时间。
`POST /operational-tasks/recovery/reconcile` 由现有 operational worker 每轮调用,按项目及任务创建边界处理等待复核的任务;SCADA 读取失败不阻断其他调查派发。复核直接读取项目时序库中完工反馈时间之后最近时刻的 SCADA,不依赖回放状态或推进回放时间。 `POST /operational-tasks/recovery/reconcile` 由现有 operational worker 每轮调用,按项目及任务创建边界处理等待复核的任务;SCADA 读取失败不阻断其他调查派发。复核直接读取项目时序库中完工反馈时间之后最近时刻的 SCADA,不依赖回放状态或推进回放时间。
@@ -74,11 +79,13 @@ SCADA作为事件影响的辅助证据,按目标测点和事件/对照窗口
在筛选最多20个展示节点之前,以原生模拟全网节点的起算首帧为计算对象,严格读取同一时刻已有在线模拟压力。不同压力单位换算为米水头,下降量为在线压力减爆管压力,互斥划分 `1≤Δ<3 m`、`3≤Δ<5 m`、`Δ≥5 m`。小于1米与缺少可比数据分别计数;错时、缺测、无效值或未知单位不补零、不取邻近值,不从代表测点外推全网。需要 `simulation.run`、`scada.view`,影响比较另需 `simulation.view`。 在筛选最多20个展示节点之前,以原生模拟全网节点的起算首帧为计算对象,严格读取同一时刻已有在线模拟压力。不同压力单位换算为米水头,下降量为在线压力减爆管压力,互斥划分 `1≤Δ<3 m`、`3≤Δ<5 m`、`Δ≥5 m`。小于1米与缺少可比数据分别计数;错时、缺测、无效值或未知单位不补零、不取邻近值,不从代表测点外推全网。需要 `simulation.run`、`scada.view`,影响比较另需 `simulation.view`。
输出 `impact` 保留每个可比节点的在线压力、爆管压力、下降量、分组、缺测清单、比较时刻和读取来源;`coverage` 和 `map_payload` 使用实际GIS节点坐标生成三档空间包络。缺坐标、覆盖节点超过10000或几何超预算时明确报告,不截取前若干节点冒充完整范围。地图采用黄/橙/红,图例保留每档已绘制/分组节点数。 输出 `impact` 保留每个可比节点的在线压力、爆管压力、下降量、分组、缺测清单、比较时刻和读取来源;`coverage` 和 `map_payload` 使用实际GIS节点坐标生成三档空间包络。缺坐标、覆盖节点超过10000或几何超预算时明确报告,不截取前若干节点冒充完整范围。爆管分档包络按高降压档优先裁剪为互斥展示面,保留整体覆盖并集、原始节点分组和计数;低档包络被完全覆盖时保留其节点元数据,不输出空几何。该裁剪只处理空间包络的展示重叠,不把节点包络变为精确水力等值区。通用节点分组及关阀覆盖仍保持原有生成语义。地图三档采用蓝/橙/洋红,避免与关阀覆盖使用的红色混淆,图例保留每档已绘制/分组节点数。
爆管模型使用 PDA 需水方式和原有10模型压力单位的服务压力设定;在线模型方式、当前模型时态及在线压力单位推定来源一并保留。在线记录没有独立版本化单位元数据,不能把此估计称为严格历史同模型反事实或现场停水边界。 爆管模型使用 PDA 需水方式和原有10模型压力单位的服务压力设定;在线模型方式、当前模型时态及在线压力单位推定来源一并保留。在线记录没有独立版本化单位元数据,不能把此估计称为严格历史同模型反事实或现场停水边界。
携带 `replay_context` 的调查只开放启用影响覆盖的爆管情景,起算时刻不得超过 `knowledge_cutoff`。使用当前模型的独立假设情景,初始化及扩展模拟均传入空SCADA边界映射,禁止从实时SCADA回填冻结观测;同刻在线结果仅作为计算时读取的模型参考,标明非冻结基线。未来样本属于预测,不能作为已发生的观测。 回放调查可自主选择在线补算、爆管、关阀、调压、污染传播及冲洗模型计算,起算时刻不超过knowledge_cutoff,使用当前模型和空SCADA边界映射。单独探索情景不强制生成覆盖;目标包含影响范围时应启用覆盖并核对结果。同刻在线结果为可变模型参考,未来样本属于预测,均不冒充冻结观测。
基线缺失时,impact.baseline_recovery提供run_simulation补算建议。Agent可按目标用同刻短窗补算,读取成功结果并核对模型基线,再重试依赖分析;新的工具调用可创建新作业,无需改变破口参数来绕过旧结果。可选择其他有依据的方法,并说明剩余限制。
完成持久计算后,Agent按回执 `data.presentation.command` 用证据编号发布覆盖,并保存三档计数、假设与缺口表。Server从任务所属的不可变证据解析几何,将完整展示命令写入事件。历史播放还原这份命令,前进到隔离范围和后退到事件影响均不重新查询在线模型,也不派单。工单审核、派发与反馈继续由通知和标准工单窗口记录;模型结果补入同一调查,不重复创建工单。 完成持久计算后,Agent按回执 `data.presentation.command` 用证据编号发布覆盖,并保存三档计数、假设与缺口表。Server从任务所属的不可变证据解析几何,将完整展示命令写入事件。历史播放还原这份命令,前进到隔离范围和后退到事件影响均不重新查询在线模型,也不派单。工单审核、派发与反馈继续由通知和标准工单窗口记录;模型结果补入同一调查,不重复创建工单。
+3
View File
@@ -43,6 +43,9 @@ API 保留 `uvicorn --workers 4`。长计算在项目 BizDB 持久化入队,
调查的排队回执不写成分析证据。成功结果读取时,按原计算来源、任务及运行验证,并幂等地保存证据。未完成的计算阻止提交最终报告、`insufficient_data` 或将运行标记成功;中间进度仍可保存。 调查的排队回执不写成分析证据。成功结果读取时,按原计算来源、任务及运行验证,并幂等地保存证据。未完成的计算阻止提交最终报告、`insufficient_data` 或将运行标记成功;中间进度仍可保存。
计算子进程的完整结果文件及 supervisor 读取不设 8MiB 整份体积上限,全网分析结果按原值保存到 `calculation_jobs.result`。
Agent 读取仍使用 `get_calculation_result` 的摘要及 `data_path/limit/offset` 分页预算;完整结果保存与单次工具消息大小分别处理。
Agent 调查执行在原生回合空闲后由宿主短轮询等待,继续维持调查心跳。新作业进入终态即触发模型读取结果,其余作业继续后台运行。状态读取遇到网络、限流或临时服务错误,最多重试 3 次,采用指数退避和随机抖动;权限拒绝、上下文失效立即停止,重试耗尽明确报错,不重新提交或取消计算。普通聊天展示任务编号,可在当前会话后续查询。异常流结束时若无法安全继续处理新结果,会明确失败而不是虚报完成;Server 中的计算结果仍保留。 Agent 调查执行在原生回合空闲后由宿主短轮询等待,继续维持调查心跳。新作业进入终态即触发模型读取结果,其余作业继续后台运行。状态读取遇到网络、限流或临时服务错误,最多重试 3 次,采用指数退避和随机抖动;权限拒绝、上下文失效立即停止,重试耗尽明确报错,不重新提交或取消计算。普通聊天展示任务编号,可在当前会话后续查询。异常流结束时若无法安全继续处理新结果,会明确失败而不是虚报完成;Server 中的计算结果仍保留。
## 排队期限与结果保留 ## 排队期限与结果保留
@@ -57,6 +57,12 @@ Server 不验证依赖执行顺序或推断节点完工。
`progress.graph_update` 包含可选 `title`、`nodes` 和 `edges`。节点沿用 `node_id/label/status/evidence_refs/action_refs/map_targets`, `progress.graph_update` 包含可选 `title`、`nodes` 和 `edges`。节点沿用 `node_id/label/status/evidence_refs/action_refs/map_targets`,
可用 `detail` 记录实际结果或失败原因;连线为 `{source,target,label?}`。仅提交新增或变化的内容, 可用 `detail` 记录实际结果或失败原因;连线为 `{source,target,label?}`。仅提交新增或变化的内容,
`update_investigation_graph` 合并节点、追加明确连线并生成 Mermaid,保留已有路径和引用。 `update_investigation_graph` 合并节点、追加明确连线并生成 Mermaid,保留已有路径和引用。
新建调查图使用 `flowchart TD`,按自上而下的阅读顺序展示。多步骤图必须显式连接流程关系:
独立核查从共同问题分叉并在判断处汇总,条件分支标注条件,失败尝试连接重试或替代路线。
新增节点须通过连线接入已有调查脉络;首次发布多个节点时须形成相连的图。
未接入的新增节点返回 `GRAPH_RELATION_REQUIRED`(422),错误中列出需补关系的节点。
根据真实关系修正后重试,不按节点列表顺序自动补线;关系仅解释调查,不约束执行先后。
单个起点、仅更新已有节点、纯文字更新和已保存的历史图仍可不传连线,不重排或改写历史报告。
“保留路径”约束图的结构,不冻结已有节点的标记。`nodes` 按 `node_id` 合并:新增节点必须提供 `label`; “保留路径”约束图的结构,不冻结已有节点的标记。`nodes` 按 `node_id` 合并:新增节点必须提供 `label`;
修改已保存节点时可只提交 `node_id` 与变化字段,无需再提供 `label`、全部节点或连线。 修改已保存节点时可只提交 `node_id` 与变化字段,无需再提供 `label`、全部节点或连线。
省略字段保留原值,`label: null` 也保留名称;`status: null` 显式恢复为参考步骤。 省略字段保留原值,`label: null` 也保留名称;`status: null` 显式恢复为参考步骤。
+2 -2
View File
@@ -33,7 +33,7 @@ Agent 原生工具统一使用 `mcp_tjwater_` 前缀。Server目录共67项:42
`persisted_baseline=true` 表示在线基线已重算,`persisted_scheme=false` 表示没有创建方案。 `persisted_baseline=true` 表示在线基线已重算,`persisted_scheme=false` 表示没有创建方案。
该工具的 MCP annotation 为 `readOnlyHint=false`、`destructiveHint=true`,因为重算会替换对应时刻的基线。 该工具的 MCP annotation 为 `readOnlyHint=false`、`destructiveHint=true`,因为重算会替换对应时刻的基线。
它不会创建 `analysis.run_id`,不能将本次计算冒充已有爆管事件运行;调查调用仍可保存证据。 它不会创建 `analysis.run_id`,不能将本次计算冒充已有爆管事件运行;调查调用仍可保存证据。
计算读取现有模型和对应SCADA条件,不从摘要虚构观测,也不自动补齐数据或执行现场操作。 普通计算读取现有模型及对应SCADA条件;回放计算使用当前模型和空SCADA映射,不读取实时观测。Agent可自主补算缺失模型基线,不从摘要虚构观测或执行现场操作。
爆管定位 `run_burst_location` 默认使用 `data_source="monitoring"`。`pressure_scada_ids` 可省略,由后端从当前项目SCADA配置自动获取压力测点对应的模型节点;显式指定时仍限制为1至20个不同节点。正常对照时间窗 `scada_normal_start` / `scada_normal_end` 可成对省略,沿用后端默认的前一天同时间段;返回结果中的 `pressure_scada_ids` 和 `scada_window` 说明实际测点和时间窗。默认对照不等于已确认该时段正常。正常窗无有效压力数据或两窗没有共同有效测点时计算失败,不补造数据、不自动换窗。正常窗只有部分测点缺数时,沿用服务逻辑使用两窗共同有效测点,结合返回测点范围解释结果。 爆管定位 `run_burst_location` 默认使用 `data_source="monitoring"`。`pressure_scada_ids` 可省略,由后端从当前项目SCADA配置自动获取压力测点对应的模型节点;显式指定时仍限制为1至20个不同节点。正常对照时间窗 `scada_normal_start` / `scada_normal_end` 可成对省略,沿用后端默认的前一天同时间段;返回结果中的 `pressure_scada_ids` 和 `scada_window` 说明实际测点和时间窗。默认对照不等于已确认该时段正常。正常窗无有效压力数据或两窗没有共同有效测点时计算失败,不补造数据、不自动换窗。正常窗只有部分测点缺数时,沿用服务逻辑使用两窗共同有效测点,结合返回测点范围解释结果。
@@ -81,7 +81,7 @@ HTTP 预算由宿主从目录元数据读取,Server 默认 120 秒、Agent 默
`analyze_valve_isolation` 和 `calculate_demand_allocation` 保持同步。队列消费者独立于Uvicorn的4个API进程,默认全部配置项目合计并发2项,并通过元数据库单实例锁防止重复启动。取消、超时和租约失效需要停止计算子进程组;成功结果按原运行授权保存证据,未完成计算阻止调查提前结案。 `analyze_valve_isolation` 和 `calculate_demand_allocation` 保持同步。队列消费者独立于Uvicorn的4个API进程,默认全部配置项目合计并发2项,并通过元数据库单实例锁防止重复启动。取消、超时和租约失效需要停止计算子进程组;成功结果按原运行授权保存证据,未完成计算阻止调查提前结案。
在线重算逐个时刻写入基线,超时或中途失败可能已有部分时刻更新,不能把工具失败解释为整个窗口已回滚;不要自动重试整个窗口。 在线重算逐个时刻写入基线,超时或中途失败可能已有部分时刻更新。先核对实际作业、采样与基线,再自主补算必要范围,不能假定整个窗口已回滚而盲目重提。
队列接入需要在目标项目BizDB依次应用 `resources/sql/026_calculation_jobs.sql`、`resources/sql/027_calculation_job_lifecycle.sql` 并启动独立计算Worker。Server与Agent需配套发布,client模式还需重载原生连接。既有OAuth与项目权限保持不变;完整部署与回滚步骤见后台计算队列文档。 队列接入需要在目标项目BizDB依次应用 `resources/sql/026_calculation_jobs.sql`、`resources/sql/027_calculation_job_lifecycle.sql` 并启动独立计算Worker。Server与Agent需配套发布,client模式还需重载原生连接。既有OAuth与项目权限保持不变;完整部署与回滚步骤见后台计算队列文档。
+1 -1
View File
@@ -16,7 +16,7 @@ Agent 根据401 challenge 或路径/根 well-known 发现受保护资源元数
普通查询默认20条;目录类最多500条,显式指定设备的时序查询最多10000条、20个标识、24小时;offset 最大10000。SCADA调查首次省略 `query_scada_readings.device_ids`,由Server读取项目完整设备目录及窗口内全部授权观测,内部自动翻页,`offset` 必须为0。其余查询按问题选定对象和窗口。SQL 有 limit/offset 和 statement_timeout。历史对比在同一条SQL中按序列、时间对数据点分页,返回 page.has_more;非零offset也标明覆盖不完整。历史曲线展示调用初始每页500点,`RESULT_TOO_LARGE` 时原位减半重试,最多读取1000条、发出16次请求,共用120秒超时(单次请求同时受该剩余总预算约束),图表最多100个有效点;这不改变模型首次查询默认值或时序接口上限。 普通查询默认20条;目录类最多500条,显式指定设备的时序查询最多10000条、20个标识、24小时;offset 最大10000。SCADA调查首次省略 `query_scada_readings.device_ids`,由Server读取项目完整设备目录及窗口内全部授权观测,内部自动翻页,`offset` 必须为0。其余查询按问题选定对象和窗口。SQL 有 limit/offset 和 statement_timeout。历史对比在同一条SQL中按序列、时间对数据点分页,返回 page.has_more;非零offset也标明覆盖不完整。历史曲线展示调用初始每页500点,`RESULT_TOO_LARGE` 时原位减半重试,最多读取1000条、发出16次请求,共用120秒超时(单次请求同时受该剩余总预算约束),图表最多100个有效点;这不改变模型首次查询默认值或时序接口上限。
单份结果超过8MiB报错;超过12KB的调查工具消息默认返回证据资源引用,设备目录的精简路径见下段。完整快照仍保存,Server 的 `resources/read` 可以读取它。Agent 本地 MCP 只提供工具,将上游资源引用转为 `evidence_read` 中的 `mcp_tjwater_get_task_evidence` 参数,不向 OpenCode 泄露不可在本地读取的链接。`get_task_evidence` 在数据库中按 data_path、limit、offset 选页,嵌套集合和超过256字符的字符串返回路径描述;字符串按 Unicode 字符分页。页和完整资源均在数据库返回前检查8MiB上限,保留原证据编号和合成来源;普通查询要求缩小范围。其他结果仍保留执行后的整体8MiB检查,不能把它理解为所有查询均有数据库端字节限制。原始/清洗实测、模型基线、分析结果、比较和 synthetic 均明确标记。非有限数值转为 null 并记录数据缺口。 同步查询单份结果超过8MiB报错;后台计算作业的完整结果保存不设此上限,读取时仍执行工具消息摘要和分页预算;超过12KB的调查工具消息默认返回证据资源引用,设备目录的精简路径见下段。完整快照仍保存,Server 的 `resources/read` 可以读取它。Agent 本地 MCP 只提供工具,将上游资源引用转为 `evidence_read` 中的 `mcp_tjwater_get_task_evidence` 参数,不向 OpenCode 泄露不可在本地读取的链接。`get_task_evidence` 在数据库中按 data_path、limit、offset 选页,嵌套集合和超过256字符的字符串返回路径描述;字符串按 Unicode 字符分页。页和完整资源均在数据库返回前检查8MiB上限,保留原证据编号和合成来源;普通查询要求缩小范围。其他结果仍保留执行后的整体8MiB检查,不能把它理解为所有查询均有数据库端字节限制。原始/清洗实测、模型基线、分析结果、比较和 synthetic 均明确标记。非有限数值转为 null 并记录数据缺口。
`list_scada_devices` 默认每页100个、最多500个,其他集合仍沿用各自的分页默认值。设备目录超过12,000字节时,先内联本页全部设备的 `device_id`、`device_type` 和 `measurement_unit`,省略其他属性并明确标注;模型映射、坐标等通过 `get_scada_device` 核验,任务证据和幂等回执保留完整属性。此时 `truncated` 表示属性省略,设备是否还有后续页以 `page.has_more` 为准,按本页实际设备数递增 `offset`。精简目录仍超预算时沿用证据引用或缩页路径。 `list_scada_devices` 默认每页100个、最多500个,其他集合仍沿用各自的分页默认值。设备目录超过12,000字节时,先内联本页全部设备的 `device_id`、`device_type` 和 `measurement_unit`,省略其他属性并明确标注;模型映射、坐标等通过 `get_scada_device` 核验,任务证据和幂等回执保留完整属性。此时 `truncated` 表示属性省略,设备是否还有后续页以 `page.has_more` 为准,按本页实际设备数递增 `offset`。精简目录仍超预算时沿用证据引用或缩页路径。
+4 -4
View File
@@ -24,7 +24,7 @@ flowchart TD
decision -->|是| final[分区恢复建议与最终报告] decision -->|是| final[分区恢复建议与最终报告]
``` ```
图中连线表示业务与展示层次,先期保护、工单审核和独立计算可以并行。阀门操作均为方案,现场动作继续经过原审批与派发;阶段记录不能替代执行回执。首次展示保留处置前传播结果或明确的数据缺口,再展示隔离及清除方案。 图示为参考路径,Agent可按风险、证据和实际依赖调整方法、顺序及并行分析。各类结果按就绪顺序展示并分别标明性质,现场动作仍须审批与派发,阶段记录不能替代执行回执。
## 侦测入口 ## 侦测入口
@@ -46,13 +46,13 @@ flowchart TD
阈值仅为模型展示条件,默认0表示模型正浓度,不是水质安全限值。沿用当前模型初始水质、已有源和反应条件;统计的是总模型浓度,不能称为本次源的独立贡献。源浓度、模式、业务起算时刻、预测时长及其假设必须有依据,不能把余氯下降或浊度换算成污染源浓度。 阈值仅为模型展示条件,默认0表示模型正浓度,不是水质安全限值。沿用当前模型初始水质、已有源和反应条件;统计的是总模型浓度,不能称为本次源的独立贡献。源浓度、模式、业务起算时刻、预测时长及其假设必须有依据,不能把余氯下降或浊度换算成污染源浓度。
回放同样允许该工具,要求启用影响覆盖且起算不晚于知识截止;预测时段可以越过截止,但后续时刻只是模型预测。与爆管情景一样,使用当前模型和空 SCADA 映射传入临时模型计算,不读取可变实时监测,不重建历史模型。仍禁止借此放开未授权观测或其他水力工具。 回放允许自主选择传播及其他已开放模型计算,起算不晚于知识截止;预测时段可以越过截止,未来样本仅为模型预测。使用当前模型及空SCADA映射,不读取实时监测或重建历史模型。探索情景可不生成覆盖,影响范围目标应启用覆盖并核对结果。
工具沿用计算队列和 owner/run 校验,返回终态后通过 `get_calculation_result` 读取并持久保存证据。`presentation_preview` 提供基于 `evidence_id` 的地图请求;用正式调查展示事件保存模型曲线和覆盖,历史播放还原已保存证据。审批、派发可在计算排队或运行期间进行;存在未终态计算时继续保留原运行,不允许发布 final 或成功结束调查,已有工单不受模拟失败影响。 工具沿用计算队列和owner/run校验,终态后通过get_calculation_result读取并保存证据。data.presentation.command提供基于evidence_id的地图请求;正式事件保存曲线与覆盖,历史播放复用。审批、派发可与计算并行;未终态计算保留原运行,已有工单不受模拟失败影响。
## 展示、反馈与收尾 ## 展示、反馈与收尾
`water-pollution-event` 1.2.0 将观测核验、候选定位、处置前传播、源头控制、用户隔离、清除冲洗、现场反馈、水质复测和恢复复核分开。每次正式展示保存 `purpose`;有证据的阶段通过 `narration.stage` 记录,新增 `source_control`、`flushing_plan`、`clearance_verification`。历史保存格式继续使用原事件 metadata JSON,不增加数据库列。前端显示阶段和本步目的,逐步回看保留独立画面;同阶段兼容图表仍可合并。 `water-pollution-event` 1.2.1 将观测核验、候选定位、处置前传播、源头控制、用户隔离、清除冲洗、现场反馈、水质复测和恢复复核分开,作为可调整的参考路径。每次正式展示保存 `purpose`;有证据的阶段通过 `narration.stage` 记录,沿用 `source_control`、`flushing_plan`、`clearance_verification`。历史保存格式继续使用原事件 metadata JSON,不增加数据库列。前端显示阶段和本步目的,逐步回看保留独立画面;同阶段兼容图表仍可合并。
源头控制与保护下游用户分别决策。用已核实的连接和流向判断水力下游,不能按地理方位判断;源头未核实不得声称污染输入已停止。清除方法按污染物及现场条件选择,不统一推荐煮沸、加药或排水。通知保留,工单按执行职责和阶段复用或必要拆分,不能同时要求同一阀门开和关。 源头控制与保护下游用户分别决策。用已核实的连接和流向判断水力下游,不能按地理方位判断;源头未核实不得声称污染输入已停止。清除方法按污染物及现场条件选择,不统一推荐煮沸、加药或排水。通知保留,工单按执行职责和阶段复用或必要拆分,不能同时要求同一阀门开和关。
@@ -2,7 +2,7 @@
name: appointment-follow-up name: appointment-follow-up
description: 核查关联预约工单的审批、提交、现场执行和反馈,分别判断预约履约与原事件闭环。 description: 核查关联预约工单的审批、提交、现场执行和反馈,分别判断预约履约与原事件闭环。
metadata: metadata:
version: "2.0.4" version: "2.0.5"
--- ---
# 预约任务跟进 # 预约任务跟进
@@ -13,7 +13,7 @@ metadata:
读取当前任务、关联工单、已有报告、证据、动作和最新反馈,核对预约时间、地点、负责人、工作内容及回执要求;需要时追溯关联的投诉或爆管。新一轮优先检查新增事实和旧未决项,保留本流程名称和版本。 读取当前任务、关联工单、已有报告、证据、动作和最新反馈,核对预约时间、地点、负责人、工作内容及回执要求;需要时追溯关联的投诉或爆管。新一轮优先检查新增事实和旧未决项,保留本流程名称和版本。
围绕原工作目标完成必要核查: 以下为参考核查路径,围绕原工作目标自主选择方法、调整顺序并补齐可查询或推导的资料。反馈缺项先核对已有来源和证据,不因首次缺数直接停止;新增现场事实仍以实际回执为准:
1. 将要求与来源工单最新版本逐项对照,区分预约、派发、到场、完成和原事件发生时间,不用创建或更新时间替代现场时间,也不因预约已过推定已经执行或失约。 1. 将要求与来源工单最新版本逐项对照,区分预约、派发、到场、完成和原事件发生时间,不用创建或更新时间替代现场时间,也不因预约已过推定已经执行或失约。
2. 核对动作的审批、提交、执行状态和回执。`approved` 只表示允许提交,`submitted` 只表示已接收,`succeeded` 的建单动作只证明工单创建成功;这些状态均不证明采样、通知、抢修、冲洗或复测完成。`unknown` 由 Server 复核原执行请求,不重复下发。 2. 核对动作的审批、提交、执行状态和回执。`approved` 只表示允许提交,`submitted` 只表示已接收,`succeeded` 的建单动作只证明工单创建成功;这些状态均不证明采样、通知、抢修、冲洗或复测完成。`unknown` 由 Server 复核原执行请求,不重复下发。
@@ -2,12 +2,12 @@
name: burst-event-handling name: burst-event-handling
description: 根据爆管上报工单处理事件,完成资产关联、隔离方案、影响说明、处置建议与反馈复核。 description: 根据爆管上报工单处理事件,完成资产关联、隔离方案、影响说明、处置建议与反馈复核。
metadata: metadata:
version: "1.1.5" version: "1.1.6"
--- ---
# 爆管事件处理 # 爆管事件处理
适用于工单触发的 `workflow=burst_report`。以工单上报的事件位置和资产线索开展处置,依次完成下列步骤;每一步给出实际结果或具体待补项,已有证据可直接复用。SCADA 异常发现使用独立的 scada-anomaly 流程。 适用于工单触发的 `workflow=burst_report`。目标是核实事件、评估影响并提出处置与复核依据。以下为参考流程,按证据和依赖自主选择、调整或并行步骤,复用已有成果,补查或补算可取得的前置资料。SCADA 异常发现使用独立的 scada-anomaly 流程。
先按handling_context接续:等待阶段按第7节保存本轮阶段报告;verification_ready复用已有异常分析与资产、隔离证据,仅补反馈和恢复复核,不重跑初次调查步骤。真正新增的独立事实另行核查。 先按handling_context接续:等待阶段按第7节保存本轮阶段报告;verification_ready复用已有异常分析与资产、隔离证据,仅补反馈和恢复复核,不重跑初次调查步骤。真正新增的独立事实另行核查。
@@ -23,6 +23,8 @@ metadata:
爆管候选管段用 locate_features 传 event_kind="burst" 定位。在地图展示上报位置和关联资产,标明关联依据及现场确认状态。存在多个合理对象时保留候选及选择理由;关键位置需要人工补充时,先完成独立工作,再用 ask_task_user 提问。 爆管候选管段用 locate_features 传 event_kind="burst" 定位。在地图展示上报位置和关联资产,标明关联依据及现场确认状态。存在多个合理对象时保留候选及选择理由;关键位置需要人工补充时,先完成独立工作,再用 ask_task_user 提问。
上报位置与资产不足以区分候选时,可按现有监测和模型证据自主选择 run_burst_location 等补充分析;准备工具要求的输入,保留假设,不因工单来源限制分析方法。
定位到管道后立即保存 interim/concluded 阶段报告,明确“候选管段待现场核验”,调用 propose_task_action 提出 kind=create_work_order、work_order_type=general 的现场核查/抢修草稿,引用定位证据、地点和管段;沿用已有 action_id,不重复建单。人工审核、派发按 Server 权限与版本完成。审批或现场处理等待期间,继续独立的爆管影响模拟和隔离分析,不等模拟完成才发起工单;模拟结果补入同一调查与后续报告,不能把模型计算成功写成已派单。 定位到管道后立即保存 interim/concluded 阶段报告,明确“候选管段待现场核验”,调用 propose_task_action 提出 kind=create_work_order、work_order_type=general 的现场核查/抢修草稿,引用定位证据、地点和管段;沿用已有 action_id,不重复建单。人工审核、派发按 Server 权限与版本完成。审批或现场处理等待期间,继续独立的爆管影响模拟和隔离分析,不等模拟完成才发起工单;模拟结果补入同一调查与后续报告,不能把模型计算成功写成已派单。
## 3. 爆管后、处置前的影响范围 ## 3. 爆管后、处置前的影响范围
@@ -31,7 +33,7 @@ metadata:
工具在筛选展示节点前比较全网节点的起算同刻在线模拟压力,换算米水头,三档互斥:1≤下降<3、3≤下降<5、下降≥5。只用同刻有效值,不以代表节点、邻近时刻或缺失值代替;保留缺测、单位来源、模型时态、比较节点数及覆盖缺口。范围表示爆管后、关阀或抢修前的模型估计,单独于隔离失源范围。 工具在筛选展示节点前比较全网节点的起算同刻在线模拟压力,换算米水头,三档互斥:1≤下降<3、3≤下降<5、下降≥5。只用同刻有效值,不以代表节点、邻近时刻或缺失值代替;保留缺测、单位来源、模型时态、比较节点数及覆盖缺口。范围表示爆管后、关阀或抢修前的模型估计,单独于隔离失源范围。
读取计算结果后按 data.presentation.command 的 evidence_id 用 publish_task_presentation 发布“爆管后、处置前的预计影响范围”,并发布三档节点计数、比较时刻和假设表;无地图几何时说明原因。证据、地图及表格进入正式任务事件,历史播放还原已保存结果,不重新查询在线基线。本步 narration.stage 用 event_impact。三级结果 unavailable 时单列原因和待补项;先保留此步骤,再展示隔离方案,不得用关阀覆盖替代。 读取结果后按 data.presentation.command 发布预计降压范围和分档计数、比较时刻及假设,narration.stage 用 event_impact。基线缺失时可自主用 run_simulation 补算同刻数据;等待成功并读取结果后重试依赖计算,不靠扩大破口面积修复基线缺失。其他失败按原因选择补查、修正参数或替代方法,补救后仍不可用才列具体限制。事件降压与关阀范围分别保存,展示按结果就绪顺序发布;历史播放复用保存证据。
## 4. 隔离方案 ## 4. 隔离方案
@@ -2,14 +2,14 @@
name: burst-report name: burst-report
description: 爆管业务流程的历史入口索引,运行时按事件来源选择独立 Skill。 description: 爆管业务流程的历史入口索引,运行时按事件来源选择独立 Skill。
metadata: metadata:
version: "2.0.11" version: "2.0.12"
--- ---
# 爆管业务流程入口 # 爆管业务流程入口
Server 根据绑定任务的 workflow 和 replay_context 分发当前流程: Server 根据绑定任务的 workflow 和 replay_context 分发当前流程:
- 工单触发的 burst_report:使用 burst-event-handling,依次完成接收核对、地点与资产关联、隔离方案、影响说明、处置建议和反馈复核。 - 工单触发的 burst_report:使用 burst-event-handling,围绕事件核实、资产关联、影响评估、处置建议和反馈复核自主组织分析。
- SCADA 异常及带 replay_context 的历史回放任务:使用 scada-anomaly,完成冻结数据核验、诊断、候选定位与隔离影响分析。 - SCADA 异常及带 replay_context 的历史回放任务:使用 scada-anomaly,完成冻结数据核验、诊断、候选定位与隔离影响分析。
通过 get_task_context 的 section="skill" 读取实际流程,保留返回的 name、version、content_sha256。工具范围由绑定任务的服务端策略及 Agent 会话策略共同确定。 通过 get_task_context 的 section="skill" 读取实际流程,保留返回的 name、version、content_sha256。工具范围由绑定任务的服务端策略及 Agent 会话策略共同确定。
@@ -2,12 +2,12 @@
name: scada-anomaly name: scada-anomaly
description: 从 SCADA 指标异常开展数据核验、爆管诊断、候选定位与影响分析。 description: 从 SCADA 指标异常开展数据核验、爆管诊断、候选定位与影响分析。
metadata: metadata:
version: "1.0.20" version: "1.0.21"
--- ---
# SCADA 数据异常调查 # SCADA 数据异常调查
读取任务与 replay_context,核对设备、单位、异常时刻、knowledge_cutoff 和 batch_sequence。使用本轮已提交的 SCADA 观测开展调查,同一持续事件沿用已有调查。证据来源字段用于记录数据取得方式,不构成停止分析的条件。 目标是核验异常、判断原因和影响并形成处置依据。下文为参考流程,可按证据和依赖自主调整方法与顺序,补查、补算前置资料。核对任务、设备、单位、异常时刻、knowledge_cutoff及batch_sequence,使用本轮已提交观测,同一持续事件沿用原调查。证据来源字段用于记录数据取得方式,不构成停止分析的条件。
先按handling_context接续:待审批/工单/监测且无独立工作时,保存本轮interim/concluded及action_id后退出,不轮询。verification_ready复用原异常证据,只补恢复复核;初次分析按下文。 先按handling_context接续:待审批/工单/监测且无独立工作时,保存本轮interim/concluded及action_id后退出,不轮询。verification_ready复用原异常证据,只补恢复复核;初次分析按下文。
@@ -31,9 +31,9 @@ metadata:
取得候选即展示管道,发 interim/concluded 报告及现场工单草稿;复用action_id,待审批继续模拟。 取得候选即展示管道,发 interim/concluded 报告及现场工单草稿;复用action_id,待审批继续模拟。
run_burst_simulation设include_impact_coverage=true,用定位管道、异常时刻及有依据的破口面积cm²。全网同刻在线压力差按米水头分档1≤下降<3、3≤下降<5、下降≥5。按data.presentation发布地图及计数,narration.stage=event_impact;不可用时单列原因,禁用关阀范围替代。回放仅用当前模型、禁实时SCADA;在线参考非冻结观测,历史展示复用保存证据。 run_burst_simulation设include_impact_coverage=true,用候选管道、异常时刻及有依据的破口面积cm²,按米水头分档1≤下降<3、3≤下降<5、下降≥5。基线缺失可自主用run_simulation补算同刻数据,读取成功结果后重试。其他缺口先按原因补查、补算或修正参数,仍不可用才记录限制。按data.presentation发布地图及计数,stage=event_impact;禁用关阀范围替代。回放模型计算不读实时SCADA,非冻结观测;历史展示复用保存证据。
再按授权用 analyze_valve_isolation 分析隔离边界,保留必关阀、可选阀、affected_nodes、supply_affected_nodes 及计数;用 generate_node_coverage 展示关阀覆盖,stage=isolation_plan。候选与拓扑建议标注为待现场核验方案。爆管候选用locate_features传event_kind=burst。报告前核对定位结果,失败列明错误与缺口。 按需用analyze_valve_isolation分析隔离边界,保留必关阀、可选阀、affected_nodes、supply_affected_nodes及计数;generate_node_coverage展示关阀覆盖,stage=isolation_plan;analyze_valve_closure可补水力影响。候选与建议待现场核验,展示按就绪顺序。候选用locate_features传event_kind=burst。
## 对比展示与影响 ## 对比展示与影响
@@ -43,22 +43,22 @@ run_burst_simulation设include_impact_coverage=true,用定位管道、异常
定位输入具备后继续定位。压力、流量按设备另发七天同期复合图,不占用或替代全量当前总览。chart_type=time_comparison:x_data是UTC+8的HH:mm或HH:mm:ss共享槽,不拼接历史日期;当前[t-3小时,t]用series.type=line,前7天每天t-30分钟、t-15分钟、t用scatter。15分钟数据13槽,历史七组仅末3槽有值。每组sample_times与data等长,保留真实带时区日期;有效值与对应槽的本地时刻一致,缺测值和时间均null。当前曲线共用窗口,历史每组取此前某天对应窗口,跨午夜顺序排列;普通图用line/bar,坐标轴标单位。七天按t的本地日期回退,不含当天。按实际周期保留缺口,按需翻页并报告参考点数;基线未冻结、读取失败或缺测时说明并保留当前趋势,不退化为前一天或相邻两点。 定位输入具备后继续定位。压力、流量按设备另发七天同期复合图,不占用或替代全量当前总览。chart_type=time_comparison:x_data是UTC+8的HH:mm或HH:mm:ss共享槽,不拼接历史日期;当前[t-3小时,t]用series.type=line,前7天每天t-30分钟、t-15分钟、t用scatter。15分钟数据13槽,历史七组仅末3槽有值。每组sample_times与data等长,保留真实带时区日期;有效值与对应槽的本地时刻一致,缺测值和时间均null。当前曲线共用窗口,历史每组取此前某天对应窗口,跨午夜顺序排列;普通图用line/bar,坐标轴标单位。七天按t的本地日期回退,不含当天。按实际周期保留缺口,按需翻页并报告参考点数;基线未冻结、读取失败或缺测时说明并保留当前趋势,不退化为前一天或相邻两点。
影响说明列出异常前参考、异常期间和已有恢复观测中的压力、流量或水质变化、持续时间、涉及测点及已核实资产。可比对象、单位、时段与运行条件下给出变化量,分别标明实际观测、模型预测和拓扑隔离。停水范围与漏量引用相应拓扑、水力、流量平衡或现场依据;恢复观测、用户关联或现场证据缺失时列为具体待核实项。 影响说明比较异常前、异常期间及已有恢复观测,列压力、流量或水质变化、持续时间、测点与资产。同对象、单位、时段及条件计算变化量,区分观测、模型和拓扑结果;停水及漏量保留依据,缺恢复、用户或现场资料时明确限制。
## 范围与报告 ## 范围与报告
本轮时序分析保持任务冻结窗口,后续播放形成的观测由相应轮次处理。资产属性、空间查询和拓扑隔离使用项目当前模型,注明模型时态;水力工具按已开放的冻结输入能力调用。定位调用执行权限、设备与冻结观测校验;基线或覆盖缺口按实际返回记录。 观测保持本轮冻结窗口,后续观测由相应轮次处理。资产、空间与拓扑用当前模型;水力计算标明模型来源,定位保留权限、设备及冻结观测校验。
报告先写判断与关键依据,摘要建议 150 字、最多 300 字;多测点数值入 Table,保留单位、方向与缺测。区分各阶段测点数,不相加不同影响集合。现场事项按回执列待办。现场工单与设备操作遵守服务端动作权限,恢复复核通过后闭环。 报告先写判断与依据,摘要建议150字、最多300字;多测点数值入Table,保留单位、方向和缺测,不相加不同影响集合。现场事项按回执列待办。现场工单与设备操作遵守服务端动作权限,恢复复核通过后闭环。
现场工单用propose_task_action(kind=create_work_order、work_order_type=general),注明对象、条件、回执及回放来源;有权限者确认派发,通知保留,报告完成不等于现场完成;回放禁设备命令。 现场工单用propose_task_action(kind=create_work_order、work_order_type=general),注明对象、条件、回执及回放来源;有权限者确认派发,通知保留,报告完成不等于现场完成;回放禁设备命令。
## 完工后的恢复复核 ## 完工后的恢复复核
关联工单completed后,以handling_context.returned_at为实际完工业务时间,不用创建、预约或电脑当前时间代替。Server读取严格晚于该时刻的最近一帧及事件前15天同相位对照,独立保存恢复证据。回放来源工单同样适用,不推进回放,原knowledge_cutoff与batch_sequence不变。 工单completed后,以handling_context.returned_at为实际完工时间;Server取严格晚于该时刻的最近一帧及事件前15天同相位对照,保存恢复证据。回放同样适用,不推进回放或修改原冻结边界。
waiting_work_order等待完工;waiting_scada按last_check说明尚无后续采样、数据缺项或仍异常,等待监测复核。verification_ready时用get_task_evidence读取verification_evidence_id完整快照;工单completed且恢复证据status=passed时,发布final/concluded恢复闭环报告,同时引用原异常全量证据与当前恢复证据。Server完成运行后关闭任务并解除关联告警,phase=completed表示已闭环。阀位、漏点照片、现场复测数值等未提供时仅记为资料缺项,不新增现场核验、人工确认或更多采样的闭环条件。 waiting_work_order等待完工;waiting_scada按last_check说明尚无后续采样、数据缺项或仍异常,等待监测复核。verification_ready时用get_task_evidence读取verification_evidence_id完整快照;工单completed且恢复证据status=passed时,发布final/concluded恢复闭环报告,同时引用原异常全量证据与当前恢复证据。Server完成运行后关闭任务并解除关联告警,phase=completed表示已闭环。阀位、漏点照片、现场复测数值等未提供时仅记为资料缺项,不新增现场核验、人工确认或更多采样的闭环条件。
恢复报告先写现场反馈与完工时间、采样时刻和结果,表格列全部复核测点及基线;边界说明单帧与模型限制,日常监测列建议。不重列旧定位参数或执行字段;更新原图中已解除的等待标记,保留路线与失败记录。 恢复报告列反馈、完工与采样时间、全部复核测点及基线,说明单帧与模型边界,日常监测列建议。更新已解除的等待标记,保留路线与失败记录。
恢复观测由Server独立保存,原冻结上限不阻断此证据的使用;query_scada_readings仍遵守冻结窗口。完工时间修订后使用新证据编号,旧证据仅作历史。确有独立工作时再提议新动作。 恢复观测由Server独立保存,原冻结上限不阻断此证据的使用;query_scada_readings仍遵守冻结窗口。完工时间修订后使用新证据编号,旧证据仅作历史。确有独立工作时再提议新动作。
@@ -2,13 +2,15 @@
name: water-pollution-event name: water-pollution-event
description: 水污染侦测后的观测核验、候选溯源、源头控制、用户隔离、清除冲洗与水质复测。 description: 水污染侦测后的观测核验、候选溯源、源头控制、用户隔离、清除冲洗与水质复测。
metadata: metadata:
version: "1.2.0" version: "1.2.1"
--- ---
# 水污染侦测事件调查 # 水污染侦测事件调查
适用于 workflow=water_pollution,与 water_quality_complaint 投诉分开,不依赖爆管评分、漏量或压力流量异常。侦测信号、候选污染源、污染确诊和恢复供水分别说明。 适用于 workflow=water_pollution,与 water_quality_complaint 投诉分开,不依赖爆管评分、漏量或压力流量异常。侦测信号、候选污染源、污染确诊和恢复供水分别说明。
目标是核验风险、来源、影响及处置恢复依据。以下为参考流程,可自主调整方法、顺序,补查参数并比较明确假设;缺口先补救,保护措施及时提出。
读取任务、信号、时间、handling_context 和已有成果。回放遵守 knowledge_cutoff、batch_sequence;阅读时间 Asia/Shanghai,查询与 sample_times 带时区。后续轮次复用成果,优先新增事实。 读取任务、信号、时间、handling_context 和已有成果。回放遵守 knowledge_cutoff、batch_sequence;阅读时间 Asia/Shanghai,查询与 sample_times 带时区。后续轮次复用成果,优先新增事实。
## 1. 核验观测与先期保护 ## 1. 核验观测与先期保护
@@ -25,15 +27,15 @@ query_scada_readings 省略 device_ids,读取异常前三小时全量 SCADA,
## 3. 处置前污染影响 ## 3. 处置前污染影响
run_contaminant_simulation 设 source=located_node、include_impact_coverage=true,采用事件起算时刻、明确预测时长和需画曲线的 output_node_ids。concentration 为 mg/L,须有来源或说明情景假设,不能把浊度、余氯下降或感官描述直接换算成污染源浓度。缺依据时列具体参数缺口,继续工单和独立分析。 run_contaminant_simulation 设 source=located_node、include_impact_coverage=true,采用事件起算时刻、明确预测时长和需画曲线的 output_node_ids。concentration 为 mg/L,须有来源或说明情景假设,不能把浊度、余氯下降或感官描述直接换算成污染源浓度。可自主补查来源、比较明确假设的浓度或时长;不可推断的现场事实才列具体缺口,继续工单和独立分析。
impact_threshold_mg_l 默认0、严格大于,仅供展示,非饮用水安全限值。全网整个时段汇总累计节点、首个超阈值采样时刻、峰值与覆盖;沿用初始水质和反应条件,非源独立贡献,范围外不等于安全。 impact_threshold_mg_l 默认0、严格大于,仅供展示,非饮用水安全限值。全网整个时段汇总累计节点、首个超阈值采样时刻、峰值与覆盖;沿用初始水质和反应条件,非源独立贡献,范围外不等于安全。
queued/running 时继续独立工作,get_calculation_job 跟踪、get_calculation_result 读终态。未完计算不能 final,由宿主等待接续;待审批不阻止分析,模拟失败不撤回工单。 queued/running 时继续独立工作,get_calculation_job 跟踪、get_calculation_result 读终态。未完计算不能 final,由宿主等待接续;待审批不阻止分析,模拟失败不撤回工单。
按 presentation_preview.evidence_id 发布“水污染传播预计影响范围(处置前情景)”、参数与计数表及浓度曲线;模型曲线不填观测专用 chart.scada。保存正式事件供历史复用;空范围、缺测、单位及几何限制单列。先保留污染传播步骤,再展示隔离范围,不以隔离覆盖替代传播范围。 按返回的data.presentation.command发布“水污染传播预计影响范围(处置前情景)”、参数与计数表及浓度曲线;模型曲线不填观测专用chart.scada。空范围、缺测、单位及几何问题先核对可行补救,再列剩余限制;传播与隔离分别保存,不以隔离覆盖替代传播范围。
回放允许传播及冲洗情景,起算不晚于知识截止,使用当前模型且不读取实时 SCADA;未来样本仅预测,非历史重建。 回放允许在线基线补算及传播、关阀、调压、冲洗情景,起算不晚于知识截止,使用当前模型且不读取实时SCADA;未来样本仅预测,非历史重建。
## 4. 源头控制与用户隔离 ## 4. 源头控制与用户隔离
@@ -47,7 +49,7 @@ analyze_valve_isolation 设 accident_element=[located_node]、accident_element_t
先说明污染物性质、处理依据和未知项,再提出排空、冲洗、消毒或其他适用处理;不得默认任何污染都能冲洗清除。排放去向及污染水收集处理须有依据,不默认排入雨水、河道或普通排水口。 先说明污染物性质、处理依据和未知项,再提出排空、冲洗、消毒或其他适用处理;不得默认任何污染都能冲洗清除。排放去向及污染水收集处理须有依据,不默认排入雨水、河道或普通排水口。
核对清洁水源、排放点、流量及许可条件后用 analyze_flushing。每阶段保存目的、前置条件、清洁进水依据、排放去向、完整开关阀清单、顺序、流量、时长、预计水量、监测和待办。隔离和冲洗不能共用固定关阀清单,进水阀打开不等于用户复供;缺参数仅停依赖分支。 核对清洁水源、排放点、流量及许可条件后用analyze_flushing。阶段记录目的、条件、进排水依据、完整阀态、顺序、流量、时长、水量、监测及待办。隔离和冲洗按各自目标选阀,进水阀打开不等于复供;缺参数先补查。
opened_valves 明确开阀、closed_valves 指定关闭阀门,两者互斥。每次是独立当前模型情景,未列阀门沿用模型状态,不继承上次模拟;重列本阶段必要阀态,不能称为连续污染清除。flushing_plan 保存方案及估计排放量,仅证明水力结果,不验证清洁进水、排放合规或现场执行。尚未耦合停止输入后的水质衰减,压力、流量或模型零浓度不能替代复测。 opened_valves 明确开阀、closed_valves 指定关闭阀门,两者互斥。每次是独立当前模型情景,未列阀门沿用模型状态,不继承上次模拟;重列本阶段必要阀态,不能称为连续污染清除。flushing_plan 保存方案及估计排放量,仅证明水力结果,不验证清洁进水、排放合规或现场执行。尚未耦合停止输入后的水质衰减,压力、流量或模型零浓度不能替代复测。
@@ -57,9 +59,9 @@ opened_valves 明确开阀、closed_valves 指定关闭阀门,两者互斥。
水质 chart.scada 填 metric=quality、unit、role、完整三小时 window_start/window_end,总览覆盖全部目标。逐设备 time_comparison 用当前 line、七天每天末三真实槽 scatter;series 带 device_id、day_offset、等长 sample_times,缺测 null。不同指标或单位分图,超限分段,不标成流量。 水质 chart.scada 填 metric=quality、unit、role、完整三小时 window_start/window_end,总览覆盖全部目标。逐设备 time_comparison 用当前 line、七天每天末三真实槽 scatter;series 带 device_id、day_offset、等长 sample_times,缺测 null。不同指标或单位分图,超限分段,不标成流量。
展示依次为观测、候选、传播、源头控制、隔离、冲洗、反馈、复测、恢复;计算可并行,首次隔离展示须在传播结果或缺口说明之后,通知和工单不等待展示。发布填写具体 purpose;有证据时带 narration(stage、summary、evidence_refs)。沿用原阶段,源头控制用 source_control、冲洗用 flushing_plan、水质复测用 clearance_verification。阶段不等于批准或已执行,不为凑阶段造证据或空操作。 展示随证据就绪发布,按观测、候选、传播、源头控制、隔离、冲洗、反馈、复测和恢复标明性质与依赖,无需等待其他独立分析或固定播放顺序。通知和工单不等待展示。发布填写purpose及有依据的narration(stage、summary、evidence_refs);源头控制用source_control、冲洗用flushing_plan、水质复测用clearance_verification。阶段不等于批准或已执行,不为凑阶段造证据或空操作。
报告依次写事件概况、观测、候选与传播、源头控制、用户隔离、清除冲洗、反馈复测及恢复条件;各项列结论、对象、预计或实际、证据和待办。操作表保留阶段、阀态、进排水、条件、反馈及复测依据。引用本轮全量 SCADA 和已取得的分析证据;权限拒绝不写成无数据。各阶段独立保存,回放不合并吞步骤,不派单或操作设备。 报告按风险与结论组织,覆盖观测、候选、传播、源头控制、隔离、清除及复测恢复,区分预计与实际。操作表保留阀态、进排水、条件和反馈依据。引用本轮全量SCADA与分析证据;权限拒绝不写成无数据。阶段独立保存,历史播放不派单或操作设备。
## 7. 工单反馈与复测恢复 ## 7. 工单反馈与复测恢复
@@ -2,7 +2,7 @@
name: water-quality-complaint name: water-quality-complaint
description: 调查供水水质投诉,核查投诉与监测覆盖,评估候选原因、影响和采样处置建议。 description: 调查供水水质投诉,核查投诉与监测覆盖,评估候选原因、影响和采样处置建议。
metadata: metadata:
version: "2.0.4" version: "2.0.5"
--- ---
# 水质投诉处置 # 水质投诉处置
@@ -15,7 +15,7 @@ metadata:
## 风险与工程分析 ## 风险与工程分析
根据当前证据选择必要步骤: 以下为参考核查路径,根据当前证据选择必要步骤,自主调整顺序、查询范围、工具组合及模型情景。可通过补查、推导或有说明的假设解决的缺口先处理,确需现场事实或人工决策时再提问:
1. 核实投诉位置及相近时段的同类投诉,用 `query_peripheral_data` 按实际时间、位置和来源标识核对外围工单或观察。目录首屏不能证明没有其他投诉;未知点位先查目录,不猜编号或把演示点当真实资产。 1. 核实投诉位置及相近时段的同类投诉,用 `query_peripheral_data` 按实际时间、位置和来源标识核对外围工单或观察。目录首屏不能证明没有其他投诉;未知点位先查目录,不猜编号或把演示点当真实资产。
2. 解析未知地址,查询附近资产、属性和连接,区分上游、管网、末梢、二次供水和用户端的实际覆盖。地点明确后展示投诉位置,取得关联资产后展示正在核查的管段或节点。拓扑连接不等于实际流向,方向判断需要同窗模型或监测依据。 2. 解析未知地址,查询附近资产、属性和连接,区分上游、管网、末梢、二次供水和用户端的实际覆盖。地点明确后展示投诉位置,取得关联资产后展示正在核查的管段或节点。拓扑连接不等于实际流向,方向判断需要同窗模型或监测依据。
@@ -0,0 +1,86 @@
from types import SimpleNamespace
from uuid import uuid4
import asyncio
from fastapi.testclient import TestClient
import pytest
from app.api.v1.endpoints.timeseries import playback
from app.auth.project_dependencies import ProjectContext
from tests.conftest import build_test_app
def client(monkeypatch):
context = ProjectContext(project_id=uuid4(), project_code="test", user_id=uuid4(), project_role="viewer")
app = build_test_app(playback.router, "/api/v1")
app.dependency_overrides[playback.get_project_context] = lambda: context
app.dependency_overrides[playback.get_project_pg_connection] = lambda: object()
app.dependency_overrides[playback.get_timescale_connection] = lambda: object()
async def geometry(*_):
return SimpleNamespace(version="a" * 24, content=b"geometry")
monkeypatch.setattr(playback, "geometry_for_project", geometry)
return TestClient(app)
def test_geometry_is_private_and_frames_reject_a_different_model_version(monkeypatch):
http = client(monkeypatch)
response = http.get("/api/v1/timeseries/realtime/playback/geometry")
assert response.status_code == 200
assert response.headers["cache-control"] == "private, no-store"
assert response.content == b"geometry"
response = http.get("/api/v1/timeseries/realtime/playback/frames", params={
"start_time": "2026-07-16T00:00:00+08:00", "model_version": "b" * 24,
})
assert response.status_code == 409
@pytest.mark.parametrize("params", [
{"start_time": "2026-07-16T00:00:00"},
{"start_time": "2026-07-16T00:00:00Z", "frame_count": 9},
{"start_time": "2026-07-16T00:00:00Z", "step_seconds": 0},
])
def test_frames_require_timezone_and_bound_the_block_size(monkeypatch, params):
http = client(monkeypatch)
response = http.get("/api/v1/timeseries/realtime/playback/frames", params={"model_version": "a" * 24, **params})
assert response.status_code == 422
def test_frames_pass_exact_utc_time_and_counts_to_reader(monkeypatch):
http = client(monkeypatch)
async def frames(_connection, geometry, start, count, step):
assert geometry.version == "a" * 24
assert start.isoformat() == "2026-07-15T16:00:00+00:00"
assert count == 4 and step == 900
return b"frames"
monkeypatch.setattr(playback, "read_frames", frames)
response = http.get("/api/v1/timeseries/realtime/playback/frames", params={
"start_time": "2026-07-16T00:00:00+08:00", "model_version": "a" * 24,
})
assert response.status_code == 200 and response.content == b"frames"
def test_cache_separates_projects_and_database_routes(monkeypatch):
playback._cache.clear()
calls = []
async def read(conn):
calls.append(conn.info.dbname)
return SimpleNamespace(version=conn.info.dbname)
monkeypatch.setattr(playback, "read_geometry", read)
ctx = SimpleNamespace(project_id=uuid4())
async def run():
for name in ["one", "one", "two"]:
connection = SimpleNamespace(info=SimpleNamespace(host="host", port=5432, dbname=name))
result = await playback.geometry_for_project(ctx, connection)
assert result.version == name
other = SimpleNamespace(project_id=uuid4())
await playback.geometry_for_project(other, SimpleNamespace(info=SimpleNamespace(host="host", port=5432, dbname="one")))
asyncio.run(run())
assert calls == ["one", "two", "one"]
playback._cache.clear()
def test_contract_advertises_binary_packets_instead_of_json():
schema = build_test_app(playback.router, "/api/v1").openapi()
for suffix in ("geometry", "frames"):
content = schema["paths"][f"/api/v1/timeseries/realtime/playback/{suffix}"]["get"]["responses"]["200"]["content"]
assert content == {"application/octet-stream": {"schema": {"type": "string", "format": "binary"}}}
+24 -2
View File
@@ -22,11 +22,11 @@ def inputs(monkeypatch):
read = Mock(side_effect=lambda ids, *a, **kw: { read = Mock(side_effect=lambda ids, *a, **kw: {
node: [{"time": START.isoformat(), "value": 30}] for node in ids}) node: [{"time": START.isoformat(), "value": 30}] for node in ids})
monkeypatch.setattr(service.InternalQueries, "query_realtime_simulation_by_ids_timerange", read) monkeypatch.setattr(service.InternalQueries, "query_realtime_simulation_by_ids_timerange", read)
def coverage(network, groups): def coverage(network, groups, **parameters):
ids = sorted({node for group in groups for node in group["node_ids"]}) ids = sorted({node for group in groups for node in group["node_ids"]})
data = build_node_coverage(groups, [ data = build_node_coverage(groups, [
{"id": node, "longitude": 121.5 + i * .001, "latitude": 30.9} {"id": node, "longitude": 121.5 + i * .001, "latitude": 30.9}
for i, node in enumerate(ids)], []) for i, node in enumerate(ids)], [], **parameters)
payload = data.pop("map_payload") payload = data.pop("map_payload")
return {"coverage": data, "map_payload": payload} return {"coverage": data, "map_payload": payload}
monkeypatch.setattr(service, "generate_node_coverage", coverage) monkeypatch.setattr(service, "generate_node_coverage", coverage)
@@ -44,6 +44,7 @@ def test_thresholds_are_exclusive_with_no_rounding_and_no_closure(inputs):
inputs.assert_called_once_with([f"J{i}" for i in range(8)], START, START, "node", "pressure", db_name="own") inputs.assert_called_once_with([f"J{i}" for i in range(8)], START, START, "node", "pressure", db_name="own")
assert len(result["map_payload"]["features"]) == 3 assert len(result["map_payload"]["features"]) == 3
assert all(feature["properties"]["model_estimate"] for feature in result["map_payload"]["features"]) assert all(feature["properties"]["model_estimate"] for feature in result["map_payload"]["features"])
assert result["coverage"]["parameters"]["exclusive_groups"] is True
@pytest.mark.parametrize("unit,factor", [("kPa", 1 / 9.80665), ("PSI", 6894.757293168 / 9806.65), @pytest.mark.parametrize("unit,factor", [("kPa", 1 / 9.80665), ("PSI", 6894.757293168 / 9806.65),
@@ -99,6 +100,27 @@ def test_unknown_units_do_not_draw_an_impact_area(inputs):
inputs.assert_not_called() inputs.assert_not_called()
def test_missing_online_baseline_offers_recalculation_then_coverage_can_be_retried(inputs):
inputs.side_effect = None
inputs.return_value = {}
native = simulation([2, 4, 6])
missing = service.estimate_burst_impact("own", START, native)
assert missing["impact"]["reason"] == "no_exact_comparable_online_snapshot"
recovery = missing["impact"]["baseline_recovery"]
assert recovery["tool"] == "run_simulation"
assert recovery["parameters"] == {
"start_time": START.isoformat(), "duration": 60, "output_node_ids": ["J0"],
}
assert "map_payload" not in missing
inputs.return_value = {
f"J{i}": [{"time": START.isoformat(), "value": 30}] for i in range(3)
}
retried = service.estimate_burst_impact("own", START, native)
assert retried["impact"]["status"] == "ready"
assert len(retried["map_payload"]["features"]) == 3
def test_full_native_network_compared_before_selected_series_filter(monkeypatch, inputs): def test_full_native_network_compared_before_selected_series_filter(monkeypatch, inputs):
from app.mcp import scenario_service from app.mcp import scenario_service
from app.services import simulation_scenarios from app.services import simulation_scenarios
+61
View File
@@ -1,5 +1,6 @@
"""Worker boundaries use real managed subprocesses, without external databases.""" """Worker boundaries use real managed subprocesses, without external databases."""
import asyncio import asyncio
import json
from functools import wraps from functools import wraps
from types import SimpleNamespace from types import SimpleNamespace
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -27,6 +28,66 @@ def run_async(function):
return wrapper return wrapper
@run_async
async def test_child_keeps_complete_result_above_eight_mib(monkeypatch, tmp_path):
from app.mcp import calculation_jobs
from app.services import calculation_worker
data = {"comparisons": "x" * (8 * 1024 * 1024), "map_payload": {"features": ["coverage"]}}
@asynccontextmanager
async def scope(row):
yield object()
async def calculate(scope, row):
return SimpleNamespace(model_dump=lambda **kwargs: data)
monkeypatch.setattr(calculation_worker, "execution_scope", scope)
monkeypatch.setattr(calculation_jobs, "execute_calculation_job", calculate)
input_path, output_path = tmp_path / "input.json", tmp_path / "result.json"
input_path.write_text("{}")
await calculation_worker.execute_child(str(input_path), str(output_path))
assert output_path.stat().st_size > 8 * 1024 * 1024
assert json.loads(output_path.read_text()) == {"status": "succeeded", "result": data}
@run_async
async def test_supervisor_persists_complete_result_above_eight_mib(monkeypatch, tmp_path):
monkeypatch.setenv("CALCULATION_WORKER_STATE_DIR", str(tmp_path))
original = asyncio.create_subprocess_exec
async def spawn(*args, **kwargs):
# A real child writes the same file protocol as the calculation process.
code = ('import json,pathlib,sys; pathlib.Path(sys.argv[1]).write_text('
'json.dumps({"status":"succeeded","result":{"comparisons":"x"*(8*1024*1024),'
'"map_payload":{"features":["coverage"]}}}))')
return await original(sys.executable, "-c", code, args[-1], start_new_session=True)
monkeypatch.setattr(asyncio, "create_subprocess_exec", spawn)
results = []
class Repository:
async def heartbeat(self, *args):
return "running"
async def finish(self, row, owner, **terminal):
results.append(terminal)
return True
@asynccontextmanager
async def repository(project):
yield Repository()
worker = CalculationWorker(WorkerSettings(heartbeat_seconds=.02), repository=repository)
await asyncio.wait_for(worker.run_job({"project_id": "project", "job_id": "large-result",
"timeout_seconds": 10}), 15)
assert len(results) == 1
assert results[0]["status"] == "succeeded"
assert results[0]["result"] == {
"comparisons": "x" * (8 * 1024 * 1024), "map_payload": {"features": ["coverage"]},
}
def test_worker_settings_require_valid_concurrency(monkeypatch): def test_worker_settings_require_valid_concurrency(monkeypatch):
settings = WorkerSettings.from_environment() settings = WorkerSettings.from_environment()
assert settings.max_concurrency == 2 assert settings.max_concurrency == 2
@@ -42,27 +42,29 @@ def context(monkeypatch):
return scope, binding return scope, binding
def test_replay_prediction_can_extend_past_cutoff_without_reading_future_observations(monkeypatch): @pytest.mark.parametrize("include_impact", [True, False])
def test_replay_prediction_can_extend_past_cutoff_without_reading_future_observations(monkeypatch, include_impact):
scope, binding = context(monkeypatch) scope, binding = context(monkeypatch)
worker = AsyncMock(return_value={"impact": {"affected_node_count": 2}}) worker = AsyncMock(return_value={"impact": {"affected_node_count": 2}})
monkeypatch.setattr(analysis_service, "calculate_replay_contaminant_simulation", worker) monkeypatch.setattr(analysis_service, "calculate_replay_contaminant_simulation", worker)
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["run_contaminant_simulation"], args())) parameters = args(include_impact_coverage=include_impact)
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["run_contaminant_simulation"], parameters))
assert result.origin == "synthetic" and result.data["result"]["impact"]["affected_node_count"] == 2 assert result.origin == "synthetic" and result.data["result"]["impact"]["affected_node_count"] == 2
assert "预测" in result.data_gaps[0] assert "预测" in result.data_gaps[0]
scope.connection.commit.assert_awaited_once() scope.connection.commit.assert_awaited_once()
worker.assert_awaited_once() worker.assert_awaited_once()
assert worker.await_args.args[0].include_impact_coverage is include_impact
assert worker.await_args.kwargs == {"network": "project", "username": "actor"} assert worker.await_args.kwargs == {"network": "project", "username": "actor"}
assert should_queue(TOOLS["run_contaminant_simulation"], args()) assert should_queue(TOOLS["run_contaminant_simulation"], args())
@pytest.mark.parametrize("problem", ["future_start", "no_impact", "reader", "permission"]) @pytest.mark.parametrize("problem", ["future_start", "reader", "permission"])
def test_replay_admission_keeps_time_permission_and_reader_boundaries(monkeypatch, problem): def test_replay_admission_keeps_time_permission_and_reader_boundaries(monkeypatch, problem):
scope, binding = context(monkeypatch) scope, binding = context(monkeypatch)
parameters = args() parameters = args()
if problem == "future_start": parameters = args(start_time="2026-07-16T08:15:00+08:00") if problem == "future_start": parameters = args(start_time="2026-07-16T08:15:00+08:00")
if problem == "no_impact": parameters = args(include_impact_coverage=False)
if problem == "reader": binding.purpose = "task_reader" if problem == "reader": binding.purpose = "task_reader"
if problem == "permission": binding.permission_cap.remove("scada.view") if problem == "permission": binding.permission_cap.remove("scada.view")
with pytest.raises(HTTPException) as caught: with pytest.raises(HTTPException) as caught:
asyncio.run(query_service.authorize_query(scope, binding, TOOLS["run_contaminant_simulation"], parameters)) asyncio.run(query_service.authorize_query(scope, binding, TOOLS["run_contaminant_simulation"], parameters))
assert caught.value.status_code == (422 if problem in {"future_start", "no_impact"} else 403) assert caught.value.status_code == (422 if problem == "future_start" else 403)
+93
View File
@@ -0,0 +1,93 @@
import asyncio
from datetime import datetime, timezone
import json
import math
import struct
import pytest
from app.services.hydraulic_playback import build_geometry, read_frames, result_factors
def fixture_geometry(longitude=121.5):
return build_geometry([
{"id": "J1", "longitude": longitude, "latitude": 30.8},
{"id": "J2", "longitude": longitude + .001, "latitude": 30.8},
], [{"id": "P1", "start_node_id": "J1", "diameter": 600,
"geometry": {"type": "LineString", "coordinates": [[longitude + .001, 30.8], [longitude, 30.8]]}}],
{"flow": "LPS", "pressure": "METERS"})
def unpack(content):
length = struct.unpack_from("<I", content)[0]
header = json.loads(content[4:4 + length])
count = (len(content) - 4 - length) // 4
return header, struct.unpack_from(f"<{count}f", content, 4 + length)
def test_geometry_version_tracks_absolute_location_and_orients_geometry_with_start_node():
geometry = fixture_geometry()
header, values = unpack(geometry.content)
assert header["nodeIds"] == ["J1", "J2"]
assert header["pipeIds"] == ["P1"]
assert header["segmentCount"] == 1
assert values[4:6] == values[:2] # reversed GIS geometry is oriented J1 -> J2
assert values[6:8] == values[2:4]
assert fixture_geometry().version == geometry.version
assert fixture_geometry(121.6).version != geometry.version
def test_unit_factors_preserve_signed_flow_and_reject_unknown_units():
assert result_factors({"flow": "LPS", "pressure": "METERS"}) == (3.6, 1, 1)
flow, pressure, velocity = result_factors({"flow": "GPM", "pressure": "PSI"})
assert flow == pytest.approx(.003785411784 * 60)
assert pressure == pytest.approx(.7032496149)
assert velocity == .3048
with pytest.raises(ValueError):
result_factors({"flow": "unknown", "pressure": "METERS"})
class Cursor:
def __init__(self, time):
self.time = time
self.rows = []
async def __aenter__(self):
return self
async def __aexit__(self, *_):
pass
async def execute(self, query, params):
assert len(params[0]) == 2
if "node_results" in query:
self.rows = [{"time": self.time, "node_id": "J1", "pressure": 25},
{"time": self.time, "node_id": "J2", "pressure": None}]
else:
self.rows = [{"time": self.time, "link_id": "P1", "flow": -10, "velocity": .7}]
def __aiter__(self):
self.iterator = iter(self.rows)
return self
async def __anext__(self):
try:
return next(self.iterator)
except StopIteration:
raise StopAsyncIteration
class Connection:
def __init__(self, time):
self.time = time
def cursor(self):
return Cursor(self.time)
def test_frames_pack_exact_sample_times_signed_values_and_missing_values_without_extrapolation():
time = datetime(2026, 7, 15, 16, tzinfo=timezone.utc)
content = asyncio.run(read_frames(Connection(time), fixture_geometry(), time, 2, 900))
header, values = unpack(content)
assert header["times"] == [1784131200000, 1784132100000]
assert header["available"] == [True, False]
assert header["validCounts"]["pressure"] == [1, 0]
assert values[0] == 25
assert math.isnan(values[1])
assert values[2] == -36
assert values[3] == pytest.approx(.7)
assert all(math.isnan(value) for value in values[4:])
+79
View File
@@ -239,6 +239,85 @@ def test_graph_renderer_escapes_labels_and_keeps_explicit_parallel_and_failed_pa
assert graph.node_refs[2].status == "reference" assert graph.node_refs[2].status == "reference"
@pytest.mark.parametrize("edges", [[], [{"source": "check", "target": "check"}]])
def test_new_multistep_graph_requires_relationships_instead_of_isolated_nodes(edges):
with pytest.raises(DomainError) as error:
update_investigation_graph(None, InvestigationGraphUpdate.model_validate({
"nodes": [{"node_id": "read", "label": "观测核验"},
{"node_id": "check", "label": "异常判断"}],
"edges": edges,
}))
assert error.value.code == "GRAPH_RELATION_REQUIRED"
assert "check" in str(error.value)
def test_new_branch_must_connect_to_the_saved_narrative_without_inventing_edges():
original = update_investigation_graph(None, InvestigationGraphUpdate.model_validate({
"nodes": [{"node_id": "root", "label": "调查起点"}],
}))
with pytest.raises(DomainError) as error:
update_investigation_graph(original, InvestigationGraphUpdate.model_validate({
"nodes": [{"node_id": "left", "label": "独立核查"},
{"node_id": "right", "label": "核查发现"}],
"edges": [{"source": "left", "target": "right"}],
}))
assert error.value.code == "GRAPH_RELATION_REQUIRED"
assert original.edges == []
assert len(original.node_refs) == 1
def test_connected_parallel_branches_and_join_render_top_down():
graph = update_investigation_graph(None, InvestigationGraphUpdate.model_validate({
"nodes": [{"node_id": key, "label": label} for key, label in (
("start", "异常核验"), ("isolation", "隔离分析"),
("impact", "爆管影响"), ("summary", "汇总判断"))],
"edges": [{"source": source, "target": target, "label": label}
for source, target, label in (
("start", "isolation", "并行核查"), ("start", "impact", "并行核查"),
("isolation", "summary", "汇总依据"), ("impact", "summary", "汇总依据"))],
}))
assert graph.mermaid.startswith("flowchart TD\n")
assert len(graph.edges) == 4
assert 'start -->|"并行核查"| impact' in graph.mermaid
patched = update_investigation_graph(graph, InvestigationGraphUpdate.model_validate({
"nodes": [{"node_id": "impact", "status": "recorded"}],
}))
assert patched.edges == graph.edges
def test_existing_disconnected_snapshot_remains_readable_and_patchable():
original = InvestigationDiagram.model_validate({
"title": "已保存的旧图", "mermaid": 'flowchart TD\n read["观测"]\n check["判断"]',
"edges": [], "node_refs": [{"node_id": "read", "label": "观测"},
{"node_id": "check", "label": "判断"}],
})
patched = update_investigation_graph(original, InvestigationGraphUpdate.model_validate({
"nodes": [{"node_id": "check", "status": "recorded"}],
}))
assert patched.mermaid == original.mermaid
assert patched.edges == original.edges == []
assert original.node_refs[1].status is None
@pytest.mark.anyio
async def test_publication_rejects_missing_links_and_accepts_a_corrected_graph():
task, repo, service = setup_service()
first = await publish(service, repo, task["task_id"], nodes=[
{"node_id": "read", "label": "观测核验"},
])
original = first.task.latest_progress.progress.diagram.model_dump(mode="json")
with pytest.raises(DomainError) as error:
await publish(service, repo, task["task_id"], nodes=[
{"node_id": "check", "label": "异常判断"},
])
assert error.value.code == "GRAPH_RELATION_REQUIRED"
assert (await service.detail(task["task_id"])).latest_progress.progress.diagram.model_dump(mode="json") == original
corrected = await publish(service, repo, task["task_id"], nodes=[
{"node_id": "check", "label": "异常判断"},
], edges=[{"source": "read", "target": "check", "label": "核验依据"}])
assert 'read -->|"核验依据"| check' in corrected.task.latest_progress.progress.diagram.mermaid
def test_first_structured_update_preserves_an_existing_mermaid_without_reinterpreting_it(): def test_first_structured_update_preserves_an_existing_mermaid_without_reinterpreting_it():
original = InvestigationDiagram.model_validate({ original = InvestigationDiagram.model_validate({
"title": "原调查路线", "mermaid": 'flowchart LR\nA{核查条件} -->|具备| B[原路径]\nA --> C[另一分支]', "title": "原调查路线", "mermaid": 'flowchart LR\nA{核查条件} -->|具备| B[原路径]\nA --> C[另一分支]',
+9 -12
View File
@@ -373,7 +373,7 @@ def test_replay_binding_denies_future_and_allows_device_metadata(monkeypatch):
asyncio.run(query_service.authorize_query(SimpleNamespace(project=object()), bound, TOOLS["list_scada_devices"], SimpleNamespace())) asyncio.run(query_service.authorize_query(SimpleNamespace(project=object()), bound, TOOLS["list_scada_devices"], SimpleNamespace()))
def test_replay_burst_scenario_requires_coverage_permission_and_known_start(monkeypatch): def test_replay_burst_scenario_allows_exploration_and_checks_coverage_permission(monkeypatch):
bound = binding(task=True) bound = binding(task=True)
bound.replay_context = {"knowledge_cutoff": "2026-10-08T01:00:00Z"} bound.replay_context = {"knowledge_cutoff": "2026-10-08T01:00:00Z"}
permissions = PERMISSIONS | {"simulation.run", "scada.view", "simulation.view"} permissions = PERMISSIONS | {"simulation.run", "scada.view", "simulation.view"}
@@ -386,11 +386,12 @@ def test_replay_burst_scenario_requires_coverage_permission_and_known_start(monk
"include_impact_coverage": True, "include_impact_coverage": True,
}) })
asyncio.run(query_service.authorize_query(scope, bound, TOOLS["run_burst_simulation"], args)) asyncio.run(query_service.authorize_query(scope, bound, TOOLS["run_burst_simulation"], args))
for changed, status in (({"include_impact_coverage": False}, 422), asyncio.run(query_service.authorize_query(scope, bound, TOOLS["run_burst_simulation"],
({"start_time": datetime(2026, 10, 8, 2, tzinfo=timezone.utc)}, 422)): args.model_copy(update={"include_impact_coverage": False})))
with pytest.raises(HTTPException) as error: with pytest.raises(HTTPException) as error:
asyncio.run(query_service.authorize_query(scope, bound, TOOLS["run_burst_simulation"], args.model_copy(update=changed))) asyncio.run(query_service.authorize_query(scope, bound, TOOLS["run_burst_simulation"],
assert error.value.status_code == status args.model_copy(update={"start_time": datetime(2026, 10, 8, 2, tzinfo=timezone.utc)})))
assert error.value.status_code == 422
bound.permission_cap = sorted(permissions - {"simulation.view"}) bound.permission_cap = sorted(permissions - {"simulation.view"})
with pytest.raises(HTTPException) as error: with pytest.raises(HTTPException) as error:
asyncio.run(query_service.authorize_query(scope, bound, TOOLS["run_burst_simulation"], args)) asyncio.run(query_service.authorize_query(scope, bound, TOOLS["run_burst_simulation"], args))
@@ -881,7 +882,7 @@ def test_old_task_run_and_reader_parent_are_rejected(monkeypatch):
@pytest.mark.parametrize("purpose", ["task_execution", "task_collaboration"]) @pytest.mark.parametrize("purpose", ["task_execution", "task_collaboration"])
@pytest.mark.parametrize("replay", [False, True]) @pytest.mark.parametrize("replay", [False, True])
def test_work_order_task_excludes_location_but_scada_replay_keeps_it(monkeypatch, purpose, replay): def test_work_order_source_does_not_remove_authorized_analysis_tools(monkeypatch, purpose, replay):
monkeypatch.setattr(contexts, "permissions_for_context", lambda _: PERMISSIONS) monkeypatch.setattr(contexts, "permissions_for_context", lambda _: PERMISSIONS)
value = binding(task=True) value = binding(task=True)
value.purpose = purpose value.purpose = purpose
@@ -899,13 +900,9 @@ def test_work_order_task_excludes_location_but_scada_replay_keeps_it(monkeypatch
monkeypatch.setattr(contexts, "task_repository", lambda _: Tasks()) monkeypatch.setattr(contexts, "task_repository", lambda _: Tasks())
scope = SimpleNamespace(project=object(), connection=Connection()) scope = SimpleNamespace(project=object(), connection=Connection())
asyncio.run(contexts.verify_task(scope, value)) asyncio.run(contexts.verify_task(scope, value))
assert ("run_burst_location" in value.tool_cap) is replay assert "run_burst_location" in value.tool_cap
assert "analyze_valve_isolation" in value.tool_cap assert "analyze_valve_isolation" in value.tool_cap
assert "analyze_valve_closure" in value.tool_cap assert "analyze_valve_closure" in value.tool_cap
if not replay:
with pytest.raises(HTTPException) as caught:
asyncio.run(query_service.authorize_query(scope, value, TOOLS["run_burst_location"], SimpleNamespace()))
assert caught.value.status_code == 403
def test_jwt_signature_issuer_audience_lifetime_and_key_cache(): def test_jwt_signature_issuer_audience_lifetime_and_key_cache():
+3 -3
View File
@@ -46,7 +46,7 @@ def test_work_order_handling_and_legacy_scada_replay_receive_separate_skills():
assert replay["workflow"] == "burst_report" assert replay["workflow"] == "burst_report"
assert "影响说明" in handling["instructions"] assert "影响说明" in handling["instructions"]
assert "supply_affected_nodes" in handling["instructions"] assert "supply_affected_nodes" in handling["instructions"]
assert "run_burst_location" not in handling["instructions"] assert "run_burst_location" in handling["instructions"]
assert "run_burst_location" in replay["instructions"] assert "run_burst_location" in replay["instructions"]
@@ -66,14 +66,14 @@ def test_pollution_instructions_offer_work_order_before_simulation_and_reuse_the
assert text.index("propose_task_action") < text.index("run_contaminant_simulation") assert text.index("propose_task_action") < text.index("run_contaminant_simulation")
assert "include_impact_coverage=true" in text and "不等模拟完成才发起工单" in text assert "include_impact_coverage=true" in text and "不等模拟完成才发起工单" in text
assert "同一 task_id" in text and "不覆盖已审核工单或绕过版本校验" in text assert "同一 task_id" in text and "不覆盖已审核工单或绕过版本校验" in text
assert "模型曲线不填观测专用 chart.scada" in text assert "模型曲线不填观测专用chart.scada" in text
assert "不能把浊度、余氯下降或感官描述直接换算成污染源浓度" in text assert "不能把浊度、余氯下降或感官描述直接换算成污染源浓度" in text
def test_pollution_workflow_distinguishes_protection_flushing_and_clearance(): def test_pollution_workflow_distinguishes_protection_flushing_and_clearance():
skill = read_work_order_skill("water_pollution", include_content=True) skill = read_work_order_skill("water_pollution", include_content=True)
text = skill["instructions"] text = skill["instructions"]
assert skill["version"] == "1.2.0" assert skill["version"] == "1.2.1"
assert text.index("## 4. 源头控制与用户隔离") < text.index("## 5. 污染物清除与分步冲洗") assert text.index("## 4. 源头控制与用户隔离") < text.index("## 5. 污染物清除与分步冲洗")
assert "opened_valves" in text and "closed_valves" in text assert "opened_valves" in text and "closed_valves" in text
assert "不继承上次模拟" in text and "排放去向" in text assert "不继承上次模拟" in text and "排放去向" in text
+41 -2
View File
@@ -3,11 +3,12 @@ from datetime import datetime, timezone
import pytest import pytest
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
from pydantic import ValidationError from pydantic import ValidationError
from shapely.geometry import Point, shape from shapely.geometry import Point, box, mapping, shape
from shapely.ops import unary_union
from app.domain.schemas.operational_data import TaskDataSnapshot from app.domain.schemas.operational_data import TaskDataSnapshot
from app.mcp.analysis_models import NodeCoverageInput from app.mcp.analysis_models import NodeCoverageInput
from app.services.node_coverage import build_node_coverage from app.services.node_coverage import build_node_coverage, exclusive_coverage_features
from app.services.operational_presentation import resolve_overlay from app.services.operational_presentation import resolve_overlay
@@ -32,6 +33,44 @@ def test_coverage_can_compare_arbitrary_groups_without_assigning_isolation_seman
assert coverage["parameters"]["split_by_connectivity"] is False assert coverage["parameters"]["split_by_connectivity"] is False
def test_exclusive_envelopes_keep_union_and_counts_with_higher_groups_taking_precedence():
originals = [box(0, 0, 4, 4), box(2, 0, 6, 4), box(3, 1, 5, 3)]
features = [{"type": "Feature", "id": str(i), "properties": {
"coverage_group": str(i), "node_count": 10 + i, "mapped_node_count": 10 + i,
}, "geometry": mapping(geometry)} for i, geometry in enumerate(originals)]
result = exclusive_coverage_features(features)
visible = [shape(feature["geometry"]) for feature in result]
assert len(result) == 3
assert all(a.intersection(b).area == 0 for i, a in enumerate(visible) for b in visible[i + 1:])
assert unary_union(visible).equals(unary_union(originals))
assert visible[-1].equals(originals[-1])
assert [feature["properties"]["node_count"] for feature in result] == [10, 11, 12]
assert [feature["properties"]["mapped_node_count"] for feature in result] == [10, 11, 12]
assert all(feature["properties"]["overlap_policy"] == "higher_priority_group_first" for feature in result)
assert shape(features[0]["geometry"]).equals(originals[0]) # Never rewrite saved inputs.
def test_covered_grade_keeps_node_metadata_without_invalid_empty_geometry():
positions = [position("A", 117.24), position("B", 117.24)]
groups = [group(["A"], "low"), group(["B"], "high")]
ordinary = build_node_coverage(groups, positions, [])
assert len(ordinary["map_payload"]["features"]) == 2
exclusive = build_node_coverage(groups, positions, [], exclusive_groups=True)
assert [feature["id"] for feature in exclusive["map_payload"]["features"]] == ["high"]
assert exclusive["groups"]["low"]["node_ids"] == ["A"]
assert exclusive["groups"]["low"]["mapped_node_count"] == 1
assert exclusive["groups"]["low"]["display_area_available"] is False
assert exclusive["groups"]["high"]["display_area_available"] is True
summaries = exclusive["map_payload"]["features"][0]["properties"]["coverage_group_summary"]
assert summaries["low"]["node_count"] == 1
assert summaries["low"]["mapped_node_count"] == 1
assert summaries["low"]["display_area_available"] is False
# A second application must not drop the summary for the clipped group.
repeated = exclusive_coverage_features(exclusive["map_payload"]["features"])
assert repeated[0]["properties"]["coverage_group_summary"] == summaries
assert exclusive["status"] == "ready"
def test_connectivity_option_does_not_bridge_disconnected_groups(): def test_connectivity_option_does_not_bridge_disconnected_groups():
positions = [position("A", 117.24), position("B", 117.241), positions = [position("A", 117.24), position("B", 117.241),
position("C", 117.26), position("D", 117.261)] position("C", 117.26), position("D", 117.261)]
+11
View File
@@ -46,6 +46,17 @@ def test_online_simulation_rejects_invalid_step_before_calculating(monkeypatch):
run.assert_not_called() run.assert_not_called()
def test_online_baseline_preserves_fractional_start_for_exact_impact_comparison(monkeypatch):
start = START.replace(microsecond=123456)
monkeypatch.setattr(simulation, "get_time", lambda _: {"HYDRAULIC TIMESTEP": "0:15:00"})
monkeypatch.setattr(simulation, "query_corresponding_element_id_and_query_id", lambda _: object())
run = Mock(return_value={"node_results": [{"node": "J1", "result": [{"pressure": 30}]}]})
monkeypatch.setattr(simulation, "run_simulation", run)
result = simulation.run_online_simulation("online", start, 60, output_node_ids=["J1"])
assert run.call_args.kwargs["modify_pattern_start_time"] == start.isoformat()
assert result["times"]["sample_times"] == [start.isoformat()]
def test_mcp_online_simulation_does_not_copy_a_database(monkeypatch): def test_mcp_online_simulation_does_not_copy_a_database(monkeypatch):
from app.mcp.scenario_service import calculate_scenario from app.mcp.scenario_service import calculate_scenario
+48
View File
@@ -0,0 +1,48 @@
from copy import deepcopy
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from app.domain.schemas.project_settings import ProjectSettings
from app.services.operational_recovery import reconcile_recoveries
from app.services.operational_tasks import OperationalTaskService
from tests.support.operational_task_repository import TaskRepository, _active_run, _context, _task_document
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.mark.anyio
@pytest.mark.parametrize("cached", [False, True])
async def test_recovery_uses_report_devices_instead_of_all_browsed_network_devices(monkeypatch, cached):
now = datetime(2026, 7, 16, 6, tzinfo=timezone.utc)
incident = now - timedelta(hours=6)
returned = now - timedelta(minutes=20)
task = _task_document(_context(), now, lease=now + timedelta(minutes=1))
task.update(workflow="scada_anomaly", status="waiting_human", lifecycle_status="waiting_human",
current_report_revision=1, trigger={"kind": "event", "occurred_at": incident.isoformat(), "detail": "异常"},
handling_context={"phase": "waiting_scada", "returned_at": returned.isoformat()})
if cached:
task["handling_context"].update(device_ids=["P1", "unrelated"], interval_seconds=900)
repo = TaskRepository(task, _active_run(task, now))
repo.get_latest_report = AsyncMock(return_value={"revision": 1, "report": {"scada_device_ids": ["P1"]}})
repo.list_evidence = AsyncMock(return_value=[{"data_snapshot": {"source": "query_scada_readings",
"data": {"data": [{"device_id": "P1"}, {"device_id": "unrelated"}]}}}])
repo.recovery_candidates = AsyncMock(side_effect=lambda *_: [deepcopy(repo.task)]
if repo.task["handling_context"]["phase"] == "waiting_scada" else [])
service = OperationalTaskService(repo, _context(), set(), clock=lambda: now)
at = returned + timedelta(minutes=5)
rows = [{"time": at - timedelta(days=day), "device_id": "P1", "monitored_value": 100.}
for day in range(1, 16)] + [{"time": at, "device_id": "P1", "monitored_value": 100.}]
reader = AsyncMock(side_effect=lambda _, devices, *args, **kwargs: rows if devices == ["P1"] else [{}] * 100_001)
monkeypatch.setattr("app.infra.db.timescaledb.repositories.scada.ScadaRepository.get_recovery_scada_readings", reader)
settings = ProjectSettings(scada_monitoring_interval_seconds=900, scada_monitoring={"pressure_device_ids": ["P1"]})
assert await reconcile_recoveries(service, SimpleNamespace(transaction=repo.transaction), settings) == 1
assert repo.task["handling_context"]["device_ids"] == ["P1"]
assert repo.task["handling_context"]["phase"] == "verification_ready"
assert repo.run["status"] == "queued"
assert await reconcile_recoveries(service, SimpleNamespace(transaction=repo.transaction), settings) == 0
+171
View File
@@ -0,0 +1,171 @@
import asyncio
from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
from uuid import uuid4
import pytest
from fastapi import HTTPException
from app.mcp import query_service
from app.mcp.analysis_models import ScenarioInput
from app.mcp.catalog import TOOLS
from app.services import simulation
def parameters(**changes):
return ScenarioInput.model_validate({
"context_handle": "a" * 43, "start_time": "2026-07-16T08:00:00+08:00",
"duration": 60, "output_node_ids": ["J1"], **changes,
})
def context(monkeypatch):
permissions = {"simulation.run", "simulation.view", "scada.view", "webgis.view", "optimization.run"}
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: permissions)
@asynccontextmanager
async def routing():
yield
scope = SimpleNamespace(
connection=SimpleNamespace(commit=AsyncMock()), simulation_routing=routing,
project=SimpleNamespace(project_code="owned", project_role="member"),
user=SimpleNamespace(username="actor"),
)
binding = SimpleNamespace(
tool_cap=["run_simulation"], permission_cap=sorted(permissions),
purpose="task_execution", task_id=uuid4(), task_scope_cap=[],
replay_context={"knowledge_cutoff": "2026-07-16T08:00:00+08:00"},
)
return scope, binding
def test_replay_coordinator_can_rebuild_online_baseline_without_live_scada(monkeypatch):
scope, binding = context(monkeypatch)
monkeypatch.setattr(simulation, "get_time", lambda _: {"HYDRAULIC TIMESTEP": "0:15:00"})
live_mappings = Mock(side_effect=AssertionError("Replay must not load live SCADA mappings"))
monkeypatch.setattr(simulation, "query_corresponding_element_id_and_query_id", live_mappings)
native = Mock(return_value={
"units": {"pressure": "MTR"},
"node_results": [{"node": "J1", "result": [{"pressure": 30}]}],
})
monkeypatch.setattr(simulation, "run_simulation", native)
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["run_simulation"], parameters()))
assert result.origin == "synthetic"
assert any("不读取实时SCADA" in gap for gap in result.data_gaps)
assert result.data["result"]["persisted_baseline"] is True
assert result.data["result"]["node_results"][0]["result"] == [{"pressure": 30}]
native.assert_called_once()
call = native.call_args.kwargs
assert call["name"] == "owned" and call["simulation_type"] == "realtime"
assert call["modify_pattern_start_time"] == "2026-07-16T08:00:00+08:00"
assert all(not values for values in vars(call["scada_mappings"]).values())
live_mappings.assert_not_called()
@pytest.mark.parametrize("problem", ["future_start", "reader", "permission", "tool_cap"])
def test_replay_online_baseline_preserves_existing_authorization(monkeypatch, problem):
scope, binding = context(monkeypatch)
args = parameters()
if problem == "future_start":
args = parameters(start_time="2026-07-16T08:15:00+08:00")
if problem == "reader":
binding.purpose = "task_reader"
if problem == "permission":
binding.permission_cap.remove("simulation.run")
if problem == "tool_cap":
binding.tool_cap.clear()
with pytest.raises(HTTPException) as caught:
asyncio.run(query_service.authorize_query(scope, binding, TOOLS["run_simulation"], args))
assert caught.value.status_code == (422 if problem == "future_start" else 403)
@pytest.mark.parametrize("name", ["analyze_valve_closure", "analyze_pressure_regulation"])
def test_replay_can_compare_hydraulic_alternatives_with_model_only_boundaries(monkeypatch, name):
from app.infra.db.postgresql.scada import ScadaElementMappings
from app.services import simulation_scenarios as scenarios
scope, binding = context(monkeypatch)
binding.tool_cap = [name]
captures = []
monkeypatch.setattr(scenarios, "get_option", lambda _: {"UNITS": "LPS"})
monkeypatch.setattr(scenarios, "set_option", lambda *_: None)
monkeypatch.setattr(simulation, "query_corresponding_element_id_and_query_id",
lambda _: ScadaElementMappings({}, {}, {"P1": "device"}, {}, {}, {}, {}))
monkeypatch.setattr(simulation, "run_simulation", lambda **kw: captures.append(kw) or {
"node_results": [{"node": "J1", "result": [{"pressure": 30}]}],
})
native_name = "valve_close_analysis" if name == "analyze_valve_closure" else "pressure_regulation"
native = getattr(scenarios, native_name).__wrapped__
monkeypatch.setattr(scenarios, native_name,
lambda **kw: native(**kw, _temporary_project="tjw_tmp_owned"))
args = TOOLS[name].model.model_validate({
**parameters().model_dump(),
**({"valves": ["V1"]} if name == "analyze_valve_closure" else {"pump_control": {"P1": [0]}}),
})
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS[name], args))
assert result.origin == "synthetic"
assert result.data["result"]["persisted_baseline"] is False
assert len(captures) == 1
assert captures[0]["name"] == "tjw_tmp_owned" and captures[0]["persist_results"] is False
assert all(not values for values in vars(captures[0]["scada_mappings"]).values())
if name == "analyze_valve_closure":
assert captures[0]["modify_valve_opening"] == {"V1": 0.0}
else:
assert captures[0]["modify_fixed_pump_pattern"] == {"P1": [0]}
@pytest.mark.parametrize("name", [name for name, tool in TOOLS.items()
if tool.semantics == "asset" and not tool.mutating])
def test_replay_allows_authorized_asset_queries_for_input_discovery(monkeypatch, name):
scope, binding = context(monkeypatch)
binding.tool_cap = [name]
# Authorization needs only the schema kind; execution validates each tool's inputs.
asyncio.run(query_service.authorize_query(scope, binding, TOOLS[name], SimpleNamespace(kind="node")))
binding.permission_cap = []
with pytest.raises(HTTPException) as caught:
asyncio.run(query_service.authorize_query(scope, binding, TOOLS[name], SimpleNamespace(kind="node")))
assert caught.value.status_code == 403
@pytest.mark.parametrize("name,fields", [
("query_model_baseline_series", {"element_type": "junction", "element_ids": ["J1"],
"start": "2026-07-16T07:45:00+08:00", "end": "2026-07-16T08:00:00+08:00"}),
("get_model_baseline_at_time", {"element_type": "junction", "element_id": "J1", "time": "2026-07-16T08:00:00+08:00"}),
("query_model_baseline_field", {"element_type": "junction", "field": "pressure", "time": "2026-07-16T08:00:00+08:00"}),
])
def test_replay_can_verify_baseline_without_reading_future_values(monkeypatch, name, fields):
scope, binding = context(monkeypatch)
binding.tool_cap = [name]
args = TOOLS[name].model.model_validate({"context_handle": "a" * 43, **fields})
asyncio.run(query_service.authorize_query(scope, binding, TOOLS[name], args))
future = parameters(start_time="2026-07-16T08:15:00+08:00").start_time
with pytest.raises(HTTPException) as caught:
asyncio.run(query_service.authorize_query(scope, binding, TOOLS[name], args.model_copy(
update={"end" if "end" in fields else "time": future},
)))
assert caught.value.status_code == 422
@pytest.mark.parametrize("name", ["calculate_demand_allocation", "optimize_sensor_placement"])
def test_replay_can_choose_current_model_design_calculations(monkeypatch, name):
scope, binding = context(monkeypatch)
binding.tool_cap = [name]
asyncio.run(query_service.authorize_query(scope, binding, TOOLS[name], SimpleNamespace()))
binding.purpose = "task_reader"
with pytest.raises(HTTPException) as caught:
asyncio.run(query_service.authorize_query(scope, binding, TOOLS[name], SimpleNamespace()))
assert caught.value.status_code == 403
def test_replay_can_explore_contamination_without_requiring_map_generation(monkeypatch):
scope, binding = context(monkeypatch)
binding.tool_cap = ["run_contaminant_simulation"]
args = TOOLS["run_contaminant_simulation"].model.model_validate({
**parameters().model_dump(), "source": "J1", "concentration": 1,
"include_impact_coverage": False,
})
asyncio.run(query_service.authorize_query(scope, binding, TOOLS["run_contaminant_simulation"], args))
+3 -1
View File
@@ -186,7 +186,9 @@ def test_recovery_read_selects_first_raw_frame_strictly_after_feedback_and_bound
assert result == rows assert result == rows
query, parameters = conn.cursor_instance.calls[0] query, parameters = conn.cursor_instance.calls[0]
assert "min(time)" in query and "time>%s AND time<=%s" in query assert "min(time)" in query and "time>%s AND time<=%s" in query
assert "time>=%s AND time<%s" in query and "JOIN next_frame" in query assert "m.time>=%s AND m.time<%s" in query and "JOIN next_frame" in query
assert "mod(extract(epoch FROM m.time)::bigint,86400)=" in query
assert "mod(extract(epoch FROM f.time)::bigint,86400)" in query
assert parameters == (["P1"], feedback, through, ["P1"], incident - timedelta(days=15), incident, 100_001, ["P1"]) assert parameters == (["P1"], feedback, through, ["P1"], incident - timedelta(days=15), incident, 100_001, ["P1"])