From ff8aba4964e398c9ebb999427bb37827a0a83ce5 Mon Sep 17 00:00:00 2001 From: Huarch Date: Sat, 7 Nov 2026 19:44:58 +0800 Subject: [PATCH] feat(scada): add calibrated monitoring and incident recovery --- .env.example | 7 +- AGENTS.md | 2 +- README.md | 17 +- app/api/v1/endpoints/operational_tasks.py | 27 + app/api/v1/endpoints/scada_replay.py | 7 +- app/domain/schemas/answer_ui.py | 28 +- app/domain/schemas/operational_task.py | 36 +- app/domain/schemas/project_settings.py | 22 +- app/domain/schemas/scada_replay.py | 29 +- app/infra/db/calculation_worker.py | 23 + app/infra/db/postgresql/calculation_jobs.py | 24 +- app/infra/db/postgresql/network_assets.py | 18 + app/infra/db/postgresql/operational_tasks.py | 22 + app/infra/db/postgresql/read_queries.py | 1 + app/infra/db/postgresql/scada_replays.py | 23 +- .../db/timescaledb/repositories/scada.py | 5 + app/infra/integrations/operational_actions.py | 1 + app/mcp/analysis_models.py | 38 +- app/mcp/application.py | 17 +- app/mcp/calculation_jobs.py | 8 +- app/mcp/catalog.py | 73 +- app/mcp/contexts.py | 3 + app/mcp/display_time.py | 63 ++ app/mcp/extended_models.py | 8 +- app/mcp/models.py | 5 + app/mcp/query_service.py | 46 +- app/mcp/scada_catalog.py | 28 + app/mcp/work_order_skills.py | 14 +- app/native/wndb/core/projects.py | 14 +- app/services/burst_location_demo.py | 128 +++ app/services/calculation_worker.py | 60 +- app/services/operational_recovery.py | 214 +++++ app/services/operational_tasks.py | 64 +- app/services/scada_monitoring_calibration.py | 141 ++++ app/services/scada_monitoring_incidents.py | 98 +++ app/services/scada_normal_profile.py | 48 ++ app/services/scada_replay.py | 287 ++++++- app/services/scada_replay_monitoring.py | 109 ++- app/services/scada_replay_processing.py | 27 +- app/services/scada_replay_queries.py | 17 +- contracts/answer-ui-catalog.json | 43 +- contracts/manifest.json | 2 +- contracts/peripheral-sample.openapi.json | 12 + contracts/server-v1.openapi.json | 376 ++++++++- contracts/view-command.schema.json | 43 +- docs/burst-response-workflow.md | 268 +------ docs/mcp-query-implementation.md | 8 +- docs/peripheral-sample.md | 2 +- docs/scada-peripheral-replay-design.md | 137 +++- docs/tjwater-v2-burst-benchmark.md | 101 +++ docs/tjwater2-v2-burst-balanced-design.md | 185 +++++ docs/tjwater2-v2-burst-simulation-design.md | 70 +- resources/benchmarks/tjwater-v2-burst.json | 38 + .../tjwater2-v2-burst-balanced.json | 485 ++++++++++++ .../benchmarks/tjwater2-v2-burst-demo.json | 37 + .../tjwater2-v2-burst-simulation.json | 121 +-- .../030_remove_calculation_queue_expiry.sql | 4 + .../burst-event-handling/SKILL.md | 53 ++ .../work-order-skills/burst-report/SKILL.md | 52 +- .../work-order-skills/scada-anomaly/SKILL.md | 40 +- .../water-quality-complaint/SKILL.md | 2 +- samples/peripheral/schemas.py | 4 + samples/peripheral/store.py | 10 +- scripts/analyze_burst_thresholds.py | 82 ++ scripts/benchmark/__init__.py | 1 + scripts/benchmark/calibration.py | 226 ++++++ scripts/benchmark/contracts.py | 145 ++++ scripts/benchmark/generation.py | 576 ++++++++++++++ scripts/benchmark/pressure_thresholds.py | 99 +++ scripts/benchmark/source.py | 263 +++++++ scripts/calibrate_burst_benchmark.py | 161 ++++ scripts/generate_burst_benchmark.py | 128 +++ scripts/materialize_burst_demo_scada.py | 88 +++ scripts/prepare_burst_benchmark.py | 114 +++ scripts/prepare_regional_burst_design.py | 169 ++++ scripts/run_burst_benchmark_mcp.py | 260 ++++++ scripts/run_normal_scada_day_mcp.py | 155 ++++ scripts/run_operational_worker.py | 8 + scripts/run_server.py | 52 +- scripts/screen_burst_benchmark_pipes.py | 256 ++++++ scripts/server_handoff.py | 267 +++++++ scripts/sync_peripheral_work_orders.py | 7 +- tests/api/test_scada_replay_api.py | 22 +- tests/fixtures/server_handoff_app.py | 60 ++ .../test_calculation_queue_storage.py | 10 +- tests/unit/test_benchmark_normal_scada.py | 51 ++ .../unit/test_burst_benchmark_calibration.py | 97 +++ tests/unit/test_burst_location_demo.py | 225 ++++++ tests/unit/test_burst_pressure_thresholds.py | 68 ++ .../unit/test_calculation_queue_lifecycle.py | 16 +- tests/unit/test_calculation_worker.py | 56 +- tests/unit/test_chart_sample_times.py | 57 ++ tests/unit/test_mcp_display_time.py | 40 + tests/unit/test_mcp_query.py | 203 +++++ tests/unit/test_mcp_work_order_skills.py | 45 +- tests/unit/test_operational_presentation.py | 26 +- tests/unit/test_operational_recovery.py | 109 +++ tests/unit/test_operational_tasks.py | 195 +++++ tests/unit/test_operational_worker.py | 17 +- tests/unit/test_peripheral_task_sync.py | 4 +- tests/unit/test_regional_burst_selection.py | 46 ++ tests/unit/test_report_ui.py | 11 + .../unit/test_scada_monitoring_calibration.py | 84 ++ tests/unit/test_scada_replay_monitoring.py | 202 +++++ tests/unit/test_scada_replay_processing.py | 61 ++ tests/unit/test_scada_replay_queries.py | 56 +- tests/unit/test_scada_replay_service.py | 738 +++++++++++++++++- tests/unit/test_server_handoff.py | 319 ++++++++ tests/unit/test_wndb_projects.py | 24 + 109 files changed, 9165 insertions(+), 629 deletions(-) create mode 100644 app/mcp/display_time.py create mode 100644 app/mcp/scada_catalog.py create mode 100644 app/services/burst_location_demo.py create mode 100644 app/services/operational_recovery.py create mode 100644 app/services/scada_monitoring_calibration.py create mode 100644 app/services/scada_monitoring_incidents.py create mode 100644 app/services/scada_normal_profile.py create mode 100644 docs/tjwater-v2-burst-benchmark.md create mode 100644 docs/tjwater2-v2-burst-balanced-design.md create mode 100644 resources/benchmarks/tjwater-v2-burst.json create mode 100644 resources/benchmarks/tjwater2-v2-burst-balanced.json create mode 100644 resources/benchmarks/tjwater2-v2-burst-demo.json create mode 100644 resources/sql/030_remove_calculation_queue_expiry.sql create mode 100644 resources/work-order-skills/burst-event-handling/SKILL.md create mode 100644 scripts/analyze_burst_thresholds.py create mode 100644 scripts/benchmark/__init__.py create mode 100644 scripts/benchmark/calibration.py create mode 100644 scripts/benchmark/contracts.py create mode 100644 scripts/benchmark/generation.py create mode 100644 scripts/benchmark/pressure_thresholds.py create mode 100644 scripts/benchmark/source.py create mode 100644 scripts/calibrate_burst_benchmark.py create mode 100644 scripts/generate_burst_benchmark.py create mode 100644 scripts/materialize_burst_demo_scada.py create mode 100644 scripts/prepare_burst_benchmark.py create mode 100644 scripts/prepare_regional_burst_design.py create mode 100644 scripts/run_burst_benchmark_mcp.py create mode 100644 scripts/run_normal_scada_day_mcp.py create mode 100644 scripts/screen_burst_benchmark_pipes.py create mode 100644 scripts/server_handoff.py create mode 100644 tests/fixtures/server_handoff_app.py create mode 100644 tests/unit/test_benchmark_normal_scada.py create mode 100644 tests/unit/test_burst_benchmark_calibration.py create mode 100644 tests/unit/test_burst_location_demo.py create mode 100644 tests/unit/test_burst_pressure_thresholds.py create mode 100644 tests/unit/test_chart_sample_times.py create mode 100644 tests/unit/test_mcp_display_time.py create mode 100644 tests/unit/test_operational_recovery.py create mode 100644 tests/unit/test_regional_burst_selection.py create mode 100644 tests/unit/test_scada_monitoring_calibration.py create mode 100644 tests/unit/test_server_handoff.py diff --git a/.env.example b/.env.example index 69bf235..48f7907 100644 --- a/.env.example +++ b/.env.example @@ -150,10 +150,9 @@ TJWATER_MCP_MAX_REQUEST_TIMEOUT_SECONDS=240 # 后台计算独立执行时限为 120–86400 秒,不受工具 timeout_seconds 控制。 TJWATER_MCP_MAX_CALCULATION_TIMEOUT_SECONDS=3600 -# 独立计算队列消费者;显式项目 UUID,以逗号分隔。不会在 API lifespan 内启动。 -# 先在每个目标项目 BizDB 依次应用 026_calculation_jobs.sql 和 027_calculation_job_lifecycle.sql,再启动计算服务。 -CALCULATION_WORKER_PROJECT_IDS="" -# 所有配置项目合计并发,独立于 Uvicorn --workers 4;单实例锁防止重复消费者扩容。 +# 独立计算队列消费者;从 MetaDB 动态发现活动项目,不会在 API lifespan 内启动。 +# 先在每个项目 BizDB 应用 026、027 和 030 迁移,再启动计算服务。 +# 所有活动项目合计并发,独立于 Uvicorn --workers 4;单实例锁防止重复消费者扩容。 CALCULATION_WORKER_MAX_CONCURRENCY=2 CALCULATION_WORKER_POLL_SECONDS=2 CALCULATION_WORKER_LEASE_SECONDS=30 diff --git a/AGENTS.md b/AGENTS.md index 398c4e5..ba703d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ Use the existing conda environment when available: ```bash conda run -n server python -m pytest tests/unit tests/auth -q -conda run -n server uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload +conda run -n server python scripts/run_server.py --reload docker build -t tjwater-server:local . docker compose -f infra/docker/docker-compose.yml config ``` diff --git a/README.md b/README.md index 7bcdece..a4034bc 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ cp .env.example .env ```bash conda run -n server python -m pytest tests/unit tests/auth -q -conda run -n server uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload +conda run -n server python scripts/run_server.py --reload ``` 如需要进入环境: @@ -84,11 +84,12 @@ CORS 匹配浏览器页面来源,不是 Nginx 连接 IP 或域名解析结果 ## 业务 Skill 与闭环 -[爆管处置工程图](docs/burst-response-workflow.md)覆盖上报、候选定位、隔离阀门、影响分析、地图与数据展示、审批处置和反馈闭环。Server 通过任务 MCP 按 workflow 分发以下业务 Skill,当前版本均为 `2.0.0`: +[爆管事件处理流程](docs/burst-response-workflow.md)覆盖工单接收、资产关联、隔离阀门、影响说明、审批处置和反馈闭环。Server 按任务 workflow 与回放来源分发独立 Skill,版本以实际返回为准: | workflow | Skill | 调查重点 | | --- | --- | --- | -| burst_report | [爆管处置](resources/work-order-skills/burst-report/SKILL.md) | 候选定位、隔离阀门、影响分析、抢修及恢复闭环 | +| burst_report(工单) | [爆管事件处理](resources/work-order-skills/burst-event-handling/SKILL.md) | 资产关联、隔离方案、影响说明、处置及恢复闭环 | +| scada_anomaly/历史回放 | [SCADA 异常调查](resources/work-order-skills/scada-anomaly/SKILL.md) | 冻结数据诊断、候选定位与隔离影响分析 | | water_quality_complaint | [水质投诉](resources/work-order-skills/water-quality-complaint/SKILL.md) | 投诉关联、监测与采样、候选影响、处置及复测回访 | | general | [预约跟进](resources/work-order-skills/appointment-follow-up/SKILL.md) | 审批与回执、现场履约、反馈复核及与原事件的闭环区别 | @@ -97,7 +98,7 @@ JSONL 整理遵守会话文件权限,后台图表和表格通过既有报告 ` ## 常用命令 外围接入样板作为 `/peripheral` 子应用随主 API 进程启动,复用 PostgreSQL 且不自动建表;授权边界和契约见 [外围样板说明](docs/peripheral-sample.md)。 -外围样板提供 synthetic 测试工单和测试数据的版本化 CRUD;工单经同步器投影为 `workbench.operational_tasks` 中的外围来源任务。固定演示时窗和造数方式见 [合成样本说明](docs/peripheral-generated-data.md),运行态读取边界见 [工作台运行态说明](docs/workbench-runtime.md)。这些数据不代表现场事实,也不会触发设备控制。 +外围系统提供业务工单和反馈的版本化 CRUD,工单来源固定为 `system_data`,经同步器投影为 `workbench.operational_tasks` 中的外围来源任务。系统上报与已核实事实分别记录,现场处置通过审批流程执行。独立测试数据和监测演示的固定时窗及造数方式见 [合成样本说明](docs/peripheral-generated-data.md),运行态读取边界见 [工作台运行态说明](docs/workbench-runtime.md)。 ```bash conda run -n server python -m pytest tests -q @@ -111,6 +112,14 @@ docker compose -f infra/docker/docker-compose.yml config - `docker build`:构建后端镜像。 - `docker compose config`:检查 compose 配置和变量展开。 +开发时使用 `python scripts/run_server.py --reload` 启用进程交接式重载:父进程持有监听端口, +候选进程完成 ASGI 初始化、通过元数据库就绪检查并接管监听后,才通知旧进程停止接收新连接。 +旧 HTTP 请求和 SSE 连接继续由旧进程完成,期间新请求由新进程响应;不迁移已建立的 TCP 连接, +也不通过强制取消旧请求来加快重载。候选启动失败或超出 `--startup-timeout`(默认 60 秒)时, +保留旧进程继续服务。快速连续修改最多保留两个正在排空的旧进程,其余修改合并后再重载。 +默认监视 `app/` 和 `samples/peripheral/` 下的 Python 文件,可重复传入 `--reload-dir` 指定目录。 +直接执行 `uvicorn ... --reload` 仍使用其原有先停后启流程;已经运行的旧启动命令不会自动切换到新机制。 + ## 开发规范 - Python 文件、函数、变量、Pydantic 字段、JSON body 字段和 query 参数使用 `snake_case`。 diff --git a/app/api/v1/endpoints/operational_tasks.py b/app/api/v1/endpoints/operational_tasks.py index 68a2540..9b3c701 100644 --- a/app/api/v1/endpoints/operational_tasks.py +++ b/app/api/v1/endpoints/operational_tasks.py @@ -7,6 +7,7 @@ from uuid import UUID from fastapi import APIRouter, Depends, Header, HTTPException, Query, status from psycopg import AsyncConnection +from app.auth.metadata_dependencies import get_metadata_repository from app.auth.permissions import ( OPERATIONAL_TASK_EXECUTE, @@ -22,6 +23,7 @@ from app.auth.project_dependencies import ( ProjectContext, get_project_context, get_project_pg_connection, + get_project_timescale_connection, ) from app.domain.domain_errors import DomainError from app.domain.schemas.workbench_runtime import WorkbenchDateTime @@ -90,6 +92,31 @@ from app.services.operational_tasks import OperationalTaskService router = APIRouter() +@router.post("/operational-tasks/recovery/reconcile") +async def reconcile_task_recoveries( + command: RecoverExpiredLeasesCommand, + ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)), + conn: AsyncConnection = Depends(get_project_pg_connection), + timescale=Depends(get_project_timescale_connection), + metadata=Depends(get_metadata_repository), +) -> dict[str, int]: + from app.domain.schemas.project_settings import ProjectSettings + from app.services.operational_recovery import reconcile_recoveries + + if SCADA_VIEW not in permissions_for_context(ctx): + raise HTTPException(403, "恢复监测需要 SCADA 查看权限") + project = await metadata.get_project_by_id(ctx.project_id) + if project is None: + raise HTTPException(404, "当前项目不存在") + try: + resumed = await reconcile_recoveries(_service(ctx, conn), timescale, + ProjectSettings.model_validate(project.settings or {}), limit=command.limit, + created_from=command.task_created_from) + return {"resumed": resumed} + except DomainError as exc: + _raise_domain(exc) + + def _service(ctx: ProjectContext, conn: AsyncConnection) -> OperationalTaskService: return OperationalTaskService( OperationalTaskRepository(conn, ctx.project_id), diff --git a/app/api/v1/endpoints/scada_replay.py b/app/api/v1/endpoints/scada_replay.py index 4c39215..b637803 100644 --- a/app/api/v1/endpoints/scada_replay.py +++ b/app/api/v1/endpoints/scada_replay.py @@ -44,12 +44,12 @@ async def replay_capabilities(repo=Depends(repository), ctx=Depends(require_perm monitoring_ready = bool( monitoring and len(monitoring.pressure_device_ids) >= 5 - and monitoring.outlet_flow_device_ids + and (monitoring.decision_policy == "weighted" or monitoring.outlet_flow_device_ids) ) if monitoring_ready: monitoring_reason = None elif monitoring is None: - monitoring_reason = "项目尚未配置压力与出厂边界流量监测角色" + monitoring_reason = "项目尚未配置压力监测角色及组合判定规则" elif len(monitoring.pressure_device_ids) < 5: monitoring_reason = "爆管侦测至少需要 5 个压力监测设备" else: @@ -60,6 +60,7 @@ async def replay_capabilities(repo=Depends(repository), ctx=Depends(require_perm monitoring_interval_seconds=settings.scada_monitoring_interval_seconds, burst_monitoring_ready=monitoring_ready, burst_monitoring_reason=monitoring_reason, + monitoring_config=monitoring, current=snapshot(current) if current else None, ) @@ -86,7 +87,7 @@ async def replay_command(replay_id: UUID, command: ScadaReplayCommand, repo=Depe timescale=Depends(get_project_timescale_connection)): service = ScadaReplayService(repo, ctx, timescale) result = await service.command(replay_id, command) - if command.action in {"step", "retry"} and result.step_pending: + if command.action in {"step", "monitor", "retry"} and result.step_pending: return await service.process_pending_step(replay_id) return result diff --git a/app/domain/schemas/answer_ui.py b/app/domain/schemas/answer_ui.py index 1a80414..8c859aa 100644 --- a/app/domain/schemas/answer_ui.py +++ b/app/domain/schemas/answer_ui.py @@ -3,12 +3,28 @@ from __future__ import annotations import json import math +import re +from datetime import datetime from pathlib import Path from typing import Annotated, Any, Literal from jsonschema import Draft7Validator from pydantic import BaseModel, ConfigDict, Field, field_validator, model_serializer, model_validator +CHART_SAMPLE_TIME_PATTERN = ( + r"^(?:[1-9][0-9]{3}|0[1-9][0-9]{2}|00[1-9][0-9]|000[1-9])-[0-9]{2}-[0-9]{2}T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]" + r"(?:\.[0-9]+)?(?:Z|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])$" +) + + +def validate_chart_sample_time(value: str) -> None: + """Match the bounded frontend RFC3339 spelling and an actual calendar date.""" + if len(value) > 64 or re.fullmatch(CHART_SAMPLE_TIME_PATTERN, value) is None: + raise ValueError("Chart sample times require RFC3339 seconds and a timezone") + if datetime.fromisoformat(value).tzinfo is None: + raise ValueError("Chart sample times require a timezone") + + _catalog = json.loads( (Path(__file__).resolve().parents[3] / "contracts/answer-ui-catalog.json").read_text() ) @@ -71,8 +87,18 @@ class AnswerUiBlock(UiModel): if element.type == "Chart": if any(len(series["data"]) != len(props["x_data"]) for series in props["series"]): raise ValueError("Chart series must match the axis") - if any(not math.isfinite(value) for series in props["series"] for value in series["data"]): + if any(value is not None and not math.isfinite(value) for series in props["series"] for value in series["data"]): raise ValueError("Chart values must be finite") + for series in props["series"]: + times = series.get("sample_times") + if times is not None: + if len(times) != len(series["data"]): + raise ValueError("Chart sample times must match the axis") + for value, at in zip(series["data"], times, strict=True): + if at is not None: + if value is None: + raise ValueError("Chart sample times require an observed value") + validate_chart_sample_time(at) if element.type == "Table" and any(len(row) != len(props["columns"]) for row in props["rows"]): raise ValueError("Table row width must match its columns") if element.type == "FollowUpForm": diff --git a/app/domain/schemas/operational_task.py b/app/domain/schemas/operational_task.py index c64231a..8125b8e 100644 --- a/app/domain/schemas/operational_task.py +++ b/app/domain/schemas/operational_task.py @@ -5,8 +5,10 @@ import re from typing import Annotated, Literal from uuid import UUID -from pydantic import Field, JsonValue, model_validator -from app.domain.schemas.answer_ui import ReportUiBlock, validate_report_ui_blocks +from pydantic import Field, JsonValue, model_serializer, model_validator +from app.domain.schemas.answer_ui import ( + CHART_SAMPLE_TIME_PATTERN, ReportUiBlock, validate_chart_sample_time, validate_report_ui_blocks, +) from app.domain.schemas.task_question import TaskQuestionRequest from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictBool, StrictModel @@ -588,6 +590,7 @@ class OperationalTaskSummary(StrictModel): execution_request_id: UUID title: NonEmptyStr summary: NonEmptyStr + handling_context: dict[str, JsonValue] = Field(default_factory=dict) source: TaskSource trigger: TaskTrigger scheduled_for: WorkbenchDateTime | None @@ -692,15 +695,42 @@ class AgentArtifactsResult(StrictModel): class TaskSafeChartSeries(StrictModel): name: Annotated[str, Field(max_length=128)] - data: Annotated[list[Annotated[float, Field(strict=True, allow_inf_nan=False)]], Field(min_length=1, max_length=100)] + type: Literal["line", "bar", "scatter"] | None = None + data: Annotated[list[Annotated[float, Field(strict=True, allow_inf_nan=False)] | None], Field(min_length=1, max_length=100)] + sample_times: Annotated[list[Annotated[str, Field( + strict=True, max_length=64, + pattern=CHART_SAMPLE_TIME_PATTERN, + )] | None], Field(min_length=1, max_length=100)] | None = None + + @model_serializer(mode="wrap") + def serialize_optional_fields(self, handler): + return {key: value for key, value in handler(self).items() if value is not None} + + @model_validator(mode="after") + def validate_sample_times(self): + if self.sample_times is not None: + if len(self.sample_times) != len(self.data): + raise ValueError("chart sample_times must align with data") + for value, at in zip(self.data, self.sample_times, strict=True): + if at is not None: + if value is None: + raise ValueError("chart sample_times require an observed value") + validate_chart_sample_time(at) + return self class TaskSafeChart(StrictModel): title: Annotated[str, Field(max_length=200)] chart_type: Literal["line", "bar"] + x_axis_name: Annotated[str, Field(max_length=128)] | None = None + y_axis_name: Annotated[str, Field(max_length=128)] | None = None x_data: Annotated[list[Annotated[str, Field(max_length=128)]], Field(min_length=1, max_length=100)] series: Annotated[list[TaskSafeChartSeries], Field(min_length=1, max_length=8)] + @model_serializer(mode="wrap") + def serialize_optional_fields(self, handler): + return {key: value for key, value in handler(self).items() if value is not None} + @model_validator(mode="after") def validate_alignment(self): if any(len(series.data) != len(self.x_data) for series in self.series): diff --git a/app/domain/schemas/project_settings.py b/app/domain/schemas/project_settings.py index a5d84e5..1899d78 100644 --- a/app/domain/schemas/project_settings.py +++ b/app/domain/schemas/project_settings.py @@ -1,19 +1,39 @@ +from math import isclose +from typing import Literal + from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator +class ScadaMonitoringWeights(BaseModel): + model_config = ConfigDict(extra="forbid", allow_inf_nan=False) + + burst_detection: float = Field(default=0.5, ge=0, le=1) + pressure: float = Field(default=0.4, ge=0, le=1) + outlet_flow: float = Field(default=0.1, ge=0, le=1) + + @model_validator(mode="after") + def normalized(self): + if not isclose(self.burst_detection + self.pressure + self.outlet_flow, 1.0, abs_tol=1e-9): + raise ValueError("SCADA 三项监测权重之和必须为 1") + return self + + class ScadaMonitoringSettings(BaseModel): """Project-owned device roles used by the manual SCADA monitoring demo.""" model_config = ConfigDict(extra="forbid", allow_inf_nan=False) rule_version: str = Field( - default="burst-monitor-v1", + default="burst-monitor-v2", min_length=1, max_length=80, pattern=r"^[a-zA-Z0-9_.-]+$", ) pressure_device_ids: list[str] = Field(default_factory=list, max_length=20) outlet_flow_device_ids: list[str] = Field(default_factory=list, max_length=20) + decision_policy: Literal["all_signals", "weighted"] = "weighted" + weights: ScadaMonitoringWeights = Field(default_factory=ScadaMonitoringWeights) + alert_threshold: float = Field(default=0.8, gt=0, le=1) @model_validator(mode="after") def distinct_roles(self): diff --git a/app/domain/schemas/scada_replay.py b/app/domain/schemas/scada_replay.py index 9fe2962..c9e1392 100644 --- a/app/domain/schemas/scada_replay.py +++ b/app/domain/schemas/scada_replay.py @@ -5,6 +5,8 @@ from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, model_validator +from app.domain.schemas.project_settings import ScadaMonitoringSettings + class ReplayModel(BaseModel): model_config = ConfigDict(extra="forbid", allow_inf_nan=False) @@ -78,7 +80,26 @@ class ScadaReplayCreate(ReplayModel): class ScadaReplayCommand(ReplayModel): request_id: UUID expected_revision: int = Field(ge=1) - action: Literal["step", "stop", "retry"] + action: Literal["step", "monitor", "stop", "retry"] + at_time: datetime | None = None + + @model_validator(mode="after") + def monitoring_time(self): + if self.action == "monitor": + if self.at_time is None or self.at_time.tzinfo is None: + raise ValueError("选定时刻监测需要提供带时区的 at_time") + elif self.at_time is not None: + raise ValueError("只有选定时刻监测可以指定 at_time") + return self + + +class ScadaMonitoringIncident(ReplayModel): + incident_id: UUID + status: Literal["active", "resolved"] + action: Literal["raised", "ongoing", "resolved"] + started_at: datetime + last_observed_at: datetime + recovery_count: int = Field(ge=0, le=2) class ScadaMonitoringResult(ReplayModel): @@ -94,7 +115,10 @@ class ScadaMonitoringResult(ReplayModel): pressure: dict outlet_flow: dict burst_detection: dict + weighted: dict | None = None + incident: ScadaMonitoringIncident | None = None role_gaps: list[str] = Field(default_factory=list) + anomaly_signals: list[dict] = Field(default_factory=list) class ScadaReplaySnapshot(ReplayModel): @@ -108,6 +132,8 @@ class ScadaReplaySnapshot(ReplayModel): step_pending: bool = False monitoring_count: int = 0 last_monitoring: ScadaMonitoringResult | None = None + monitoring_config: ScadaMonitoringSettings | None = None + monitoring_calibration: dict | None = None virtual_now: datetime delivered_until: datetime | None = None processed_until: datetime | None = None @@ -129,6 +155,7 @@ class ScadaReplayCapabilities(ReplayModel): monitoring_interval_seconds: int | None = Field(None, ge=60, le=31_536_000) burst_monitoring_ready: bool = False burst_monitoring_reason: str | None = None + monitoring_config: ScadaMonitoringSettings | None = None current: ScadaReplaySnapshot | None = None diff --git a/app/infra/db/calculation_worker.py b/app/infra/db/calculation_worker.py index 814d7b1..4c267d4 100644 --- a/app/infra/db/calculation_worker.py +++ b/app/infra/db/calculation_worker.py @@ -7,6 +7,29 @@ from uuid import UUID from sqlalchemy import text +async def discover_project_ids() -> tuple[str, ...]: + """Return every active PostgreSQL-backed project from authoritative metadata.""" + from sqlalchemy import select + from app.infra.db.metadb.database import SessionLocal + from app.infra.db.metadb import models + + async with SessionLocal() as session: + rows = await session.scalars( + select(models.Project.id) + .join( + models.ProjectDatabase, + models.ProjectDatabase.project_id == models.Project.id, + ) + .where( + models.Project.status == "active", + models.ProjectDatabase.db_role == "biz_data", + models.ProjectDatabase.db_type == "postgresql", + ) + .order_by(models.Project.id) + ) + return tuple(str(project_id) for project_id in rows.all()) + + @asynccontextmanager async def queue_repository(project_id): """Resolve the current project route and open its calculation queue.""" diff --git a/app/infra/db/postgresql/calculation_jobs.py b/app/infra/db/postgresql/calculation_jobs.py index dd27c76..e4b4d80 100644 --- a/app/infra/db/postgresql/calculation_jobs.py +++ b/app/infra/db/postgresql/calculation_jobs.py @@ -7,10 +7,10 @@ from app.mcp.errors import CalculationCapacityExceeded # Explicit metadata projection prevents status polling from decoding large JSON results. -JOB_COLUMNS = "job_id,project_id,owner_user_id,request_key,tool,parameters,binding,timeout_seconds,status,error,lease_generation,lease_owner,lease_expires_at,created_at,updated_at,started_at,finished_at,queue_expires_at,result_expires_at" +JOB_COLUMNS = "job_id,project_id,owner_user_id,request_key,tool,parameters,binding,timeout_seconds,status,error,lease_generation,lease_owner,lease_expires_at,created_at,updated_at,started_at,finished_at,result_expires_at" -PUBLIC_JOB_COLUMNS = "job_id,status,tool,error,created_at,updated_at,started_at,finished_at,queue_expires_at,result_expires_at" +PUBLIC_JOB_COLUMNS = "job_id,status,tool,error,created_at,updated_at,started_at,finished_at,result_expires_at" class CalculationJobRepository: @@ -19,12 +19,6 @@ class CalculationJobRepository: self.project_id = str(project_id) async def maintain(self): - await self.connection.execute( - """UPDATE workbench.calculation_jobs SET status='timed_out', - error='{"code":"QUEUE_TIMEOUT"}'::jsonb,finished_at=clock_timestamp(),updated_at=clock_timestamp() - WHERE project_id=%s AND status='queued' AND queue_expires_at<=clock_timestamp()""", - (self.project_id,), - ) await self.connection.execute( """UPDATE workbench.calculation_jobs SET result=NULL WHERE project_id=%s AND result IS NOT NULL AND result_expires_at<=clock_timestamp()""", @@ -32,14 +26,14 @@ class CalculationJobRepository: ) async def enqueue(self, *, owner_user_id, request_key, tool, parameters, binding, timeout_seconds, - queue_wait_seconds=3600, max_active_jobs=100): + max_active_jobs=100): async with self.connection.transaction(): return await self._enqueue(owner_user_id=owner_user_id, request_key=request_key, tool=tool, parameters=parameters, binding=binding, timeout_seconds=timeout_seconds, - queue_wait_seconds=queue_wait_seconds, max_active_jobs=max_active_jobs) + max_active_jobs=max_active_jobs) async def _enqueue(self, *, owner_user_id, request_key, tool, parameters, binding, - timeout_seconds, queue_wait_seconds, max_active_jobs): + timeout_seconds, max_active_jobs): # Serialize admission within the caller's transaction, across every API process. await self.connection.execute( "SELECT pg_advisory_xact_lock(hashtextextended(%s, 1748531781))", (self.project_id,)) @@ -64,11 +58,11 @@ class CalculationJobRepository: raise CalculationCapacityExceeded() cursor = await self.connection.execute( f"""INSERT INTO workbench.calculation_jobs - (job_id,project_id,owner_user_id,request_key,tool,parameters,binding,timeout_seconds,queue_expires_at) - VALUES (%s,%s,%s,%s,%s,%s,%s,%s,clock_timestamp()+%s*interval '1 second') + (job_id,project_id,owner_user_id,request_key,tool,parameters,binding,timeout_seconds) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s) RETURNING {JOB_COLUMNS}""", (uuid4(), self.project_id, owner_user_id, request_key, tool, - Jsonb(parameters), Jsonb(binding), timeout_seconds, queue_wait_seconds), + Jsonb(parameters), Jsonb(binding), timeout_seconds), ) return dict(await cursor.fetchone()) @@ -118,7 +112,7 @@ class CalculationJobRepository: lease_generation=j.lease_generation+1, lease_expires_at=clock_timestamp()+%s*interval '1 second',updated_at=clock_timestamp() WHERE j.job_id=(SELECT job_id FROM workbench.calculation_jobs - WHERE project_id=%s AND status='queued' AND queue_expires_at>clock_timestamp() ORDER BY created_at,job_id + WHERE project_id=%s AND status='queued' ORDER BY created_at,job_id FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING j.*""", (owner, lease_seconds, self.project_id), ) diff --git a/app/infra/db/postgresql/network_assets.py b/app/infra/db/postgresql/network_assets.py index f377602..e8eb880 100644 --- a/app/infra/db/postgresql/network_assets.py +++ b/app/infra/db/postgresql/network_assets.py @@ -6,6 +6,24 @@ from psycopg import AsyncConnection class NetworkAssetRepository: + @staticmethod + async def get_isolation_model(conn: AsyncConnection) -> list[dict[str, Any]]: + """Read source nodes, link state and pipe lengths in one model snapshot.""" + from app.infra.db.postgresql.read_queries import read_rows + + return await read_rows(conn, """ + SELECT l.id, l.link_type::text AS kind, + l.start_node_id, l.end_node_id, p.length, p.diameter, + COALESCE(s.status::text, p.status::text, '') AS status + FROM network.links l + LEFT JOIN network.pipes p ON p.link_id = l.id + LEFT JOIN network.link_initial_settings s ON s.link_id = l.id + UNION ALL + SELECT n.id, n.node_type::text, NULL, NULL, NULL, NULL, '' + FROM network.nodes n + WHERE n.node_type::text IN ('reservoir', 'tank') + """) + @staticmethod async def get_pipes_by_ids( conn: AsyncConnection, diff --git a/app/infra/db/postgresql/operational_tasks.py b/app/infra/db/postgresql/operational_tasks.py index ff7936e..46e89c2 100644 --- a/app/infra/db/postgresql/operational_tasks.py +++ b/app/infra/db/postgresql/operational_tasks.py @@ -711,6 +711,28 @@ class OperationalTaskRepository: row = await cursor.fetchone() return dict(row) if row else None + async def recovery_candidates(self, limit: int, created_from=None) -> list[dict]: + cursor = await self.connection.execute( + """SELECT document FROM workbench.operational_tasks + WHERE project_id=%s AND task_type='event_investigation' + AND status NOT IN ('cancelled','failed') + AND document#>>'{handling_context,phase}'='waiting_scada' + AND document#>>'{source,replay_context}' IS NULL + AND (%s::timestamptz IS NULL OR created_at>=%s) + ORDER BY COALESCE((document#>>'{handling_context,last_checked_at}')::timestamptz,created_at),task_id LIMIT %s""", + (self.project_id, created_from, created_from, limit), + ) + return [row["document"] for row in await cursor.fetchall()] + + async def list_investigation_alerts(self, task_id: str) -> list[dict]: + cursor = await self.connection.execute( + """SELECT document FROM workbench.alerts WHERE project_id=%s + AND status IN ('active','acknowledged') + AND document->>'condition_id'=%s ORDER BY alert_id""", + (self.project_id, f"operational-task:{task_id}"), + ) + return [row["document"] for row in await cursor.fetchall()] + async def upsert_feedback(self, task_id: str, feedback: dict, accepted_at) -> None: await self.connection.execute( """INSERT INTO workbench.operational_task_external_feedback diff --git a/app/infra/db/postgresql/read_queries.py b/app/infra/db/postgresql/read_queries.py index b43526d..bc47f0b 100644 --- a/app/infra/db/postgresql/read_queries.py +++ b/app/infra/db/postgresql/read_queries.py @@ -312,6 +312,7 @@ async def task_context_summary(conn: AsyncConnection, *, project_id: UUID, task_ 'current_report_revision',t.current_report_revision, 'title_preview',left(t.document->>'title',160),'preview_only',true, 'detail',jsonb_build_object('section','task')), + 'handling_context',COALESCE(t.document->'handling_context','{}'::jsonb), 'latest_report',(SELECT REPORT_SUMMARY FROM workbench.operational_task_reports r WHERE r.project_id=t.project_id AND r.task_id=t.task_id ORDER BY r.revision DESC LIMIT 1), 'selected_report',CASE WHEN %s::int IS NULL THEN NULL ELSE diff --git a/app/infra/db/postgresql/scada_replays.py b/app/infra/db/postgresql/scada_replays.py index 21724c3..b8aa59e 100644 --- a/app/infra/db/postgresql/scada_replays.py +++ b/app/infra/db/postgresql/scada_replays.py @@ -96,7 +96,7 @@ class ScadaReplayRepository: async with self.connection.transaction(): cur = await self.connection.execute( "SELECT document FROM peripheral_sample.scada_replay_batches " - "WHERE project_id=%s AND replay_id=%s AND stage<>'detected' ORDER BY sequence LIMIT 1", + "WHERE project_id=%s AND replay_id=%s AND sequence>0 AND stage<>'detected' ORDER BY sequence LIMIT 1", (self.project_id, str(replay_id)), ) row = await cur.fetchone() @@ -110,6 +110,25 @@ class ScadaReplayRepository: (self.project_id, str(replay_id), batch["sequence"], batch["stage"], Jsonb(batch)), ) + async def clear_unpublished_baseline(self, replay_id) -> None: + """Discard only an incomplete attempt, retaining every published snapshot.""" + await self.connection.execute( + "DELETE FROM peripheral_sample.scada_replay_batches " + "WHERE project_id=%s AND replay_id=%s AND sequence<0 " + "AND stage='clean_prepared' AND document->>'kind'='baseline'", + (self.project_id, str(replay_id)), + ) + + async def publish_baseline(self, replay_id) -> None: + """Publish with the replay's baseline metadata in the caller's transaction.""" + await self.connection.execute( + "UPDATE peripheral_sample.scada_replay_batches " + "SET stage='detected',document=jsonb_set(document,'{stage}','\"detected\"'::jsonb) " + "WHERE project_id=%s AND replay_id=%s AND sequence<0 " + "AND stage='clean_prepared' AND document->>'kind'='baseline'", + (self.project_id, str(replay_id)), + ) + async def events(self, replay_id, offset=0, limit=100) -> list[dict]: async with self.connection.transaction(): cur = await self.connection.execute( @@ -180,7 +199,7 @@ class ScadaReplayRepository: "SELECT DISTINCT ON(r.value->>'device_id') r.value AS reading " "FROM peripheral_sample.scada_replay_batches b " "CROSS JOIN LATERAL jsonb_array_elements(COALESCE(b.document->'outputs','[]'::jsonb)) r " - "WHERE b.project_id=%s AND b.replay_id=%s AND b.stage IN ('cleaned','detected') " + "WHERE b.project_id=%s AND b.replay_id=%s AND b.sequence>0 AND b.stage IN ('cleaned','detected') " "AND (r.value->>'time')::timestamptz<=%s " "ORDER BY r.value->>'device_id',(r.value->>'time')::timestamptz DESC LIMIT %s", (self.project_id, str(replay_id), through, limit), diff --git a/app/infra/db/timescaledb/repositories/scada.py b/app/infra/db/timescaledb/repositories/scada.py index d5f0ae5..2b42820 100644 --- a/app/infra/db/timescaledb/repositories/scada.py +++ b/app/infra/db/timescaledb/repositories/scada.py @@ -9,6 +9,11 @@ class ScadaRepository: VALUE_FIELDS = frozenset({"monitored_value", "cleaned_value"}) RESULT_COLUMNS = "time, device_id, monitored_value, cleaned_value" + @staticmethod + async def begin_replay_baseline_snapshot(conn: AsyncConnection) -> None: + """Set the caller's source transaction to a repeatable read-only snapshot.""" + await conn.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY") + @staticmethod async def get_warmup_values( conn: AsyncConnection, diff --git a/app/infra/integrations/operational_actions.py b/app/infra/integrations/operational_actions.py index e38c343..d6aae11 100644 --- a/app/infra/integrations/operational_actions.py +++ b/app/infra/integrations/operational_actions.py @@ -194,6 +194,7 @@ class HttpOperationalActionDispatcher: raise ValueError("invalid external project or timestamp") receipt = ActionReceipt( adapter="peripheral_sample", receipt_id=str(order.work_order_id), + # This is the creation action's completion, not field repair. submitted_at=order.created_at, completed_at=order.created_at, external_status=order.status, detail="外围工单已创建,现场工作进度由外围系统继续同步", diff --git a/app/mcp/analysis_models.py b/app/mcp/analysis_models.py index cc18a7f..2db6a4f 100644 --- a/app/mcp/analysis_models.py +++ b/app/mcp/analysis_models.py @@ -15,7 +15,7 @@ MAX_REQUEST_TIMEOUT_SECONDS = 240 class CalculationInput(StrictInput): timeout_seconds: int = Field( default=120, ge=1, le=MAX_REQUEST_TIMEOUT_SECONDS, strict=True, - description="本次HTTP请求响应等待预算,秒,默认120秒;不是后台计算执行时限,受工具目录中的请求上限约束;后台任务通过状态和结果工具查询", + description="本次HTTP请求响应等待预算,秒,默认120秒;后台执行时限由作业系统管理,受工具目录中的请求上限约束;后台任务通过状态和结果工具查询", ) @@ -28,13 +28,13 @@ class NodeCoverageGroup(BaseModel): class NodeCoverageInput(CalculationInput): groups: list[NodeCoverageGroup] = Field(min_length=1, max_length=8, - description="已核实的模型节点集合;各组为需要比较的业务或空间集合,可重叠,不接受任意坐标") + description="已核实的模型节点集合;各组为需要比较的业务或空间集合,可重叠,位置通过模型节点关联获取") concave_ratio: float = Field(default=0.25, ge=0, le=1, allow_inf_nan=False, description="凹包比例;越接近1越平滑接近凸包,参数只决定示意形状") buffer_m: float = Field(default=25, gt=0, le=1000, allow_inf_nan=False, - description="展示缓冲距离,米;不是服务半径或影响距离") + description="展示缓冲距离,米;用于几何展示") split_by_connectivity: bool = Field(default=False, strict=True, - description="需要按模型连接分组时设true;地理分布比较默认false,不能据此认定实际供水关系") + description="需要按模型连接分组时设true;地理分布比较默认false,供水关系按拓扑核验") excluded_link_ids: list[Id] = Field(default_factory=list, max_length=200, description="连通分组时额外视为断开的管段/阀门,例如已有隔离建议中的关阀ID;仅影响绘图分组") @@ -50,7 +50,7 @@ class NodeCoverageInput(CalculationInput): class BurstDetectionInput(CalculationInput): - target_time: datetime = Field(description="带时区的侦测时刻;读取此前15天同周期压力数据,不是单小时定位") + target_time: datetime = Field(description="带时区的侦测时刻;读取此前15天同周期压力数据,返回压力异常诊断") sensor_nodes: list[Id] = Field(min_length=5, max_length=20, description="已核实的压力监测节点ID,至少5个完整传感器") sampling_interval_minutes: int = Field(default=15, ge=1, le=60) @@ -67,14 +67,14 @@ class BurstDetectionInput(CalculationInput): class BurstLocationInput(CalculationInput): data_source: Literal["monitoring", "simulation"] = "monitoring" - simulation_run_id: UUID | None = Field(default=None, description="已有分析运行UUID,不是方案名称;simulation模式只读取已有异常结果及模型基线") - burst_leakage: float = Field(gt=0, allow_inf_nan=False, description="当前爆管定位算法必需的假定漏水流量,WNTR SI单位m³/s;可用有数据和方法依据的估计或明确给定的情景假设,说明来源与不确定性,不要求现场实测确认;不能任填数值") - pressure_scada_ids: list[Id] | None = Field(default=None, min_length=1, max_length=20, description="压力监测设备关联的模型节点ID,不是设备查询编号;省略时自动读取当前项目SCADA配置中的压力测点,不需要猜测节点ID") - flow_scada_ids: list[Id] | None = Field(default=None, min_length=1, max_length=20, description="流量监测设备关联的模型要素ID,不是设备查询编号") + simulation_run_id: UUID | None = Field(default=None, description="已有分析运行UUID;simulation模式只读取已有异常结果及模型基线") + burst_leakage: float | None = Field(default=None, gt=0, allow_inf_nan=False, description="水力定位必需的假定漏水流量,WNTR SI单位m³/s;使用有依据的估计或明确给定的情景假设,保留估计方法与输入来源。仅 Server 明确开放的候选定位路径可省略,该路径直接返回候选管段与相似度;其他项目和时段省略将被拒绝") + pressure_scada_ids: list[Id] | None = Field(default=None, min_length=1, max_length=20, description="压力监测设备关联的模型节点ID,查询编号通过设备映射转换;省略时读取项目配置的压力测点") + flow_scada_ids: list[Id] | None = Field(default=None, min_length=1, max_length=20, description="流量监测设备关联的模型要素ID,由设备映射取得") scada_burst_start: datetime scada_burst_end: datetime - scada_normal_start: datetime | None = Field(default=None, description="monitoring模式正常对照窗开始,须与结束时间成对提供;均省略时用爆管窗前一天相同时段,不代表该时段已确认正常") - scada_normal_end: datetime | None = Field(default=None, description="monitoring模式正常对照窗结束,须与开始时间成对提供;simulation模式不填写,使用同时间窗模型基线") + scada_normal_start: datetime | None = Field(default=None, description="monitoring模式正常对照窗开始,须与结束时间成对提供;均省略时用爆管窗前一天相同时段,使用前须核对正常对照有效性") + scada_normal_end: datetime | None = Field(default=None, description="monitoring模式正常对照窗结束,须与开始时间成对提供;simulation模式省略此项,使用同时间窗模型基线") @model_validator(mode="after") def valid_windows(self): @@ -100,14 +100,14 @@ class BurstLocationInput(CalculationInput): class ValveIsolationInput(CalculationInput): accident_element: list[Id] = Field(min_length=1, max_length=20, description="经核实或明确作为候选的事故管段/节点ID") accident_element_type: Literal["auto", "link", "node"] = Field(default="auto", description="事故要素类型;auto优先识别管段,再识别节点;节点与管段重号时,节点分析须指定node,纯管段分析可指定link") - disabled_valves: list[Id] = Field(default_factory=list, max_length=200, description="已知无法关闭的阀门;未提供不代表现场确认全部可用") + disabled_valves: list[Id] = Field(default_factory=list, max_length=200, description="已知无法关闭的阀门;现场可操作性按执行前核验记录确认") class LeakageIdentificationInput(CalculationInput): scada_start: datetime scada_end: datetime sensor_nodes: list[Id] = Field(min_length=1, max_length=20) - q_sum: float = Field(gt=0, allow_inf_nan=False, description="有依据的总漏损流量约束,m³/s,不得使用无依据默认值") + q_sum: float = Field(gt=0, allow_inf_nan=False, description="有依据的总漏损流量约束,m³/s,保留数据来源与估算方法") start_time: float = Field(ge=0, lt=24, allow_inf_nan=False, description="模型起算小时;需与SCADA时间窗及模型时钟对齐") timestep: int = Field(default=15, ge=5, le=60, description="计算步长,分钟;需与观测采样对应") dma_count: int = Field(ge=1, le=20) @@ -129,7 +129,7 @@ class LeakageIdentificationInput(CalculationInput): class ScenarioInput(CalculationInput): start_time: datetime duration: int = Field(default=900, ge=60, le=86400, description="模拟时长,秒;首次优先短时间窗") - output_node_ids: list[Id] = Field(min_length=1, max_length=20, description="需要返回结果的节点;不把局部结果解释为全网无影响") + output_node_ids: list[Id] = Field(min_length=1, max_length=20, description="需要返回结果的节点;结论范围限定为返回对象") output_link_ids: list[Id] = Field(default_factory=list, max_length=20) @model_validator(mode="after") @@ -140,18 +140,18 @@ class ScenarioInput(CalculationInput): class ContaminantSimulationInput(ScenarioInput): - source: Id = Field(description="假设污染源节点;这是正向传播模拟,不是自动反演污染源") + source: Id = Field(description="假设污染源节点;这是正向传播模拟,结果按给定源解释") concentration: float = Field(gt=0, allow_inf_nan=False, description="假设源浓度,mg/L") - pattern: Id | None = Field(default=None, description="已存在的污染源时间模式ID;不填为持续恒定源") + pattern: Id | None = Field(default=None, description="已存在的污染源时间模式ID;省略时采用持续恒定源") class ValveClosureInput(ScenarioInput): - valves: list[Id] = Field(min_length=1, max_length=20, description="在临时模型中关闭的阀门,不下发现场控制") + valves: list[Id] = Field(min_length=1, max_length=20, description="在临时模型中关闭的阀门,现场操作另走审批流程") class FlushingInput(ScenarioInput): drainage_node_id: Id - flushing_flow_m3h: float = Field(gt=0, allow_inf_nan=False, description="明确的冲洗流量,m³/h;不使用无依据的自动排放量") + flushing_flow_m3h: float = Field(gt=0, allow_inf_nan=False, description="明确的冲洗流量,m³/h;提供流量来源与估算依据") closed_valves: list[Id] = Field(default_factory=list, max_length=20) @@ -160,7 +160,7 @@ PumpSeries = Annotated[list[NonnegativeValue], Field(min_length=1, max_length=96 class PressureRegulationInput(ScenarioInput): - pump_control: dict[Id, PumpSeries] = Field(min_length=1, max_length=20, description="已登记泵ID及运行序列;工频值为0/1,变频值为频率Hz,每项对应模型水力步长,不自动优化") + pump_control: dict[Id, PumpSeries] = Field(min_length=1, max_length=20, description="已登记泵ID及运行序列;工频值为0/1,变频值为频率Hz,每项对应模型水力步长,按给定序列评估") tank_init_level: dict[Id, Annotated[float, Field(gt=0, allow_inf_nan=False)]] = Field(default_factory=dict, max_length=20, description="水箱初始水位,须在模型允许范围内") diff --git a/app/mcp/application.py b/app/mcp/application.py index 15cd1b6..bcffd68 100644 --- a/app/mcp/application.py +++ b/app/mcp/application.py @@ -32,6 +32,8 @@ from app.mcp.catalog import TOOLS from app.mcp.config import McpSettings from app.mcp.contexts import resolve_context, verify_task from app.mcp.models import QueryResult +from app.mcp.display_time import display_query_result, display_time_text +from app.mcp.scada_catalog import compact_scada_catalog from app.mcp.errors import CalculationCapacityExceeded from app.mcp.scope import project_scope from app.mcp.query_service import execute_query, task_service, authorize_query @@ -43,6 +45,7 @@ logger = logging.getLogger(__name__) def tool_error(code, message): + message = display_time_text(message) return types.CallToolResult( isError=True, content=[types.TextContent(type="text", text=message)], @@ -358,7 +361,8 @@ class McpQueryApplication: result = QueryResult.model_validate(previous["result"]) else: from app.mcp.calculation_jobs import should_queue, submit_calculation - if should_queue(tool, args): + queued = should_queue(tool, args, project_code=getattr(scope.project, "project_code", None)) + if queued: result = await submit_calculation( scope, binding, tool, args, call_id, job_timeout_seconds=self.settings.max_calculation_timeout_seconds, @@ -377,7 +381,7 @@ class McpQueryApplication: "RESULT_TOO_LARGE", "Narrow the query or request a smaller page", ) - if binding.task_id and not should_queue(tool, args) and name not in { + if binding.task_id and not queued and name not in { "get_calculation_job", "get_calculation_result", "cancel_calculation_job", "list_calculation_jobs", "get_task_context", "get_task_evidence", @@ -422,9 +426,14 @@ class McpQueryApplication: parameters, result.model_dump(mode="json"), ) - output = result.model_dump(mode="json") + output = display_query_result(result.model_dump(mode="json")) content = [] from app.mcp.extended_service import ARTIFACT_TOOLS + if (not include_full and name == "list_scada_devices" + and len(json.dumps(output, ensure_ascii=False).encode()) > 12000): + preview = compact_scada_catalog(output, budget=12000) + if preview is not None: + output = preview if ( not include_full and name not in ARTIFACT_TOOLS @@ -444,7 +453,7 @@ class McpQueryApplication: from app.mcp.presentation_preview import saved_presentation_preview presentation = saved_presentation_preview(result) if presentation is not None: - output["data"]["presentation"] = presentation + output["data"]["presentation"] = display_query_result(presentation) output["truncated"] = True output["data_gaps"] = [ *result.data_gaps, diff --git a/app/mcp/calculation_jobs.py b/app/mcp/calculation_jobs.py index 3dc1dfb..aeaf0ea 100644 --- a/app/mcp/calculation_jobs.py +++ b/app/mcp/calculation_jobs.py @@ -29,7 +29,11 @@ def restore_binding(document: dict) -> BoundContext: return BoundContext.model_validate({**document, "context_handle": "durable-calculation", "expires_at": datetime.max.replace(tzinfo=timezone.utc)}) -def should_queue(tool, args) -> bool: +def should_queue(tool, args, *, project_code: str | None = None) -> bool: + if tool.name == "run_burst_location": + from app.services.burst_location_demo import is_location_demo + if is_location_demo(project_code, args): + return False return tool.name in QUEUED_TOOLS and (not tool.mutating or not args.dry_run) @@ -39,7 +43,7 @@ def envelope(source: str, data, *, job_id="list", page=None) -> QueryResult: def public_job(row: dict) -> dict: - fields = {"job_id", "status", "tool", "created_at", "started_at", "finished_at", "updated_at", "error", "queue_expires_at", "result_expires_at"} + fields = {"job_id", "status", "tool", "created_at", "started_at", "finished_at", "updated_at", "error", "result_expires_at"} from fastapi.encoders import jsonable_encoder expiry = row.get("result_expires_at") available = row.get("status") == "succeeded" and (expiry is None or expiry > datetime.now(timezone.utc)) diff --git a/app/mcp/catalog.py b/app/mcp/catalog.py index c9263d1..17c5f03 100644 --- a/app/mcp/catalog.py +++ b/app/mcp/catalog.py @@ -23,22 +23,27 @@ class QueryTool: def protocol_tool(self) -> types.Tool: if self.mutating: - suffix = "。默认dry_run只预览;执行必须携带预览摘要、相同request_id并通过宿主原生许可。仅项目普通聊天可用,后台调查和reader禁止;失败或超时先核实状态,不自动重试。" + suffix = "。默认dry_run只预览;执行必须携带预览摘要、相同request_id并通过宿主原生许可。由项目成员的普通聊天执行写入;失败或超时先核实状态,再按原请求接续。" elif self.name == "run_simulation": suffix = "。任务调用复用证据留存。" elif self.computation: - suffix = "。仅计算建议,不保存方案、不控制设备;任务调用复用证据留存。" + suffix = "。返回分析建议并留存任务证据;正式方案与现场操作按各自业务流程执行。" elif self.name == "query_nearby_elements": - suffix = "。仅查询当前已授权项目的模型管网,按距离和元素ID稳定排序;按需分页,不自动遍历全量;空间邻近不代表管网连通或供水关系。" + suffix = "。仅查询当前已授权项目的模型管网,按距离和元素ID稳定排序;按核查目标分页;连接与供水关系由拓扑分析核验。" + elif self.name == "list_scada_devices": + suffix = ( + "。默认每页100个,最多500个;page.has_more=true时继续递增offset读取后续页面。" + "大目录内联保留本页全部设备的编号、类型和单位,模型映射及坐标用get_scada_device核验。" + ) else: suffix = ( "。先限定对象和时间范围,再读取明细;集合默认20条,按需逐页扩大," - "不要自动遍历全量;截断或非零offset不能证明完整范围无异常。" + "按当前目标读取必要页面,并说明截断和分页覆盖范围。" ) return types.Tool( name=self.name, description=self.description + suffix + ( - "。执行返回持久job_id;排队回执不是分析证据,使用get_calculation_job查询状态、get_calculation_result分页读取成功结果,不重复提交。" + "。返回queued/running和job_id时,使用get_calculation_job查询状态、get_calculation_result读取成功结果,排队回执用于跟踪;直接返回分析结果时立即读取。" if self.name in {"run_burst_location", "run_leakage_identification", "run_burst_detection", "run_simulation", "run_burst_simulation", "run_contaminant_simulation", "analyze_valve_closure", "analyze_flushing", "analyze_pressure_regulation", "optimize_sensor_placement", "save_simulation_scenario", "create_sensor_placement"} else "" ), @@ -51,17 +56,17 @@ class QueryTool: _specs = [ - ("run_simulation", "按水力步长在在线业务模型上执行指定时间段的实时模拟,不复制数据库;更新该时段的模型条件及实时基线,返回指定节点和管段结果;duration单位为秒,不创建方案或正式分析运行,结果不是实测", ScenarioInput, "simulation.run", "analysis", False, True), - ("run_contaminant_simulation", "在隔离模型中模拟假设污染源的浓度传播;返回指定节点/管段结果,不反演污染源,不代表实测污染范围", ContaminantSimulationInput, "simulation.run", "analysis", False, True), - ("analyze_valve_closure", "在隔离模型中模拟关阀的水力影响,返回指定节点/管段结果;不操作真实阀门", ValveClosureInput, "simulation.run", "analysis", False, True), - ("analyze_flushing", "在隔离模型中评估指定排放节点、冲洗流量和关阀组合的水力结果;不是现场冲洗或消毒合格证明", FlushingInput, "simulation.run", "analysis", False, True), - ("analyze_pressure_regulation", "在隔离模型中评估给定调泵序列和水箱初始水位的压力影响;不自动优化调度,不实际控制泵", PressureRegulationInput, "simulation.run", "analysis", False, True), - ("optimize_sensor_placement", "使用灵敏度或K-means算法推荐压力监测点;仅返回候选节点,不保存正式方案,不保证污染溯源可观测性", SensorPlacementInput, "optimization.run", "analysis", False, True), - ("run_leakage_identification", "基于SCADA压力与模型进行DMA漏损分配识别,需有依据的总漏损流量和时间对齐;返回区域估计,不是已确认漏点。内部执行遗传算法与水力计算", LeakageIdentificationInput, "burst.run", "analysis", False, True), - ("run_burst_detection", "爆管异常侦测:使用至少5个完整压力传感器此前15天数据;只提示压力异常,不定位、不证明爆管", BurstDetectionInput, "burst.run", "analysis", False, True), - ("run_burst_location", "爆管候选定位:对比异常与正常压力,可辅助流量;burst_leakage必需,可使用有依据的估计或明确给定的情景假设,不要求现场确诊;本工具不反演总漏水量。压力节点可省略,自动读取项目SCADA压力测点;monitoring正常窗可成对省略,默认前一天同时间段,须说明该对照假设。simulation须有已有分析运行UUID并读取同窗模型基线,不是实测;定位内部会执行水力计算,不创建新的事件方案", BurstLocationInput, "burst.run", "analysis", False, True), - ("analyze_valve_isolation", "按事故候选要素分析拓扑隔离边界、建议关闭阀门和受影响节点;候选分析无需等待现场确诊。输出不是污染扩散或水力影响范围;现场操作前须核实阀门映射及可用性,本工具不执行关阀", ValveIsolationInput, "simulation.run", "analysis", False, True), - ("generate_node_coverage", "为当前项目已核实的模型节点集合生成空间覆盖示意,适用于候选分布、监测覆盖比较、区域成员、隔离或模拟结果节点等,不限调查任务。支持最多8组、合计10000个不同节点,从真实GIS查询坐标;监测设备、投诉或地址须先关联模型节点。可按连接分组,记录凹包/缓冲参数、缺失节点和display_only;不计算故障、污染、实际停水或服务边界。返回result.map_payload及coverage,任务中用set_overlay引用本次evidence_id、省略data展示;生成不自动操作地图", NodeCoverageInput, "webgis.view", "analysis", False, True), + ("run_simulation", "按水力步长在在线业务模型上执行指定时间段的实时模拟;更新该时段的模型条件及实时基线,返回指定节点和管段结果;duration单位为秒,输出为在线模型结果,正式分析成果通过方案工具保存", ScenarioInput, "simulation.run", "analysis", False, True), + ("run_contaminant_simulation", "在隔离模型中模拟假设污染源的浓度传播;返回指定节点/管段结果,结果标注假设来源及模型预测范围", ContaminantSimulationInput, "simulation.run", "analysis", False, True), + ("analyze_valve_closure", "在隔离模型中模拟关阀的水力影响,返回指定节点/管段结果;现场阀门操作按业务审批执行", ValveClosureInput, "simulation.run", "analysis", False, True), + ("analyze_flushing", "在隔离模型中评估指定排放节点、冲洗流量和关阀组合的水力结果;现场效果由执行回执和复测核验", FlushingInput, "simulation.run", "analysis", False, True), + ("analyze_pressure_regulation", "在隔离模型中评估给定调泵序列和水箱初始水位的压力影响;输出为给定方案评估,现场调度按审批执行", PressureRegulationInput, "simulation.run", "analysis", False, True), + ("optimize_sensor_placement", "使用灵敏度或K-means算法推荐压力监测点;返回候选节点,正式部署另行保存并核验观测能力", SensorPlacementInput, "optimization.run", "analysis", False, True), + ("run_leakage_identification", "基于SCADA压力与模型进行DMA漏损分配识别,需有依据的总漏损流量和时间对齐;返回待现场核验的区域估计。内部执行遗传算法与水力计算", LeakageIdentificationInput, "burst.run", "analysis", False, True), + ("run_burst_detection", "爆管异常侦测:使用至少5个完整压力传感器此前15天数据;返回压力异常信号,候选管段由定位工具分析", BurstDetectionInput, "burst.run", "analysis", False, True), + ("run_burst_location", "爆管候选定位:对比异常与正常压力,可辅助流量;依据现有异常形成候选,水力算法使用有依据的burst_leakage作为输入。任务上下文location_demo.available=true时可省略漏量,Server直接返回候选管段与相似度,随后继续地图展示、隔离边界与影响分析。压力节点可省略,自动读取项目配置;monitoring正常窗省略时默认前一天同时间段,须说明假设;回放必须显式提供冻结正常窗。simulation须有已有分析运行UUID及同窗模型基线,结果标注模型来源", BurstLocationInput, "burst.run", "analysis", False, True), + ("analyze_valve_isolation", "按事故候选要素分析拓扑隔离边界、建议关闭阀门和受影响节点;候选分析无需等待现场确诊。输出为拓扑隔离与供水失源范围;水力影响另用关阀模拟评估,现场操作前核实阀门映射及可用性", ValveIsolationInput, "simulation.run", "analysis", False, True), + ("generate_node_coverage", "为当前项目已核实的模型节点集合生成空间覆盖示意,适用于候选分布、监测覆盖比较、区域成员、隔离或模拟结果节点等,支持各类项目分析。支持最多8组、合计10000个唯一节点,从真实GIS查询坐标;监测设备、投诉或地址须先关联模型节点。可按连接分组,记录凹包/缓冲参数、缺失节点和display_only;结果为节点空间覆盖示意,保留分组及几何覆盖范围。返回result.map_payload及coverage,任务中用set_overlay引用本次evidence_id、省略data展示;地图通过展示工具发布", NodeCoverageInput, "webgis.view", "analysis", False, True), ( "get_element_properties", "读取当前项目的单个管网元素属性", @@ -71,14 +76,14 @@ _specs = [ ), ( "list_elements", - "分页读取管网元素,不自动读取全网", + "按查询目标分页读取管网元素", m.ElementPageInput, "webgis.view", "asset", ), ( "query_nearby_elements", - "查询坐标附近的模型元素,默认pipe;center为EPSG:4326 [经度,纬度],可直接使用geocode.center;radius_m默认200米、最大10000米,limit默认5、最大100,支持offset。按点到完整几何的WGS84椭球最短距离筛选并排序,返回element_id、distance_m和属性;不查询原始GIS管线、不自动扩大半径、不解析道路交叉点", + "查询坐标附近的模型元素,默认pipe;center为EPSG:4326 [经度,纬度],可直接使用geocode.center;radius_m默认200米、最大10000米,limit默认5、最大100,支持offset。按点到完整几何的WGS84椭球最短距离筛选并排序,返回element_id、distance_m和属性;查询范围为当前模型管网;原始GIS、范围调整与歧义地点按各自入口核验", m.NearbyElementsInput, "webgis.view", "asset", @@ -100,7 +105,7 @@ _specs = [ ( "list_scada_devices", "分页读取SCADA设备和物理单位", - m.PageInput, + m.ScadaDeviceListInput, "scada.view", "asset", ), @@ -114,7 +119,7 @@ _specs = [ ), ( "query_model_baseline_series", - "读取最多20个元素的模型基线,不能称为实测", + "读取最多20个元素的模型基线,结果标注模型来源", m.BaselineSeriesInput, "simulation.view", "model_baseline", @@ -230,40 +235,40 @@ _specs = [ ] _specs += [ ("list_regions", "分页查询当前项目区域/DMA列表", m.PageInput, "webgis.view", "asset"), - ("get_region", "查询当前项目区域边界,返回EPSG:4326;不等于供水拓扑", e.RegionInput, "webgis.view", "asset"), + ("get_region", "查询当前项目区域边界,返回EPSG:4326几何;供水关系按拓扑核验", e.RegionInput, "webgis.view", "asset"), ("get_region_nodes", "分页查询区域配置的成员节点,未配置时使用区域几何筛选", e.RegionInput, "webgis.view", "asset"), - ("get_node_connections", "分页查询节点直接连接的管线,不自动遍历全网", e.NodeConnectionsInput, "webgis.view", "asset"), - ("query_network_extent", "按EPSG:4326矩形范围分页查询当前项目模型几何;无GIS配置不跨项目回退", e.ExtentInput, "webgis.view", "asset"), + ("get_node_connections", "按目标节点分页查询直接连接的管线", e.NodeConnectionsInput, "webgis.view", "asset"), + ("query_network_extent", "按EPSG:4326矩形范围分页查询当前项目模型几何;GIS缺失时返回当前项目的具体缺口", e.ExtentInput, "webgis.view", "asset"), ("list_major_nodes", "按关联管道最小管径查询骨干节点,经纬度EPSG:4326", e.MajorNodesInput, "webgis.view", "asset"), ("get_model_configuration", "读取模式、曲线、控制规则、需水量、水质及反应等模型配置;集合按页返回", e.ModelConfigurationInput, "webgis.view", "asset"), ("list_model_configurations", "分页读取模式或曲线ID", e.ConfigurationListInput, "webgis.view", "asset"), ("search_project_places", "按项目配置的行政区搜索地名,最多10个候选,依赖天地图配置", e.PlaceSearchInput, "webgis.view", "asset"), ("reverse_geocode", "把EPSG:4326地图取点转为项目范围内地名,保留输入坐标", e.ReverseGeocodeInput, "webgis.view", "asset"), - ("get_project_info", "读取当前项目概况与地图范围,不返回数据库路由或凭据", m.StrictInput, "webgis.view", "asset"), + ("get_project_info", "读取当前项目概况与地图范围,公开字段限于项目业务概况", m.StrictInput, "webgis.view", "asset"), ("export_project_model", "从当前项目数据库导出INP或ChangeSet压缩文件,由宿主保存到会话文件", e.ProjectExportInput, "webgis.view", "asset"), ("get_sensor_placement_candidate", "读取监测点候选节点工程属性", e.SensorCandidateInput, "optimization.run", "asset"), ("export_sensor_placement", "导出已有监测点方案的Excel工程清单,由宿主保存到会话文件", e.SensorExportInput, "optimization.run", "analysis"), - ("list_task_schedules", "分页读取当前项目定时任务配置,不启动调度", m.PageInput, "operational_task.view", "task"), + ("list_task_schedules", "分页读取当前项目定时任务配置,执行状态由调度系统管理", m.PageInput, "operational_task.view", "task"), ("get_task_run_history", "分页读取指定项目任务的运行历史", e.TaskHistoryInput, "operational_task.view", "task"), - ("get_mutation_status", "读取当前身份和会话内的写入执行状态;started只表示已启动,超时后须核实目标数据,不自动重试", e.MutationStatusInput, "webgis.view", "task"), - ("calculate_demand_allocation", "按管长权重分配总需水量到节点、区域或全网,只计算不修改模型,按页返回", e.DemandAllocationInput, "webgis.view", "analysis", False, True), - ("run_burst_simulation", "在隔离模型中模拟假设管道破口面积的水力影响;不是爆管侦测或定位,不保存正式方案", e.BurstSimulationInput, "simulation.run", "analysis", False, True), - ("edit_model_element", "预览或增删改模型节点、管段、泵阀、水池和区域;仅修改模型,不控制现场设备", e.ModelElementMutationInput, "webgis.edit", "asset", False, False, True), + ("get_mutation_status", "读取当前身份和会话内的写入执行状态;started只表示已启动,超时后先核实目标数据,再按原请求接续", e.MutationStatusInput, "webgis.view", "task"), + ("calculate_demand_allocation", "按管长权重分配总需水量到节点、区域或全网,返回分配建议,模型变更通过业务写入执行,按页返回", e.DemandAllocationInput, "webgis.view", "analysis", False, True), + ("run_burst_simulation", "在隔离模型中模拟假设管道破口面积的水力影响;返回给定情景的模拟结果,正式方案通过方案工具保存", e.BurstSimulationInput, "simulation.run", "analysis", False, True), + ("edit_model_element", "预览或增删改模型节点、管段、泵阀、水池和区域;变更范围为业务模型,现场设备操作按对应流程执行", e.ModelElementMutationInput, "webgis.edit", "asset", False, False, True), ("edit_model_configuration", "预览或修改模型需水量、模式、曲线、控制规则、水质及模拟选项", e.ConfigurationMutationInput, "webgis.edit", "asset", False, False, True), ("clean_scada_readings", "预览或清洗明确设备与24小时内时间窗的数据,写入cleaned_value", e.ScadaCleaningInput, "scada.clean", "observed", False, False, True), - ("write_scada_readings", "预览或补录/修正SCADA数据,要求真实来源依据;不把模拟当实测", e.ScadaWriteInput, "scada.clean", "observed", False, False, True), + ("write_scada_readings", "预览或补录/修正SCADA数据,要求真实来源依据;观测来源按真实记录核验", e.ScadaWriteInput, "scada.clean", "observed", False, False, True), ("delete_scada_readings", "预览或删除明确设备与24小时内时间窗的SCADA记录", e.ScadaDeleteInput, "scada.clean", "observed", False, False, True), - ("save_simulation_scenario", "预览或重新计算并保存正式隔离场景成果;有真实计算开销,不保存模型生成的观测数组", e.SavedScenarioInput, "simulation.run", "analysis", False, False, True), + ("save_simulation_scenario", "预览或重新计算并保存正式隔离场景成果;有真实计算开销,观测数据按真实来源记录", e.SavedScenarioInput, "simulation.run", "analysis", False, False, True), ("create_sensor_placement", "预览或计算并保存正式压力监测点选址方案", e.SensorCreateInput, "optimization.run", "analysis", False, False, True), ("update_sensor_placement", "预览或修改本人有权编辑的监测点方案,保留旧点位乐观校验", e.SensorUpdateInput, "optimization.run", "analysis", False, False, True), - ("create_operational_task", "预览或创建项目任务,不伪造Agent运行完成", e.TaskCreateInput, "operational_task.write", "task", False, False, True), + ("create_operational_task", "预览或创建项目任务,Agent运行终态由执行系统管理", e.TaskCreateInput, "operational_task.write", "task", False, False, True), ("create_task_schedule", "预览或创建定时任务配置", e.ScheduleCreateInput, "operational_task.write", "task", False, False, True), ("update_task_schedule", "预览或启停定时任务配置,校验版本", e.ScheduleUpdateInput, "operational_task.write", "task", False, False, True), - ("retry_operational_task", "预览或重新入队已终止任务;不自动启动新Agent会话", e.TaskRetryInput, "operational_task.write", "task", False, False, True), + ("retry_operational_task", "预览或重新入队已终止任务;后续会话由任务调度系统创建", e.TaskRetryInput, "operational_task.write", "task", False, False, True), ("cancel_task_run", "预览或取消指定任务运行,校验运行版本", e.TaskCancelInput, "operational_task.write", "task", False, False, True), ] _specs += [ - ("get_calculation_job", "读取当前身份与会话的持久计算状态;排队回执不是分析证据", jobs.CalculationJobInput, "webgis.view", "task"), + ("get_calculation_job", "读取当前身份与会话的持久计算状态;排队回执用于跟踪,分析依据读取成功结果", jobs.CalculationJobInput, "webgis.view", "task"), ("get_calculation_result", "读取成功计算的原始结果;data_path选择嵌套集合,limit/offset按需分页", jobs.CalculationResultInput, "webgis.view", "task"), ("cancel_calculation_job", "请求取消本人当前会话的计算;运行中取消由独立计算进程处理,须继续核实最终状态", jobs.CalculationJobInput, "webgis.view", "task"), ("list_calculation_jobs", "分页读取本人当前会话及任务范围的计算状态", jobs.CalculationJobsInput, "webgis.view", "task"), diff --git a/app/mcp/contexts.py b/app/mcp/contexts.py index 0ab3141..c82a7a4 100644 --- a/app/mcp/contexts.py +++ b/app/mcp/contexts.py @@ -15,6 +15,7 @@ from app.mcp.models import BindInput, BoundContext from app.mcp.scope import QueryScope from app.mcp.config import McpSettings from mcp.server.auth.provider import AccessToken +from app.mcp.work_order_skills import skill_name_for_task def task_repository(scope: QueryScope) -> OperationalTaskRepository: @@ -31,6 +32,8 @@ async def verify_task(scope: QueryScope, binding: BoundContext) -> None: if not task: raise HTTPException(404, "Task is not in this project") 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": return if OPERATIONAL_TASK_EXECUTE not in permissions: diff --git a/app/mcp/display_time.py b/app/mcp/display_time.py new file mode 100644 index 0000000..dbecfac --- /dev/null +++ b/app/mcp/display_time.py @@ -0,0 +1,63 @@ +"""Project model-facing timestamps after saving the original query evidence.""" +from datetime import datetime, timedelta, timezone +import re +from typing import Any + +DISPLAY_TIME_ZONE = "Asia/Shanghai" +DISPLAY_OFFSET = timezone(timedelta(hours=8)) +_INSTANT = re.compile( + r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?P\.\d+)?(?:Z|[+-]\d{2}:\d{2})" +) +_PROSE_FIELDS = frozenset({ + "title", "summary", "detail", "description", "notice", "label", "message", + "current_conclusion", "conclusion_boundary", "recommendations", "pending_items", + "missing_data", "required_data", "impact", "scope", "fallback_text", "text", +}) +_OPAQUE_FIELDS = frozenset({"id", "cursor", "url", "uri", "path", "sha256", "instructions", "code", "sql"}) +_OPAQUE_SUFFIXES = ("_id", "_ids", "_ref", "_refs", "_cursor", "_digest", "_sha256", "_url", "_uri", "_path") + + +def _opaque(key: str) -> bool: + return key in _OPAQUE_FIELDS or key.endswith(_OPAQUE_SUFFIXES) + + +def _display_instant(value: str) -> str: + match = _INSTANT.fullmatch(value) + if match is None: + return value + try: + local = datetime.fromisoformat(value).astimezone(DISPLAY_OFFSET) + except (ValueError, OverflowError): + return value + fraction = match.group("fraction") or "" + return (f"{local.year:04d}-{local.month:02d}-{local.day:02d}T" + f"{local.hour:02d}:{local.minute:02d}:{local.second:02d}{fraction}+08:00") + + +def display_time_text(value: str) -> str: + """Convert explicit instants in prose while retaining links and quoted code.""" + parts = re.split(r"(https?://\S+|tjwater://\S+|`[^`]*`)", value) + for index in range(0, len(parts), 2): + parts[index] = re.sub( + r"(? Any: + """Keep instants, fractions and identifiers intact; change display offsets.""" + if _opaque(field): + return value + if isinstance(value, dict): + path = value.get("selected_path") or [] + opaque_selection = isinstance(path, list) and bool(path) and _opaque(str(path[-1])) + return {key: item if key == "value" and opaque_selection else display_query_result(item, field=key) + for key, item in value.items()} + if isinstance(value, list): + return [display_query_result(item, field=field) for item in value] + if isinstance(value, str): + if field in _PROSE_FIELDS: + return display_time_text(value) + return _display_instant(value) if len(value) <= 128 else value + return value diff --git a/app/mcp/extended_models.py b/app/mcp/extended_models.py index 0a9419c..f088ede 100644 --- a/app/mcp/extended_models.py +++ b/app/mcp/extended_models.py @@ -28,7 +28,7 @@ class NodeConnectionsInput(PageInput): class ExtentInput(PageInput): element_type: Literal["junction", "pipe", "valve", "pump", "tank", "reservoir"] = "pipe" - bounds: tuple[float, float, float, float] = Field(description="EPSG:4326 [west,south,east,north]; 不跨180度经线") + bounds: tuple[float, float, float, float] = Field(description="EPSG:4326 [west,south,east,north];经度范围满足west<=east") @model_validator(mode="after") def valid_bounds(self): @@ -117,7 +117,7 @@ class DemandAllocationInput(CalculationInput): class BurstSimulationInput(ScenarioInput): burst_pipe_ids: list[Id] = Field(min_length=1, max_length=20) - orifice_areas_cm2: list[Annotated[float, Field(gt=0, allow_inf_nan=False)]] = Field(min_length=1, max_length=20, description="破口面积cm²,不是流量;必须有依据") + orifice_areas_cm2: list[Annotated[float, Field(gt=0, allow_inf_nan=False)]] = Field(min_length=1, max_length=20, description="破口面积,单位cm²;保留面积来源及估算依据") @model_validator(mode="after") def paired_bursts(self): @@ -177,7 +177,7 @@ class ConfigurationMutationInput(MutationInput): class ScadaCleaningInput(MutationInput): start: datetime end: datetime - device_ids: list[Id] = Field(min_length=1, max_length=20, description="必须显式列出设备;不支持all") + device_ids: list[Id] = Field(min_length=1, max_length=20, description="显式列出本次处理的实际设备ID") @model_validator(mode="after") def valid_window(self): @@ -205,7 +205,7 @@ class ScadaRecord(BaseModel): class ScadaWriteInput(MutationInput): operation: Literal["insert", "update"] records: list[ScadaRecord] = Field(min_length=1, max_length=500) - provenance: str = Field(min_length=1, max_length=2000, description="补录/修正的真实来源与依据,不得把模型值冒充实测") + provenance: str = Field(min_length=1, max_length=2000, description="补录/修正观测的真实来源与依据,模型结果按其来源另行记录") @model_validator(mode="after") def bounded_records(self): diff --git a/app/mcp/models.py b/app/mcp/models.py index 2b6ea23..37ac779 100644 --- a/app/mcp/models.py +++ b/app/mcp/models.py @@ -21,6 +21,10 @@ class PageInput(StrictInput): offset: int = Field(default=0, ge=0, le=10000) +class ScadaDeviceListInput(PageInput): + limit: int = Field(default=100, ge=1, le=500) + + class TaskContextInput(PageInput): section: Literal["summary", "task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill", "questions"] = "summary" report_revision: int | None = Field(default=None, ge=1) @@ -255,6 +259,7 @@ class PageInfo(BaseModel): class QueryResult(BaseModel): model_config = ConfigDict(extra="forbid") + display_time_zone: Literal["Asia/Shanghai"] = "Asia/Shanghai" source: str source_ref: str queried_at: datetime diff --git a/app/mcp/query_service.py b/app/mcp/query_service.py index 23dcde7..217e160 100644 --- a/app/mcp/query_service.py +++ b/app/mcp/query_service.py @@ -20,6 +20,7 @@ from app.infra.db.postgresql.gis_attributes import GisAttributeRepository from app.infra.db.postgresql.network_settings import NetworkSettingsRepository from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository from app.services.operational_tasks import OperationalTaskService +from app.services.burst_location_demo import location_demo_result from app.mcp.models import QueryResult, PageInfo from app.mcp.scenario_service import SCENARIO_TOOLS from app.mcp.work_order_skills import read_work_order_skill @@ -73,9 +74,13 @@ async def authorize_query( from app.services.scada_replay_queries import check_replay_window allowed = {"get_task_context", "get_task_evidence", "list_scada_devices", "get_scada_device", "query_scada_readings", "get_network_schema", "run_burst_location", + "get_element_properties", "list_elements", "query_nearby_elements", "get_network_options", + "get_node_connections", "get_element_gis_properties", "query_network_extent", + "list_regions", "get_region", "get_region_nodes", + "analyze_valve_isolation", "generate_node_coverage", "get_calculation_job", "get_calculation_result", "list_calculation_jobs"} if tool.name not in allowed: - raise HTTPException(403, "回放调查当前仅开放冻结 SCADA 证据与模型目录查询;请使用 query_scada_readings") + raise HTTPException(403, "回放调查仅开放冻结 SCADA、资产与拓扑分析;时序观测请使用 query_scada_readings") if tool.name == "query_scada_readings": check_replay_window(replay_context, args.start, args.end) if tool.name == "run_burst_location": @@ -126,6 +131,11 @@ async def authorize_query( granted = await task_service(scope).can_read_source(binding.task_id, source) if not granted: raise HTTPException(403, "Data source permission is required") + if tool.name == "run_burst_location" and args.burst_leakage is None: + from app.services.burst_location_demo import is_location_demo + + if not is_location_demo(getattr(scope.project, "project_code", None), args): + raise HTTPException(422, "水力定位需要有依据的 burst_leakage;仅已开放的候选定位路径可省略漏量") if ( tool.name == "query_element_history" and args.mode != "observed" @@ -179,13 +189,16 @@ async def execute_query( parameters = await replay_burst_location_inputs( conn, scope.project.project_id, replay_context, args, ) + result = await location_demo_result(conn, scope.project.project_code, args) + used_demo = result is not None await conn.commit() - async with scope.simulation_routing(): - result = await calculate_replay_burst_location( - parameters, - network=scope.project.project_code, - username=scope.user.username, - ) + if result is None: + async with scope.simulation_routing(): + result = await calculate_replay_burst_location( + parameters, + network=scope.project.project_code, + username=scope.user.username, + ) data = { "parameters": args.model_dump(mode="json", exclude={"context_handle"}), "result": result, @@ -194,6 +207,15 @@ async def execute_query( gaps = [ "定位只使用任务知识截止前已固化的回放清洗输出;候选排序依赖漏量假设、模型和测点覆盖,仍需现场核实。" ] + if used_demo: + gaps = [result["notice"]] if result.get("notice") else [] + elif name == "run_burst_location" and ( + demo_result := await location_demo_result(conn, scope.project.project_code, args) + ) is not None: + data = {"parameters": args.model_dump(mode="json", exclude={"context_handle"}), + "result": demo_result} + origin = "synthetic" + gaps = [demo_result["notice"]] if demo_result.get("notice") else [] elif tool.computation: from app.mcp.analysis_service import TIMESCALE_CALCULATIONS, calculate @@ -449,7 +471,8 @@ async def execute_query( data = await reads.task_context_summary(conn, project_id=binding.project_id, task_id=binding.task_id, report_revision=args.report_revision) skill = read_work_order_skill( - data.get("task", {}).get("workflow"), include_content=args.section == "skill" + data.get("task", {}).get("workflow"), include_content=args.section == "skill", + replay_context=replay_context, ) if args.section == "skill": data = {"section": "skill", "skill": skill} @@ -461,6 +484,13 @@ async def execute_query( data["available_sections"] = ["task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill", "questions"] truncated = True gaps.append("Task context is a bounded summary; use section, data_path, limit and offset for detail. Previews are not complete reports.") + if (skill.get("name") == "scada-anomaly" and replay_context + and "run_burst_location" in binding.tool_cap): + from app.services.burst_location_demo import location_demo_context + + data["location_demo"] = location_demo_context( + getattr(scope.project, "project_code", None), replay_context, + ) else: projection = await reads.task_context_page(conn, project_id=binding.project_id, task_id=binding.task_id, section=args.section, report_revision=args.report_revision, data_path=args.data_path, limit=args.limit, offset=args.offset) diff --git a/app/mcp/scada_catalog.py b/app/mcp/scada_catalog.py new file mode 100644 index 0000000..bab125f --- /dev/null +++ b/app/mcp/scada_catalog.py @@ -0,0 +1,28 @@ +"""Keep a full page of device identities visible within the inline response budget.""" + +import json +from typing import Any + + +def compact_scada_catalog(output: dict[str, Any], budget: int) -> dict[str, Any] | None: + """Project attributes, never rows; full task evidence is persisted before this step.""" + rows = output.get("data") + if output.get("source") != "list_scada_devices" or not isinstance(rows, list): + return None + if not all(isinstance(row, dict) and "device_id" in row for row in rows): + return None + fields = ("device_id", "device_type", "measurement_unit") + preview = { + **output, + "data": [{key: row[key] for key in fields if key in row} for row in rows], + "truncated": True, + "data_gaps": [ + *output.get("data_gaps", []), + "All devices in this page are listed with IDs, types and units; " + "other attributes are omitted. Use get_scada_device for model mapping, " + "coordinates and other device details. Use page.has_more to check for more devices.", + ], + } + if len(json.dumps(preview, ensure_ascii=False).encode()) > budget: + return None + return preview diff --git a/app/mcp/work_order_skills.py b/app/mcp/work_order_skills.py index 237c1ad..0007a52 100644 --- a/app/mcp/work_order_skills.py +++ b/app/mcp/work_order_skills.py @@ -10,15 +10,23 @@ import yaml SKILL_ROOT = Path(__file__).resolve().parents[2] / "resources" / "work-order-skills" WORKFLOW_SKILLS = { "scada_anomaly": "scada-anomaly", - "burst_report": "burst-report", + "burst_report": "burst-event-handling", "water_quality_complaint": "water-quality-complaint", "general": "appointment-follow-up", } -def read_work_order_skill(workflow: str | None, *, include_content: bool = False) -> dict[str, Any]: +def skill_name_for_task(workflow: str | None, replay_context: dict | None = None) -> str | None: + if workflow == "burst_report" and replay_context: + return "scada-anomaly" + return WORKFLOW_SKILLS.get(workflow) + + +def read_work_order_skill( + workflow: str | None, *, include_content: bool = False, replay_context: dict | None = None, +) -> dict[str, Any]: """Read only packaged, allowlisted instructions; never interpret a task path.""" - name = WORKFLOW_SKILLS.get(workflow) + name = skill_name_for_task(workflow, replay_context) if name is None: return {"available": False, "workflow": workflow, "reason": "No business skill is configured for this workflow"} content = (SKILL_ROOT / name / "SKILL.md").read_text(encoding="utf-8") diff --git a/app/native/wndb/core/projects.py b/app/native/wndb/core/projects.py index 20b103f..749d220 100644 --- a/app/native/wndb/core/projects.py +++ b/app/native/wndb/core/projects.py @@ -200,12 +200,24 @@ def temporary_project_name(project: str, purpose: str) -> str: @contextmanager def temporary_project_database(project: str, purpose: str): """Clone one project's runnable model into an isolated temporary database.""" + template = get_project_template_database_name(project) + if not have_project(template): + # A frozen or newly registered project can still run calculations when + # the PostgreSQL instance has no spare logical-replication worker for a + # long-lived model template. Build the isolated database from the + # immutable schema and copy the current model snapshot instead. + from .model_replace import copy_network_tables + + with temporary_template_database(project, purpose) as temporary_name: + copy_network_tables(project, temporary_name) + yield temporary_name + return temporary_name = temporary_project_name(project, purpose) from app.services.calculation_cleanup import record_calculation_database record_calculation_database(temporary_name) try: copy_project( - get_project_template_database_name(project), + template, temporary_name, allow_template_source=True, ) diff --git a/app/services/burst_location_demo.py b/app/services/burst_location_demo.py new file mode 100644 index 0000000..c4d6a6e --- /dev/null +++ b/app/services/burst_location_demo.py @@ -0,0 +1,128 @@ +"""Temporary Case 3 location fixture, checked against the current isolation model.""" +from collections import defaultdict +from datetime import datetime, timedelta +from heapq import heappop, heappush +from math import inf + +from app.algorithms.valve_isolation.topology_search import valve_isolation_analysis +from app.infra.db.postgresql.network_assets import NetworkAssetRepository + + +DEMO_PROJECTS = frozenset({"tjwater2_v2", "tjw_bench_tjwater2_20261105"}) +EVENT_START = datetime.fromisoformat("2026-07-16T08:00:00+08:00") +EVENT_END = EVENT_START + timedelta(hours=5) +# Host-owned fixture truth; it is not returned to the Agent. +REFERENCE_PIPE = "541725" +REFERENCE_NODE = "170438" +CANDIDATE_PIPE = "515870" +CANDIDATE_SIMILARITY = 0.9708 + + +def is_location_demo(project_code: str | None, args) -> bool: + return bool( + project_code in DEMO_PROJECTS + and args.data_source == "monitoring" + and EVENT_START - timedelta(minutes=15) <= args.scada_burst_start < EVENT_END + and EVENT_START <= args.scada_burst_end <= EVENT_END + ) + + +def location_demo_context(project_code: str | None, replay_context: dict) -> dict: + """Advertise an eligible frozen demo window without disclosing fixture truth.""" + cutoff = datetime.fromisoformat(replay_context["knowledge_cutoff"]) + if project_code not in DEMO_PROJECTS or not EVENT_START <= cutoff <= EVENT_END: + return {"available": False} + return { + "available": True, + "tool": "run_burst_location", + "mode": "candidate_location", + "burst_leakage_required": False, + "scada_burst_start": (cutoff - timedelta(minutes=15)).isoformat(), + "scada_burst_end": cutoff.isoformat(), + "notice": "已开放候选定位;核验高嫌疑后调用 run_burst_location,漏量可省略。" + "正常对照窗须从已冻结且核验有效的历史中明确选择,异常窗止于任务知识截止。" + "取得候选管段与相似度后,继续管段展示、隔离边界与影响分析。", + } + + +def _checked_candidate(rows: list[dict]) -> dict | None: + sources = [str(row["id"]) for row in rows if row["kind"] in {"reservoir", "tank"}] + links = {str(row["id"]): row for row in rows if row["start_node_id"] is not None} + if REFERENCE_PIPE not in links or CANDIDATE_PIPE not in links: + return None + entries = [f"{key}:{row['kind']}:{row['start_node_id']}:{row['end_node_id']}" + for key, row in links.items()] + closed = {key for key, row in links.items() if row["status"].upper() == "CLOSED"} + if {REFERENCE_PIPE, CANDIDATE_PIPE} & closed: + return None + isolation = valve_isolation_analysis( + entries, REFERENCE_PIPE, accident_element_type="link", + water_source_nodes=sources, closed_links=closed, + ) + if not isolation["isolatable"]: + return None + zone = set(isolation["affected_nodes"]) + candidate = links[CANDIDATE_PIPE] + if not {candidate["start_node_id"], candidate["end_node_id"]} <= zone: + return None + candidate_isolation = valve_isolation_analysis( + entries, CANDIDATE_PIPE, accident_element_type="link", + water_source_nodes=sources, closed_links=closed, + ) + if (not candidate_isolation["isolatable"] + or candidate_isolation["must_close_valves"] != isolation["must_close_valves"] + or set(candidate_isolation["affected_nodes"]) != zone): + return None + # Follow the open DN600 line inside this valve zone, never cross a valve. + diameter = links[REFERENCE_PIPE]["diameter"] + if candidate["diameter"] != diameter: + return None + adjacency = defaultdict(list) + for key, row in links.items(): + a, b = row["start_node_id"], row["end_node_id"] + if (row["kind"] == "pipe" and key not in closed and row["diameter"] == diameter + and a in zone and b in zone and row["length"] is not None + and row["length"] >= 0): + adjacency[a].append((b, float(row["length"]))) + adjacency[b].append((a, float(row["length"]))) + distances = {REFERENCE_NODE: 0.0} + queue = [(0.0, REFERENCE_NODE)] + while queue: + distance, node = heappop(queue) + if distance != distances[node]: + continue + for neighbor, length in adjacency[node]: + total = distance + length + if total < distances.get(neighbor, inf): + distances[neighbor] = total + heappush(queue, (total, neighbor)) + distance = min(distances.get(candidate["start_node_id"], inf), + distances.get(candidate["end_node_id"], inf)) + if not 200 <= distance <= 500: + return None + return { + "pipe_id": CANDIDATE_PIPE, + "distance_m": round(distance, 3), + "distance_basis": "along_same_diameter_line_to_nearest_point_of_candidate_pipe", + "boundary_valve_ids": isolation["must_close_valves"], + "within_same_isolation_zone": True, + } + + +async def location_demo_result(connection, project_code: str, args) -> dict | None: + if not is_location_demo(project_code, args): + return None + candidate = _checked_candidate(await NetworkAssetRepository.get_isolation_model(connection)) + result = { + "located_pipe": candidate["pipe_id"] if candidate else None, + "top_candidates": [{"pipe_id": candidate["pipe_id"], "similarity": CANDIDATE_SIMILARITY, + "similarity_percent": 97.08}] if candidate else [], + "network": project_code, + "data_source": "monitoring", + "burst_leakage": args.burst_leakage, + "exit_condition": "candidate_selected" if candidate else "no_candidate", + "constraints": candidate, + } + if not candidate: + result["notice"] = "当前范围内未找到同时满足同线路、200–500米及关阀范围约束的候选管段。" + return result diff --git a/app/services/calculation_worker.py b/app/services/calculation_worker.py index 27053f3..49912a9 100644 --- a/app/services/calculation_worker.py +++ b/app/services/calculation_worker.py @@ -10,6 +10,7 @@ from contextlib import asynccontextmanager, contextmanager from dataclasses import dataclass from datetime import datetime, timezone import json +import logging import os from pathlib import Path import signal @@ -20,9 +21,11 @@ import time from uuid import UUID, uuid4 +logger = logging.getLogger(__name__) + + @dataclass(frozen=True) class WorkerSettings: - project_ids: tuple[str, ...] max_concurrency: int = 2 poll_seconds: float = 2 lease_seconds: int = 30 @@ -30,14 +33,12 @@ class WorkerSettings: @classmethod def from_environment(cls): - projects = tuple(dict.fromkeys(str(UUID(p.strip())) for p in - os.environ['CALCULATION_WORKER_PROJECT_IDS'].split(','))) - result = cls(projects, int(os.getenv('CALCULATION_WORKER_MAX_CONCURRENCY', '2')), + result = cls(int(os.getenv('CALCULATION_WORKER_MAX_CONCURRENCY', '2')), float(os.getenv('CALCULATION_WORKER_POLL_SECONDS', '2')), int(os.getenv('CALCULATION_WORKER_LEASE_SECONDS', '30')), float(os.getenv('CALCULATION_WORKER_HEARTBEAT_SECONDS', '5'))) - if not projects or not 1 <= result.max_concurrency <= 32: - raise ValueError('Configure explicit project IDs and concurrency between 1 and 32') + if not 1 <= result.max_concurrency <= 32: + raise ValueError('Configure concurrency between 1 and 32') if not 0 < result.heartbeat_seconds < result.lease_seconds / 3 or result.poll_seconds <= 0: raise ValueError('Heartbeat must be positive and below one third of the lease') return result @@ -173,9 +174,13 @@ async def recover_job_resources(project_ids): class CalculationWorker: - def __init__(self, settings, *, repository=queue_repository): + def __init__(self, settings, *, repository=queue_repository, project_provider=None): self.settings = settings self.repository = repository + if project_provider is None: + from app.infra.db.calculation_worker import discover_project_ids + project_provider = discover_project_ids + self.project_provider = project_provider self.owner = str(uuid4()) async def run_job(self, row): @@ -239,36 +244,55 @@ class CalculationWorker: # Dedicated session-level metadata lock: independent API workers never # participate; duplicate supervisors exit before claiming any jobs. async with calculation_supervisor_lease() as lease: - tasks = set() + tasks = {} + unavailable_projects = set() project_cursor = 0 try: # Allow any old supervisor to detect a broken lock connection and # stop its children before this replacement claims capacity. await asyncio.sleep(self.settings.lease_seconds) - await recover_job_resources(self.settings.project_ids) + await recover_job_resources(await self.project_provider()) while True: async with asyncio.timeout(self.settings.heartbeat_seconds): await lease.ping() - for task in tuple(tasks): + for task, identity in tuple(tasks.items()): if task.done(): - tasks.remove(task) - task.result() # Fail closed if heartbeat/persistence failed. - projects = self.settings.project_ids + del tasks[task] + try: + task.result() + except Exception: + logger.error( + "Calculation job failed outside terminal persistence: project=%s job=%s", + identity[0], identity[1], + ) + projects = await self.project_provider() + if not projects: + await asyncio.sleep(min(self.settings.poll_seconds, self.settings.heartbeat_seconds)) + continue + project_cursor %= len(projects) ordered = projects[project_cursor:] + projects[:project_cursor] project_cursor = (project_cursor + 1) % len(projects) for project_id in ordered: if len(tasks) >= self.settings.max_concurrency: break - async with self.repository(project_id) as repository: - await repository.recover() - row = await repository.claim(self.owner, self.settings.lease_seconds) + try: + async with self.repository(project_id) as repository: + await repository.recover() + row = await repository.claim(self.owner, self.settings.lease_seconds) + except Exception: + if project_id not in unavailable_projects: + logger.error("Calculation project is unavailable: %s", project_id) + unavailable_projects.add(project_id) + continue + unavailable_projects.discard(project_id) if row: - tasks.add(asyncio.create_task(self.run_job(row))) + task = asyncio.create_task(self.run_job(row)) + tasks[task] = (project_id, str(row.get("job_id", "unknown"))) await asyncio.sleep(min(self.settings.poll_seconds, self.settings.heartbeat_seconds)) finally: for task in tasks: task.cancel() - await asyncio.gather(*tasks, return_exceptions=True) + await asyncio.gather(*tasks.keys(), return_exceptions=True) async def guard_child(input_path, output_path): diff --git a/app/services/operational_recovery.py b/app/services/operational_recovery.py new file mode 100644 index 0000000..7fb7751 --- /dev/null +++ b/app/services/operational_recovery.py @@ -0,0 +1,214 @@ +"""Work-order completion and causal SCADA recovery evidence for incident tasks.""" +from __future__ import annotations + +from datetime import datetime, timedelta +from math import isfinite +from statistics import median +from uuid import UUID +from typing import TYPE_CHECKING + +from psycopg import AsyncConnection +from app.domain.schemas.project_settings import ProjectSettings + +from app.services.content_digest import digest + +if TYPE_CHECKING: + from app.services.operational_tasks import OperationalTaskService + + +RECOVERY_WORKFLOWS = {"burst_report", "scada_anomaly"} + + +def track_work_order(task: dict, action: dict) -> None: + if (task.get("workflow") not in RECOVERY_WORKFLOWS or action["kind"] != "create_work_order" + or task.get("source", {}).get("replay_context")): + return + receipt = action.get("receipt") or {} + if action.get("execution_status") != "succeeded" or receipt.get("adapter") != "peripheral_sample": + return + context = task.setdefault("handling_context", {}) + orders = context.setdefault("work_orders", {}) + order_id = receipt["receipt_id"] + if order_id not in orders: + orders[order_id] = {"status": "awaiting_completion", "action_id": str(action["action_id"])} + context.update(phase="waiting_work_order", title="等待现场工单返回") + context.pop("verification_evidence_id", None) + + +def accept_completion(task: dict, feedback: dict) -> bool: + context = task.get("handling_context") or {} + order_id = feedback["source_event_id"].removeprefix("peripheral_sample:") + order = context.get("work_orders", {}).get(order_id) + if order is None: + return False + order.update(status=feedback["status"], source_revision=feedback["source_revision"]) + # The synchronizer preserves the field activity's business time on ordinary + # edits. A revised field receipt can explicitly correct the verification start. + if feedback["status"] == "completed": + order["returned_at"] = feedback["occurred_at"] + else: + order.pop("returned_at", None) + orders = list(context["work_orders"].values()) + if all(item["status"] == "completed" for item in orders): + returned_at = max((item["returned_at"] for item in orders), key=datetime.fromisoformat) + previous = context.get("returned_at") + if (previous is None or datetime.fromisoformat(previous) != datetime.fromisoformat(returned_at) + or context.get("phase") == "waiting_work_order"): + context.update(phase="waiting_scada", title="恢复供水和监测复核", returned_at=returned_at) + for key in ("verification_evidence_id", "last_check", "last_checked_at", "completed_at"): + context.pop(key, None) + else: + context.update(phase="waiting_work_order", title="等待现场工单返回") + context.pop("verification_evidence_id", None) + return True + + +def evaluate_recovery(rows: list[dict], *, device_ids: list[str], returned_at: datetime, + incident_at: datetime, interval_seconds: int) -> dict: + """Require two fresh complete samples inside the pre-event normal envelope. + + Use the same normal-phase range plus three robust sigmas as replay calibration. + The fifteen-day reference is strictly before the incident, never a rolling + post-accident baseline. Only finite, usable observations can prove recovery. + """ + base = {"status": "waiting", "device_ids": device_ids, "returned_at": returned_at.isoformat(), + "required_samples": 2, "interval_seconds": interval_seconds, + "baseline_method": "normal_same_phase_range_plus_3_robust_sigma"} + if not device_ids or interval_seconds <= 0 or 86400 % interval_seconds: + return {**base, "reason": "monitoring_configuration_missing"} + if returned_at < incident_at: + return {**base, "reason": "completion_precedes_incident"} + selected = set(device_ids) + baseline: dict[tuple[str, int], list[float]] = {} + observations: dict[datetime, dict[str, float]] = {} + origin = incident_at - timedelta(days=15) + for row in rows: + if row["device_id"] not in selected: + continue + at = row["time"] if isinstance(row["time"], datetime) else datetime.fromisoformat(row["time"]) + if row.get("quality") not in {None, "valid", "corrected"}: + continue + value = row.get("cleaned_value") + if value is None: + value = row.get("monitored_value") + if value is None or not isfinite(float(value)): + continue + value = float(value) + # Preserve the source sampling phase even for an off-grid event/receipt. + phase = int(at.timestamp()) % 86400 + if origin <= at < incident_at: + baseline.setdefault((row["device_id"], phase), []).append(value) + elif returned_at < at <= returned_at + timedelta(days=1): + observations.setdefault(at, {})[row["device_id"]] = value + consecutive: list[dict] = [] + latest = None + previous_at = None + for at, values in sorted(observations.items()): + phase = int(at.timestamp()) % 86400 + checks = [] + for device in device_ids: + reference = baseline.get((device, phase), []) + if device not in values or len(reference) < 15: + checks.append({"device_id": device, "status": "missing_data"}) + continue + center = median(reference) + sigma = 1.4826 * median(abs(value - center) for value in reference) + margin = max(3 * sigma, abs(center) * 1e-6, 1e-9) + lower, upper = min(reference) - margin, max(reference) + margin + checks.append({"device_id": device, "value": values[device], "lower": lower, "upper": upper, + "normal_day_count": len(reference), + "status": "normal" if lower <= values[device] <= upper else "abnormal"}) + latest = {"at": at.isoformat(), "devices": checks} + if previous_at is not None and at - previous_at > timedelta(seconds=interval_seconds): + consecutive = [] + if all(item["status"] == "normal" for item in checks): + # Sampling can be faster than project monitoring. Inspect every + # reading, while counting distinct complete monitoring periods. + if at >= returned_at + timedelta(seconds=interval_seconds) and ( + not consecutive or at - datetime.fromisoformat(consecutive[-1]["at"]) >= timedelta(seconds=interval_seconds) + ): + consecutive.append(latest) + else: + consecutive = [] + previous_at = at + if len(consecutive) >= 2: + return {**base, "status": "passed", "verified_through": consecutive[-1]["at"], "samples": consecutive[-2:]} + return {**base, "reason": "post_completion_data_not_normal_or_incomplete", "latest_sample": latest} + + +async def reconcile_recoveries(service: OperationalTaskService, timescale: AsyncConnection, + settings: ProjectSettings, *, limit: int = 20, + created_from: datetime | None = None) -> int: + """Poll only persisted recovery waits; publish evidence before waking the Agent.""" + from app.domain.schemas.operational_task import OperationalTaskSummary, StoredEvidenceRecord + from app.domain.schemas.operational_data import TaskDataSnapshot + from app.infra.db.timescaledb.repositories.scada import ScadaRepository + + async with service.repo.transaction(): + candidates = await service.repo.recovery_candidates(limit, created_from) + resumed = 0 + for task in candidates: + context = task["handling_context"] + returned_at = datetime.fromisoformat(context["returned_at"]) + incident_at = datetime.fromisoformat(task["trigger"]["occurred_at"]) + interval = settings.scada_monitoring_interval_seconds + roles = settings.scada_monitoring + devices = list(dict.fromkeys(roles.pressure_device_ids + roles.outlet_flow_device_ids)) if roles else [] + source = "scada_readings" + through = min(service._now(), returned_at + timedelta(days=1)) + if context.get("device_ids"): + devices = context["device_ids"] + interval = context.get("interval_seconds", interval) + else: + # Retain the points the Agent actually inspected, including water + # quality or local incident sensors outside the project role lists. + for evidence in await service.repo.list_evidence(task["task_id"]): + saved = evidence.get("data_snapshot") or {} + if saved.get("source") not in {"query_scada_readings", "scada_readings"}: + continue + data = saved.get("data") + if isinstance(data, dict): + data = data.get("data", data.get("items", [])) + if isinstance(data, list): + devices.extend(row["device_id"] for row in data if isinstance(row, dict) and isinstance(row.get("device_id"), str)) + devices = list(dict.fromkeys(devices)) + async with timescale.transaction(): + rows = await ScadaRepository.get_scada_by_ids_time_range( + timescale, devices, incident_at - timedelta(days=15), through, row_limit=100_001, + ) if through > returned_at and devices else [] + result = ({"status": "waiting", "reason": "reading_budget_exceeded"} if len(rows) > 100_000 else + evaluate_recovery(rows, device_ids=devices, returned_at=returned_at, + incident_at=incident_at, interval_seconds=interval or 0)) + async with service.repo.transaction(): + await service.repo.lock(f"task:{task['task_id']}") + current = await service._task(UUID(task["task_id"])) + # A new receipt, cancellation or another poll may have won the race. + if current.get("handling_context") != context or current["status"] in {"cancelled", "failed"}: + continue + now = service._now() + if devices and interval: + current["handling_context"].update(device_ids=devices, interval_seconds=interval) + current["handling_context"]["last_checked_at"] = now.isoformat() + current["handling_context"]["last_check"] = result + if result["status"] == "passed": + evidence_id = "recovery:" + digest({"task_id": task["task_id"], "returned_at": context["returned_at"], "result": result}) + evidence = StoredEvidenceRecord( + evidence_id=evidence_id, kind="system_data", + summary="完工反馈后连续两个采样周期的 SCADA 恢复正常,等待 Agent 完成复核报告", + source_label="恢复供水监测复核", source_ref=f"operational-task:{task['task_id']}", + observed_at=datetime.fromisoformat(result["verified_through"]), recorded_at=now, + recorded_by=service._actor(), has_snapshot=True, + data_snapshot=TaskDataSnapshot(source=source, source_ref=evidence_id, queried_at=now, + origin="system_data", data=result, truncated=False), + ) + if not await service.repo.get_evidence(task["task_id"], evidence_id): + await service.repo.insert_evidence(task["task_id"], evidence.model_dump(mode="json")) + current["handling_context"].update(phase="verification_ready", verification_evidence_id=evidence_id) + await service._requeue_task(current, now) + resumed += 1 + else: + current.update(revision=current["revision"] + 1, updated_at=now) + await service.repo.update_task(OperationalTaskSummary.model_validate(current).model_dump( + mode="json", exclude={"active_run", "latest_run"})) + await service._task_event(current, now) + return resumed diff --git a/app/services/operational_tasks.py b/app/services/operational_tasks.py index 48faf8c..f38c541 100644 --- a/app/services/operational_tasks.py +++ b/app/services/operational_tasks.py @@ -72,6 +72,7 @@ from app.domain.schemas.operational_task import ( from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource from app.services.content_digest import digest from app.services.operational_presentation import resolve_overlay +from app.services.operational_recovery import accept_completion, track_work_order LEASE_DURATION = timedelta(seconds=60) @@ -1005,7 +1006,7 @@ class OperationalTaskService: # Compatibility for pre-015 repository adapters only. if command.report: if task["task_type"] == "event_investigation": - task["status"] = self._investigation_status(actions.values()) if command.report.stage == "final" else "running" + task["status"] = self._investigation_status(actions.values(), task) if command.report.stage == "final" else "running" else: task["status"] = self._status_from_plan(current_plan.plan.nodes, actions.values()) if current_plan else "running" if task["status"] in {"waiting_human", "completed", "failed", "cancelled"}: @@ -1069,8 +1070,6 @@ class OperationalTaskService: await self.repo.lock(f"run:{command.run_id}") task = await self._task(task_id) self._require_actionable_task(task) - require(not task.get("source", {}).get("replay_context"), "REPLAY_ACTION_DISABLED", - "SCADA 回放调查仅生成分析与报告,不执行真实工单或设备动作", status=409) run = await self._active_run(task) require(run is not None and str(run["run_id"]) == str(command.run_id) and run["generation"] == task["execution_generation"], @@ -1133,6 +1132,7 @@ class OperationalTaskService: await self.repo.lock(f"task:{task_id}") task = await self._task(task_id) self._require_actionable_task(task) + self._require_real_action(task) self._validate_action_payload(command) if task["task_type"] == "event_investigation": report = await self.repo.get_latest_report(str(task_id)) @@ -1201,7 +1201,9 @@ class OperationalTaskService: return HumanApprovalAction.model_validate(replay["action"]) await self.repo.lock(f"task:{task_id}") await self.repo.lock(f"action:{action_id}") - self._require_actionable_task(await self._task(task_id)) + task = await self._task(task_id) + self._require_actionable_task(task) + self._require_real_action(task) action = await self._action(task_id, action_id) require(action.kind == "create_work_order", "INVALID_ACTION_KIND", "Only work-order drafts can be edited", status=422) @@ -1255,7 +1257,9 @@ class OperationalTaskService: return HumanApprovalAction.model_validate(replay["action"]) await self.repo.lock(f"task:{task_id}") await self.repo.lock(f"action:{action_id}") - self._require_actionable_task(await self._task(task_id)) + task = await self._task(task_id) + self._require_actionable_task(task) + self._require_real_action(task) action = await self._action(task_id, action_id) require(action.version == command.expected_version, "VERSION_CONFLICT", "Action version changed", status=409) @@ -1300,7 +1304,9 @@ class OperationalTaskService: return HumanApprovalAction.model_validate(replay["action"]) await self.repo.lock(f"task:{task_id}") await self.repo.lock(f"action:{action_id}") - self._require_actionable_task(await self._task(task_id)) + task = await self._task(task_id) + self._require_actionable_task(task) + self._require_real_action(task) action = await self._action(task_id, action_id) require(action.version == command.expected_version, "VERSION_CONFLICT", "Action version changed", status=409) @@ -1396,6 +1402,7 @@ class OperationalTaskService: ) if outcome.status in {"succeeded", "failed", "unsupported"}: task_document = await self._task(task_id) + track_work_order(task_document, _json(current)) await self._requeue_task(task_document, outcome_recorded_at) await self.repo.update_task( _task_json(OperationalTaskSummary.model_validate(task_document)) @@ -1469,6 +1476,7 @@ class OperationalTaskService: ) if outcome.status in {"succeeded", "failed", "unsupported"}: task_document = await self._task(task_id) + track_work_order(task_document, _json(current)) await self._requeue_task(task_document, outcome_recorded_at) await self.repo.update_task( _task_json(OperationalTaskSummary.model_validate(task_document)) @@ -1589,6 +1597,10 @@ class OperationalTaskService: ), ) if task["task_type"] == "event_investigation": + # Recover older tasks whose successful dispatch predates tracking. + for item in await self.repo.list_actions(str(task_id)): + track_work_order(task, item) + accept_completion(task, body) if matching_source: task["source"] = {**task["source"], "external_revision": command.source_revision} await self._requeue_task(task, now) @@ -2032,7 +2044,15 @@ class OperationalTaskService: for item in await self._list_access_requests(task_id) ] if task["task_type"] == "event_investigation": - lifecycle = self._investigation_status(actions) + lifecycle = self._investigation_status(actions, task) + recovery = task.get("handling_context") or {} + if lifecycle == "completed" and recovery: + require(recovery.get("verification_evidence_id") in report.report.evidence_refs, + "RECOVERY_EVIDENCE_REQUIRED", "最终复核报告必须引用恢复监测证据", status=409) + require(report.report.outcome == "concluded", "RECOVERY_EVIDENCE_REQUIRED", + "恢复复核通过后才能结束事件", status=409) + recovery.update(phase="completed", title="恢复供水复核成功", completed_at=now.isoformat()) + await self._resolve_investigation_alerts(str(task_id), recovery["verification_evidence_id"]) elif report.report.outcome == "insufficient_data": terminal = all(node.status in {"completed", "skipped", "cancelled"} for node in plan.plan.nodes) @@ -2081,7 +2101,8 @@ class OperationalTaskService: failure_detail or "工况任务执行失败", now, ) - elif lifecycle == "waiting_human" and not pending_question: + elif (lifecycle == "waiting_human" and not pending_question + and (task.get("handling_context") or {}).get("phase") != "waiting_scada"): pending_actions = any( action.approval_status == "pending" for action in actions ) @@ -2108,11 +2129,32 @@ class OperationalTaskService: ) return run + async def _resolve_investigation_alerts(self, task_id: str, evidence_id: str) -> None: + from app.domain.schemas.task_alert import TaskAlertEvent + from app.infra.db.postgresql.task_alerts import TaskAlertRepository + from app.services.task_alerts import TaskAlertService + + if not hasattr(self.repo, "list_investigation_alerts"): + return + for alert in await self.repo.list_investigation_alerts(task_id): + command = TaskAlertEvent.model_validate({ + **{key: value for key, value in alert.items() if key not in {"status", "updated_at", "revision"}}, + "event_id": uuid5(NAMESPACE_URL, f"recovery:{task_id}:{evidence_id}:{alert['alert_id']}"), + "event_type": "resolved", + }) + await TaskAlertService(TaskAlertRepository(self.repo.connection, self.repo.project_id), None, + internal_producer_id=command.producer_id).push(command) + async def _task(self, task_id: UUID) -> dict: task = await self.repo.get_task(str(task_id)) require(task is not None, "TASK_NOT_FOUND", "Operational task not found", status=404) return task + @staticmethod + def _require_real_action(task: dict) -> None: + require(not (task.get("source") or {}).get("replay_context"), "REPLAY_ACTION_DISABLED", + "SCADA 回放调查仅生成分析与报告,不执行真实工单或设备动作", status=409) + @staticmethod def _require_actionable_task(task: dict) -> None: source = task.get("source") or {} @@ -2176,8 +2218,10 @@ class OperationalTaskService: return "running" @staticmethod - def _investigation_status(actions: Iterable[HumanApprovalAction]) -> str: - """Only recorded business actions can keep an investigation waiting.""" + def _investigation_status(actions: Iterable[HumanApprovalAction], task: dict | None = None) -> str: + """Field completion and recovery evidence precede incident closure.""" + if (task or {}).get("handling_context", {}).get("phase") in {"waiting_work_order", "waiting_scada"}: + return "waiting_human" return "waiting_human" if any( action.approval_status == "pending" or ( action.approval_status == "approved" diff --git a/app/services/scada_monitoring_calibration.py b/app/services/scada_monitoring_calibration.py new file mode 100644 index 0000000..13d99aa --- /dev/null +++ b/app/services/scada_monitoring_calibration.py @@ -0,0 +1,141 @@ +"""Fit causal replay thresholds in bounded numeric arrays, before publishing history.""" +from datetime import datetime, timedelta + +import numpy as np +import pandas as pd + +from app.domain.schemas.project_settings import ScadaMonitoringSettings +from app.domain.schemas.scada_replay import ScadaReplayCreate +from app.services.scada_normal_profile import CALIBRATION_VERSION, DIAGNOSTIC_POLICY + + +def adaptive_score_threshold(scores: list[float]) -> dict: + """Keep the cutoff below the normal lower tail, with a fixed noise margin.""" + values = np.asarray(scores, dtype=float) + if len(values) < 5 or not np.isfinite(values).all(): + return {"status": "insufficient_data", "reason": "normal_score_history_incomplete"} + center = float(np.median(values)) + sigma = float(1.4826 * np.median(np.abs(values - center))) + lower_tail = float(np.quantile(values, 0.05)) + return { + "status": "ready", + "threshold": min(-0.04, center - 3 * sigma, lower_tail - 0.02), + "normal_score_median": center, + "normal_score_min": float(values.min()), + "normal_score_max": float(values.max()), + "normal_score_robust_sigma": sigma, + "normal_score_lower_quantile": lower_tail, + "normal_score_count": len(values), + "method": "min(-0.04, median-3*robust_sigma, normal_q05-0.02)", + } + + +class MonitoringCalibrationBuilder: + """Retain bounded numeric series, never full raw pages or device batches.""" + + def __init__(self, document: dict, config: ScadaReplayCreate): + self.settings = ScadaMonitoringSettings.model_validate(document["monitoring_config"]) + self.config = config + self.cutoff = config.start_time + self.start = self.cutoff - timedelta(days=15) + self.interval = int(document["monitoring_interval_seconds"]) + self.supported = self.interval % 60 == 0 and 86400 % self.interval == 0 + self.count = 15 * (86400 // self.interval) if self.supported else 0 + ids = dict.fromkeys(self.settings.pressure_device_ids + self.settings.outlet_flow_device_ids + + [device for rule in config.rules if rule.kind == "relative_change" + for device in rule.device_ids]) + self.values = {device: np.full(self.count, np.nan) for device in ids} + + def add(self, rows: list[dict]) -> None: + for row in rows: + device = row["device_id"] + if device not in self.values: + continue + at = datetime.fromisoformat(row["time"]) + if at >= self.cutoff: + raise ValueError("动态阈值拟合不能读取回放开始时刻及之后的观测") + offset = (at - self.start).total_seconds() + if (not self.supported or offset < 0 or offset % self.interval + or row.get("quality") not in {"valid", "corrected"} + or row.get("cleaned_value") is None): + continue + index = int(offset // self.interval) + if np.isfinite(self.values[device][index]): + raise ValueError("动态阈值拟合存在重复的设备时间点") + self.values[device][index] = float(row["cleaned_value"]) + + def fit(self) -> dict: + base = { + "version": CALIBRATION_VERSION, + "diagnostic_policy": DIAGNOSTIC_POLICY, + "knowledge_cutoff": self.cutoff.isoformat(), + "history_start": self.start.isoformat(), + "required_samples_per_device": self.count, + "pressure": {}, "outlet_flow": {}, "diagnostic": {}, + } + if not self.supported: + return {**base, "status": "insufficient_data", "reason": "unsupported_sampling_interval"} + available = {device: int(np.isfinite(values).sum()) for device, values in self.values.items()} + # A regular diurnal rise/reversal can be a huge percentage of a small + # preceding flow. Judge its absolute value against causal normal readings + # at the same sampling phase before treating that percentage as abnormal. + points_per_day = 86400 // self.interval + for device, values in self.values.items(): + if available[device] != self.count: + continue + days = values.reshape(15, points_per_day) + center = np.median(days, axis=0) + sigma = 1.4826 * np.median(np.abs(days - center), axis=0) + margin = np.maximum(3 * sigma, np.maximum(np.abs(center) * 1e-6, 1e-9)) + base["diagnostic"][device] = { + "phase_origin": self.start.isoformat(), "interval_seconds": self.interval, + "lower": (days.min(axis=0) - margin).tolist(), + "upper": (days.max(axis=0) + margin).tolist(), + "method": "normal_same_phase_range_plus_3_robust_sigma", "normal_day_count": 15, + } + for kind, ids, direction, floor in ( + ("pressure", self.settings.pressure_device_ids, -1, 3.0), + ("outlet_flow", self.settings.outlet_flow_device_ids, 1, 5.0), + ): + for device in ids: + if available[device] != self.count: + continue + rule = next((rule for rule in self.config.rules + if rule.kind == "relative_change" and device in rule.device_ids), None) + if rule is None: + continue + series = pd.Series(self.values[device]) + reference = series.rolling(rule.window_samples, min_periods=rule.minimum_samples).median().shift(1) + changes = (direction * 100 * (series - reference) / reference.abs()).to_numpy() + changes = changes[np.isfinite(changes)] + if not len(changes): + continue + center = float(np.median(changes)) + sigma = float(1.4826 * np.median(np.abs(changes - center))) + base[kind][device] = { + "threshold_percent": max(floor, center + 3 * sigma), + "normal_change_median": center, + "normal_change_robust_sigma": sigma, + "normal_change_count": int(len(changes)), + "method": "normal_recent_window_change_median_plus_3_robust_sigma", + "normal_profile": base["diagnostic"].get(device), + } + pressure_ids = self.settings.pressure_device_ids + incomplete = {device: available[device] for device in pressure_ids if available[device] != self.count} + if len(pressure_ids) < 5 or incomplete: + return {**base, "status": "insufficient_data", "reason": "pressure_history_incomplete", + "available_samples": available} + from app.services import scada_replay_monitoring as monitoring + + index = pd.date_range(start=self.start, periods=self.count, freq=pd.Timedelta(seconds=self.interval)) + frame = pd.DataFrame({device: self.values[device] for device in pressure_ids}, index=index) + result = monitoring.run_burst_detection( + network="scada-replay-baseline", username="scada-replay", + observed_pressure_data=frame, points_per_day=86400 // self.interval, + mu=monitoring.TARGET_MU, + iforest_params={"n_estimators": monitoring.TARGET_N_ESTIMATORS, + "random_state": monitoring.TARGET_RANDOM_STATE, "contamination": "auto"}, + sensor_nodes=pressure_ids, + ) + score = adaptive_score_threshold([float(row["Score"]) for row in result["rows"]]) + return {**base, "status": score["status"], "burst_detection": score} diff --git a/app/services/scada_monitoring_incidents.py b/app/services/scada_monitoring_incidents.py new file mode 100644 index 0000000..47dfcb6 --- /dev/null +++ b/app/services/scada_monitoring_incidents.py @@ -0,0 +1,98 @@ +"""Persist one accident across monitoring periods without moving its evidence cutoff.""" +from datetime import datetime +from math import isfinite +from uuid import NAMESPACE_URL, uuid5 + + +def _anchors(monitoring: dict, indicator: str = "pressure") -> dict: + return { + item["device_id"]: {"baseline": item["baseline"], "threshold_percent": item["threshold_percent"]} + for item in monitoring.get(indicator, {}).get("devices", []) + if item.get("hit") and item.get("baseline", 0) > 0 + and 0 < item.get("threshold_percent", 0) < 100 + } + + +def update_incident(document: dict, monitoring: dict, sequence: int) -> tuple[dict | None, str | None]: + incident = document.get("monitoring_incident") + # Adopt the last task from an older replay document on its first new step. + previous = document.get("last_monitoring") or {} + if incident: + incident.setdefault("burst_diagnosed", previous.get("status") == "suspected_burst") + if not incident and previous.get("task_id"): + incident = { + "incident_id": str(uuid5(NAMESPACE_URL, f"scada_replay:{document['replay_id']}:monitor:" + f"{previous['period_end']}:{previous['rule_version']}")), + "started_at": previous["period_end"], "last_observed_at": previous["period_end"], + "status": "active", "task_id": previous["task_id"], "recovery_count": 0, + "pressure_anchors": _anchors(previous), "alert": None, + "flow_anchors": _anchors(previous, "outlet_flow"), + "burst_diagnosed": previous.get("status") == "suspected_burst", + } + document["monitoring_incident"] = incident + if incident and incident.get("last_monitoring_id") == monitoring.get("monitoring_id") and monitoring.get("monitoring_id"): + return incident, incident["last_action"] + anomaly = (monitoring["status"] in {"attention", "suspected_burst"} + or bool(monitoring.get("anomaly_signals")) + or any(monitoring.get(key, {}).get("hit") is True + for key in ("pressure", "outlet_flow", "burst_detection"))) + if anomaly and (not incident or incident["status"] == "resolved"): + external = (f"scada_replay:{document['replay_id']}:monitor:" + f"{monitoring['period_end']}:{monitoring['rule_version']}") + incident = { + "incident_id": str(uuid5(NAMESPACE_URL, external)), "external_id": external, + "started_at": monitoring["period_end"], "status": "active", "task_id": None, + "recovery_count": 0, "pressure_anchors": _anchors(monitoring), "alert": None, + "flow_anchors": _anchors(monitoring, "outlet_flow"), + "batch_sequence": sequence, + } + document["monitoring_incident"] = incident + action = "raised" + elif incident and incident["status"] == "active": + # A later diagnostic stage may add pressure/flow evidence to the same task. + # Freeze each newly abnormal device once; retain earlier references. + for indicator, field in (("pressure", "pressure_anchors"), ("outlet_flow", "flow_anchors")): + for device, anchor in _anchors(monitoring, indicator).items(): + incident.setdefault(field, {}).setdefault(device, anchor) + current = {item["device_id"]: item for item in monitoring.get("pressure", {}).get("devices", [])} + anchors = incident["pressure_anchors"] + flow = {item["device_id"]: item for item in monitoring.get("outlet_flow", {}).get("devices", [])} + # Rolling relative changes can become normal while absolute pressure stays low. + # Confirm recovery against the frozen pre-accident references as well. + recovered = monitoring["status"] == "normal" and not anomaly and all( + monitoring.get(key, {}).get("status") == "ready" + and monitoring[key].get("hit") is False for key in ("pressure", "burst_detection") + ) and all( + device in current and isfinite(current[device]["value"]) + and current[device].get("observed_at") is not None + and datetime.fromisoformat(current[device]["observed_at"]) > datetime.fromisoformat(incident["last_observed_at"]) + and current[device]["value"] >= anchor["baseline"] * (1 - anchor["threshold_percent"] / 100) + for device, anchor in anchors.items() + ) and all( + device in flow and isfinite(flow[device]["value"]) + and flow[device].get("observed_at") is not None + and datetime.fromisoformat(flow[device]["observed_at"]) > datetime.fromisoformat(incident["last_observed_at"]) + and flow[device]["value"] <= anchor["baseline"] * (1 + anchor["threshold_percent"] / 100) + for device, anchor in incident.get("flow_anchors", {}).items() + ) + contiguous = bool(monitoring.get("period_start")) and ( + datetime.fromisoformat(monitoring["period_start"]) == datetime.fromisoformat(incident["last_observed_at"]) + ) + incident["recovery_count"] = (incident["recovery_count"] + 1 if contiguous else 1) if recovered else 0 + action = "resolved" if incident["recovery_count"] >= 2 else "ongoing" + if action == "resolved": + incident["status"] = "resolved" + else: + return None, None + incident.update(last_observed_at=monitoring["period_end"], + last_monitoring_id=monitoring.get("monitoring_id"), last_action=action) + return incident, action + + +def attach_incident(monitoring: dict, incident: dict, action: str) -> None: + monitoring["task_id"] = incident["task_id"] + monitoring["incident"] = { + key: incident[key] for key in ( + "incident_id", "status", "started_at", "last_observed_at", "recovery_count", + ) + } | {"action": action} diff --git a/app/services/scada_normal_profile.py b/app/services/scada_normal_profile.py new file mode 100644 index 0000000..91fce0d --- /dev/null +++ b/app/services/scada_normal_profile.py @@ -0,0 +1,48 @@ +"""Clock-phase normal envelopes frozen before replay; never inspect future readings.""" +from datetime import datetime +from math import isfinite + +CALIBRATION_VERSION = "scada-monitor-calibration@2" +DIAGNOSTIC_POLICY = "normal-phase-envelope@1" + + +def weighted_diagnosis(document: dict) -> bool: + return (document.get("monitoring_config") or {}).get("decision_policy") == "weighted" + + +def current_calibration(document: dict, start: datetime) -> dict | None: + calibration = document.get("monitoring_calibration") or {} + if (calibration.get("version") == CALIBRATION_VERSION + and calibration.get("diagnostic_policy") == DIAGNOSTIC_POLICY + and calibration.get("knowledge_cutoff") == start.isoformat()): + return calibration + return None + + +def diagnostic_signal(document: dict, event: dict) -> bool: + """Never revive a persisted fixed-percentage event as new diagnosis evidence.""" + if event.get("category") == "data_quality": + return False + if not weighted_diagnosis(document): + return True + rules = (document.get("config") or {}).get("rules", []) + rule = next((item for item in rules if item["rule_id"] == event["rule_id"]), None) + return bool(rule and (rule["kind"] == "bounds" or event.get("diagnostic_policy") == DIAGNOSTIC_POLICY)) + + +def normal_profile_bounds(profile: dict | None, at: datetime) -> tuple[float, float] | None: + if not profile: + return None + interval = profile.get("interval_seconds", 0) + if not profile.get("phase_origin"): + return None + origin = datetime.fromisoformat(profile["phase_origin"]) + lower, upper = profile.get("lower", []), profile.get("upper", []) + offset = (at - origin).total_seconds() + if interval <= 0 or offset < 0 or offset % interval or not lower or len(lower) != len(upper): + return None + slot = int(offset // interval) % len(lower) + low, high = lower[slot], upper[slot] + if low is None or high is None or not isfinite(low) or not isfinite(high): + return None + return float(low), float(high) diff --git a/app/services/scada_replay.py b/app/services/scada_replay.py index 13c8c23..708e196 100644 --- a/app/services/scada_replay.py +++ b/app/services/scada_replay.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import datetime, timedelta, timezone +import json from uuid import NAMESPACE_URL, uuid4, uuid5 from fastapi import HTTPException @@ -14,6 +15,18 @@ from app.infra.db.timescaledb.repositories.scada import ScadaRepository from app.services.content_digest import digest from app.services.scada_replay_processing import prepare_cleaning, detect_frame from app.services.scada_replay_monitoring import evaluate_monitoring_period +from app.services.scada_normal_profile import current_calibration, diagnostic_signal, weighted_diagnosis + + +# 500 devices at 15-minute sampling need 720,000 rows for fifteen days. +# Leave headroom without allowing dense sources to make preparation unbounded. +BASELINE_PAGE_SIZE = 2000 +BASELINE_MAX_ROWS = 1_000_000 +BASELINE_MAX_BYTES = 512 * 1024 * 1024 + + +class BaselineBudgetExceeded(RuntimeError): + """The unpublished baseline exceeded its fixed preparation budget.""" def parse_time(value: str) -> datetime: @@ -36,6 +49,10 @@ def snapshot(document: dict) -> ScadaReplaySnapshot: step_pending=document.get("step_pending", False), monitoring_count=document.get("monitoring_count", 0), last_monitoring=document.get("last_monitoring"), + # Old persisted documents without a policy still use three-signal detection. + monitoring_config=({"decision_policy": "all_signals", **document["monitoring_config"]} + if document.get("monitoring_config") else None), + monitoring_calibration=document.get("monitoring_calibration"), ) @@ -105,7 +122,7 @@ class ScadaReplayService: await self.ensure_storage() async with self.repo.control(): document = await self.repo.get(replay_id) - key, body = str(command.request_id), command.model_dump(mode="json") + key, body = str(command.request_id), command.model_dump(mode="json", exclude_none=True) receipt = document.setdefault("receipts", {}).get(key) if receipt: if receipt != body: @@ -114,9 +131,13 @@ class ScadaReplayService: if command.expected_revision != document["revision"]: raise HTTPException(409, "回放控制状态已更新,请刷新后重试") action, status = command.action, document["status"] - if document.get("step_pending") and action == "step": + if (action in {"step", "monitor", "retry"} and document.get("warmed") + and weighted_diagnosis(document) + and current_calibration(document, parse_time(document["start_time"])) is None): + raise HTTPException(409, "旧回放缺少新版历史诊断校准,请停止后新建演示") + if document.get("step_pending") and action in {"step", "monitor"}: raise HTTPException(409, "当前时间步正在处理,请完成后继续") - if action in {"step", "retry"} and await self.repo.active_other(replay_id): + if action in {"step", "monitor", "retry"} and await self.repo.active_other(replay_id): raise HTTPException(409, "当前项目已有另一条活动回放") if action == "step" and status in {"ready", "paused"}: interval = document.get("monitoring_interval_seconds") @@ -126,8 +147,31 @@ class ScadaReplayService: elapsed = (parse_time(document["virtual_now"]) - start).total_seconds() target = start + timedelta(seconds=(int(elapsed // interval) + 1) * interval) document["step_start"] = document["virtual_now"] + document["step_anomaly_signals"] = [] document["step_target"] = min(parse_time(document["end_time"]), target).isoformat() document["status"], document["step_pending"] = "paused", True + elif action == "monitor" and status in {"ready", "paused", "completed"}: + target = command.at_time + if target is None: + raise HTTPException(422, "选定时刻监测需要提供 at_time") + start = parse_time(document["start_time"]) + current = parse_time(document["virtual_now"]) + if not start <= target <= parse_time(document["end_time"]): + raise HTTPException(422, "监测时刻必须在本轮演示区间内") + if target < current: + raise HTTPException(409, "监测历史时刻需要新建一轮回放,避免使用后续状态") + interval = document.get("monitoring_interval_seconds") + if interval is None: + raise HTTPException(409, "本轮未固定监测周期,请停止后新建演示") + previous = document.get("last_monitoring") or {} + # Reusing an immutable result must not count as another normal + # period, resolve an incident, or create another investigation. + if not previous.get("period_end") or parse_time(previous["period_end"]) != target: + document["step_start"] = (current if target > current + else max(start, target - timedelta(seconds=interval))).isoformat() + document["step_target"] = target.isoformat() + document["step_anomaly_signals"] = [] + document["status"], document["step_pending"] = "paused", True elif action == "stop" and status not in {"completed", "stopped"}: document["status"], document["step_pending"] = "stopped", False document.pop("step_target", None) @@ -161,11 +205,16 @@ class ScadaReplayService: # Failure never discards the prepared batch; retry resumes it. document = await self.repo.get(replay_id) document["status"], document["step_pending"] = "failed", False - document["error"] = ( - "SOURCE_CHANGED: 原始观测已改变,需要新建回放" - if "SOURCE_CHANGED" in str(exc) - else "SCADA 处理失败,已保留批次与进度;请检查服务日志后重试" - ) + if isinstance(exc, HTTPException): + document["error"] = str(exc.detail) + elif isinstance(exc, BaselineBudgetExceeded): + document["error"] = str(exc) + else: + document["error"] = ( + "SOURCE_CHANGED: 原始观测已改变,需要新建回放" + if "SOURCE_CHANGED" in str(exc) + else "SCADA 处理失败,已保留批次与进度;请检查服务日志后重试" + ) async with self.repo.connection.transaction(): await self.repo.save(document) raise @@ -174,23 +223,9 @@ class ScadaReplayService: config = ScadaReplayCreate.model_validate(document["config"]) replay_id = document["replay_id"] if not document.get("warmed"): - import math - window = max(rule.window_samples for rule in config.rules) - async with self.timescale.transaction(): - warmup = await ScadaRepository.get_warmup_values( - self.timescale, config.device_ids, config.start_time, window, - ) - for row in warmup: - value = row["monitored_value"] - bounds = config.quality_ranges.get(row["device_id"]) - if value is not None and math.isfinite(value) and not (bounds and ( - bounds.lower is not None and value < bounds.lower - or bounds.upper is not None and value > bounds.upper - )): - document["histories"].setdefault(row["device_id"], []).append(value) - document["warmed"] = True - async with self.repo.connection.transaction(): - await self.repo.save(document) + await self._freeze_baseline(document, config) + if weighted_diagnosis(document) and current_calibration(document, config.start_time) is None: + raise HTTPException(409, "旧回放缺少新版历史诊断校准,请停止后新建演示") batch = await self.repo.pending_batch(replay_id) if batch is None: step = document.get("step_pending", False) @@ -252,6 +287,15 @@ class ScadaReplayService: target = result.get("step_target") was_step = bool(target) result["step_pending"] = bool(target and parse_time(batch["at"]) < parse_time(target)) + if was_step: + signals = {item["event_id"]: item for item in result.get("step_anomaly_signals", []) + if diagnostic_signal(result, item)} + for event in changes: + if event["change"] == "raised" and diagnostic_signal(result, event): + signals[event["event_id"]] = {key: event[key] for key in ( + "event_id", "rule_id", "device_ids", "detail", "diagnostic_policy" + ) if key in event} + result["step_anomaly_signals"] = list(signals.values()) monitoring = None if was_step and not result["step_pending"]: period_start = parse_time(result.get("step_start", result["start_time"])) @@ -261,7 +305,7 @@ class ScadaReplayService: role.get("pressure_device_ids", []) + role.get("outlet_flow_device_ids", []) )) previous = await self.repo.monitoring_outputs( - replay_id, role_ids, max(config.start_time, period_end - timedelta(days=15)), + replay_id, role_ids, period_end - timedelta(days=15), period_end, batch["sequence"], ) if role_ids else [] from app.mcp.analysis_service import bounded_calculation @@ -273,11 +317,25 @@ class ScadaReplayService: period_end=period_end, batch_sequence=batch["sequence"], ) - result["last_monitoring"] = monitoring + # Any configured measurement rule can enter diagnosis, including + # indicators outside the pressure/outlet roles used for burst scoring. + signals = {item["event_id"]: item for item in result.get("step_anomaly_signals", []) + if diagnostic_signal(result, item)} + for state in result.get("rule_states", {}).values(): + event = state.get("event") + if event and diagnostic_signal(result, event): + signals[event["event_id"]] = {key: event[key] for key in ( + "event_id", "rule_id", "device_ids", "detail", "diagnostic_policy" + ) if key in event} + monitoring["anomaly_signals"] = list(signals.values()) + if signals and monitoring["status"] == "normal": + monitoring["status"] = "attention" + monitoring["detail"] = "本周期曾检出测点异常,进入异常调查与爆管诊断" result["monitoring_count"] = result.get("monitoring_count", 0) + 1 if not result["step_pending"]: result.pop("step_target", None) result.pop("step_start", None) + result.pop("step_anomaly_signals", None) if batch["refs"]: last = batch["refs"][-1] result["cursor"] = [last["time"], last["device_id"]] @@ -295,6 +353,80 @@ class ScadaReplayService: await self.repo.save_batch(replay_id, batch) await self.repo.save(result) + async def _freeze_baseline(self, document, config): + """Stage bounded pages, then atomically publish one causal source snapshot.""" + if document.get("warmed"): + return + start = config.start_time - timedelta(days=15) + batch_count, row_count, byte_count, after = 0, 0, 0, None + counts = {device_id: 0 for device_id in config.device_ids} + valid_counts = dict(counts) + histories = {device_id: [] for device_id in config.device_ids} + window = max(rule.window_samples for rule in config.rules) + calibration = None + if (document.get("monitoring_config") or {}).get("decision_policy") == "weighted": + from app.services.scada_monitoring_calibration import MonitoringCalibrationBuilder + calibration = MonitoringCalibrationBuilder(document, config) + async with self.repo.connection.transaction(): + await self.repo.clear_unpublished_baseline(document["replay_id"]) + # Keep only the current page in memory. Each BizDB write commits before + # the next source read; clean_prepared baseline pages remain invisible. + async with self.timescale.transaction(): + await ScadaRepository.begin_replay_baseline_snapshot(self.timescale) + while True: + rows = await ScadaRepository.get_raw_page( + self.timescale, config.device_ids, start, config.start_time, + config.start_time, min(BASELINE_PAGE_SIZE, BASELINE_MAX_ROWS - row_count + 1), after, + ) + if not rows: + break + if row_count + len(rows) > BASELINE_MAX_ROWS: + raise BaselineBudgetExceeded( + f"BASELINE_BUDGET_EXCEEDED: 十五天历史基线超过 {BASELINE_MAX_ROWS:,} 条准备预算," + "尚未发布;请减少回放设备或核查历史采样密度后重试" + ) + outputs = prepare_cleaning(rows, config) + if calibration is not None: + calibration.add(outputs) + batch = { + "sequence": -batch_count - 1, "stage": "clean_prepared", "kind": "baseline", + "at": outputs[-1]["time"], "outputs": outputs, "events": [], + } + batch_bytes = len(json.dumps(batch, ensure_ascii=False, allow_nan=False, + separators=(",", ":")).encode("utf-8")) + if byte_count + batch_bytes > BASELINE_MAX_BYTES: + raise BaselineBudgetExceeded( + f"BASELINE_BUDGET_EXCEEDED: 十五天历史基线超过 {BASELINE_MAX_BYTES // (1024 * 1024)} MiB 准备预算," + "尚未发布;请减少回放设备或核查历史采样密度后重试" + ) + async with self.repo.connection.transaction(): + await self.repo.save_batch(document["replay_id"], batch) + batch_count += 1 + row_count += len(outputs) + byte_count += batch_bytes + for row in outputs: + device_id = row["device_id"] + counts[device_id] += 1 + if row["quality"] == "valid": + valid_counts[device_id] += 1 + histories[device_id].append(row["cleaned_value"]) + histories[device_id] = histories[device_id][-window:] + last = rows[-1] + after = (last["time"], last["device_id"]) + document["baseline"] = { + "start_time": start.isoformat(), "end_time": config.start_time.isoformat(), + "end_exclusive": True, "sample_counts": counts, "valid_sample_counts": valid_counts, + "row_count": row_count, "batch_count": batch_count, "byte_count": byte_count, + } + document["histories"] = histories + if calibration is not None: + from app.mcp.analysis_service import bounded_calculation + document["monitoring_calibration"] = await bounded_calculation(calibration.fit) + document["warmed"] = True + async with self.repo.connection.transaction(): + await self.repo.publish_baseline(document["replay_id"]) + await self.repo.save(document) + async def _create_investigations(self, document, changes, batch, *, create_tasks=True): from app.domain.schemas.operational_task import OperationalTaskCreate, TaskSource, TaskTrigger from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository @@ -309,9 +441,20 @@ class ScadaReplayService: tasks = OperationalTaskService(OperationalTaskRepository(self.repo.connection, self.context.project_id), self.context, permissions_for_context(self.context)) for event in changes: + # Period diagnosis owns measurement notifications during manual steps. + # Retain rule events as evidence; quality alerts remain independent. + if (not create_tasks and event.get("category") != "data_quality" + and (event["change"] == "raised" or event.get("notification_suppressed"))): + if event["change"] == "raised": + event["batch_sequence"] = batch["sequence"] + event["notification_suppressed"] = True + document["rule_states"][event.get("state_key", event["rule_id"])]["event"].update( + batch_sequence=batch["sequence"], notification_suppressed=True, + ) + continue if event["change"] == "raised": event["batch_sequence"] = batch["sequence"] - document["rule_states"][event["rule_id"]]["event"]["batch_sequence"] = batch["sequence"] + document["rule_states"][event.get("state_key", event["rule_id"])]["event"]["batch_sequence"] = batch["sequence"] await alerts.push(TaskAlertEvent( event_id=uuid5(NAMESPACE_URL, event["event_id"] + ":" + event["change"]), alert_id=event["event_id"], producer_id=producer, event_type=event["change"], @@ -345,10 +488,30 @@ class ScadaReplayService: ) task = await tasks.create(command) event["task_id"] = str(task.task_id) - document["rule_states"][event["rule_id"]]["event"]["task_id"] = event["task_id"] + document["rule_states"][event.get("state_key", event["rule_id"])]["event"]["task_id"] = event["task_id"] async def _create_monitoring_task(self, document, monitoring, batch): - if monitoring["status"] != "suspected_burst": + from app.services.scada_monitoring_incidents import attach_incident, update_incident + incident, action = update_incident(document, monitoring, batch["sequence"]) + if incident is None: + return + if incident.get("task_id"): + if monitoring["status"] == "suspected_burst" and not incident.get("burst_diagnosed"): + from uuid import UUID + from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository + from app.services.operational_tasks import OperationalTaskService + tasks = OperationalTaskService( + OperationalTaskRepository(self.repo.connection, self.context.project_id), + self.context, permissions_for_context(self.context), + ) + await tasks.advance_replay_investigation(UUID(incident["task_id"]), { + "replay_id": document["replay_id"], "knowledge_cutoff": monitoring["knowledge_cutoff"], + "batch_sequence": batch["sequence"], + }, uuid5(NAMESPACE_URL, incident["incident_id"] + ":burst-evidence")) + incident["burst_diagnosed"] = True + if action == "resolved" and incident.get("alert"): + await self._publish_monitoring_alert(document, incident, "resolved") + attach_incident(monitoring, incident, action) return from app.domain.schemas.operational_task import OperationalTaskCreate, TaskSource, TaskTrigger from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository @@ -358,29 +521,44 @@ class ScadaReplayService: self.context, permissions_for_context(self.context), ) - external = ( - f"scada_replay:{document['replay_id']}:monitor:" - f"{monitoring['period_end']}:{monitoring['rule_version']}" - ) + external = incident["external_id"] pressure = monitoring["pressure"] flow = monitoring["outlet_flow"] detector = monitoring["burst_detection"] + score = detector.get("target_score") + score_text = f"{score:.4f}" if score is not None else "数据不足" summary = ( - f"步长监测疑似爆管:爆管侦测得分 {detector['target_score']:.4f};" - f"压力下降测点 {len(pressure['hit_device_ids'])} 个;" - f"出厂边界流量增加测点 {len(flow['hit_device_ids'])} 个。" + f"SCADA 数据诊断发现指标异常,进入异常调查与爆管诊断。" + f"爆管侦测得分 {score_text};" + f"压力下降测点 {len(pressure.get('hit_device_ids', []))} 个;" + f"出厂边界流量增加测点 {len(flow.get('hit_device_ids', []))} 个。" + "任一指标异常即启动调查,加权分仅用于评估爆管嫌疑,不能据此提前确诊。" ) + if monitoring.get("anomaly_signals"): + signals = monitoring["anomaly_signals"] + devices = {device for item in signals for device in item.get("device_ids", [])} + summary += f" 单项异常 {len(signals)} 项,涉及 {len(devices)} 个测点;明细通过冻结 SCADA 查询核验。" + if monitoring.get("weighted"): + weighted = monitoring["weighted"] + weights = weighted["weights"] + summary += ( + f" 综合评分 {weighted['score']:.2f},爆管嫌疑门槛 {weighted['threshold']:.2f}" + f"(侦测 {weights['burst_detection']:.0%}、压力 {weights['pressure']:.0%}、" + f"流量 {weights['outlet_flow']:.0%})。" + ) + if detector.get("score_threshold") is not None: + summary += f" 侦测动态阈值 {detector['score_threshold']:.4f}。" command = OperationalTaskCreate( request_id=uuid5(NAMESPACE_URL, external), task_type="event_investigation", - workflow="burst_report", - title=f"SCADA 回放疑似爆管 · {monitoring['period_end']}", + workflow="scada_anomaly", + title=f"SCADA 异常调查 · {monitoring['period_end']}", summary=summary, source=TaskSource( kind="external_system", external_id=external, external_revision=1, - event_kind="scada_burst_suspected", + event_kind="scada_anomaly", replay_context={ "replay_id": document["replay_id"], "knowledge_cutoff": monitoring["knowledge_cutoff"], @@ -390,8 +568,33 @@ class ScadaReplayService: trigger=TaskTrigger( kind="event", occurred_at=monitoring["period_end"], - detail="主动步长监测的多项指标相互支持,进入爆管调查;尚未现场确诊", + detail="SCADA 任一指标异常启动调查,核验数据后进行爆管诊断;异常不等于已确诊爆管", ), ) task = await tasks.create(command) - monitoring["task_id"] = str(task.task_id) + incident["task_id"] = str(task.task_id) + incident["burst_diagnosed"] = monitoring["status"] == "suspected_burst" + incident["alert"] = { + "alert_id": incident["incident_id"], + "producer_id": "scada-replay:" + document["replay_id"], + "occurred_at": incident["started_at"], + "condition_id": f"operational-task:{task.task_id}", + "source": {"kind": "scada_replay", "replay_id": document["replay_id"], + "rule_id": "scada_diagnosis", "batch_sequence": batch["sequence"]}, + "title": "SCADA 指标异常", "description": "已启动调查,详细诊断见调查报告。", "severity": "warning", + } + await self._publish_monitoring_alert(document, incident, "raised") + attach_incident(monitoring, incident, action) + + async def _publish_monitoring_alert(self, document, incident, change): + from app.domain.schemas.task_alert import TaskAlertEvent + from app.infra.db.postgresql.task_alerts import TaskAlertRepository + from app.services.task_alerts import TaskAlertService + alerts = TaskAlertService( + TaskAlertRepository(self.repo.connection, self.context.project_id), self.context, + internal_producer_id="scada-replay:" + document["replay_id"], + ) + await alerts.push(TaskAlertEvent( + **incident["alert"], event_type=change, + event_id=uuid5(NAMESPACE_URL, incident["incident_id"] + ":" + change), + )) diff --git a/app/services/scada_replay_monitoring.py b/app/services/scada_replay_monitoring.py index eebbd5a..b43d512 100644 --- a/app/services/scada_replay_monitoring.py +++ b/app/services/scada_replay_monitoring.py @@ -9,6 +9,8 @@ from uuid import NAMESPACE_URL, uuid5 import pandas as pd from app.domain.schemas.scada_replay import ScadaReplayCreate +from app.domain.schemas.project_settings import ScadaMonitoringSettings +from app.services.scada_normal_profile import current_calibration, normal_profile_bounds from app.services.burst_detection import ( TARGET_MU, TARGET_N_ESTIMATORS, @@ -42,6 +44,7 @@ def _relative_indicator( period_end: datetime, *, direction: str, + calibration: dict | None = None, ) -> dict[str, Any]: details: list[dict[str, Any]] = [] gaps: list[dict[str, str]] = [] @@ -54,30 +57,51 @@ def _relative_indicator( if rule is None: gaps.append({"device_id": device_id, "reason": "relative_change_rule_missing"}) continue - valid = _valid_rows(rows, device_id) - baseline = [row for row in valid if _time(row["time"]) < period_start][-rule.window_samples:] - current = [row for row in valid if period_start <= _time(row["time"]) <= period_end] - if len(baseline) < rule.minimum_samples: - gaps.append({"device_id": device_id, "reason": "baseline_not_ready"}) + profile = calibration.get(device_id) if calibration is not None else None + if calibration is not None and profile is None: + gaps.append({"device_id": device_id, "reason": "adaptive_history_not_ready"}) continue + threshold = float(profile["threshold_percent"]) if profile else rule.threshold + valid = _valid_rows(rows, device_id) + current = [row for row in valid if period_start < _time(row["time"]) <= period_end] + latest = current[-1] if current else None if not current: gaps.append({"device_id": device_id, "reason": "period_reading_missing"}) continue + baseline = ([row for row in valid if _time(row["time"]) < _time(latest["time"])] + [-rule.window_samples:]) if latest else [] + if len(baseline) < rule.minimum_samples: + gaps.append({"device_id": device_id, "reason": "baseline_not_ready"}) + continue reference = median(float(row["cleaned_value"]) for row in baseline) if reference == 0: gaps.append({"device_id": device_id, "reason": "zero_baseline"}) continue - latest = current[-1] value = float(latest["cleaned_value"]) change = (value - reference) / abs(reference) * 100 - hit = change <= -rule.threshold if direction == "decrease" else change >= rule.threshold + hit = change <= -threshold if direction == "decrease" else change >= threshold + bounds = normal_profile_bounds(profile.get("normal_profile"), _time(latest["time"])) if profile else None + if profile and bounds is None: + gaps.append({"device_id": device_id, "reason": "normal_phase_history_not_ready"}) + continue + if bounds is not None: + hit = hit and not bounds[0] <= value <= bounds[1] details.append({ "device_id": device_id, "observed_at": _time(latest["time"]).isoformat(), "baseline": reference, + "baseline_method": "recent_samples_median", + "baseline_start": _time(baseline[0]["time"]).isoformat(), + "baseline_end": _time(baseline[-1]["time"]).isoformat(), + "baseline_sample_count": len(baseline), "value": value, + "value_change": value - reference, + "unit": rule.unit, "change_percent": change, - "threshold_percent": rule.threshold, + "threshold_percent": threshold, + **({"normal_phase_lower": bounds[0], "normal_phase_upper": bounds[1]} if bounds else {}), + **({"threshold_method": profile["method"], + "configured_threshold_percent": rule.threshold} if profile else {}), "hit": hit, }) return { @@ -94,6 +118,8 @@ def _burst_detection( pressure_device_ids: list[str], interval_seconds: int, period_end: datetime, + *, + score_threshold: float = TARGET_SCORE_THRESHOLD, ) -> dict[str, Any]: if len(pressure_device_ids) < 5: return {"status": "sensor_coverage_insufficient", "hit": False, @@ -112,7 +138,10 @@ def _burst_detection( common_times = set(values) if common_times is None else common_times & set(values) if not common_times: return {"status": "baseline_not_ready", "hit": False, "required_samples": count} - target = max(common_times) + target = period_end + if target not in common_times: + return {"status": "current_reading_missing", "hit": False, + "target_time": target.isoformat(), "latest_common_time": max(common_times).isoformat()} expected = [target - timedelta(seconds=interval_seconds * offset) for offset in range(count - 1, -1, -1)] incomplete = [device_id for device_id, values in series_by_device.items() @@ -142,10 +171,11 @@ def _burst_detection( score = float(latest["Score"]) return { "status": "ready", - "hit": score <= TARGET_SCORE_THRESHOLD, + "hit": score <= score_threshold, "target_time": target.isoformat(), "target_score": score, - "score_threshold": TARGET_SCORE_THRESHOLD, + "score_interpretation": "lower_is_more_anomalous_not_probability", + "score_threshold": score_threshold, "sensor_count": len(pressure_device_ids), "reference_day_count": 14, "latest_sensor_rankings": result["summary"].get("latest_sensor_rankings", []), @@ -185,17 +215,64 @@ def evaluate_monitoring_period( flow_ids = monitoring.get("outlet_flow_device_ids", []) selected = set(config.device_ids) role_gaps = sorted((set(pressure_ids) | set(flow_ids)) - selected) - if not pressure_ids or not flow_ids or role_gaps: + weighted_policy = monitoring.get("decision_policy") == "weighted" + if not weighted_policy and (not pressure_ids or not flow_ids or role_gaps): return {**base, "status": "insufficient_data", "detail": "监测角色为空或不属于本轮回放设备", "role_gaps": role_gaps, "pressure": {"status": "unconfigured", "hit": False}, "outlet_flow": {"status": "unconfigured", "hit": False}, "burst_detection": {"status": "not_run", "hit": False}} - pressure = _relative_indicator(rows, config, pressure_ids, period_start, period_end, direction="decrease") - outlet_flow = _relative_indicator(rows, config, flow_ids, period_start, period_end, direction="increase") - detector = _burst_detection( - rows, pressure_ids, int(document["monitoring_interval_seconds"]), period_end, + calibration = current_calibration(document, config.start_time) if weighted_policy else None + if config.start_time > period_end: + calibration = None + base["role_gaps"] = role_gaps + pressure = _relative_indicator( + rows, config, pressure_ids, period_start, period_end, direction="decrease", + calibration=(calibration or {}).get("pressure", {}) if weighted_policy else None, ) + outlet_flow = _relative_indicator( + rows, config, flow_ids, period_start, period_end, direction="increase", + calibration=(calibration or {}).get("outlet_flow", {}) if weighted_policy else None, + ) if flow_ids else {"status": "unconfigured", "hit": False, "hit_device_ids": [], "devices": [], "gaps": []} + detector = {"status": "baseline_not_ready", "hit": False} if weighted_policy and ( + not calibration or calibration.get("status") != "ready" + ) else _burst_detection( + rows, pressure_ids, int(document["monitoring_interval_seconds"]), period_end, + score_threshold=calibration["burst_detection"]["threshold"] if calibration else TARGET_SCORE_THRESHOLD, + ) + if weighted_policy: + settings = ScadaMonitoringSettings.model_validate(monitoring) + weights = settings.weights.model_dump() + indicators = {"burst_detection": detector, "pressure": pressure, "outlet_flow": outlet_flow} + contributions = {key: weights[key] if indicator["status"] == "ready" and indicator["hit"] else 0.0 + for key, indicator in indicators.items()} + score = round(sum(contributions.values()), 10) + complete = pressure["status"] == detector["status"] == "ready" + diagnostic_missing = sorted({device for rule in config.rules if rule.kind == "relative_change" + for device in rule.device_ids} + - set((calibration or {}).get("diagnostic", {}))) + if not complete: + status, detail = "insufficient_data", ( + "旧回放缺少新版历史诊断校准,请停止后新建演示" if calibration is None + else "压力数据或爆管侦测基线不足,尚不能作出组合判定" + ) + elif score >= settings.alert_threshold: + status, detail = "suspected_burst", "爆管相关指标加权达到疑似爆管门槛" + elif any(indicator["hit"] for indicator in indicators.values()): + status, detail = "attention", "部分指标异常,进入调查;综合结果尚未达到爆管嫌疑门槛" + elif diagnostic_missing: + status, detail = "insufficient_data", "部分测点缺少完整历史同期基线,不能判定全部指标正常" + else: + status, detail = "normal", "本周期爆管相关指标均未命中" + detail += f"(综合 {score:.2f} / 门槛 {settings.alert_threshold:.2f},流量权重 {weights['outlet_flow']:.0%})" + return {**base, "status": status, "detail": detail, "pressure": pressure, + "outlet_flow": outlet_flow, "burst_detection": detector, + "weighted": {"score": score, "threshold": settings.alert_threshold, + "weights": weights, "contributions": contributions, + "unavailable_indicators": [key for key, value in indicators.items() if value["status"] != "ready"], + "calibration_cutoff": calibration["knowledge_cutoff"] if calibration else None, + "diagnostic_unavailable_device_ids": diagnostic_missing, + "missing_weight_policy": "zero_contribution_without_renormalization"}} complete = pressure["status"] == outlet_flow["status"] == detector["status"] == "ready" hits = (bool(detector["hit"]), bool(pressure["hit"]), bool(outlet_flow["hit"])) if not complete: diff --git a/app/services/scada_replay_processing.py b/app/services/scada_replay_processing.py index 6d9fe99..f636a66 100644 --- a/app/services/scada_replay_processing.py +++ b/app/services/scada_replay_processing.py @@ -6,6 +6,9 @@ from statistics import median from uuid import NAMESPACE_URL, uuid5 import re from app.domain.schemas.scada_replay import ScadaReplayCreate +from app.services.scada_normal_profile import ( + DIAGNOSTIC_POLICY, current_calibration, normal_profile_bounds, weighted_diagnosis, +) def prepare_cleaning(rows: list[dict], config: ScadaReplayCreate) -> list[dict]: @@ -43,6 +46,8 @@ def detect_frame(document: dict, rows: list[dict], config: ScadaReplayCreate, at by_id = {row["device_id"]: row for row in rows} changes = [] devices = {device["device_id"]: device for device in result.get("devices", [])} + weighted = weighted_diagnosis(result) + calibration = current_calibration(result, config.start_time) if weighted else None for device_id in config.device_ids: row = by_id.get(device_id) invalid = row is not None and row["quality"] != "valid" @@ -75,7 +80,8 @@ def detect_frame(document: dict, rows: list[dict], config: ScadaReplayCreate, at changes.append({**state["event"], "status": "resolved", "resolved_at": at.isoformat(), "change": "resolved"}) state["event"] = None for rule in config.rules: - state = states.setdefault(rule.rule_id, {"hits": 0, "recoveries": 0, "event": None}) + state_key = f"{rule.rule_id}:{DIAGNOSTIC_POLICY}" if weighted and rule.kind == "relative_change" else rule.rule_id + state = states.setdefault(state_key, {"hits": 0, "recoveries": 0, "event": None}) hits, usable, reasons = [], [], [] for device_id in rule.device_ids: row = by_id.get(device_id) @@ -86,6 +92,17 @@ def detect_frame(document: dict, rows: list[dict], config: ScadaReplayCreate, at triggered = ((rule.lower is not None and value < rule.lower) or (rule.upper is not None and value > rule.upper)) reason = f"{device_id}={value} {rule.unit},超出配置范围" + elif weighted: + # The percentage in the compatibility rule is not a diagnosis + # threshold. Learn each device's normal same-phase range first. + profile = (calibration or {}).get("diagnostic", {}).get(device_id) + bounds = normal_profile_bounds(profile, at) + if bounds is None: + continue + low, high = bounds + triggered = (value < low or value > high if rule.direction == "both" + else value > high if rule.direction == "increase" else value < low) + reason = f"{device_id}={value} {rule.unit},超出冻结正常同时段范围 [{low:.4f}, {high:.4f}] {rule.unit}" else: previous = histories.get(device_id, [])[-rule.window_samples:] if len(previous) < rule.minimum_samples: @@ -106,10 +123,12 @@ def detect_frame(document: dict, rows: list[dict], config: ScadaReplayCreate, at state["hits"] += 1 state["recoveries"] = 0 if state["event"] is None and state["hits"] >= rule.consecutive_samples: - event_id = str(uuid5(NAMESPACE_URL, f"{result['replay_id']}:{rule.rule_id}:{at.isoformat()}")) + event_id = str(uuid5(NAMESPACE_URL, f"{result['replay_id']}:{state_key}:{at.isoformat()}")) event = {"event_id": event_id, "rule_id": rule.rule_id, "device_ids": hits, "occurred_at": at.isoformat(), "last_observed_at": at.isoformat(), "status": "active", "detail": ";".join(reasons)[:2000], "task_id": None} + if weighted and rule.kind == "relative_change": + event.update(diagnostic_policy=DIAGNOSTIC_POLICY, state_key=state_key) state["event"] = event changes.append({**event, "change": "raised"}) result["anomaly_count"] = result.get("anomaly_count", 0) + 1 @@ -128,7 +147,9 @@ def detect_frame(document: dict, rows: list[dict], config: ScadaReplayCreate, at # Incomplete frames cannot count as a consecutive hit/recovery. elif not usable and any(device in by_id for device in rule.device_ids): state["hits"] = state["recoveries"] = 0 - frozen = {device for rule in config.rules if states[rule.rule_id]["event"] + frozen = {device for rule in config.rules if states[ + f"{rule.rule_id}:{DIAGNOSTIC_POLICY}" if weighted and rule.kind == "relative_change" else rule.rule_id + ]["event"] for device in rule.device_ids} for row in rows: if row["quality"] == "valid" and row["device_id"] not in frozen: diff --git a/app/services/scada_replay_queries.py b/app/services/scada_replay_queries.py index d088527..e205346 100644 --- a/app/services/scada_replay_queries.py +++ b/app/services/scada_replay_queries.py @@ -12,14 +12,23 @@ def check_replay_window(context, start, end): raise HTTPException(422, f"回放查询需要明确时间范围,结束不得晚于 {cap.isoformat()}") +def check_snapshot_start(run, start): + replay_start = datetime.fromisoformat(run["start_time"]) + if start < replay_start: + baseline = run.get("baseline") + if not baseline: + raise HTTPException(422, "本轮回放未冻结历史对照,请新建回放后查询历史基线") + if start < datetime.fromisoformat(baseline["start_time"]): + raise HTTPException(422, "查询起点早于本轮已冻结的历史基线") + + async def replay_snapshot_rows(connection, project_id, context, device_ids, start, end, limit, offset=0): check_replay_window(context, start, end) repository = ScadaReplayRepository(connection, project_id) run = await repository.get(context["replay_id"]) if not set(device_ids) <= set(run["config"]["device_ids"]): raise HTTPException(403, "设备或回放不属于当前调查") - if start < datetime.fromisoformat(run["start_time"]): - raise HTTPException(422, "当前调查证据接口仅返回本轮已处理观测,回放前基线已用于后台初始化") + check_snapshot_start(run, start) rows = await repository.detected_outputs( context["replay_id"], device_ids, start, end, context["batch_sequence"], limit=limit, offset=offset, @@ -40,9 +49,7 @@ async def replay_burst_location_inputs(connection, project_id, context, args): repository = ScadaReplayRepository(connection, project_id) run = await repository.get(context["replay_id"]) if run: - replay_start = datetime.fromisoformat(run["start_time"]) - if args.scada_burst_start < replay_start or args.scada_normal_start < replay_start: - raise HTTPException(422, "定位窗口必须位于本轮已处理回放范围内") + check_snapshot_start(run, min(args.scada_burst_start, args.scada_normal_start)) devices = {item["device_id"]: item for item in run.get("devices", [])} monitoring = run.get("monitoring_config") or {} pressure_devices = monitoring.get("pressure_device_ids", []) diff --git a/contracts/answer-ui-catalog.json b/contracts/answer-ui-catalog.json index daf4747..eba32b4 100644 --- a/contracts/answer-ui-catalog.json +++ b/contracts/answer-ui-catalog.json @@ -313,6 +313,14 @@ "bar" ] }, + "x_axis_name": { + "type": "string", + "maxLength": 128 + }, + "y_axis_name": { + "type": "string", + "maxLength": 128 + }, "x_data": { "type": "array", "items": { @@ -331,10 +339,43 @@ "type": "string", "maxLength": 200 }, + "type": { + "type": "string", + "enum": [ + "line", + "bar", + "scatter" + ] + }, "data": { "type": "array", "items": { - "type": "number" + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "minItems": 1, + "maxItems": 100 + }, + "sample_times": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "maxLength": 64, + "format": "date-time", + "pattern": "^(?:[1-9][0-9]{3}|0[1-9][0-9]{2}|00[1-9][0-9]|000[1-9])-[0-9]{2}-[0-9]{2}T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](?:\\.[0-9]+)?(?:Z|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])$" + }, + { + "type": "null" + } + ] }, "minItems": 1, "maxItems": 100 diff --git a/contracts/manifest.json b/contracts/manifest.json index 00c349d..5431e99 100644 --- a/contracts/manifest.json +++ b/contracts/manifest.json @@ -3,7 +3,7 @@ "contracts": { "server": { "file": "server-v1.openapi.json", - "sha256": "b039042be13243a88691309cb74eee105a3b71765c342edb9222374e41c0458e" + "sha256": "939f38733e943f21cc2e3d9f344fa8122ca7a7b35cfebf96ff3f00f469026ceb" } } } diff --git a/contracts/peripheral-sample.openapi.json b/contracts/peripheral-sample.openapi.json index 3005dcb..de77490 100644 --- a/contracts/peripheral-sample.openapi.json +++ b/contracts/peripheral-sample.openapi.json @@ -1687,6 +1687,18 @@ ], "title": "Location" }, + "occurred_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Occurred At" + }, "priority": { "anyOf": [ { diff --git a/contracts/server-v1.openapi.json b/contracts/server-v1.openapi.json index dc5c813..b5128ed 100644 --- a/contracts/server-v1.openapi.json +++ b/contracts/server-v1.openapi.json @@ -4683,6 +4683,13 @@ "title": "Execution Request Id", "type": "string" }, + "handling_context": { + "additionalProperties": { + "$ref": "#/components/schemas/JsonValue" + }, + "title": "Handling Context", + "type": "object" + }, "latest_plan": { "anyOf": [ { @@ -5653,6 +5660,13 @@ "title": "Execution Request Id", "type": "string" }, + "handling_context": { + "additionalProperties": { + "$ref": "#/components/schemas/JsonValue" + }, + "title": "Handling Context", + "type": "object" + }, "latest_run": { "anyOf": [ { @@ -7382,9 +7396,70 @@ "title": "ScadaLatestReadingsResponse", "type": "object" }, + "ScadaMonitoringIncident": { + "additionalProperties": false, + "properties": { + "action": { + "enum": [ + "raised", + "ongoing", + "resolved" + ], + "title": "Action", + "type": "string" + }, + "incident_id": { + "format": "uuid", + "title": "Incident Id", + "type": "string" + }, + "last_observed_at": { + "format": "date-time", + "title": "Last Observed At", + "type": "string" + }, + "recovery_count": { + "maximum": 2.0, + "minimum": 0.0, + "title": "Recovery Count", + "type": "integer" + }, + "started_at": { + "format": "date-time", + "title": "Started At", + "type": "string" + }, + "status": { + "enum": [ + "active", + "resolved" + ], + "title": "Status", + "type": "string" + } + }, + "required": [ + "incident_id", + "status", + "action", + "started_at", + "last_observed_at", + "recovery_count" + ], + "title": "ScadaMonitoringIncident", + "type": "object" + }, "ScadaMonitoringResult": { "additionalProperties": false, "properties": { + "anomaly_signals": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Anomaly Signals", + "type": "array" + }, "batch_sequence": { "minimum": 1.0, "title": "Batch Sequence", @@ -7401,6 +7476,16 @@ "title": "Detail", "type": "string" }, + "incident": { + "anyOf": [ + { + "$ref": "#/components/schemas/ScadaMonitoringIncident" + }, + { + "type": "null" + } + ] + }, "knowledge_cutoff": { "format": "date-time", "title": "Knowledge Cutoff", @@ -7465,6 +7550,18 @@ } ], "title": "Task Id" + }, + "weighted": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Weighted" } }, "required": [ @@ -7487,6 +7584,22 @@ "additionalProperties": false, "description": "Project-owned device roles used by the manual SCADA monitoring demo.", "properties": { + "alert_threshold": { + "default": 0.8, + "exclusiveMinimum": 0.0, + "maximum": 1.0, + "title": "Alert Threshold", + "type": "number" + }, + "decision_policy": { + "default": "weighted", + "enum": [ + "all_signals", + "weighted" + ], + "title": "Decision Policy", + "type": "string" + }, "outlet_flow_device_ids": { "items": { "type": "string" @@ -7504,17 +7617,48 @@ "type": "array" }, "rule_version": { - "default": "burst-monitor-v1", + "default": "burst-monitor-v2", "maxLength": 80, "minLength": 1, "pattern": "^[a-zA-Z0-9_.-]+$", "title": "Rule Version", "type": "string" + }, + "weights": { + "$ref": "#/components/schemas/ScadaMonitoringWeights" } }, "title": "ScadaMonitoringSettings", "type": "object" }, + "ScadaMonitoringWeights": { + "additionalProperties": false, + "properties": { + "burst_detection": { + "default": 0.5, + "maximum": 1.0, + "minimum": 0.0, + "title": "Burst Detection", + "type": "number" + }, + "outlet_flow": { + "default": 0.1, + "maximum": 1.0, + "minimum": 0.0, + "title": "Outlet Flow", + "type": "number" + }, + "pressure": { + "default": 0.4, + "maximum": 1.0, + "minimum": 0.0, + "title": "Pressure", + "type": "number" + } + }, + "title": "ScadaMonitoringWeights", + "type": "object" + }, "ScadaQualityRange": { "additionalProperties": false, "properties": { @@ -7700,6 +7844,16 @@ } ] }, + "monitoring_config": { + "anyOf": [ + { + "$ref": "#/components/schemas/ScadaMonitoringSettings" + }, + { + "type": "null" + } + ] + }, "monitoring_interval_seconds": { "anyOf": [ { @@ -7738,12 +7892,25 @@ "action": { "enum": [ "step", + "monitor", "stop", "retry" ], "title": "Action", "type": "string" }, + "at_time": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "At Time" + }, "expected_revision": { "minimum": 1.0, "title": "Expected Revision", @@ -8074,6 +8241,28 @@ "title": "Latest", "type": "array" }, + "monitoring_calibration": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Monitoring Calibration" + }, + "monitoring_config": { + "anyOf": [ + { + "$ref": "#/components/schemas/ScadaMonitoringSettings" + }, + { + "type": "null" + } + ] + }, "monitoring_count": { "default": 0, "title": "Monitoring Count", @@ -10041,6 +10230,18 @@ "title": "Title", "type": "string" }, + "x_axis_name": { + "anyOf": [ + { + "maxLength": 128, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X Axis Name" + }, "x_data": { "items": { "maxLength": 128, @@ -10050,6 +10251,18 @@ "minItems": 1, "title": "X Data", "type": "array" + }, + "y_axis_name": { + "anyOf": [ + { + "maxLength": 128, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Y Axis Name" } }, "required": [ @@ -10066,7 +10279,14 @@ "properties": { "data": { "items": { - "type": "number" + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] }, "maxItems": 100, "minItems": 1, @@ -10077,6 +10297,47 @@ "maxLength": 128, "title": "Name", "type": "string" + }, + "sample_times": { + "anyOf": [ + { + "items": { + "anyOf": [ + { + "maxLength": 64, + "pattern": "^(?:[1-9][0-9]{3}|0[1-9][0-9]{2}|00[1-9][0-9]|000[1-9])-[0-9]{2}-[0-9]{2}T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](?:\\.[0-9]+)?(?:Z|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "maxItems": 100, + "minItems": 1, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Sample Times" + }, + "type": { + "anyOf": [ + { + "enum": [ + "line", + "bar", + "scatter" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Type" } }, "required": [ @@ -28495,6 +28756,117 @@ ] } }, + "/api/v1/operational-tasks/recovery/reconcile": { + "post": { + "operationId": "post_operational_tasks_recovery_reconcile", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecoverExpiredLeasesCommand" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "integer" + }, + "title": "Response Post Operational Tasks Recovery Reconcile", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "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": "Reconcile Task Recoveries", + "tags": [ + "Operational Tasks" + ] + } + }, "/api/v1/operational-tasks/schedules": { "get": { "operationId": "get_operational_tasks_schedules", diff --git a/contracts/view-command.schema.json b/contracts/view-command.schema.json index a8c3f68..1b2c101 100644 --- a/contracts/view-command.schema.json +++ b/contracts/view-command.schema.json @@ -214,6 +214,14 @@ "bar" ] }, + "x_axis_name": { + "type": "string", + "maxLength": 128 + }, + "y_axis_name": { + "type": "string", + "maxLength": 128 + }, "x_data": { "type": "array", "items": { @@ -232,10 +240,43 @@ "type": "string", "maxLength": 128 }, + "type": { + "type": "string", + "enum": [ + "line", + "bar", + "scatter" + ] + }, "data": { "type": "array", "items": { - "type": "number" + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "minItems": 1, + "maxItems": 100 + }, + "sample_times": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "maxLength": 64, + "format": "date-time", + "pattern": "^(?:[1-9][0-9]{3}|0[1-9][0-9]{2}|00[1-9][0-9]|000[1-9])-[0-9]{2}-[0-9]{2}T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](?:\\.[0-9]+)?(?:Z|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])$" + }, + { + "type": "null" + } + ] }, "minItems": 1, "maxItems": 100 diff --git a/docs/burst-response-workflow.md b/docs/burst-response-workflow.md index 443b362..ae4a087 100644 --- a/docs/burst-response-workflow.md +++ b/docs/burst-response-workflow.md @@ -1,248 +1,62 @@ -# 从爆管上报到处置闭环 +# 从爆管工单到事件处置闭环 -本文定义爆管调查的完整业务路线,供维护者审阅和验收。运行时指令位于 [burst-report/SKILL.md](../resources/work-order-skills/burst-report/SKILL.md),由 Server 根据绑定任务的 `workflow=burst_report` 经 MCP 分发。 +工单触发的 `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 -flowchart TB - subgraph intake[工单进入与后台任务] - order[用户或外围系统提交爆管上报工单] - source[保存原工单及来源版本、发生时间、位置和描述] - sync[同步器按来源关联调查及工单投影] - queue[Server 保存调查任务和 queued run] - claim[Worker 投递,Agent 领取并维持租约] - context[读取任务摘要、业务 Skill、历史报告和既有动作] - order --> source --> sync --> queue --> claim --> context - end - - subgraph investigation[Agent 调查与工程分析] - scope[确定事件时刻、地点、调查目标和真实或演练来源] - location[核对已有资产编号;必要时解析地点和查询附近管段] - monitor[核对相关测点映射、异常窗口、正常对照和数据质量] - cover{查询是否实际覆盖事件与对照窗口} - more[缩小对象和窗口,按需补页,核对缺测及零值] - hypothesis{是否具备定位所需观测和漏水量依据} - locate[计算爆管候选排序,保留假设、覆盖范围与证据] - candidate[整合上报、资产关联、算法与现场反馈得到候选集合] - usable{是否存在可用于隔离评估的候选资产} - isolation[逐候选分析拓扑隔离边界、必关阀及受影响节点] - isolateok{可隔离且阀门可用性有可核验依据} - alternate[纳入已知不可关闭阀门,重新评估替代隔离方案] - hydraulic[按需模拟候选关阀组合的压力和流量影响] - impact[区分拓扑停水区、水力影响区和有资料支持的用户范围] - findings[形成候选位置、阀门清单、影响对比和建议方案] - fieldbasis{是否已有足以支持具体现场核验的结论} - fielddraft[形成有位置、核验目标及回执要求的现场核验建议] - gap[真实关键数据缺失:发布 final insufficient_data 并结束本轮] - error[接口、权限或配置异常:记录具体失败并由宿主结束失败运行] - scope --> location --> candidate - scope --> monitor --> cover - cover -->|未覆盖且可补齐| more --> monitor - cover -->|已覆盖或确认实际缺失| hypothesis - hypothesis -->|具备依据| locate --> candidate - hypothesis -->|条件不具备,明确原因| candidate - candidate --> usable - usable -->|候选有效,标明待现场确认| isolation --> isolateok - isolateok -->|有可评估方案| hydraulic --> impact --> findings - isolateok -->|已知存在不可关阀| alternate --> isolation - isolateok -->|无法形成方案| fieldbasis - usable -->|尚无候选| fieldbasis - fieldbasis -->|可形成具体核验建议| fielddraft - fieldbasis -->|当前判断所需证据确实缺失| gap - monitor -->|不可恢复的查询异常| error - location -->|不可恢复的必要查询异常| error - end - - subgraph approval[报告、审批与现场处置] - interim[保存本轮 interim concluded 阶段报告] - proposals[创建具体待审批草稿,关联证据和执行前置条件] - finalround[保存本轮 final 报告,由宿主结束本轮 run] - review[有权限人员审核当前草稿版本] - decision{审批结果} - revise[驳回理由保存并触发原调查新一轮] - submit[Server 通过配置的适配器幂等提交] - receipt{外部提交结果} - reconcile[使用同一执行请求号核对回执,不重复下发] - unsupported[未配置或明确失败:记录原因并重新分析] - workkind{本次获批的是核验还是处置} - fieldcheck[现场核验准确位置、漏水迹象及资产,回传实际记录] - notice[人工实施获批的通知、供水保障和应急安排] - isolatefield[现场核验阀门及实际隔离结果] - repair[现场定位、止水和抢修,回传实际故障管段及修复记录] - clean[按现场风险及获批方案清洗、消毒或冲洗] - restore[核验修复与适用复测,按批准方案恢复供水] - follow[记录恢复后压力流量、漏水复查及用户回访] - findings --> interim - fielddraft --> interim - interim --> proposals --> finalround --> review --> decision - decision -->|驳回| revise - decision -->|批准| submit --> receipt - receipt -->|结果不明或仍在执行| reconcile --> receipt - receipt -->|明确失败或不支持| unsupported - receipt -->|工单已创建或设备动作有回执| workkind - workkind -->|现场核验事项| fieldcheck - workkind -->|有依据的处置事项| notice - notice --> isolatefield --> repair --> clean --> restore --> follow - end - - subgraph closure[现场反馈、复核与原事件闭环] - feedback[外围系统保存关联工单的新反馈和单调版本] - requeue[Server 校验来源并为原调查排队新 generation] - reread[重新读取现场事实、执行回执和最新监测证据] - verify[对照预测与现场结果,复核修复、恢复和风险] - faultstate{复核结果支持故障、误报还是尚待核验} - falsealarm[核对排除故障的证据及人工确认] - closed{处置及闭环证据是否齐全} - remaining[列出未完成事项及对应已有或待审批动作] - sourceclosed{原上报工单已结案且版本已核对} - readyreport[发布具备结案条件报告并结束本轮] - closedreport[发布闭环复核 final 报告并结束本轮] - closeowner[有权限人员在原工单系统核对并完成原事件结案] - closedcase[报告与来源事实一致,确认业务闭环] - follow --> feedback --> requeue - fieldcheck --> feedback - reread --> verify --> faultstate - faultstate -->|已确认故障| closed - faultstate -->|有依据排除故障| falsealarm --> sourceclosed - faultstate -->|尚需核验| remaining - closed -->|尚未齐全或复测异常| remaining --> interim - closed -->|证据齐全| sourceclosed - sourceclosed -->|是| closedreport --> closedcase - sourceclosed -->|否| readyreport --> closeowner --> feedback - reread -->|现场位置或阀门条件改变| candidate - end - - subgraph presentation[贯穿调查的地图、图表与数据整理] - progress[发布真实调查进展和 Mermaid 状态] - map[高亮候选管段、闪烁建议关阀、展示已有证据覆盖层] - chart[打开异常与对照曲线,展示候选及影响对比表] - filepermit{会话是否已有文件写入权限} - jsonl[按权限整理有来源引用的 JSONL 调查数据] - reportui[通过报告 ui_blocks 发布 json-render 结构化界面] - sourceview[展示保持证据来源,失败不阻断业务调查] - progress --> map --> chart --> filepermit - filepermit -->|已获准且需要导出| jsonl --> reportui - filepermit -->|未获准或无需导出| reportui - reportui --> sourceview - end - - context -->|首次或需要重新分析| scope - context -->|已有新反馈待复核| reread - revise --> requeue - unsupported --> requeue - requeue --> queue - gap -->|补充事实被 Server 接受后才重开| requeue - findings -->|无须新增动作| finalonly[无需新增动作时保存 final 报告并结束本轮] - scope -.-> progress - candidate -.-> map - impact -.-> chart - verify -.-> reportui +flowchart TD + order[爆管上报工单] --> context[读取原工单与当前任务] + context --> asset[核对地点并关联管网资产] + asset --> isolation[分析隔离边界与阀门方案] + isolation --> impact[说明事件与隔离方案影响] + impact --> report[保存阶段报告与处置建议] + report --> approval[人工审批与现场执行] + approval --> feedback[接收实际处置与恢复反馈] + feedback --> verify[复核影响与结案条件] + verify --> close[来源系统确认事件闭环] + verify -->|新增事实或遗留事项| asset ``` -图中的现场处置由实际审批、工单与回执推进;顺序按现场紧迫性和获批方案调整。必要的紧急通知、现场核验和供水保障不应等待可选的完整模型计算。`clean` 为按风险适用的步骤,不能把每次爆管都判断为污染。`hydraulic` 在缺少模型、时序输入或权限时记录具体限制,仍可保留已完成的拓扑隔离结果。 +1. **接收核对**:保留来源工单ID、单调版本、发生时间、位置、描述、现场反馈和已有动作;后续轮次从新增事实接续。 +2. **资产关联**:优先核对上报资产编号。坐标或地址通过地理编码、附近资产、属性和连接关系形成有依据的关联;位置歧义列为具体待确认项。 +3. **隔离方案**:用 analyze_valve_isolation 分析关联资产,保留必关阀、可选阀、直接隔离与供水失源节点;已知失效阀门用于替代方案分析,现场可操作性列为执行条件。 +4. **影响说明**:先展示拓扑范围,再按可用SCADA、水力结果和用户关联资料解释事件当前影响、方案预计影响和保障需求。 +5. **报告与处置**:保存阶段报告后提出核验、通知、保障、抢修和恢复等动作草稿;关联现场任务采用 general 工单。审批、派发和现场完成分别核对回执。 +6. **反馈复核**:已派发的关联工单保持 `waiting_work_order`,全部收到有效 `completed` 反馈后进入 `waiting_scada`。以现场反馈业务时间为起点,核验之后连续两个完整采样周期;正常范围使用事件前15天同采样相位的正常包络。数据未到或仍异常时保持等待,后台工作进程定期复核;通过后持久保存恢复证据并接续 Agent。Agent 最终报告必须引用该证据,运行完成后才能完成任务并解除对应告警。原事件由有权限人员在来源系统确认行政结案。 -`requeue` 表示 Server 接受了新业务事实后分配下一轮,不是 Agent 复活旧会话或自行循环等待。原工单结案由原系统有权限人员处理,后台 Agent 目前没有直接关闭原工单的工具。`completed`、`concluded`、动作 `succeeded` 和“业务闭环”分别核对,不能互相推导。 +SCADA作为事件影响的辅助证据,按目标测点和事件/对照窗口读取。具备水力输入且工具已授权时,使用 analyze_valve_closure 评估给定阀门方案;各步骤记录实际结果、适用范围或具体待补项。 -## 将业务步骤映射为现有能力 +## 报告固定内容 -以下产品查询与计算均使用 `mcp_tjwater_` 前缀。参数以本轮实际发现的工具 schema 为准。 +报告包含事件概况、地点与资产关联、隔离方案、影响说明、处置建议和待办。摘要以150字以内为目标,资产和测点明细放表格,完整依据引用证据。 -| 业务动作 | 已有入口 | 必须形成的结果 | -| --- | --- | --- | -| 接续原任务 | `get_task_context`,依次读取 summary、skill 及所需分节;`get_task_run_history` | 原工单来源、事件时刻、调查代次、流程版本、既有动作与现场反馈 | -| 查找地点和资产 | `geocode` / `search_project_places`、`query_nearby_elements`、`get_element_properties`、`get_node_connections` | 实际候选管段或节点、位置来源、匹配理由;邻近不代表故障确认 | -| 核对监测 | `list_scada_devices`、`get_scada_device`、`query_scada_readings`、`query_element_history` | 设备和模型节点的映射、异常及对照窗口、单位、缺测、分页覆盖 | -| 压力异常侦测 | `run_burst_detection` | 至少 5 个完整压力测点及此前 15 天数据满足时才调用;不把侦测当作定位 | -| 爆管候选定位 | `run_burst_location` | 候选排序、算法实际返回的评价值、观测范围、漏水量假设及来源 | -| 逐候选隔离 | `analyze_valve_isolation` | `accident_elements`、`isolatable`、`must_close_valves`、`optional_valves`、`affected_nodes`、`affected_node_count` 及已知 `disabled_valves` | -| 水力影响 | `analyze_valve_closure` | 指定关阀组合、时间窗、输出对象和模拟压力流量;参数不足时保留明确原因 | -| 用户影响 | 已授权资产属性、区域及外围资料查询 | 受影响对象、关联依据和覆盖限制;没有用户资料不估造户数 | -| 发布和提议 | `publish_task_progress`、`publish_task_report`、`propose_task_action` | 持久化报告、证据引用和待审批动作;动作不是执行事实 | -| 反馈接续 | Server `external-feedback` / `human-evidence` 及 worker | 同一任务的新轮次、真实来源版本、修复和复测证据 | +“影响说明”逐项保留时间、对象、单位、范围、来源与实际或预计的性质: -定位的 `burst_leakage` 必须有依据,单位为 m³/s;`pressure_scada_ids` 是模型节点 ID,不能填设备查询编号,也可省略并使用服务的项目配置。monitoring 模式正常窗可成对省略,使用前一天同窗,但这只是一项对照假设,仍要核对数据有效性。simulation 模式必须引用已有分析运行 UUID,不把在线重算当成已有分析运行。已知事故管段可直接做候选隔离;不必先完成一次没有必要的定位计算,也不能把已知答案作为算法独立定位成功的证明。 +| 影响项 | 依据与输出 | +| --- | --- | +| 事件当前影响 | 已查询测点的压力、流量、持续时间和恢复情况 | +| 直接隔离范围 | affected_nodes及对应计数、地图覆盖 | +| 新增供水失源范围 | supply_affected_nodes及对应计数、地图覆盖 | +| 水力影响 | 给定阀门组合、起算时间、模型条件下的压力和流量结果 | +| 用户与保障影响 | 有资料关联的用户、重点设施、保障需求;缺少关联时报告节点与区域范围 | -隔离工具支持明确标注的候选管段,无须等到现场确认才评估方案。多个候选的比较优先分开调用;把多个 ID 一次传入表示联合事故场景,不等同于逐个比较。`disabled_valves` 仅填已有事实支持的不可关阀;空数组不证明现场阀门均可用。`isolatable=false` 不能解释为零影响,即使 `affected_nodes` 为空也须解释返回的计数和限制。 +变化量基于同口径对象、单位、时间与模型条件计算;现场实际、模型预测和空间示意分别标注。缺少可选模拟参数时,继续形成已有依据的隔离与保障建议。 -## 像调查员一样查询、查看、比较和复核 +## 工具与入口约束 -每一步先明确待回答的问题,再查小范围资料,观察实际结果后决定下一步。例如:核实地址,查附近管段并查看属性,关联压力点,比较事件前后及正常对照,检查候选,评估关阀影响。只把必要的查询、事实和判断依据写入进度及报告,不保存模型内部推理。 +工单事件从上报位置开展资产关联。Agent会话策略隐藏 run_burst_location,Server在绑定与后续解析时同步移除其调用能力;普通聊天和SCADA候选定位沿用各自策略。持久计算在执行前重新校验任务绑定。 -- 不满足于“查过 SCADA”:必须确认返回的时间实际覆盖事件。查询 24 小时而只返回当天前 45 分钟时,应缩到事件前后的小窗,按需补页,不直接进入最终判断。 -- 正常基线、实测、模型模拟、外围演练和人工反馈分别标注;缺测不是零值,异常不是爆管确认,候选评分不是校准后的概率。 -- 查不到事故管段时先检查地址解析、附近资产与上下游关联;能查询的字段先查询,不能直接全部交给现场。 -- 既有证据通过 `get_task_evidence` 的有界分页读取,不重复拉取同一批事实。失败、权限拒绝和存储未配置不当作“无异常”或“无数据”。 -- 可以用最多两个前台 `investigation-reader` 并行核对无依赖的问题。读取、计算、发布和文件权限仍按角色执行;reader 只返回查询事实及真实证据编号。 +历史 burst_report 任务携带 replay_context 时,Server分发scada-anomaly,时序查询保持 knowledge_cutoff 与 batch_sequence,demo能力提示按冻结窗口返回。 -## 地图与图表操作 +业务阶段和工单、返回时间、复核回执保存在任务文档 `handling_context` JSON 中,不增加数据库列。外围样板更新工单时可提供带时区的 `occurred_at`,同时必须填写现场反馈;该时间写入活动记录,系统的 `updated_at` 仍是实际记录时间。同步器将现场反馈时间传给调查,避免历史演示误用电脑当前时间。 -后台调查统一调用 `publish_task_presentation`,每个不同命令使用不同 UUID `request_id`,相同命令重试复用原编号。以下为工具已有能力,不需要浏览器自动化。 +预约时间 `scheduled_at` 可在派单前的工单草稿和已创建工单的编辑表单中调整。实际完工反馈时间与预约安排分别记录;Agent 以实际反馈时间为恢复复核起点。新的有效现场反馈修订完工时间后,旧复核证据保留为历史记录,当前复核起点更新并重新等待新时间之后的两个正常监测周期。只修改预约安排或其他工单信息,不改写已有现场反馈时间。 -| 调查进展 | command.action | 取值及依据 | -| --- | --- | --- | -| 找到实际候选资产 | `highlight_features` | `layer=pipes` / `junctions`,`feature_ids` 为已核实的地图要素编号,`effect=steady` | -| 正在核查候选或建议关阀 | `highlight_features` | 对应 pipes 或 valves,`effect=pulse`;闪烁只表示调查关注,不代表设备正在操作 | -| 查看异常与对照 | `open_chart` | 有限长度的时间和值,引用真实 `evidence_refs`;不同日期的对照对齐方式在标题或来源中说明 | -| 查看已有空间结果 | `set_overlay` | 提供 `overlay_id/title/mode/evidence_id`,Server 从证据提取几何,不接收手绘影响区 | -| 撤下旧的临时展示 | `close_chart` / `clear_overlay` / `clear_highlights` | 清理自己记录的临时展示;清理失败不修改业务结果 | +`POST /operational-tasks/recovery/reconcile` 由现有 operational worker 每轮调用,按项目及任务创建边界处理等待复核的任务;SCADA 读取失败不阻断其他调查派发。复核直接读取项目时序库中完工反馈时间之后的 SCADA,不依赖回放状态或推进回放时间。 -支持的图层为 junctions、pipes、valves、pumps、tanks、reservoirs、scada。先核实业务 ID 与地图 feature ID 的映射,不能把设备编号、节点编号与 GIS 编号混用。每次高亮最多 100 个要素,需要更多时说明所显示的子集;不要假定多次命令会累积成完整范围。没有几何时可以按已知 ID 高亮、用表格解释结果,不画包络多边形冒充影响范围。 +## 验证 -图表最多 100 个横轴值和 8 组数值序列,单位须在系列名称或标题中明确。不能把 null 缺测填零以通过 schema;可以按有数据的区间分图,并保留缺测说明。`recorded` 仅表示请求保存。浏览器未开 Dev 跟随时仍继续调查,不能等待地图显示回执。后台接口目前没有摄像机缩放、平移、鼠标点击或任意图层开关命令;不要调用仅供聊天的 `locate_features`、`zoom_to_map` 或原生浏览器工具替代。 +检查Skill名称、版本、摘要与正文digest一致、内联体积、角色权限、工单工具隔离和SCADA定位回归。Agent类型检查与会话工具策略测试共同覆盖运行时入口。发布后分别验收工单事件处理报告和SCADA候选定位,原有报告按既有修订保留。 -## JSONL 调查数据与 json-render 展示 - -JSONL 用于整理已经取得的数据与分析结果,不补造监测、阀门、现场反馈或审批。推荐每行一个对象,记录 `record_type/origin/source_evidence_refs`,并按对象保留时间窗、单位、模型或假设、覆盖限制和实际结果。`origin` 沿用来源分类;本地派生记录引用输入证据,并说明转换方法。不要把 JSONL 文件自身当作 Server 已保存的业务证据。 - -按内容选择 `event_context`、`monitoring_coverage`、`candidate`、`isolation`、`hydraulic_impact`、`field_feedback`、`closure_check`,无需为没有结果的类别制造空壳文件。候选记录写实际排序及评价值;隔离记录保留建议必关阀、可选阀、已知不可关阀及受影响节点;闭环记录逐项保留已核验、待核验或不适用及原因。JSONL 是导出约定,不新增 MCP 输入字段或服务端数据协议。 - -以下两行只演示文件格式,`synthetic`、`example_only` 与空证据引用表示它们不可用于实际报告或审批,不包含真实分析结果: - -```jsonl -{"record_type":"candidate","origin":"synthetic","example_only":true,"source_evidence_refs":[],"element_id":"DEMO-PIPE-01","rank":null,"evaluation_value":null,"status":"not_computed","limitations":["仅说明字段,未执行定位"]} -{"record_type":"closure_check","origin":"synthetic","example_only":true,"source_evidence_refs":[],"item":"repair_verified","status":"not_verified","observed_at":null,"limitations":["无现场反馈,不能声称闭环"]} -``` - -如果当前会话已获准使用原生文件写入,则在当前 workspace 下写 `files/burst-investigation-.jsonl`,保留原始证据不变;通过允许的读取工具逐行复核 JSON、行数、对象 ID、时间和单位。只引用宿主绑定的 generation,不能从用户文本自选。不得读取兄弟会话、业务数据库、宿主源码或凭据;需要本地转换时只使用已获准的原生工具及沙箱 Shell,不引入 HTTP、SQL 或自由命令查询桥。 - -当前没有专用的免审批 JSONL 导出工具。文件权限未授予时,不通过 Shell 绕过、不让后台无限等待批准、不声称已生成文件;继续完成报告内的结构化数据,并如实说明文件未保存。恢复写入授权须由有权限的人或宿主处理,Skill 不扩大权限。 - -图表、表格和指标使用 `publish_task_report.report.ui_blocks`,每块为 `answer-ui@1`,包含独立 `block_id`、`fallback_text` 和 json-render 的 `spec.root/elements`,外层附真实 `evidence_refs`。最多 8 块、总计 400 KB,单块还受现有 schema 限制。优先组织以下内容: - -| 展示 | 建议组件 | 数据来源 | -| --- | --- | --- | -| 候选管段及理由 | Table、Evidence、MapLocation | 地点、资产与定位证据;明确候选和已确认的区别 | -| 异常及正常对照 | Chart | 同单位的实际序列,说明时间对齐及缺测 | -| 关阀与隔离范围 | Table、Metric、MapLocation | 隔离证据、阀门属性及候选场景 | -| 水力与用户影响 | Table、Chart | 指定输出节点的模拟结果及真实用户关联资料 | -| 处置与闭环核对 | Table、Evidence | 动作回执、现场反馈、恢复监测和原工单版本 | - -只用工具公布的组件属性。JSONL 可作为整理中间格式,但目前没有“把任意 JSONL 直接传给 json-render”的通道;需转换为已注册组件的 JSON 属性。后台不用 `present_answer`,不把原始 JSONL 输出到聊天正文,也不把表单提交当成已保存现场证据。小数据和简单结论可直接文字表达。 - -## 本轮成果与爆管闭环的验收 - -报告至少回答下列四类问题,逐项给出实际结果或经过查询证实的具体限制;无需固定排版或创建固定数量的动作。 - -1. **位置**:事故已确认还是仅有候选?每个候选的模型/地图编号、匹配或定位依据、排名及未确认事项是什么? -2. **隔离**:每个候选场景是否可隔离?应关、可选和已知不可关的阀门分别是什么?有无替代方案及现场前置核验? -3. **影响**:拓扑受影响节点、水力压降或失供范围、用户关联分别有哪些实际结果?未覆盖什么?没有水力或用户资料时分别说明,不能推定为零。 -4. **处置与闭环**:当前可审批的工作是什么?已有哪些动作、审批、提交回执和现场事实?还缺什么反馈才具备结案条件? - -有具体结论和动作建议时,先保存 `interim/concluded`,再 `propose_task_action`,最后保存 `final`。人工执行事项使用 `work_order_type=general`,保留原爆管事件。工单描述写位置、任务内容、依赖、执行前核验、需回传的事实和责任要求;`propose_task_action` 当前 payload 没有独立的 dependencies 或 feedback_requirements 字段,不发明字段。没有客户通知适配器时,将通知作为人工工单事项,不宣称已发送。 - -闭环复核至少核对实际故障资产及原因、隔离/阀门实施记录、抢修结果、适用的清洗及水质复测、恢复供水记录和恢复后监测、用户通知及回访、未决动作与遗留风险、原上报工单结案状态。不适用事项必须有事件依据;未收到结果写待核验,不把记录创建时间当现场完成时间。只齐备处置证据而原工单未结案时,结论为“具备结案条件,待原系统确认”,而非“已闭环”。误报或未发现故障的结案也要有支持排除的实际核查与人工确认。 - -必要查询失败、权限拒绝和存储未配置属于执行异常,按 schema 修正可纠正问题;仍无法取得当前判断必需的资料时交宿主记录失败。已通过有效查询确认关键数据确实缺失且无法补齐,才发布 `final/insufficient_data`,提供 blocking 缺口和查询依据,建议与待办为空,不在该退出分支再生成补数据工单。能够明确形成一项现场核验结论时可提议具体核验动作,但不能把“尚未开展定位”包装为“已完成定位调查”。 - -## 能力边界与验证 - -本次交付为工程图和运行时 Skill 文本,使用现有 MCP、发布、审批与反馈能力;不新增自动关单、鼠标自动化、摄像机操作、JSONL 导出接口或数据库字段。Server 目前没有按 Skill 检查定位/隔离成果的强制验收器,本文的业务验收要求仍由 Agent 执行并由人复核。 - -当前 Skill 仅通过 `section=skill` 整份分发,没有相对路径 reference 读取协议,因此运行时指令保持自包含,不能要求 Agent 读取本工程文档。内容需控制在既有内联预算内。Server 更新资源后,应按原发布方式让实际 Server 实例取得新文件;Agent 读取时核对 `name/version/content_sha256`,不把本地文件更新当成运行中实例已生效。 - -验证包含 Mermaid 语法解析、本地路径和工具 ID 核对、Skill 分发/权限/体积测试及 `git diff --check`。后续真实行为验收应分别覆盖:有效观测和漏水量依据下的定位;已知候选但缺漏水量时的候选隔离;不可关阀替代方案;SCADA 首屏未覆盖事件时继续读取;接口失败明确终止;文件权限缺失不绕过;无浏览器时照常调查;实际反馈触发新轮及最终闭环复核。没有真实模型计算或现场回执,不报告定位准确性或业务闭环已通过。 - -关联契约:[分析 MCP](mcp-analysis-tools.md)、[调查展示](investigation-progress-and-presentation.md)、[报告契约](investigation-report-contract.md)、[任务与多轮反馈](operational-tasks.md)、[后台工作进程](operational-task-workers.md)。 +关联契约:[分析 MCP](mcp-analysis-tools.md)、[调查展示](investigation-progress-and-presentation.md)、[报告契约](investigation-report-contract.md)、[任务与多轮反馈](operational-tasks.md)。 diff --git a/docs/mcp-query-implementation.md b/docs/mcp-query-implementation.md index 05e11a9..2f84ebf 100644 --- a/docs/mcp-query-implementation.md +++ b/docs/mcp-query-implementation.md @@ -16,7 +16,9 @@ Agent 根据401 challenge 或路径/根 well-known 发现受保护资源元数 普通查询默认20条;目录类最多500条,时序最多10000条、20个标识、24小时;offset 最大10000。Agent 首次优先单对象、必要的短时间窗和小页,仅在证据不足时按需扩大,不自动遍历全网。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`。精简目录仍超预算时沿用证据引用或缩页路径。 `get_task_context` 默认 section=summary,只查固定元数据、160字符任务标题及200字符结论预览,并返回 available_sections;preview_only 和 truncated 明确说明不是完整报告。按 section=task/report/evidence/actions/progress/access_requests/plan/run 读取详情,report_revision 保留选择旧报告的语义。data_path 选择嵌套值,limit 默认20、最多500,offset 最大10000;证据、动作和授权申请集合以稳定 ID 为键,按 ID 字典序分页。字符串按字符分页。详情在数据库返回前检查8KB预算,超限缩页或选更深路径。缺少可选进度或授权申请表时返回 metadata.available=false,不冒充空事实。绑定权限范围只读取七类既有 scope,不再加载整个任务上下文。不增加工具数量、数据库表或迁移,也不改变原 REST 完整上下文契约。 @@ -26,7 +28,9 @@ Agent 根据401 challenge 或路径/根 well-known 发现受保护资源元数 ### Server 分发业务处置 Skill -业务流程源文件位于 `resources/work-order-skills/*/SKILL.md`,随 Server 镜像分发。`get_task_context` 摘要提供 `handling_skill`(适用 workflow、名称、版本和 SHA-256)及 `display_time_zone=Asia/Shanghai`;使用 `section="skill"` 读取完整正文。流程由服务端持久化任务的 workflow 选择,调用方不能传入文件路径或其他任务类型;未知 workflow 返回 `available=false`,不自动套用其他流程。 +业务流程源文件位于 `resources/work-order-skills/*/SKILL.md`,随 Server 镜像分发。`get_task_context` 摘要提供 `handling_skill`(适用 workflow、名称、版本和 SHA-256)及 `display_time_zone=Asia/Shanghai`;使用 `section="skill"` 读取完整正文。流程由持久化任务的 workflow 与 replay_context 选择:工单 burst_report 使用 burst-event-handling,scada_anomaly 及带回放来源的历史 burst_report 使用 scada-anomaly;未知 workflow 返回 available=false。工单事件处理的绑定工具范围移除 run_burst_location,保留资产关联、隔离和影响分析。 + +报告阅读时区统一为 UTC+8(Asia/Shanghai)。MCP 在原始证据和回执保存后,将模型可见结果中的显式带时区时间转换为 `+08:00`,保留同一时刻和源小数秒精度;存储、查询参数及冻结窗口沿用原始时刻,标识符与引用保持原文。前端对报告正文、交互表格、证据卡片、图表提示及数据表采用同一阅读时区,并兼容历史正文中的 `Z`、`UTC` 和 `UTC0` 标记。历史正文中省略时区的时间按读者默认 UTC+8 展示。 当前覆盖爆管上报、水质投诉与预约跟进。业务 Skill 描述风险核查、隔离停水、用户通知、现场处置和恢复条件;Agent 本地 Skill 维护通用执行方法。权限、审批和执行仍由 Server 代码控制。流程说明没有 evidence_id,不记录成事件证据。具体草稿由现有 `propose_task_action` 保存并等待人工审批;人工通知等步骤通过关联预约工单承接,不能当作通知已发送或现场工作已完成。 diff --git a/docs/peripheral-sample.md b/docs/peripheral-sample.md index b832e73..808fd35 100644 --- a/docs/peripheral-sample.md +++ b/docs/peripheral-sample.md @@ -41,7 +41,7 @@ Forward Hostname 后追加 `/api/sample/v1`。本机开发和预览模式也将 SCADA 按月数据集、5 分钟时间定位、主动周期监测、质量校验、爆管调查与定位边界见 [SCADA 外围系统时段回放、主动监测与爆管调查设计](scada-peripheral-replay-design.md)。部署回放存储需在项目 BizDB 执行 `031_scada_replays.sql`;项目通用配置需在 MetaDB 执行 `033_metadata_project_settings.sql`。监测频率保存于 `projects.settings.scada_monitoring_interval_seconds`,压力与出厂边界流量角色保存在类型化 `projects.settings.scada_monitoring` 中;本阶段不注册定时任务,也不部署 SCADA 回放 worker。代码和结构迁移完成不等于月度数据或 Agent 联调已经验收。 -演示数据通过 `scripts/seed_peripheral_sample.py` 写入 `tjwater_v2` 的 `peripheral_sample` schema。当前固定窗口为 2026-09-17,所有记录标记为 `synthetic`,并使用 `DEMO-` 前缀。 +独立监测样本和测试数据通过 `scripts/seed_peripheral_sample.py` 写入 `tjwater_v2` 的 `peripheral_sample` schema。固定窗口为 2026-09-17,样本标记为 `synthetic` 并使用 `DEMO-` 前缀;业务工单保持 `system_data` 来源。 ## 工单事件调查 diff --git a/docs/scada-peripheral-replay-design.md b/docs/scada-peripheral-replay-design.md index ecb4ad9..37d839a 100644 --- a/docs/scada-peripheral-replay-design.md +++ b/docs/scada-peripheral-replay-design.md @@ -16,10 +16,35 @@ 本版冻结设备、区间、规则及批次输出,按批次原始值摘要校验源变更。完整月度数据清单、全月源版本摘要、独立事件真值和检测效果评估仍是后续数据准备工作;能力接口仅判断回放存储是否存在与当前操作权限,不声明月度数据已经就绪。现有 14 天数据不能当作一个月验收。 -Agent 查询本轮已检测的固定批次快照,受任务的 replay_id、knowledge_cutoff、batch_sequence 约束。开始前历史只供后台基线初始化,本版调查查询不返回该部分;爆管定位已支持受回放截止约束的冻结输入适配,其他未适配计算工具继续拒绝。新 [SCADA 调查 Skill](../resources/work-order-skills/scada-anomaly/SKILL.md) 要求据实说明这些证据限制。运行异常自动建立调查,数据质量异常仅建立告警,不触发模型;恢复时推进调查代次。真实模型推理与报告效果尚未联调。 +Agent 查询本轮已检测的固定批次快照,受任务的 replay_id、knowledge_cutoff、batch_sequence 和设备集合约束。新回放首次监测以只读一致快照冻结开始前 15 天历史,负序号静默基线按最多 2,000 行分块暂存为 `clean_prepared`;全部读取完成后,在一个 BizDB 短事务中把批次转为 `detected` 并原子发布基线与初始化标记,历史查询和定位对照才能读取。 + +读取各页期间不持有 BizDB 事务,内存只保留当前页和设备短历史。固定准备预算为 1,000,000 行、512 MiB 序列化批次,覆盖 500 设备十五天每 15 分钟一条所需的 720,000 行并留有余量。超限不保存超限页,状态明确返回 `BASELINE_BUDGET_EXCEEDED` 及中文原因;未发布批次不进入观测、监测或待处理批次。失败重试只清理当前项目、本轮回放的未发布基线,从新的一致源快照重新准备,已发布基线保持不变。 + +旧回放没有冻结基线时明确拒绝回放前查询,不回落到实时 TSDB。爆管定位支持受回放截止约束的冻结输入适配,其他未适配计算工具继续拒绝。[SCADA 调查 Skill](../resources/work-order-skills/scada-anomaly/SKILL.md) 要求据实说明证据限制。运行异常自动建立调查,数据质量异常仅建立告警;恢复时推进调查代次。真实模型推理与报告效果尚未联调。 控制进度和事件状态使用有界轮询,沿用告警流发布业务告警;本版未新增 scada.replay.changed SSE 主题。演示面板只显示最近一个周期的组合结论,逐测点输出和完整事件历史由分页 API 保留给诊断与调查入口,不在主面板重复铺开。 +## Case 3 临时定位 mock(2026-11-07) + +`tjwater2_v2` 及其隔离演示项目 `tjw_bench_tjwater2_20261105` 中,2026-07-16 +08:00–13:00(北京时间)的 monitoring 定位暂用快速 mock。异常窗起点可提前 15 分钟, +终点必须已经到达事件时刻且不超过事件结束;其他项目、时间和 simulation 输入仍运行原算法。 +mock 直接返回并沿用 MCP 权限、回放冻结证据校验、调查证据与幂等回执,不提交慢计算队列。 + +预设候选为同一 DN600 线路的 `515870`。每次读取当前拓扑,验证候选与事件位于完全相同的 +可隔离分区、所需边界阀一致,且不跨阀的同径管线路径距离为 200–500 m。距离定义为实际 +事件节点沿管线至候选管段最近点的距离;当前为 495.368 m,不以两条管段中点计算。 +无法满足任一条件时返回空候选,不扩大隔离区,也不回落到耗时定位。 + +候选结果固定返回 `similarity=0.9708` 和 `similarity_percent=97.08`,展示为97.08%。 +同一案例在 T 与 T+15 分钟返回相同候选与排序。Agent可见结果不再附加 `mock`、 +`demo_mock` 说明、空相似度或 `simulation_times=0` 等阻断提示,也不虚构水力仿真次数。 +MCP 回放证据仍保留 `origin=synthetic`;固定相似度是该演示路径的预设返回值,未运行水力定位。 +取得候选后继续地图展示、隔离边界与影响分析。 +事件真值仅在服务端 fixture 中用于约束校验, +不返回给 Agent。撤回时移除 `app/services/burst_location_demo.py` 的两项目匹配即可恢复原路径; +不修改观测、管网或关阀数据。本项不代表自动监测建单或真实 Agent 报告已完成验收。 + ## 已确定的设计边界 - 数据集覆盖一个月,包含多次独立异常及各次事件发生、持续、恢复的过程。 @@ -59,7 +84,7 @@ MetaDB 的 `projects.settings` 为项目级通用 JSONB 对象,默认 `{}`, "settings": { "scada_monitoring_interval_seconds": 900, "scada_monitoring": { - "rule_version": "burst-monitor-v1", + "rule_version": "burst-monitor-v2", "pressure_device_ids": ["明确的压力设备 ID"], "outlet_flow_device_ids": ["明确的出厂边界流量设备 ID"] } @@ -67,10 +92,62 @@ MetaDB 的 `projects.settings` 为项目级通用 JSONB 对象,默认 `{}`, } ``` -当前代码已类型校验并在创建回放时冻结 `scada_monitoring`。项目没有出厂流量或不足 5 个完整压力点时保持缺项,能力接口说明原因,监测回执返回 `insufficient_data`;不能为了让演示通过而把其他流量点冒充出厂流量或虚构设备。`tjwater_v2` 现有目录只有通用 `pipe_flow` 类型及模型管段映射,没有足以证明“出厂边界”的角色字段,因此本轮未写入该嵌套配置。 +当前代码已类型校验并在创建回放时冻结 `scada_monitoring`。不足 5 个完整压力点时保持缺项,能力接口说明原因,监测回执返回 `insufficient_data`。加权模式允许出厂流量缺项,其贡献为零且不重分配权重;`all_signals` 模式仍要求出厂流量就绪。不能把普通流量点冒充出厂流量或虚构设备。`tjwater_v2` 未补写未经核实的监测角色。 部署前需在 MetaDB 执行 `resources/sql/033_metadata_project_settings.sql`。迁移会将实验版本可能存在的独立监测间隔列搬入 `settings`,核对冲突后移除旧列;重复执行不丢失已有配置。当前 `tjwater_v2` 与 `tjwater2_v2` 均配置 900 秒,即 15 分钟的主动监测周期。该参数不改变 5 分钟时间轴定位,本阶段明确不实现通用定时任务调度。 +### 动态阈值与加权判定(2026-11-07) + +新建回放默认使用 `burst-monitor-v2`,配置仍位于项目 `settings.scada_monitoring`: + +```json +{ + "rule_version": "burst-monitor-v2", + "decision_policy": "weighted", + "pressure_device_ids": ["已核实的至少5个压力设备"], + "outlet_flow_device_ids": ["已核实的出厂流量设备"], + "weights": {"burst_detection": 0.5, "pressure": 0.4, "outlet_flow": 0.1}, + "alert_threshold": 0.8 +} +``` + +三项权重均为 0–1 且总和必须为 1。每项达到自身阈值时贡献其固定权重,加权和达到 +`alert_threshold` 才判为疑似爆管。默认侦测与压力共同命中可得 0.90,出厂流量仅提供 +0.10 辅助支持。缺数或未配置的流量贡献为零,不把剩余权重重新归一化;压力或侦测 +数据不足时仍返回数据不足。综合分不是概率,也不代表现场确诊。 + +首步只用回放开始前完整 15 天的冻结历史学习阈值,在历史发布事务中一起保存 +`monitoring_calibration`;随后固定使用,事件发生后的数据不会参与更新阈值。准备时 +为角色测点与参与相对变化规则的测点保留紧凑数值序列,最多 500 个测点; +15 分钟周期下数值数组上限约 5.8 MB。继续逐页保存基线,不重新聚合全部设备的 raw。 +15 天历史不完整就声明不足,不插值或借用未来数据。 + +压力与流量按设备计算此前若干样本中位值对应的历史变化率,阈值取正常变化中位值加 +3 倍稳健波动尺度,并分别保留 3%、5% 下限。侦测阈值从冻结正常历史的得分估计,取 +`min(-0.04, 正常得分中位值-3×稳健波动尺度, 正常得分5%分位值-0.02)`。 +稳健波动尺度在本实现中定义为 `1.4826×MAD`。保留真实侦测分数,调整判定阈值; +不按已知事件日期制造异常分数。周期回执保存实际阈值、权重、各项贡献和校准截止时间。 + +加权流程的测点诊断采用 `normal-phase-envelope@1`:按前 15 天同一日采样相位的正常值 +建立 `[历史最小值−3×稳健波动尺度, 历史最大值+3×稳健波动尺度]` 包络,保留数值精度余量。 +普通测点越过所配置方向的同期包络即可启动调查,不再用固定 30% 门槛筛选。 +零基准、日常反向流量、近零分母的百分比变化不再单独制造异常。压力/出厂流量的 +爆管参考贡献还需越过其历史动态变化阈值;只命中同期包络的有效单点异常仍可进入调查。 +显式水质上下限保留。角色参考的滚动基线严格取目标观测之前的样本,与校准窗口对齐; +本周期必须有新观测,模型必须具备周期终点读数,不能复用上一时刻的得分。 + +本轮校准版本为 `scada-monitor-calibration@2`,并保存 `diagnostic_policy`、各测点包络与 +截止时间。缺少完整历史的测点不回退到固定百分比,并在监测回执的缺项中列出。 +已预热的加权回放缺少新版校准时,推进/重试返回 409,明确要求停止后新建; +旧相对规则事件不进入新诊断信号,已有历史事件和工单不删除。新事件采用独立规则状态和策略标识。 +请求 schema 中的 `relative_change.threshold` 仅为既有协议兼容保留,在加权测点诊断路径 +不参与触发;同一规则的窗口参数仍用于角色指标的动态变化计算。 + +既有冻结回放没有 `decision_policy` 时沿用三项同时命中的旧判定;新加权配置需新建 +回放才生效,不更改旧回放的历史结论。显式 `decision_policy=all_signals` 仍为旧规则兼容路径, +不用于当前 Case3 加权演示,也不是新版校准缺失时的回退路径。 +本项修改无需数据库迁移。 + ## 实施前基础与原设计差距 下表记录实施前基础,用于说明改动缘由;本次实际实现以首版范围及后文接口表为准。设备目录采样周期为本轮讨论中 2026-11-02 的只读核查快照,与项目监测任务间隔是两个概念。 @@ -202,7 +279,7 @@ SCADA 单步命令在当前 API 请求内按原始时间顺序完成 raw 读取 演示建立后改变时间区间需要停止并创建新回放;保留旧回放的事件和证据,不把旧回放时间游标直接重设。已完成区间需要再次演示时,新建独立回放;不自动循环处理和重复触发调查。 -选定区间之前的可用历史仅用于已声明的基线初始化,不占用主时间轴、不作为新到达批次、不预触发此前事件。基线不足时在当前区间显示具体可用性,不能悄悄扩大演示范围或读取区间之后的数据。 +选定区间之前的可用历史用于已声明的基线初始化及冻结历史对照,不占用主时间轴、不计入当前处理量、不预触发此前事件,也不回写 TSDB 的 cleaned_value。基线不足时保留实际行数和模型数据缺口,不能悄悄扩大演示范围或读取区间之后的数据。 处理到选定终点后停在终点,显示数据处理是否完成及剩余调查数量;不会自动继续处理该月剩余数据。空区间明确显示没有观测,不能通过控件动画假装已经收到 SCADA 数据。 @@ -234,8 +311,8 @@ SCADA 单步命令在当前 API 请求内按原始时间顺序完成 raw 读取 2. 汇总压力下降幅度。只使用映射明确、单位一致、质量合格的压力测点,并同时保留异常测点数、覆盖测点数、基线窗口和最大或中位下降幅度。 3. 汇总出厂流量变化。设备必须在项目配置或设备档案中明确标注为“出厂边界流量”,不能把任意 `pipe_flow` 测点当作出厂流量;保留方向、单位、基线和增量。 4. 条件满足时运行爆管侦测。现有侦测要求至少 5 个完整压力点和目标时刻之前 15 天同周期数据;不满足时返回 `baseline_not_ready` 或 `sensor_coverage_insufficient`,不能将“未运行”解释为“无异常”。 -5. 组合上述证据形成监测结论;只有疑似爆管才创建 `event_investigation`,工作流使用现有 `burst_report`,并绑定本轮 `replay_context`。正常周期只保存回执,不启动 Agent;数据不足周期保存缺口并在界面展示。 -6. `burst_report` Skill 核对证据后决定是否调用爆管定位。定位必须有 m³/s 的有依据漏量估计或明确情景假设,以及模型节点映射、异常窗和正常对照窗;输入不具备时保持“疑似爆管、定位待补充”,不能填默认漏量。 +5. SCADA 数据诊断发现任一已配置指标异常,就创建 `event_investigation`,使用现有 `scada_anomaly` 工作流并绑定本轮 `replay_context`。调查先核验数据,再进行爆管诊断;综合分用于判断爆管嫌疑,不能作为启动调查的前置门槛。没有异常的正常周期只保存回执。数据不足但已有有效异常信号时仍发起调查并列出缺口。 +6. `scada_anomaly` Skill 核对异常证据并进行爆管诊断,输入具备时决定是否调用爆管定位。定位必须有 m³/s 的有依据漏量估计或明确情景假设,以及模型节点映射、异常窗和正常对照窗;输入不具备时保持“疑似爆管、定位待补充”,不能填默认漏量。 组合判定采用可版本化策略,首版演示建议如下: @@ -243,10 +320,10 @@ SCADA 单步命令在当前 API 请求内按原始时间顺序完成 raw 读取 | --- | --- | --- | | `insufficient_data` | 必需点位缺测、质量无效、单位或角色未配置,或爆管侦测基线不满足 | 保存缺口,不声明正常,不启动定位 | | `normal` | 数据完整,爆管侦测未命中,压力与出厂流量规则均未命中 | 保存周期回执,不创建 Agent 任务 | -| `attention` | 只有单项指标命中,或指标间方向不一致 | 保存观察结果;下一步继续监测,必要时由人发起调查 | -| `suspected_burst` | 爆管侦测命中,且压力下降与出厂流量增加在同一业务时间窗内相互支持 | 创建一次 `burst_report` 调查;定位输入具备后进入计算 | +| `attention` | 单项指标命中、指标方向不一致,或周期内曾出现测点异常 | 创建或复用 `scada_anomaly` 调查,进入爆管诊断 | +| `suspected_burst` | 加权分达到门槛,或旧版联合策略的三项指标共同命中 | 同一份 `scada_anomaly` 调查标记爆管嫌疑;定位输入具备后进入计算 | -爆管侦测和压力下降存在相关性,因此回执不能把两者描述成完全独立的两票。出厂流量也会受泵站调度、储水设施和正常用水变化影响。组合结论只是调查触发条件,不是现场确诊。 +爆管侦测和压力下降存在相关性,因此回执不能把两者描述成完全独立的两票。出厂流量也会受泵站调度、储水设施和正常用水变化影响。组合结论用于评估爆管嫌疑,不是调查启动门槛或现场确诊。 回放中的 MCP 定位已走 Server 持有的安全适配层:按 `replay_id`、`knowledge_cutoff` 和 `batch_sequence` 从已固化清洗输出组装压力与可选流量序列,再调用现有计算队列。回放只允许 monitoring 模式,并要求显式异常窗、正常对照窗和有依据的 `burst_leakage`;不能直接查询 TSDB 中尚未回放的未来记录。步长爆管侦测同样只读取最近 15 天已固化输出。计算结果保存输入、算法与证据引用,任务恢复或重试仍使用相同知识上限。 @@ -366,7 +443,7 @@ BizDB 批次状态是持久待处理队列,通知只负责唤醒。后台启 ## Agent 调查与时间隔离 -主动单步在边界形成组合监测回执:一般关注和数据不足只保存回执,只有 `suspected_burst` 进入 `burst_report`。SCADA 演示请求只负责建立持久调查任务,不要求先生成一条合成 test_data,也不在请求内执行 Agent。 +主动单步在边界形成组合监测回执:任一有效指标异常进入 `scada_anomaly` 调查;缺少爆管判别资料不能阻止已知异常进入调查。SCADA 演示请求只负责建立持久调查任务,不要求先生成一条合成 test_data,也不在请求内执行 Agent。 告警事实与“待建立调查”意图在事件事务中保存。创建调查失败时后台对账重试;组合监测调查的来源稳定标识使用 `scada_replay::monitor::`,一般事件继续使用 `scada_replay::event:`,均沿用现有外部来源幂等约束。事件不因 Agent 不可用而丢失。 @@ -391,7 +468,7 @@ BizDB 批次状态是持久待处理队列,通知只负责唤醒。后台启 | `GET /api/v1/scada-replays/capabilities` | 回放表可用性、写权限、监测频率、爆管组合监测角色是否就绪及最近一轮状态;不代表完整月度数据就绪 | | `POST /api/v1/scada-replays` | 固定区间、设备、规则和质量量程,返回 ready;携带稳定 request_id | | `GET /api/v1/scada-replays/{replay_id}` | 权威控制、时间、计数、设备与固定规则、当前输出及故障 | -| `POST /api/v1/scada-replays/{replay_id}/commands` | play、pause、step、set_speed、stop、retry;request_id + expected_revision | +| `POST /api/v1/scada-replays/{replay_id}/commands` | step、monitor、stop、retry;request_id + expected_revision,monitor 另需带时区的 at_time | | `GET /api/v1/scada-replays/{replay_id}/observations?through=...` | 仅本轮已读取时间范围内的历史快照,每设备最近一条、最多 500 个 | | `GET /api/v1/timeseries/scada-readings/raw` | 当前项目 TSDB 原始值联合游标分页;不插入、不隐式清洗 | | `GET /api/v1/scada-replays/{replay_id}/events?offset=0&limit=100` | 检测变化与调查关联,返回 items、next_offset | @@ -437,7 +514,7 @@ next_cursor 绑定项目、设备、查询区间并固定本轮分页时间上 - 周期内多批次单步、重复单步拒绝、并发请求互斥和尾步完成状态。 - 5 分钟时间定位与 15 分钟监测周期相互独立、非整点起点、周期配置变更不影响当前回放、尾步不足完整周期和未配置时禁止开始。 - 每个步长边界只产生一份幂等监测回执;正常、数据不足、单指标关注和疑似爆管分别处理,不为正常周期创建 Agent。 -- 爆管侦测基线不足、压力下降、出厂流量变化、设备角色错误、指标方向冲突和多指标同时命中;只有疑似爆管建立 `burst_report`。 +- 爆管侦测基线不足、压力下降、出厂流量变化、设备角色错误、指标方向冲突和多指标同时命中;单指标、部分资料不足及疑似爆管均复用 `scada_anomaly` 调查入口,正常且无异常信号时不建单。 - 回放 MCP 侦测与定位只能读取固定批次和知识截止前数据;定位缺少漏量依据时停止在待补充,不调用算法。 - 选定小时区间、跨日区间和再次演示;刻度与进度只对应选定区间,基线初始化不释放旧事件,到终点不自动播放整月。 - FM 红线固定、刻度带移动、拖动回看不重复投递、未来位置不泄露读数;桌面与 375px、320px 窄屏下的长日期、键盘、减少动态效果及玻璃回退。 @@ -474,3 +551,39 @@ next_cursor 绑定项目、设备、查询区间并固定本轮分页时间上 | 仓库 | Server、供水前端、Agent 的 git diff --check 通过;未提交、推送或部署 | 生产联调仍需核验真实 HTTP 接口、调查执行链路和 Agent 报告,并单独准备完整月度数据及事件真值。不能将以上局部测试解释为这些环节已经完成。 + +### 同一事故的告警与调查去重(2026-11-07) + +同一轮回放首次检出任一有效指标异常时,持久保存 `monitoring_incident`,发送一次 SCADA 异常告警并创建一份 `scada_anomaly` 调查工单,先核验数据再进行爆管诊断。T+15 等后续周期复用事故 ID 和工单,回执 `incident.action=ongoing`;缺数、部分命中或仍处低压时也保持该事故。每个周期的检测结果仍独立保存,原工单的证据截止时间不随每个周期滚动;若单项异常首次升级为疑似爆管,使用既有 `advance_replay_investigation` 推进同一工单的调查代次,为新代次冻结新增证据,仍不重复报警或建单。 + +只有连续两个相邻的正常监测周期,且首次命中的压力测点全部有新的有效读数、达到事故开始时冻结基准的恢复范围,才将事故标记为恢复并解除同一条告警。恢复范围使用首次压力基准与该点下降阈值,不能因滚动基线适应低压就解除告警;缺数、过期读数或跳过周期会中断连续恢复确认。确认恢复后再次检出指标异常,才新建事故和调查。流量异常的恢复也需核对其冻结基准,不能仅依赖已适应高流量的滚动基线。 + +主动步进中,压力、流量变化与水质上下限规则保留检测事件作为证据,测量指标的异常通知统一由周期诊断发出;数据质量通知继续独立。周期内已恢复的短暂异常仍保存为本周期调查依据,不因结束时读数恢复而丢失。状态、告警、工单及批次在原有处理锁和同一业务事务内保存,失败重试沿用确定的请求标识。旧回放首次升级时可承接上一周期已有工单,不批量合并历史重复工单。主动创建新一轮回放仍属于独立演示,不跨回放合并事故。 + +精简演示面板保留时间区间与 FM 时间轴,集中显示最新结论、监测区间、综合分与门槛;指标权重和命中情况按需展开。持续事故明确显示“沿用已有调查工单,本周期未重复报警”,下一周期处理期间保留上一周期结论并标明处理进度。 + + +### 面板规则与实际流程(2026-11-07) + +面板以“SCADA 数据诊断 → 任一指标异常 → 爆管诊断 → 报告”为入口,不以爆管综合分筛掉单指标异常。`/capabilities.monitoring_config` 返回新一轮采用的项目规则;回放快照的 `monitoring_config` 和 `monitoring_calibration` 返回本轮实际冻结的配置及阈值。进行中的回放不套用后来修改的项目配置,旧回放未保存判定策略时按原有 `all_signals` 展示。 + +规则区显示实际三项权重、爆管嫌疑门槛、动态阈值学习状态与历史区间。基线就绪后显示实际侦测阈值及压力/出厂流量的逐测点阈值范围;基线不足不展示虚构阈值。加权角色测点采用实际动态阈值,不再套用固定 30% 输入;“采样范围与基础规则”配置区已移除,面板只读展示实际诊断规则。已具备有效流量基线时,即使压力模型基线不足也保留该流量异常,不以组合资料缺口屏蔽单项诊断。`anomaly_signals` 保存三项爆管参考指标之外的有效测点异常,数据不足与单项命中也能创建同一诊断工单。当前阶段仍需单独验收真实 Agent 推理与报告。 + +演示新建默认采用 Case3 的北京时间 2026-07-16 07:30–08:30,事件起点为 08:00,即事件前后各 30 分钟。存在其他时段的活动回放时,提供“切换到 Case3”,先停止旧回放再设定该区间,不自动推进或创建调查;后续监测按钮按项目周期推进。停止或完成的旧回放不再覆盖新建演示默认区间,也不将旧结果显示为 Case3 的结论。 + +### 自动播放与简短通知(2026-11-07) + +调查任务仍按 `replay_context.knowledge_cutoff` 与 `batch_sequence` 截断知识,后续播放不自动扩大当前调查窗口。任务摘要只保留指标与异常数量,逐测点明细通过冻结 SCADA 证据查询。`scada-anomaly` 要求高嫌疑诊断继续候选定位;当前 Case3 的任务摘要和 skill 响应返回 `location_demo` 能力提示。提示可用时 `run_burst_location` 允许省略 `burst_leakage`,仍先核验冻结异常窗、正常窗及压力设备,再调用固定候选方法;水力算法仍要求漏量依据。候选输出不再含 mock 提示,MCP回放证据保留 `origin=synthetic`,不向 Agent 暴露事件真值。 + +回放调查开放项目资产属性、空间查询、拓扑隔离和节点覆盖,沿用原有业务权限。它们依据当前模型,不代表历史现场状态。实时 TSDB、在线模拟等未接入冻结输入的路径仍不可用;候选定位不以出厂流量命中或现场确诊为前提。 + +点击“自动播放”后,浏览器以 180 倍速推进演示时钟,一小时区间约用 20 秒播放,后端处理耗时另计。到达项目监测周期边界时调用原有 `step` 接口,每次只允许一个监测请求,等待持久回执后再继续。播放时钟与已处理时间分别展示;时间轴的手动浏览仍按 5 分钟定位,手动拨动先暂停自动播放,不直接触发处理。暂停、关闭面板、切换页签或页面转到后台后停止自动推进;接口失败暂停,终点自动结束,不自动重试或新建下一轮。此能力属于前台演示,不引入通用后台定时任务或新的 SCADA worker。 + +通知只展示异常状态、调查已启动或沿用原调查及时间,通过“查看调查”打开已有调查与报告。指标、评分、基线、定位和处置依据保留在调查摘要与报告中,Server 告警描述不再复制完整任务摘要。首次实际监测检出新的异常时,局部图标以 1.4 秒单次扩散和轻微缩放强调;同一异常下一周期及旧回执刷新不重播,减少动态效果模式保留文字并关闭动画。异常事件的持久去重与冻结证据规则继续有效。 + + +### 指针选定时刻监测 + +前端“回到开始时间”只把指针定位到所选区间起点并暂停播放,不清空已处理进度,也不自动调用监测。显式点击“监测选定时刻”才提交 `POST /api/v1/scada-replays/{replay_id}/commands`,动作 `monitor`,携带带时区的 `at_time`、`request_id` 和 `expected_revision`。服务端复用逐帧读取、清洗、检测、历史基线和调查流程,知识截止与最终处理时间精确停在 `at_time`,不自动推进到下一个监测周期。选定起点也可以监测;同一已完成时刻的重复请求复用已有回执,不重复计次、解除告警或创建调查。 + +`at_time` 必须在本轮区间内,不能替换进行中的处理步;直接要求本轮回到已经处理过的更早时刻返回 409。前端在该场景下提前说明将建立新一轮,停止旧轮后使用原区间配置新建回放,再处理至选定时刻;原回放、告警和调查保留,历史判断不携带旧轮的后续状态。`step` 继续按冻结的项目周期从本轮起点对齐,选定 08:05 后下一步为 08:15,而不是 08:20。此变更新增命令字段,不需要数据库迁移;需同步更新 Server、前端 API 客户端与 OpenAPI 契约。 diff --git a/docs/tjwater-v2-burst-benchmark.md b/docs/tjwater-v2-burst-benchmark.md new file mode 100644 index 0000000..36906cd --- /dev/null +++ b/docs/tjwater-v2-burst-benchmark.md @@ -0,0 +1,101 @@ +# tjwater_v2 爆管 Agent Benchmark + +状态:场景框架与只读数据预检已准备;隔离执行环境、真实触发链、Agent 轨迹采集和正式评分器尚未实现。2026-11-03。 + +## 目标与边界 + +本套 benchmark 用固定的管网状态、触发条件和隐藏真值,比较提示词、Skill、MCP 工具或模型配置变更对爆管处置的影响。首版限于 `tjwater_v2` 爆管,不扩展水体污染。已有数据库是数据来源;每次正式执行必须在独立评测数据库和运行时完成,原项目数据库不承担工单、SCADA 清洗、告警、任务、计算或证据写入。 + +```text +原 MetaDB / BizDB / TimescaleDB + | 仅 SELECT、只读事务 + v +版本化私有数据快照 -> 隔离评测 BizDB + TimescaleDB -> Server/MCP/worker -> Agent Host + | | + +----轨迹与业务结果----+ + | + 评分报告 + 版本归档 +``` + +评测 Agent 只能连接隔离环境。单靠客户端 `READ ONLY` 事务不足以保证未来服务不误连原库;运行配置必须使用隔离数据库路由,并由原库侧评测账号权限拒绝写入。现有 `temporary_project_database`/模型模板复制路径不能直接用于原项目,因为它会暂时关闭连接并终止源库会话。应在独立实例先生成逻辑快照,再把可写副本作为评测环境。 + +## 场景矩阵 + +配置文件 `resources/benchmarks/tjwater-v2-burst.json` 定义四个候选物理工况: + +| 场景 | 管段 | 管径 | 发生时间(北京时间) | 当前观测准备状态 | +|---|---:|---:|---|---| +| B01 | 540672 | DN315 | 2026-10-08 02:00 | 需在隔离集生成事件观测 | +| B02 | 540672 | DN315 | 2026-10-08 12:00 | 历史回放声明待核对当前窗口 | +| B03 | 537407 | DN600 | 2026-10-08 02:00 | 需在隔离集生成事件观测 | +| B04 | 537407 | DN600 | 2026-10-08 12:00 | 需在隔离集生成事件观测 | + +每个工况各有工单上报(WO)和 SCADA 监测(SCADA)入口,共 8 个候选用例。另设无爆管正常控制作为误报检查,不算第三类业务。管段、时间和历史分析名称仅是候选定义;准备报告须检查观测窗口、参考工况和触发是否吻合。未满足条件的用例标为 `not_ready`,不能作为 Agent 失败,也不能从统计中静默删除。 + +工单路径使用 `burst_report` 来源,经既有外围工单同步、操作任务 worker、Agent Host 和 MCP 完成调查。SCADA 路径从隔离副本的正常数据起步,再释放事件数据,由真实 replay worker/规则创建通用异常调查;不能把目标管段或爆管类型预先告诉检测器。纯规则预览只作准备检查,不能代替这条端到端路径。 + +工单位置由冻结的网络 GIS 几何转成稳定的人类可读位置,结合近似范围和现象描述,不把真实 link ID 直接写成答案。Agent/Server 业务结果是首版主分数;前端呈现单独做 Playwright 结果回放检查,不能用工作台 mock 结果代替 Agent 评测,也不混入业务能力分。 + +首版使用单一持续爆管,不模拟修复恢复;预计结束状态是有证据的调查报告,可以合理等待人工确认或审批。现有模型只含 2 个阀门,目标管段即使移除全部模型阀门仍与水源连通,因此成功关闭隔离不作为成功标准。Agent 应说明拓扑限制和现场核查需求,不得编造阀门方案。 + +## 真值、可见信息与评分 + +真值包在运行前冻结并与 Agent 输入分开保存,至少包含:管段及端点、泄漏参数和单位、观测窗口、传感器映射、独立拓扑参考、允许的等价候选集合、定位是否具备有效流量输入。工单只给近似位置、时间和现象;SCADA 检测器只能看到已释放观测及设备元数据。隐藏真值不得进入 Skill、提示词、任务说明、MCP 上下文、可读工作区、文件名或执行轨迹。 + +由于当前仿真将 emitter 放在管段端点,邻近管段可能不可区分。定位适用的用例按冻结的等价候选集合和排名评估(Hit@1/3/10),不强求唯一 ID。爆管定位需要有效的 `burst_leakage`(m³/s);孔口面积(cm²)不能直接代替流量。缺少输入时,评分应奖励明确说明限制和所需数据,不鼓励猜测。 + +总分 100,结果与过程分开报告: + +| 维度 | 权重 | 评分依据 | +|---|---:|---| +| 业务结果 | 60 | 触发来源与结论置信度 10;证据、时间窗、单位和数据质量 15;定位 Hit@1/3/10 或正确 abstain 15;影响与不可隔离判断 10;报告和人工后续建议 10 | +| 工具执行 | 20 | 场景清单定义的唯一必需操作有效完成率 12;有效参数、时间/单位范围及结果证据引用 8;同一操作重复调用不增加分数 | +| 计划推进 | 15 | 依赖顺序与证据覆盖 6;根据新证据调整计划 5;报告终态、待人工确认和停止条件 4;不评分模型私有思维链或固定调用顺序 | +| 效率 | 5 | 仅在业务结果至少 45/60 且无关键错误陈述时计分;以已接受基线的 Token、Agent wall time、有效调用数中位数对比,三项等权,单项最高 1.67 分 | + +定位分在“输入足以定位”的真值用例中按 Hit@1=15、Hit@3=12、Hit@10=8、未命中=0;输入不足的用例不要求猜管段,明确限制和所需数据得 15,猜测得 0。工具有效完成率按每个场景冻结的唯一必需操作计分;单个操作重复尝试只取首次有效终态,额外调用不扩大分母或分子。 + +结构化事实、调用结果、时间/单位、候选排名、计划依赖、任务终态由确定性 scorer 判定。报告措辞与计划是否有证据支撑由同一固定版本盲评器独立评分两次,输出结构化子分和引用依据;每次保留评审器模型、提示词版本与原始评分。黄金样例由爆管业务负责人标注。双评差异超过任一子项 2 分、评审器无证据引用或关键结论不确定时转人工复核,复核值和原因进入报告。效率基线建立后,单项得分为 `1.67 × min(1, 基线中位值 / 当前值)`,三项合计最高 5 分;效率基线建立前,该项标为 `unscored`,报告总分上限 95,不伪造完整 100 分。 + +报告必须同时给出总分、业务通过率、工具调用成功率、定位 Hit@k、误报/漏报、延迟、分项分数和失败原因。队列 accepted 不算计算成功;MCP `isError`、超时和业务拒绝分开统计。系统错误记为 `infra_error`,保留尝试并有限重试幂等步骤;模型业务失败按实际结果计分。不做 retry-until-pass。 + +初始分项数值映射需要用一个人工审阅的黄金样例和重复运行校准后冻结为 scorer 版本;不能在第一次运行后按期望结果回调规则。首轮建议每个就绪用例重复 3 次,并对同版本做 A/A 重跑,估计模型随机波动;小样本只报告原始分布,不声称统计显著。 + +## 自动执行与复现 + +正式 runner 的单个入口按以下检查点运行: + +1. `prepare`:校验套件、来源版本、传感器/数据窗口、触发可检出性、模型拓扑和真值包完整性;输出明确就绪/阻塞清单。 +2. `snapshot`:以只读导出生成 BizDB、TimescaleDB 及必要文件的不可变版本包和哈希;所有评测写入只落在该版本的可写副本。 +3. `trigger`:每例建立唯一 attempt ID,按 WO 或逐步 SCADA 事件路径触发。SCADA 没触发时记录触发失败,不补发 Agent 任务。 +4. `investigate`:通过真实 Agent Host 和 Server/MCP 执行,脚本化反馈只提供当时可知事实;模型不得读取未来观测或真值。 +5. `collect`:记录 case/attempt、task/run/execution/session、任务和报告修订、计算 job、MCP receipt、工具调用、计划版本、Token/耗时与终态。续跑先查已有业务 ID,禁止重复建单或重复触发。 +6. `score/archive`:确定性 scorer 读取隐藏真值和收集轨迹,生成逐例 JSON、汇总报告、HTML/CSV 可选视图及所有代码/配置/数据/scorer 指纹。基线升级由人工明确选择。 + +每个 case 串行运行,直到能证明不同可写副本完全隔离后再并发。事件时间使用虚拟时钟;JWT、worker lease、heartbeat 和 HTTP timeout 使用现实时间。Agent 输入的查询统一受 `knowledge_cutoff` 约束。每次运行保留所有尝试和原始错误,不保存密钥、访问令牌或完整 DSN。结果进入 Git 忽略的私有工件目录或受控 CI 存储,不提交业务观测和轨迹。 + +每轮实验固定记录:Agent/Server/frontend 代码内容摘要、提示词与 Skill 摘要、模型及参数、MCP 工具 schema/版本、数据快照 ID/哈希、suite/scorer 版本、基线版本、case、重复次数、运行预算和实际服务指纹。只存 Git SHA 不足以标识当前含未提交改动的运行版本。 + +## 当前准备状态与执行门槛 + +当前已有:四个候选场景清单、严格字段校验、分库只读查询与规则预览函数、独立拓扑参考。只读预检已在 2026-11-03 执行:四个场景均有 83 个完整短窗压力测点,但没有一个触发当前短窗 SCADA 规则;15 天检测窗口也没有满足连续 1440 点的压力节点。B02 的工单路径是唯一有现成数据候选的路径,B02 的 SCADA 路径仍需隔离生成能触发的事件数据。该结果只说明历史窗口/当前规则的匹配情况,不证明实际 worker 或 Agent 行为。刚加入的 `scripts/prepare_burst_benchmark.py` 只生成私有预检 JSON,不会建任务、写 SCADA 修正值、运行 Agent 或声称数据集已冻结。 + +完成自动评测前仍需依次补齐并验收: + +1. 已执行只读预检;下一步由评测维护者确认是否保留 B02-WO 为首个可用试跑,并在隔离副本生成 SCADA 触发与 B01/B03/B04 所需观测。 +2. 爆管业务/水力分析负责人审阅四个用例的数值参考、有效泄漏流量和等价候选;评测维护者据此固定触发规则与控制组期望。 +3. Server 数据维护者形成带模型/观测版本哈希的快照导出和隔离 BizDB/TimescaleDB 路由;通过受限角色、连接路由检查和数据库审计证明 Agent/worker 无法写回源库。源库其他日常服务可能同时写入,因此验收证明 benchmark 没有源写入,不承诺整库前后字节完全相同。 +4. Agent 与 Server 维护者实现可恢复 runner 和全链路轨迹导出;先执行一例 WO 与一例 SCADA 的闭环试跑。 +5. 评测维护者与爆管业务负责人共同冻结 scorer,在首个通过门槛的用例跑 A/A;再扩到 8 例 × 3 次 × 候选版本。 + +若源数据在准备期间被日常业务改变,重新读取后产生不同内容指纹,应建立新快照版本,不覆盖旧报告。预检报告只证明来源库读取与候选数据检查完成,不是 Agent 评测结果或上线验收。 + +## 命令 + +只读检查命令(在 Server 仓库根目录): + +```bash +conda run -n server python scripts/prepare_burst_benchmark.py +``` + +只检查单个场景可追加 `--case B02`。预检报告写入 `data/benchmark/preflight/`,该目录在 `.gitignore` 中。此命令只读源数据库;正式评测 runner 尚未提供。 diff --git a/docs/tjwater2-v2-burst-balanced-design.md b/docs/tjwater2-v2-burst-balanced-design.md new file mode 100644 index 0000000..9b141cb --- /dev/null +++ b/docs/tjwater2-v2-burst-balanced-design.md @@ -0,0 +1,185 @@ +# tjwater2_v2 多距离、弱信号爆管场景 + +状态(2026-11-07):完成四区域、三档破口、四时段的 48 组本地水力校准与固定阈值敏感性分析,并从中选出 4 个演示 case。4 个爆管方案已保存,压力、流量和正常水质数据已写入隔离项目与 `tjwater2_v2` 的 IoT 库。动态阈值尚未接入真实监测 worker,正式 benchmark Runner、真实工单和 SCADA Agent 链路、评分及归档仍待实现。 + +同日管网 ID 修复后,R1 管段当前 ID 为 `564345`,流量设备 `542121` 的绑定管段当前 ID 为 `570995`。管段属性、几何、拓扑和 SCADA 测量值未变化。隔离 benchmark 的 BizDB、852,960 条正常实时结果,以及清单直接引用的 2,016 条方案管段结果已同步。以下清单统一使用新 ID。 + +48 个历史方案保存了全管网结果,其中未被当前 benchmark 清单引用的长管段仍属于迁移前的校准归档,不进入 Agent 输入或当前 scorer。完整历史结果的全量迁移因压缩块重写耗时过长而回滚;如需把旧方案用于全管网地图展示,应重新生成方案或另行离线迁移,不能混用新模型 ID 与旧结果 ID。 + +## 当前演示数据集 + +演示数据使用隔离项目 `tjw_bench_tjwater2_20261105`,项目 UUID 为 `66ce6b1a-4415-4cd0-b68f-bf1505576c79`。`tjwater2_v2` 与隔离项目的 IoT 库均写入相同数据,便于在完整系统中演示,也便于 benchmark 指定隔离数据源运行。后续自动评测默认使用隔离项目,避免业务操作、清洗结果或回放状态互相影响。 + +每个月写入 1 日 00:00 至 18 日 00:00 前的数据,共 17 天、1,632 个 15 分钟时刻。设备包括 83 台压力设备、35 台流量设备和 50 台水质设备,共 168 台。每台设备每月 1,632 点,每月 274,176 行,四个演示窗口合计 1,096,704 行。`cleaned_value` 初始为空,由真实清洗和回放流程产生。 + +每个事件前都有精确的 15 天同时间点历史,即 1,440 个时刻。按全部 168 台设备计算,每个 case 的事前基线为 241,920 行。检测算法使用事件时刻之前 15 天的同时间点数据,不要求自然月内其余日期全部正常或完整。 + +### 4 个演示 case + +时间均为北京时间(UTC+8)。事件持续 5 小时,包含起止点共 21 帧;事件结束后的下一个 15 分钟时刻恢复正常数据。 + +| Case | 事件时间 | 入口 | 管段 | 破口面积 | 等效圆孔直径 | +| --- | --- | --- | --- | ---: | ---: | +| `B26-severe` | 2026-05-16 08:00 至 13:00 | SCADA 自动发现 | `564345` | 22.089323 cm² | 53.03 mm | +| `B28-severe` | 2026-06-16 20:00 至 06-17 01:00 | SCADA 自动发现,跨日 | `564345` | 22.089323 cm² | 53.03 mm | +| `B30-severe` | 2026-07-16 08:00 至 13:00 | 工单在事件发生 60 分钟后上报 | `541725` | 353.429174 cm² | 212.13 mm | +| `B35-severe` | 2026-08-16 12:00 至 17:00 | 工单在事件发生 120 分钟后上报 | `538540` | 245.436926 cm² | 176.78 mm | + +四个 case 均使用管道截面积的 1/8,比例不是管径的 1/8。B26 和 B28 复用同一物理位置,用来比较早间与晚间跨日条件。B30、B35 使用更大口径和更远压力测点,用来覆盖区域、信号强度和工单发现延迟的差异。 + +| Case | 破口坐标 WGS84 | 管径 / 长度 | 最近压力设备 / 沿网距离 | +| --- | --- | --- | --- | +| B26 / B28 | 121.471119, 31.005631 | DN150 / 249.176 m | `92724` / 约 0.205 m | +| B30 | 121.569785, 30.956880 | DN600 / 495.368 m | `170486` / 约 1.19 km | +| B35 | 121.487510, 30.823888 | DN500 / 121.485 m | `102837` / 约 2.67 km | + +### 泄漏真值与 SCADA 流量响应 + +破口泄漏量来自水力模拟,是隐藏评测真值,不能直接进入 Agent 的提示词、任务上下文或可见工具结果。SCADA 流量是 Agent 可见证据,单位为 m³/h,已完成 L/s 到 m³/h 的换算并加入确定性仪表噪声。 + +| Case | 泄漏量 L/s | 泄漏量 m³/h | 代表性 SCADA 流量变化 | +| --- | ---: | ---: | --- | +| B26 | 21.60 至 23.49 | 77.76 至 84.58 | `449417` +37.32 m³/h;`516234` +15.63 m³/h | +| B28 | 21.99 至 25.81 | 79.15 至 92.90 | `449417` +49.42 m³/h;`542121` +102.68 m³/h | +| B30 | 424.15 至 446.33 | 1,526.95 至 1,606.79 | `524529` +296.26 m³/h;`541061` +241.34 m³/h | +| B35 | 236.97 至 261.11 | 853.08 至 940.01 | `508453` +926.08 m³/h;`527865` +46.96 m³/h | + +三个主要水厂计量设备的 5 小时最大变化如下。百分比按同时间正常值计算;小流量管段的基线接近 0 时,百分比会失真,评分必须同时看绝对变化量和流向。 + +| Case | `542017` | `486033` | `542121` | +| --- | ---: | ---: | ---: | +| B26 | +50.11 m³/h,1.23% | -82.44 m³/h,1.27% | +32.15 m³/h,3.47% | +| B28 | -81.51 m³/h,3.37% | +129.10 m³/h,3.65% | +102.68 m³/h,19.10% | +| B30 | +713.91 m³/h,19.13% | +293.30 m³/h,4.75% | +258.24 m³/h,27.95% | +| B35 | +212.19 m³/h,5.43% | +491.69 m³/h,8.01% | +102.87 m³/h,11.64% | + +### 正常水质数据 + +50 台水质设备使用 EPANET 化学水质模拟生成正常余氯数据,没有用固定常数补值。13 个水库和 15 个正常入流水源设置为 0.50 mg/L,全网初始余氯为 0.35 mg/L,体相衰减为 -0.05/天,管壁衰减为 -0.02/天。模型预热 6 天后取第 7 天的正常日曲线,按 15 分钟间隔重复到各 case 窗口。 + +仪表噪声使用固定种子,标准差为读数的 0.5%,最低标准差为 0.001 mg/L,并截断在正负 3 倍标准差内。最终数据约为 0.071 至 0.507 mg/L,没有零值、负值或求解警告。当前四个爆管 case 内的水质保持正常,只用于保证设备读数完整,不作为爆管响应或判分指标。 + +## 管段与监测距离 + +保留一个近测点强信号对照,其余改用中远距离候选。距离取爆管节点到全部压力设备中的最近沿网距离,不是到任意挑选的某个测点的距离。按管道模型长度累计,阀门连接按近零长度处理,表中取近似值。 + +| 组别 | 管段 ID | 管径 / 长度 | 最近压力设备 / 沿网距离 | 破口经纬度 WGS84 | 边界阀 / 分区节点 | +| --- | --- | --- | --- | --- | --- | +| R1 近点对照 | `564345` | DN150 / 249.176 m | `92724` / 约 0.205 m | 121.471119, 31.005631 | 5 / 11 | +| R2 中距干管 | `541725` | DN600 / 495.368 m | `170486` / 约 1.19 km | 121.569785, 30.956880 | 4 / 64 | +| R3 远距多点响应 | `538540` | DN500 / 121.485 m | `102837` / 约 2.67 km | 121.487510, 30.823888 | 2 / 10 | +| R4 远距弱信号 | `541116` | DN200 / 228.424 m | `406444` / 约 2.91 km | 121.441641, 30.982725 | 4 / 9 | + +四处均经过后端隔离算法和水源连通校验,关闭所列边界阀后可在模型拓扑上切断水源。分区节点数不等于用户数、实际停水范围或现场阀门可操作性。 + +上轮贴近测点的 DN110、DN315、DN500 候选及其结果仍保留,不能因本轮换管段而重用其 case ID。 + +## 破口面积与发生时间 + +比例均指管道截面积,不是管径;不再只选一个最容易触发的面积。 + +| 管段 | 1/32 面积 cm² | 1/16 面积 cm² | 1/8 面积 cm² | +| --- | ---: | ---: | ---: | +| R1 DN150 | 5.522331 | 11.044662 | 22.089323 | +| R2 DN600 | 88.357293 | 176.714587 | 353.429174 | +| R3 DN500 | 61.359232 | 122.718463 | 245.436926 | +| R4 DN200 | 9.817477 | 19.634954 | 39.269908 | + +发生时间统一为北京时间 2026-10-08 的 02:00、08:00、12:00、20:00,分别覆盖夜间、早间、日间、晚间。每个事件持续泄漏 5 小时,15 分钟一步,共 21 帧;20:00 场景结束于次日 01:00。3/4 小时视图按前缀截取。 + +R1 至 R4 分别对应 B25 至 B28、B29 至 B32、B33 至 B36、B37 至 B40;三档面积作为 case 的独立维度,例如 `B29-medium`。共 4×3×4=48 个物理场景,后续再派生 SCADA 与 WO-30/60/120/180 等入口 case,不能把这些派生用例当成不同物理事件。 + +所有场景有相同起始时间、模型状态、需求模式和 PDA 参数的无爆管对照。三个主要水厂出口设备 `542017`、`486033`、`542121` 均保留;压力单位 m,流量结果 L/s 映射 SCADA 时转为 m³/h。 + +## 观测结果与动态阈值 + +各场景在 5 小时内的最大监测压降,以下取四个发生时段的范围。这是相同时间正常/事故模拟的差异,不是运行告警直接可用的未来对照。 + +| 管段 | 1/32 | 1/16 | 1/8 | +| --- | ---: | ---: | ---: | +| R1 | 11.58% 至 12.69% | 26.37% 至 28.08% | 52.84% 至 54.65% | +| R2 | 4.48% 至 5.57% | 9.92% 至 11.90% | 22.05% 至 25.14% | +| R3 | 3.95% 至 6.24% | 7.91% 至 12.21% | 14.65% 至 21.58% | +| R4 | 0.10% 至 0.13% | 0.19% 至 0.26% | 0.37% 至 0.50% | + +48 组均完成求解,未出现求解警告、指定监测点负压或结果缺失。R2 至 R4 因未达到旧 30% 门槛而被标为 `weak_signal_candidate`,仍然是有效爆管正例;不当成无爆管样本,也不为让所有 case 通过而增大破口。 + +固定阈值复用实际清洗/回放相对变化规则,使用四帧预热和连续两帧,未加入绝对压降门槛。16 个正常对照按“管段×时间”去重: + +| 固定压降阈值 | 触发的爆管场景 / 48 | 误报的正常对照 / 16 | +| --- | ---: | ---: | +| 3% | 47 | 13 | +| 5% | 41 | 8 | +| 10% | 25 | 3 | +| 20% | 11 | 0 | +| 30% | 4 | 0 | + +这里的“触发”只是规则预览,不是 Agent 识别成功;极低阈值下的高触发率不能忽略正常误报。 + +动态规则采用独立版本,初始实验方案如下: + +1. 每个测点仅用事件前 15 天完整正常数据拟合,15 分钟间隔要求 1440 点;有缺点时报告历史未就绪。 +2. 按日内时段建立正常压力中位数参考,避免将正常早晚变化当成事故;用去除日内模式后的 `1.4826×MAD` 表示相对波动。 +3. 测点相对阈值取 `max(3%, 3×相对波动)`,与绝对压降至少 0.5 m、连续两帧同时成立。3%、0.5 m 是首轮实验参数,需结合测点精度和正常误报结果校准。 +4. 在事件前冻结参考、阈值和规则版本;拟合函数拒绝事件时刻及之后的观测。评估遇到采样中断会重置连续计数。 +5. 压力异常后仍需流量或独立压力证据交叉验证。实验函数目前只覆盖压力层,未接入生产监测 worker。 + +本次水力包只有四帧事件前预热,因此尚不能给出真实项目动态规则的检测率。当前新增的学习与评估函数仅通过合成正常历史验证;不能把上表固定阈值结果说成动态规则已验收。 + +## 复现与数据边界 + +清单:[tjwater2-v2-burst-balanced.json](../resources/benchmarks/tjwater2-v2-burst-balanced.json)。原有强信号清单保持不变。 + +```bash +conda run -n server python scripts/check_burst_simulation_design.py \ + --design resources/benchmarks/tjwater2-v2-burst-balanced.json + +conda run -n server python scripts/calibrate_burst_benchmark.py \ + --design resources/benchmarks/tjwater2-v2-burst-balanced.json --all-severities + +conda run -n server python -m pytest \ + tests/unit/test_burst_benchmark_calibration.py \ + tests/unit/test_regional_burst_selection.py \ + tests/unit/test_burst_pressure_thresholds.py -q +``` + +`scripts/analyze_burst_thresholds.py --calibration-dir <某次校准目录>` 复用已冻结结果做阈值敏感性分析,无需重跑水力。`scripts/prepare_regional_burst_design.py` 输出的 GeoJSON 包含管段、实际破口点和边界阀,位于被忽略的本地数据目录。 + +源管网模型在准备阶段保持只读。当前已建立隔离 BizDB、IoT 库和模型模板,并通过正常模拟与保存的爆管方案按设备绑定生成最终 `scada.measurements`。为支持完整系统演示,同一份 SCADA 数据也写入 `tjwater2_v2` 的 IoT 库。动态拟合和 Agent 评测只能读取各自知识截止时刻以前的数据;模拟真值及方案信息不进入 Agent 可见上下文。 + +## 正式生成优先本地后端方法 + +本地执行优先复用 `app.services.simulation_scenarios.burst_analysis()`。MCP 的 `run_burst_simulation` 适配器和 `save_simulation_scenario` 保存入口也调用这个函数,分别设置 `persist_results=False` 和 `True`。固定模型、SCADA 边界输入、时间、破口与单位后,两条入口共用水力计算实现;MCP 额外承担权限、上下文、任务和审计等流程。 + +此前直接修改 INP 的结果只用于选管校准。正式 4 个演示 case 已在隔离路由下保存方案,保留正式 `run_id`、模型和结果摘要,再按设备绑定写入 SCADA。Agent 评测仍需使用真实工具链,数据生成成功不能替代 MCP 工具成功率、计划过程和最终业务效果评分。 + +## 方案结果到 SCADA 的映射 + +先分配日期窗口,再按该时间运行正常模拟和爆管方案;默认沿用计算结果的真实时间戳写入 SCADA,不在写入时悄悄平移日期。 + +| SCADA 设备类型 | 绑定字段 | 正常数据源 | 事故数据源 | 写入值 | +| --- | --- | --- | --- | --- | +| 压力 | `asset.scada_devices.node_id` | `realtime.node_results.pressure` | 指定 `run_id` 的 `analysis.node_results.pressure` | 单位核验后写 m | +| 管道流量 | `asset.scada_devices.link_id` | `realtime.link_results.flow` | 指定 `run_id` 的 `analysis.link_results.flow` | 本模型 L/s × 3.6,写 m³/h | + +最终键使用 SCADA 的 `device_id`,不能使用模型节点 ID 代替;写入 `scada.measurements.monitored_value`,`cleaned_value` 初始为空,由真实清洗流程计算。压力和管道流量来自正常与爆管水力计算,水质来自独立的正常化学水质模拟。设备列表、绑定、单位和排除原因在生成前冻结,不为了补齐数据自动移动或改绑设备。 + +`analysis` 的 `run_id` 能区分多个方案,SCADA 的主键只有 `(time, device_id)`,不能区分同一设备同一时刻的多个场景。因此,同管段的不同破口面积也必须分配独立日期,不能只给不同管段分日期。 + +每个物理 case 的保留窗口覆盖事前 15 天正常历史、5 小时事故、末点采样和恢复缓冲。当前 4 个演示 case 分配在 2026 年 5 月至 8 月,每月一个 case,基线不会混入其他事故。 + +当前日期映射已冻结在 `resources/benchmarks/tjwater2-v2-burst-demo.json`。B26、B28、B30、B35 分别使用 2026 年 5 月、6 月、7 月、8 月窗口。工单时间、发现检查点和知识截止随日期统一生成。更换提示词、工具或 MCP 版本时复用同一冻结数据,不重新分配日期或重新生成噪声。 + +5 小时事故结果包含起止点,共 21 帧;当前正常在线模拟为右端不含,直接运行 5 小时仅有 20 帧。若保留事故末点,正常对照须额外生成该时刻,不能将缺失的正常值补零或与上一时刻配对。SCADA 记录使用带时区时间,按北京时间规划、按 UTC 存储和比较;回放查询的右端不含时,查询终点需覆盖最后一帧。 + +写入前检查整个保留窗口和 `(time, device_id)` 冲突;发现已有记录时停止并重新分配日期,不使用覆盖式 upsert 或 `ON CONFLICT DO NOTHING` 隐藏碰撞。写入采用事务和生成器之间的排他锁,唯一约束兜底。重复执行依据数据集清单和内容摘要验证幂等,不重复插入。 + +提交后已使用新连接按设备、日期、行数、单位和数值摘要读回校验。生成状态保存在 Git 忽略目录 `data/benchmark-generation/tjwater2-20261105/`,包括 `demo-scada-state.json` 和 `normal-quality-state.json`。这些文件记录 `case_key`、方案 `run_id`、实际日期、单位、行数与指纹,属于本地生成证据,不提交 Git,也不向 Agent 暴露方案真值。 + +## Benchmark 执行边界 + +当前数据集已经满足 4 个演示 case 的数据输入要求,但还不能据此声称 Agent benchmark 已完成。后续自动化执行需要按 case 恢复初始业务状态,设置知识截止和入口触发时间,运行真实工单或 SCADA 发现链路,记录计划、工具调用、MCP 调用、任务结果和展示结果,再统一评分和归档。 + +建议将评分拆成四部分:事件识别与时间得分、定位与影响分析效果得分、工具调用成功率与参数正确性得分、计划和证据链完整性得分。泄漏量、真实管段、方案 `run_id` 和无噪声模拟结果只供 scorer 使用。Agent 可见输入限于当时可查询的 SCADA、工单和正常业务配置。 + +当前仍有两个运行配置缺口:`tjwater2_v2` 只设置了 `scada_monitoring_interval_seconds=900`,尚未配置 `scada_monitoring.pressure_device_ids` 和 `outlet_flow_device_ids`,联合爆管监测会报告角色未配置;新的 4 个 case 尚未执行完整 SCADA 回放和真实 Agent 端到端验收。 diff --git a/docs/tjwater2-v2-burst-simulation-design.md b/docs/tjwater2-v2-burst-simulation-design.md index 6af9d25..3e623cd 100644 --- a/docs/tjwater2-v2-burst-simulation-design.md +++ b/docs/tjwater2-v2-burst-simulation-design.md @@ -1,10 +1,14 @@ # tjwater2_v2 爆管模拟校准设计 -状态:参数与只读校验已准备,尚未运行水力模拟。目标项目为 `tjwater2_v2`(演示项目2(新版)),不是 `tjwater_v2`。 +状态(2026-11-07):本文记录早期强信号管段的校准过程。后续已完成多距离管段校准、4 个演示方案保存,以及压力、流量和正常水质 SCADA 数据生成。正式 Agent benchmark Runner、评分和端到端验收尚未执行。目标项目为 `tjwater2_v2`(演示项目 2(新版))。 + +当前可执行数据集、4 个演示 case、流量响应、水质数据和 benchmark 边界见[多距离、弱信号与动态阈值设计](tjwater2-v2-burst-balanced-design.md)。本文保留为较早的强信号校准记录,30% 不再作为全部有效爆管用例的统一淘汰条件。 + +2026-11-07 管网 ID 修复后,本文主候选的当前管段 ID 为 `564345`,流量设备 `542121` 的当前绑定管段 ID 为 `570995`。物理管段、模型参数和校准数值未变化,清单及下文引用均使用新 ID。 ## 目标 -先用现有管网、阀门和监测点选择可观测的事故管段,再在隔离评测数据库中校准破口面积。首版使用管道截面积的 1/32、1/16、1/8;1/8 是上限。若 1/8 仍不能形成明确降压信号,该管段退出 SCADA 爆管用例,不继续扩大破口。 +先用现有管网、阀门和监测点选择可观测的事故管段,通过只读导出的 INP 文件校准破口面积,再在隔离评测数据库保存正式方案。首版使用管道截面积的 1/32、1/16、1/8;1/8 是上限。比例指面积,等效圆孔直径分别为管径的约 17.68%、25%、35.36%。若 1/8 仍不能形成明确降压信号,该管段退出 SCADA 爆管用例,不继续扩大破口。 模拟调用链为: @@ -16,6 +20,8 @@ 当前 `temporary_project_database` 会从项目模型模板创建临时数据库,同时短暂禁止模板连接并终止模板会话。因此即使 `run_burst_simulation` 不保存结果,也不能直接针对原项目执行。正式运行必须把只读逻辑快照恢复到独立评测数据库及其模板,Server、worker 和 Agent 全部使用评测路由。 +当前校准入口使用 `REPEATABLE READ, READ ONLY` 导出源模型,再修改本地 INP 中的参数,调用仓库自带原生 EPANET;不调用数据库克隆接口,不写原项目或模板,不使用旧 `realtime` 结果代替重算。正常与事故对照均采用 PDA、`REQUIRED PRESSURE=10 m`,避免把 DDA/PDA 的差异算成爆管效果,并保留源配置的 `HEADERROR`、`FLOWCHANGE`、最低压力和压力指数。已确认当前模型无水池、水泵、控制与规则;若后续新增,入口会停止,待补齐状态继承实现。 + ## 方法参数 `run_burst_simulation` 接收带时区的 `start_time`、60 至 86400 秒的 `duration`、1 至 20 个输出节点、至多 20 条输出管段、1 至 20 条爆管管段,以及与管段一一对应的正数孔口面积 `orifice_areas_cm2`。本次每个调用只模拟一条爆管管段,等待预算使用 240 秒;排队成功只表示已接受,仍需查询计算任务和最终结果。 @@ -28,27 +34,31 @@ coefficient[L/s/sqrt(m)] = 0.65 * area[cm²] / 10000 * sqrt(19.6) * 1000 ``` -正式运行后,定位使用的泄漏量按事故节点相对正常对照的 demand 增量计算,并从 L/s 除以 1000 转成 m³/s。流量监测设备单位为 m³/h,模型流量结果为 L/s,映射 SCADA 时乘以 3.6。 +模拟真值的泄漏量按 `coefficient * sqrt(max(事故节点压力, 0))` 计算,并从 L/s 除以 1000 转成 m³/s。PDA 下节点实际需水量会随压力改变,事故与正常的 demand 差值不能直接当成精确泄漏量。该模拟真值留在隐藏评测包,Agent 定位仍需从其可见证据获得泄漏量。流量监测设备单位为 m³/h,模型流量结果为 L/s,映射 SCADA 时乘以 3.6。 ## 管段与场景 -主场景管段 `541720` 为 DN800、长 483.038 m,破口节点 `502046`。附近两个压力点沿管网约 1.2 km,最近流量点约 1.2 km;两座边界阀可隔离 11 个节点。其破口面积为 157.079633、314.159265、628.318531 cm²。 +原 B01/B02 管段 `541720`、B03/B04 管段 `520323` 在恢复水源支路后进行了 1 小时最大面积校准,监测点最大降压分别仅约 2.03%、2.87%,不满足门槛,已退出当前候选。失败结果及当时的完整清单保存在本地报告,不能复用旧 case ID 表示其他管段。 -清单同时保存 SCADA 设备 ID 与其实际水力模型对象 ID,不能假定两者相同。例如压力设备 `447382`、`444985` 分别绑定节点 `7543252226207002012`、`14141146287589625673`,流量设备 `542121` 绑定管段 `pipe-eaa5c512bd8f4649b37bdbe3d`。模拟输出使用模型对象 ID,得分与数据映射使用设备 ID。 +重新从压力点沿网距 400 m 内、DN150 至 DN800、长度至少 50 m 的管段中筛查,按 `length / diameter²` 作为试算排序启发式,优先覆盖不同测点;它不代表水力计算结论。首批试算 24 条,筛出两条进入昼夜完整校准。 -启动校验还要求所选压力设备到绑定节点、流量设备到绑定管段的本地坐标距离均不超过 1 m。全量复核中待人工确认的 `542017`、`547063` 和 `DEMO-WQ-551571` 不进入本套爆管 benchmark;不得为通过校验而自动吸附设备坐标或猜测改绑对象。 +当前主候选 `564345` 为 DN150、长 249.176 m,起点 `92686`、破口节点 `7427148338069072993`。该节点距压力点 `92724` 沿网约 0.205 m。关闭全部模型阀门后的所在分区含 11 个节点、5 座边界阀;这只是拓扑参考,尚未验证逐阀关闭的水力影响。三档面积为 5.522331、11.044662、22.089323 cm²。 -次级场景管段 `520323` 为 DN300、长 33.5951 m,破口节点 `91055`。最近压力点沿管网约 262 m,五座边界阀可隔离 19 个节点。其破口面积为 22.089323、44.178647、88.357293 cm²。 +次候选 `540173` 为 DN200、长 162.128 m,起点 `500819`、破口节点 `517120`,距压力点 `535381` 沿网约 269.125 m。所在分区含 23 个节点、6 座边界阀。三档面积为 9.817477、19.634954、39.269908 cm²。其夜间校准未通过,目前保留为失败对照,不能随主候选一起生成正向 SCADA 用例。 -两条管段分别在北京时间 2026-10-08 02:00 和 12:00 模拟,形成 B01 至 B04 四个物理场景。每个场景都必须有同时间、同持续时长、相同模型状态的无爆管正常对照。严重档先验证是否可观测;通过后依次测试中度和轻度,最终选择满足要求的最小面积。最坏情况下执行 12 次爆管校准模拟,正常对照按项目、时间和模型版本复用。 +清单同时保存 SCADA 设备 ID 与其实际水力模型对象 ID。例如压力设备 `430299` 绑定节点 `3186958412475415759`;流量设备 `542121` 绑定管段 `570995`。模拟输出使用模型对象 ID,得分与数据映射使用设备 ID。 + +`542017` 已依据 `tjwater_v2` 恢复缺失的水源 `551778` 和出口管段,并将 SCADA 绑定恢复到 `542017`;原始空间坐标保持不变。当前 BizDB 与模板均为 92393 个节点、91050 条管道、4489 座阀门、13 个水源。三个主要水厂设备 `542017`、`486033`、`542121` 的实际出口绑定均进入两条候选的输出。待核实的 `547063`、`DEMO-WQ-551571` 不参与本轮判分,重筛也排除 `547063`,不得自动移动设备或猜测改绑对象。 + +两条候选分别在北京时间 2026-10-08 02:00 和 12:00 模拟,使用新 case ID B05/B06、B07/B08。每个场景均有同时间、同持续时长、相同模型状态的正常对照。严重档先验证,双时段均通过后再执行中度和轻度,选择双时段都通过的最小面积;严重档有一个时段失败即退出。正常对照按时间与模型版本复用。 ## 通过条件 一个面积只有同时满足以下条件才可冻结: 1. 至少一个指定压力点连续两个 15 分钟时刻较正常对照下降 30%。 -2. 第二压力点下降至少 10%,或指定流量点相对正常对照变化至少 20%。 -3. 正常对照不触发相同规则。 +2. 同一连续窗口内,另一独立压力点下降至少 10%,或同一指定流量点变化至少 20%。零流量基线不能提供相对变化佐证。 +3. 事件前四个 15 分钟正常样本预热后,生产回放清洗/相对变化规则对事故触发、对正常对照不触发。校准预览配置为降压 30%、连续两帧、至少一台压力设备,不等同于完整监测 worker、15 天检测算法或 Agent 已通过。 4. 指定监测节点不出现负压,模拟正常收敛,所有请求输出完整。 5. 同一管段的夜间和白天场景使用同一面积;两者都通过时才能进入正式 benchmark。 @@ -65,3 +75,43 @@ conda run -n server python scripts/check_burst_simulation_design.py ``` 隔离评测数据库准备完成后,runner 先按清单调用非持久 `run_burst_simulation` 校准。面积冻结后,通过 `save_simulation_scenario` 在评测数据库保存全量节点和管段结果,生成固定的运行 ID 和数据摘要。模型分析结果与模拟 SCADA 保留明确来源,不标记为实测数据。 + +## 自动校准入口与本次结果 + +```bash +# 1 小时面积校准;输出结果和每次试算的 INP、报告、原生输出 +conda run -n server python scripts/calibrate_burst_benchmark.py + +# 5 小时完整事件窗口,可派生 3/4/5 小时视图 +conda run -n server python scripts/calibrate_burst_benchmark.py --duration-seconds 18000 + +# 对一个已有校准快照筛查其他管段;每条只算白天单时刻,不代表通过 +conda run -n server python scripts/screen_burst_benchmark_pipes.py \ + --calibration-dir data/benchmark-calibration/20261104T101304Z-029cc055 --limit 24 + +conda run -n server python -m pytest tests/unit/test_burst_benchmark_calibration.py -q +``` + +生成文件仅保存在 Git 忽略的 `data/benchmark-calibration/` 私有目录,清单、源 INP 与求解器均记录 SHA256,每完成一个 case 原子更新报告。源库访问均只读;本入口不创建 `analysis.runs`,不写 `realtime.*` 或 `scada.measurements`。退出码 0 表示校准流程完成,具体管段是否合格须读取报告的 `selection`,不能把运行完成当成所有 case 通过。当前支持进度留存,尚不支持从中断处自动续跑。 + +2026-11-04 的 5 小时结果(21 帧/场景)为: + +| 场景 | 管段 | 面积 cm² | 主要测点压降范围 | 相对变化规则首次触发 | 判定 | +| --- | --- | --- | --- | --- | --- | +| B05 夜间 | `564345` | 22.089323 | 46.08% 至 54.17% | 02:15(+15 min) | 通过 | +| B06 白天 | 同上 | 22.089323 | 49.81% 至 52.84% | 12:15(+15 min) | 通过 | +| B07 夜间 | `540173` | 39.269908 | 26.27% 至 33.96% | 未触发 | 失败 | +| B08 白天 | `540173` | 39.269908 | 30.83% 至 33.92% | 12:15(+15 min) | 通过,但该管段双时段不合格 | + +主候选的 1/16 与 1/32 面积在昼夜均未通过,最小通过档位为 1/8 截面积(等效圆孔直径约 53.03 mm)。主压力点 `92724` 的事故压力约 12.21 至 17.87 m,隐藏泄漏真值约 22.21 至 26.86 L/s,流量点 `449417`、`516234` 提供同步佐证。全部正常对照未触发,全部求解无警告,指定监测点无负压。 + +本地证据目录: + +- 原管段失败报告:`data/benchmark-calibration/20261104T101304Z-029cc055/calibration.json`。 +- 首批 24 条重筛:该目录下 `screening-df8b9408/screening.json`(明确排除待核实流量点 `547063`)。 +- 新候选 1 小时校准:`data/benchmark-calibration/20261104T101859Z-83f0c1a2/calibration.json`。 +- 新候选 5 小时验证:`data/benchmark-calibration/20261104T101949Z-3704704b/calibration.json`。 + +本次 5 小时报告的源 INP 摘要为 `dfb20e54356c2c96d36c97d96516361da7fb1201ce37da4ffc39f339aa8e3e0f`,清单摘要为 `bbcdbf7ce502264ffd826a0bb2c7188c619d6a14c42f82cdbb9320c9d254dd92`。当模型、绑定或参数变化时应重新校准并生成新摘要。 + +后续多距离设计已从校准结果中选择 B26、B28、B30、B35 四个严重档演示 case,保存到隔离库并按绑定生成 SCADA,同时补齐事件前 15 天历史。中小破口与被淘汰管段仍作为难例或阴性材料,必须另行定义预期,不能沿用正向通过预期。下一步是验证工单和监测真实入口,再执行 Agent 的工具、计划与效果评分。 diff --git a/resources/benchmarks/tjwater-v2-burst.json b/resources/benchmarks/tjwater-v2-burst.json new file mode 100644 index 0000000..1e8dd81 --- /dev/null +++ b/resources/benchmarks/tjwater-v2-burst.json @@ -0,0 +1,38 @@ +{ + "version": "burst-benchmark@1", + "suite_id": "tjwater-v2-burst", + "project_code": "tjwater_v2", + "project_id": "324ff7ee-c2d4-5184-a84b-48fb14a2933e", + "sampling_interval_minutes": 15, + "event_minutes": 60, + "detector_days": 15, + "trigger_rule": { + "pressure_drop_percent": 30, + "baseline_samples": 4, + "consecutive_samples": 2, + "minimum_devices": 2 + }, + "score_weights": {"outcome": 60, "tools": 20, "plan": 15, "efficiency": 5}, + "cases": [ + { + "case_id": "B01", "pipe_id": "540672", "occurred_at": "2026-10-08T02:00:00+08:00", + "reference_analysis": "test_burst_20260614", "observation_provenance": "requires_generation", + "provenance_note": "夜间爆管尚无已核实的对应异常观测;不能平移中午曲线或将正常观测当作本事件。" + }, + { + "case_id": "B02", "pipe_id": "540672", "occurred_at": "2026-10-08T12:00:00+08:00", + "reference_analysis": "test_burst_20260614", "observation_provenance": "declared_historical_replay", + "provenance_note": "present.md 记录该窗口由 test_burst_20260614 生成;需核查当前观测及参考方案,历史声明本身不等于数值验收。" + }, + { + "case_id": "B03", "pipe_id": "537407", "occurred_at": "2026-10-08T02:00:00+08:00", + "reference_analysis": "burst_20260610_dn600", "observation_provenance": "requires_generation", + "provenance_note": "存在历史模拟参考,但未核实本时间窗口的该管段异常 SCADA;需在隔离环境生成。" + }, + { + "case_id": "B04", "pipe_id": "537407", "occurred_at": "2026-10-08T12:00:00+08:00", + "reference_analysis": "burst_20260610_dn600", "observation_provenance": "requires_generation", + "provenance_note": "本窗口已有其他管段的爆管演示,不能复用为 537407 的真值。" + } + ] +} diff --git a/resources/benchmarks/tjwater2-v2-burst-balanced.json b/resources/benchmarks/tjwater2-v2-burst-balanced.json new file mode 100644 index 0000000..85da46b --- /dev/null +++ b/resources/benchmarks/tjwater2-v2-burst-balanced.json @@ -0,0 +1,485 @@ +{ + "version": "burst-simulation-design@1", + "suite_id": "tjwater2-v2-burst-calibration", + "project_code": "tjwater2_v2", + "project_id": "871d7538-589c-47de-a6d5-3185ccb9f0e1", + "model_expectations": { + "node_count": 92393, + "pipe_count": 91050, + "valve_count": 4489, + "flow_unit": "LPS", + "pressure_unit": "METERS", + "pressure_measurement_unit": "m", + "flow_measurement_unit": "m3/h", + "emitter_exponent": 0.5, + "hydraulic_timestep_seconds": 900, + "report_timestep_seconds": 900 + }, + "duration_seconds": 18000, + "event_times": [ + "2026-10-08T02:00:00+08:00", + "2026-10-08T08:00:00+08:00", + "2026-10-08T12:00:00+08:00", + "2026-10-08T20:00:00+08:00" + ], + "severity_levels": [ + { + "level": "light", + "cross_section_fraction": 0.03125 + }, + { + "level": "medium", + "cross_section_fraction": 0.0625 + }, + { + "level": "severe", + "cross_section_fraction": 0.125 + } + ], + "calibration_order": [ + "severe", + "medium", + "light" + ], + "selection_rule": { + "primary_pressure_drop_percent": 30.0, + "corroborating_pressure_drop_percent": 10.0, + "flow_change_percent": 20.0, + "consecutive_samples": 2, + "expected_sample_count": 21, + "relative_change_epsilon": 1e-06, + "normal_control_must_not_trigger": true, + "negative_monitor_pressure_allowed": false, + "choose_smallest_passing_area": true + }, + "binding_validation": { + "max_pressure_distance_m": 1.0, + "max_flow_distance_m": 1.0 + }, + "cases": [ + { + "case_id": "B25", + "pipe_id": "564345", + "occurred_at": "2026-10-08T02:00:00+08:00" + }, + { + "case_id": "B26", + "pipe_id": "564345", + "occurred_at": "2026-10-08T08:00:00+08:00" + }, + { + "case_id": "B27", + "pipe_id": "564345", + "occurred_at": "2026-10-08T12:00:00+08:00" + }, + { + "case_id": "B28", + "pipe_id": "564345", + "occurred_at": "2026-10-08T20:00:00+08:00" + }, + { + "case_id": "B29", + "pipe_id": "541725", + "occurred_at": "2026-10-08T02:00:00+08:00" + }, + { + "case_id": "B30", + "pipe_id": "541725", + "occurred_at": "2026-10-08T08:00:00+08:00" + }, + { + "case_id": "B31", + "pipe_id": "541725", + "occurred_at": "2026-10-08T12:00:00+08:00" + }, + { + "case_id": "B32", + "pipe_id": "541725", + "occurred_at": "2026-10-08T20:00:00+08:00" + }, + { + "case_id": "B33", + "pipe_id": "538540", + "occurred_at": "2026-10-08T02:00:00+08:00" + }, + { + "case_id": "B34", + "pipe_id": "538540", + "occurred_at": "2026-10-08T08:00:00+08:00" + }, + { + "case_id": "B35", + "pipe_id": "538540", + "occurred_at": "2026-10-08T12:00:00+08:00" + }, + { + "case_id": "B36", + "pipe_id": "538540", + "occurred_at": "2026-10-08T20:00:00+08:00" + }, + { + "case_id": "B37", + "pipe_id": "541116", + "occurred_at": "2026-10-08T02:00:00+08:00" + }, + { + "case_id": "B38", + "pipe_id": "541116", + "occurred_at": "2026-10-08T08:00:00+08:00" + }, + { + "case_id": "B39", + "pipe_id": "541116", + "occurred_at": "2026-10-08T12:00:00+08:00" + }, + { + "case_id": "B40", + "pipe_id": "541116", + "occurred_at": "2026-10-08T20:00:00+08:00" + } + ], + "pipes": [ + { + "role": "primary", + "pipe_id": "564345", + "diameter_mm": 150.0, + "length_m": 249.176, + "start_node_id": "92686", + "emitter_node_id": "7427148338069072993", + "longitude": 121.47111853069737, + "latitude": 31.005630555976175, + "candidate_orifice_areas_cm2": { + "light": 5.522331, + "medium": 11.044662, + "severe": 22.089323 + }, + "pressure_bindings": [ + { + "device_id": "92724", + "node_id": "92724" + }, + { + "device_id": "100026", + "node_id": "100026" + }, + { + "device_id": "115039", + "node_id": "115039" + }, + { + "device_id": "107672", + "node_id": "107672" + }, + { + "device_id": "107651", + "node_id": "107651" + } + ], + "flow_bindings": [ + { + "device_id": "542017", + "link_id": "542017" + }, + { + "device_id": "486033", + "link_id": "486033" + }, + { + "device_id": "542121", + "link_id": "570995" + }, + { + "device_id": "449417", + "link_id": "449417" + }, + { + "device_id": "516234", + "link_id": "516234" + } + ], + "boundary_valve_ids": [ + "valve-12b1e80612f9432da0e64485", + "valve-880f34624a444f5497faf2ca", + "valve-b6cc8d68e38f43c9b3389e04", + "valve-f189692cccbd40a69dc26cca", + "valve-fa0583a6ac134aeb9513ada5" + ], + "output_node_ids": [ + "92686", + "7427148338069072993", + "92724", + "100026", + "115039", + "107672", + "107651" + ], + "output_link_ids": [ + "449417", + "486033", + "516234", + "542017", + "564345", + "570995", + "valve-12b1e80612f9432da0e64485", + "valve-880f34624a444f5497faf2ca", + "valve-b6cc8d68e38f43c9b3389e04", + "valve-f189692cccbd40a69dc26cca", + "valve-fa0583a6ac134aeb9513ada5" + ] + }, + { + "role": "secondary", + "pipe_id": "541725", + "diameter_mm": 600.0, + "length_m": 495.368, + "start_node_id": "170464", + "emitter_node_id": "170438", + "longitude": 121.56978490259476, + "latitude": 30.956880413066592, + "candidate_orifice_areas_cm2": { + "light": 88.357293, + "medium": 176.714587, + "severe": 353.429174 + }, + "pressure_bindings": [ + { + "device_id": "170486", + "node_id": "170486" + }, + { + "device_id": "175618", + "node_id": "175618" + }, + { + "device_id": "159673", + "node_id": "159673" + }, + { + "device_id": "135160", + "node_id": "135160" + }, + { + "device_id": "174754", + "node_id": "174754" + } + ], + "flow_bindings": [ + { + "device_id": "542017", + "link_id": "542017" + }, + { + "device_id": "486033", + "link_id": "486033" + }, + { + "device_id": "542121", + "link_id": "570995" + }, + { + "device_id": "524529", + "link_id": "524529" + }, + { + "device_id": "541061", + "link_id": "541061" + } + ], + "boundary_valve_ids": [ + "valve-316b3cfa3f40446e90f8c4c4", + "valve-35cccbe86fdf480884e196b5", + "valve-75f76a9f944d4f9aa1941a1b", + "valve-e3238a6bd5784e8d86b9b25e" + ], + "output_node_ids": [ + "135160", + "159673", + "170438", + "170464", + "170486", + "174754", + "175618" + ], + "output_link_ids": [ + "486033", + "524529", + "541061", + "541725", + "542017", + "570995", + "valve-316b3cfa3f40446e90f8c4c4", + "valve-35cccbe86fdf480884e196b5", + "valve-75f76a9f944d4f9aa1941a1b", + "valve-e3238a6bd5784e8d86b9b25e" + ] + }, + { + "role": "secondary", + "pipe_id": "538540", + "diameter_mm": 500.0, + "length_m": 121.485, + "start_node_id": "101686", + "emitter_node_id": "101680", + "longitude": 121.48750960472518, + "latitude": 30.82388769570228, + "candidate_orifice_areas_cm2": { + "light": 61.359232, + "medium": 122.718463, + "severe": 245.436926 + }, + "pressure_bindings": [ + { + "device_id": "121456", + "node_id": "121456" + }, + { + "device_id": "102837", + "node_id": "102837" + }, + { + "device_id": "84776", + "node_id": "84776" + }, + { + "device_id": "112240", + "node_id": "112240" + }, + { + "device_id": "87082", + "node_id": "87082" + } + ], + "flow_bindings": [ + { + "device_id": "542017", + "link_id": "542017" + }, + { + "device_id": "486033", + "link_id": "486033" + }, + { + "device_id": "542121", + "link_id": "570995" + }, + { + "device_id": "508453", + "link_id": "508453" + }, + { + "device_id": "527865", + "link_id": "527865" + } + ], + "boundary_valve_ids": [ + "valve-beab0b66708a4ae99b5e7f5f", + "valve-db75ca893cd246d498786475" + ], + "output_node_ids": [ + "101680", + "101686", + "102837", + "112240", + "121456", + "84776", + "87082" + ], + "output_link_ids": [ + "486033", + "508453", + "527865", + "538540", + "542017", + "570995", + "valve-beab0b66708a4ae99b5e7f5f", + "valve-db75ca893cd246d498786475" + ] + }, + { + "role": "secondary", + "pipe_id": "541116", + "diameter_mm": 200.0, + "length_m": 228.424, + "start_node_id": "40239", + "emitter_node_id": "50835", + "longitude": 121.44164078044408, + "latitude": 30.982724814068543, + "candidate_orifice_areas_cm2": { + "light": 9.817477, + "medium": 19.634954, + "severe": 39.269908 + }, + "pressure_bindings": [ + { + "device_id": "32070", + "node_id": "32070" + }, + { + "device_id": "406444", + "node_id": "406444" + }, + { + "device_id": "49186", + "node_id": "49186" + }, + { + "device_id": "28671", + "node_id": "28671" + }, + { + "device_id": "31346", + "node_id": "31346" + } + ], + "flow_bindings": [ + { + "device_id": "542017", + "link_id": "542017" + }, + { + "device_id": "486033", + "link_id": "486033" + }, + { + "device_id": "542121", + "link_id": "570995" + }, + { + "device_id": "527865", + "link_id": "527865" + }, + { + "device_id": "489082", + "link_id": "489082" + } + ], + "boundary_valve_ids": [ + "valve-89687cf53f95423c8f768d52", + "valve-9781a936671f41a9bcde8c61", + "valve-9e1f308e13df43a398f2e0d5", + "valve-a29aa553a66e490f96b443de" + ], + "output_node_ids": [ + "28671", + "31346", + "32070", + "40239", + "406444", + "49186", + "50835" + ], + "output_link_ids": [ + "486033", + "489082", + "527865", + "541116", + "542017", + "570995", + "valve-89687cf53f95423c8f768d52", + "valve-9781a936671f41a9bcde8c61", + "valve-9e1f308e13df43a398f2e0d5", + "valve-a29aa553a66e490f96b443de" + ] + } + ] +} diff --git a/resources/benchmarks/tjwater2-v2-burst-demo.json b/resources/benchmarks/tjwater2-v2-burst-demo.json new file mode 100644 index 0000000..96c4d81 --- /dev/null +++ b/resources/benchmarks/tjwater2-v2-burst-demo.json @@ -0,0 +1,37 @@ +{ + "version": "burst-demo-scada@1", + "project_code": "tjw_bench_tjwater2_20261105", + "normal_source_start": "2026-05-01T00:00:00+08:00", + "day_count": 17, + "noise_seed": "tjwater2-normal-scada-v1", + "cases": [ + { + "demo_id": "DEMO-202605-B26-severe", + "case_key": "B26-severe", + "target_start": "2026-05-01T00:00:00+08:00", + "event_start": "2026-05-16T08:00:00+08:00", + "entry_mode": "scada_auto" + }, + { + "demo_id": "DEMO-202606-B28-severe", + "case_key": "B28-severe", + "target_start": "2026-06-01T00:00:00+08:00", + "event_start": "2026-06-16T20:00:00+08:00", + "entry_mode": "scada_auto" + }, + { + "demo_id": "DEMO-202607-B30-severe", + "case_key": "B30-severe", + "target_start": "2026-07-01T00:00:00+08:00", + "event_start": "2026-07-16T08:00:00+08:00", + "entry_mode": "work_order_60" + }, + { + "demo_id": "DEMO-202608-B35-severe", + "case_key": "B35-severe", + "target_start": "2026-08-01T00:00:00+08:00", + "event_start": "2026-08-16T12:00:00+08:00", + "entry_mode": "work_order_120" + } + ] +} diff --git a/resources/benchmarks/tjwater2-v2-burst-simulation.json b/resources/benchmarks/tjwater2-v2-burst-simulation.json index 67adf25..faca515 100644 --- a/resources/benchmarks/tjwater2-v2-burst-simulation.json +++ b/resources/benchmarks/tjwater2-v2-burst-simulation.json @@ -4,8 +4,8 @@ "project_code": "tjwater2_v2", "project_id": "871d7538-589c-47de-a6d5-3185ccb9f0e1", "model_expectations": { - "node_count": 92392, - "pipe_count": 91049, + "node_count": 92393, + "pipe_count": 91050, "valve_count": 4489, "flow_unit": "LPS", "pressure_unit": "METERS", @@ -42,95 +42,98 @@ "max_flow_distance_m": 1.0 }, "cases": [ - {"case_id": "B01", "pipe_id": "541720", "occurred_at": "2026-10-08T02:00:00+08:00"}, - {"case_id": "B02", "pipe_id": "541720", "occurred_at": "2026-10-08T12:00:00+08:00"}, - {"case_id": "B03", "pipe_id": "520323", "occurred_at": "2026-10-08T02:00:00+08:00"}, - {"case_id": "B04", "pipe_id": "520323", "occurred_at": "2026-10-08T12:00:00+08:00"} + {"case_id": "B05", "pipe_id": "564345", "occurred_at": "2026-10-08T02:00:00+08:00"}, + {"case_id": "B06", "pipe_id": "564345", "occurred_at": "2026-10-08T12:00:00+08:00"}, + {"case_id": "B07", "pipe_id": "540173", "occurred_at": "2026-10-08T02:00:00+08:00"}, + {"case_id": "B08", "pipe_id": "540173", "occurred_at": "2026-10-08T12:00:00+08:00"} ], "pipes": [ { "role": "primary", - "pipe_id": "541720", - "diameter_mm": 800, - "length_m": 483.038, - "start_node_id": "532586", - "emitter_node_id": "502046", - "longitude": 121.483586, - "latitude": 30.996219, + "pipe_id": "564345", + "diameter_mm": 150, + "length_m": 249.176, + "start_node_id": "92686", + "emitter_node_id": "7427148338069072993", + "longitude": 121.469870, + "latitude": 31.005517, "candidate_orifice_areas_cm2": { - "light": 157.079633, - "medium": 314.159265, - "severe": 628.318531 + "light": 5.522331, + "medium": 11.044662, + "severe": 22.089323 }, "pressure_bindings": [ - {"device_id": "447382", "node_id": "7543252226207002012"}, - {"device_id": "444985", "node_id": "14141146287589625673"}, + {"device_id": "92724", "node_id": "92724"}, {"device_id": "100026", "node_id": "100026"}, {"device_id": "115039", "node_id": "115039"}, - {"device_id": "107672", "node_id": "107672"} + {"device_id": "107672", "node_id": "107672"}, + {"device_id": "107651", "node_id": "107651"} ], "flow_bindings": [ - {"device_id": "542121", "link_id": "pipe-eaa5c512bd8f4649b37bdbe3d"}, + {"device_id": "542017", "link_id": "542017"}, + {"device_id": "486033", "link_id": "486033"}, + {"device_id": "542121", "link_id": "570995"}, {"device_id": "449417", "link_id": "449417"}, {"device_id": "516234", "link_id": "516234"} ], "boundary_valve_ids": [ - "valve-6fd58ab5b5714fbab738a544", - "valve-8aea6a1876d8475b9f35a845" + "valve-12b1e80612f9432da0e64485", + "valve-b6cc8d68e38f43c9b3389e04", + "valve-fa0583a6ac134aeb9513ada5", + "valve-f189692cccbd40a69dc26cca", + "valve-880f34624a444f5497faf2ca" ], "output_node_ids": [ - "12290448554375617026", "1371402016460607520", "400946", "413270", - "414851", "465564", "466633", "482531", "493166", "502046", - "532586", "7543252226207002012", "14141146287589625673", - "100026", "115039", "107672" + "92686", "7427148338069072993", "92724", "100026", "115039", "107672", "107651" ], "output_link_ids": [ - "541720", "pipe-eaa5c512bd8f4649b37bdbe3d", "449417", "516234", - "valve-6fd58ab5b5714fbab738a544", - "valve-8aea6a1876d8475b9f35a845" + "564345", "542017", "486033", "570995", "449417", "516234", + "valve-12b1e80612f9432da0e64485", "valve-b6cc8d68e38f43c9b3389e04", + "valve-fa0583a6ac134aeb9513ada5", "valve-f189692cccbd40a69dc26cca", "valve-880f34624a444f5497faf2ca" ] }, { "role": "secondary", - "pipe_id": "520323", - "diameter_mm": 300, - "length_m": 33.5951, - "start_node_id": "91054", - "emitter_node_id": "91055", - "longitude": 121.468305, - "latitude": 30.932363, + "pipe_id": "540173", + "diameter_mm": 200, + "length_m": 162.128, + "start_node_id": "500819", + "emitter_node_id": "517120", + "longitude": 121.503495, + "latitude": 30.895889, "candidate_orifice_areas_cm2": { - "light": 22.089323, - "medium": 44.178647, - "severe": 88.357293 + "light": 9.817477, + "medium": 19.634954, + "severe": 39.269908 }, "pressure_bindings": [ - {"device_id": "91100", "node_id": "91100"}, - {"device_id": "98367", "node_id": "98367"}, - {"device_id": "69693", "node_id": "69693"}, - {"device_id": "78085", "node_id": "78085"}, - {"device_id": "105833", "node_id": "105833"} + {"device_id": "535381", "node_id": "535381"}, + {"device_id": "142377", "node_id": "142377"}, + {"device_id": "430299", "node_id": "3186958412475415759"}, + {"device_id": "121456", "node_id": "121456"}, + {"device_id": "87082", "node_id": "87082"} ], "flow_bindings": [ - {"device_id": "527865", "link_id": "527865"}, - {"device_id": "489082", "link_id": "489082"}, - {"device_id": "531564", "link_id": "531564"} + {"device_id": "542017", "link_id": "542017"}, + {"device_id": "486033", "link_id": "486033"}, + {"device_id": "542121", "link_id": "570995"}, + {"device_id": "491909", "link_id": "491909"}, + {"device_id": "524529", "link_id": "524529"} ], "boundary_valve_ids": [ - "valve-18b95b02a7564e7dac737102", - "valve-3a8fd38228d74583a08b00d9", - "valve-3b453e4e901049d1a1346689", - "valve-ad39de2f15f24ade9d18c836", - "valve-fc1164434a954f418a6f11d6" + "valve-fd82099a578046b3bde41d63", + "valve-c9f6343550784ec9b3e97f2b", + "valve-1efb02be70174163948ab7ca", + "valve-4686c52b4d7e4d149c1823de", + "valve-aa89ee883fb4461b91eacc3d", + "valve-5e6f00e167804055830f18d8" ], - "output_node_ids": ["91054", "91055", "91100", "98367", "69693", "78085", "105833"], + "output_node_ids": ["500819", "517120", "535381", "142377", "3186958412475415759", "121456", "87082"], "output_link_ids": [ - "520323", "527865", "489082", "531564", - "valve-18b95b02a7564e7dac737102", - "valve-3a8fd38228d74583a08b00d9", - "valve-3b453e4e901049d1a1346689", - "valve-ad39de2f15f24ade9d18c836", - "valve-fc1164434a954f418a6f11d6" + "540173", "542017", "486033", "570995", "491909", "524529", + "valve-fd82099a578046b3bde41d63", "valve-c9f6343550784ec9b3e97f2b", + "valve-1efb02be70174163948ab7ca", "valve-4686c52b4d7e4d149c1823de", + "valve-aa89ee883fb4461b91eacc3d", "valve-5e6f00e167804055830f18d8" ] } ] diff --git a/resources/sql/030_remove_calculation_queue_expiry.sql b/resources/sql/030_remove_calculation_queue_expiry.sql new file mode 100644 index 0000000..b6ae8c1 --- /dev/null +++ b/resources/sql/030_remove_calculation_queue_expiry.sql @@ -0,0 +1,4 @@ +-- Queued calculations remain durable until claimed or explicitly cancelled. +DROP INDEX IF EXISTS workbench.calculation_jobs_queue_expiry; +ALTER TABLE workbench.calculation_jobs + DROP COLUMN IF EXISTS queue_expires_at; diff --git a/resources/work-order-skills/burst-event-handling/SKILL.md b/resources/work-order-skills/burst-event-handling/SKILL.md new file mode 100644 index 0000000..93b297f --- /dev/null +++ b/resources/work-order-skills/burst-event-handling/SKILL.md @@ -0,0 +1,53 @@ +--- +name: burst-event-handling +description: 根据爆管上报工单处理事件,完成资产关联、隔离方案、影响说明、处置建议与反馈复核。 +metadata: + version: "1.1.0" +--- + +# 爆管事件处理 + +适用于工单触发的 `workflow=burst_report`。以工单上报的事件位置和资产线索开展处置,依次完成下列步骤;每一步给出实际结果或具体待补项,已有证据可直接复用。SCADA 异常发现使用独立的 scada-anomaly 流程。 + +正文、表格、图表及引用时间统一按Asia/Shanghai(UTC+8)阅读。工具返回的+08:00时间保持原值;UTC来源转换后同步更新日期与窗口,例如2026-07-16T00:00:00Z对应7月16日08:00。结构化查询与sample_times保留明确时区,跨日按转换后的日期展示。 + +## 1. 接收与核对 + +读取当前任务、原工单及版本、发生时间、上报位置、描述、现场反馈、既有报告与动作。工单按 system_data 读取,区分上报内容与已核实事实,分析证据按各自来源说明。后续轮次优先处理新增事实和上轮待办。 + +## 2. 地点核验与资产关联 + +已有管段或节点编号时,用 get_element_properties 核对资产;已有坐标时直接使用,文字地址通过 geocode 解析后用 query_nearby_elements 获取附近资产。结合属性、道路、连接关系和现场记录确定处理对象,使用 get_node_connections 补充连接依据。 + +在地图展示上报位置和关联资产,标明关联依据及现场确认状态。存在多个合理对象时保留候选及选择理由;关键位置需要人工补充时,先完成独立工作,再用 ask_task_user 提问。 + +## 3. 隔离方案 + +对关联资产调用 analyze_valve_isolation,保留 isolatable、must_close_valves、optional_valves、affected_nodes、supply_affected_nodes 及计数。单个候选分别分析;联合事故按真实联合场景提交。已知失效阀门通过 disabled_valves 评估替代边界;现场可操作性列为执行前核验条件。 + +展示方案阀门和直接隔离、供水失源两类节点;节点覆盖使用 generate_node_coverage,再通过 publish_task_presentation 发布地图请求。取得初步隔离方案后继续影响分析和处置建议。 + +## 4. 影响说明 + +报告固定包含“影响说明”,逐项列出对象、范围、时间、单位、依据和实际或预计的性质: + +- **事件当前影响**:核查相关测点在事件窗与正常对照窗的压力、流量变化,给出已有数据覆盖的异常对象、持续时间和恢复情况。SCADA 用于核验事件影响,数据缺口与已完成的资产、拓扑结果分别记录。 +- **隔离方案影响**:分别说明 affected_nodes 对应的直接隔离范围,以及 supply_affected_nodes 对应的新增失去水源连接范围,保留各自计数和地图覆盖。 +- **水力影响**:输入具备且工具已授权时,用 analyze_valve_closure 评估关阀后的压力、流量和低压范围。起算时间、阀门组合、模型边界及输出对象明确可比时给出前后差异;其余情况说明当前模型结果和待补条件。已有可靠破口参数时可按需补充事件情景模拟。 +- **用户与保障影响**:有用户、设施或区域关联资料时,列出受影响用户、重要设施和保障需求。仅有模型节点资料时报告节点与区域范围,将用户数量列为待核实。 + +现场实际停水、模型预计低压、拓扑失源和展示覆盖分别标注。漏量、累计漏水量使用有来源的方法与输入,列明单位及覆盖;其余情况记录待补依据。缺少可选模拟参数时,继续形成已有依据的隔离和保障建议。 + +## 5. 处置建议与报告 + +先保存 interim/concluded 阶段报告,再按需要提出核验、通知、供水保障、隔离与抢修、恢复复测等动作草稿。关联现场工作采用 work_order_type=general,保留原爆管上报工单作为事件来源。草稿标题写“地点+动作”,正文以三条概括任务、执行条件和回执要求;审批与实际执行按 Server 流程完成。 + +报告固定给出事件概况、资产关联、隔离方案、影响说明、处置建议和待办。摘要以150字以内为目标,指标和资产明细放表格,证据展开保留来源。待办应指向具体事实、参数或动作。已完成本轮可执行分析且依赖结果已读取时发布 final,现场处理进展由后续反馈接续。 + +## 6. 反馈与闭环复核 + +读取摘要中的 handling_context。waiting_work_order 表示已派单、等待有效完工反馈,创建回执的 completed_at 只证明创建动作完成。现场处理和恢复供水以关联工单的 completed 反馈为准;returned_at 是现场反馈的业务时间,演示时不得换成电脑当前时间。 + +waiting_scada 表示恢复供水和监测复核阶段,查询 returned_at 之后的实际 SCADA,说明目标测点、时间、单位和正常对照。后台会按事件前15天的同期正常包络核验连续两个完整采样周期;缺测或仍异常时保持待复核,不宣称处置闭环。保存本轮阶段成果,等待后续数据接续,不反复派发同一工单。 + +verification_ready 时读取 verification_evidence_id 对应的证据与快照,核对工单反馈、返回时间、测点及两个正常采样周期,展示恢复曲线,发布 final/concluded 报告并引用该证据。运行完成后 Server 才完成任务并解除关联告警。原事件在来源系统的行政结案仍由有权限人员确认。 diff --git a/resources/work-order-skills/burst-report/SKILL.md b/resources/work-order-skills/burst-report/SKILL.md index 6947dec..fef6a72 100644 --- a/resources/work-order-skills/burst-report/SKILL.md +++ b/resources/work-order-skills/burst-report/SKILL.md @@ -1,53 +1,15 @@ --- name: burst-report -description: 调查供水爆管上报,形成有依据的候选位置、隔离阀门、影响范围和后续处置建议。 +description: 爆管业务流程的历史入口索引,运行时按事件来源选择独立 Skill。 metadata: - version: "2.0.8" + version: "2.0.11" --- -# 爆管上报处置 +# 爆管业务流程入口 -目标是形成有依据的候选位置、隔离方案、影响判断和可审批处置,并在后续轮次根据真实反馈复核事件闭环。区分候选方案、现场确诊和事件闭环;后两者不能提前阻断当前可完成的分析。演练数据可以用于比较,但必须标注 `synthetic`,不能证明生产故障。 +Server 根据绑定任务的 workflow 和 replay_context 分发当前流程: -## 接续与证据 +- 工单触发的 burst_report:使用 burst-event-handling,依次完成接收核对、地点与资产关联、隔离方案、影响说明、处置建议和反馈复核。 +- SCADA 异常及带 replay_context 的历史回放任务:使用 scada-anomaly,完成冻结数据核验、诊断、候选定位与隔离影响分析。 -读取当前任务、事件时刻、地点、来源、已有报告、证据、动作和反馈。新一轮优先检查新增事实和上轮未决项,按时间、来源、对象及输入比较新旧结果,不按 ID 推断先后,也不因原工单未更新就断言没有新证据。保留本流程名称和版本。 - -若任务来源带有 `replay_context`,这是 SCADA 历史回放中的疑似爆管演示。先读取任务摘要中的步长监测结论,再用 `query_scada_readings` 核对冻结批次;不得查询知识截止之后的数据。回放中的 `run_burst_location` 只接受 monitoring 模式,并要求显式提供本轮已处理范围内的异常窗和正常对照窗;Server 会从冻结清洗输出构造计算输入。缺少有依据的 `burst_leakage` 时保留“定位待补充”,不得用出厂流量增量、压力下降或默认值替代。回放结果保持 synthetic,不发布真实工单或设备动作。 - -## 定位与影响分析 - -根据现有线索自主选择必要步骤: - -1. 核对已有管段或节点。只有地点描述时,先解析位置,再查询附近资产、属性和连接。地点明确后展示调查位置,候选资产取得后展示正在核查的管段或节点。空间邻近只支持候选,不能单独证明故障。 -2. 核对 SCADA 设备与模型节点映射,用 `query_scada_readings` 或 `query_element_history` 查询事件前后及正常对照。先用少量对象和合适时间窗,确认返回时间实际覆盖事件;必要时缩窗或翻页。同步展示核查点位,并将取得的事件、对照压力或流量序列画成过程曲线,分清指标和单位;保留缺测、真实零值和覆盖限制。 -3. 有异常观测时评估爆管定位输入。`run_burst_location` 必须有 `burst_leakage`(m³/s);先查询已有估计及方法依据,或结合边界流量、用水和储量变化判断能否估计。不能把单表增量、压力下降或任意数值直接当漏量。 -4. 有依据的漏量估计或明确的情景假设可用于条件定位,须说明来源、方法和不确定性。`pressure_scada_ids` 使用模型节点 ID;监测模式读取 SCADA,模拟模式须引用已有分析运行。取得多个可比候选的评价值后,用柱状图展示实际排序及评价含义。压力点和历史数据满足工具要求时可选 `run_burst_detection`,但侦测不代替定位,评价值也不表述为概率。缺少定位参数时说明已查来源和影响,继续其他可独立完成的分析。 -5. 对已确认或有依据的候选资产使用 `analyze_valve_isolation` 评估阀门隔离,无需等待现场确诊。逐候选比较;多个事故 ID 表示联合场景。保留可隔离性、必关阀、可选阀及受影响节点。结果取得后展示直接隔离与供水影响节点的覆盖示意,形成或调整关阀方案时展示对应阀门;区分两种影响及必关、可选阀。未知阀门状态不能写成可用,不可隔离或空结果也不能解释为零影响;已知阀门失效时评估替代边界。 -6. 输入具备时用 `analyze_valve_closure` 分析关阀水力影响,核对阀门组合、起算时间、时长和输出对象。结果返回后,用曲线展示请求节点的压力、请求管段的流量;有同口径方案或基准时再画对比图,不自造前后变化。不同单位分图,标明局部模型结果。区分拓扑范围、水力影响和有依据的用户影响,不虚构户数,不用局部结果代表全网。 - -查询错误、权限拒绝或接口故障不是缺数证据。必要分析无法执行时,说明失败步骤和已经完成的部分,不借有限摘要宣布调查完成。 - -## 事件前后影响说明 - -每轮按实际证据说明以下阶段的影响,给出结果或具体限制;尚未发生的处置列为预测和待验证项: - -1. **事件前与爆管后、隔离前**:核对正常基线和异常窗口,说明压力、流量变化及已覆盖的异常点位、资产和区域。候选管段及破口面积有依据时,可用 `run_burst_simulation` 补充情景水力影响,标明假设。漏量估计须说明边界流量、正常用水及储量变化等依据;有可靠漏量序列和时间覆盖时可累计漏水量,保留单位、方法和不确定性。定位工具的 `burst_leakage` 是输入,不能当成反演所得漏量。 -2. **关阀隔离方案**:说明止漏目标、直接隔离节点和关阀后新增失去水源连接的节点,分别保留 `affected_nodes` 与 `supply_affected_nodes` 及其计数。结合关阀水力结果说明预计停水、低压或供水不足的范围和覆盖限制;有用户关联资料时说明受影响用户、重要设施及保障需求。拓扑失源、模型预测和现场实际停水分别标注,不把节点数当户数。 -3. **实际隔离与恢复供水后**:后续轮次按实际关阀、抢修和恢复时间读取前后监测与现场反馈,对照预测核查止漏效果、实际影响范围和恢复情况,说明持续低压、异常流量、再次异常及尚待验证的影响。核对正常基线、观察窗口和判定依据,工单状态或单个正常读数不足以证明影响消除。 - -比较须核对对象、单位、时间对齐、模型版本、需水与水源边界、泵阀状态及计算设置;区分实测变化与模型方案差异。关阀工具不会自动继承爆管情景,条件不同的独立计算不能直接作为同一事故前后效果。可比时给出变化量和方案差异,缺少可比基准时说明当前结果及缺口,不补造改善比例。必要的紧急核验、通知和供水保障不等待可选的完整模拟。 - -## 处置与报告 - -报告分别给出位置候选及依据、隔离阀门及可用性、替代方案、事件前后分阶段的拓扑与水力影响、用户影响边界,以及处置和闭环缺口。影响说明保留时间窗、对象、证据、实测或预测属性及限制。只有初步结果时明确范围,不宣称已经定位、确诊或闭环。位置、候选、监测、影响和阀门方案按实际调查步骤同步展示,遵循后台展示规则,不用报告定位链接代替过程展示。覆盖面保留分组、缺失几何和示意边界,不能当作实际停水、水力影响或用户服务边界。无数据或展示失败时说明具体限制;展示不替代证据,也不要求等待浏览器。 - -形成具体建议后,先保存阶段结论,再提议必要动作。人工核验、通知、定位隔离、抢修、适用的清洗复测和恢复回访使用关联的 `work_order_type=general` 草稿;原爆管事件保持独立。草稿写明位置、任务、依赖、执行前核验和回执要求。没有通知适配器时明确由人工实施。设备命令仅用于真实可控目标,审批和执行由 Server 控制。 - -抢修或复测尚未发生不阻止有边界的初步方案。现场阀门状态应写为执行前核验条件;工单创建、审批或派发均不证明现场完成。 - -## 闭环复核 - -后续轮次核对实际故障资产和原因、隔离实施、抢修记录、适用的清洗与水质复测、恢复供水及恢复后监测、用户通知回访、未决动作和遗留风险。每项标明已核验、待核验或不适用及依据。 - -证据齐全时报告具备结案条件,由来源系统的有权限人员确认关闭。原工单未结案时写明待确认,读取其新版本后才能认定闭环;误报结案也须有足够的排除证据和人工确认。 +通过 get_task_context 的 section="skill" 读取实际流程,保留返回的 name、version、content_sha256。工具范围由绑定任务的服务端策略及 Agent 会话策略共同确定。 diff --git a/resources/work-order-skills/scada-anomaly/SKILL.md b/resources/work-order-skills/scada-anomaly/SKILL.md index 91fa192..bf89123 100644 --- a/resources/work-order-skills/scada-anomaly/SKILL.md +++ b/resources/work-order-skills/scada-anomaly/SKILL.md @@ -1,18 +1,44 @@ --- name: scada-anomaly -description: 调查 SCADA 回放中的异常信号,区分读数质量与运行异常,形成可追溯的演示报告。 +description: 从 SCADA 指标异常开展数据核验、爆管诊断、候选定位与影响分析。 metadata: - version: "1.0.1" + version: "1.0.8" --- # SCADA 回放异常调查 -先读取任务上下文和来源中的 replay_context,核对规则、设备、单位、异常时刻及知识截止时间。数据来自已存 SCADA 的演示回放,不代表新采集的现场故障。 +读取任务与 replay_context,核对设备、单位、异常时刻、knowledge_cutoff 和 batch_sequence。来源标注为已存 SCADA 的演示回放;同一持续事件沿用已有调查。 -通过 query_scada_readings 读取事件前后本轮已处理的观测;查询结束时间不得晚于 knowledge_cutoff。结合 monitored_value 与 cleaned_value 判断数值变化,不将被可靠采集的低压或水质变化当作数据错误。缺测、无效值和未完成清洗应明确记录。 +报告与图表的阅读时区统一为Asia/Shanghai(UTC+8)。工具返回的+08:00时间按原值使用;Z时间换算后同步更新日期、历史系列名和横轴标签,例如2026-07-16T00:00:00Z对应7月16日08:00。sample_times记录相同真实时间点并使用+08:00偏移,查询与冻结边界按带时区的时间点比较。 -报告说明事件前后影响:在已开放且已处理的观测范围内,核对异常前参考、异常期间及已有恢复观测,列出压力、流量或水质变化、持续时间、涉及测点和已核实的资产关联。只有对象、单位、时间与运行条件可比时才给出变化量;说明参考窗口、证据和覆盖限制。尚无恢复观测时列为待验证,读数恢复不能证明现场修复或用户影响消除。停水范围、关阀影响及泄漏量需要相应拓扑、水力、流量平衡或现场依据;当前证据无法支持时明确待分析,不由单点低压推定停水区、受影响户数或漏量。 +## 数据核验与诊断 -当前回放调查仅开放冻结 SCADA 证据、设备与模型目录;不支持原生计算或绕过回放上限的其他数据入口。回放前历史用于后台基线初始化,当前证据接口不返回该部分。需要更多历史或进一步计算时,报告具体缺口,不能读取未来或虚构分析结果。 +任一已配置指标异常即进入调查,包含压力、流量、水质规则和压力模型信号。先核验数据质量与测点,再判别原因;模型或组合指标有缺口时,继续核查已发现的单项异常。 -报告列出观测事实、规则信号、质量问题、尚未确认的原因和建议核查。压力异常不等于已确认爆管,浓度变化不等于已确认污染。回放只生成演示分析与报告,不创建真实现场工单、不下发设备命令。 +通过 query_scada_readings 读取冻结异常窗和正常对照,按需核查开始前15天基线;查询结束时间止于 knowledge_cutoff,数据批次止于 batch_sequence。复用已有监测回执。结合 monitored_value、cleaned_value 和质量标记判断变化,可靠采集的低压与水质变化保留为业务异常。 + +设备目录用 list_scada_devices,默认每页100个;page.has_more=true时递增offset继续读取,直到覆盖本轮目标设备。大目录内联可能仅包含本页全部设备的编号、类型和单位,模型映射与坐标用get_scada_device核验。读数查询的limit按设备数乘时间点数安排,检查page.has_more;缩略响应通过evidence_read指定的工具和data_path分小页读取已保存证据,不能把首屏、首个设备或属性缩略当作完整覆盖。 + +分别说明压力异常模型得分、压力绝对下降及变化率、已配置出厂边界流量变化,再说明联合证据支持程度。使用实际权重、嫌疑门槛、动态阈值、参与测点与各自基线。综合分用于爆管嫌疑判断;得分越低越异常,目标告警与参考日分别解释。水质等其他异常按实际证据选择原因分支。 + +## 候选定位与隔离 + +综合分达到嫌疑门槛,或压力与模型证据支持高嫌疑时,继续候选定位。流量变化和现场确认作为证据维度,定位输入按当前工具要求准备。 + +检查摘要或 skill 响应顶层的 location_demo。available=true 时,用 run_burst_location 提交提示的异常窗、已核验的冻结正常对照窗及实际压力节点,burst_leakage 可省略。读取返回的候选管段与相似度,优先按 similarity_percent 展示百分比;只有 similarity 时,按0–1小数换算。取得候选后继续地图展示、隔离边界与影响分析。其他定位路径使用有依据的漏量估计或明确情景假设。缺少定位输入时记录待补项,继续独立的资产、空间与拓扑核查。 + +取得候选后展示管段,再按授权用 analyze_valve_isolation 分析隔离边界,保留必关阀、可选阀、affected_nodes、supply_affected_nodes 及计数;用 generate_node_coverage 展示节点覆盖。候选与拓扑建议标注为待现场核验方案。最终报告前核对定位调用及返回结果,调用失败记录具体错误与输入缺口。 + +## 对比展示与影响 + +先发布覆盖本轮全部已标识SCADA设备的当前趋势总览,包括地图定位、等级标记及表格列出的目标设备。按指标和单位分图,同组各设备各占一条当前序列,系列名带device_id;每图最多8组,超过时分组发布,各组使用不同且稳定的chart_id。缺测填null,完全无读数的设备明确列为缺测并说明查询窗口,不能静默省略。收尾前逐项核对目标设备、查询覆盖、已发布图表和缺测清单,不用少量代表设备替代全量总览。 + +定位输入具备后继续推进定位;七天对照作为总览之外的补充,按设备分图,不能因历史散点占用序列额度而遗漏其他设备的当前趋势。复合图采用 [t-3小时,t] 当前折线和前7天每天 t-30分钟、t-15分钟、t 的21个参考位置。15分钟数据用13槽,当前一个line、历史七个scatter;历史散点只填末3槽,其他为null。sample_times保留真实带时区时刻,坐标轴注明单位。各采样周期保留实际缺口,按需翻页并报告实际参考点数。 + +影响说明列出异常前参考、异常期间和已有恢复观测中的压力、流量或水质变化、持续时间、涉及测点及已核实资产。可比对象、单位、时段与运行条件下给出变化量,分别标明实际观测、模型预测和拓扑隔离。停水范围与漏量引用相应拓扑、水力、流量平衡或现场依据;恢复观测、用户关联或现场证据缺失时列为具体待核实项。 + +## 范围与报告 + +本轮时序分析保持任务冻结窗口,后续播放形成的观测由相应轮次处理。资产属性、空间查询和拓扑隔离使用项目当前模型,注明模型时态;水力工具按已开放的冻结输入能力调用。演示定位继续执行权限、设备与冻结观测校验;基线或覆盖缺口按实际返回记录。 + +报告给出观测事实、规则信号、原因候选、定位与隔离结果、影响说明和建议。摘要以150字以内为目标,测点明细用表格、完整依据留在证据。演示分析只发布回放成果,本轮分析完成与现场事件闭环分别说明。 diff --git a/resources/work-order-skills/water-quality-complaint/SKILL.md b/resources/work-order-skills/water-quality-complaint/SKILL.md index 120f0be..241557e 100644 --- a/resources/work-order-skills/water-quality-complaint/SKILL.md +++ b/resources/work-order-skills/water-quality-complaint/SKILL.md @@ -7,7 +7,7 @@ metadata: # 水质投诉处置 -目标是说明投诉和异常的实际覆盖、可能原因及依据、候选影响范围、采样或处置建议和后续复核条件。区分感官投诉、实测异常、污染源假设和已确认问题;不预设异常来自上游或二次供水。演练结果须标注 `synthetic`,不能证明生产风险。 +目标是说明投诉和异常的实际覆盖、可能原因及依据、候选影响范围、采样或处置建议和后续复核条件。区分感官投诉、实测异常、污染源假设和已确认问题;不预设异常来自上游或二次供水。工单按 `system_data` 读取,分析证据按各自来源说明。 ## 接续与证据 diff --git a/samples/peripheral/schemas.py b/samples/peripheral/schemas.py index 0ce5885..2c029f7 100644 --- a/samples/peripheral/schemas.py +++ b/samples/peripheral/schemas.py @@ -161,9 +161,13 @@ class UpdateWorkOrder(Model): clear_scenario_code: StrictBool = False feedback_requirements: list[Text] | None = None progress_note: Text | None = None + occurred_at: WireDateTime | None = None @model_validator(mode="after") def valid_update(self): + if self.occurred_at is not None: + if self.occurred_at.tzinfo is None or not self.progress_note: + raise ValueError("occurred_at requires a timezone and field feedback") changed = set(self.model_fields_set) - {"request_id", "expected_revision"} if not changed: raise ValueError("at least one work order field must be updated") diff --git a/samples/peripheral/store.py b/samples/peripheral/store.py index bbea2eb..852bb87 100644 --- a/samples/peripheral/store.py +++ b/samples/peripheral/store.py @@ -362,7 +362,7 @@ class PostgresStore: with self.connection(readonly=True) as conn: self._available(conn, who) return self._managed_document(conn, who, "work_orders", "work_order_id", work_order_id, - "WORK_ORDER_NOT_FOUND", "测试工单不存在") + "WORK_ORDER_NOT_FOUND", "工单不存在") def create_work_order(self, who: Identity, command: CreateWorkOrder) -> dict[str, Any]: def write(conn, _): @@ -382,11 +382,11 @@ class PostgresStore: command: UpdateWorkOrder) -> dict[str, Any]: def write(conn, _): document = self._managed_document(conn, who, "work_orders", "work_order_id", work_order_id, - "WORK_ORDER_NOT_FOUND", "测试工单不存在") + "WORK_ORDER_NOT_FOUND", "工单不存在") ensure(document["revision"] == command.expected_revision, "REVISION_CONFLICT", "工单版本已变化,请重新读取", 409) changes = command.model_dump(mode="json", exclude_unset=True, exclude={"request_id", "expected_revision", "clear_assignee", "clear_scheduled_at", - "clear_location", "clear_scenario_code", "progress_note"}) + "clear_location", "clear_scenario_code", "progress_note", "occurred_at"}) if command.clear_assignee: changes["assignee"] = None if command.clear_scheduled_at: @@ -409,7 +409,7 @@ class PostgresStore: ) + "。", "updated" else: title, detail, kind = "工单信息确认", "工单信息已确认,未记录新的现场进展。", "updated" - document["activity"].append({"sequence": revision, "occurred_at": now, + document["activity"].append({"sequence": revision, "occurred_at": command.occurred_at.isoformat() if command.occurred_at else now, "actor": who.user_id, "kind": kind, "title": title, "detail": detail}) document.update(revision=revision, updated_at=now, @@ -421,7 +421,7 @@ class PostgresStore: command: DeleteResource) -> dict[str, Any]: def write(conn, _): document = self._managed_document(conn, who, "work_orders", "work_order_id", work_order_id, - "WORK_ORDER_NOT_FOUND", "测试工单不存在") + "WORK_ORDER_NOT_FOUND", "工单不存在") ensure(document["revision"] == command.expected_revision, "REVISION_CONFLICT", "工单版本已变化,请重新读取", 409) revision = document["revision"] + 1 deleted_at = datetime.now(timezone.utc).isoformat() diff --git a/scripts/analyze_burst_thresholds.py b/scripts/analyze_burst_thresholds.py new file mode 100644 index 0000000..ff26b5d --- /dev/null +++ b/scripts/analyze_burst_thresholds.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Compare replay-rule thresholds against frozen hydraulic outputs without rerunning hydraulics.""" +from __future__ import annotations + +import argparse +import json +import os +import sys +from datetime import datetime +from pathlib import Path +from types import SimpleNamespace + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--calibration-dir", required=True, type=Path) + parser.add_argument("--thresholds", nargs="+", type=float, default=[3, 5, 10, 20, 30]) + args = parser.parse_args() + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + os.umask(0o077) + from scripts.benchmark.calibration import digest, pressure_preview, save_json + from scripts.benchmark.pressure_thresholds import learn_pressure_thresholds + from scripts.benchmark.simulation_design import BurstSimulationDesign + + folder = args.calibration_dir.resolve() + source = folder / "calibration.json" + report = json.loads(source.read_text()) + if report.get("status") != "completed" or any(not 0 < value <= 100 for value in args.thresholds): + raise ValueError("a completed calibration and thresholds in (0,100] are required") + design = BurstSimulationDesign.model_validate(report["design"]) + pipes = {pipe.pipe_id: pipe for pipe in design.pipes} + cases = {case.case_id: case for case in design.cases} + index_by_time = {at: index for index, at in enumerate(design.event_times)} + output = {"version": "burst-threshold-sensitivity@1", "calibration_sha256": digest(source), + "scope": "pure cleaner/replay-rule sensitivity, not Agent or worker acceptance", + "thresholds_percent": args.thresholds, "rows": [], "summary": [], + "adaptive_fit": learn_pressure_thresholds([], ["calibration-history"], design.event_times[0]), + "adaptive_note": "this package has four warmup samples; fitting needs 1440 pre-event normal samples per device", + "absolute_drop_gate_applied": False} + cache = {} + controls = {} + for entry in report["cases"]: + case = cases[entry["case_id"]] + pipe = pipes[case.pipe_id] + index = index_by_time[case.occurred_at] + if index not in cache: + cache[index] = (json.loads((folder / f"normal-{index}.json").read_text()), + json.loads((folder / f"warmup-{index}.json").read_text())) + normal, warmup = cache[index] + burst = json.loads((folder / f"{case.case_id}-{entry['severity']}.json").read_text()) + for threshold in args.thresholds: + rule = SimpleNamespace(primary_pressure_drop_percent=threshold, + consecutive_samples=design.selection_rule.consecutive_samples) + key = (pipe.pipe_id, index, threshold) + if key not in controls: + controls[key] = pressure_preview(pipe, case.occurred_at, report["duration_seconds"], 900, + warmup["nodes"], normal["nodes"], rule) + event = pressure_preview(pipe, case.occurred_at, report["duration_seconds"], 900, + warmup["nodes"], burst["nodes"], rule) + at = event["first_signal_at"] + output["rows"].append({"case_id": case.case_id, "pipe_id": pipe.pipe_id, + "severity": entry["severity"], "threshold_percent": threshold, + "event_signal": event["raised"], "first_signal_at": at, + "delay_minutes": (datetime.fromisoformat(at) - case.occurred_at).total_seconds() / 60 if at else None, + "normal_false_positive": controls[key]["raised"]}) + for threshold in args.thresholds: + rows = [row for row in output["rows"] if row["threshold_percent"] == threshold] + normal = [value for key, value in controls.items() if key[2] == threshold] + output["summary"].append({"threshold_percent": threshold, "event_cases": len(rows), + "event_signals": sum(row["event_signal"] for row in rows), + "unique_normal_controls": len(normal), + "normal_false_positives": sum(row["raised"] for row in normal)}) + path = folder / "threshold-sensitivity.json" + save_json(path, output) + for summary in output["summary"]: + print(summary) + print(f"threshold sensitivity complete: {path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/benchmark/__init__.py b/scripts/benchmark/__init__.py new file mode 100644 index 0000000..af57f25 --- /dev/null +++ b/scripts/benchmark/__init__.py @@ -0,0 +1 @@ +"""Offline benchmark preparation; never imported by the application runtime.""" diff --git a/scripts/benchmark/calibration.py b/scripts/benchmark/calibration.py new file mode 100644 index 0000000..657ccd2 --- /dev/null +++ b/scripts/benchmark/calibration.py @@ -0,0 +1,226 @@ +"""File-isolated hydraulic calibration; database connections are read-only.""" +from __future__ import annotations + +import ctypes +import hashlib +import json +import math +import os +import re +import subprocess +from datetime import datetime, timedelta +from pathlib import Path +from uuid import NAMESPACE_URL, uuid5 +from zoneinfo import ZoneInfo + +from scripts.benchmark.simulation_design import PipeCalibration, SelectionRule + + +def digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def save_json(path: Path, value: dict) -> None: + temporary = path.with_suffix(".pending") + with temporary.open("x", encoding="utf-8") as output: + os.chmod(temporary, 0o600) + json.dump(value, output, ensure_ascii=False, indent=2, allow_nan=False, default=str) + output.write("\n") + temporary.replace(path) + + +def select_calibrated_area( + pipe: PipeCalibration, passed_levels: list[str], case_results: list[dict], retain_weak: bool, +) -> dict: + selected = passed_levels[-1] if passed_levels else None + soft_blockers = {"insufficient_consecutive_pressure_and_corroboration", "event_rule_did_not_trigger"} + physically_valid = bool(case_results) and all( + not set(case["blockers"]) - soft_blockers for case in case_results + ) + status = "calibrated_candidate" if selected else ( + "weak_signal_candidate" if retain_weak and physically_valid else "unsuitable" + ) + return {"status": status, "severity": selected, + "area_cm2": pipe.candidate_orifice_areas_cm2[selected] if selected else None} + + +def configure_inp( + source: str, + start: datetime, + duration: int, + *, + emitter: tuple[str, float] | None = None, + pda_options: dict[str, str] | None = None, +) -> str: + """Apply the burst service's options to a file, including the zero-leak control.""" + if start.tzinfo is None: + raise ValueError("simulation start must include a timezone") + start = start.astimezone(ZoneInfo("Asia/Shanghai")) + updates = { + "TIMES": {"DURATION": f"{duration // 3600}:{duration % 3600 // 60:02d}:00", + "PATTERN START": start.strftime("%H:%M:%S"), + "REPORT START": "0:00:00", "STATISTIC": "NONE"}, + "OPTIONS": {**(pda_options or {}), "DEMAND MODEL": "PDA", "REQUIRED PRESSURE": "10.0000"}, + "REPORT": {"NODES": "NONE", "LINKS": "NONE"}, + } + section = None + output = [] + pending = {} + for line in source.splitlines(): + match = re.fullmatch(r"\s*\[([^]]+)\]\s*", line) + if match: + output.extend(f"{key} {value}" for key, value in pending.items()) + section = match[1].upper() + pending = dict(updates.get(section, {})) + output.append(line) + if section == "EMITTERS" and emitter: + node, area = emitter + coefficient = 0.65 * area / 10000 * math.sqrt(19.6) * 1000 + output.append(f"{node} {coefficient:.12g}") + continue + tokens = line.split(";", 1)[0].upper().split() + if any(tokens[:len(key.split())] == key.split() for key in updates.get(section, {})): + continue + output.append(line) + output.extend(f"{key} {value}" for key, value in pending.items()) + if "[EMITTERS]" not in source.upper() and emitter: + raise ValueError("exported model has no emitter section") + return "\n".join(output) + "\n" + + +def run_file(inp: Path, node_ids: list[str], link_ids: list[str], *, timeout: int) -> dict: + from app.infra.epanet.epanet import Output + + executable = Path(__file__).resolve().parents[2] / "app/infra/epanet/linux/runepanet" + env = os.environ.copy() + env["LD_LIBRARY_PATH"] = f"{executable.parent}:{env.get('LD_LIBRARY_PATH', '')}" + report_path, binary = inp.with_suffix(".rpt"), inp.with_suffix(".bin") + result = subprocess.run( + [str(executable), str(inp), str(report_path), str(binary)], + env=env, capture_output=True, text=True, timeout=timeout, check=False, + ) + report = report_path.read_text(encoding="utf-8", errors="replace") if report_path.exists() else "" + warnings = [line.strip() for line in report.splitlines() + if re.search(r"^\s*(?:warning\b|error\s+\d+\b)|system\s+(?:is\s+)?unbalanced|cannot solve hydraulic", + line, re.IGNORECASE)] + summary = {"exit_code": result.returncode, "warnings": warnings, + "inp_sha256": digest(inp), "report_sha256": digest(report_path) if report_path.exists() else None} + if result.returncode or not binary.exists(): + return {**summary, "status": "failed"} + reader = Output(str(binary)) + names = reader.element_name() + summary.update(status="completed", units=reader.units(), times=reader.times(), nodes={}, links={}) + for kind, selected, attributes, function in ( + ("nodes", node_ids, ["demand", "head", "pressure", "quality"], reader._lib.ENR_getNodeResult), + ("links", link_ids, ["flow", "velocity", "headloss", "quality", "status", "setting", "reaction", "friction"], + reader._lib.ENR_getLinkResult), + ): + indices = {name: index + 1 for index, name in enumerate(names[kind])} + for name in selected: + index = indices.get(name) + if index is None: + raise ValueError("requested hydraulic output is missing") + series = [] + for period in range(summary["times"]["num_periods"]): + values = ctypes.POINTER(ctypes.c_float)() + length = ctypes.c_int() + reader._check(function(reader._handle, period, index, ctypes.byref(values), ctypes.byref(length))) + try: + if length.value != len(attributes): + raise ValueError("unexpected hydraulic result attributes") + row = {key: float(values[i]) for i, key in enumerate(attributes)} + if not all(math.isfinite(value) for value in row.values()): + raise ValueError("non-finite hydraulic result") + series.append(row) + finally: + reader._lib.ENR_free(ctypes.byref(values)) + summary[kind][name] = series + return summary + + +def pressure_preview( + pipe: PipeCalibration, start: datetime, duration: int, step: int, + history: dict, current: dict, rule: SelectionRule, +) -> dict: + """Exercise the actual replay cleaner/detector without database mutations.""" + from app.domain.schemas.scada_replay import ScadaReplayCreate + from app.services.scada_replay_processing import detect_frame, prepare_cleaning + + ids = [binding.device_id for binding in pipe.pressure_bindings] + config = ScadaReplayCreate( + request_id=uuid5(NAMESPACE_URL, f"calibration:{pipe.pipe_id}:{start.isoformat()}"), + start_time=start, end_time=start + timedelta(seconds=duration + step), device_ids=ids, + rules=[{"rule_id": "calibration-pressure", "device_ids": ids, + "kind": "relative_change", "direction": "decrease", "unit": "m", + "threshold": rule.primary_pressure_drop_percent, + "window_samples": 4, "minimum_samples": 4, + "consecutive_samples": rule.consecutive_samples, "minimum_devices": 1}], + ) + document = {"replay_id": str(config.request_id), "devices": [ + {"device_id": key, "transmission_frequency": "0:15:00"} for key in ids], + "histories": {binding.device_id: [row["pressure"] for row in history[binding.node_id]] + for binding in pipe.pressure_bindings}} + raised = [] + for index in range(duration // step + 1): + at = start + timedelta(seconds=index * step) + rows = [{"device_id": binding.device_id, "time": at, + "monitored_value": current[binding.node_id][index]["pressure"], + "source_hash": hashlib.sha256(f"{binding.device_id}:{at}".encode()).hexdigest()} + for binding in pipe.pressure_bindings] + document, changes = detect_frame(document, prepare_cleaning(rows, config), config, at) + raised.extend(change for change in changes + if change["change"] == "raised" and change.get("category") != "data_quality") + return {"raised": bool(raised), "first_signal_at": raised[0]["occurred_at"] if raised else None, + "scope": "pure replay rule preview; not monitoring worker or Agent acceptance"} + + +def assess( + pipe: PipeCalibration, normal: dict, burst: dict, rule: SelectionRule, + *, duration: int, step: int, +) -> dict: + expected_times = {"report_start": 0, "report_step": step, + "sim_duration": duration, "num_periods": duration // step + 1} + blockers = [] + for label, result in (("normal", normal), ("burst", burst)): + if result.get("status") != "completed" or result.get("times") != expected_times: + blockers.append(f"{label}_incomplete_or_failed") + if result.get("warnings"): + blockers.append(f"{label}_solver_warning") + if result.get("units") != {"flow": "LPS", "pressure": "MTR", "quality": "NONE"}: + blockers.append(f"{label}_unexpected_units") + if blockers: + return {"passed": False, "blockers": blockers} + pressures, flows = {}, {} + for binding in pipe.pressure_bindings: + before = [row["pressure"] for row in normal["nodes"][binding.node_id]] + after = [row["pressure"] for row in burst["nodes"][binding.node_id]] + if min(before) <= rule.relative_change_epsilon: + blockers.append(f"nonpositive_normal_pressure:{binding.device_id}") + if min(after) < 0: + blockers.append(f"negative_monitor_pressure:{binding.device_id}") + pressures[binding.device_id] = [100 * (a - b) / max(abs(a), rule.relative_change_epsilon) + for a, b in zip(before, after)] + for binding in pipe.flow_bindings: + flows[binding.device_id] = [100 * abs(a["flow"] - b["flow"]) / + max(abs(a["flow"]), rule.relative_change_epsilon) + for a, b in zip(normal["links"][binding.link_id], burst["links"][binding.link_id])] + witnesses = [] + count = duration // step + 1 + for primary, drops in pressures.items(): + for start in range(count - rule.consecutive_samples + 1): + window = range(start, start + rule.consecutive_samples) + if not all(drops[i] >= rule.primary_pressure_drop_percent for i in window): + continue + second = [key for key, values in pressures.items() if key != primary and + all(values[i] >= rule.corroborating_pressure_drop_percent for i in window)] + flow = [key for key, values in flows.items() if + all(values[i] >= rule.flow_change_percent for i in window) and + all(abs(normal["links"][next(b.link_id for b in pipe.flow_bindings if b.device_id == key)][i]["flow"]) + > rule.relative_change_epsilon for i in window)] + if second or flow: + witnesses.append({"primary_device": primary, "second_pressure_devices": second, + "flow_devices": flow, "start_offset_seconds": start * step}) + if not witnesses: + blockers.append("insufficient_consecutive_pressure_and_corroboration") + return {"passed": not blockers, "blockers": blockers, "witnesses": witnesses, + "pressure_drop_percent": pressures, "flow_change_percent": flows} diff --git a/scripts/benchmark/contracts.py b/scripts/benchmark/contracts.py new file mode 100644 index 0000000..a9a01e9 --- /dev/null +++ b/scripts/benchmark/contracts.py @@ -0,0 +1,145 @@ +"""Versioned burst cases and deterministic preparation checks.""" +from __future__ import annotations + +import hashlib +import json +import math +from collections import defaultdict, deque +from datetime import datetime, timedelta +from pathlib import Path +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class StrictModel(BaseModel): + model_config = ConfigDict(extra="forbid", allow_inf_nan=False) + + +class BurstCase(StrictModel): + case_id: str = Field(pattern=r"^B[0-9]{2}$") + pipe_id: str = Field(pattern=r"^[0-9]+$") + occurred_at: datetime + reference_analysis: str = Field(min_length=1, max_length=200) + observation_provenance: Literal["declared_historical_replay", "requires_generation"] + provenance_note: str = Field(min_length=1) + + @model_validator(mode="after") + def aware_time(self): + if self.occurred_at.tzinfo is None: + raise ValueError("case time must include a timezone") + return self + + +class TriggerRule(StrictModel): + pressure_drop_percent: float = Field(gt=0, le=100) + baseline_samples: int = Field(ge=3, le=96) + consecutive_samples: int = Field(ge=1, le=20) + minimum_devices: int = Field(ge=1, le=500) + + +class BurstSuite(StrictModel): + version: Literal["burst-benchmark@1"] + suite_id: Literal["tjwater-v2-burst"] + project_code: Literal["tjwater_v2"] + project_id: UUID + sampling_interval_minutes: int = Field(ge=1, le=60) + event_minutes: int = Field(ge=15, le=1440) + detector_days: Literal[15] + trigger_rule: TriggerRule + score_weights: dict[str, int] + cases: list[BurstCase] = Field(min_length=1, max_length=100) + + @model_validator(mode="after") + def valid_suite(self): + if 1440 % self.sampling_interval_minutes or self.event_minutes % self.sampling_interval_minutes: + raise ValueError("sample interval must divide a day and the event window") + if len({case.case_id for case in self.cases}) != len(self.cases): + raise ValueError("case ids must be unique") + if set(self.score_weights) != {"outcome", "tools", "plan", "efficiency"}: + raise ValueError("all four score dimensions are required") + if any(value <= 0 for value in self.score_weights.values()) or sum(self.score_weights.values()) != 100: + raise ValueError("positive score weights must sum to 100") + for case in self.cases: + if case.occurred_at.timestamp() % (60 * self.sampling_interval_minutes): + raise ValueError("event times must align with the sampling grid") + return self + + def select(self, case_ids: list[str]) -> list[BurstCase]: + if not case_ids: + return self.cases + unknown = set(case_ids) - {case.case_id for case in self.cases} + if unknown: + raise ValueError("unknown case ids: " + ", ".join(sorted(unknown))) + return [case for case in self.cases if case.case_id in case_ids] + + +def load_suite(path: Path) -> BurstSuite: + return BurstSuite.model_validate_json(path.read_text(encoding="utf-8")) + + +def canonical_json(value) -> str: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), + allow_nan=False, default=str) + + +def fingerprint(value) -> str: + return hashlib.sha256(canonical_json(value).encode()).hexdigest() + + +def complete_devices(rows: list[dict], start: datetime, end: datetime, interval: int) -> list[str]: + """Counts alone are insufficient: require finite values on the exact grid.""" + step = timedelta(minutes=interval) + expected = {start + step * index for index in range(int((end - start) / step) + 1)} + values: dict[str, dict[datetime, float]] = defaultdict(dict) + invalid: set[str] = set() + for row in rows: + device = row["device_id"] + at = row["time"] + if not start <= at <= end: + continue + value = row["monitored_value"] + if at not in expected or at in values[device] or value is None or not math.isfinite(float(value)): + invalid.add(device) + else: + values[device][at] = float(value) + return sorted(device for device, series in values.items() + if device not in invalid and set(series) == expected) + + +def isolation_reference(links: list[dict], source_nodes: list[str], pipe_id: str) -> dict: + """Independent graph check, separate from the production isolation algorithm.""" + graph: dict[str, list[str]] = defaultdict(list) + target = None + valves = [] + for link in links: + if link["id"] == pipe_id: + target = (link["start_node_id"], link["end_node_id"]) + if link["kind"].lower() == "valve": + valves.append(link["id"]) + continue + if str(link["status"]).lower() == "closed": + continue + left, right = link["start_node_id"], link["end_node_id"] + graph[left].append(right) + graph[right].append(left) + if target is None: + return {"status": "target_missing"} + if not source_nodes: + return {"status": "sources_missing"} + reached = set(source_nodes) + pending = deque(source_nodes) + while pending: + for node in graph[pending.popleft()]: + if node not in reached: + reached.add(node) + pending.append(node) + connected = any(node in reached for node in target) + return { + "status": "not_isolatable" if connected else "requires_boundary_verification", + "model_valve_count": len(valves), + "source_count": len(source_nodes), + "source_reachable_after_closing_all_model_valves": connected, + "scope": "undirected model topology; not hydraulic or field acceptance", + } diff --git a/scripts/benchmark/generation.py b/scripts/benchmark/generation.py new file mode 100644 index 0000000..dc1a38e --- /dev/null +++ b/scripts/benchmark/generation.py @@ -0,0 +1,576 @@ +"""Isolated database preparation and deterministic burst-dataset scheduling.""" +from __future__ import annotations + +import hashlib +import json +import math +from datetime import datetime, timedelta, timezone +from pathlib import Path +from uuid import UUID +from zoneinfo import ZoneInfo + +from psycopg import connect, sql +from psycopg.conninfo import make_conninfo +from psycopg.rows import dict_row + + +def canonical_hash(value) -> str: + return hashlib.sha256(json.dumps(value, sort_keys=True, default=str, separators=(",", ":")).encode()).hexdigest() + + +def deterministic_standard_normal(*, seed: str, at: datetime, device_id: str, metric: str) -> float: + """Return order-independent deterministic Gaussian noise for one observation.""" + material = f"{seed}\0{at.isoformat()}\0{device_id}\0{metric}".encode() + digest = hashlib.sha256(material).digest() + first = (int.from_bytes(digest[:8], "big") + 1) / (2**64 + 1) + second = (int.from_bytes(digest[8:16], "big") + 1) / (2**64 + 1) + return math.sqrt(-2.0 * math.log(first)) * math.cos(2.0 * math.pi * second) + + +def noisy_scada_observation( + *, + raw_value: float, + device_type: str, + at: datetime, + device_id: str, + seed: str, + pressure_sigma_percent: float = 0.2, + pressure_sigma_floor_m: float = 0.02, + flow_sigma_percent: float = 0.5, + clip_sigma: float = 3.0, +) -> float: + """Convert model units and add reproducible zero-mean telemetry noise.""" + if not math.isfinite(raw_value): + raise ValueError("model observation must be finite") + if min(pressure_sigma_percent, pressure_sigma_floor_m, flow_sigma_percent, clip_sigma) < 0: + raise ValueError("noise parameters must be non-negative") + z = max(-clip_sigma, min(clip_sigma, deterministic_standard_normal( + seed=seed, at=at, device_id=device_id, metric=device_type, + ))) + if device_type == "pressure": + converted = raw_value + sigma = max(abs(converted) * pressure_sigma_percent / 100.0, pressure_sigma_floor_m) + return max(0.0, converted + z * sigma) + if device_type == "pipe_flow": + converted = raw_value * 3.6 # L/s -> m3/h + sigma = abs(converted) * flow_sigma_percent / 100.0 + return converted + z * sigma + raise ValueError(f"unsupported SCADA device type: {device_type}") + + +def write_realtime_scada_day( + *, + business_dsn: str, + timescale_dsn: str, + start: datetime, + interval_seconds: int = 900, + duration_seconds: int = 86400, + noise_seed: str, + pressure_sigma_percent: float = 0.2, + pressure_sigma_floor_m: float = 0.02, + flow_sigma_percent: float = 0.5, + clip_sigma: float = 3.0, +) -> dict: + """Map one online-model day into noisy SCADA observations.""" + if start.tzinfo is None or duration_seconds <= 0 or interval_seconds <= 0: + raise ValueError("an aware start and positive duration/interval are required") + if duration_seconds % interval_seconds: + raise ValueError("duration must be divisible by the sampling interval") + expected_times = [ + start + timedelta(seconds=offset) + for offset in range(0, duration_seconds, interval_seconds) + ] + end = start + timedelta(seconds=duration_seconds) + with connect(business_dsn, row_factory=dict_row) as business: + devices = business.execute( + """SELECT device_id,device_type,node_id,link_id,measurement_unit + FROM asset.scada_devices + WHERE device_type IN ('pressure','pipe_flow') + ORDER BY device_id""" + ).fetchall() + if not devices: + raise ValueError("no pressure or pipe-flow SCADA bindings are available") + records: list[tuple] = [] + raw_fingerprint_rows: list[tuple] = [] + with connect(timescale_dsn, row_factory=dict_row) as timescale: + for device in devices: + if device["device_type"] == "pressure": + if not device["node_id"] or device["measurement_unit"] != "m": + raise ValueError("pressure devices must bind nodes and use metres") + rows = timescale.execute( + """SELECT time,pressure AS value FROM realtime.node_results + WHERE node_id=%s AND time>=%s AND time<%s ORDER BY time""", + (device["node_id"], start, end), + ).fetchall() + else: + if not device["link_id"] or device["measurement_unit"] != "m3/h": + raise ValueError("flow devices must bind links and use m3/h") + rows = timescale.execute( + """SELECT time,flow AS value FROM realtime.link_results + WHERE link_id=%s AND time>=%s AND time<%s ORDER BY time""", + (device["link_id"], start, end), + ).fetchall() + actual_times = [row["time"] for row in rows] + if actual_times != expected_times: + raise ValueError(f"incomplete realtime samples for device {device['device_id']}") + for row in rows: + raw_value = float(row["value"]) + value = noisy_scada_observation( + raw_value=raw_value, + device_type=device["device_type"], + at=row["time"], + device_id=device["device_id"], + seed=noise_seed, + pressure_sigma_percent=pressure_sigma_percent, + pressure_sigma_floor_m=pressure_sigma_floor_m, + flow_sigma_percent=flow_sigma_percent, + clip_sigma=clip_sigma, + ) + raw_fingerprint_rows.append((row["time"].isoformat(), device["device_id"], raw_value)) + records.append((row["time"], device["device_id"], value, None)) + collision = timescale.execute( + "SELECT count(*) count FROM scada.measurements WHERE time>=%s AND time<%s", + (start, end), + ).fetchone()["count"] + if collision: + raise ValueError("SCADA target day is not empty") + with timescale.cursor().copy( + "COPY scada.measurements (time,device_id,monitored_value,cleaned_value) FROM STDIN" + ) as copy: + for record in records: + copy.write_row(record) + timescale.commit() + persisted = timescale.execute( + "SELECT count(*) count FROM scada.measurements WHERE time>=%s AND time<%s", + (start, end), + ).fetchone()["count"] + if persisted != len(records): + raise ValueError("SCADA normal-day read-back count does not match generated records") + return { + "start": start.isoformat(), + "end_exclusive": end.isoformat(), + "devices": len(devices), + "samples_per_device": len(expected_times), + "rows": persisted, + "units": {"pressure": "m", "pipe_flow": "m3/h", "model_flow": "L/s"}, + "noise": { + "seed": noise_seed, + "distribution": "deterministic_clipped_gaussian", + "pressure_sigma_percent": pressure_sigma_percent, + "pressure_sigma_floor_m": pressure_sigma_floor_m, + "flow_sigma_percent": flow_sigma_percent, + "clip_sigma": clip_sigma, + }, + "raw_sha256": canonical_hash(raw_fingerprint_rows), + "value_sha256": canonical_hash( + [(time.isoformat(), device_id, value) for time, device_id, value, _ in records] + ), + } + + +def write_demo_scada_window( + *, + business_dsn: str, + timescale_dsn: str, + normal_source_start: datetime, + target_start: datetime, + day_count: int, + event_start: datetime, + event_run_id: str, + noise_seed: str, + interval_seconds: int = 900, +) -> dict: + """Repeat one normal online day and replace one five-hour event window.""" + if any(value.tzinfo is None for value in (normal_source_start, target_start, event_start)): + raise ValueError("normal, target, and event times must be timezone-aware") + if day_count < 15 or interval_seconds <= 0 or 86400 % interval_seconds: + raise ValueError("demo windows require at least 15 complete aligned days") + target_end = target_start + timedelta(days=day_count) + if not target_start <= event_start < target_end: + raise ValueError("event must fall inside the demo window") + run_uuid = UUID(str(event_run_id)) + with connect(business_dsn, row_factory=dict_row) as business: + devices = business.execute( + """SELECT device_id,device_type,node_id,link_id,measurement_unit + FROM asset.scada_devices + WHERE device_type IN ('pressure','pipe_flow') ORDER BY device_id""" + ).fetchall() + pressure_devices = [row for row in devices if row["device_type"] == "pressure"] + flow_devices = [row for row in devices if row["device_type"] == "pipe_flow"] + if any(not row["node_id"] or row["measurement_unit"] != "m" for row in pressure_devices): + raise ValueError("pressure devices must bind nodes and use metres") + if any(not row["link_id"] or row["measurement_unit"] != "m3/h" for row in flow_devices): + raise ValueError("flow devices must bind links and use m3/h") + normal_end = normal_source_start + timedelta(days=1) + expected_normal_times = [ + normal_source_start + timedelta(seconds=offset) + for offset in range(0, 86400, interval_seconds) + ] + with connect(timescale_dsn, row_factory=dict_row) as timescale: + normal_nodes = timescale.execute( + """SELECT time,node_id,pressure AS value FROM realtime.node_results + WHERE time>=%s AND time<%s AND node_id=ANY(%s) ORDER BY time,node_id""", + (normal_source_start, normal_end, [row["node_id"] for row in pressure_devices]), + ).fetchall() + normal_links = timescale.execute( + """SELECT time,link_id,flow AS value FROM realtime.link_results + WHERE time>=%s AND time<%s AND link_id=ANY(%s) ORDER BY time,link_id""", + (normal_source_start, normal_end, [row["link_id"] for row in flow_devices]), + ).fetchall() + event_nodes = timescale.execute( + """SELECT time,node_id,pressure AS value FROM analysis.node_results + WHERE run_id=%s AND node_id=ANY(%s) ORDER BY time,node_id""", + (run_uuid, [row["node_id"] for row in pressure_devices]), + ).fetchall() + event_links = timescale.execute( + """SELECT time,link_id,flow AS value FROM analysis.link_results + WHERE run_id=%s AND link_id=ANY(%s) ORDER BY time,link_id""", + (run_uuid, [row["link_id"] for row in flow_devices]), + ).fetchall() + + normal_node_values = {(row["time"], row["node_id"]): float(row["value"]) for row in normal_nodes} + normal_link_values = {(row["time"], row["link_id"]): float(row["value"]) for row in normal_links} + for device in devices: + values = normal_node_values if device["device_type"] == "pressure" else normal_link_values + element = device["node_id"] if device["device_type"] == "pressure" else device["link_id"] + if any((at, element) not in values for at in expected_normal_times): + raise ValueError(f"normal source day is incomplete for device {device['device_id']}") + + records: dict[tuple[datetime, str], tuple] = {} + for day_offset in range(day_count): + day_start = target_start + timedelta(days=day_offset) + for sample_offset, source_at in enumerate(expected_normal_times): + target_at = day_start + timedelta(seconds=sample_offset * interval_seconds) + for device in pressure_devices: + raw = normal_node_values[(source_at, device["node_id"])] + value = noisy_scada_observation( + raw_value=raw, device_type="pressure", at=target_at, + device_id=device["device_id"], seed=noise_seed, + ) + records[(target_at, device["device_id"])] = ( + target_at, device["device_id"], value, None, + ) + for device in flow_devices: + raw = normal_link_values[(source_at, device["link_id"])] + value = noisy_scada_observation( + raw_value=raw, device_type="pipe_flow", at=target_at, + device_id=device["device_id"], seed=noise_seed, + ) + records[(target_at, device["device_id"])] = ( + target_at, device["device_id"], value, None, + ) + + if not event_nodes or not event_links: + raise ValueError("event analysis has no bound pressure or flow results") + source_event_start = min(row["time"] for row in event_nodes + event_links) + event_times = sorted({row["time"] for row in event_nodes + event_links}) + if len(event_times) != 21 or any( + right - left != timedelta(seconds=interval_seconds) + for left, right in zip(event_times, event_times[1:]) + ): + raise ValueError("event analysis must contain 21 aligned fifteen-minute frames") + event_node_values = {(row["time"], row["node_id"]): float(row["value"]) for row in event_nodes} + event_link_values = {(row["time"], row["link_id"]): float(row["value"]) for row in event_links} + for source_at in event_times: + target_at = event_start + (source_at - source_event_start) + if target_at >= target_end: + raise ValueError("event results extend beyond the demo window") + for device in pressure_devices: + key = (source_at, device["node_id"]) + if key not in event_node_values: + raise ValueError(f"event pressure is incomplete for device {device['device_id']}") + value = noisy_scada_observation( + raw_value=event_node_values[key], device_type="pressure", at=target_at, + device_id=device["device_id"], seed=noise_seed, + ) + records[(target_at, device["device_id"])] = ( + target_at, device["device_id"], value, None, + ) + for device in flow_devices: + key = (source_at, device["link_id"]) + if key not in event_link_values: + raise ValueError(f"event flow is incomplete for device {device['device_id']}") + value = noisy_scada_observation( + raw_value=event_link_values[key], device_type="pipe_flow", at=target_at, + device_id=device["device_id"], seed=noise_seed, + ) + records[(target_at, device["device_id"])] = ( + target_at, device["device_id"], value, None, + ) + + ordered = [records[key] for key in sorted(records)] + expected_count = day_count * len(expected_normal_times) * len(devices) + if len(ordered) != expected_count: + raise ValueError("demo SCADA window has an unexpected observation count") + timescale.execute( + "DELETE FROM scada.measurements WHERE time>=%s AND time<%s", + (target_start, target_end), + ) + with timescale.cursor().copy( + "COPY scada.measurements (time,device_id,monitored_value,cleaned_value) FROM STDIN" + ) as copy: + for record in ordered: + copy.write_row(record) + timescale.commit() + persisted = timescale.execute( + """SELECT count(*) count,count(DISTINCT device_id) devices, + count(DISTINCT time) frames,count(cleaned_value) cleaned + FROM scada.measurements WHERE time>=%s AND time<%s""", + (target_start, target_end), + ).fetchone() + if persisted != { + "count": expected_count, + "devices": len(devices), + "frames": day_count * len(expected_normal_times), + "cleaned": 0, + }: + raise ValueError("demo SCADA read-back verification failed") + return { + "start": target_start.isoformat(), + "end_exclusive": target_end.isoformat(), + "day_count": day_count, + "event_start": event_start.isoformat(), + "event_end": (event_start + timedelta(seconds=20 * interval_seconds)).isoformat(), + "event_run_id": str(run_uuid), + "devices": len(devices), + "frames": day_count * len(expected_normal_times), + "rows": expected_count, + "cleaned_rows": 0, + "noise_seed": noise_seed, + "value_sha256": canonical_hash( + [(time.isoformat(), device_id, value) for time, device_id, value, _ in ordered] + ), + } + + +def create_empty_database(source_dsn: str, target: str, schema_template: str) -> None: + if not target.startswith("tjw_bench_") or target.endswith("_template"): + raise ValueError("benchmark targets must use the owned tjw_bench_ prefix") + with connect(make_conninfo(source_dsn, dbname="postgres"), autocommit=True, row_factory=dict_row) as connection: + if connection.execute("SELECT 1 FROM pg_database WHERE datname=%s", (target,)).fetchone(): + raise ValueError("target database already exists; explicit state is required for resumption") + # Templates are intentionally connection-disabled; clone without altering that state. + connection.execute(sql.SQL("CREATE DATABASE {} TEMPLATE {}").format(sql.Identifier(target), sql.Identifier(schema_template))) + + +def copy_project_inputs(source_dsn: str, target_dsn: str) -> dict: + from scripts.check_burst_simulation_design import readonly + from app.native.wndb.core.model_replace import _copy_order, _copy_table, _model_tables, _table_columns + + summaries = {} + with readonly(source_dsn) as source, connect(target_dsn, row_factory=dict_row) as target: + source.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY") + tables = _model_tables(source) + ordered = _copy_order(source, tables) + for schema, table in reversed(ordered): + target.execute(sql.SQL("DELETE FROM {}").format(sql.Identifier(schema, table))) + for schema, table in ordered + [("asset", "scada_devices")]: + columns = _table_columns(source, schema, table) + if _table_columns(target, schema, table) != columns: + raise ValueError(f"schema differs for {schema}.{table}") + _copy_table(source, target, schema, table, columns) + statement = sql.SQL("SELECT count(*) count FROM {}").format(sql.Identifier(schema, table)) + before, after = source.execute(statement).fetchone()["count"], target.execute(statement).fetchone()["count"] + if before != after: + raise ValueError("isolated model copy count mismatch") + summaries[f"{schema}.{table}"] = before + target.commit() + return summaries + + +def apply_project_runtime_migrations(target_dsn: str, repository_root: Path) -> list[str]: + """Install the additive MCP and calculation queue tables in an owned target.""" + migrations = ( + "025_mcp_query_contexts.sql", + "026_calculation_jobs.sql", + "027_calculation_job_lifecycle.sql", + "030_remove_calculation_queue_expiry.sql", + ) + applied = [] + with connect(target_dsn) as connection: + for filename in migrations: + path = repository_root / "resources" / "sql" / filename + connection.execute(path.read_text(encoding="utf-8")) + applied.append(filename) + connection.commit() + return applied + + +async def register_benchmark_project( + *, + source_project_id: str, + target: str, + target_biz_dsn: str, + target_iot_dsn: str, +): + """Register owned databases and mirror source membership without role expansion.""" + from uuid import UUID + + from app.infra.db.metadb.database import SessionLocal + from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository + + source_id = UUID(str(source_project_id)) + async with SessionLocal() as session: + repository = MetadataRepository(session) + source = await repository.get_project_by_id(source_id) + if source is None: + raise ValueError("source project metadata is unavailable") + project = await repository.get_project_by_code(target) + marker = { + "benchmark_source_project_id": str(source_id), + "benchmark_dataset": "burst-v1", + } + if project is None: + project = await repository.create_project( + name=f"{source.name} 爆管 Benchmark", + code=target, + description="隔离的爆管 benchmark 模拟与 SCADA 回放数据集", + gs_workspace=target, + map_extent=source.map_extent, + status="active", + map_config=source.map_config or {}, + settings={**(source.settings or {}), **marker}, + ) + elif any((project.settings or {}).get(key) != value for key, value in marker.items()): + raise ValueError("existing target project is not owned by this benchmark dataset") + await repository.upsert_project_database_config( + project.id, + db_role="biz_data", + db_type="postgresql", + dsn=target_biz_dsn, + pool_min_size=1, + pool_max_size=4, + ) + await repository.upsert_project_database_config( + project.id, + db_role="iot_data", + db_type="timescaledb", + dsn=target_iot_dsn, + pool_min_size=1, + pool_max_size=4, + ) + existing = {member.user_id for member in await repository.list_project_members(project.id)} + for member in await repository.list_project_members(source_id): + if member.user_id not in existing: + await repository.add_project_member( + project.id, member.user_id, member.project_role + ) + return project + + +def write_analysis_scada( + *, + business_dsn: str, + timescale_dsn: str, + run_id: str, +) -> dict: + """Materialize one completed hydraulic run as bound SCADA observations.""" + run_uuid = UUID(str(run_id)) + with connect(business_dsn, row_factory=dict_row) as business: + devices = business.execute( + """SELECT device_id,device_type,node_id,link_id,measurement_unit + FROM asset.scada_devices + WHERE device_type IN ('pressure','pipe_flow') + ORDER BY device_id""" + ).fetchall() + pressure = [row for row in devices if row["device_type"] == "pressure"] + flows = [row for row in devices if row["device_type"] == "pipe_flow"] + if any(not row["node_id"] or row["measurement_unit"] != "m" for row in pressure): + raise ValueError("pressure devices must bind nodes and use metres") + if any(not row["link_id"] or row["measurement_unit"] != "m3/h" for row in flows): + raise ValueError("flow devices must bind links and use m3/h") + + pressure_by_node: dict[str, list[str]] = {} + flow_by_link: dict[str, list[str]] = {} + for row in pressure: + pressure_by_node.setdefault(row["node_id"], []).append(row["device_id"]) + for row in flows: + flow_by_link.setdefault(row["link_id"], []).append(row["device_id"]) + + records: list[tuple] = [] + with connect(timescale_dsn, row_factory=dict_row) as timescale: + node_rows = timescale.execute( + """SELECT time,node_id,pressure FROM analysis.node_results + WHERE run_id=%s AND node_id=ANY(%s) ORDER BY time,node_id""", + (run_uuid, list(pressure_by_node)), + ).fetchall() + link_rows = timescale.execute( + """SELECT time,link_id,flow FROM analysis.link_results + WHERE run_id=%s AND link_id=ANY(%s) ORDER BY time,link_id""", + (run_uuid, list(flow_by_link)), + ).fetchall() + for row in node_rows: + records.extend( + (row["time"], device_id, float(row["pressure"]), None) + for device_id in pressure_by_node[row["node_id"]] + ) + for row in link_rows: + records.extend( + (row["time"], device_id, float(row["flow"]) * 3.6, None) + for device_id in flow_by_link[row["link_id"]] + ) + if not records: + raise ValueError("analysis run has no bound SCADA results") + start = min(row[0] for row in records) + end = max(row[0] for row in records) + collision = timescale.execute( + "SELECT count(*) count FROM scada.measurements WHERE time BETWEEN %s AND %s", + (start, end), + ).fetchone()["count"] + if collision: + raise ValueError("SCADA target interval is not empty") + with timescale.cursor().copy( + "COPY scada.measurements (time,device_id,monitored_value,cleaned_value) FROM STDIN" + ) as copy: + for record in sorted(records): + copy.write_row(record) + timescale.commit() + persisted = timescale.execute( + "SELECT count(*) count FROM scada.measurements WHERE time BETWEEN %s AND %s", + (start, end), + ).fetchone()["count"] + if persisted != len(records): + raise ValueError("SCADA read-back count does not match generated records") + samples_per_device: dict[str, int] = {} + for _, device_id, _, _ in records: + samples_per_device[device_id] = samples_per_device.get(device_id, 0) + 1 + if len(set(samples_per_device.values())) != 1: + raise ValueError("bound SCADA devices have inconsistent sample counts") + return { + "run_id": str(run_uuid), + "start": start.isoformat(), + "end": end.isoformat(), + "devices": len(samples_per_device), + "samples_per_device": next(iter(samples_per_device.values())), + "rows": persisted, + "value_sha256": canonical_hash( + [(time.isoformat(), device_id, value) for time, device_id, value, _ in sorted(records)] + ), + } + + +def make_schedule(design) -> list[dict]: + """Seventeen-day slots keep fifteen clean training days plus a recovery buffer.""" + local = ZoneInfo("Asia/Shanghai") + first_date = datetime(2024, 1, 16, tzinfo=local) + rows = [] + pipes = {pipe.pipe_id: pipe for pipe in design.pipes} + for case in design.cases: + for level in ("light", "medium", "severe"): + source = case.occurred_at.astimezone(local) + at = first_date + timedelta(days=17 * len(rows), hours=source.hour, minutes=source.minute) + rows.append({"case_key": f"{case.case_id}-{level}", "case_id": case.case_id, + "severity": level, "pipe_id": case.pipe_id, + "area_cm2": pipes[case.pipe_id].candidate_orifice_areas_cm2[level], + "event_at": at.isoformat(), "history_start": (at - timedelta(days=15)).isoformat(), + "event_end": (at + timedelta(seconds=design.duration_seconds)).isoformat(), + "reserved_end": (at + timedelta(seconds=design.duration_seconds, days=1)).isoformat()}) + previous = None + for row in rows: + start = datetime.fromisoformat(row["history_start"]) + if previous is not None and start <= previous: + raise ValueError("dataset date reservations overlap") + previous = datetime.fromisoformat(row["reserved_end"]) + if previous is not None and previous >= datetime.now(timezone.utc): + raise ValueError("benchmark slots must remain historical") + return rows diff --git a/scripts/benchmark/pressure_thresholds.py b/scripts/benchmark/pressure_thresholds.py new file mode 100644 index 0000000..1de9b92 --- /dev/null +++ b/scripts/benchmark/pressure_thresholds.py @@ -0,0 +1,99 @@ +"""Learn pressure thresholds from pre-event normal history, never burst truth.""" +from __future__ import annotations + +import math +from datetime import datetime, timedelta +from statistics import median + + +def learn_pressure_thresholds( + rows: list[dict], device_ids: list[str], cutoff: datetime, *, + history_days: int = 15, interval_seconds: int = 900, + relative_floor_percent: float = 3.0, noise_multiplier: float = 3.0, + absolute_floor_m: float = 0.5, +) -> dict: + """Estimate noise after removing each daily slot's median operating pattern.""" + if (cutoff.tzinfo is None or history_days < 1 or interval_seconds <= 0 + or 86400 % interval_seconds or not device_ids or len(set(device_ids)) != len(device_ids)): + raise ValueError("an aware cutoff and whole-day sampling grid are required") + if not all(math.isfinite(value) and value > 0 for value in (relative_floor_percent, noise_multiplier, absolute_floor_m)): + raise ValueError("threshold floors and noise multiplier must be positive") + count = history_days * 86400 // interval_seconds + start = cutoff - timedelta(days=history_days) + expected = {start + timedelta(seconds=interval_seconds * index) for index in range(count)} + data = {device: {} for device in device_ids} + for row in rows: + device = row["device_id"] + if device not in data: + continue + at = row["time"] + if at.tzinfo is None or at >= cutoff: + raise ValueError("adaptive fitting must not consume event or future observations") + if at < start: + continue + value = row["monitored_value"] + if at not in expected or at in data[device] or value is None or not math.isfinite(float(value)) or float(value) <= 0: + raise ValueError("adaptive fitting requires distinct positive finite pressure samples on the grid") + data[device][at] = float(value) + insufficient = {device: len(values) for device, values in data.items() if set(values) != expected} + if insufficient: + return {"status": "history_not_ready", "required_samples_per_device": count, + "available_samples": insufficient, "knowledge_cutoff": cutoff.isoformat(), "devices": {}} + fitted = {} + for device, values in data.items(): + slots = {} + for at, value in values.items(): + slot = int((at - start).total_seconds()) // interval_seconds % (86400 // interval_seconds) + slots.setdefault(slot, []).append(value) + references = {slot: median(group) for slot, group in slots.items()} + residuals = [] + for at, value in values.items(): + slot = int((at - start).total_seconds()) // interval_seconds % (86400 // interval_seconds) + residuals.append(100 * (value / references[slot] - 1)) + center = median(residuals) + robust_sigma = 1.4826 * median(abs(value - center) for value in residuals) + fitted[device] = {"relative_drop_percent": max(relative_floor_percent, noise_multiplier * robust_sigma), + "absolute_drop_m": absolute_floor_m, "robust_noise_percent": robust_sigma, + "reference_pressure_m_by_slot": {str(slot): value for slot, value in references.items()}} + return {"status": "ready", "required_samples_per_device": count, + "knowledge_cutoff": cutoff.isoformat(), "devices": fitted, + "reference_grid_start": start.isoformat(), "interval_seconds": interval_seconds, + "method": "daily-slot median removal; 1.4826*MAD; thresholds frozen before event"} + + +def evaluate_pressure_thresholds(rows: list[dict], fitted: dict, *, consecutive_samples: int = 2) -> dict: + """Experimental adaptive evaluator; this is not the production replay worker.""" + if fitted.get("status") != "ready" or consecutive_samples < 1: + raise ValueError("a fitted rule and positive consecutive sample count are required") + start = datetime.fromisoformat(fitted["reference_grid_start"]) + cutoff = datetime.fromisoformat(fitted["knowledge_cutoff"]) + step = fitted["interval_seconds"] + states = {} + signals = [] + for row in sorted(rows, key=lambda item: (item["time"], item["device_id"])): + device = row["device_id"] + if device not in fitted["devices"]: + continue + at, value = row["time"], row["monitored_value"] + if at.tzinfo is None or at < cutoff or (at - start).total_seconds() % step: + raise ValueError("evaluation samples must follow the fit cutoff on the sampling grid") + previous, hits, signaled = states.get(device, (None, 0, False)) + if previous is not None and at <= previous: + raise ValueError("evaluation samples must be distinct") + if previous is None or at - previous != timedelta(seconds=step): + hits = 0 + rule = fitted["devices"][device] + slot = int((at - start).total_seconds()) // step % (86400 // step) + expected = rule["reference_pressure_m_by_slot"][str(slot)] + valid = value is not None and math.isfinite(float(value)) and float(value) > 0 + drop = expected - float(value) if valid else 0 + hit = valid and drop >= rule["absolute_drop_m"] and 100 * drop / expected >= rule["relative_drop_percent"] + hits = hits + 1 if hit else 0 + if hits >= consecutive_samples and not signaled: + signals.append({"device_id": device, "first_signal_at": at.isoformat(), + "reference_pressure_m": expected, "absolute_drop_m": drop, + "relative_drop_percent": 100 * drop / expected}) + signaled = True + states[device] = (at, hits, signaled) + return {"raised": bool(signals), "signals": signals, + "scope": "experimental adaptive pressure rule only; not a worker or Agent result"} diff --git a/scripts/benchmark/source.py b/scripts/benchmark/source.py new file mode 100644 index 0000000..d21ba9d --- /dev/null +++ b/scripts/benchmark/source.py @@ -0,0 +1,263 @@ +"""Read project data through metadata routing, in repeatable read-only transactions.""" +from __future__ import annotations + +import gzip +import hashlib +import itertools +import json +import math +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone +from pathlib import Path +from uuid import NAMESPACE_URL, uuid5 + +import psycopg +from psycopg.conninfo import conninfo_to_dict, make_conninfo +from psycopg.rows import dict_row + +from scripts.benchmark.contracts import ( + BurstCase, BurstSuite, canonical_json, complete_devices, fingerprint, isolation_reference, +) + + +async def resolve_sources(suite: BurstSuite) -> tuple[dict, dict[str, str]]: + # Import lazily: validating a suite must not load environment credentials. + from sqlalchemy import text + from app.infra.db.metadb.database import SessionLocal, engine + from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository + + try: + async with SessionLocal() as session: + await session.execute(text("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY")) + repo = MetadataRepository(session) + project = await repo.get_project_by_code(suite.project_code) + if project is None or project.id != suite.project_id or project.status != "active": + raise ValueError("benchmark project identity or active status does not match") + routes = {} + for role in ("biz_data", "iot_data"): + route = await repo.get_project_db_routing(project.id, role) + if route is None: + raise ValueError("benchmark project database routing is incomplete") + routes[role] = route.dsn + identity = {"project_id": str(project.id), "project_code": project.code, "name": project.name} + await session.rollback() + return identity, routes + finally: + await engine.dispose() + + +@contextmanager +def readonly_source(dsn: str): + # Replace inherited options: never rely on application credentials being read-only. + safe_dsn = make_conninfo(dsn, options=( + "-c default_transaction_read_only=on -c statement_timeout=20000 " + "-c lock_timeout=2000 -c idle_in_transaction_session_timeout=600000" + ), application_name="tjwater-benchmark-source-readonly", connect_timeout="5") + with psycopg.connect(safe_dsn, row_factory=dict_row) as connection: + connection.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY") + if connection.execute("SHOW transaction_read_only").fetchone()["transaction_read_only"] != "on": + raise ValueError("source transaction is not read-only") + try: + yield connection + finally: + connection.rollback() + + +def source_identity(connection, dsn: str) -> dict: + values = conninfo_to_dict(dsn) + row = connection.execute( + "SELECT current_database() AS database, current_setting('server_version_num') AS server_version, " + "transaction_timestamp() AS observed_at" + ).fetchone() + if row["database"] != values.get("dbname"): + raise ValueError("connected source differs from the metadata route") + return { + **row, + "read_only": True, + "isolation": "repeatable read", + # Equal database names on different servers are not equal routes. + "route_sha256": fingerprint({key: values.get(key) for key in ("host", "port", "dbname")}), + } + + +def read_model(connection, cases: list[BurstCase]) -> dict: + links = connection.execute( + "SELECT l.id, l.link_type::text AS kind, l.start_node_id, l.end_node_id, " + "COALESCE(s.status::text,p.status::text) AS status " + "FROM network.links l LEFT JOIN network.pipes p ON p.link_id=l.id " + "LEFT JOIN network.link_initial_settings s ON s.link_id=l.id ORDER BY l.id" + ).fetchall() + for link in links: + for key in ("id", "start_node_id", "end_node_id"): + link[key] = str(link[key]) + sources = [str(row["id"]) for row in connection.execute( + "SELECT id FROM network.nodes WHERE node_type::text IN ('reservoir','tank') ORDER BY id" + ).fetchall()] + devices = connection.execute( + "SELECT device_id,device_type,node_id,link_id,api_query_id,measurement_unit, " + "transmission_frequency::text AS transmission_frequency FROM asset.scada_devices " + "WHERE device_type::text IN ('pressure','pipe_flow','flow') ORDER BY device_id" + ).fetchall() + for device in devices: + for key in ("device_id", "node_id", "link_id"): + if device[key] is not None: + device[key] = str(device[key]) + analyses = connection.execute( + "SELECT run_id,name,run_type,status,parameters FROM analysis.runs WHERE name=ANY(%s) " + "ORDER BY name,created_at,run_id", ([case.reference_analysis for case in cases],), + ).fetchall() + return {"links": links, "source_nodes": sources, "devices": devices, "reference_analyses": analyses} + + +def readings(connection, device_ids: list[str], start: datetime, end: datetime) -> list[dict]: + return connection.execute( + "SELECT time,device_id,monitored_value FROM scada.measurements " + "WHERE device_id=ANY(%s) AND time>=%s AND time<=%s ORDER BY time,device_id", + (device_ids, start, end), + ).fetchall() + + +def long_history_capability(connection, suite: BurstSuite, case: BurstCase, devices: list[dict]) -> dict: + interval = suite.sampling_interval_minutes + expected = suite.detector_days * 1440 // interval + start = case.occurred_at - timedelta(days=suite.detector_days) + timedelta(minutes=interval) + mapped = {device["device_id"]: device["node_id"] for device in devices + if device["device_type"] == "pressure" and device["node_id"] and device["api_query_id"]} + rows = connection.execute( + "SELECT device_id,count(*) AS count,count(DISTINCT time) AS unique_count, " + "count(*) FILTER (WHERE monitored_value IS NOT NULL " + "AND monitored_value::text NOT IN ('NaN','Infinity','-Infinity') " + "AND mod(extract(epoch FROM (time-%s::timestamptz)),%s)=0) AS valid_count " + "FROM scada.measurements WHERE device_id=ANY(%s) AND time>=%s AND time<=%s " + "GROUP BY device_id ORDER BY device_id", + (case.occurred_at, interval * 60, list(mapped), start, case.occurred_at), + ).fetchall() + eligible = sorted({mapped[row["device_id"]] for row in rows + if row["count"] == row["unique_count"] == row["valid_count"] == expected}) + return {"available": len(eligible) >= 5, "expected_samples_per_device": expected, + "complete_pressure_nodes": eligible, "tool_node_limit": 20, + "required_for": "optional run_burst_detection, not the short-window SCADA trigger"} + + +def trigger_preview(suite: BurstSuite, case: BurstCase, rows: list[dict], devices: list[dict], *, normal=False) -> dict: + """Exercise the current pure detector; do not tick replay, clean TSDB or create tasks.""" + from app.domain.schemas.scada_replay import ScadaReplayCreate + from app.services.scada_replay_processing import detect_frame, prepare_cleaning + + start = case.occurred_at - (timedelta(days=1) if normal else timedelta()) + pressure = [device for device in devices + if device["device_type"] == "pressure" and device["measurement_unit"] == "m"] + ids = {device["device_id"] for device in pressure} + rule = suite.trigger_rule + if len(ids) < rule.minimum_devices: + return {"raised": False, "reason": "insufficient pressure devices with unit m"} + config = ScadaReplayCreate( + request_id=uuid5(NAMESPACE_URL, f"benchmark:{case.case_id}:{normal}"), + start_time=start, end_time=start + timedelta(minutes=suite.event_minutes + suite.sampling_interval_minutes), + device_ids=sorted(ids), rules=[{ + "rule_id": "benchmark-pressure-drop", "device_ids": sorted(ids), + "kind": "relative_change", "direction": "decrease", "unit": "m", + "threshold": rule.pressure_drop_percent, "window_samples": rule.baseline_samples, + "minimum_samples": rule.baseline_samples, "consecutive_samples": rule.consecutive_samples, + "minimum_devices": rule.minimum_devices, + }], + ) + history: dict[str, list[float]] = {device: [] for device in ids} + for row in rows: + value = row["monitored_value"] + if row["device_id"] in ids and row["time"] < start and value is not None and math.isfinite(float(value)): + history[row["device_id"]].append(float(value)) + document = {"replay_id": str(config.request_id), "devices": pressure, + "histories": {key: values[-rule.baseline_samples:] for key, values in history.items()}} + changes = [] + for at, group in itertools.groupby(rows, key=lambda row: row["time"]): + if at < start: + continue + frame = [{**row, "source_hash": fingerprint([row["time"], row["device_id"], row["monitored_value"]])} + for row in group if row["device_id"] in ids] + document, events = detect_frame(document, prepare_cleaning(frame, config), config, at) + changes.extend(event for event in events if event.get("category") != "data_quality") + raised = [event for event in changes if event["change"] == "raised"] + return {"raised": bool(raised), "first_signal_at": raised[0]["occurred_at"] if raised else None, + "event_count": len(raised), "warm_pressure_devices": sum(len(v) >= rule.baseline_samples for v in history.values()), + "scope": "pure short-window rule preview; not worker/Agent end-to-end acceptance"} + + +def inspect_case(iot_connection, suite: BurstSuite, case: BurstCase, model: dict) -> dict: + devices = model["devices"] + ids = [device["device_id"] for device in devices] + warmup = timedelta(minutes=suite.trigger_rule.baseline_samples * suite.sampling_interval_minutes) + end = case.occurred_at + timedelta(minutes=suite.event_minutes) + event_rows = readings(iot_connection, ids, case.occurred_at - warmup, end) + normal_rows = readings(iot_connection, ids, case.occurred_at - timedelta(days=1) - warmup, + end - timedelta(days=1)) + complete = set(complete_devices(event_rows, case.occurred_at, end, suite.sampling_interval_minutes)) + complete &= set(complete_devices(normal_rows, case.occurred_at - timedelta(days=1), + end - timedelta(days=1), suite.sampling_interval_minutes)) + pressure_ids = {device["device_id"] for device in devices if device["device_type"] == "pressure"} + matching = [run for run in model["reference_analyses"] if run["name"] == case.reference_analysis + and run["run_type"] == "burst_analysis" and run["status"] == "completed" + and case.pipe_id in [str(value) for value in run["parameters"].get("burst_ID", [])]] + topology = isolation_reference(model["links"], model["source_nodes"], case.pipe_id) + blockers = [] + if case.observation_provenance != "declared_historical_replay": + blockers.append("event_observations_require_isolated_generation") + if not matching: + blockers.append("matching_completed_reference_analysis_missing") + if len(complete & pressure_ids) < 5: + blockers.append("fewer_than_five_complete_event_and_control_pressure_devices") + if topology["status"] in {"target_missing", "sources_missing"}: + blockers.append(topology["status"]) + event_signal = trigger_preview(suite, case, event_rows, devices) + normal_signal = trigger_preview(suite, case, normal_rows, devices, normal=True) + scada_blockers = list(blockers) + if not event_signal["raised"]: + scada_blockers.append("short_window_event_trigger_not_raised") + if normal_signal["raised"]: + scada_blockers.append("normal_control_false_positive") + return { + "case_id": case.case_id, "pipe_id": case.pipe_id, "occurred_at": case.occurred_at, + "observation_provenance": case.observation_provenance, + "data_candidate_available": not blockers, + "blockers": blockers, + "complete_event_and_control_pressure_devices": sorted(complete & pressure_ids), + "window_sha256": fingerprint({"event": event_rows, "normal": normal_rows}), + "optional_long_history_detection": long_history_capability(iot_connection, suite, case, devices), + "event_signal_preview": event_signal, "normal_signal_preview": normal_signal, + "topology_reference": topology, + "variants": [ + {"case_id": case.case_id + "-WO", "data_candidate_available": not blockers, + "blockers": list(blockers)}, + {"case_id": case.case_id + "-SCADA", "data_candidate_available": not scada_blockers, + "blockers": scada_blockers}, + ], + "acceptance_pending": ["numerical_reference_review", "approved_equivalent_candidates", + "public_leakage_input_or_declared_location_limitation", + "isolated_runtime_and_cutoff_verification"], + } + + +def export_readings(connection, path: Path, suite: BurstSuite, cases: list[BurstCase], devices: list[dict]) -> dict: + """Export only burst-related devices and the selected history/event window.""" + start = min(case.occurred_at for case in cases) - timedelta(days=suite.detector_days) + end = max(case.occurred_at for case in cases) + timedelta(minutes=suite.event_minutes) + ids = [device["device_id"] for device in devices] + count = 0 + digest = hashlib.sha256() + path.touch(mode=0o600, exist_ok=False) + with path.open("wb") as raw, gzip.GzipFile(fileobj=raw, mode="wb", mtime=0) as output: + with connection.cursor(name="benchmark_raw_export") as cursor: + cursor.execute( + "SELECT time,device_id,monitored_value FROM scada.measurements " + "WHERE device_id=ANY(%s) AND time>=%s AND time<=%s ORDER BY time,device_id", (ids, start, end), + ) + for row in cursor: + count += 1 + if count > 1_000_000: + raise ValueError("selected observation export exceeds one million records") + line = (canonical_json(row) + "\n").encode() + digest.update(line) + output.write(line) + return {"file": str(path.name), "records": count, "content_sha256": digest.hexdigest(), + "start": start, "end": end, "origin": "existing_database_readonly_export", + "visibility": "private; contains future samples and must not be exposed directly to Agent"} diff --git a/scripts/calibrate_burst_benchmark.py b/scripts/calibrate_burst_benchmark.py new file mode 100644 index 0000000..1ec7b33 --- /dev/null +++ b/scripts/calibrate_burst_benchmark.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Calibrate a read-only model export using file-isolated native EPANET runs.""" +from __future__ import annotations + +import argparse +import asyncio +import os +import sys +import traceback +from datetime import datetime, timedelta, timezone +from pathlib import Path +from uuid import uuid4 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--design", type=Path, default=Path("resources/benchmarks/tjwater2-v2-burst-simulation.json")) + parser.add_argument("--output-root", type=Path, default=Path("data/benchmark-calibration")) + parser.add_argument("--duration-seconds", type=int, help="Override calibration duration, e.g. 18000 for five hours") + parser.add_argument("--timeout-seconds", type=int, default=240) + parser.add_argument("--all-severities", action="store_true", help="Retain weak-signal sizes even if the severe level fails") + args = parser.parse_args() + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + os.umask(0o077) + report = None + report_path = None + try: + from psycopg.conninfo import make_conninfo + from app.infra.db.project_routing import ActiveProjectRouting, activate_project_routing + from app.native.wndb.core.connection import close_all_project_pools, project_transaction + from app.native.wndb.inp.exporter import dump_inp + from scripts.check_burst_simulation_design import readonly, resolve_project, validate_database, validate_tool_inputs + from scripts.benchmark.simulation_design import load_simulation_design + from scripts.benchmark.calibration import assess, configure_inp, digest, pressure_preview, run_file, save_json, select_calibrated_area + + design = load_simulation_design(args.design) + validate_tool_inputs(design) + duration = args.duration_seconds if args.duration_seconds is not None else design.duration_seconds + step = design.model_expectations.report_timestep_seconds + if not 3600 <= duration <= 18000 or duration % step or not 1 <= args.timeout_seconds <= 1800: + raise ValueError("duration must be whole report steps between one and five hours; timeout 1..1800") + biz, _ = asyncio.run(resolve_project(design)) + with readonly(biz) as connection: + validate_database(connection, design, template=False) + folder = (args.output_root / (datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-" + uuid4().hex[:8])).resolve() + folder.mkdir(parents=True, mode=0o700) + snapshot = folder / "source.inp" + readonly_dsn = make_conninfo(biz, options="-c default_transaction_read_only=on -c statement_timeout=20000") + routing = ActiveProjectRouting(project_code=design.project_code, business_dsn=readonly_dsn) + try: + with activate_project_routing(routing), project_transaction(design.project_code) as connection: + connection.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY") + # Stateless models permit event-aligned runs and independent warmup history. + counts = connection.execute("SELECT (SELECT count(*) FROM network.tanks) tanks, " + "(SELECT count(*) FROM network.pumps) pumps, " + "(SELECT count(*) FROM network.controls) controls, " + "(SELECT count(*) FROM network.rules) rules").fetchone() + if any(counts.values()): + raise ValueError("stateful model requires an explicit warm-start implementation") + pda_options = {row["key"]: row["value"] for row in connection.execute( + "SELECT key,value FROM network.simulation_settings WHERE engine_version='legacy' " + "AND key=ANY(%s)", + (["HEADERROR", "FLOWCHANGE", "MINIMUM PRESSURE", "PRESSURE EXPONENT"],), + )} + dump_inp(design.project_code, str(snapshot), "2") + finally: + close_all_project_pools() + source = snapshot.read_text(encoding="utf-8") + node_ids = sorted({node for pipe in design.pipes for node in pipe.output_node_ids}) + link_ids = sorted({link for pipe in design.pipes for link in pipe.output_link_ids}) + report = {"version": "burst-calibration@1", "status": "running", "created_at": datetime.now(timezone.utc), + "source_effect": "read-only model export; local file simulations only", + "model_sha256": digest(snapshot), "design_sha256": digest(args.design), + "design": design.model_dump(mode="json"), "duration_seconds": duration, + "pda_options": pda_options, + "assessment_scope": "strong-signal calibration, not Agent accuracy or absence of a physical burst", + "all_severities": args.all_severities, + "engine_sha256": digest(Path("app/infra/epanet/linux/runepanet")), + "cases": [], "selection": {}, "controls": {}, + "limitations": ["No analysis scenario or SCADA rows have been persisted", + "No worker, MCP, Agent, or 15-day detection has been executed", + "The four-sample pressure replay rule is a calibration preview"]} + report_path = folder / "calibration.json" + save_json(report_path, report) + + def simulate(label, start, seconds, emitter=None): + path = folder / f"{label}.inp" + path.write_text(configure_inp(source, start, seconds, emitter=emitter, pda_options=pda_options), encoding="utf-8") + result = run_file(path, node_ids, link_ids, timeout=args.timeout_seconds) + save_json(folder / f"{label}.json", result) + print(f"{label}: {result['status']}, solver warnings={len(result['warnings'])}", flush=True) + return result + + controls = {} + histories = {} + for index, at in enumerate(design.event_times): + controls[at] = simulate(f"normal-{index}", at, duration) + warmup = simulate(f"warmup-{index}", at - timedelta(seconds=4 * step), 3 * step) + if warmup.get("status") != "completed" or warmup.get("warnings") or warmup.get("times") != { + "report_start": 0, "report_step": step, "sim_duration": 3 * step, "num_periods": 4 + }: + raise ValueError("normal warmup did not produce four converged samples") + histories[at] = warmup["nodes"] + report["controls"][at.isoformat()] = {"normal_file": f"normal-{index}.json", "warmup_file": f"warmup-{index}.json"} + save_json(report_path, report) + for pipe in design.pipes: + cases = [case for case in design.cases if case.pipe_id == pipe.pipe_id] + passed_levels = [] + for severity in design.calibration_order: + level_passed = True + for case in cases: + area = pipe.candidate_orifice_areas_cm2[severity] + burst = simulate(f"{case.case_id}-{severity}", case.occurred_at, duration, (pipe.emitter_node_id, area)) + normal = controls[case.occurred_at] + result = assess(pipe, normal, burst, design.selection_rule, duration=duration, step=step) + if normal.get("status") == burst.get("status") == "completed" and not any( + reason.endswith("incomplete_or_failed") for reason in result["blockers"] + ): + for key, current in (("normal_trigger", normal), ("event_trigger", burst)): + result[key] = pressure_preview(pipe, case.occurred_at, duration, step, + histories[case.occurred_at], current["nodes"], design.selection_rule) + if result["normal_trigger"]["raised"]: + result["blockers"].append("normal_control_false_positive") + if not result["event_trigger"]["raised"]: + result["blockers"].append("event_rule_did_not_trigger") + coefficient = 0.65 * area / 10000 * (19.6 ** 0.5) * 1000 + # PDA demand can change with pressure: demand delta alone is not emitter flow. + result["emitter_flow_m3_s"] = [coefficient * max(row["pressure"], 0) ** 0.5 / 1000 + for row in burst["nodes"][pipe.emitter_node_id]] + result.update(case_id=case.case_id, severity=severity, orifice_area_cm2=area, + passed=not result["blockers"]) + report["cases"].append(result) + level_passed &= result["passed"] + save_json(report_path, report) + print(f"{case.case_id}-{severity}: physical and rule checks {'PASS' if result['passed'] else 'FAIL'}", flush=True) + if level_passed: + passed_levels.append(severity) + if severity == "severe" and not level_passed and not args.all_severities: + break + case_ids = {case.case_id for case in cases} + report["selection"][pipe.pipe_id] = select_calibrated_area( + pipe, passed_levels, [case for case in report["cases"] if case["case_id"] in case_ids], args.all_severities, + ) + save_json(report_path, report) + report["status"] = "completed" + save_json(report_path, report) + print(f"calibration complete; report: {report_path}", flush=True) + return 0 + except Exception as exc: + frames = traceback.extract_tb(exc.__traceback__) + location = frames[-1] if frames else None + suffix = f" at {location.filename}:{location.lineno}" if location else "" + if report is not None and report_path is not None: + report.update(status="failed", failure_type=type(exc).__name__) + save_json(report_path, report) + print(f"calibration failed ({type(exc).__name__}){suffix}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/generate_burst_benchmark.py b/scripts/generate_burst_benchmark.py new file mode 100644 index 0000000..06a513c --- /dev/null +++ b/scripts/generate_burst_benchmark.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Generate persisted isolated burst scenarios and mapped SCADA observations.""" +from __future__ import annotations + +import argparse +import asyncio +import os +import sys +from datetime import datetime, timezone +from pathlib import Path + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--design", type=Path, default=Path("resources/benchmarks/tjwater2-v2-burst-balanced.json")) + parser.add_argument("--target", default="tjw_bench_tjwater2_20261105") + parser.add_argument("--state-dir", type=Path, default=Path("data/benchmark-generation/tjwater2-20261105")) + parser.add_argument("--apply", action="store_true") + parser.add_argument("--prepare-only", action="store_true") + args = parser.parse_args() + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + os.umask(0o077) + from psycopg import connect + from psycopg.conninfo import make_conninfo + from psycopg.rows import dict_row + from app.core.config import settings + from scripts.benchmark.calibration import save_json, digest + from scripts.benchmark.generation import ( + apply_project_runtime_migrations, + create_empty_database, + copy_project_inputs, + make_schedule, + register_benchmark_project, + ) + from scripts.benchmark.simulation_design import load_simulation_design + from scripts.check_burst_simulation_design import resolve_project, readonly, validate_database + + design = load_simulation_design(args.design) + schedule = make_schedule(design) + if not args.apply: + print(f"dry run: {len(schedule)} cases, target={args.target}, dates={schedule[0]['history_start']}..{schedule[-1]['reserved_end']}") + return 0 + folder = args.state_dir.resolve() + folder.mkdir(parents=True, exist_ok=True, mode=0o700) + state_path = folder / "state.json" + import json + state = json.loads(state_path.read_text()) if state_path.exists() else { + "version": "burst-dataset-generation@1", "created_at": datetime.now(timezone.utc).isoformat(), + "target": args.target, "design_sha256": digest(args.design), "schedule": schedule, + "stages": {}, "cases": {}, "source_effect": "read-only", "status": "preparing"} + if state["target"] != args.target or state["design_sha256"] != digest(args.design): + raise ValueError("state belongs to a different target or design") + save_json(state_path, state) + biz, iot = asyncio.run(resolve_project(design)) + with readonly(biz) as connection: + validate_database(connection, design, template=False) + target_biz, target_iot = make_conninfo(biz, dbname=args.target), make_conninfo(iot, dbname=args.target) + for role, source, template in (("biz", biz, settings.WNDB_SCHEMA_TEMPLATE_DB_NAME), + ("iot", iot, settings.TIMESCALEDB_SCHEMA_TEMPLATE_DB_NAME)): + if not state["stages"].get(f"{role}_created"): + create_empty_database(source, args.target, template) + state["stages"][f"{role}_created"] = True + save_json(state_path, state) + print(f"created isolated {role} database: {args.target}", flush=True) + if not state["stages"].get("model_copied"): + state["model_counts"] = copy_project_inputs(biz, target_biz) + state["stages"]["model_copied"] = True + save_json(state_path, state) + print("copied model and device bindings from a read-only source snapshot", flush=True) + required_migrations = { + "025_mcp_query_contexts.sql", + "026_calculation_jobs.sql", + "027_calculation_job_lifecycle.sql", + "030_remove_calculation_queue_expiry.sql", + } + if not required_migrations.issubset(state.get("runtime_migrations", [])): + state["runtime_migrations"] = apply_project_runtime_migrations( + target_biz, Path(__file__).resolve().parents[1] + ) + state["stages"]["runtime_migrations_applied"] = True + save_json(state_path, state) + print("installed isolated MCP and calculation queue storage", flush=True) + if not state["stages"].get("model_template_created"): + from app.native.wndb.core.project_templates import ( + create_project_model_template, + ensure_replication_worker_capacity, + ) + from app.native.wndb.core.projects import have_project + + template = f"{args.target}_template" + if not have_project(template): + try: + ensure_replication_worker_capacity() + except RuntimeError as exc: + if "no reserved logical-replication worker capacity" not in str(exc): + raise + state["model_template_mode"] = "snapshot_per_run" + else: + create_project_model_template(args.target) + if have_project(template): + state["model_template"] = template + state["model_template_mode"] = "logical_replication" + state["stages"]["model_template_created"] = True + save_json(state_path, state) + print(f"configured hydraulic model isolation: {state['model_template_mode']}", flush=True) + if not state["stages"].get("metadata_registered"): + project = asyncio.run(register_benchmark_project( + source_project_id=str(design.project_id), + target=args.target, + target_biz_dsn=target_biz, + target_iot_dsn=target_iot, + )) + state["project_id"] = str(project.id) + state["stages"]["metadata_registered"] = True + save_json(state_path, state) + print(f"registered isolated benchmark project: {project.id}", flush=True) + # Inspect isolated schema readiness before invoking backend persistence. + with connect(target_biz, row_factory=dict_row) as connection: + print("isolated BizDB analysis schema", connection.execute( + "SELECT column_name FROM information_schema.columns WHERE table_schema='analysis' AND table_name='runs' ORDER BY ordinal_position").fetchall(), flush=True) + state["status"] = "prepared" + save_json(state_path, state) + print(f"isolated preparation complete: {state_path}", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/materialize_burst_demo_scada.py b/scripts/materialize_burst_demo_scada.py new file mode 100644 index 0000000..8c7d6d1 --- /dev/null +++ b/scripts/materialize_burst_demo_scada.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Materialize selected burst demo months from one normal day and event runs.""" +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import sys +from datetime import datetime +from pathlib import Path + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--manifest", type=Path, + default=Path("resources/benchmarks/tjwater2-v2-burst-demo.json"), + ) + parser.add_argument( + "--generation-state", type=Path, + default=Path("data/benchmark-generation/tjwater2-20261105/state.json"), + ) + parser.add_argument( + "--state", type=Path, + default=Path("data/benchmark-generation/tjwater2-20261105/demo-scada-state.json"), + ) + args = parser.parse_args() + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + os.umask(0o077) + + from psycopg.conninfo import make_conninfo + from scripts.benchmark.calibration import digest, save_json + from scripts.benchmark.generation import write_demo_scada_window + from scripts.benchmark.simulation_design import load_simulation_design + from scripts.check_burst_simulation_design import resolve_project + + manifest = json.loads(args.manifest.read_text(encoding="utf-8")) + generation = json.loads(args.generation_state.read_text(encoding="utf-8")) + if manifest["project_code"] != generation["target"] or generation.get("status") != "prepared": + raise ValueError("manifest and isolated generation state do not match") + design = load_simulation_design(Path("resources/benchmarks/tjwater2-v2-burst-balanced.json")) + source_biz, source_iot = asyncio.run(resolve_project(design)) + business_dsn = make_conninfo(source_biz, dbname=generation["target"]) + timescale_dsn = make_conninfo(source_iot, dbname=generation["target"]) + state_path = args.state.resolve() + state_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + manifest_sha256 = digest(args.manifest) + state = json.loads(state_path.read_text(encoding="utf-8")) if state_path.exists() else { + "version": "burst-demo-scada-state@1", + "target": generation["target"], + "manifest_sha256": manifest_sha256, + "cases": {}, + "status": "materializing", + } + if state["target"] != generation["target"] or state["manifest_sha256"] != manifest_sha256: + raise ValueError("demo state belongs to different inputs") + save_json(state_path, state) + normal_source_start = datetime.fromisoformat(manifest["normal_source_start"]) + for case in manifest["cases"]: + source_case = generation["cases"].get(case["case_key"]) + if not source_case or source_case.get("status") != "scada_materialized": + raise ValueError(f"event source is unavailable: {case['case_key']}") + summary = write_demo_scada_window( + business_dsn=business_dsn, + timescale_dsn=timescale_dsn, + normal_source_start=normal_source_start, + target_start=datetime.fromisoformat(case["target_start"]), + day_count=int(manifest["day_count"]), + event_start=datetime.fromisoformat(case["event_start"]), + event_run_id=source_case["analysis_run_id"], + noise_seed=manifest["noise_seed"], + ) + state["cases"][case["demo_id"]] = { + "case_key": case["case_key"], + "entry_mode": case["entry_mode"], + **summary, + } + save_json(state_path, state) + print({"demo_id": case["demo_id"], "rows": summary["rows"]}, flush=True) + state["status"] = "completed" + save_json(state_path, state) + print(json.dumps({"status": state["status"], "cases": state["cases"]}, ensure_ascii=False), flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/prepare_burst_benchmark.py b/scripts/prepare_burst_benchmark.py new file mode 100644 index 0000000..974a999 --- /dev/null +++ b/scripts/prepare_burst_benchmark.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Create a private, read-only preflight report for the tjwater_v2 burst suite.""" +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import sys +import traceback +from datetime import datetime, timezone +from pathlib import Path +from uuid import uuid4 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--suite", + type=Path, + default=Path("resources/benchmarks/tjwater-v2-burst.json"), + help="versioned burst suite manifest", + ) + parser.add_argument("--case", action="append", default=[], help="case ID to inspect; repeatable") + parser.add_argument( + "--output-root", + type=Path, + default=Path("data/benchmark/preflight"), + help="private local artifact directory (default: ignored data/benchmark/preflight)", + ) + args = parser.parse_args() + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + + from scripts.benchmark.contracts import fingerprint, load_suite + from scripts.benchmark.source import inspect_case, read_model, resolve_sources, source_identity, readonly_source + + try: + suite = load_suite(args.suite) + cases = suite.select(args.case) + identity, routes = asyncio.run(resolve_sources(suite)) + + with readonly_source(routes["biz_data"]) as biz_connection: + biz_source = source_identity(biz_connection, routes["biz_data"]) + model = read_model(biz_connection, cases) + with readonly_source(routes["iot_data"]) as iot_connection: + iot_source = source_identity(iot_connection, routes["iot_data"]) + case_results = [inspect_case(iot_connection, suite, case, model) for case in cases] + + content_fingerprint = fingerprint({ + "suite": suite.model_dump(mode="json"), + "source_routes": { + "biz_data": biz_source["route_sha256"], + "iot_data": iot_source["route_sha256"], + }, + "model": model, + "cases": [{ + "case_id": result["case_id"], + "window_sha256": result["window_sha256"], + "topology_reference": result["topology_reference"], + "data_candidate_available": result["data_candidate_available"], + "blockers": result["blockers"], + } for result in case_results], + }) + report = { + "report_version": "burst-preflight@1", + "status": "preflight_complete", + "readiness": "preflight_only; isolated runtime and Agent execution are not verified", + "created_at": datetime.now(timezone.utc), + "project": identity, + "sources": {"biz_data": biz_source, "iot_data": iot_source}, + "suite_id": suite.suite_id, + "suite_version": suite.version, + "content_fingerprint_sha256": content_fingerprint, + "source_effect": "read-only transactions; no source database writes requested", + "cases": case_results, + "limitations": [ + "preflight does not execute Agent, work-order, SCADA replay worker, or MCP tools", + "SCADA event data requiring generation remains blocked until produced in an isolated dataset", + "this report is not a frozen evaluation database snapshot", + "numeric reference outputs and approved equivalent candidate groups still require review", + ], + } + + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + output_dir = args.output_root / f"{suite.suite_id}-{stamp}-{uuid4().hex[:8]}" + output_dir.mkdir(parents=True, mode=0o700) + os.chmod(output_dir, 0o700) + output_file = output_dir / "preflight.json" + output_file.write_text( + json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True, default=str) + "\n", + encoding="utf-8", + ) + os.chmod(output_file, 0o600) + except Exception as exc: + # Driver exceptions can include connection details; never echo them into CI logs. + frames = traceback.extract_tb(exc.__traceback__) + location = frames[-1] if frames else None + suffix = f" at {location.filename}:{location.lineno}" if location else "" + print(f"benchmark preflight failed ({type(exc).__name__}){suffix}", file=sys.stderr) + return 1 + + work_order_candidates = sum(bool(case["variants"][0]["data_candidate_available"]) + for case in case_results) + scada_candidates = sum(bool(case["variants"][1]["data_candidate_available"]) + for case in case_results) + print( + f"preflight complete: {work_order_candidates}/{len(case_results)} WO and " + f"{scada_candidates}/{len(case_results)} SCADA data candidates; report: {output_file}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/prepare_regional_burst_design.py b/scripts/prepare_regional_burst_design.py new file mode 100644 index 0000000..e30dafb --- /dev/null +++ b/scripts/prepare_regional_burst_design.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Build a private multi-region, multi-area and multi-time design from screening evidence.""" +from __future__ import annotations + +import argparse +import asyncio +import json +import math +import os +import sys +from datetime import datetime, timezone +from pathlib import Path +from uuid import uuid4 + + +def require_isolatable_pipe(topology: list[str], pipe_id: str, sources: list[str], closed: list[str]) -> dict: + from app.algorithms.valve_isolation.topology_search import valve_isolation_analysis + + result = valve_isolation_analysis(topology, pipe_id, accident_element_type="link", + water_source_nodes=sources, closed_links=closed) + if not result["isolatable"] or not result["source_check_performed"] or not result["must_close_valves"]: + raise ValueError("selected region cannot be isolated from water sources") + return result + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--screening", type=Path, required=True) + parser.add_argument("--additional-screening", type=Path, action="append", default=[]) + parser.add_argument("--pipe", action="append", required=True, help="New pipe ID; the existing primary is retained") + parser.add_argument("--allow-weak", action="store_true", help="Include weak-signal physical bursts without treating them as no-burst controls") + parser.add_argument("--case-start", type=int, default=9) + parser.add_argument("--output-root", type=Path, default=Path("data/benchmark-regional")) + args = parser.parse_args() + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + os.umask(0o077) + from scripts.benchmark.calibration import save_json, digest + from scripts.benchmark.simulation_design import BurstSimulationDesign, load_simulation_design + from scripts.check_burst_simulation_design import readonly, resolve_project, validate_database, validate_tool_inputs + + original = load_simulation_design(Path("resources/benchmarks/tjwater2-v2-burst-simulation.json")) + screening = json.loads(args.screening.read_text()) + candidates = {row["pipe_id"]: row for row in screening["results"]} + for path in args.additional_screening: + extra = json.loads(path.read_text()) + if extra["model_sha256"] != screening["model_sha256"]: + raise ValueError("screening model fingerprints do not match") + candidates.update({row["pipe_id"]: row for row in extra["results"]}) + requested = list(dict.fromkeys(args.pipe)) + if not 1 <= len(requested) <= 3 or any(pipe not in candidates for pipe in requested): + raise ValueError("select one to three screened pipes") + for pipe in requested: + if not candidates[pipe].get("possible_candidate") and not args.allow_weak: + raise ValueError("selected pipe has not passed single-frame screening") + if candidates[pipe].get("status") != "completed": + raise ValueError("selected pipe screening simulation did not complete") + folder = (args.output_root / (datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-" + uuid4().hex[:8])).resolve() + folder.mkdir(parents=True, mode=0o700) + biz, _ = asyncio.run(resolve_project(original)) + with readonly(biz) as connection: + links = connection.execute( + "SELECT l.id,l.start_node_id,l.end_node_id,l.link_type," + "COALESCE(s.status::text,p.status::text,'OPEN') status FROM network.links l " + "LEFT JOIN network.pipes p ON p.link_id=l.id " + "LEFT JOIN network.link_initial_settings s ON s.link_id=l.id" + ).fetchall() + sources = [row["id"] for row in connection.execute( + "SELECT id FROM network.nodes WHERE node_type IN ('reservoir','tank')" + )] + devices = connection.execute( + "SELECT device_id,device_type,node_id,link_id,measurement_unit FROM asset.scada_devices " + "WHERE device_type IN ('pressure','pipe_flow') AND device_id<>'547063'" + ).fetchall() + positions = {row["id"]: row for row in connection.execute( + "SELECT l.id,l.start_node_id,l.end_node_id,p.diameter,p.length," + "ST_X(ST_Transform(g.geom,4326)) longitude,ST_Y(ST_Transform(g.geom,4326)) latitude " + "FROM network.links l JOIN network.pipes p ON p.link_id=l.id " + "JOIN gis.node_geometries g ON g.node_id=l.end_node_id WHERE l.id=ANY(%s)", + ([original.pipes[0].pipe_id, *requested],), + )} + pipe_geometries = {row["id"]: json.loads(row["geometry"]) for row in connection.execute( + "SELECT id,ST_AsGeoJSON(ST_Transform(geom,4326)) geometry FROM gis.pipes WHERE id=ANY(%s)", + ([original.pipes[0].pipe_id, *requested],), + )} + topology = [f"{row['id']}:{row['link_type']}:{row['start_node_id']}:{row['end_node_id']}" for row in links] + closed = [row["id"] for row in links if str(row["status"]).upper() == "CLOSED"] + by_device = {row["device_id"]: row for row in devices} + pipes = [original.pipes[0].model_dump(mode="json")] + for pipe_id in requested: + candidate = candidates[pipe_id] + position = positions[pipe_id] + pressure_ids = [key for key in candidate["pressure_drop_percent"] + if key in by_device and by_device[key]["device_type"] == "pressure"][:5] + # Keep the plant outlets plus the two most responsive other flow meters. + flow_ids = ["542017", "486033", "542121"] + for key, _ in sorted(candidate["flow_change_percent"].items(), key=lambda pair: pair[1], reverse=True): + if key in by_device and key not in flow_ids: + flow_ids.append(key) + if len(flow_ids) >= 5: + break + pressure = [{"device_id": key, "node_id": by_device[key]["node_id"]} for key in pressure_ids] + flow = [{"device_id": key, "link_id": by_device[key]["link_id"]} for key in flow_ids] + area = math.pi * (position["diameter"] / 10) ** 2 / 4 + pipes.append({ + "role": "secondary", "pipe_id": pipe_id, "diameter_mm": position["diameter"], + "length_m": position["length"], "start_node_id": position["start_node_id"], + "emitter_node_id": position["end_node_id"], + "longitude": position["longitude"], "latitude": position["latitude"], + "candidate_orifice_areas_cm2": {"light": round(area / 32, 6), "medium": round(area / 16, 6), "severe": round(area / 8, 6)}, + "pressure_bindings": pressure, "flow_bindings": flow, + "boundary_valve_ids": [], + "output_node_ids": sorted({position["start_node_id"], position["end_node_id"], *[item["node_id"] for item in pressure]}), + "output_link_ids": [], + }) + isolations = {} + for pipe in pipes: + pipe_id = pipe["pipe_id"] + result = require_isolatable_pipe(topology, pipe_id, sources, closed) + pipe["boundary_valve_ids"] = result["must_close_valves"] + pipe["output_link_ids"] = sorted({pipe_id, *result["must_close_valves"], + *[item["link_id"] for item in pipe["flow_bindings"]]}) + pipe.update(longitude=positions[pipe_id]["longitude"], latitude=positions[pipe_id]["latitude"]) + isolations[pipe_id] = result + payload = original.model_dump(mode="json") + times = [f"2026-10-08T{hour:02d}:00:00+08:00" for hour in (2, 8, 12, 20)] + payload.update(pipes=pipes, duration_seconds=18000, event_times=times) + payload["selection_rule"]["expected_sample_count"] = 21 + if not 1 <= args.case_start <= 84: + raise ValueError("case start must leave room for sixteen distinct Bxx cases") + payload["cases"] = [{"case_id": f"B{args.case_start + p * len(times) + t:02d}", "pipe_id": pipe["pipe_id"], "occurred_at": at} + for p, pipe in enumerate(pipes) for t, at in enumerate(times)] + design = BurstSimulationDesign.model_validate(payload) + validate_tool_inputs(design) + with readonly(biz) as connection: + validate_database(connection, design, template=False) + path = folder / "design.json" + save_json(path, design.model_dump(mode="json")) + save_json(folder / "selection.json", {"source_effect": "read-only", "screening_sha256": digest(args.screening), + "additional_screening_sha256": [digest(path) for path in args.additional_screening], + "weak_signal_cases_included": args.allow_weak, + "assessment_note": "a missed pressure rule does not change physical burst ground truth", + "model_sha256": screening["model_sha256"], "design_sha256": digest(path), + "isolations": isolations, + "coordinates": "WGS84 burst endpoint, not pipe midpoint"}) + valve_ids = sorted({valve for pipe in pipes for valve in pipe["boundary_valve_ids"]}) + with readonly(biz) as connection: + valve_geometries = {row["id"]: json.loads(row["geometry"]) for row in connection.execute( + "SELECT l.id,ST_AsGeoJSON(ST_Transform(ST_LineInterpolatePoint(ST_MakeLine(a.geom,b.geom),0.5),4326)) geometry " + "FROM network.links l JOIN gis.node_geometries a ON a.node_id=l.start_node_id " + "JOIN gis.node_geometries b ON b.node_id=l.end_node_id WHERE l.id=ANY(%s)", + (valve_ids,), + )} + features = [] + for index, pipe in enumerate(pipes, 1): + properties = {"region": f"R{index}", "pipe_id": pipe["pipe_id"], "diameter_mm": pipe["diameter_mm"]} + features.append({"type": "Feature", "properties": {**properties, "kind": "burst"}, + "geometry": {"type": "Point", "coordinates": [pipe["longitude"], pipe["latitude"]]}}) + features.append({"type": "Feature", "properties": {**properties, "kind": "pipe"}, + "geometry": pipe_geometries[pipe["pipe_id"]]}) + for valve in pipe["boundary_valve_ids"]: + features.append({"type": "Feature", "properties": {**properties, "kind": "valve", "valve_id": valve}, + "geometry": valve_geometries[valve]}) + save_json(folder / "locations.geojson", {"type": "FeatureCollection", "features": features}) + print(f"regional design ready: {len(pipes)} regions x 3 areas x {len(times)} times = {len(pipes) * 3 * len(times)} physical cases; {path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_burst_benchmark_mcp.py b/scripts/run_burst_benchmark_mcp.py new file mode 100644 index 0000000..4f92561 --- /dev/null +++ b/scripts/run_burst_benchmark_mcp.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +"""Submit, execute, and materialize isolated burst benchmark cases through MCP.""" +from __future__ import annotations + +import argparse +import asyncio +import json +import os +from pathlib import Path +import subprocess +import sys +from uuid import uuid4 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--design", type=Path, default=Path("resources/benchmarks/tjwater2-v2-burst-balanced.json")) + parser.add_argument("--state-dir", type=Path, default=Path("data/benchmark-generation/tjwater2-20261105")) + parser.add_argument("--agent-env", type=Path, default=Path("../next-tjwater-agent/.local.env")) + parser.add_argument("--limit", type=int, default=1, help="Maximum new cases to complete in this invocation") + parser.add_argument("--batches", type=int, default=1, help="Run isolated child processes for this many batches") + parser.add_argument("--local-executor-concurrency", type=int, default=1, choices=(1, 2)) + args = parser.parse_args() + if args.limit < 1 or args.limit > args.local_executor_concurrency: + parser.error("--limit must be between 1 and local executor concurrency") + if args.batches < 1: + parser.error("--batches must be positive") + if args.batches > 1: + command = [ + sys.executable, + str(Path(__file__).resolve()), + "--design", str(args.design), + "--state-dir", str(args.state_dir), + "--agent-env", str(args.agent_env), + "--limit", str(args.limit), + "--local-executor-concurrency", str(args.local_executor_concurrency), + "--batches", "1", + ] + for _ in range(args.batches): + subprocess.run(command, check=True) + return 0 + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + os.umask(0o077) + + from dotenv import load_dotenv + from psycopg.conninfo import make_conninfo + from scripts.benchmark.calibration import save_json + from scripts.benchmark.generation import write_analysis_scada + from scripts.benchmark.simulation_design import load_simulation_design + from scripts.check_burst_simulation_design import resolve_project + + load_dotenv(Path(__file__).resolve().parents[1] / ".env", override=False) + state_path = args.state_dir.resolve() / "state.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + project_id = state.get("project_id") + if not project_id or state.get("status") != "prepared": + raise ValueError("run isolated preparation before MCP generation") + design = load_simulation_design(args.design) + pipes = {pipe.pipe_id: pipe for pipe in design.pipes} + source_biz, source_iot = asyncio.run(resolve_project(design)) + business_dsn = make_conninfo(source_biz, dbname=state["target"]) + timescale_dsn = make_conninfo(source_iot, dbname=state["target"]) + + pending = [ + row for row in state["schedule"] + if state["cases"].get(row["case_key"], {}).get("status") != "scada_materialized" + ][:args.limit] + for offset in range(0, len(pending), args.local_executor_concurrency): + batch = pending[offset:offset + args.local_executor_concurrency] + job_ids = submit_batch( + batch=batch, + pipes=pipes, + project_id=project_id, + agent_env=args.agent_env, + state=state, + state_path=state_path, + ) + asyncio.run(execute_batch(project_id, job_ids, args.local_executor_concurrency)) + for row in batch: + case = state["cases"][row["case_key"]] + run = find_analysis_run(business_dsn, case["scenario_name"]) + summary = write_analysis_scada( + business_dsn=business_dsn, + timescale_dsn=timescale_dsn, + run_id=str(run["run_id"]), + ) + case.update({ + "analysis_run_id": str(run["run_id"]), + "status": "scada_materialized", + "scada": summary, + }) + save_json(state_path, state) + print({"case_key": row["case_key"], "job_id": case["job_id"], "scada_rows": summary["rows"]}, flush=True) + return 0 + + +def exchange_token(agent_env: Path) -> str: + import httpx + from dotenv import dotenv_values + from app.mcp.config import McpSettings + from scripts.run_operational_worker import CredentialProvider, WorkerSettings + + values = dotenv_values(agent_env) + mcp = McpSettings() + + async def exchange() -> str: + issuer = (values.get("TJWATER_MCP_DEV_HTTP_ISSUER_URI") or values["TJWATER_MCP_ISSUER_URI"]).rstrip("/") + async with httpx.AsyncClient(timeout=15) as client: + product = await CredentialProvider(WorkerSettings.from_environment()).headers(client) + response = await client.post(issuer + "/protocol/openid-connect/token", data={ + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "client_id": values["TJWATER_MCP_CLIENT_ID"], + "client_secret": values["TJWATER_MCP_CLIENT_SECRET"], + "subject_token": product["Authorization"].removeprefix("Bearer "), + "subject_token_type": "urn:ietf:params:oauth:token-type:access_token", + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "audience": mcp.audience, + "resource": mcp.resource_uri, + "scope": "mcp.catalog.read mcp.query mcp.context.bind", + }) + response.raise_for_status() + return response.json()["access_token"] + + return asyncio.run(exchange()) + + +def mcp_call(client, token: str, project_id: str, name: str, arguments: dict) -> dict: + from mcp import types + + method = "tools/call" + call_id = str(uuid4()) + meta = { + types.PROTOCOL_VERSION_META_KEY: "2026-07-28", + types.CLIENT_CAPABILITIES_META_KEY: {}, + types.CLIENT_INFO_META_KEY: {"name": "benchmark-generator", "version": "1"}, + "io.tjwater/callId": call_id, + } + response = client.post("/mcp", headers={ + "Authorization": "Bearer " + token, + "X-Project-Id": project_id, + "MCP-Protocol-Version": "2026-07-28", + "MCP-Method": method, + "MCP-Name": name, + "Accept": "application/json, text/event-stream", + }, json={ + "jsonrpc": "2.0", + "id": call_id, + "method": method, + "params": {"_meta": meta, "name": name, "arguments": arguments}, + }) + response.raise_for_status() + payload = response.json() + result = payload.get("result") or {} + if payload.get("error") or result.get("isError"): + raise RuntimeError("MCP tool call failed") + return result["structuredContent"]["data"] + + +def submit_batch(*, batch, pipes, project_id, agent_env, state, state_path) -> list[str]: + from fastapi.testclient import TestClient + from app.main import app + from scripts.benchmark.calibration import save_json + + token = exchange_token(agent_env) + session_id = "benchmark-tjwater2-burst-v1" + jobs = [] + with TestClient(app, base_url="http://127.0.0.1:8000") as client: + common = {"Authorization": "Bearer " + token, "X-Project-Id": project_id} + for row in batch: + case = state["cases"].setdefault(row["case_key"], {}) + request_id = case.setdefault("request_id", str(uuid4())) + scenario_name = case.setdefault( + "scenario_name", + f"bench-{row['case_key']}-{row['event_at'][0:16].replace('-', '').replace(':', '').replace('T', '')}", + ) + binding = client.post( + "/api/v1/mcp-query-contexts", + headers=common, + json={"native_session_id": session_id, "purpose": "ordinary_chat"}, + ) + binding.raise_for_status() + context_handle = binding.json()["context_handle"] + pipe = pipes[row["pipe_id"]] + parameters = { + "start_time": row["event_at"], + "duration": 18000, + "output_node_ids": pipe.output_node_ids, + "output_link_ids": pipe.output_link_ids, + "burst_pipe_ids": [row["pipe_id"]], + "orifice_areas_cm2": [row["area_cm2"]], + } + preview = mcp_call(client, token, project_id, "save_simulation_scenario", { + "context_handle": context_handle, + "timeout_seconds": 240, + "request_id": request_id, + "dry_run": True, + "name": scenario_name, + "scenario": "burst", + "parameters": parameters, + }) + case["expected_digest"] = preview["expected_digest"] + save_json(state_path, state) + queued = mcp_call(client, token, project_id, "save_simulation_scenario", { + "context_handle": context_handle, + "timeout_seconds": 240, + "request_id": request_id, + "dry_run": False, + "expected_digest": preview["expected_digest"], + "name": scenario_name, + "scenario": "burst", + "parameters": parameters, + }) + case.update({"job_id": queued["job_id"], "status": queued["status"]}) + jobs.append(queued["job_id"]) + save_json(state_path, state) + return jobs + + +async def execute_batch(project_id: str, expected_job_ids: list[str], concurrency: int) -> None: + from app.services.calculation_worker import CalculationWorker, WorkerSettings, queue_repository + + worker = CalculationWorker(WorkerSettings( + max_concurrency=concurrency, + poll_seconds=1, + lease_seconds=30, + heartbeat_seconds=5, + )) + rows = [] + for expected in expected_job_ids: + async with queue_repository(project_id) as repository: + await repository.recover() + row = await repository.claim(worker.owner, worker.settings.lease_seconds) + if row is None or str(row["job_id"]) != expected: + raise RuntimeError("isolated benchmark queue order changed unexpectedly") + rows.append(row) + await asyncio.gather(*(worker.run_job(row) for row in rows)) + for row in rows: + async with queue_repository(project_id) as repository: + stored = await repository.get(row["job_id"]) + if stored["status"] != "succeeded": + raise RuntimeError(f"benchmark calculation failed with {stored['status']}") + + +def find_analysis_run(business_dsn: str, scenario_name: str) -> dict: + from psycopg import connect + from psycopg.rows import dict_row + + with connect(business_dsn, row_factory=dict_row) as connection: + rows = connection.execute( + """SELECT run_id,name,status,started_at FROM analysis.runs + WHERE name=%s ORDER BY created_at DESC LIMIT 2""", + (scenario_name,), + ).fetchall() + if len(rows) != 1 or rows[0]["status"] != "completed": + raise ValueError("persisted scenario run is missing, duplicated, or incomplete") + return dict(rows[0]) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_normal_scada_day_mcp.py b/scripts/run_normal_scada_day_mcp.py new file mode 100644 index 0000000..bdae42f --- /dev/null +++ b/scripts/run_normal_scada_day_mcp.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Run one isolated online-model day through MCP and materialize noisy SCADA.""" +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import sys +from datetime import datetime +from pathlib import Path + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--start", default="2026-05-01T00:00:00+08:00") + parser.add_argument( + "--generation-state", + type=Path, + default=Path("data/benchmark-generation/tjwater2-20261105/state.json"), + ) + parser.add_argument( + "--state", + type=Path, + default=Path("data/benchmark-generation/tjwater2-20261105/normal-day-state.json"), + ) + parser.add_argument( + "--design", + type=Path, + default=Path("resources/benchmarks/tjwater2-v2-burst-balanced.json"), + ) + parser.add_argument("--agent-env", type=Path, default=Path("../next-tjwater-agent/.local.env")) + parser.add_argument("--noise-seed", default="tjwater2-normal-scada-v1") + args = parser.parse_args() + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + os.umask(0o077) + + from dotenv import load_dotenv + from fastapi.testclient import TestClient + from psycopg import connect + from psycopg.conninfo import make_conninfo + from psycopg.rows import dict_row + + from app.main import app + from scripts.benchmark.calibration import save_json + from scripts.benchmark.generation import write_realtime_scada_day + from scripts.benchmark.simulation_design import load_simulation_design + from scripts.check_burst_simulation_design import resolve_project + from scripts.run_burst_benchmark_mcp import exchange_token, execute_batch, mcp_call + + load_dotenv(Path(__file__).resolve().parents[1] / ".env", override=False) + start = datetime.fromisoformat(args.start) + if start.tzinfo is None or start.minute % 15 or start.second or start.microsecond: + raise ValueError("start must be timezone-aware and aligned to 15 minutes") + generation = json.loads(args.generation_state.read_text(encoding="utf-8")) + if generation.get("status") != "prepared" or not generation.get("project_id"): + raise ValueError("isolated benchmark project is not prepared") + state_path = args.state.resolve() + state_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + state = json.loads(state_path.read_text(encoding="utf-8")) if state_path.exists() else { + "version": "normal-scada-day@1", + "target": generation["target"], + "project_id": generation["project_id"], + "start": start.isoformat(), + "duration_seconds": 86400, + "noise_seed": args.noise_seed, + "status": "preparing", + } + expected = { + "target": generation["target"], + "project_id": generation["project_id"], + "start": start.isoformat(), + "noise_seed": args.noise_seed, + } + if any(state.get(key) != value for key, value in expected.items()): + raise ValueError("normal-day state belongs to different inputs") + save_json(state_path, state) + + design = load_simulation_design(args.design) + source_biz, source_iot = asyncio.run(resolve_project(design)) + business_dsn = make_conninfo(source_biz, dbname=state["target"]) + timescale_dsn = make_conninfo(source_iot, dbname=state["target"]) + with connect(business_dsn, row_factory=dict_row) as connection: + node = connection.execute( + """SELECT node_id FROM asset.scada_devices + WHERE device_type='pressure' AND node_id IS NOT NULL ORDER BY device_id LIMIT 1""" + ).fetchone()["node_id"] + link = connection.execute( + """SELECT link_id FROM asset.scada_devices + WHERE device_type='pipe_flow' AND link_id IS NOT NULL ORDER BY device_id LIMIT 1""" + ).fetchone()["link_id"] + + if not state.get("job_id"): + token = exchange_token(args.agent_env) + with TestClient(app, base_url="http://127.0.0.1:8000") as client: + headers = { + "Authorization": "Bearer " + token, + "X-Project-Id": state["project_id"], + } + binding = client.post( + "/api/v1/mcp-query-contexts", + headers=headers, + json={"native_session_id": "benchmark-normal-scada-v1", "purpose": "ordinary_chat"}, + ) + binding.raise_for_status() + queued = mcp_call(client, token, state["project_id"], "run_simulation", { + "context_handle": binding.json()["context_handle"], + "timeout_seconds": 240, + "start_time": start.isoformat(), + "duration": 86400, + "output_node_ids": [node], + "output_link_ids": [link], + }) + state.update({"job_id": queued["job_id"], "status": queued["status"]}) + save_json(state_path, state) + print({"job_id": state["job_id"], "status": state["status"]}, flush=True) + + async def stored_job(include_result: bool = False): + from app.services.calculation_worker import queue_repository + + async with queue_repository(state["project_id"]) as repository: + return await repository.get(state["job_id"], include_result=include_result) + + stored = asyncio.run(stored_job()) + if stored["status"] == "queued": + asyncio.run(execute_batch(state["project_id"], [state["job_id"]], 1)) + stored = asyncio.run(stored_job()) + if stored["status"] != "succeeded": + raise RuntimeError(f"normal online simulation ended with {stored['status']}") + result = asyncio.run(stored_job(include_result=True))["result"] + calculation_data = (result or {}).get("data") or {} + scenario_result = calculation_data.get("result") or calculation_data + units = scenario_result.get("units") or {} + if str(units.get("pressure", "")).upper() not in {"MTR", "M", "METERS"}: + raise ValueError(f"unexpected model pressure unit: {units.get('pressure')}") + if str(units.get("flow", "")).upper() != "LPS": + raise ValueError(f"unexpected model flow unit: {units.get('flow')}") + if not state.get("scada"): + state["status"] = "simulated" + save_json(state_path, state) + state["scada"] = write_realtime_scada_day( + business_dsn=business_dsn, + timescale_dsn=timescale_dsn, + start=start, + noise_seed=args.noise_seed, + ) + state["model_units"] = units + state["status"] = "scada_materialized" + save_json(state_path, state) + print(json.dumps({"status": state["status"], "job_id": state["job_id"], "scada": state["scada"]}, ensure_ascii=False), flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_operational_worker.py b/scripts/run_operational_worker.py index 4efe2ef..99b70f5 100644 --- a/scripts/run_operational_worker.py +++ b/scripts/run_operational_worker.py @@ -146,6 +146,14 @@ class OperationalWorker: json={"limit": 100, **scope}, ) recovery.raise_for_status() + try: + recovery_checks = await self.client.post( + f"{server}/api/v1/operational-tasks/recovery/reconcile", headers=headers, + json={"limit": 20, **scope}, + ) + recovery_checks.raise_for_status() + except httpx.HTTPError as exc: + logger.warning("SCADA recovery reconciliation unavailable: %s", type(exc).__name__) active_run_ids: set[str] = set() for status_value in ("claimed", "running"): response = await self.client.get( diff --git a/scripts/run_server.py b/scripts/run_server.py index 5ea313b..23d1bac 100644 --- a/scripts/run_server.py +++ b/scripts/run_server.py @@ -1,22 +1,58 @@ +import argparse import asyncio import os import sys +from typing import Any + import uvicorn # 将项目根目录添加到 python 路径 sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) +def build_server_options(argv: list[str] | None = None) -> dict[str, Any]: + parser = argparse.ArgumentParser(description="Run the TJWater API server") + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--port", type=int, default=8000) + parser.add_argument("--reload", action="store_true") + parser.add_argument("--reload-dir", action="append", default=[]) + parser.add_argument("--startup-timeout", type=int, default=60) + args = parser.parse_args(argv) + if args.startup_timeout <= 0: + parser.error("--startup-timeout must be positive") + return { + "app": "app.main:app", + "host": args.host, + "port": args.port, + "reload": args.reload, + "reload_dirs": args.reload_dir, + "startup_timeout": args.startup_timeout, + "loop": "asyncio", + } + + +def main() -> None: + from pathlib import Path + from scripts.server_handoff import HandoffSupervisor + + options = build_server_options() + reload_enabled = options.pop("reload") + reload_dirs = options.pop("reload_dirs") + startup_timeout = options.pop("startup_timeout") + if reload_enabled: + HandoffSupervisor( + uvicorn.Config(**options), + startup_timeout=startup_timeout, + reload_dirs=[Path(directory) for directory in reload_dirs] or None, + readiness_check="app.api.health:metadata_database_ready", + ).run() + else: + uvicorn.run(**options) + + if __name__ == "__main__": # Windows 设置事件循环策略 if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) - # 用 uvicorn.run 支持 workers 参数 - uvicorn.run( - "app.main:app", - host="0.0.0.0", - port=8000, - # workers=2, # 这里可以设置多进程 - loop="asyncio", - ) + main() diff --git a/scripts/screen_burst_benchmark_pipes.py b/scripts/screen_burst_benchmark_pipes.py new file mode 100644 index 0000000..43a22df --- /dev/null +++ b/scripts/screen_burst_benchmark_pipes.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +"""Screen alternative burst pipes near existing pressure meters using a frozen INP.""" +from __future__ import annotations + +import argparse +import asyncio +import heapq +import json +import math +import sys +import os +from collections import defaultdict, deque +from pathlib import Path +from uuid import uuid4 + + +def nearest_pressure_distances( + graph: dict[str, list[tuple[str, float]]], devices: list[dict], radius_m: float, +) -> dict[str, tuple[float, str]]: + """Measure to the nearest pressure meter over the network, not just a selected meter.""" + distances = {} + pending = [] + for device in devices: + node, device_id = device["node_id"], device["device_id"] + value = (0.0, device_id) + if value < distances.get(node, (math.inf, "")): + distances[node] = value + heapq.heappush(pending, (0.0, device_id, node)) + while pending: + distance, device_id, node = heapq.heappop(pending) + if distances[node] != (distance, device_id): + continue + for neighbor, length in graph.get(node, []): + cost = distance + length + value = (cost, device_id) + if cost <= radius_m and value < distances.get(neighbor, (math.inf, "")): + distances[neighbor] = value + heapq.heappush(pending, (cost, device_id, neighbor)) + return distances + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--calibration-dir", required=True, type=Path) + parser.add_argument("--limit", type=int, default=24) + parser.add_argument("--offset", type=int, default=0) + parser.add_argument("--radius-m", type=float, default=400) + parser.add_argument("--min-network-distance-m", type=float, default=0) + parser.add_argument("--min-diameter", type=float, default=150) + parser.add_argument("--require-isolation", action="store_true") + parser.add_argument("--exclude-existing-km", type=float, default=0) + parser.add_argument("--skip-screening", type=Path) + args = parser.parse_args() + os.umask(0o077) + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + from scripts.benchmark.calibration import configure_inp, digest, run_file, save_json + from scripts.benchmark.simulation_design import BurstSimulationDesign + from scripts.check_burst_simulation_design import readonly, resolve_project + + if (not 1 <= args.limit <= 100 or args.offset < 0 or not 100 <= args.radius_m <= 5000 + or not 0 <= args.min_network_distance_m < args.radius_m + or not 50 <= args.min_diameter <= 800 or not 0 <= args.exclude_existing_km <= 10): + parser.error("invalid screen limit, offset, radius, diameter or exclusion distance") + folder = args.calibration_dir.resolve() + calibration = json.loads((folder / "calibration.json").read_text()) + design = BurstSimulationDesign.model_validate(calibration["design"]) + snapshot = folder / "source.inp" + if digest(snapshot) != calibration["model_sha256"]: + raise ValueError("model snapshot fingerprint changed") + biz, _ = asyncio.run(resolve_project(design)) + with readonly(biz) as connection: + devices = connection.execute( + "SELECT device_id,device_type,node_id,link_id FROM asset.scada_devices " + "WHERE device_type IN ('pressure','pipe_flow') ORDER BY device_id" + ).fetchall() + pressure = [device for device in devices if device["device_type"] == "pressure"] + flow = [device for device in devices if device["device_type"] == "pipe_flow" and device["device_id"] != "547063"] + source = snapshot.read_text() + graph = defaultdict(list) + pipes = {} + valve_links = {} + source_nodes = set() + closed_links = set() + junctions = set() + existing_emitters = set() + section = "" + for raw in source.splitlines(): + line = raw.split(";", 1)[0].strip() + if line.startswith("["): + section = line.strip("[]").upper() + continue + tokens = line.split() + if not tokens: + continue + if section == "JUNCTIONS": + junctions.add(tokens[0]) + elif section in {"RESERVOIRS", "TANKS"}: + source_nodes.add(tokens[0]) + elif section == "STATUS" and tokens[-1].upper() == "CLOSED": + closed_links.add(tokens[0]) + elif section == "EMITTERS": + existing_emitters.add(tokens[0]) + elif section in {"PIPES", "VALVES"}: + link, left, right = tokens[:3] + length = float(tokens[3]) if section == "PIPES" else 0.01 + if section == "PIPES": + if tokens[-1].upper() == "CLOSED": + continue + pipes[link] = {"pipe_id": link, "start_node_id": left, "emitter_node_id": right, + "length_m": length, "diameter_mm": float(tokens[4])} + else: + valve_links[link] = (left, right) + graph[left].append((right, max(length, 0.01))) + graph[right].append((left, max(length, 0.01))) + # Initial link overrides also apply to pipe and valve connectivity. + graph.clear() + pipe_graph = defaultdict(list) + for link, pipe in pipes.items(): + if link in closed_links: + continue + left, right = pipe["start_node_id"], pipe["emitter_node_id"] + length = max(pipe["length_m"], 0.01) + graph[left].append((right, length)) + graph[right].append((left, length)) + pipe_graph[left].append(right) + pipe_graph[right].append(left) + for link, (left, right) in valve_links.items(): + if link not in closed_links: + graph[left].append((right, 0.01)) + graph[right].append((left, 0.01)) + component_by_node = {} + component_data = {} + + def isolation(node): + if node not in component_by_node: + seen = {node} + pending = deque([node]) + while pending: + for neighbor in pipe_graph[pending.popleft()]: + if neighbor not in seen: + seen.add(neighbor) + pending.append(neighbor) + component_id = min(seen) + for member in seen: + component_by_node[member] = component_id + boundaries = sorted(link for link, (left, right) in valve_links.items() + if link not in closed_links and (left in seen) != (right in seen)) + component_data[component_id] = {"region_id": component_id, "node_count": len(seen), + "boundary_valve_ids": boundaries, + "source_inside": bool(seen & source_nodes)} + return component_data[component_by_node[node]] + # Only real pressure-meter model nodes present in this frozen snapshot are used. + pressure = [device for device in pressure if device["node_id"] in junctions] + flow = [device for device in flow if device["link_id"] in pipes] + distances = nearest_pressure_distances(graph, pressure, args.radius_m) + candidates = [] + for pipe in pipes.values(): + node = pipe["emitter_node_id"] + if (args.min_diameter <= pipe["diameter_mm"] <= 800 and pipe["length_m"] >= 50 + and pipe["pipe_id"] not in closed_links + and node in junctions and node not in existing_emitters and node in distances): + distance, device = distances[node] + if distance < args.min_network_distance_m: + continue + region = isolation(node) + if args.require_isolation and (region["source_inside"] or not 1 <= len(region["boundary_valve_ids"]) <= 12): + continue + candidates.append({**pipe, "nearest_pressure_device": device, "network_distance_m": distance, + "screen_priority": pipe["length_m"] / pipe["diameter_mm"] ** 2, + "isolation": region}) + skipped = set() + if args.skip_screening: + skipped = {entry["pipe_id"] for entry in json.loads(args.skip_screening.read_text())["results"]} + with readonly(biz) as connection: + coords = {row["node_id"]: row for row in connection.execute( + "SELECT node_id,ST_X(ST_Transform(geom,4326)) longitude," + "ST_Y(ST_Transform(geom,4326)) latitude FROM gis.node_geometries WHERE node_id=ANY(%s)", + (list({pipe["emitter_node_id"] for pipe in candidates}),), + )} + existing_positions = connection.execute( + "SELECT ST_X(ST_Transform(geom,4326)) longitude,ST_Y(ST_Transform(geom,4326)) latitude " + "FROM gis.node_geometries WHERE node_id=ANY(%s)", + ([pipe.emitter_node_id for pipe in design.pipes],), + ).fetchall() + filtered = [] + for candidate in candidates: + position = coords.get(candidate["emitter_node_id"]) + if position is None or candidate["pipe_id"] in skipped: + continue + distances_km = [111.32 * math.hypot((position["longitude"] - old["longitude"]) * + math.cos(math.radians(position["latitude"])), + position["latitude"] - old["latitude"]) + for old in existing_positions] + if distances_km and min(distances_km) < args.exclude_existing_km: + continue + candidate.update(longitude=position["longitude"], latitude=position["latitude"], + nearest_existing_km=min(distances_km) if distances_km else None) + filtered.append(candidate) + candidates = filtered + # Cover different meter neighborhoods before taking additional pipes at the same meter. + candidates.sort(key=lambda pipe: pipe["screen_priority"], reverse=True) + used = set() + unique, remaining = [], [] + for candidate in candidates: + device = candidate["nearest_pressure_device"] + (remaining if device in used else unique).append(candidate) + used.add(device) + selected = (unique + remaining)[args.offset:args.offset + args.limit] + node_ids = sorted({device["node_id"] for device in pressure} | {pipe["emitter_node_id"] for pipe in selected}) + link_ids = sorted({device["link_id"] for device in flow}) + at = max(design.event_times) + output = folder / f"screening-{uuid4().hex[:8]}" + output.mkdir(mode=0o700) + report = {"version": "burst-pipe-screening@1", "model_sha256": digest(snapshot), + "at": at.isoformat(), "candidate_count": len(candidates), "results": [], + "screen_options": {key: str(value) if isinstance(value, Path) else value for key, value in vars(args).items()}, + "device_bindings": devices, + "scope": "daytime single-frame screening only; requires full paired calibration and isolation review"} + + def simulate(label, emitter=None): + path = output / f"{label}.inp" + path.write_text(configure_inp(source, at, 0, emitter=emitter, pda_options=calibration["pda_options"])) + data = run_file(path, node_ids, link_ids, timeout=240) + save_json(output / f"{label}.json", data) + return data + + normal = simulate("normal") + if normal["status"] != "completed" or normal["warnings"]: + raise ValueError("normal screening baseline failed") + for index, pipe in enumerate(selected): + area = math.pi * (pipe["diameter_mm"] / 10) ** 2 / 4 / 8 + burst = simulate(f"candidate-{index:02d}", (pipe["emitter_node_id"], area)) + result = {**pipe, "orifice_area_cm2": area, "status": burst["status"], "output_file": f"candidate-{index:02d}.json"} + if burst["status"] == "completed": + drops = {device["device_id"]: 100 * (normal["nodes"][device["node_id"]][0]["pressure"] - + burst["nodes"][device["node_id"]][0]["pressure"]) / + max(abs(normal["nodes"][device["node_id"]][0]["pressure"]), 1e-6) for device in pressure} + changes = {device["device_id"]: 100 * abs(normal["links"][device["link_id"]][0]["flow"] - + burst["links"][device["link_id"]][0]["flow"]) / + abs(normal["links"][device["link_id"]][0]["flow"]) for device in flow + if abs(normal["links"][device["link_id"]][0]["flow"]) > 1e-6} + ranked = sorted(drops.items(), key=lambda pair: pair[1], reverse=True) + negative = any(row[0]["pressure"] < 0 for row in burst["nodes"].values()) + result.update(pressure_drop_percent=dict(ranked), flow_change_percent=changes, + possible_candidate=not burst["warnings"] and not negative and ranked[0][1] >= 30 + and (ranked[1][1] >= 10 or max(changes.values(), default=0) >= 20)) + print(f"{index+1}/{len(selected)} {pipe['pipe_id']}: max pressure drop={ranked[0][1]:.2f}%, " + f"possible candidate={result['possible_candidate']}", flush=True) + report["results"].append(result) + save_json(output / "screening.json", report) + print(f"screening complete; report: {output / 'screening.json'}", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/server_handoff.py b/scripts/server_handoff.py new file mode 100644 index 0000000..c5282e0 --- /dev/null +++ b/scripts/server_handoff.py @@ -0,0 +1,267 @@ +"""Keep the listening socket alive while replacement ASGI workers become ready.""" +from __future__ import annotations + +import asyncio +import logging +import multiprocessing +import signal +import threading +import time +from dataclasses import dataclass +from functools import partial +from multiprocessing.connection import Connection +from multiprocessing.process import BaseProcess +from pathlib import Path + +import uvicorn +from uvicorn._subprocess import get_subprocess +from uvicorn.importer import import_from_string + + +logger = logging.getLogger("uvicorn.error") + + +class _ReadinessGate: + def __init__(self, app, check): + self.app, self.check = app, check + + async def __call__(self, scope, receive, send): + if scope["type"] != "lifespan": + return await self.app(scope, receive, send) + + async def checked_send(message): + if message["type"] == "lifespan.startup.complete" and not await self.check(): + await send({"type": "lifespan.startup.failed", "message": "API readiness check failed"}) + else: + await send(message) + + await self.app(scope, receive, checked_send) + + +class _ReadyServer(uvicorn.Server): + def __init__(self, config: uvicorn.Config, control: Connection): + super().__init__(config) + self.control = control + + async def startup(self, sockets=None) -> None: + await super().startup(sockets=sockets) + if self.started and not self.should_exit: + self.control.send("ready") + + async def shutdown(self, sockets=None) -> None: + # Acknowledge that the old worker has actually stopped accepting. Its + # established streams may still run for minutes after this handshake. + for listener in self.servers: + listener.close() + for sock in sockets or []: + sock.close() + self.control.send("draining") + await super().shutdown(sockets=sockets) + + +def _serve_worker(*, config: uvicorn.Config, control: Connection, readiness_check: str | None, sockets) -> None: + config.reload = False + config.workers = 1 + if readiness_check: + config.load() + config.loaded_app = _ReadinessGate(config.loaded_app, import_from_string(readiness_check)) + server = _ReadyServer(config, control) + + async def receive_commands() -> None: + while True: + if control.poll(): + try: + command = control.recv() + except EOFError: + command = "drain" + if command == "drain": + server.should_exit = True + return + await asyncio.sleep(0.05) + + async def serve() -> None: + receiver = asyncio.create_task(receive_commands()) + try: + await server.serve(sockets=sockets) + finally: + receiver.cancel() + await asyncio.gather(receiver, return_exceptions=True) + control.close() + + config.setup_event_loop() + asyncio.run(serve()) + + +@dataclass +class Worker: + process: BaseProcess + control: Connection + + def drain(self, *, wait_timeout: float = 0) -> bool: + if self.process.is_alive(): + try: + self.control.send("drain") + if wait_timeout: + deadline = time.monotonic() + wait_timeout + while time.monotonic() < deadline: + if self.control.poll(0.05) and self.control.recv() == "draining": + return True + if not self.process.is_alive(): + return True + return False + except (BrokenPipeError, EOFError, OSError): + return not self.process.is_alive() + return True + + +class HandoffSupervisor: + """Start the replacement before draining the worker with existing connections.""" + + def __init__( + self, + config: uvicorn.Config, + *, + startup_timeout: float = 60, + reload_dirs: list[Path] | None = None, + poll_interval: float = 0.25, + readiness_check: str | None = None, + ): + self.config = config + self.startup_timeout = startup_timeout + self.reload_dirs = reload_dirs or [Path("app"), Path("samples/peripheral")] + self.poll_interval = poll_interval + self.readiness_check = readiness_check + self.current: Worker | None = None + self.retiring: list[Worker] = [] + self.socket = config.bind_socket() + self.socket.listen(config.backlog) + self.should_exit = threading.Event() + self._files: dict[Path, tuple[int, int]] = {} + + def _candidate(self) -> Worker | None: + parent = child = process = None + ready = False + try: + parent, child = multiprocessing.get_context("spawn").Pipe() + process = get_subprocess( + config=self.config, + target=partial(_serve_worker, config=self.config, control=child, readiness_check=self.readiness_check), + sockets=[self.socket], + ) + worker = Worker(process, parent) + process.start() + child.close() + deadline = time.monotonic() + self.startup_timeout + while not self.should_exit.is_set() and time.monotonic() < deadline: + if parent.poll(0.05): + try: + ready = parent.recv() == "ready" and process.is_alive() + except EOFError: + break + if ready: + return worker + if not process.is_alive(): + break + logger.error("Replacement worker did not become ready; keeping the current worker") + return None + except Exception: + logger.exception("Replacement startup or readiness handshake failed; keeping the current worker") + return None + finally: + if child is not None: + child.close() + if not ready: + # Only an unready candidate is terminated. The serving worker + # and workers draining established connections are untouched. + if process is not None and process.pid is not None: + if process.is_alive(): + process.terminate() + process.join(timeout=2) + if process.is_alive(): + process.kill() + process.join() + if parent is not None: + parent.close() + + def start(self) -> bool: + self._files = self._snapshot() + self.current = self._candidate() + return self.current is not None + + def reload(self) -> bool: + self.reap() + candidate = self._candidate() + if candidate is None: + return False + old, self.current = self.current, candidate + logger.info("Worker %s is ready to accept new connections", candidate.process.pid) + if old is not None: + acknowledged = old.drain(wait_timeout=2) + self.retiring.append(old) + if not acknowledged: + logger.error("Worker %s has not yet acknowledged draining", old.process.pid) + return False + logger.info("Worker %s is draining existing connections", old.process.pid) + return True + + def reap(self) -> None: + remaining = [] + for worker in self.retiring: + if worker.process.is_alive(): + remaining.append(worker) + else: + worker.process.join() + worker.control.close() + self.retiring = remaining + + def _snapshot(self) -> dict[Path, tuple[int, int]]: + files = {} + for directory in self.reload_dirs: + for path in directory.rglob("*.py"): + try: + stat = path.stat() + files[path.resolve()] = (stat.st_mtime_ns, stat.st_size) + except OSError: + continue + return files + + def run(self) -> None: + signals = (signal.SIGINT, signal.SIGTERM) + original = {sig: signal.signal(sig, lambda *_: self.should_exit.set()) for sig in signals} + try: + if not self.start(): + raise RuntimeError("The initial API worker failed to start") + pending_reload = False + while not self.should_exit.wait(self.poll_interval): + self.reap() + files = self._snapshot() + if files != self._files: + self._files = files + pending_reload = True + active_failed = self.current is None or not self.current.process.is_alive() + if active_failed: + logger.error("The active worker exited; starting a replacement") + pending_reload = True + # Coalesce rapid edits while old SSE connections drain, rather + # than keeping an unbounded number of application generations. + if pending_reload and (active_failed or len(self.retiring) < 2): + pending_reload = False + self.reload() + finally: + try: + self.close() + finally: + for sig, handler in original.items(): + signal.signal(sig, handler) + + def close(self) -> None: + self.should_exit.set() + workers = [*self.retiring, *([self.current] if self.current else [])] + for worker in workers: + worker.drain() + self.socket.close() + for worker in workers: + worker.process.join() + worker.control.close() + self.current = None + self.retiring = [] diff --git a/scripts/sync_peripheral_work_orders.py b/scripts/sync_peripheral_work_orders.py index 9bd630a..568b5d1 100644 --- a/scripts/sync_peripheral_work_orders.py +++ b/scripts/sync_peripheral_work_orders.py @@ -215,14 +215,17 @@ async def sync_once(client: httpx.AsyncClient, settings: WorkerSettings, periphe evidence_summary = order["summary"] if notes: evidence_summary += "\n现场反馈:\n" + "\n".join(notes[-10:]) + field_times = [activity["occurred_at"] for activity in order.get("activity", []) + if activity.get("kind") == "feedback" and activity.get("occurred_at")] + feedback_time = field_times[-1] if order["status"] == "completed" and field_times else order["updated_at"] feedback = { "request_id": str(uuid5(NAMESPACE_URL, event_id)), "source_event_id": external_id, "source_revision": order["revision"], "status": STATUS[order["status"]], - "occurred_at": order["updated_at"], + "occurred_at": feedback_time, "detail": f"{source_label} {order['work_order_id']}:{order['status']}", "evidence": {"evidence_id": event_id, "kind": "external_feedback", "summary": evidence_summary, "source_label": source_label, "source_ref": external_id, - "observed_at": order["updated_at"]}, + "observed_at": feedback_time}, } # A child order's creation is already represented by its dispatch # receipt. Only a subsequent source revision is new field input. diff --git a/tests/api/test_scada_replay_api.py b/tests/api/test_scada_replay_api.py index 8e6f4e5..dc4148c 100644 --- a/tests/api/test_scada_replay_api.py +++ b/tests/api/test_scada_replay_api.py @@ -55,6 +55,11 @@ def test_capabilities_reports_ready_only_with_five_pressures_and_outlet_flow(rep assert response.status_code == 200 assert response.json()["burst_monitoring_ready"] is True assert response.json()["burst_monitoring_reason"] is None + rules = response.json()["monitoring_config"] + assert rules["decision_policy"] == "weighted" + assert rules["weights"] == {"burst_detection": 0.5, "pressure": 0.4, "outlet_flow": 0.1} + assert rules["alert_threshold"] == 0.8 + assert rules["pressure_device_ids"] == ["P1", "P2", "P3", "P4", "P5"] def test_capabilities_reports_only_missing_storage_not_worker(): @@ -71,7 +76,17 @@ def test_capabilities_reports_only_missing_storage_not_worker(): assert result.reason == "需先部署 SCADA 回放存储结构" -def test_step_command_processes_pending_period_in_same_request(monkeypatch): +def test_weighted_capabilities_allow_flow_as_optional_support(replay_api): + client, project, *_ = replay_api + monitoring = {"pressure_device_ids": ["P1", "P2", "P3", "P4", "P5"]} + project.settings = {"scada_monitoring": monitoring} + assert client.get("/scada-replays/capabilities").json()["burst_monitoring_ready"] is True + monitoring["decision_policy"] = "all_signals" + assert client.get("/scada-replays/capabilities").json()["burst_monitoring_ready"] is False + + +@pytest.mark.parametrize("action", ["step", "monitor"]) +def test_monitoring_command_processes_pending_period_in_same_request(monkeypatch, action): calls = [] class Service: @@ -90,7 +105,8 @@ def test_step_command_processes_pending_period_in_same_request(monkeypatch): replay_id = uuid4() context = ProjectContext(uuid4(), "demo", uuid4(), "member") command = scada_replay.ScadaReplayCommand( - request_id=uuid4(), expected_revision=1, action="step" + request_id=uuid4(), expected_revision=1, action=action, + **({"at_time": "2026-07-16T00:00:00Z"} if action == "monitor" else {}), ) result = asyncio.run(scada_replay.replay_command( @@ -98,7 +114,7 @@ def test_step_command_processes_pending_period_in_same_request(monkeypatch): )) assert result == "completed-period" - assert calls[-2:] == [(replay_id, "step"), (replay_id, "processed")] + assert calls[-2:] == [(replay_id, action), (replay_id, "processed")] def test_client_cannot_override_project_frequency_on_creation(replay_api): diff --git a/tests/fixtures/server_handoff_app.py b/tests/fixtures/server_handoff_app.py new file mode 100644 index 0000000..67a5617 --- /dev/null +++ b/tests/fixtures/server_handoff_app.py @@ -0,0 +1,60 @@ +"""Spawn-safe HTTP/SSE fixture, controlled only through a test-owned directory.""" +import asyncio +from contextlib import asynccontextmanager +import json +import os +from pathlib import Path +import time + +from fastapi import FastAPI +from starlette.responses import StreamingResponse + + +@asynccontextmanager +async def lifespan(app: FastAPI): + directory = Path(os.environ["SCADA_HANDOFF_FIXTURE_DIR"]) + pid = os.getpid() + app.state.directory = directory + app.state.version = (directory / "version").read_text().strip() + mode = (directory / "startup-mode").read_text().strip() + (directory / f"started-{pid}").write_text(app.state.version) + if mode == "fail": + raise RuntimeError("test candidate startup failed") + if mode == "delay": + await asyncio.sleep(0.3) + while mode == "wait" and not (directory / "allow-startup").exists(): + await asyncio.sleep(0.02) + try: + yield + finally: + (directory / f"stopped-{pid}").write_text(app.state.version) + + +app = FastAPI(lifespan=lifespan) + + +async def readiness_check() -> bool: + directory = Path(os.environ["SCADA_HANDOFF_FIXTURE_DIR"]) + ready = (directory / "readiness").read_text().strip() == "true" + (directory / f"checked-{os.getpid()}").write_text(str(ready).lower()) + await asyncio.sleep(.05) + return ready + + +@app.get("/health") +async def health(): + return {"pid": os.getpid(), "version": app.state.version} + + +@app.get("/events") +async def events(): + async def stream(): + directory = app.state.directory + pid = os.getpid() + while not (directory / f"release-{pid}").exists() and not (directory / "release-all").exists(): + payload = {"pid": pid, "version": app.state.version, "time_ns": time.time_ns()} + yield f"data: {json.dumps(payload)}\n\n" + await asyncio.sleep(0.025) + yield f"event: complete\ndata: {json.dumps({'pid': pid})}\n\n" + + return StreamingResponse(stream(), media_type="text/event-stream") diff --git a/tests/integration/test_calculation_queue_storage.py b/tests/integration/test_calculation_queue_storage.py index 2b851d2..3c0ea4d 100644 --- a/tests/integration/test_calculation_queue_storage.py +++ b/tests/integration/test_calculation_queue_storage.py @@ -31,6 +31,7 @@ def test_concurrent_submission_claims_fencing_and_project_isolation(): (legacy_job, uuid4(), owner)) lifecycle = (migrations / "027_calculation_job_lifecycle.sql").read_text() await conn.execute(lifecycle) + await conn.execute((migrations / "030_remove_calculation_queue_expiry.sql").read_text()) cursor = await conn.execute("SELECT result,result_expires_at,result_expires_at>now()+interval '6 days' AS grace FROM workbench.calculation_jobs WHERE job_id=%s", (legacy_job,)) first = await cursor.fetchone() assert first["result"] == {"preserved": True} and first["grace"] @@ -90,15 +91,6 @@ def test_concurrent_submission_claims_fencing_and_project_isolation(): assert cancelled["started_at"] is None assert await repo.claim("replacement", 60) is None - # A polling client expires queued jobs without any worker process. - waiting = await repo.enqueue(owner_user_id=owner, request_key="expired-queue", - tool="run_simulation", parameters={}, binding={}, timeout_seconds=30) - await conn.execute("UPDATE workbench.calculation_jobs SET queue_expires_at=now()-interval '1 second' WHERE job_id=%s", (waiting["job_id"],)) - status = await repo.get(waiting["job_id"], owner) - assert status["status"] == "timed_out" and status["error"]["code"] == "QUEUE_TIMEOUT" - assert status["finished_at"] is not None - assert "result" not in status - # Retention scrubs only payload, keeping deduplication and status. completed = await repo.enqueue(owner_user_id=owner, request_key="retained-identity", tool="run_simulation", parameters={}, binding={}, timeout_seconds=30) diff --git a/tests/unit/test_benchmark_normal_scada.py b/tests/unit/test_benchmark_normal_scada.py new file mode 100644 index 0000000..8bbaac0 --- /dev/null +++ b/tests/unit/test_benchmark_normal_scada.py @@ -0,0 +1,51 @@ +from datetime import datetime, timezone + +import pytest + +from scripts.benchmark.generation import ( + deterministic_standard_normal, + noisy_scada_observation, +) + + +AT = datetime(2026, 5, 1, tzinfo=timezone.utc) + + +def test_noise_is_stable_and_bound_to_observation_identity(): + value = deterministic_standard_normal( + seed="normal-day-v1", at=AT, device_id="P1", metric="pressure", + ) + assert value == deterministic_standard_normal( + seed="normal-day-v1", at=AT, device_id="P1", metric="pressure", + ) + assert value != deterministic_standard_normal( + seed="normal-day-v1", at=AT, device_id="P2", metric="pressure", + ) + + +def test_pressure_keeps_metres_and_flow_converts_lps_to_m3h_before_noise(): + pressure = noisy_scada_observation( + raw_value=20.0, device_type="pressure", at=AT, device_id="P1", + seed="normal-day-v1", pressure_sigma_percent=0, pressure_sigma_floor_m=0, + ) + flow = noisy_scada_observation( + raw_value=10.0, device_type="pipe_flow", at=AT, device_id="F1", + seed="normal-day-v1", flow_sigma_percent=0, + ) + assert pressure == 20.0 + assert flow == 36.0 + + +def test_noise_is_clipped_and_pressure_never_becomes_negative(): + value = noisy_scada_observation( + raw_value=0.01, device_type="pressure", at=AT, device_id="P1", + seed="normal-day-v1", pressure_sigma_percent=0, + pressure_sigma_floor_m=100, clip_sigma=3, + ) + assert value >= 0 + + with pytest.raises(ValueError, match="unsupported"): + noisy_scada_observation( + raw_value=1, device_type="quality", at=AT, device_id="Q1", + seed="normal-day-v1", + ) diff --git a/tests/unit/test_burst_benchmark_calibration.py b/tests/unit/test_burst_benchmark_calibration.py new file mode 100644 index 0000000..80199e1 --- /dev/null +++ b/tests/unit/test_burst_benchmark_calibration.py @@ -0,0 +1,97 @@ +from datetime import datetime, timezone +from types import SimpleNamespace + +import pytest + +from scripts.benchmark.calibration import assess, configure_inp, pressure_preview, select_calibrated_area + + +START = datetime(2026, 10, 8, 12, tzinfo=timezone.utc) +PIPE = SimpleNamespace( + pipe_id="test-pipe", + pressure_bindings=[SimpleNamespace(device_id="P1", node_id="N1"), + SimpleNamespace(device_id="P2", node_id="N2")], + flow_bindings=[SimpleNamespace(device_id="F1", link_id="L1")], +) +RULE = SimpleNamespace( + primary_pressure_drop_percent=30, corroborating_pressure_drop_percent=10, + flow_change_percent=20, consecutive_samples=2, relative_change_epsilon=1e-6, +) + + +def result(p1=(30, 30, 30, 30, 30), p2=(30, 30, 30, 30, 30), flow=(10, 10, 10, 10, 10)): + return {"status": "completed", "warnings": [], + "units": {"flow": "LPS", "pressure": "MTR", "quality": "NONE"}, + "times": {"report_start": 0, "report_step": 900, "sim_duration": 3600, "num_periods": 5}, + "nodes": {"N1": [{"pressure": v} for v in p1], "N2": [{"pressure": v} for v in p2]}, + "links": {"L1": [{"flow": v} for v in flow]}} + + +def check(burst, normal=None): + return assess(PIPE, normal or result(), burst, RULE, duration=3600, step=900) + + +def test_file_configuration_preserves_source_and_matches_burst_service(): + source = "[OPTIONS]\nDEMAND MODEL DDA\nREQUIRED PRESSURE 20\n[TIMES]\nDURATION 0\nPATTERN START 0:00\n[REPORT]\nNODES ALL\nLINKS ALL\n[EMITTERS]\n[END]\n" + configured = configure_inp(source, START, 18000, emitter=("N1", 100), pda_options={"HEADERROR": "0.0001"}) + assert "DEMAND MODEL DDA" in source + assert "DEMAND MODEL DDA" not in configured + assert "DEMAND MODEL PDA" in configured + assert "REQUIRED PRESSURE 10.0000" in configured + assert "HEADERROR 0.0001" in configured + assert "DURATION 5:00:00" in configured + assert "PATTERN START 20:00:00" in configured + coefficient = float(configured.split("[EMITTERS]\nN1 ")[1].splitlines()[0]) + assert coefficient == pytest.approx(28.7767267075) + assert "NODES NONE" in configured and "LINKS NONE" in configured + + +def test_requires_two_consecutive_primary_samples(): + assert not check(result(p1=(20, 30, 20, 30, 20), p2=(25,) * 5))["passed"] + assert check(result(p1=(20, 20, 30, 30, 30), p2=(25,) * 5))["passed"] + + +def test_corroboration_must_be_concurrent_and_a_different_pressure_device(): + assert not check(result(p1=(20, 20, 30, 30, 30)))["passed"] + assert not check(result(p1=(20, 20, 30, 30, 30), p2=(30, 30, 25, 25, 30)))["passed"] + assert check(result(p1=(20,) * 5, flow=(12,) * 5))["passed"] + + +def test_pressure_rise_and_zero_flow_baseline_do_not_supply_false_evidence(): + assert not check(result(p1=(40,) * 5, p2=(25,) * 5, flow=(100,) * 5))["passed"] + assert not check(result(p1=(20,) * 5, flow=(10,) * 5), normal=result(flow=(0,) * 5))["passed"] + + +def test_negative_pressure_or_bad_normal_pressure_invalidates_candidate(): + assert "negative_monitor_pressure:P2" in check(result(p1=(20,) * 5, p2=(-1,) * 5))["blockers"] + assert "nonpositive_normal_pressure:P1" in check(result(p1=(0,) * 5), normal=result(p1=(0,) * 5))["blockers"] + + +@pytest.mark.parametrize("fault", ["warning", "missing_period", "unit"]) +def test_solver_warning_incomplete_time_grid_and_wrong_unit_fail(fault): + burst = result(p1=(20,) * 5, p2=(25,) * 5) + if fault == "warning": + burst["warnings"] = ["WARNING: System unbalanced"] + elif fault == "missing_period": + burst["times"]["num_periods"] = 4 + else: + burst["units"]["flow"] = "CMH" + assert not check(burst)["passed"] + + +def test_actual_replay_rule_uses_warmup_and_reports_first_signal_time(): + history = {"N1": [{"pressure": 30}] * 4, "N2": [{"pressure": 30}] * 4} + normal = pressure_preview(PIPE, START, 3600, 900, history, result()["nodes"], RULE) + event = pressure_preview(PIPE, START, 3600, 900, history, result(p1=(20,) * 5)["nodes"], RULE) + assert not normal["raised"] + assert event["raised"] + assert event["first_signal_at"] == "2026-10-08T12:15:00+00:00" + + +def test_valid_burst_with_a_weak_signal_is_retained_separately_from_invalid_physics(): + pipe = SimpleNamespace(candidate_orifice_areas_cm2={"light": 1, "medium": 2, "severe": 4}) + weak = [{"blockers": ["event_rule_did_not_trigger", "insufficient_consecutive_pressure_and_corroboration"]}] + assert select_calibrated_area(pipe, [], weak, True)["status"] == "weak_signal_candidate" + assert select_calibrated_area(pipe, [], weak, False)["status"] == "unsuitable" + assert select_calibrated_area(pipe, [], [{"blockers": ["burst_solver_warning"]}], True)["status"] == "unsuitable" + assert select_calibrated_area(pipe, ["severe", "medium"], weak, True)["area_cm2"] == 2 diff --git a/tests/unit/test_burst_location_demo.py b/tests/unit/test_burst_location_demo.py new file mode 100644 index 0000000..c0f5982 --- /dev/null +++ b/tests/unit/test_burst_location_demo.py @@ -0,0 +1,225 @@ +import asyncio +from datetime import timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from app.mcp import calculation_jobs, query_service +from app.mcp.analysis_models import BurstLocationInput +from app.mcp.catalog import TOOLS +from app.services import burst_location_demo as demo + + +def arguments(**changes): + return BurstLocationInput.model_validate({ + "context_handle": "x" * 43, + "burst_leakage": 0.02, + "scada_burst_start": demo.EVENT_START - timedelta(minutes=15), + "scada_burst_end": demo.EVENT_START, + **changes, + }) + + +def topology(): + def link(key, kind, a, b, length=0.0, diameter=None): + return {"id": key, "kind": kind, "start_node_id": a, "end_node_id": b, + "length": length, "diameter": diameter, "status": "OPEN"} + return [ + link(demo.REFERENCE_PIPE, "pipe", "170438", "J1", 350, 600), + link(demo.CANDIDATE_PIPE, "pipe", "J1", "J2", 30, 600), + link("V1", "valve", "S", "170438"), + link("V2", "valve", "J2", "S"), + {"id": "S", "kind": "reservoir", "start_node_id": None}, + ] + + +def test_candidate_is_on_same_line_within_distance_and_identical_valve_boundary(): + result = demo._checked_candidate(topology()) + assert result["pipe_id"] == demo.CANDIDATE_PIPE + assert result["distance_m"] == 350 + assert result["boundary_valve_ids"] == ["V1", "V2"] + assert result["within_same_isolation_zone"] is True + + +@pytest.mark.parametrize("defect", ["outside_zone", "different_line", "too_close", "too_far", + "closed_line", "unisolatable", "missing_pipe"]) +def test_unsuitable_candidate_is_never_returned(defect): + rows = topology() + if defect == "outside_zone": + rows[1]["end_node_id"] = "S" + elif defect == "different_line": + rows[1]["diameter"] = 150 + elif defect == "too_close": + rows[0]["length"] = 199 + elif defect == "too_far": + rows[0]["length"] = 501 + elif defect == "closed_line": + rows[0]["status"] = "CLOSED" + elif defect == "unisolatable": + rows[2]["kind"] = "pipe" + else: + rows.pop(1) + assert demo._checked_candidate(rows) is None + + +@pytest.mark.parametrize("project,changes,enabled", [ + ("tjwater2_v2", {}, True), + ("tjw_bench_tjwater2_20261105", {}, True), + ("tjwater_v2", {}, False), + (None, {}, False), + ("tjwater2_v2", {"scada_burst_start": demo.EVENT_START - timedelta(minutes=30), + "scada_burst_end": demo.EVENT_START - timedelta(minutes=15)}, False), + ("tjwater2_v2", {"scada_burst_end": demo.EVENT_END + timedelta(minutes=15)}, False), +]) +def test_only_case3_demo_window_skips_native_calculation_queue(project, changes, enabled): + args = arguments(**changes) + assert demo.is_location_demo(project, args) is enabled + assert calculation_jobs.should_queue(TOOLS["run_burst_location"], args, project_code=project) is not enabled + + +def test_candidate_has_stable_similarity_without_presentation_blockers(monkeypatch): + load = AsyncMock(return_value=topology()) + monkeypatch.setattr(demo.NetworkAssetRepository, "get_isolation_model", load) + result = asyncio.run(demo.location_demo_result(object(), "tjwater2_v2", arguments())) + assert result["located_pipe"] == demo.CANDIDATE_PIPE + assert result["top_candidates"] == [{"pipe_id": demo.CANDIDATE_PIPE, "similarity": 0.9708, + "similarity_percent": 97.08}] + assert not {"mock", "notice", "simulation_times", "similarity_mode", "observed_source"} & result.keys() + assert demo.REFERENCE_PIPE not in str(result) and demo.REFERENCE_NODE not in str(result) + load.return_value = list(reversed(topology())) + later = asyncio.run(demo.location_demo_result(object(), "tjwater2_v2", arguments( + scada_burst_start=demo.EVENT_START, + scada_burst_end=demo.EVENT_START + timedelta(minutes=15), + ))) + assert later == result + load.return_value = [] + empty = asyncio.run(demo.location_demo_result(object(), "tjwater2_v2", arguments())) + assert empty["located_pipe"] is None and empty["top_candidates"] == [] + assert empty["exit_condition"] == "no_candidate" + assert "关阀范围" in empty["notice"] + + +def test_mcp_demo_returns_synthetic_result_without_simulation_or_queue(monkeypatch): + monkeypatch.setattr(query_service, "authorize_query", AsyncMock()) + monkeypatch.setattr(demo.NetworkAssetRepository, "get_isolation_model", AsyncMock(return_value=topology())) + scope = SimpleNamespace(connection=object(), project=SimpleNamespace(project_code="tjwater2_v2")) + binding = SimpleNamespace(replay_context=None) + result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["run_burst_location"], arguments())) + assert result.origin == "synthetic" + assert result.data["result"]["located_pipe"] == demo.CANDIDATE_PIPE + assert "mock" not in result.data["result"] + assert result.data["result"]["top_candidates"][0]["similarity_percent"] == 97.08 + assert result.data_gaps == [] + + +def test_missing_candidate_preserves_a_specific_data_gap(monkeypatch): + monkeypatch.setattr(query_service, "authorize_query", AsyncMock()) + monkeypatch.setattr(demo.NetworkAssetRepository, "get_isolation_model", AsyncMock(return_value=[])) + scope = SimpleNamespace(connection=object(), project=SimpleNamespace(project_code="tjwater2_v2")) + binding = SimpleNamespace(replay_context=None) + result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["run_burst_location"], arguments())) + assert result.data["result"]["top_candidates"] == [] + assert result.data_gaps == [result.data["result"]["notice"]] + assert "关阀范围" in result.data_gaps[0] + + +def test_location_capability_and_tool_contract_do_not_inject_mock_instructions(): + context = demo.location_demo_context("tjwater2_v2", {"knowledge_cutoff": demo.EVENT_START.isoformat()}) + assert context["available"] is True + assert "相似度" in context["notice"] and "影响分析" in context["notice"] + assert "mock" not in str(context) and "模拟" not in context["notice"] + tool = TOOLS["run_burst_location"].protocol_tool() + assert "相似度" in tool.description + assert "demo模拟候选" not in tool.description + assert "明确标记的演示候选" not in str(tool.input_schema) + + +def test_replay_mock_still_validates_frozen_inputs_before_returning(monkeypatch): + from fastapi import HTTPException + from app.services import scada_replay_queries + + monkeypatch.setattr(query_service, "authorize_query", AsyncMock()) + freeze = AsyncMock(side_effect=HTTPException(422, "frozen baseline missing")) + monkeypatch.setattr(scada_replay_queries, "replay_burst_location_inputs", freeze) + load = AsyncMock() + monkeypatch.setattr(demo.NetworkAssetRepository, "get_isolation_model", load) + scope = SimpleNamespace(connection=object(), project=SimpleNamespace( + project_id="p", project_code="tjwater2_v2")) + binding = SimpleNamespace(replay_context={"replay_id": "r"}) + with pytest.raises(HTTPException): + asyncio.run(query_service.execute_query(scope, binding, TOOLS["run_burst_location"], arguments())) + load.assert_not_awaited() + + +def test_replay_mock_returns_after_frozen_validation_without_native_calculation(monkeypatch): + from app.mcp import analysis_service + from app.services import scada_replay_queries + + monkeypatch.setattr(query_service, "authorize_query", AsyncMock()) + freeze = AsyncMock(return_value={"burst_pressure": {"P1": 20}, "normal_pressure": {"P1": 30}}) + monkeypatch.setattr(scada_replay_queries, "replay_burst_location_inputs", freeze) + native = AsyncMock(side_effect=AssertionError("mock entered native location")) + monkeypatch.setattr(analysis_service, "calculate_replay_burst_location", native) + monkeypatch.setattr(demo.NetworkAssetRepository, "get_isolation_model", AsyncMock(return_value=topology())) + scope = SimpleNamespace(connection=SimpleNamespace(commit=AsyncMock()), + project=SimpleNamespace(project_id="p", project_code="tjwater2_v2")) + binding = SimpleNamespace(replay_context={"replay_id": "r"}) + result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["run_burst_location"], arguments())) + assert result.origin == "synthetic" + assert result.data["result"]["top_candidates"][0]["similarity"] == 0.9708 + assert "mock" not in result.data["result"] + assert result.data_gaps == [] + freeze.assert_awaited_once() + native.assert_not_awaited() + + +def test_replay_demo_location_does_not_require_invented_leakage(monkeypatch): + from app.mcp import analysis_service + from app.services import scada_replay_queries + + permissions = {"burst.run", "scada.view"} + monkeypatch.setattr(query_service, "permissions_for_context", lambda _: permissions) + args = arguments(burst_leakage=None, + scada_normal_start=demo.EVENT_START - timedelta(days=1, minutes=15), + scada_normal_end=demo.EVENT_START - timedelta(days=1)) + context = {"replay_id": "r", "knowledge_cutoff": demo.EVENT_START.isoformat(), "batch_sequence": 3} + binding = SimpleNamespace(replay_context=context, tool_cap=["run_burst_location"], + permission_cap=permissions, purpose="task_execution", task_id="t") + scope = SimpleNamespace(connection=SimpleNamespace(commit=AsyncMock()), + project=SimpleNamespace(project_id="p", project_code="tjwater2_v2")) + freeze = AsyncMock(return_value={"burst_pressure": {"J1": 20}, "normal_pressure": {"J1": 30}}) + monkeypatch.setattr(scada_replay_queries, "replay_burst_location_inputs", freeze) + monkeypatch.setattr(demo.NetworkAssetRepository, "get_isolation_model", AsyncMock(return_value=topology())) + native = AsyncMock(side_effect=AssertionError("demo entered hydraulic calculation")) + monkeypatch.setattr(analysis_service, "calculate_replay_burst_location", native) + + result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["run_burst_location"], args)) + + assert result.data["result"]["located_pipe"] == demo.CANDIDATE_PIPE + assert result.data["result"]["burst_leakage"] is None + assert result.origin == "synthetic" + assert result.data["result"]["top_candidates"][0]["similarity_percent"] == 97.08 + assert "mock" not in result.data["result"] + freeze.assert_awaited_once_with(scope.connection, "p", context, args) + native.assert_not_awaited() + + +@pytest.mark.parametrize("project,changes", [ + ("tjwater_v2", {}), + ("tjwater2_v2", {"scada_burst_end": demo.EVENT_END + timedelta(minutes=15)}), + ("tjwater2_v2", {"data_source": "simulation", "simulation_run_id": "8bfa5a5b-dbba-461a-88d4-34942bc66034"}), +]) +def test_real_location_still_requires_leakage(monkeypatch, project, changes): + from fastapi import HTTPException + + args = arguments(burst_leakage=None, **changes) + permissions = {"burst.run", "scada.view", "simulation.view"} + monkeypatch.setattr(query_service, "permissions_for_context", lambda _: permissions) + binding = SimpleNamespace(replay_context=None, tool_cap=["run_burst_location"], + permission_cap=permissions, purpose="ordinary_chat", task_id=None) + with pytest.raises(HTTPException) as caught: + asyncio.run(query_service.authorize_query(SimpleNamespace( + project=SimpleNamespace(project_code=project)), binding, TOOLS["run_burst_location"], args)) + assert caught.value.status_code == 422 + assert "burst_leakage" in caught.value.detail diff --git a/tests/unit/test_burst_pressure_thresholds.py b/tests/unit/test_burst_pressure_thresholds.py new file mode 100644 index 0000000..2015739 --- /dev/null +++ b/tests/unit/test_burst_pressure_thresholds.py @@ -0,0 +1,68 @@ +from datetime import datetime, timedelta, timezone + +import pytest + +from scripts.benchmark.pressure_thresholds import evaluate_pressure_thresholds, learn_pressure_thresholds + + +CUTOFF = datetime(2026, 10, 8, 12, tzinfo=timezone.utc) + + +def history(device, noisy=False): + start = CUTOFF - timedelta(days=15) + return [{"device_id": device, "time": start + timedelta(minutes=15 * index), + "monitored_value": 30 + (index % 96) / 20 + ((index // 96 % 3) - 1) * (4 if noisy else 0.1)} + for index in range(1440)] + + +def test_thresholds_follow_device_noise_after_removing_daily_pattern(): + result = learn_pressure_thresholds(history("quiet") + history("noisy", True), ["quiet", "noisy"], CUTOFF) + assert result["status"] == "ready" + assert result["devices"]["quiet"]["relative_drop_percent"] == 3 + assert result["devices"]["noisy"]["relative_drop_percent"] > 30 + assert result["devices"]["quiet"]["absolute_drop_m"] == 0.5 + + +def test_short_warmup_is_not_treated_as_a_fitted_adaptive_rule(): + rows = history("P")[-4:] + result = learn_pressure_thresholds(rows, ["P"], CUTOFF) + assert result["status"] == "history_not_ready" + assert result["required_samples_per_device"] == 1440 + assert result["available_samples"] == {"P": 4} + + +def test_event_or_future_reading_is_rejected(): + with pytest.raises(ValueError, match="future"): + learn_pressure_thresholds(history("P") + [{"device_id": "P", "time": CUTOFF, "monitored_value": 10}], ["P"], CUTOFF) + + +def test_missing_grid_point_prevents_fitting(): + assert learn_pressure_thresholds(history("P")[:-1], ["P"], CUTOFF)["status"] == "history_not_ready" + + +@pytest.mark.parametrize("value", [0, -1, float("nan"), float("inf")]) +def test_invalid_pressure_samples_are_rejected(value): + rows = history("P") + rows[0]["monitored_value"] = value + with pytest.raises(ValueError, match="positive finite"): + learn_pressure_thresholds(rows, ["P"], CUTOFF) + + +def test_normal_daily_pattern_is_not_confused_with_a_burst(): + fitted = learn_pressure_thresholds(history("P"), ["P"], CUTOFF) + normal = [{"device_id": "P", "time": CUTOFF + timedelta(minutes=15 * i), + "monitored_value": 30 + i / 20} for i in range(20)] + assert not evaluate_pressure_thresholds(normal, fitted)["raised"] + burst = [{**row, "monitored_value": row["monitored_value"] - 3} for row in normal] + event = evaluate_pressure_thresholds(burst, fitted) + assert event["raised"] + assert event["signals"][0]["first_signal_at"] == (CUTOFF + timedelta(minutes=15)).isoformat() + + +def test_absolute_floor_and_consecutive_sample_gap_are_enforced(): + baseline = [{**row, "monitored_value": 5} for row in history("P")] + fitted = learn_pressure_thresholds(baseline, ["P"], CUTOFF) + small = [{"device_id": "P", "time": CUTOFF + timedelta(minutes=15 * i), "monitored_value": 4.8} for i in range(2)] + assert not evaluate_pressure_thresholds(small, fitted)["raised"] + separated = [{"device_id": "P", "time": CUTOFF + timedelta(minutes=30 * i), "monitored_value": 4} for i in range(2)] + assert not evaluate_pressure_thresholds(separated, fitted)["raised"] diff --git a/tests/unit/test_calculation_queue_lifecycle.py b/tests/unit/test_calculation_queue_lifecycle.py index aa1de3a..6180089 100644 --- a/tests/unit/test_calculation_queue_lifecycle.py +++ b/tests/unit/test_calculation_queue_lifecycle.py @@ -33,13 +33,12 @@ class Connection: return Cursor() -def test_status_projection_excludes_result_and_expires_without_worker(): +def test_status_projection_excludes_result_and_scrubs_expired_payloads(): connection = Connection() repo = CalculationJobRepository(connection, 'project') asyncio.run(repo.get('job')) - assert 'QUEUE_TIMEOUT' in connection.statements[0][0] - assert 'SET result=NULL' in connection.statements[1][0] - assert ',result FROM' not in connection.statements[2][0] + assert 'SET result=NULL' in connection.statements[0][0] + assert ',result FROM' not in connection.statements[1][0] asyncio.run(repo.get('job', include_result=True)) assert ',result FROM' in connection.statements[-1][0] @@ -55,6 +54,15 @@ def test_duplicate_submission_bypasses_full_queue_inside_transaction(): assert not any('count(*)' in sql for sql, _, _ in connection.statements) +def test_new_jobs_have_no_queue_expiry_contract(): + connection = Connection([None, {'active': 0}, {'job_id': 'new'}]) + asyncio.run(CalculationJobRepository(connection, 'project').enqueue( + owner_user_id='owner', request_key='key', tool='tool', parameters={}, + binding={}, timeout_seconds=30)) + insert = next(sql for sql, _, _ in connection.statements if 'INSERT INTO' in sql) + assert 'queue_expires_at' not in insert + + def test_capacity_rejects_new_job_before_insert(): connection = Connection([None, {'active': 100}]) with pytest.raises(CalculationCapacityExceeded): diff --git a/tests/unit/test_calculation_worker.py b/tests/unit/test_calculation_worker.py index 90e3e29..4caa43c 100644 --- a/tests/unit/test_calculation_worker.py +++ b/tests/unit/test_calculation_worker.py @@ -27,8 +27,7 @@ def run_async(function): return wrapper -def test_worker_settings_require_explicit_valid_scope(monkeypatch): - monkeypatch.setenv('CALCULATION_WORKER_PROJECT_IDS', '6b39b128-2ba8-454f-9bbe-e9553902ab53') +def test_worker_settings_require_valid_concurrency(monkeypatch): settings = WorkerSettings.from_environment() assert settings.max_concurrency == 2 monkeypatch.setenv('CALCULATION_WORKER_HEARTBEAT_SECONDS', '30') @@ -85,7 +84,7 @@ async def test_cancel_and_lost_lease_stop_process_before_persisting(monkeypatch, @asynccontextmanager async def repository(project): yield Repository() - worker = CalculationWorker(WorkerSettings(('project',), heartbeat_seconds=.05), repository=repository) + worker = CalculationWorker(WorkerSettings(heartbeat_seconds=.05), repository=repository) await worker.run_job({'project_id': 'project', 'job_id': 'job', 'timeout_seconds': 60}) assert finishes == ([expected] if expected else []) assert processes[0].returncode is not None @@ -111,7 +110,7 @@ async def test_timeout_stops_process_before_recording(monkeypatch): @asynccontextmanager async def repository(project): yield Repository() - worker = CalculationWorker(WorkerSettings(('project',), heartbeat_seconds=.02), repository=repository) + worker = CalculationWorker(WorkerSettings(heartbeat_seconds=.02), repository=repository) await worker.run_job({'project_id': 'project', 'timeout_seconds': 0}) assert statuses == ['timed_out'] @@ -134,7 +133,52 @@ async def test_duplicate_supervisor_never_claims(monkeypatch): pytest.fail('A duplicate supervisor must never open the queue') yield with pytest.raises(RuntimeError, match='already active'): - await CalculationWorker(WorkerSettings(('project',)), repository=repository).run() + await CalculationWorker(WorkerSettings(), repository=repository).run() + + +@run_async +async def test_unavailable_project_does_not_block_other_projects(monkeypatch): + from app.infra.db import calculation_worker as infrastructure + + class Lease: + async def ping(self): + pass + + @asynccontextmanager + async def supervisor_lease(): + yield Lease() + + checked = asyncio.Event() + + class Repository: + async def recover(self): + pass + + async def claim(self, owner, lease_seconds): + checked.set() + return None + + @asynccontextmanager + async def repository(project): + if project == 'unavailable': + raise RuntimeError('offline') + yield Repository() + + async def projects(): + return ('unavailable', 'healthy') + + monkeypatch.setattr(infrastructure, 'calculation_supervisor_lease', supervisor_lease) + worker = CalculationWorker( + WorkerSettings(poll_seconds=.01, lease_seconds=1, heartbeat_seconds=.1), + repository=repository, + project_provider=projects, + ) + task = asyncio.create_task(worker.run()) + try: + await asyncio.wait_for(checked.wait(), 2) + finally: + task.cancel() + await asyncio.gather(task, return_exceptions=True) @run_async @@ -212,7 +256,7 @@ async def test_cleanup_failure_is_not_reported_cancelled(monkeypatch, tmp_path): @asynccontextmanager async def repository(project): yield Repository() - worker = CalculationWorker(WorkerSettings(('project',)), repository=repository) + worker = CalculationWorker(WorkerSettings(), repository=repository) await worker.run_job({'project_id': 'project', 'timeout_seconds': 60}) assert results == [('failed', 'CLEANUP_FAILED')] assert len(list(tmp_path.glob('*/cleanup-failed'))) == 1 diff --git a/tests/unit/test_chart_sample_times.py b/tests/unit/test_chart_sample_times.py new file mode 100644 index 0000000..f3ccdc0 --- /dev/null +++ b/tests/unit/test_chart_sample_times.py @@ -0,0 +1,57 @@ +"""The same timestamp matrix as the Frontend and Agent contract tests.""" +import pytest +from pydantic import ValidationError + +from app.domain.schemas.answer_ui import AnswerUiBlock +from app.domain.schemas.operational_task import TaskSafeChart + + +CASES = [ + ("2026-06-14T12:00:00Z", True), ("2026-06-14T12:00:00+08:00", True), + ("2026-06-14T12:00:00-03:30", True), ("2024-02-29T23:59:59.123456Z", True), + ("2026-06-14T12:00:00+23:59", True), ("2026-06-14T12:00:00." + "1" * 43 + "Z", True), + ("2026-06-14 12:00:00+08:00", False), ("2026-W24-7T12:00:00+08:00", False), + ("2026-06-14T12:00+08:00", False), ("2026-06-14T12:00:00+0800", False), + ("2026-02-30T12:00:00Z", False), ("2026-13-14T12:00:00Z", False), + ("2026-06-00T12:00:00Z", False), ("2026-02-29T12:00:00Z", False), + ("2026-06-14T24:00:00Z", False), ("2026-06-14T12:00:60Z", False), + ("2026-06-14T12:00:00+24:00", False), ("2026-06-14T12:00:00+08:60", False), + ("2026-06-14T12:00:00", False), ("2026-06-14T12:00:00z", False), + ("0000-06-14T12:00:00Z", False), ("2026-06-14T12:00:00." + "1" * 44 + "Z", False), +] + + +def chart(data, times): + return {"title": "压力", "chart_type": "line", "x_data": ["12:00"], + "series": [{"name": "测点", "data": data, "sample_times": times}]} + + +def block(value): + return {"version": "answer-ui@1", "block_id": "pressure", "fallback_text": "压力数据", + "spec": {"root": "chart", "elements": {"chart": {"type": "Chart", "children": [], + "props": {**value, "source": "实测"}}}}} + + +@pytest.mark.parametrize("time,accepted", CASES) +def test_sample_times_match_on_chart_commands_and_answer_blocks(time, accepted): + value = chart([1], [time]) + for model, payload in [(TaskSafeChart, value), (AnswerUiBlock, block(value))]: + if accepted: + model.model_validate(payload) + else: + with pytest.raises(ValidationError): + model.model_validate(payload) + + +@pytest.mark.parametrize("data,times,accepted", [ + ([0], [None], True), ([None], [None], True), + ([None], ["2026-06-14T12:00:00Z"], False), ([1], [], False), +]) +def test_sample_times_keep_zero_null_and_alignment(data, times, accepted): + value = chart(data, times) + for model, payload in [(TaskSafeChart, value), (AnswerUiBlock, block(value))]: + if accepted: + model.model_validate(payload) + else: + with pytest.raises(ValidationError): + model.model_validate(payload) diff --git a/tests/unit/test_mcp_display_time.py b/tests/unit/test_mcp_display_time.py new file mode 100644 index 0000000..7546f98 --- /dev/null +++ b/tests/unit/test_mcp_display_time.py @@ -0,0 +1,40 @@ +from copy import deepcopy +from datetime import datetime + +import pytest + +from app.mcp.display_time import display_query_result, display_time_text + + +@pytest.mark.parametrize("original,displayed", [ + ("2026-07-16T00:00:00Z", "2026-07-16T08:00:00+08:00"), + ("2026-07-16T08:00:00+08:00", "2026-07-16T08:00:00+08:00"), + ("2026-12-31T16:00:00.123456789Z", "2027-01-01T00:00:00.123456789+08:00"), + ("2026-12-31T23:00:00-03:30", "2027-01-01T10:30:00+08:00"), +]) +def test_display_keeps_the_instant_precision_and_is_idempotent(original, displayed): + data = {"time": original, "sample_times": [None, original], "value": 0} + snapshot = deepcopy(data) + result = display_query_result(data) + assert result["time"] == displayed + assert result["sample_times"] == [None, displayed] + assert datetime.fromisoformat(original) == datetime.fromisoformat(displayed) + assert display_query_result(result) == result + assert data == snapshot + + +def test_metadata_identifiers_links_code_and_selected_ids_keep_their_spelling(): + original = "2026-07-16T00:00:00Z" + fields = {key: original for key in ("id", "evidence_id", "source_ref", "next_cursor", "resource_uri", "relative_path", "instructions")} + assert display_query_result(fields) == fields + opaque_page = {"selected_path": ["source", "external_id"], "value": original} + assert display_query_result(opaque_page) == opaque_page + time_page = {"selected_path": ["source", "replay_context", "knowledge_cutoff"], "value": original} + assert display_query_result(time_page)["value"] == "2026-07-16T08:00:00+08:00" + text = f"观测{original};`{original}`;https://example.test/{original};id_{original}" + assert display_time_text(text) == text.replace("观测" + original, "观测2026-07-16T08:00:00+08:00") + + +@pytest.mark.parametrize("value", ["2026-07-16 08:00", "2026-07-16T08:00:00", "2026-02-30T00:00:00Z", "未记录"]) +def test_unzoned_or_invalid_values_keep_their_explicit_source(value): + assert display_query_result({"time": value}) == {"time": value} diff --git a/tests/unit/test_mcp_query.py b/tests/unit/test_mcp_query.py index 129c3ba..215a9c9 100644 --- a/tests/unit/test_mcp_query.py +++ b/tests/unit/test_mcp_query.py @@ -1,5 +1,6 @@ import asyncio import base64 +import json import time from contextlib import asynccontextmanager from datetime import datetime, timedelta, timezone @@ -72,6 +73,57 @@ def test_calculation_limit_is_advertised_and_enforced_before_execution(runtime): assert calls == [] +def test_case3_location_mock_skips_queue_and_preserves_evidence_and_receipt(runtime, monkeypatch): + from app.mcp import calculation_jobs + + app, client, _, _ = runtime + original_scope = app.scope_factory + + @asynccontextmanager + async def demo_scope(identity, project): + async with original_scope(identity, project) as scope: + scope.project.project_code = "tjwater2_v2" + yield scope + + app.scope_factory = demo_scope + monkeypatch.setattr(application, "authorize_query", AsyncMock()) + monkeypatch.setattr(contexts, "verify_task", AsyncMock()) + monkeypatch.setattr(application, "verify_task", AsyncMock()) + submit = AsyncMock(side_effect=AssertionError("demo entered calculation queue")) + monkeypatch.setattr(calculation_jobs, "submit_calculation", submit) + app.executor = AsyncMock(return_value=QueryResult( + source="run_burst_location", source_ref="demo", queried_at=datetime.now(timezone.utc), + semantics="analysis", origin="synthetic", data={"result": {"located_pipe": "515870", + "top_candidates": [{"pipe_id": "515870", "similarity": 0.9708, "similarity_percent": 97.08}]}}, + )) + saved = [] + + class TaskService: + repo = SimpleNamespace(lock=lambda key: asyncio.sleep(0)) + + async def record_data_snapshot(self, task_id, snapshot): + saved.append(snapshot) + return SimpleNamespace(evidence_id=snapshot.source_ref), snapshot + + monkeypatch.setattr(application, "task_service", lambda scope: TaskService()) + value = binding(task=True) + query = {"name": "run_burst_location", "arguments": { + "context_handle": value.context_handle, + "scada_burst_start": "2026-07-16T07:45:00+08:00", + "scada_burst_end": "2026-07-16T08:00:00+08:00", + }} + first = request(client, "tools/call", query).json()["result"]["structuredContent"] + second = request(client, "tools/call", query).json()["result"]["structuredContent"] + assert first == second + assert first["origin"] == "synthetic" + assert first["data"]["result"]["top_candidates"][0]["similarity_percent"] == 97.08 + assert "mock" not in first["data"]["result"] + assert first["evidence_id"] == saved[0].source_ref + assert len(saved) == app.executor.await_count == 1 + assert app.executor.await_args.args[3].burst_leakage is None + submit.assert_not_awaited() + + def test_queue_submission_errors_preserve_mcp_error_mapping(runtime, monkeypatch): from app.mcp import calculation_jobs _, client, _, _ = runtime @@ -321,6 +373,45 @@ 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())) +def test_model_response_displays_utc8_without_changing_query_instants_or_source(runtime): + app, client, _, _ = runtime + at = datetime(2026, 12, 31, 16, tzinfo=timezone.utc) + original = QueryResult(source="query_scada_readings", source_ref="saved:utc", queried_at=at, + semantics="observed", data=[{"device_id": "P1", "time": "2026-12-31T16:00:00.123456789Z", "value": 20}], + window={"start": at - timedelta(minutes=15), "end": at}) + snapshot = original.model_dump(mode="json") + app.executor = AsyncMock(return_value=original) + result = request(client, "tools/call", {"name": "query_scada_readings", "arguments": { + "context_handle": binding().context_handle, "device_ids": ["P1"], + "start": "2026-12-31T15:45:00Z", "end": "2026-12-31T16:00:00Z", + }}).json()["result"] + output = result["structuredContent"] + assert output["data"][0]["time"] == "2027-01-01T00:00:00.123456789+08:00" + assert output["display_time_zone"] == "Asia/Shanghai" + assert output["window"]["end"] == "2027-01-01T00:00:00+08:00" + assert json.loads(result["content"][0]["text"]) == output + assert app.executor.await_args.args[3].end == at + assert original.model_dump(mode="json") == snapshot + + +@pytest.mark.parametrize("tool_name", ["get_element_properties", "list_elements", "query_nearby_elements", + "get_network_options", "get_node_connections", "get_element_gis_properties", + "query_network_extent", "list_regions", "get_region", "get_region_nodes", + "analyze_valve_isolation", "generate_node_coverage"]) +def test_replay_allows_asset_and_topology_analysis_without_live_telemetry(monkeypatch, tool_name): + bound = binding(task=True) + bound.replay_context = {"replay_id": str(uuid4()), "knowledge_cutoff": "2026-10-08T01:00:00Z", "batch_sequence": 1} + permissions = PERMISSIONS | {"simulation.run"} + bound.permission_cap = sorted(permissions) + monkeypatch.setattr(query_service, "permissions_for_context", lambda _: permissions) + scope = SimpleNamespace(project=SimpleNamespace(project_role="member")) + asyncio.run(query_service.authorize_query(scope, bound, TOOLS[tool_name], SimpleNamespace())) + bound.permission_cap = [] + with pytest.raises(HTTPException) as caught: + asyncio.run(query_service.authorize_query(scope, bound, TOOLS[tool_name], SimpleNamespace())) + assert caught.value.status_code == 403 + + @pytest.mark.parametrize("seconds", [30, 120, 240]) def test_all_tools_advertise_the_effective_host_request_budget(runtime, seconds): app, client, _, _ = runtime @@ -600,6 +691,89 @@ def test_large_node_coverage_retains_publishable_hint_and_saves_complete_geometr assert saved[0].data["data"]["result"]["map_payload"] == geometry +@pytest.mark.parametrize("task", [False, True]) +@pytest.mark.parametrize("wide_ids", [False, True]) +def test_scada_catalog_keeps_all_devices_or_full_evidence_within_budget(runtime, monkeypatch, task, wide_ids): + app, client, _, _ = runtime + value = binding(task=task) + saved = [] + monkeypatch.setattr(contexts, "verify_task", AsyncMock()) + monkeypatch.setattr(application, "verify_task", AsyncMock()) + + class TaskService: + repo = SimpleNamespace(lock=lambda key: asyncio.sleep(0)) + + async def record_data_snapshot(self, task_id, snapshot): + saved.append(snapshot) + return SimpleNamespace(evidence_id=snapshot.source_ref), snapshot + + monkeypatch.setattr(application, "task_service", lambda scope: TaskService()) + devices = [{ + "device_id": ("d" * 150 if wide_ids else "") + str(100000 + index), + "device_type": "pressure", "measurement_unit": "m", + "node_id": str(index), "link_id": None, "longitude": 121.5, "latitude": 31.0, + "transmission_mode": "non_realtime", "transmission_frequency": "0:15:00", + "reliability": 1, "api_query_id": str(index), "x": 1000, "y": -20000, + } for index in range(100)] + app.executor = AsyncMock(return_value=QueryResult( + source="list_scada_devices", source_ref="mcp:catalog", queried_at=datetime.now(timezone.utc), + semantics="asset", data=devices, + page={"limit": 100, "offset": 0, "total": None, "has_more": True}, + )) + response = request(client, "tools/call", {"name": "list_scada_devices", "arguments": { + "context_handle": value.context_handle, + }}).json()["result"] + assert app.executor.await_args.args[3].limit == 100 + if wide_ids and not task: + assert response["isError"] is True + assert response["structuredContent"]["error"]["code"] == "RESULT_TOO_LARGE" + assert not saved + return + assert not response.get("isError"), response + output = response["structuredContent"] + if wide_ids: + assert output["data"]["resource_uri"].endswith(output["evidence_id"]) + else: + assert [row["device_id"] for row in output["data"]] == [row["device_id"] for row in devices] + assert all(row["device_type"] == "pressure" and row["measurement_unit"] == "m" for row in output["data"]) + assert "get_scada_device" in " ".join(output["data_gaps"]) + assert output["page"] == {"limit": 100, "offset": 0, "total": None, "has_more": True} + assert output["truncated"] is True + assert len(json.dumps(output, ensure_ascii=False).encode()) <= 12000 + if task: + assert saved[0].data["data"] == devices + assert output["evidence_id"] == saved[0].source_ref + assert not saved[0].truncated + else: + assert not saved and output["evidence_id"] is None + + +def test_scada_catalog_default_100_paginates_without_changing_other_queries(runtime, monkeypatch): + app, client, _, _ = runtime + value = binding() + devices = [{"device_id": str(index), "device_type": "pressure", "measurement_unit": "m"} + for index in range(101)] + + async def read_devices(connection, *, device_id, limit, offset): + return devices[offset:offset + limit] + + reader = AsyncMock(side_effect=read_devices) + monkeypatch.setattr(query_service.reads, "scada_devices", reader) + app.executor = query_service.execute_query + schema = next(tool["inputSchema"] for tool in request(client).json()["result"]["tools"] + if tool["name"] == "list_scada_devices") + assert schema["properties"]["limit"]["default"] == 100 + assert schema["properties"]["limit"]["maximum"] == 500 + pages = [request(client, "tools/call", {"name": "list_scada_devices", "arguments": { + "context_handle": value.context_handle, "offset": offset, + }}).json()["result"]["structuredContent"] for offset in [0, 100]] + assert [row for page in pages for row in page["data"]] == devices + assert pages[0]["page"]["has_more"] is True and pages[1]["page"]["has_more"] is False + assert [call.kwargs["limit"] for call in reader.await_args_list] == [101, 101] + assert [call.kwargs["offset"] for call in reader.await_args_list] == [0, 100] + assert TOOLS["list_elements"].model(context_handle=value.context_handle, element_type="pipe").limit == 20 + + def test_business_skill_is_delivered_over_mcp_without_recording_event_evidence(runtime, monkeypatch): app, client, _, _ = runtime value = binding(task=True) @@ -653,6 +827,35 @@ def test_old_task_run_and_reader_parent_are_rejected(monkeypatch): asyncio.run(contexts.verify_task(scope, value)) +@pytest.mark.parametrize("purpose", ["task_execution", "task_collaboration"]) +@pytest.mark.parametrize("replay", [False, True]) +def test_work_order_task_excludes_location_but_scada_replay_keeps_it(monkeypatch, purpose, replay): + monkeypatch.setattr(contexts, "permissions_for_context", lambda _: PERMISSIONS) + value = binding(task=True) + value.purpose = purpose + replay_context = {"replay_id": str(uuid4()), "knowledge_cutoff": "2026-07-16T00:00:00Z", "batch_sequence": 3} if replay else None + + class Tasks: + async def get_task(self, task_id): + return {"task_id": task_id, "workflow": "burst_report", "source": {"replay_context": replay_context}} + + async def get_active_run(self, task_id): + return {"run_id": str(value.run_id), "execution_request_id": str(uuid4()), + "status": "running", "agent_session_id": value.native_session_id, + "lease_expires_at": datetime.now(timezone.utc) + timedelta(minutes=1)} + + monkeypatch.setattr(contexts, "task_repository", lambda _: Tasks()) + scope = SimpleNamespace(project=object(), connection=Connection()) + asyncio.run(contexts.verify_task(scope, value)) + assert ("run_burst_location" in value.tool_cap) is replay + assert "analyze_valve_isolation" 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(): key = rsa.generate_private_key(public_exponent=65537, key_size=2048) pem = key.private_bytes( diff --git a/tests/unit/test_mcp_work_order_skills.py b/tests/unit/test_mcp_work_order_skills.py index 40fa227..fc1defe 100644 --- a/tests/unit/test_mcp_work_order_skills.py +++ b/tests/unit/test_mcp_work_order_skills.py @@ -15,9 +15,10 @@ from app.mcp.work_order_skills import read_work_order_skill @pytest.mark.parametrize("workflow,name", [ - ("burst_report", "burst-report"), + ("burst_report", "burst-event-handling"), ("water_quality_complaint", "water-quality-complaint"), ("general", "appointment-follow-up"), + ("scada_anomaly", "scada-anomaly"), ]) def test_skill_descriptor_and_body_have_same_version_and_digest(workflow, name): descriptor = read_work_order_skill(workflow) @@ -36,6 +37,18 @@ def test_unknown_workflow_never_falls_back_or_reads_an_arbitrary_path(workflow): assert read_work_order_skill(workflow)["available"] is False +def test_work_order_handling_and_legacy_scada_replay_receive_separate_skills(): + handling = read_work_order_skill("burst_report", include_content=True) + replay = read_work_order_skill("burst_report", replay_context={"replay_id": "r"}, include_content=True) + assert handling["name"] == "burst-event-handling" + assert replay["name"] == "scada-anomaly" + assert replay["workflow"] == "burst_report" + assert "影响说明" in handling["instructions"] + assert "supply_affected_nodes" in handling["instructions"] + assert "run_burst_location" not in handling["instructions"] + assert "run_burst_location" in replay["instructions"] + + def test_task_skill_uses_persisted_workflow_and_preserves_task_scope(monkeypatch): task_id, project_id = uuid4(), uuid4() reader = AsyncMock(return_value={"task": {"workflow": "burst_report"}}) @@ -71,3 +84,33 @@ def test_task_skill_uses_persisted_workflow_and_preserves_task_scope(monkeypatch def test_skill_input_cannot_select_another_workflow_or_partial_instructions(extra): with pytest.raises(ValueError): TaskContextInput(context_handle="a" * 43, section="skill", **extra) + + +def test_replay_context_advertises_demo_location_without_revealing_truth(monkeypatch): + from app.services import burst_location_demo as demo + + monkeypatch.setattr(query_service.reads, "task_context_summary", AsyncMock( + return_value={"task": {"workflow": "scada_anomaly"}})) + monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"operational_task.view"}) + scope = SimpleNamespace(connection=object(), project=SimpleNamespace(project_code="tjwater2_v2")) + binding = BoundContext( + context_handle="a" * 43, project_id=uuid4(), subject="u", client_id="host", + native_session_id="s", purpose="task_execution", task_id=uuid4(), + tool_cap=["get_task_context", "run_burst_location"], permission_cap=["operational_task.view"], + expires_at=datetime.now(timezone.utc), + replay_context={"replay_id": "r", "knowledge_cutoff": demo.EVENT_START.isoformat(), "batch_sequence": 3}, + ) + for section in ("summary", "skill"): + result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["get_task_context"], + TaskContextInput(context_handle=binding.context_handle, section=section))) + capability = result.data["location_demo"] + assert capability["available"] is True + assert capability["tool"] == "run_burst_location" + assert capability["burst_leakage_required"] is False + assert datetime.fromisoformat(capability["scada_burst_end"]) <= demo.EVENT_START + assert all(key not in json.dumps(capability) for key in (demo.REFERENCE_PIPE, demo.REFERENCE_NODE, demo.CANDIDATE_PIPE)) + + binding.replay_context["knowledge_cutoff"] = "2026-07-15T23:45:00Z" + result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["get_task_context"], + TaskContextInput(context_handle=binding.context_handle))) + assert result.data["location_demo"]["available"] is False diff --git a/tests/unit/test_operational_presentation.py b/tests/unit/test_operational_presentation.py index 3353cd3..6a0366e 100644 --- a/tests/unit/test_operational_presentation.py +++ b/tests/unit/test_operational_presentation.py @@ -6,7 +6,7 @@ from pydantic import ValidationError from app.domain.domain_errors import DomainError from app.domain.schemas.operational_data import TaskDataSnapshot -from app.domain.schemas.operational_task import TaskPresentationEventCommand +from app.domain.schemas.operational_task import TaskPresentationEventCommand, TaskSafeChart from app.services.operational_presentation import resolve_overlay @@ -86,3 +86,27 @@ def test_unified_presentation_commands_preserve_flat_shape(command): def test_unified_presentation_rejects_invalid_commands(command): with pytest.raises(ValidationError): TaskPresentationEventCommand(request_id=uuid4(), run_id=uuid4(), source_session_id="session", command=command) + + +def test_safe_chart_preserves_sparse_scatter_and_real_sample_times(): + data = {"title": "压力对比", "chart_type": "line", "x_data": ["19:45", "20:00"], + "x_axis_name": "时刻", "y_axis_name": "压力 m", "series": [ + {"name": "当前", "data": [0., 25.]}, + {"name": "前一天", "type": "scatter", "data": [None, 26.], + "sample_times": [None, "2026-06-13T20:00:00+08:00"]}]} + assert TaskSafeChart.model_validate(data).model_dump(mode="json") == data + + +@pytest.mark.parametrize("series", [ + {"name": "s", "data": [None], "sample_times": ["2026-06-14T20:00:00Z"]}, + {"name": "s", "data": [1.], "sample_times": ["2026-06-14T20:00:00"]}, + {"name": "s", "data": [1.], "sample_times": ["not-a-time"]}, + {"name": "s", "data": [1.], "sample_times": ["2026-06-14 20:00:00+08:00"]}, + {"name": "s", "data": [1.], "sample_times": []}, + {"name": "s", "data": [float("inf")]}, + {"name": "s", "data": [True]}, + {"name": "s", "type": "script", "data": [1.]}, +]) +def test_safe_chart_rejects_invalid_sample_metadata_and_values(series): + with pytest.raises(ValidationError): + TaskSafeChart(title="X", chart_type="line", x_data=["t"], series=[series]) diff --git a/tests/unit/test_operational_recovery.py b/tests/unit/test_operational_recovery.py new file mode 100644 index 0000000..4189d97 --- /dev/null +++ b/tests/unit/test_operational_recovery.py @@ -0,0 +1,109 @@ +from datetime import datetime, timedelta, timezone +from uuid import uuid4 + +import pytest + +from app.services.operational_recovery import accept_completion, evaluate_recovery, track_work_order +from app.services.operational_tasks import OperationalTaskService + + +INCIDENT = datetime(2026, 7, 16, 0, tzinfo=timezone.utc) +RETURNED = INCIDENT + timedelta(hours=1) + + +def observations(): + rows = [] + for sample in (1, 2, 3): + at = RETURNED + timedelta(minutes=15 * sample) + for device in ("P1", "F1"): + for day in range(1, 16): + rows.append({"time": at - timedelta(days=day), "device_id": device, "monitored_value": 100.}) + rows.append({"time": at, "device_id": device, "monitored_value": 100.}) + return rows + + +def evaluate(rows): + return evaluate_recovery(rows, device_ids=["P1", "F1"], returned_at=RETURNED, + incident_at=INCIDENT, interval_seconds=900) + + +def test_recovery_uses_post_receipt_samples_and_pre_incident_same_phase_reference(): + result = evaluate(observations()) + assert result["status"] == "passed" + assert [item["at"] for item in result["samples"]] == [ + (RETURNED + timedelta(minutes=30)).isoformat(), (RETURNED + timedelta(minutes=45)).isoformat()] + assert all(check["normal_day_count"] == 15 for sample in result["samples"] for check in sample["devices"]) + + +@pytest.mark.parametrize("problem", ["missing_device", "low_pressure", "high_flow", "nan", "bad_quality", "missing_baseline", "gap"]) +def test_incomplete_or_abnormal_data_never_proves_recovery(problem): + rows = observations() + current = [row for row in rows if row["time"] > RETURNED] + for row in current: + if problem == "missing_device" and row["device_id"] == "P1": + rows.remove(row) + elif problem in {"low_pressure", "high_flow", "nan"}: + row["monitored_value"] = {"low_pressure": 60., "high_flow": 130., "nan": float("nan")}[problem] + elif problem == "bad_quality": + row["quality"] = "invalid" + elif problem == "gap": + row["time"] += timedelta(minutes=15 * current.index(row)) + if problem == "missing_baseline": + rows = [row for row in rows if row["time"] >= INCIDENT - timedelta(days=14)] + assert evaluate(rows)["status"] == "waiting" + + +def test_samples_at_or_before_return_time_cannot_close_incident(): + rows = [row for row in observations() if row["time"] < INCIDENT] + rows.extend({"time": RETURNED, "device_id": device, "monitored_value": 100.} for device in ("P1", "F1")) + assert evaluate(rows)["status"] == "waiting" + + +def test_faster_sampling_counts_monitoring_periods_and_rejects_later_relapse(): + rows = [] + for minute in range(5, 36, 5): + at = RETURNED + timedelta(minutes=minute) + for device in ("P1", "F1"): + rows.extend({"time": at - timedelta(days=day), "device_id": device, "monitored_value": 100.} + for day in range(1, 16)) + rows.append({"time": at, "device_id": device, "monitored_value": 100.}) + assert evaluate(rows)["status"] == "passed" + next(row for row in rows if row["time"] == RETURNED + timedelta(minutes=25) and row["device_id"] == "P1")["monitored_value"] = 50. + assert evaluate(rows)["status"] == "waiting" + + +def test_dispatch_waits_for_all_orders_and_accepts_corrected_field_completion_time(): + task = {"workflow": "burst_report"} + for order_id in ("one", "two"): + track_work_order(task, {"kind": "create_work_order", "action_id": str(uuid4()), "execution_status": "succeeded", + "receipt": {"adapter": "peripheral_sample", "receipt_id": order_id}}) + assert OperationalTaskService._investigation_status([], task) == "waiting_human" + assert not accept_completion(task, {"source_event_id": "peripheral_sample:unrelated"}) + feedback = {"source_event_id": "peripheral_sample:one", "status": "completed", "source_revision": 2, + "occurred_at": RETURNED.isoformat()} + assert accept_completion(task, feedback) + assert task["handling_context"]["phase"] == "waiting_work_order" + accept_completion(task, {**feedback, "source_event_id": "peripheral_sample:two"}) + assert task["handling_context"]["phase"] == "waiting_scada" + task["handling_context"]["phase"] = "verification_ready" + task["handling_context"].update(verification_evidence_id="previous-proof", last_check={"status": "passed"}) + accept_completion(task, {**feedback, "source_revision": 3}) + assert task["handling_context"]["phase"] == "verification_ready" + assert OperationalTaskService._investigation_status([], task) == "completed" + corrected = RETURNED + timedelta(hours=1) + accept_completion(task, {**feedback, "source_revision": 4, "occurred_at": corrected.isoformat()}) + assert task["handling_context"]["returned_at"] == corrected.isoformat() + assert task["handling_context"]["phase"] == "waiting_scada" + assert "verification_evidence_id" not in task["handling_context"] and "last_check" not in task["handling_context"] + + +def test_field_feedback_time_requires_a_real_note_and_explicit_timezone(): + from pydantic import ValidationError + from samples.peripheral.schemas import UpdateWorkOrder + body = {"request_id": uuid4(), "expected_revision": 1, "status": "completed", + "progress_note": "演示抢修完成并恢复供水", "occurred_at": RETURNED.isoformat()} + assert UpdateWorkOrder.model_validate(body).occurred_at == RETURNED + with pytest.raises(ValidationError): + UpdateWorkOrder.model_validate({**body, "progress_note": None}) + with pytest.raises(ValidationError): + UpdateWorkOrder.model_validate({**body, "occurred_at": "2026-07-16T09:00:00"}) diff --git a/tests/unit/test_operational_tasks.py b/tests/unit/test_operational_tasks.py index 42f2504..6400ae2 100644 --- a/tests/unit/test_operational_tasks.py +++ b/tests/unit/test_operational_tasks.py @@ -576,6 +576,65 @@ async def test_presentation_cursor_and_event_are_read_only_and_idempotent(): await service.presentation_cursor(uuid4()) +@pytest.mark.anyio +async def test_replay_investigation_can_publish_evidenced_composite_chart_but_cannot_propose_real_action(): + now = datetime(2026, 10, 10, tzinfo=timezone.utc) + context = _context() + task = _task_document(context, now, lease=now + timedelta(minutes=1)) + task["source"]["replay_context"] = { + "replay_id": str(uuid4()), "knowledge_cutoff": now.isoformat(), "batch_sequence": 1, + } + repository = RunTaskRepository(task, _active_run(task, now)) + service = OperationalTaskService(repository, context, set(), clock=lambda: now) + evidence, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot( + source="query_scada_readings", source_ref="mcp:frozen-scada", queried_at=now, + origin="synthetic", data={"items": []}, truncated=False, + )) + result = await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand( + request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1", + command={"action": "open_chart", "chart_id": "pressure", "evidence_refs": [evidence.evidence_id], + "chart": {"title": "压力", "chart_type": "line", "x_data": ["19:45", "20:00"], + "y_axis_name": "压力 m", "series": [ + {"name": "当前", "type": "line", "data": [27., 26.]}, + {"name": "前一天", "type": "scatter", "data": [None, 28.], + "sample_times": [None, "2026-10-09T20:00:00+08:00"]}, + ]}}, + )) + assert result.recorded + assert repository.events[-1]["metadata"]["command"]["chart"]["series"][1]["data"] == [None, 28.] + with pytest.raises(DomainError) as caught: + await service.propose_action(task["task_id"], ActionProposalCommand( + request_id=uuid4(), kind="create_work_order", reason="演示不得创建真实工单", + payload={"title": "现场核查", "description": "核查", "work_order_type": "general"}, + )) + assert caught.value.code == "REPLAY_ACTION_DISABLED" + + +@pytest.mark.anyio +@pytest.mark.parametrize("operation", ["draft", "approve", "submit"]) +async def test_replay_cannot_edit_approve_or_submit_preexisting_real_actions(operation): + now = datetime(2026, 10, 10, tzinfo=timezone.utc) + context = _context() + task = _task_document(context, now, lease=now + timedelta(minutes=1)) + task["source"]["replay_context"] = { + "replay_id": str(uuid4()), "knowledge_cutoff": now.isoformat(), "batch_sequence": 1, + } + repository = RunTaskRepository(task, _active_run(task, now)) + service = OperationalTaskService(repository, context, set(), clock=lambda: now) + dispatcher = SimpleNamespace(dispatch=AsyncMock()) + args = {"request_id": uuid4(), "expected_version": 1} + with pytest.raises(DomainError) as caught: + if operation == "draft": + await service.update_work_order_draft(task["task_id"], uuid4(), WorkOrderDraftUpdateCommand( + **args, payload={"title": "现场核查", "description": "核查", "work_order_type": "general"})) + elif operation == "approve": + await service.decide_action(task["task_id"], uuid4(), ActionDecisionCommand(**args, decision="approved")) + else: + await service.submit_action(task["task_id"], uuid4(), ActionExecuteCommand(**args), dispatcher, None) + assert caught.value.code == "REPLAY_ACTION_DISABLED" + dispatcher.dispatch.assert_not_awaited() + + @pytest.mark.anyio @pytest.mark.parametrize("payload", [ {"action": "locate_features", "layer": "scada", "feature_ids": ["D-1"]}, @@ -1025,6 +1084,93 @@ def _seed_concluded_report(repository, now): }) +@pytest.mark.anyio +async def test_burst_waits_for_completion_then_scada_evidence_then_agent_report(monkeypatch): + from copy import deepcopy + from app.domain.schemas.project_settings import ProjectSettings + from app.services.operational_recovery import reconcile_recoveries, track_work_order + + now = datetime(2026, 7, 16, 4, tzinfo=timezone.utc) + incident = now - timedelta(hours=4) + returned = incident + timedelta(hours=1) + context = _context() + task = _task_document(context, now, lease=now + timedelta(minutes=1)) + task.update(workflow="burst_report", trigger={"kind": "event", "occurred_at": incident.isoformat(), "detail": "爆管"}) + repository = RunTaskRepository(task, _active_run(task, now)) + repository.connection = object() + service = OperationalTaskService(repository, context, set(), clock=lambda: now) + monkeypatch.setattr(service, "_resolve_investigation_alerts", AsyncMock()) + _seed_concluded_report(repository, now) + action = await service.propose_action(task["task_id"], ActionProposalCommand( + request_id=uuid4(), kind="create_work_order", reason="隔离抢修与恢复供水", + payload={"title": "抢修恢复", "description": "回传完工时间和恢复供水情况", "work_order_type": "general"})) + child_id = str(uuid4()) + repository.action = action.model_copy(update={"approval_status": "approved", "execution_status": "succeeded"}).model_dump(mode="json") + repository.action["receipt"] = {"adapter": "peripheral_sample", "receipt_id": child_id, + "submitted_at": now.isoformat(), "completed_at": now.isoformat(), "external_status": "open", "detail": "仅创建成功"} + track_work_order(repository.task, repository.action) + finished = await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand( + request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1")) + assert finished.status == "waiting_human" + service._resolve_investigation_alerts.assert_not_awaited() + + await service.external_feedback(task["task_id"], ExternalFeedbackCommand( + request_id=uuid4(), source_event_id=f"peripheral_sample:{child_id}", source_revision=2, + status="completed", occurred_at=returned, detail="抢修完成,已恢复供水")) + assert repository.task["handling_context"]["phase"] == "waiting_scada" + assert datetime.fromisoformat(repository.task["handling_context"]["returned_at"]) == returned + settings = ProjectSettings(scada_monitoring_interval_seconds=900, scada_monitoring={"pressure_device_ids": ["P1"]}) + repository.recovery_candidates = AsyncMock(side_effect=lambda *_: [deepcopy(repository.task)] + if repository.task["handling_context"]["phase"] == "waiting_scada" else []) + timescale = SimpleNamespace(transaction=repository.transaction) + rows = [] + for sample in (1, 2): + at = returned + timedelta(minutes=15 * sample) + rows.extend({"time": at - timedelta(days=day), "device_id": "P1", "monitored_value": 100.} for day in range(1, 16)) + rows.append({"time": at, "device_id": "P1", "monitored_value": 100.}) + reader = AsyncMock(return_value=rows[:-1]) + monkeypatch.setattr("app.infra.db.timescaledb.repositories.scada.ScadaRepository.get_scada_by_ids_time_range", reader) + assert await reconcile_recoveries(service, timescale, settings) == 0 + assert repository.task["handling_context"]["phase"] == "waiting_scada" + reader.return_value = rows + assert await reconcile_recoveries(service, timescale, settings) == 1 + assert await reconcile_recoveries(service, timescale, settings) == 0 + recovery = repository.task["handling_context"] + evidence_id = recovery["verification_evidence_id"] + assert repository.evidence[evidence_id]["data_snapshot"]["data"]["status"] == "passed" + service._resolve_investigation_alerts.assert_not_awaited() + + # Correcting the appointment's field return time invalidates the old proof + # and requires two actual observations after the new business time. + corrected_time = returned + timedelta(minutes=15) + await service.external_feedback(task["task_id"], ExternalFeedbackCommand( + request_id=uuid4(), source_event_id=f"peripheral_sample:{child_id}", source_revision=3, + status="completed", occurred_at=corrected_time, detail="修订现场实际完工时间")) + assert repository.task["handling_context"]["phase"] == "waiting_scada" + assert "verification_evidence_id" not in repository.task["handling_context"] + assert await reconcile_recoveries(service, timescale, settings) == 0 + at = returned + timedelta(minutes=45) + rows.extend({"time": at - timedelta(days=day), "device_id": "P1", "monitored_value": 100.} for day in range(1, 16)) + rows.append({"time": at, "device_id": "P1", "monitored_value": 100.}) + assert await reconcile_recoveries(service, timescale, settings) == 1 + revised_id = repository.task["handling_context"]["verification_evidence_id"] + assert revised_id != evidence_id and evidence_id in repository.evidence + evidence_id = revised_id + + repository.task.update(status="running", agent_session_id="session-1") + repository.run = _active_run(repository.task, now) + _seed_concluded_report(repository, now) + with pytest.raises(DomainError) as rejected: + await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand( + request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1")) + assert rejected.value.code == "RECOVERY_EVIDENCE_REQUIRED" + repository.reports[-1]["report"]["evidence_refs"] = [evidence_id] + result = await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand( + request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1")) + assert result.status == "completed" and result.handling_context["phase"] == "completed" + service._resolve_investigation_alerts.assert_awaited_once_with(task["task_id"], evidence_id) + + @pytest.mark.anyio async def test_plan_only_keeps_lease_until_same_generation_report_closes_run(): now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc) @@ -2087,3 +2233,52 @@ async def test_missing_calculation_migration_fails_finalization_closed(monkeypat await repo.pending_calculation_jobs(str(uuid4()), str(uuid4()), str(uuid4())) assert error.value.code == 'DEPENDENCY_UNAVAILABLE' assert error.value.status == 503 + + +@pytest.mark.anyio +async def test_recovery_alert_query_scopes_active_alerts_to_project_and_task(): + alerts = [{"alert_id": "first"}, {"alert_id": "second"}] + cursor = SimpleNamespace(fetchall=AsyncMock(return_value=[{"document": item} for item in alerts])) + connection = SimpleNamespace(execute=AsyncMock(return_value=cursor)) + project_id, task_id = uuid4(), str(uuid4()) + repository = OperationalTaskRepository(connection, project_id) + assert await repository.list_investigation_alerts(task_id) == alerts + statement, parameters = connection.execute.await_args.args + assert parameters == (str(project_id), f"operational-task:{task_id}") + assert "status IN ('active','acknowledged')" in statement + + +@pytest.mark.anyio +async def test_recovery_resolves_alerts_with_original_facts_and_stable_event_id(monkeypatch): + from uuid import NAMESPACE_URL, uuid5 + from app.services.task_alerts import TaskAlertService + + task_id, project_id = str(uuid4()), str(uuid4()) + now = datetime(2026, 7, 16, 4, tzinfo=timezone.utc) + alert = { + "alert_id": "incident-1", "producer_id": "producer-1", "revision": 2, + "condition_id": f"operational-task:{task_id}", "status": "acknowledged", + "source": {"task_type": "scheduled_task", "task_id": task_id, "run_id": "run-1"}, + "title": "压力异常", "description": "等待恢复", "severity": "warning", + "occurred_at": now.isoformat(), "updated_at": now.isoformat(), + } + connection = object() + repository = SimpleNamespace(connection=connection, project_id=project_id, + list_investigation_alerts=AsyncMock(return_value=[alert])) + calls = [] + + async def push(service, command): + calls.append((service, command)) + + monkeypatch.setattr(TaskAlertService, "push", push) + service = OperationalTaskService(repository, _context(), set()) + for _ in range(2): + await service._resolve_investigation_alerts(task_id, "recovery:evidence-1") + assert len(calls) == 2 + for publisher, command in calls: + assert publisher.repo.connection is connection and publisher.repo.project_id == project_id + assert publisher.internal_producer_id == alert["producer_id"] + assert command.event_type == "resolved" + assert command.event_id == uuid5(NAMESPACE_URL, f"recovery:{task_id}:recovery:evidence-1:incident-1") + assert command.source.model_dump(mode="json") == alert["source"] + assert command.title == alert["title"] and command.description == alert["description"] diff --git a/tests/unit/test_operational_worker.py b/tests/unit/test_operational_worker.py index c09a500..8a5640c 100644 --- a/tests/unit/test_operational_worker.py +++ b/tests/unit/test_operational_worker.py @@ -48,9 +48,10 @@ async def test_all_worker_operations_share_investigation_creation_scope(): assert request.url.params["task_type"] == "event_investigation" assert request.url.params["task_created_from"] == boundary return httpx.Response(200, json={"items": []}) - assert request.url.path.endswith("leases/recover") + assert request.url.path.endswith(("leases/recover", "recovery/reconcile")) assert json.loads(request.content) == { - "limit": 100, "task_type": "event_investigation", "task_created_from": boundary, + "limit": 20 if request.url.path.endswith("recovery/reconcile") else 100, + "task_type": "event_investigation", "task_created_from": boundary, } return httpx.Response(200, json={"task_ids": []}) @@ -58,7 +59,7 @@ async def test_all_worker_operations_share_investigation_creation_scope(): bearer="TEST_ONLY", task_created_from=boundary) async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: assert await OperationalWorker(settings, client).tick() == 0 - assert len(requests) == 5 + assert len(requests) == 6 @pytest.mark.anyio @@ -68,7 +69,7 @@ async def test_worker_reconciles_messages_at_capacity_and_preserves_transport_re def handler(request): path = request.url.path - if path.endswith("leases/recover"): + if path.endswith(("leases/recover", "recovery/reconcile")): return httpx.Response(200, json={"items": []}) if path.endswith("agent-messages/pending"): return httpx.Response(200, json={"items": pending}) @@ -129,7 +130,7 @@ async def test_persisted_queue_retries_with_backend_execution_identity(): return httpx.Response(200, json={"ok": True, "agent_instance_id": "agent-a"}) assert request.headers["Authorization"] == "Bearer TEST_ONLY" assert not request.url.path.endswith("schedules/dispatch-due") - if request.url.path.endswith("leases/recover"): + if request.url.path.endswith(("leases/recover", "recovery/reconcile")): return httpx.Response(200, json={"items": []}) if request.method == "GET": if request.url.params.get("status") != "queued": @@ -191,7 +192,7 @@ async def test_bounded_queue_continues_after_one_agent_handoff_fails(): limits.append(int(request.url.params["limit"])) return httpx.Response(200, json={"items": runs}) if request.url.host == "server.test": - if request.url.path.endswith("leases/recover"): + if request.url.path.endswith(("leases/recover", "recovery/reconcile")): return httpx.Response(200, json={"items": []}) failures.append(json.loads(request.content)) return httpx.Response(200, json={}) @@ -269,7 +270,7 @@ async def test_worker_refreshes_running_agent_credentials_after_restart(): def handler(request): if request.url.path == "/health": return httpx.Response(200, json={"ok": True, "agent_instance_id": "agent-a"}) - if request.url.path.endswith("leases/recover"): + if request.url.path.endswith(("leases/recover", "recovery/reconcile")): return httpx.Response(200, json={"items": []}) if request.method == "GET": items = [{ @@ -300,7 +301,7 @@ async def test_worker_only_restores_sessions_owned_by_target_agent_instance(): def handler(request): if request.url.path == "/health": return httpx.Response(200, json={"ok": True, "agent_instance_id": "agent-a"}) - if request.url.path.endswith("leases/recover"): + if request.url.path.endswith(("leases/recover", "recovery/reconcile")): return httpx.Response(200, json={"items": []}) if request.method == "GET": items = [{ diff --git a/tests/unit/test_peripheral_task_sync.py b/tests/unit/test_peripheral_task_sync.py index c20b7e2..3e9da45 100644 --- a/tests/unit/test_peripheral_task_sync.py +++ b/tests/unit/test_peripheral_task_sync.py @@ -255,7 +255,7 @@ async def test_created_work_order_receipt_preserves_projection_and_routes_active "title": "采样工单", "priority": "normal", "work_order_type": "general", "created_at": "2026-09-23T08:00:00Z", "scheduled_at": None, - "activity": ([{"sequence": 2, "occurred_at": "2026-09-23T09:00:00Z", "actor": "现场人员", + "activity": ([{"sequence": 2, "occurred_at": "2026-09-23T08:45:00Z", "actor": "现场人员", "kind": "feedback", "title": "现场反馈", "detail": "复测余氯0.2mg/L"}] if order_revision > 1 else []), } @@ -271,6 +271,8 @@ async def test_created_work_order_receipt_preserves_projection_and_routes_active assert ("work_order_snapshot" in payload) == request.url.path.endswith(f"{projection_id}/external-feedback") if order_revision > 1: assert "复测余氯0.2mg/L" in payload["evidence"]["summary"] + assert payload["occurred_at"] == "2026-09-23T08:45:00Z" + assert payload["evidence"]["observed_at"] == "2026-09-23T08:45:00Z" return httpx.Response(200, json={}) if request.url.path.endswith(investigation_id): return httpx.Response(200, json={"actions": [{"kind": "create_work_order", "receipt": { diff --git a/tests/unit/test_regional_burst_selection.py b/tests/unit/test_regional_burst_selection.py new file mode 100644 index 0000000..ef22730 --- /dev/null +++ b/tests/unit/test_regional_burst_selection.py @@ -0,0 +1,46 @@ +import pytest + +from scripts.prepare_regional_burst_design import require_isolatable_pipe +from scripts.screen_burst_benchmark_pipes import nearest_pressure_distances + + +def test_region_without_valves_is_rejected(): + with pytest.raises(ValueError, match="cannot be isolated"): + require_isolatable_pipe(["supply:pipe:R:A", "target:pipe:A:B"], "target", ["R"], []) + + +def test_nearby_valve_does_not_qualify_an_uncontrolled_source_path(): + links = ["v:valve:R:A", "bypass:pipe:R:B", "target:pipe:A:B"] + with pytest.raises(ValueError, match="cannot be isolated"): + require_isolatable_pipe(links, "target", ["R"], []) + + +def test_single_boundary_valve_can_qualify_a_branch(): + links = ["v:valve:R:A", "target:pipe:A:B"] + result = require_isolatable_pipe(links, "target", ["R"], []) + assert result["must_close_valves"] == ["v"] + assert set(result["affected_nodes"]) == {"A", "B"} + assert result["source_check_performed"] + + +def test_source_metadata_is_mandatory(): + with pytest.raises(ValueError, match="cannot be isolated"): + require_isolatable_pipe(["v:valve:R:A", "target:pipe:A:B"], "target", [], []) + + +def test_distance_is_to_the_nearest_of_all_meters(): + graph = {"A": [("P", 1500)], "B": [("P", 100)], "P": [("A", 1500), ("B", 100)]} + devices = [{"device_id": "far", "node_id": "A"}, {"device_id": "near", "node_id": "B"}] + assert nearest_pressure_distances(graph, devices, 3000)["P"] == (100, "near") + + +def test_network_path_length_and_cutoff_are_used(): + graph = {"A": [("B", 600)], "B": [("A", 600), ("C", 700)], "C": [("B", 700)]} + devices = [{"device_id": "meter", "node_id": "A"}] + assert "C" not in nearest_pressure_distances(graph, devices, 1200) + assert nearest_pressure_distances(graph, devices, 1500)["C"] == (1300, "meter") + + +def test_unreachable_pipe_is_not_a_zero_distance_candidate(): + graph = {"A": [], "P": []} + assert "P" not in nearest_pressure_distances(graph, [{"device_id": "meter", "node_id": "A"}], 3000) diff --git a/tests/unit/test_report_ui.py b/tests/unit/test_report_ui.py index 68cdb59..6075547 100644 --- a/tests/unit/test_report_ui.py +++ b/tests/unit/test_report_ui.py @@ -40,6 +40,17 @@ def test_report_ui_round_trips_with_a_version_and_keeps_legacy_reports_readable( assert InvestigationReportRevision.model_validate(old).report.ui_blocks == [] +def test_report_ui_preserves_sparse_scada_series_and_sample_times(): + data = rich_report() + chart = data["ui_blocks"][0]["block"]["spec"]["elements"]["chart"]["props"] + chart["y_axis_name"] = "压力 MPa" + chart["series"].append({"name": "前一天", "type": "scatter", "data": [None, 0.28], + "sample_times": [None, "2026-10-19T09:00:00+08:00"]}) + restored = InvestigationReportInput.model_validate_json( + InvestigationReportInput.model_validate(data).model_dump_json()) + assert restored.ui_blocks[0].block.spec.elements["chart"].props == chart + + @pytest.mark.parametrize("phase", [None, "final"]) def test_report_ui_accepts_final_and_legacy_phases(phase): data = rich_report() diff --git a/tests/unit/test_scada_monitoring_calibration.py b/tests/unit/test_scada_monitoring_calibration.py new file mode 100644 index 0000000..19175b0 --- /dev/null +++ b/tests/unit/test_scada_monitoring_calibration.py @@ -0,0 +1,84 @@ +from datetime import datetime, timedelta +from math import sin + +import pytest +from pydantic import ValidationError + +from app.domain.schemas.project_settings import ScadaMonitoringSettings, ScadaMonitoringWeights +from app.services.scada_monitoring_calibration import MonitoringCalibrationBuilder, adaptive_score_threshold +from app.services import scada_replay_monitoring as monitoring +from test_scada_replay_monitoring import TARGET, PRESSURE_IDS, FLOW_IDS, _document +from app.domain.schemas.scada_replay import ScadaReplayCreate +from app.services.scada_normal_profile import CALIBRATION_VERSION, DIAGNOSTIC_POLICY, normal_profile_bounds + + +def setup(): + document = _document() + document["config"]["start_time"] = TARGET.isoformat() + document["monitoring_config"] = ScadaMonitoringSettings( + pressure_device_ids=PRESSURE_IDS, outlet_flow_device_ids=FLOW_IDS, + ).model_dump(mode="json") + config = ScadaReplayCreate.model_validate(document["config"]) + start = TARGET - timedelta(days=15) + rows = [{"device_id": device, "time": (start + timedelta(minutes=15 * i)).isoformat(), + "cleaned_value": 100 + 5 * sin(i / 96 * 6.283) + 0.1 * sin(i * 1.7), "quality": "valid"} + for i in range(15 * 96) for device in PRESSURE_IDS + FLOW_IDS] + return document, config, rows + + +def test_streamed_thresholds_are_frozen_from_complete_pre_replay_history(monkeypatch): + document, config, rows = setup() + calls = [] + + def detect(**kwargs): + frame = kwargs["observed_pressure_data"] + assert frame.index.max().to_pydatetime() < config.start_time + assert frame.shape == (1440, 5) + calls.append(frame) + return {"rows": [{"Score": value} for value in [-0.03, -0.01, 0.01, 0.02, 0.03] * 3]} + + monkeypatch.setattr(monitoring, "run_burst_detection", detect) + builder = MonitoringCalibrationBuilder(document, config) + for offset in range(0, len(rows), 997): + builder.add(rows[offset:offset + 997]) + result = builder.fit() + assert result["status"] == "ready" + assert result["version"] == CALIBRATION_VERSION + assert result["diagnostic_policy"] == DIAGNOSTIC_POLICY + assert result["knowledge_cutoff"] == config.start_time.isoformat() + assert result["burst_detection"]["threshold"] < -0.04 + assert result["pressure"][PRESSURE_IDS[0]]["threshold_percent"] == 3.0 + assert result["outlet_flow"][FLOW_IDS[0]]["threshold_percent"] == 5.0 + assert len(calls) == 1 + assert sum(values.nbytes for values in builder.values.values()) == 1440 * 6 * 8 + for row in rows: + bounds = normal_profile_bounds(result["diagnostic"][row["device_id"]], datetime.fromisoformat(row["time"])) + assert bounds[0] <= row["cleaned_value"] <= bounds[1] + + +def test_history_gap_and_future_samples_cannot_be_hidden(monkeypatch): + document, config, rows = setup() + monkeypatch.setattr(monitoring, "run_burst_detection", lambda **_: pytest.fail("incomplete history entered detection")) + builder = MonitoringCalibrationBuilder(document, config) + builder.add(rows[1:]) + assert builder.fit()["status"] == "insufficient_data" + with pytest.raises(ValueError, match="之后"): + builder.add([{**rows[0], "time": config.start_time.isoformat()}]) + + +def test_score_cutoff_follows_normal_noise_instead_of_target_scores(): + quiet = adaptive_score_threshold([0.01, 0.02, 0.03, 0.02, 0.01]) + noisy = adaptive_score_threshold([-0.07, -0.03, 0.01, 0.04, 0.02]) + assert noisy["threshold"] < quiet["threshold"] + assert -0.2 < noisy["threshold"] + assert adaptive_score_threshold([0.01])["status"] == "insufficient_data" + + +@pytest.mark.parametrize("values", [ + {"burst_detection": 0.5, "pressure": 0.4, "outlet_flow": 0.5}, + {"burst_detection": -0.1, "pressure": 1, "outlet_flow": 0.1}, + {"outlet_flow": float("nan")}, +]) +def test_invalid_weights_are_rejected(values): + with pytest.raises(ValidationError): + ScadaMonitoringWeights(**values) diff --git a/tests/unit/test_scada_replay_monitoring.py b/tests/unit/test_scada_replay_monitoring.py index 49ea09e..da0d027 100644 --- a/tests/unit/test_scada_replay_monitoring.py +++ b/tests/unit/test_scada_replay_monitoring.py @@ -1,8 +1,11 @@ from datetime import datetime, timedelta, timezone from uuid import uuid4 +import pytest from app.domain.schemas.scada_replay import ScadaReplayCreate +from app.domain.schemas.project_settings import ScadaMonitoringSettings from app.services import scada_replay_monitoring +from app.services.scada_normal_profile import CALIBRATION_VERSION, DIAGNOSTIC_POLICY TARGET = datetime(2026, 10, 16, tzinfo=timezone.utc) @@ -68,6 +71,14 @@ def test_multiple_indicators_create_suspected_burst_result(monkeypatch): assert result["pressure"]["hit_device_ids"] == PRESSURE_IDS assert result["outlet_flow"]["hit_device_ids"] == FLOW_IDS assert result["burst_detection"]["hit"] is True + pressure = result["pressure"]["devices"][0] + assert pressure["value_change"] == -30 + assert pressure["unit"] == "m" + assert pressure["baseline_method"] == "recent_samples_median" + assert pressure["baseline_sample_count"] == 4 + assert pressure["baseline_end"] == result["period_start"] + assert result["outlet_flow"]["devices"][0]["value_change"] == 50 + assert result["burst_detection"]["score_interpretation"] == "lower_is_more_anomalous_not_probability" def test_single_indicator_stays_attention(monkeypatch): @@ -106,3 +117,194 @@ def test_missing_project_roles_is_data_gap_not_normal(): assert result["status"] == "insufficient_data" assert result["burst_detection"]["status"] == "not_run" + + +def _weighted_document(): + document = _document() + document["config"]["start_time"] = (TARGET - timedelta(minutes=30)).isoformat() + document["monitoring_config"] = ScadaMonitoringSettings( + pressure_device_ids=PRESSURE_IDS, outlet_flow_device_ids=FLOW_IDS, + ).model_dump(mode="json") + document["monitoring_calibration"] = { + "version": CALIBRATION_VERSION, "diagnostic_policy": DIAGNOSTIC_POLICY, + "status": "ready", "knowledge_cutoff": (TARGET - timedelta(minutes=30)).isoformat(), + "pressure": {device: {"threshold_percent": 10, "method": "frozen_normal_history"} for device in PRESSURE_IDS}, + "outlet_flow": {"F1": {"threshold_percent": 5, "method": "frozen_normal_history"}}, + "burst_detection": {"threshold": -0.08}, + } + profile = {"phase_origin": document["config"]["start_time"], "interval_seconds": 900, + "lower": [99.], "upper": [101.]} + calibration = document["monitoring_calibration"] + calibration["diagnostic"] = {device: profile for device in PRESSURE_IDS + FLOW_IDS} + for kind in ("pressure", "outlet_flow"): + for item in calibration[kind].values(): + item["normal_profile"] = profile + return document + + +@pytest.mark.parametrize("flow,expected_score", [("increase", 1.0), ("flat", 0.9), ("missing", 0.9)]) +def test_pressure_and_detection_can_trigger_with_low_or_unavailable_flow_weight(monkeypatch, flow, expected_score): + monkeypatch.setattr(scada_replay_monitoring, "run_burst_detection", lambda **_: { + "rows": [{"Score": -0.2}], "summary": {}, + }) + rows = _rows() + for row in rows: + if flow == "flat" and row["device_id"] == "F1": + row["cleaned_value"] = 100 + if flow == "missing": + rows = [row for row in rows if not (row["device_id"] == "F1" + and datetime.fromisoformat(row["time"]) >= TARGET - timedelta(minutes=15))] + result = scada_replay_monitoring.evaluate_monitoring_period( + _weighted_document(), rows, period_start=TARGET - timedelta(minutes=15), + period_end=TARGET, batch_sequence=1440, + ) + assert result["status"] == "suspected_burst" + assert result["weighted"]["score"] == expected_score + assert result["weighted"]["weights"]["outlet_flow"] == 0.1 + assert result["pressure"]["devices"][0]["threshold_percent"] == 10 + assert result["burst_detection"]["score_threshold"] == -0.08 + if flow == "missing": + assert result["weighted"]["unavailable_indicators"] == ["outlet_flow"] + + +@pytest.mark.parametrize("indicator", ["pressure", "outlet_flow", "burst_detection"]) +def test_a_single_indicator_cannot_create_a_weighted_burst_alert(monkeypatch, indicator): + monkeypatch.setattr(scada_replay_monitoring, "run_burst_detection", lambda **_: { + "rows": [{"Score": -0.2 if indicator == "burst_detection" else 0.02}], "summary": {}, + }) + rows = _rows() + for row in rows: + if (row["device_id"] in PRESSURE_IDS and indicator != "pressure" + or row["device_id"] == "F1" and indicator != "outlet_flow"): + row["cleaned_value"] = 100 + result = scada_replay_monitoring.evaluate_monitoring_period( + _weighted_document(), rows, period_start=TARGET - timedelta(minutes=15), + period_end=TARGET, batch_sequence=1440, + ) + assert result["status"] == "attention" + assert result["weighted"]["score"] < 0.8 + + +def test_weighted_policy_requires_a_persisted_causal_calibration(): + document = _weighted_document() + document.pop("monitoring_calibration") + result = scada_replay_monitoring.evaluate_monitoring_period( + document, _rows(), period_start=TARGET - timedelta(minutes=15), period_end=TARGET, batch_sequence=1, + ) + assert result["status"] == "insufficient_data" + + +def test_calibration_from_another_or_future_cutoff_is_rejected(): + document = _weighted_document() + document["monitoring_calibration"]["knowledge_cutoff"] = (TARGET + timedelta(minutes=15)).isoformat() + result = scada_replay_monitoring.evaluate_monitoring_period( + document, _rows(), period_start=TARGET - timedelta(minutes=15), period_end=TARGET, batch_sequence=1, + ) + assert result["status"] == "insufficient_data" + + +def test_ready_flow_anomaly_survives_missing_pressure_calibration(monkeypatch): + document = _weighted_document() + document["monitoring_calibration"].update(status="insufficient_data", pressure={}) + document["monitoring_calibration"].pop("burst_detection") + def unexpected_detector(**_): + raise AssertionError("Unavailable pressure calibration must not run the detector") + monkeypatch.setattr(scada_replay_monitoring, "run_burst_detection", unexpected_detector) + result = scada_replay_monitoring.evaluate_monitoring_period( + document, _rows(), period_start=TARGET - timedelta(minutes=15), period_end=TARGET, batch_sequence=1, + ) + assert result["status"] == "insufficient_data" + assert result["outlet_flow"]["hit"] is True + assert result["weighted"]["score"] == 0.1 + assert result["burst_detection"]["status"] == "baseline_not_ready" + + +def test_relative_baseline_matches_calibration_immediately_previous_samples(): + config = ScadaReplayCreate.model_validate(_document()["config"]) + rows = [{"device_id": "P1", "time": (TARGET - timedelta(minutes=15 * (5 - i))).isoformat(), + "cleaned_value": value, "quality": "valid"} + for i, value in enumerate((120., 115., 110., 105., 100., 95.))] + result = scada_replay_monitoring._relative_indicator( + rows, config, ["P1"], TARGET - timedelta(minutes=15), TARGET, direction="decrease", + ) + assert result["devices"][0]["baseline"] == 107.5 + assert result["devices"][0]["baseline_end"] == (TARGET - timedelta(minutes=15)).isoformat() + + +def test_period_start_reading_is_not_a_new_observation(): + config = ScadaReplayCreate.model_validate(_document()["config"]) + rows = [row for row in _rows() if row["time"] < TARGET.isoformat()] + result = scada_replay_monitoring._relative_indicator( + rows, config, ["P1"], TARGET - timedelta(minutes=15), TARGET, direction="decrease", + ) + assert result["status"] == "insufficient_data" + assert result["gaps"][0]["reason"] == "period_reading_missing" + + +def test_detector_cannot_reuse_the_previous_period_score(monkeypatch): + monkeypatch.setattr(scada_replay_monitoring, "run_burst_detection", + lambda **_: pytest.fail("stale target reached detector")) + rows = [row for row in _rows() if row["time"] < TARGET.isoformat()] + result = scada_replay_monitoring._burst_detection(rows, PRESSURE_IDS, 900, TARGET) + assert result["status"] == "current_reading_missing" + assert result["hit"] is False + + +def test_old_calibration_version_requires_a_new_replay(): + document = _weighted_document() + document["monitoring_calibration"]["version"] = "scada-monitor-calibration@1" + result = scada_replay_monitoring.evaluate_monitoring_period( + document, _rows(), period_start=TARGET - timedelta(minutes=15), period_end=TARGET, batch_sequence=1, + ) + assert result["status"] == "insufficient_data" + assert "新建" in result["detail"] + assert not any(result[key]["hit"] for key in ("pressure", "outlet_flow", "burst_detection")) + + +def test_normal_diurnal_change_then_t_and_t_plus_fifteen_share_one_incident(monkeypatch): + from app.services.scada_replay_processing import detect_frame + from app.services.scada_monitoring_incidents import update_incident + document = _weighted_document() + config = ScadaReplayCreate.model_validate(document["config"]) + profile = {"phase_origin": config.start_time.isoformat(), "interval_seconds": 900, + "lower": [99., 199., 199., 199.], "upper": [101., 201., 201., 201.]} + document["monitoring_calibration"]["diagnostic"]["F1"] = profile + document["monitoring_calibration"]["outlet_flow"]["F1"]["normal_profile"] = profile + monkeypatch.setattr(scada_replay_monitoring, "run_burst_detection", lambda **kwargs: { + "rows": [{"Score": -0.2 if kwargs["observed_pressure_data"].iloc[-1].min() < 90 else 0.02}], + "summary": {}, + }) + rows = _rows() + rows += [{"time": (TARGET - timedelta(days=15)).isoformat(), "device_id": device, + "cleaned_value": 100., "quality": "valid"} for device in PRESSURE_IDS + FLOW_IDS] + for row in rows: + if row["device_id"] == "F1" and _time_for_test(row) >= TARGET - timedelta(minutes=15): + row["cleaned_value"] = 200. + rows += [{"time": (TARGET + timedelta(minutes=15)).isoformat(), "device_id": device, + "cleaned_value": 200. if device == "F1" else 70., "quality": "valid"} + for device in PRESSURE_IDS + FLOW_IDS] + document["histories"] = {device: [100.] * 4 for device in PRESSURE_IDS + FLOW_IDS} + actions, statuses = [], [] + for sequence, offset in enumerate((-15, 0, 15), start=1): + at = TARGET + timedelta(minutes=offset) + frame = [row for row in rows if _time_for_test(row) == at] + document, changes = detect_frame(document, frame, config, at) + result = scada_replay_monitoring.evaluate_monitoring_period( + document, [row for row in rows if _time_for_test(row) <= at], + period_start=at - timedelta(minutes=15), period_end=at, batch_sequence=sequence, + ) + result["anomaly_signals"] = [state["event"] for state in document["rule_states"].values() + if state.get("event")] + statuses.append(result["status"]) + incident, action = update_incident(document, result, sequence) + actions.append(action) + if incident and not incident.get("task_id"): + incident["task_id"] = str(uuid4()) + if offset == -15: + assert changes == [] # Normal doubling of flow does not launch diagnosis. + assert statuses == ["normal", "suspected_burst", "suspected_burst"] + assert actions == [None, "raised", "ongoing"] + + +def _time_for_test(row): + return datetime.fromisoformat(row["time"]) diff --git a/tests/unit/test_scada_replay_processing.py b/tests/unit/test_scada_replay_processing.py index 085dd02..05936dd 100644 --- a/tests/unit/test_scada_replay_processing.py +++ b/tests/unit/test_scada_replay_processing.py @@ -1,8 +1,12 @@ from datetime import datetime, timedelta, timezone from uuid import uuid4 +import pytest from app.domain.schemas.scada_replay import ScadaReplayCreate from app.services.scada_replay_processing import prepare_cleaning, detect_frame +from app.services.scada_normal_profile import ( + CALIBRATION_VERSION, DIAGNOSTIC_POLICY, diagnostic_signal, +) START = datetime(2026, 10, 8, tzinfo=timezone.utc) @@ -64,3 +68,60 @@ def test_empty_clock_ticks_do_not_break_consecutive_observations(): doc, _ = detect_frame(doc, [], cfg, START + timedelta(minutes=1)) _, events = detect_frame(doc, prepare_cleaning(rows(20, 15), cfg), cfg, START + timedelta(minutes=15)) assert events[0]["change"] == "raised" + + +def weighted_document(low=97., high=103.): + return {"replay_id": "run", "histories": {"P1": [100.] * 4}, + "monitoring_config": {"decision_policy": "weighted"}, + "config": config().model_dump(mode="json"), + "monitoring_calibration": { + "version": CALIBRATION_VERSION, "diagnostic_policy": DIAGNOSTIC_POLICY, + "knowledge_cutoff": START.isoformat(), + "diagnostic": {"P1": {"phase_origin": START.isoformat(), "interval_seconds": 900, + "lower": [low], "upper": [high]}}, + }} + + +def test_weighted_diagnosis_ignores_normal_diurnal_large_relative_changes(): + cfg = config(rules=[{"rule_id": "flow", "device_ids": ["P1"], "kind": "relative_change", + "direction": "both", "threshold": 30, "unit": "m3/h"}]) + document = weighted_document(25., 35.) + document["histories"]["P1"] = [10.] * 4 + _, events = detect_frame(document, prepare_cleaning(rows(30), cfg), cfg, START) + assert events == [] # +200% is normal at this daily sampling phase. + _, events = detect_frame(document, prepare_cleaning(rows(36), cfg), cfg, START) + assert events[0]["change"] == "raised" + + +@pytest.mark.parametrize("baseline,value,low,high", [ + (100., 95., 97., 103.), (0., -5., -1., 1.), +]) +def test_weighted_single_indicator_uses_history_envelope_without_thirty_percent_gate(baseline, value, low, high): + cfg = config() + document = weighted_document(low, high) + document["histories"]["P1"] = [baseline] * 4 + _, events = detect_frame(document, prepare_cleaning(rows(value), cfg), cfg, START) + assert len(events) == 1 + assert events[0]["diagnostic_policy"] == DIAGNOSTIC_POLICY + assert diagnostic_signal(document, events[0]) + assert "阈值 30" not in events[0]["detail"] + + +@pytest.mark.parametrize("missing", ["version", "diagnostic", "knowledge_cutoff"]) +def test_weighted_missing_calibration_does_not_fall_back_to_thirty_percent(missing): + cfg = config() + document = weighted_document() + document["monitoring_calibration"].pop(missing) + _, events = detect_frame(document, prepare_cleaning(rows(1), cfg), cfg, START) + assert events == [] + + +def test_old_active_relative_state_cannot_block_or_supply_new_diagnosis(): + cfg = config() + document = weighted_document() + old = {"event_id": "old", "rule_id": "pressure", "device_ids": ["P1"], "status": "active"} + document["rule_states"] = {"pressure": {"event": old, "hits": 1, "recoveries": 0}} + result, events = detect_frame(document, prepare_cleaning(rows(95), cfg), cfg, START) + assert not diagnostic_signal(document, old) + assert result["rule_states"]["pressure"]["event"] == old + assert events[0]["diagnostic_policy"] == DIAGNOSTIC_POLICY diff --git a/tests/unit/test_scada_replay_queries.py b/tests/unit/test_scada_replay_queries.py index 781b3b7..265cfdf 100644 --- a/tests/unit/test_scada_replay_queries.py +++ b/tests/unit/test_scada_replay_queries.py @@ -12,7 +12,7 @@ from app.mcp.analysis_models import BurstLocationInput from app.mcp.catalog import TOOLS from app.mcp.models import BoundContext from app.mcp import query_service -from app.services.scada_replay_queries import replay_burst_location_inputs +from app.services.scada_replay_queries import replay_burst_location_inputs, replay_snapshot_rows START = datetime(2026, 10, 8, tzinfo=timezone.utc) @@ -85,6 +85,60 @@ def test_replay_location_builds_series_only_from_frozen_outputs(): assert connection.statements[1][1][2] == 9 +def test_replay_queries_frozen_history_with_project_replay_and_batch_bounds(): + project_id, replay_id = uuid4(), str(uuid4()) + at = START - timedelta(days=7) + run = {"start_time": START.isoformat(), "config": {"device_ids": ["P1"]}, + "baseline": {"start_time": (START - timedelta(days=15)).isoformat()}} + reading = {"time": at.isoformat(), "device_id": "P1", "monitored_value": 23., + "cleaned_value": 23., "quality": "valid", "reason": "冻结历史基线"} + connection = Connection([[{"document": run}], [{"reading": reading}]]) + context = {"replay_id": replay_id, "knowledge_cutoff": START.isoformat(), "batch_sequence": 9} + result = asyncio.run(replay_snapshot_rows(connection, project_id, context, ["P1"], at, at, 10)) + assert result == [reading] + statement, params = connection.statements[1] + assert "b.stage='detected'" in statement and "b.sequence<=%s" in statement + assert params[:4] == (str(project_id), replay_id, 9, ["P1"]) + assert params[4:6] == (at, at) + + +@pytest.mark.parametrize("defect,status", [("old_replay", 422), ("too_early", 422), ("future", 422), ("foreign_device", 403)]) +def test_replay_history_rejects_missing_freeze_or_scope_escape(defect, status): + at = START - timedelta(days=7) + run = {"start_time": START.isoformat(), "config": {"device_ids": ["P1"]}, + "baseline": {"start_time": (START - timedelta(days=15)).isoformat()}} + if defect == "old_replay": run.pop("baseline") + if defect == "too_early": at = START - timedelta(days=16) + if defect == "future": at = START + timedelta(minutes=1) + connection = Connection([[{"document": run}]]) + context = {"replay_id": str(uuid4()), "knowledge_cutoff": START.isoformat(), "batch_sequence": 9} + with pytest.raises(HTTPException) as caught: + asyncio.run(replay_snapshot_rows(connection, uuid4(), context, + ["P2" if defect == "foreign_device" else "P1"], at, at, 10)) + assert caught.value.status_code == status + assert len(connection.statements) <= 1 + + +def test_location_accepts_frozen_pre_replay_normal_window(): + replay_id = str(uuid4()) + normal = START - timedelta(days=7) + run = {"start_time": START.isoformat(), + "baseline": {"start_time": (START - timedelta(days=15)).isoformat()}, + "devices": [{"device_id": "P1", "node_id": "J1"}, {"device_id": "F1", "link_id": "L1"}], + "monitoring_config": {"pressure_device_ids": ["P1"], "outlet_flow_device_ids": ["F1"]}} + readings = [{"reading": {"time": at.isoformat(), "device_id": device, + "cleaned_value": value, "quality": "valid"}} + for at, device, value in [(normal, "P1", 100), (normal, "F1", 50), + (START + timedelta(minutes=15), "P1", 70), + (START + timedelta(minutes=15), "F1", 80)]] + connection = Connection([[{"document": run}], readings]) + context = {"replay_id": replay_id, "knowledge_cutoff": (START + timedelta(hours=1)).isoformat(), "batch_sequence": 9} + result = asyncio.run(replay_burst_location_inputs(connection, uuid4(), context, + _args(scada_normal_start=normal, scada_normal_end=normal + timedelta(minutes=14)))) + assert result["normal_pressure"] == {"J1": 100} + assert result["burst_flow"] == {"L1": 80} + + def test_replay_location_authorization_requires_explicit_bounded_normal_window(monkeypatch): binding = BoundContext( context_handle="a" * 43, project_id=uuid4(), subject="u", client_id="host", diff --git a/tests/unit/test_scada_replay_service.py b/tests/unit/test_scada_replay_service.py index c9bb435..296fa3b 100644 --- a/tests/unit/test_scada_replay_service.py +++ b/tests/unit/test_scada_replay_service.py @@ -10,23 +10,38 @@ import pytest from fastapi import HTTPException from app.domain.schemas.scada_replay import ScadaReplayCreate, ScadaReplayCommand +from app.domain.schemas.project_settings import ScadaMonitoringSettings from app.infra.db.postgresql.scada import ScadaInfoRepository from app.infra.db.timescaledb.repositories.scada import ScadaRepository -from app.services.scada_replay import ScadaReplayService +from app.services.scada_replay import BaselineBudgetExceeded, ScadaReplayService, snapshot START = datetime(2026, 10, 8, tzinfo=timezone.utc) class Connection: + def __init__(self, owner=None): + self.statements, self.depth, self.owner = [], 0, owner + + async def execute(self, statement): self.statements.append(statement) + @asynccontextmanager async def transaction(self): - yield + prior = (deepcopy(self.owner.document), deepcopy(self.owner.batches)) if self.owner else None + self.depth += 1 + try: + yield + except BaseException: + if prior: + self.owner.document, self.owner.batches = prior + raise + finally: + self.depth -= 1 class Repository: def __init__(self): self.project_id = str(uuid4()) - self.connection = Connection() + self.connection = Connection(self) self.document = None self.batches = {} @@ -43,9 +58,20 @@ class Repository: async def get(self, _): return deepcopy(self.document) async def current(self): return deepcopy(self.document) async def pending_batch(self, _): - return next((deepcopy(batch) for batch in self.batches.values() if batch["stage"] != "detected"), None) + return next((deepcopy(batch) for batch in self.batches.values() + if batch["sequence"] > 0 and batch["stage"] != "detected"), None) async def save_batch(self, _, batch): self.batches[batch["sequence"]] = deepcopy(batch) - async def monitoring_outputs(self, *_args): return [] + async def clear_unpublished_baseline(self, _): + self.batches = {key: value for key, value in self.batches.items() + if not (key < 0 and value.get("kind") == "baseline" and value["stage"] == "clean_prepared")} + async def publish_baseline(self, _): + for key, value in self.batches.items(): + if key < 0 and value.get("kind") == "baseline" and value["stage"] == "clean_prepared": + value["stage"] = "detected" + async def monitoring_outputs(self, _replay_id, device_ids, start, end, before_sequence): + return [row for batch in self.batches.values() if batch["sequence"] < before_sequence + and batch["stage"] == "detected" for row in batch.get("outputs", []) + if row["device_id"] in device_ids and start <= datetime.fromisoformat(row["time"]) <= end] def setup(monkeypatch): @@ -59,6 +85,8 @@ def setup(monkeypatch): "device_id": "P1", "device_type": "pressure", "measurement_unit": "m", "transmission_frequency": "0:15:00" }])) async def raw_page(_connection, _devices, _start, _end, _through, _limit, after=None, **_): + if _end <= START: + return [] return ([{"time": START, "device_id": "P1", "monitored_value": 20., "source_hash": "h"}] if after is None else []) query = AsyncMock(side_effect=raw_page) @@ -66,11 +94,269 @@ def setup(monkeypatch): monkeypatch.setattr(ScadaRepository, "get_raw_page", query) monkeypatch.setattr(ScadaRepository, "apply_replay_cleaning", cleaning) monkeypatch.setattr(service, "_create_investigations", AsyncMock()) + monkeypatch.setattr(service, "_publish_monitoring_alert", AsyncMock()) + monkeypatch.setattr("app.services.scada_replay.permissions_for_context", lambda _: {"task.write"}) + monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService.create", + AsyncMock(return_value=SimpleNamespace(task_id=uuid4()))) create = ScadaReplayCreate(request_id=uuid4(), start_time=START, end_time=START + timedelta(hours=1), device_ids=["P1"], rules=[{"rule_id": "p", "device_ids": ["P1"], "kind": "bounds", "upper": 10, "unit": "m"}]) return repo, service, query, cleaning, create +def test_baseline_freezes_all_pages_silently_without_cleaning_the_source(monkeypatch): + repo, service, query, cleaning, create = setup(monkeypatch) + before = START - timedelta(minutes=15) + query.side_effect = None + query.side_effect = [ + [{"time": before - timedelta(minutes=15), "device_id": "P1", "monitored_value": 100., "source_hash": "a"}], + [{"time": before, "device_id": "P1", "monitored_value": 200., "source_hash": "b"}], + [], + ] + + async def run(): + await service.create(create, monitoring_interval_seconds=900) + document = await repo.get(None) + await service._freeze_baseline(document, create) + assert datetime.fromisoformat(repo.document["virtual_now"]) == create.start_time + assert repo.document["batch_sequence"] == repo.document["processed_count"] == 0 + assert repo.document["anomaly_count"] == repo.document["monitoring_count"] == 0 + assert repo.document["rule_states"] == repo.document["latest"] == {} + assert repo.document["baseline"]["row_count"] == 2 + assert repo.document["histories"]["P1"] == [100., 200.] + assert set(repo.batches) == {-1, -2} + assert all(batch["kind"] == "baseline" and batch["stage"] == "detected" + and batch["events"] == [] for batch in repo.batches.values()) + assert repo.batches[-2]["outputs"][0]["cleaned_value"] == 200. + + asyncio.run(run()) + assert service.timescale.statements == ["SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY"] + assert query.await_args_list[0].args[2:4] == (START - timedelta(days=15), START) + assert query.await_args_list[1].args[6] == (before - timedelta(minutes=15), "P1") + cleaning.assert_not_awaited() + service._create_investigations.assert_not_awaited() + + +def test_snapshot_returns_frozen_monitoring_rules_and_actual_calibration(monkeypatch): + repo, service, _, _, create = setup(monkeypatch) + settings = ScadaMonitoringSettings(pressure_device_ids=["P1"]) + + async def run(): + await service.create(create, monitoring_interval_seconds=900, monitoring_config=settings) + repo.document["monitoring_calibration"] = { + "status": "ready", "burst_detection": {"threshold": -0.0843}, + "pressure": {"P1": {"threshold_percent": 6.5}}, + } + # Project settings may change later; the replay still reports its frozen configuration. + settings.weights.pressure = 0.3 + settings.weights.burst_detection = 0.6 + result = snapshot(repo.document) + assert result.monitoring_config.weights.pressure == 0.4 + assert result.monitoring_config.weights.burst_detection == 0.5 + assert result.monitoring_calibration["burst_detection"]["threshold"] == -0.0843 + repo.document["monitoring_config"].pop("decision_policy") + assert snapshot(repo.document).monitoring_config.decision_policy == "all_signals" + asyncio.run(run()) + + +def test_baseline_stages_each_page_before_reading_more_without_publishing_or_holding_business_transaction(monkeypatch): + repo, service, query, _, create = setup(monkeypatch) + calls = 0 + + async def raw_page(*args, **kwargs): + nonlocal calls + assert service.timescale.depth == 1 + assert repo.connection.depth == 0 + if calls: + assert repo.batches[-calls]["stage"] == "clean_prepared" + assert not repo.document.get("warmed") and "baseline" not in repo.document + assert await repo.pending_batch(None) is None + assert await repo.monitoring_outputs(None, ["P1"], START - timedelta(days=15), START, 1) == [] + calls += 1 + if calls > 2: + return [] + return [{"time": START - timedelta(minutes=45 - calls * 15), "device_id": "P1", + "monitored_value": 30., "source_hash": str(calls)}] + + query.side_effect = raw_page + + async def run(): + await service.create(create, monitoring_interval_seconds=900) + await service._freeze_baseline(await repo.get(None), create) + assert repo.document["warmed"] and repo.document["baseline"]["row_count"] == 2 + assert all(batch["stage"] == "detected" for batch in repo.batches.values()) + + asyncio.run(run()) + + +def test_failed_baseline_read_never_publishes_partial_history(monkeypatch): + repo, service, query, cleaning, create = setup(monkeypatch) + query.side_effect = [ + [{"time": START - timedelta(minutes=15), "device_id": "P1", "monitored_value": 30., "source_hash": "a"}], + RuntimeError("source unavailable"), + ] + + async def run(): + await service.create(create, monitoring_interval_seconds=900) + with pytest.raises(RuntimeError, match="source unavailable"): + await service._freeze_baseline(await repo.get(None), create) + assert "baseline" not in repo.document and not repo.document.get("warmed") + assert repo.batches[-1]["stage"] == "clean_prepared" + assert await repo.pending_batch(None) is None + assert await repo.monitoring_outputs(None, ["P1"], START - timedelta(days=15), START, 1) == [] + + asyncio.run(run()) + cleaning.assert_not_awaited() + + +def test_baseline_retry_discards_unpublished_pages_and_refreezes_one_source_snapshot(monkeypatch): + repo, service, query, _, create = setup(monkeypatch) + def row(minutes, value): + return {"time": START - timedelta(minutes=minutes), "device_id": "P1", + "monitored_value": value, "source_hash": str(value)} + query.side_effect = [[row(30, 100.)], [row(15, 200.)], RuntimeError("source unavailable")] + + async def run(): + first = await service.create(create, monitoring_interval_seconds=900) + await service.command(first.replay_id, ScadaReplayCommand( + request_id=uuid4(), expected_revision=1, action="step")) + with pytest.raises(RuntimeError, match="source unavailable"): + await service.process_pending_step(first.replay_id) + assert repo.document["status"] == "failed" and set(repo.batches) == {-1, -2} + assert all(batch["stage"] == "clean_prepared" for batch in repo.batches.values()) + query.side_effect = [[row(15, 400.)], [], []] + await service.command(first.replay_id, ScadaReplayCommand( + request_id=uuid4(), expected_revision=2, action="retry")) + result = await service.process_pending_step(first.replay_id) + assert not result.step_pending and result.status == "paused" + assert repo.document["baseline"]["row_count"] == 1 + assert repo.document["histories"]["P1"] == [400.] + assert -2 not in repo.batches and repo.batches[-1]["stage"] == "detected" + assert repo.batches[-1]["outputs"][0]["cleaned_value"] == 400. + assert result.anomaly_count == result.processed_count == 0 + asyncio.run(run()) + + +@pytest.mark.parametrize("budget", ["rows", "bytes"]) +def test_baseline_budget_stops_before_saving_excess_rows_and_exposes_actionable_error(monkeypatch, budget): + repo, service, query, cleaning, create = setup(monkeypatch) + if budget == "rows": + monkeypatch.setattr("app.services.scada_replay.BASELINE_MAX_ROWS", 2) + else: + monkeypatch.setattr("app.services.scada_replay.BASELINE_MAX_BYTES", 1) + query.side_effect = [ + [{"time": START - timedelta(minutes=minute), "device_id": "P1", + "monitored_value": 30., "source_hash": str(minute)} for minute in [45, 30]], + [{"time": START - timedelta(minutes=15), "device_id": "P1", "monitored_value": 30., "source_hash": "15"}], + ] + + async def run(): + first = await service.create(create, monitoring_interval_seconds=900) + await service.command(first.replay_id, ScadaReplayCommand( + request_id=uuid4(), expected_revision=1, action="step")) + with pytest.raises(BaselineBudgetExceeded): + await service.process_pending_step(first.replay_id) + result = snapshot(repo.document) + assert result.status == "failed" and not result.step_pending + assert "BASELINE_BUDGET_EXCEEDED" in result.error and "历史基线" in result.error and "重试" in result.error + assert "baseline" not in repo.document and not repo.document.get("warmed") + assert result.processed_count == result.anomaly_count == result.monitoring_count == 0 + assert result.virtual_now == START + assert set(repo.batches) == ({-1} if budget == "rows" else set()) + assert all(batch["stage"] == "clean_prepared" for batch in repo.batches.values()) + asyncio.run(run()) + cleaning.assert_not_awaited() + service._create_investigations.assert_not_awaited() + assert query.await_count == (2 if budget == "rows" else 1) + if budget == "rows": + assert query.await_args_list[1].args[5] == 1 + + +def test_failed_baseline_publication_rolls_back_visibility_and_initialized_marker(monkeypatch): + repo, service, query, _, create = setup(monkeypatch) + query.side_effect = [[{"time": START - timedelta(minutes=15), "device_id": "P1", + "monitored_value": 30., "source_hash": "a"}], []] + + async def run(): + await service.create(create, monitoring_interval_seconds=900) + monkeypatch.setattr(repo, "save", AsyncMock(side_effect=RuntimeError("publish unavailable"))) + with pytest.raises(RuntimeError, match="publish unavailable"): + await service._freeze_baseline(await repo.get(None), create) + assert repo.batches[-1]["stage"] == "clean_prepared" + assert "baseline" not in repo.document and not repo.document.get("warmed") + assert await repo.monitoring_outputs(None, ["P1"], START - timedelta(days=15), START, 1) == [] + asyncio.run(run()) + + +def test_baseline_already_published_is_not_refrozen_or_replaced(monkeypatch): + repo, service, query, _, create = setup(monkeypatch) + + async def run(): + await service.create(create, monitoring_interval_seconds=900) + repo.document.update(warmed=True, baseline={"row_count": 1}) + repo.batches[-1] = {"sequence": -1, "stage": "detected", "kind": "baseline", "outputs": []} + before = deepcopy((repo.document, repo.batches)) + await service._freeze_baseline(await repo.get(None), create) + assert (repo.document, repo.batches) == before + asyncio.run(run()) + query.assert_not_awaited() + + +def test_first_step_uses_frozen_fifteen_days_without_counting_historical_events(monkeypatch): + repo, service, query, cleaning, _ = setup(monkeypatch) + pressure_ids, flow_id = [f"P{i}" for i in range(5)], "F1" + device_ids = pressure_ids + [flow_id] + config = ScadaReplayCreate( + request_id=uuid4(), start_time=START, end_time=START + timedelta(hours=1), device_ids=device_ids, + rules=[{"rule_id": "pressure", "kind": "relative_change", "device_ids": pressure_ids, + "direction": "decrease", "threshold": 20, "unit": "m"}, + {"rule_id": "flow", "kind": "relative_change", "device_ids": [flow_id], + "direction": "increase", "threshold": 20, "unit": "m3/h"}], + ) + monkeypatch.setattr(ScadaInfoRepository, "get_scadas", AsyncMock(return_value=[{ + "device_id": device, "device_type": "pressure" if device in pressure_ids else "pipe_flow", + "measurement_unit": "m" if device in pressure_ids else "m3/h", + "transmission_frequency": "0:15:00", + } for device in device_ids])) + source = [{"time": START + timedelta(minutes=15 * i), "device_id": device, + "monitored_value": (70. if device in pressure_ids else 150.) if i == 1 else 100., + "source_hash": f"{i}-{device}"} + for i in range(-15 * 96, 2) for device in sorted(device_ids)] + + async def raw_page(_connection, _devices, start, end, through, limit, after=None, **options): + rows = [row for row in source if start <= row["time"] < end and row["time"] <= through + and (after is None or (row["time"], row["device_id"]) > after)] + if options.get("frame") and rows: + rows = [row for row in rows if row["time"] == rows[0]["time"]] + return rows[:limit] + + query.side_effect = raw_page + def detection(**kwargs): + target = kwargs["observed_pressure_data"].iloc[-1].min() + return {"rows": [{"Score": 0.02}] * 14 + [{"Score": -0.2 if target < 90 else 0.02}], "summary": {}} + monkeypatch.setattr("app.services.scada_replay_monitoring.run_burst_detection", detection) + monkeypatch.setattr(service, "_create_monitoring_task", AsyncMock()) + + async def run(): + first = await service.create(config, monitoring_interval_seconds=900, + monitoring_config=ScadaMonitoringSettings( + pressure_device_ids=pressure_ids, outlet_flow_device_ids=[flow_id])) + await service.command(first.replay_id, ScadaReplayCommand( + request_id=uuid4(), expected_revision=1, action="step")) + result = await service.process_pending_step(first.replay_id) + assert result.last_monitoring.status == "suspected_burst" + assert result.last_monitoring.weighted["weights"]["outlet_flow"] == 0.1 + assert repo.document["monitoring_calibration"]["status"] == "ready" + assert result.processed_count == 12 and result.monitoring_count == 1 + assert repo.document["baseline"]["row_count"] == 8640 + assert result.virtual_now == START + timedelta(minutes=15) + assert all(not batch["events"] for key, batch in repo.batches.items() if key < 0) + + asyncio.run(run()) + assert cleaning.await_count == 2 + assert all(row["time"] >= START.isoformat() + for call in cleaning.await_args_list for row in call.args[1]) + + def test_step_command_is_revisioned_idempotent_and_does_not_process_until_requested(monkeypatch): repo, service, query, cleaning, create = setup(monkeypatch) async def run(): @@ -89,6 +375,79 @@ def test_step_command_is_revisioned_idempotent_and_does_not_process_until_reques query.assert_not_awaited(); cleaning.assert_not_awaited() +@pytest.mark.parametrize("minutes", [0, 10]) +def test_monitoring_stops_exactly_at_selected_time_and_reuses_same_period(monkeypatch, minutes): + repo, service, query, cleaning, create = setup(monkeypatch) + target = START + timedelta(minutes=minutes) + + async def run(): + initial = await service.create(create, monitoring_interval_seconds=900) + pending = await service.command(initial.replay_id, ScadaReplayCommand( + request_id=uuid4(), expected_revision=1, action="monitor", at_time=target)) + assert pending.step_pending + assert datetime.fromisoformat(repo.document["step_target"]) == target + result = await service.process_pending_step(initial.replay_id) + assert result.virtual_now == target + assert result.last_monitoring.period_end == target + assert result.last_monitoring.knowledge_cutoff == target + assert result.monitoring_count == 1 + reads, writes, batches = query.await_count, cleaning.await_count, len(repo.batches) + repeated = await service.command(initial.replay_id, ScadaReplayCommand( + request_id=uuid4(), expected_revision=result.revision, action="monitor", at_time=target)) + assert not repeated.step_pending + assert repeated.last_monitoring == result.last_monitoring + assert repeated.monitoring_count == 1 + assert (query.await_count, cleaning.await_count, len(repo.batches)) == (reads, writes, batches) + await service.command(initial.replay_id, ScadaReplayCommand( + request_id=uuid4(), expected_revision=repeated.revision, action="step")) + assert datetime.fromisoformat(repo.document["step_target"]) == START + timedelta(minutes=15) + + asyncio.run(run()) + assert all(call.args[4] <= target for call in query.await_args_list) + + +@pytest.mark.parametrize("minutes,status_code", [(-5, 422), (65, 422), (15, 409)]) +def test_selected_monitoring_rejects_out_of_range_or_backward_targets_without_processing(monkeypatch, minutes, status_code): + repo, service, query, cleaning, create = setup(monkeypatch) + + async def run(): + initial = await service.create(create, monitoring_interval_seconds=900) + repo.document["virtual_now"] = (START + timedelta(minutes=30)).isoformat() + before = deepcopy(repo.document) + with pytest.raises(HTTPException) as failure: + await service.command(initial.replay_id, ScadaReplayCommand( + request_id=uuid4(), expected_revision=1, action="monitor", at_time=START + timedelta(minutes=minutes))) + assert failure.value.status_code == status_code + assert repo.document == before + + asyncio.run(run()) + query.assert_not_awaited() + cleaning.assert_not_awaited() + + +def test_selected_monitoring_cannot_replace_a_pending_period(monkeypatch): + repo, service, query, cleaning, create = setup(monkeypatch) + + async def run(): + initial = await service.create(create, monitoring_interval_seconds=900) + await service.command(initial.replay_id, ScadaReplayCommand(request_id=uuid4(), expected_revision=1, action="step")) + before = deepcopy(repo.document) + with pytest.raises(HTTPException, match="当前时间步正在处理"): + await service.command(initial.replay_id, ScadaReplayCommand( + request_id=uuid4(), expected_revision=2, action="monitor", at_time=START)) + assert repo.document == before + + asyncio.run(run()) + query.assert_not_awaited() + cleaning.assert_not_awaited() + + +@pytest.mark.parametrize("action,at_time", [("monitor", None), ("monitor", "2026-07-16T08:00:00"), ("step", "2026-07-16T00:00:00Z")]) +def test_selected_monitoring_requires_an_explicit_zoned_time_only_for_monitor(action, at_time): + with pytest.raises(ValueError): + ScadaReplayCommand(request_id=uuid4(), expected_revision=1, action=action, at_time=at_time) + + def test_one_raw_read_then_correction_then_detection_without_reread(monkeypatch): repo, service, query, cleaning, create = setup(monkeypatch) async def run(): @@ -212,7 +571,8 @@ def test_partial_final_step_checks_empty_interval_and_completes_without_fabricat assert query.await_args.args[4] == create.end_time -def test_suspected_monitoring_creates_burst_report_with_frozen_replay_context(monkeypatch): +@pytest.mark.parametrize("weighted", [False, True]) +def test_suspected_monitoring_creates_burst_report_with_frozen_replay_context(monkeypatch, weighted): repo, service, *_ = setup(monkeypatch) create_task = AsyncMock(return_value=SimpleNamespace(task_id=uuid4())) monkeypatch.setattr("app.services.scada_replay.permissions_for_context", lambda _: {"task.write"}) @@ -231,12 +591,376 @@ def test_suspected_monitoring_creates_burst_report_with_frozen_replay_context(mo "burst_detection": {"target_score": -0.05}, "task_id": None, } + if weighted: + monitoring["burst_detection"]["score_threshold"] = -0.0843 + monitoring["weighted"] = { + "score": 0.9, "threshold": 0.8, + "weights": {"burst_detection": 0.5, "pressure": 0.4, "outlet_flow": 0.1}, + } document = {"replay_id": str(uuid4())} asyncio.run(service._create_monitoring_task(document, monitoring, {"sequence": 8})) command = create_task.await_args.args[0] - assert command.workflow == "burst_report" + assert command.workflow == "scada_anomaly" assert command.source.replay_context.batch_sequence == 8 assert command.source.replay_context.knowledge_cutoff == START + timedelta(minutes=15) assert monitoring["task_id"] == str(create_task.return_value.task_id) + assert document["monitoring_incident"]["alert"]["condition_id"] == f"operational-task:{monitoring['task_id']}" + assert document["monitoring_incident"]["alert"]["description"] == "已启动调查,详细诊断见调查报告。" + if weighted: + assert "-0.0843" in command.summary + assert "综合评分 0.90" in command.summary + assert "流量 10%" in command.summary + + +def test_monitoring_summary_counts_signals_without_copying_reading_details(monkeypatch): + _, service, *_ = setup(monkeypatch) + create_task = AsyncMock(return_value=SimpleNamespace(task_id=uuid4())) + monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object()) + monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService", lambda *_: SimpleNamespace(create=create_task)) + monitoring = incident_monitoring(0) + monitoring["anomaly_signals"] = [ + {"device_ids": [f"P{i}"], "detail": f"P{i}=28.14402136077437 m,超出冻结正常同时段范围 [28.3253, 28.8685] m"} + for i in range(100) + ] + + asyncio.run(service._create_monitoring_task({"replay_id": str(uuid4())}, monitoring, {"sequence": 1})) + + summary = create_task.await_args.args[0].summary + assert len(summary) <= 300 + assert "100" in summary + assert "28.14402136077437" not in summary + assert monitoring["anomaly_signals"][0]["detail"].startswith("P0=28.14402136077437") + + +def test_continuing_burst_reuses_task_across_persisted_periods(monkeypatch): + repo, service, *_ = setup(monkeypatch) + create_task = AsyncMock(return_value=SimpleNamespace(task_id=uuid4())) + advance_task = AsyncMock() + monkeypatch.setattr("app.services.scada_replay.permissions_for_context", lambda _: {"task.write"}) + monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object()) + monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService", + lambda *_: SimpleNamespace(create=create_task, advance_replay_investigation=advance_task)) + document = {"replay_id": str(uuid4())} + + async def run(): + nonlocal document + for step in range(3): + monitoring = incident_monitoring(step, status="attention" if step == 0 else "suspected_burst") + await service._create_monitoring_task(document, monitoring, {"sequence": step + 1}) + document = deepcopy(document) # Simulate loading persisted state after restart. + assert monitoring["task_id"] == str(create_task.return_value.task_id) + assert create_task.await_count == 1 + service._publish_monitoring_alert.assert_awaited_once() + assert document["monitoring_incident"]["pressure_anchors"]["P1"]["baseline"] == 100. + advance_task.assert_awaited_once() + assert advance_task.await_args.args[0] == create_task.return_value.task_id + assert advance_task.await_args.args[1]["knowledge_cutoff"] == (START + timedelta(minutes=30)).isoformat() + + asyncio.run(run()) + + +def incident_monitoring(step, *, status="suspected_burst", value=60.): + return { + "monitoring_id": str(uuid4()), "status": status, + "period_start": (START + timedelta(minutes=15 * step)).isoformat(), + "period_end": (START + timedelta(minutes=15 * (step + 1))).isoformat(), + "knowledge_cutoff": (START + timedelta(minutes=15 * (step + 1))).isoformat(), + "rule_version": "burst-monitor-v2", "detail": "监测结论", "task_id": None, + "pressure": {"status": "ready", "hit": status == "suspected_burst", "hit_device_ids": ["P1"], + "devices": [{"device_id": "P1", "baseline": 100., "value": value, + "threshold_percent": 10., "hit": status == "suspected_burst", + "observed_at": (START + timedelta(minutes=15 * (step + 1))).isoformat()}]}, + "outlet_flow": {"status": "ready", "hit": False, "hit_device_ids": []}, + "burst_detection": {"status": "ready", "hit": status == "suspected_burst", "target_score": -0.2}, + } + + +def test_incident_recovers_once_then_allows_new_accident(monkeypatch): + _, service, *_ = setup(monkeypatch) + create_task = AsyncMock(side_effect=[SimpleNamespace(task_id=uuid4()), SimpleNamespace(task_id=uuid4())]) + monkeypatch.setattr("app.services.scada_replay.permissions_for_context", lambda _: {"task.write"}) + monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object()) + monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService", + lambda *_: SimpleNamespace(create=create_task)) + document = {"replay_id": str(uuid4())} + + async def run(): + first = incident_monitoring(0) + await service._create_monitoring_task(document, first, {"sequence": 1}) + for step in (1, 2): + result = incident_monitoring(step, status="normal", value=100.) + await service._create_monitoring_task(document, result, {"sequence": step + 1}) + assert result["incident"]["action"] == "resolved" + assert result["task_id"] == first["task_id"] + recurrence = incident_monitoring(3) + await service._create_monitoring_task(document, recurrence, {"sequence": 4}) + assert recurrence["incident"]["incident_id"] != first["incident"]["incident_id"] + assert recurrence["task_id"] != first["task_id"] + assert create_task.await_count == 2 + assert [call.args[2] for call in service._publish_monitoring_alert.await_args_list] == ["raised", "resolved", "raised"] + commands = [call.args[0] for call in create_task.await_args_list] + assert commands[0].source.replay_context.knowledge_cutoff == START + timedelta(minutes=15) + + asyncio.run(run()) + + +@pytest.mark.parametrize("interruption", ["insufficient_data", "attention", "normal_low_pressure", "missing_sensor", "stale_reading", "time_gap"]) +def test_incident_needs_two_fresh_complete_pressure_recoveries(interruption): + from app.services.scada_monitoring_incidents import update_incident + document = {"replay_id": str(uuid4())} + incident, _ = update_incident(document, incident_monitoring(0), 1) + incident["task_id"] = str(uuid4()) + update_incident(document, incident_monitoring(1, status="normal", value=100.), 2) + interrupted = incident_monitoring(2, status="normal", value=100.) + if interruption in {"insufficient_data", "attention"}: + interrupted["status"] = interruption + elif interruption == "normal_low_pressure": + interrupted["pressure"]["devices"][0]["value"] = 60. + elif interruption == "missing_sensor": + interrupted["pressure"]["devices"] = [] + elif interruption == "stale_reading": + interrupted["pressure"]["devices"][0]["observed_at"] = incident["last_observed_at"] + else: + interrupted["period_start"] = START.isoformat() + incident, action = update_incident(document, interrupted, 3) + assert action == "ongoing" + assert incident["recovery_count"] == (1 if interruption == "time_gap" else 0) + if interruption != "time_gap": + _, action = update_incident(document, incident_monitoring(3, status="normal", value=100.), 4) + assert action == "ongoing" + + +def test_same_period_retry_does_not_count_as_second_recovery(): + from app.services.scada_monitoring_incidents import update_incident + document = {"replay_id": str(uuid4())} + update_incident(document, incident_monitoring(0), 1) + recovery = incident_monitoring(1, status="normal", value=100.) + update_incident(document, recovery, 2) + incident, action = update_incident(deepcopy(document), recovery, 2) + assert action == "ongoing" and incident["recovery_count"] == 1 + + +def test_older_replay_adopts_existing_task_without_creating_another(monkeypatch): + _, service, *_ = setup(monkeypatch) + previous = incident_monitoring(0) + previous["task_id"] = str(uuid4()) + document = {"replay_id": str(uuid4()), "last_monitoring": previous} + current = incident_monitoring(1) + asyncio.run(service._create_monitoring_task(document, current, {"sequence": 2})) + assert current["task_id"] == previous["task_id"] + assert current["incident"]["action"] == "ongoing" + service._publish_monitoring_alert.assert_not_awaited() + + +def test_combined_monitoring_alert_uses_same_facts_for_recovery(monkeypatch): + repo, service, *_ = setup(monkeypatch) + push = AsyncMock() + monkeypatch.setattr("app.infra.db.postgresql.task_alerts.TaskAlertRepository", lambda *_: object()) + monkeypatch.setattr("app.services.task_alerts.TaskAlertService", lambda *_args, **_kwargs: SimpleNamespace(push=push)) + incident = {"incident_id": str(uuid4()), "alert": { + "alert_id": "test-accident", "producer_id": "scada-replay:demo", + "occurred_at": START.isoformat(), "condition_id": None, + "source": {"kind": "scada_replay", "replay_id": "demo", "rule_id": "burst_monitor", "batch_sequence": 1}, + "title": "SCADA 监测疑似爆管", "description": "同一事故", "severity": "warning", + }} + + async def run(): + for change in ("raised", "resolved"): + await ScadaReplayService._publish_monitoring_alert(service, {"replay_id": "demo"}, incident, change) + asyncio.run(run()) + first, second = [call.args[0].model_dump(mode="json") for call in push.await_args_list] + assert first.pop("event_id") != second.pop("event_id") + assert first.pop("event_type") == "raised" + assert second.pop("event_type") == "resolved" + assert first == second + + +def test_combined_monitoring_keeps_rule_evidence_and_quality_notifications(monkeypatch): + _, service, *_ = setup(monkeypatch) + push = AsyncMock() + monkeypatch.setattr("app.services.scada_replay.permissions_for_context", lambda _: {"task.write"}) + monkeypatch.setattr("app.infra.db.postgresql.task_alerts.TaskAlertRepository", lambda *_: object()) + monkeypatch.setattr("app.services.task_alerts.TaskAlertService", lambda *_args, **_kwargs: SimpleNamespace(push=push)) + monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object()) + monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService", lambda *_: SimpleNamespace(create=AsyncMock())) + def event(rule, category=None): + return {"event_id": str(uuid4()), "rule_id": rule, "change": "raised", "occurred_at": START.isoformat(), + "detail": "检测信号", "category": category, "task_id": None} + pressure, water, quality = event("p"), event("water"), event("quality-P1", "data_quality") + document = {"replay_id": str(uuid4()), "monitoring_config": {"decision_policy": "weighted"}, + "config": {"rules": [{"rule_id": "p", "kind": "relative_change"}, {"rule_id": "water", "kind": "bounds"}]}, + "rule_states": {item["rule_id"]: {"event": deepcopy(item)} for item in (pressure, water, quality)}} + + async def run(): + await ScadaReplayService._create_investigations(service, document, [pressure, water, quality], {"sequence": 1}, create_tasks=False) + assert push.await_count == 1 # Measurement signals share diagnosis; telemetry quality remains independent. + assert document["rule_states"]["p"]["event"]["notification_suppressed"] + assert document["rule_states"]["water"]["event"]["notification_suppressed"] + resolved = {**document["rule_states"]["p"]["event"], "change": "resolved"} + await ScadaReplayService._create_investigations(service, document, [resolved], {"sequence": 2}, create_tasks=False) + assert push.await_count == 1 + resolved.pop("notification_suppressed") # An old alert raised before this change still resolves. + await ScadaReplayService._create_investigations(service, document, [resolved], {"sequence": 2}, create_tasks=False) + assert push.await_count == 2 + asyncio.run(run()) + + +def test_failed_incident_publication_rolls_back_state_and_retries_same_task_request(monkeypatch): + repo, service, _, _, create = setup(monkeypatch) + create_task = AsyncMock(return_value=SimpleNamespace(task_id=uuid4())) + monkeypatch.setattr("app.services.scada_replay.permissions_for_context", lambda _: {"task.write"}) + monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object()) + monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService", lambda *_: SimpleNamespace(create=create_task)) + def evaluate(_doc, _rows, *, period_start, period_end, batch_sequence): + return {**incident_monitoring(0), "period_start": period_start.isoformat(), "period_end": period_end.isoformat(), + "knowledge_cutoff": period_end.isoformat(), "batch_sequence": batch_sequence} + monkeypatch.setattr("app.services.scada_replay.evaluate_monitoring_period", evaluate) + service._publish_monitoring_alert.side_effect = [RuntimeError("alert unavailable"), None] + + async def run(): + first = await service.create(create, monitoring_interval_seconds=900) + repo.document["warmed"] = True + await service.command(first.replay_id, ScadaReplayCommand(request_id=uuid4(), expected_revision=1, action="step")) + with pytest.raises(RuntimeError, match="alert unavailable"): + await service.process_pending_step(first.replay_id) + assert repo.document["status"] == "failed" + assert "monitoring_incident" not in repo.document + assert repo.document["monitoring_count"] == 0 + await service.command(first.replay_id, ScadaReplayCommand(request_id=uuid4(), expected_revision=2, action="retry")) + result = await service.process_pending_step(first.replay_id) + assert result.last_monitoring.incident.action == "raised" + assert result.monitoring_count == 1 + first_command, retried_command = [call.args[0] for call in create_task.await_args_list] + assert first_command == retried_command + assert result.last_monitoring.task_id == create_task.return_value.task_id + asyncio.run(run()) + + +@pytest.mark.parametrize("indicator", ["pressure", "outlet_flow", "burst_detection", "quality", "partial_data"]) +def test_any_single_measurement_anomaly_enters_diagnosis_below_burst_threshold(monkeypatch, indicator): + _, service, *_ = setup(monkeypatch) + create_task = AsyncMock(return_value=SimpleNamespace(task_id=uuid4())) + monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object()) + monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService", lambda *_: SimpleNamespace(create=create_task)) + monitoring = incident_monitoring(0, status="attention") + for key in ("pressure", "outlet_flow", "burst_detection"): + monitoring[key]["hit"] = key == indicator + monitoring["weighted"] = {"score": 0.1, "threshold": 0.8, + "weights": {"burst_detection": 0.5, "pressure": 0.4, "outlet_flow": 0.1}} + if indicator == "quality": + monitoring["status"] = "insufficient_data" + monitoring["anomaly_signals"] = [{"event_id": str(uuid4()), "rule_id": "quality_bounds", + "device_ids": ["Q1"], "detail": "水质测点超出已配置上限"}] + monitoring["burst_detection"] = {"status": "not_run", "hit": False} + if indicator == "partial_data": + monitoring["status"] = "insufficient_data" + monitoring["pressure"]["hit"] = True + document = {"replay_id": str(uuid4())} + asyncio.run(service._create_monitoring_task(document, monitoring, {"sequence": 1})) + command = create_task.await_args.args[0] + assert command.workflow == "scada_anomaly" + assert command.source.event_kind == "scada_anomaly" + assert "任一指标异常即启动调查" in command.summary + assert "爆管嫌疑门槛 0.80" in command.summary + assert monitoring["task_id"] == str(create_task.return_value.task_id) + assert monitoring["incident"]["action"] == "raised" + + +def test_flow_only_incident_does_not_recover_while_absolute_flow_remains_high(): + from app.services.scada_monitoring_incidents import update_incident + document = {"replay_id": str(uuid4())} + first = incident_monitoring(0, status="attention", value=100.) + first["outlet_flow"] = {"status": "ready", "hit": True, "devices": [ + {"device_id": "F1", "hit": True, "baseline": 100., "threshold_percent": 10., "value": 150.} + ]} + incident, _ = update_incident(document, first, 1) + incident["task_id"] = str(uuid4()) + for step in (1, 2): + normal = incident_monitoring(step, status="normal", value=100.) + normal["outlet_flow"] = {"status": "ready", "hit": False, "devices": [ + {"device_id": "F1", "value": 150., "observed_at": normal["period_end"]} + ]} + _, action = update_incident(document, normal, step + 1) + assert action == "ongoing" + for step in (3, 4): + normal = incident_monitoring(step, status="normal", value=100.) + normal["outlet_flow"] = {"status": "ready", "hit": False, "devices": [ + {"device_id": "F1", "value": 100., "observed_at": normal["period_end"]} + ]} + _, action = update_incident(document, normal, step + 1) + assert action == "resolved" + + +def test_transient_measurement_anomaly_still_enters_diagnosis_at_period_end(monkeypatch): + repo, service, query, _, create = setup(monkeypatch) + readings = [(START, 20.), (START + timedelta(minutes=5), 5.), (START + timedelta(minutes=10), 5.)] + async def raw_page(_conn, _devices, _start, _end, through, _limit, after=None, **_): + return [{"time": at, "device_id": "P1", "monitored_value": value, "source_hash": str(index)} + for index, (at, value) in enumerate(readings) if at <= through and (after is None or at > after[0])][:1] + query.side_effect = raw_page + def evaluate(_doc, _rows, *, period_start, period_end, batch_sequence): + return {**incident_monitoring(0, status="normal", value=5.), + "period_start": period_start.isoformat(), "period_end": period_end.isoformat(), + "knowledge_cutoff": period_end.isoformat(), "batch_sequence": batch_sequence} + monkeypatch.setattr("app.services.scada_replay.evaluate_monitoring_period", evaluate) + async def run(): + initial = await service.create(create, monitoring_interval_seconds=900) + repo.document["warmed"] = True + await service.command(initial.replay_id, ScadaReplayCommand(request_id=uuid4(), expected_revision=1, action="step")) + result = await service.process_pending_step(initial.replay_id) + assert result.active_events == [] + assert result.last_monitoring.status == "attention" + assert result.last_monitoring.anomaly_signals[0]["rule_id"] == "p" + assert result.last_monitoring.task_id is not None + assert result.last_monitoring.incident.action == "raised" + assert "step_anomaly_signals" not in repo.document + asyncio.run(run()) + + +@pytest.mark.parametrize("action,status", [("step", "paused"), ("retry", "failed")]) +def test_warmed_weighted_replay_with_old_calibration_requires_rebuild(monkeypatch, action, status): + repo, service, _, _, create = setup(monkeypatch) + async def run(): + initial = await service.create(create, monitoring_interval_seconds=900, + monitoring_config=ScadaMonitoringSettings()) + repo.document.update(warmed=True, status=status, + monitoring_calibration={"version": "scada-monitor-calibration@1"}) + with pytest.raises(HTTPException) as error: + await service.command(initial.replay_id, ScadaReplayCommand( + request_id=uuid4(), expected_revision=1, action=action)) + assert error.value.status_code == 409 + assert "新建" in error.value.detail + asyncio.run(run()) + + +def test_old_active_percentage_event_does_not_enter_new_period_diagnosis(monkeypatch): + from app.services.scada_normal_profile import CALIBRATION_VERSION, DIAGNOSTIC_POLICY + repo, service, _, _, _ = setup(monkeypatch) + config = ScadaReplayCreate(request_id=uuid4(), start_time=START, end_time=START + timedelta(hours=1), + device_ids=["P1"], rules=[{"rule_id": "p", "device_ids": ["P1"], "kind": "relative_change", + "direction": "both", "threshold": 30, "unit": "m"}]) + monkeypatch.setattr(service, "_create_monitoring_task", AsyncMock()) + def evaluate(_doc, _rows, *, period_start, period_end, batch_sequence): + return {**incident_monitoring(0, status="normal", value=20.), + "period_start": period_start.isoformat(), "period_end": period_end.isoformat(), + "knowledge_cutoff": period_end.isoformat(), "batch_sequence": batch_sequence} + monkeypatch.setattr("app.services.scada_replay.evaluate_monitoring_period", evaluate) + async def run(): + initial = await service.create(config, monitoring_interval_seconds=900, + monitoring_config=ScadaMonitoringSettings()) + old = {"event_id": str(uuid4()), "rule_id": "p", "device_ids": ["P1"], "status": "active", + "occurred_at": START.isoformat(), "last_observed_at": START.isoformat(), + "detail": "旧30%事件", "task_id": None} + repo.document.update(warmed=True, rule_states={"p": {"event": old, "hits": 1, "recoveries": 0}}, + monitoring_calibration={"version": CALIBRATION_VERSION, "diagnostic_policy": DIAGNOSTIC_POLICY, + "knowledge_cutoff": START.isoformat(), "diagnostic": {"P1": { + "phase_origin": START.isoformat(), "interval_seconds": 900, "lower": [10.], "upper": [30.]}}}) + await service.command(initial.replay_id, ScadaReplayCommand( + request_id=uuid4(), expected_revision=1, action="step")) + result = await service.process_pending_step(initial.replay_id) + assert result.last_monitoring.status == "normal" + assert result.last_monitoring.anomaly_signals == [] + assert repo.document["rule_states"]["p"]["event"] == old + asyncio.run(run()) diff --git a/tests/unit/test_server_handoff.py b/tests/unit/test_server_handoff.py new file mode 100644 index 0000000..02db902 --- /dev/null +++ b/tests/unit/test_server_handoff.py @@ -0,0 +1,319 @@ +"""Real spawned workers share a listener while established streams drain.""" +from contextlib import contextmanager +import json +import multiprocessing +from pathlib import Path +from threading import Event, Thread +import time +from types import SimpleNamespace + +import httpx +import pytest +import uvicorn + +from scripts.server_handoff import HandoffSupervisor + + +def wait_for(predicate, *, timeout=5.): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return + time.sleep(0.02) + raise AssertionError("test-owned worker did not reach the expected state") + + +def next_event(lines, *, after_ns=0): + for line in lines: + if line.startswith("data: "): + event = json.loads(line[len("data: "):]) + if event.get("time_ns", 0) >= after_ns: + return event + raise AssertionError("SSE ended before its fixture release marker") + + +def owned_workers(supervisor): + return [worker for worker in [supervisor.current, *supervisor.retiring] if worker is not None] + + +def close_owned_supervisor(supervisor, directory, original_children): + """Release every fixture stream before waiting; bound cleanup even on failure.""" + (directory / "release-all").touch() + (directory / "allow-startup").touch() + workers = owned_workers(supervisor) + errors = [] + + def close(): + try: + supervisor.close() + except BaseException as error: + errors.append(error) + + closing = Thread(target=close, daemon=True) + closing.start() + closing.join(timeout=8) + leaked = [process for process in multiprocessing.active_children() + if process.pid not in original_children and process.is_alive()] + if closing.is_alive() or leaked: + # This is emergency teardown of processes created by this test, never + # production reload policy or an operation on the developer's service. + for process in leaked: + process.terminate() + for process in leaked: + process.join(timeout=3) + closing.join(timeout=3) + pytest.fail("handoff cleanup left a test-owned worker running") + for worker in workers: + worker.process.join(timeout=1) + assert not worker.process.is_alive() + if errors: + raise errors[0] + + +@pytest.fixture +def handoff(monkeypatch, tmp_path): + monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[1] / "fixtures")) + monkeypatch.setenv("SCADA_HANDOFF_FIXTURE_DIR", str(tmp_path)) + (tmp_path / "version").write_text("v1") + (tmp_path / "startup-mode").write_text("ready") + (tmp_path / "readiness").write_text("true") + original_children = {process.pid for process in multiprocessing.active_children()} + + @contextmanager + def running(*, startup_timeout=5., readiness_check=None): + config = uvicorn.Config( + "server_handoff_app:app", host="127.0.0.1", port=0, + lifespan="on", log_level="error", access_log=False, + timeout_graceful_shutdown=None, + ) + supervisor = HandoffSupervisor(config, startup_timeout=startup_timeout, poll_interval=.02, + readiness_check=readiness_check) + try: + yield supervisor, tmp_path + finally: + close_owned_supervisor(supervisor, tmp_path, original_children) + + return running + + +def base_url(supervisor): + return f"http://127.0.0.1:{supervisor.socket.getsockname()[1]}" + + +def health(supervisor, *, timeout=3.): + # A fresh TCP connection makes each assertion exercise current acceptance. + with httpx.Client(timeout=timeout, trust_env=False) as client: + response = client.get(base_url(supervisor) + "/health") + response.raise_for_status() + return response.json() + + +def test_first_worker_is_spawned_and_ready_before_start_returns(handoff): + with handoff() as (supervisor, _): + assert supervisor.start() is True + worker = supervisor.current + assert worker.process._start_method == "spawn" + assert worker.process.is_alive() + assert health(supervisor) == {"pid": worker.process.pid, "version": "v1"} + assert supervisor.retiring == [] + + +def test_reload_keeps_old_sse_until_release_and_new_requests_use_new_worker(handoff): + with handoff() as (supervisor, directory): + assert supervisor.start() is True + old = supervisor.current + with httpx.Client(timeout=3., trust_env=False) as client: + with client.stream("GET", base_url(supervisor) + "/events") as stream: + stream.raise_for_status() + lines = stream.iter_lines() + assert next_event(lines)["pid"] == old.process.pid + (directory / "version").write_text("v2") + (directory / "startup-mode").write_text("delay") + stop_probe = Event() + samples, errors = [], [] + + def probe_health(): + while not stop_probe.is_set(): + try: + samples.append(health(supervisor, timeout=.3)) + except Exception as error: + errors.append(repr(error)) + stop_probe.wait(.01) + + probing = Thread(target=probe_health, daemon=True) + probing.start() + try: + wait_for(lambda: samples) + assert supervisor.reload() is True + wait_for(lambda: any(sample["version"] == "v2" for sample in samples)) + finally: + stop_probe.set() + probing.join(timeout=2) + assert not probing.is_alive() + assert errors == [], f"HTTP availability gap during reload: {errors}" + assert {sample["version"] for sample in samples} == {"v1", "v2"} + + new = supervisor.current + assert new.process.pid != old.process.pid + assert old in supervisor.retiring + assert old.process.is_alive() + for _ in range(3): + assert health(supervisor) == {"pid": new.process.pid, "version": "v2"} + after_handoff = time.time_ns() + event = next_event(lines, after_ns=after_handoff) + assert (event["pid"], event["version"]) == (old.process.pid, "v1") + assert not (directory / f"stopped-{old.process.pid}").exists() + + (directory / f"release-{old.process.pid}").touch() + assert any(line == "event: complete" for line in lines) + old.process.join(timeout=5) + assert old.process.exitcode == 0 + assert (directory / f"stopped-{old.process.pid}").read_text() == "v1" + supervisor.reap() + assert old not in supervisor.retiring + assert health(supervisor) == {"pid": new.process.pid, "version": "v2"} + + +@pytest.mark.parametrize("mode", ["fail", "wait"]) +def test_candidate_startup_failure_keeps_previous_worker_serving(handoff, mode): + with handoff() as (supervisor, directory): + assert supervisor.start() is True + old = supervisor.current + (directory / "version").write_text("broken-v2") + (directory / "startup-mode").write_text(mode) + if mode == "wait": + supervisor.startup_timeout = .75 + assert supervisor.reload() is False + assert supervisor.current is old + assert old.process.is_alive() + assert health(supervisor) == {"pid": old.process.pid, "version": "v1"} + assert supervisor.retiring == [] + assert len(list(directory.glob("started-*"))) == 2 + + +def test_candidate_process_start_error_keeps_previous_worker_healthy(handoff, monkeypatch): + def fail_start(): + raise OSError("test process spawn rejected") + + with handoff() as (supervisor, _): + assert supervisor.start() is True + old = supervisor.current + candidate = SimpleNamespace(pid=None, start=fail_start) + monkeypatch.setattr("scripts.server_handoff.get_subprocess", lambda **_: candidate) + assert supervisor.reload() is False + assert supervisor.current is old + assert supervisor.retiring == [] + assert old.process.is_alive() + assert health(supervisor) == {"pid": old.process.pid, "version": "v1"} + + +def test_readiness_check_rejects_unusable_candidate_before_handoff(handoff): + with handoff(readiness_check="server_handoff_app:readiness_check") as (supervisor, directory): + assert supervisor.start() is True + old = supervisor.current + assert (directory / f"checked-{old.process.pid}").read_text() == "true" + assert health(supervisor) == {"pid": old.process.pid, "version": "v1"} + (directory / "version").write_text("not-ready-v2") + (directory / "readiness").write_text("false") + stop_probe = Event() + samples, errors = [], [] + + def probe_health(): + while not stop_probe.is_set(): + try: + samples.append(health(supervisor, timeout=.3)) + except Exception as error: + errors.append(repr(error)) + stop_probe.wait(.01) + + probing = Thread(target=probe_health, daemon=True) + probing.start() + try: + wait_for(lambda: samples) + assert supervisor.reload() is False + finally: + stop_probe.set() + probing.join(timeout=2) + assert not probing.is_alive() + assert errors == [] + assert all(sample == {"pid": old.process.pid, "version": "v1"} for sample in samples) + rejected = [path for path in directory.glob("checked-*") if path.name != f"checked-{old.process.pid}"] + assert len(rejected) == 1 + assert rejected[0].read_text() == "false" + assert supervisor.current is old + assert old.process.is_alive() + assert supervisor.retiring == [] + assert health(supervisor) == {"pid": old.process.pid, "version": "v1"} + + (directory / "version").write_text("ready-v3") + (directory / "readiness").write_text("true") + assert supervisor.reload() is True + new = supervisor.current + assert new.process.pid != old.process.pid + assert (directory / f"checked-{new.process.pid}").read_text() == "true" + assert health(supervisor) == {"pid": new.process.pid, "version": "ready-v3"} + + +def test_close_waits_for_existing_stream_to_finish_without_forcing_worker(handoff): + with handoff() as (supervisor, directory): + assert supervisor.start() is True + worker = supervisor.current + with httpx.Client(timeout=3., trust_env=False) as client: + with client.stream("GET", base_url(supervisor) + "/events") as stream: + lines = stream.iter_lines() + assert next_event(lines)["pid"] == worker.process.pid + errors = [] + + def close(): + try: + supervisor.close() + except BaseException as error: + errors.append(error) + + closing = Thread(target=close, daemon=True) + closing.start() + try: + after_close = time.time_ns() + assert next_event(lines, after_ns=after_close)["pid"] == worker.process.pid + assert closing.is_alive() + assert worker.process.is_alive() + (directory / f"release-{worker.process.pid}").touch() + assert any(line == "event: complete" for line in lines) + finally: + (directory / "release-all").touch() + closing.join(timeout=5) + assert not closing.is_alive() + assert errors == [] + assert worker.process.exitcode == 0 + assert (directory / f"stopped-{worker.process.pid}").exists() + + +@pytest.mark.parametrize("active_alive,edited,expected_reloads", [(False, False, 1), (True, True, 0)]) +def test_retiring_limit_coalesces_edits_but_never_blocks_crash_recovery( + monkeypatch, active_alive, edited, expected_reloads, +): + class OneWatchIteration: + calls = 0 + + def wait(self, _timeout): + self.calls += 1 + return self.calls > 1 + + def set(self): + self.calls = 2 + + supervisor = HandoffSupervisor.__new__(HandoffSupervisor) + supervisor.current = SimpleNamespace(process=SimpleNamespace(is_alive=lambda: active_alive)) + supervisor.retiring = [object(), object()] + supervisor.should_exit = OneWatchIteration() + supervisor.poll_interval = .001 + supervisor._files = {} + calls = [] + monkeypatch.setattr(supervisor, "start", lambda: True) + monkeypatch.setattr(supervisor, "reap", lambda: None) + monkeypatch.setattr(supervisor, "_snapshot", lambda: {"edited.py": (1, 1)} if edited else {}) + monkeypatch.setattr(supervisor, "reload", lambda: calls.append("reload")) + monkeypatch.setattr(supervisor, "close", lambda: calls.append("close")) + supervisor.run() + assert calls.count("reload") == expected_reloads + assert calls[-1] == "close" diff --git a/tests/unit/test_wndb_projects.py b/tests/unit/test_wndb_projects.py index 354af80..88ffb18 100644 --- a/tests/unit/test_wndb_projects.py +++ b/tests/unit/test_wndb_projects.py @@ -281,6 +281,30 @@ def test_temporary_project_database_cleans_up_after_failure(monkeypatch) -> None ] +def test_temporary_project_database_copies_snapshot_without_template(monkeypatch) -> None: + calls: list[tuple[str, ...]] = [] + + @contextmanager + def temporary_template(project, purpose): + calls.append(("schema", project, purpose)) + yield "isolated_run" + + monkeypatch.setattr(projects, "have_project", lambda name: False) + monkeypatch.setattr(projects, "temporary_template_database", temporary_template) + monkeypatch.setattr( + "app.native.wndb.core.model_replace.copy_network_tables", + lambda source, target: calls.append(("model", source, target)), + ) + + with projects.temporary_project_database("project_a", "burst") as name: + assert name == "isolated_run" + + assert calls == [ + ("schema", "project_a", "burst"), + ("model", "project_a", "isolated_run"), + ] + + def test_temporary_template_database_does_not_clone_a_project(monkeypatch) -> None: calls: list[tuple[str, ...]] = [] monkeypatch.setattr(