Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
105c1ca8ab | ||
|
|
4b47ae00d1 | ||
|
|
d3a4433386 | ||
|
|
747e9e53e0 | ||
|
|
58a907bfbe | ||
|
|
11eb09fe8f | ||
|
|
b45c5c6f46 | ||
|
|
2e4f6cb93b | ||
|
|
471ad92a47 | ||
|
|
1892b1d1f0 | ||
|
|
2e8fe86f3f | ||
|
|
b853b61a0b | ||
|
|
e47c61c121 | ||
|
|
6a4478bfe4 | ||
|
|
5197a93163 | ||
|
|
549019507e | ||
|
|
acc8f8f7cb | ||
|
|
b8359ba83c | ||
|
|
c0dea2aacf | ||
|
|
33df25f22b | ||
|
|
2a7b96f813 | ||
|
|
06d8539aa8 | ||
|
|
2552a9b40d | ||
|
|
01bad116c1 | ||
|
|
bf8ea45d40 | ||
|
|
dc83cf5c88 | ||
|
|
ff8aba4964 | ||
|
|
26fa5d1909 | ||
|
|
f25423dec4 | ||
|
|
e48ccfba9e | ||
|
|
f2cfce8192 | ||
|
|
05183aff93 | ||
|
|
63af998045 | ||
|
|
2865811f1b | ||
|
|
af1dc11681 | ||
|
|
8673429ec4 | ||
|
|
6d31f456e6 | ||
|
|
cddfc8647d |
+36
-8
@@ -1,10 +1,13 @@
|
|||||||
# TJWater Server 环境变量模板
|
# NextTJWater Server 环境变量模板
|
||||||
# 复制为 .env 后按启用的功能填写;不要提交 .env 或任何真实凭据。
|
# 复制为 .env 后按启用的功能填写;不要提交 .env 或任何真实凭据。
|
||||||
# 生产配置由部署主机的受控 backend.env 注入,不要把完整配置保存为仓库密钥。
|
# 生产配置由部署主机的受控 next-backend.env 注入,不随镜像或 CI/CD 同步本地 .env。
|
||||||
|
# 进程环境变量优先;修改 Compose env_file 后需重新创建容器。
|
||||||
|
|
||||||
# 基础运行
|
# 基础运行
|
||||||
PROJECT_NAME="TJWater Server"
|
PROJECT_NAME="TJWater Server"
|
||||||
ENVIRONMENT="production"
|
ENVIRONMENT="production"
|
||||||
|
# 任务领取范围;留空时沿用 ENVIRONMENT。历史任务需先执行 034 迁移。
|
||||||
|
OPERATIONAL_TASK_EXECUTION_SCOPE=""
|
||||||
API_V1_STR="/api/v1"
|
API_V1_STR="/api/v1"
|
||||||
NETWORK_NAME="tjwater"
|
NETWORK_NAME="tjwater"
|
||||||
|
|
||||||
@@ -69,9 +72,11 @@ KEYCLOAK_ALGORITHM="RS256"
|
|||||||
KEYCLOAK_AUDIENCE="account"
|
KEYCLOAK_AUDIENCE="account"
|
||||||
KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS=900
|
KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS=900
|
||||||
|
|
||||||
# 工作台事件与跨域
|
# 浏览器跨域访问(主 API)
|
||||||
# 认证接口必须使用明确来源,逗号分隔;不要配置为 *。
|
# 必须填写明确来源(协议、域名或 IP、端口),英文逗号分隔;不允许 * 或网段正则。
|
||||||
CORS_ALLOWED_ORIGINS="http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:15173,http://localhost:15173"
|
CORS_ALLOWED_ORIGINS=http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:15173,http://localhost:15173
|
||||||
|
|
||||||
|
# 工作台事件
|
||||||
WORKBENCH_EVENTS_ENABLED=true
|
WORKBENCH_EVENTS_ENABLED=true
|
||||||
WORKBENCH_EVENT_RETENTION_DAYS=7
|
WORKBENCH_EVENT_RETENTION_DAYS=7
|
||||||
|
|
||||||
@@ -116,6 +121,17 @@ OPERATIONAL_ACTION_PERIPHERAL_URL=""
|
|||||||
OPERATIONAL_ACTION_COMMAND_GATEWAY_URL=""
|
OPERATIONAL_ACTION_COMMAND_GATEWAY_URL=""
|
||||||
OPERATIONAL_ACTION_TIMEOUT_SECONDS=10
|
OPERATIONAL_ACTION_TIMEOUT_SECONDS=10
|
||||||
|
|
||||||
|
# 外围样板(挂载于 /peripheral;故障注入默认关闭)
|
||||||
|
# 独立启动时用于调用主 API 验证身份;容器内按网络填写主服务地址。
|
||||||
|
PERIPHERAL_SAMPLE_NEXT_SERVER_URL="http://127.0.0.1:8000"
|
||||||
|
# 以下两个默认库选择只供种子、导出等脚本使用,HTTP 请求仍按已授权项目路由。
|
||||||
|
PERIPHERAL_SAMPLE_DB_NAME="tjwater_v2"
|
||||||
|
PERIPHERAL_SAMPLE_PROJECT_CODE="tjwater_v2"
|
||||||
|
# 子应用独立运行时的浏览器来源列表,使用 JSON 格式;挂载时还受主 API CORS 配置控制。
|
||||||
|
PERIPHERAL_SAMPLE_CORS_ORIGINS='["http://127.0.0.1:5173","http://localhost:5173"]'
|
||||||
|
PERIPHERAL_SAMPLE_ENABLE_FAULTS=false
|
||||||
|
PERIPHERAL_SAMPLE_TIMEOUT_SECONDS=5
|
||||||
|
|
||||||
# MCP 查询服务;OAuth 和所有项目业务库就绪前保持关闭。
|
# MCP 查询服务;OAuth 和所有项目业务库就绪前保持关闭。
|
||||||
TJWATER_MCP_ENABLED=false
|
TJWATER_MCP_ENABLED=false
|
||||||
TJWATER_MCP_ISSUER_URI=""
|
TJWATER_MCP_ISSUER_URI=""
|
||||||
@@ -129,7 +145,19 @@ TJWATER_MCP_HOST_CLIENT_ID="tjwater-agent-mcp"
|
|||||||
TJWATER_MCP_ALLOWED_HOSTS='["127.0.0.1:*","localhost:*"]'
|
TJWATER_MCP_ALLOWED_HOSTS='["127.0.0.1:*","localhost:*"]'
|
||||||
TJWATER_MCP_ALLOWED_ORIGINS='[]'
|
TJWATER_MCP_ALLOWED_ORIGINS='[]'
|
||||||
TJWATER_MCP_CONTEXT_TTL_SECONDS=600
|
TJWATER_MCP_CONTEXT_TTL_SECONDS=600
|
||||||
# 普通查询预算;计算工具由 Agent 通过 timeout_seconds 按次指定。
|
# 单次 HTTP 返回预算;由目录元数据公布给宿主,Agent 默认等待 180 秒。
|
||||||
TJWATER_MCP_QUERY_TIMEOUT_SECONDS=30
|
TJWATER_MCP_QUERY_TIMEOUT_SECONDS=120
|
||||||
# 计算等待上限为 120–86400 秒,实际上限通过 tools/list 发布。
|
# HTTP 请求预算上限;Agent 加 60 秒余量并封顶 270 秒,外层 MCP 总时限 300 秒。
|
||||||
|
TJWATER_MCP_MAX_REQUEST_TIMEOUT_SECONDS=240
|
||||||
|
# 后台计算独立执行时限为 120–86400 秒,不受工具 timeout_seconds 控制。
|
||||||
TJWATER_MCP_MAX_CALCULATION_TIMEOUT_SECONDS=3600
|
TJWATER_MCP_MAX_CALCULATION_TIMEOUT_SECONDS=3600
|
||||||
|
|
||||||
|
# 独立计算队列消费者;从 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
|
||||||
|
CALCULATION_WORKER_HEARTBEAT_SECONDS=5
|
||||||
|
# 持久化本机恢复账本;容器模板已挂载独立 volume,不能当缓存删除。
|
||||||
|
CALCULATION_WORKER_STATE_DIR="temp/calculation-jobs"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Use the existing conda environment when available:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
conda run -n server python -m pytest tests/unit tests/auth -q
|
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 build -t tjwater-server:local .
|
||||||
docker compose -f infra/docker/docker-compose.yml config
|
docker compose -f infra/docker/docker-compose.yml config
|
||||||
```
|
```
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@ COPY app ./app
|
|||||||
COPY contracts ./contracts
|
COPY contracts ./contracts
|
||||||
COPY infra ./infra
|
COPY infra ./infra
|
||||||
COPY resources ./resources
|
COPY resources ./resources
|
||||||
COPY scripts/run_operational_worker.py scripts/sync_peripheral_work_orders.py ./scripts/
|
COPY scripts/run_operational_worker.py scripts/run_calculation_worker.py scripts/sync_peripheral_work_orders.py ./scripts/
|
||||||
COPY samples/peripheral ./samples/peripheral
|
COPY samples/peripheral ./samples/peripheral
|
||||||
RUN python -c "from pathlib import Path; from zipfile import ZipFile; model_dir = Path('app/algorithms/pipe_health_prediction/model'); zip_path = model_dir / 'my_survival_forest_model_quxi.zip'; joblib_name = 'my_survival_forest_model_quxi.joblib'; joblib_path = model_dir / joblib_name; assert zip_path.exists(), f'Model archive not found: {zip_path}'; archive = ZipFile(zip_path); archive.extract(joblib_name, model_dir); archive.close(); assert joblib_path.exists(), f'Model file not extracted: {joblib_path}'" && \
|
RUN python -c "from pathlib import Path; from zipfile import ZipFile; model_dir = Path('app/algorithms/pipe_health_prediction/model'); zip_path = model_dir / 'my_survival_forest_model_quxi.zip'; joblib_name = 'my_survival_forest_model_quxi.joblib'; joblib_path = model_dir / joblib_name; assert zip_path.exists(), f'Model archive not found: {zip_path}'; archive = ZipFile(zip_path); archive.extract(joblib_name, model_dir); archive.close(); assert joblib_path.exists(), f'Model file not extracted: {joblib_path}'" && \
|
||||||
rm -f app/algorithms/pipe_health_prediction/model/my_survival_forest_model_quxi.zip
|
rm -f app/algorithms/pipe_health_prediction/model/my_survival_forest_model_quxi.zip
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# TJWaterServerBinary 内部后端
|
# NextTJWater Server
|
||||||
|
|
||||||
`TJWaterServerBinary` 是 TJWater 内部版 Python 后端,基于 FastAPI 提供认证、项目、管网、模拟、爆管、漏损、SCADA 和地图服务集成能力。该仓库用于内部开发和完整功能维护。
|
`next-tjwater-server` 是 NextTJWater 供水系统的 FastAPI 后端,提供认证、项目、管网、模拟、爆管、漏损、SCADA、地图服务、工况任务和受控 MCP 能力。业务数据和时序数据按已认证项目路由,后台工作进程也必须使用明确的项目范围。
|
||||||
|
|
||||||
|
协议、工作进程、数据库、SCADA 与历史资料索引见 [文档目录](docs/README.md)。
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
@@ -30,11 +32,19 @@ infra/docker/ Docker Compose 编排
|
|||||||
|
|
||||||
## 本地开发
|
## 本地开发
|
||||||
|
|
||||||
|
先从样板创建本地配置,再按启用的功能填写:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
`.env` 只用于本地或部署主机运行,不得提交。生产配置由部署主机的受控环境文件注入,不将完整应用 `.env` 保存为 Gitea Actions 密钥。
|
||||||
|
|
||||||
推荐使用已有 conda 环境:
|
推荐使用已有 conda 环境:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
conda run -n server python -m pytest tests/unit tests/auth -q
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
如需要进入环境:
|
如需要进入环境:
|
||||||
@@ -50,10 +60,47 @@ conda activate server
|
|||||||
就绪状态不等同于 OAuth、用户权限或真实工具调用成功;指定项目的业务库和时序库仍通过
|
就绪状态不等同于 OAuth、用户权限或真实工具调用成功;指定项目的业务库和时序库仍通过
|
||||||
带身份与 `X-Project-Id` 的 `GET /api/v1/projects/current/database-health` 检查。
|
带身份与 `X-Project-Id` 的 `GET /api/v1/projects/current/database-health` 检查。
|
||||||
|
|
||||||
|
## 环境配置
|
||||||
|
|
||||||
|
`.env.example` 是唯一应提交的环境配置样板,主要分为以下几组:
|
||||||
|
|
||||||
|
- 元数据库、项目业务库、TimescaleDB 及项目数据库连接池。
|
||||||
|
- GeoServer 项目供应、Keycloak JWT 校验、CORS 和工作台事件。
|
||||||
|
- 天地图地理编码和地名搜索。
|
||||||
|
- 调查任务 worker、外围工单同步、数据与动作适配器。
|
||||||
|
- 可选的 MCP resource、OAuth issuer、宿主和超时预算。
|
||||||
|
|
||||||
|
基础数据库变量用于启动和项目供应。运行时的项目请求仍以元数据库中的 `biz_data` 和 `iot_data` 路由为准,不会因为存在默认数据库变量而回退到固定项目。
|
||||||
|
|
||||||
|
CORS 通过后端 `.env` 或部署进程环境变量中的 `CORS_ALLOWED_ORIGINS` 配置,进程环境变量优先。
|
||||||
|
必须使用英文逗号分隔的明确页面来源(协议、域名或 IP、端口,不带路径),不能填写 `*`。
|
||||||
|
未设置时默认只允许本机 `5173`、`15173` 开发来源。正式部署按实际浏览器地址填写,例如:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
CORS_ALLOWED_ORIGINS=https://next-demo.waternetwork.cn,http://192.168.1.133,https://192.168.1.133,http://192.168.1.110,https://192.168.1.110
|
||||||
|
```
|
||||||
|
|
||||||
|
CORS 匹配浏览器页面来源,不是 Nginx 连接 IP 或域名解析结果;非默认端口须逐项列出。
|
||||||
|
保留跨域凭据支持及 `Authorization`、`X-Project-Id` 请求头,接口认证和权限校验继续生效。
|
||||||
|
修改后需重启进程;使用 Compose `env_file` 注入时需重新创建容器才能读取新值。
|
||||||
|
|
||||||
|
## 业务 Skill 与闭环
|
||||||
|
|
||||||
|
[爆管事件处理流程](docs/burst-response-workflow.md)覆盖工单接收、资产关联、隔离阀门、影响说明、审批处置和反馈闭环。Server 按任务 workflow 与回放来源分发独立 Skill,版本以实际返回为准:
|
||||||
|
|
||||||
|
| workflow | Skill | 调查重点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 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) | 审批与回执、现场履约、反馈复核及与原事件的闭环区别 |
|
||||||
|
|
||||||
|
JSONL 整理遵守会话文件权限,后台图表和表格通过既有报告 `ui_blocks` 展示;Skill 不增加审批、执行或原工单关单权限。预约投影不自动启动 Agent 调查,普通协作与后台执行分别遵守其原有工具范围。
|
||||||
|
|
||||||
## 常用命令
|
## 常用命令
|
||||||
|
|
||||||
外围接入样板是独立进程,复用 PostgreSQL,不自动建表;启动、授权边界和契约见 [外围样板说明](docs/peripheral-sample.md)。
|
外围接入样板作为 `/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
|
```bash
|
||||||
conda run -n server python -m pytest tests -q
|
conda run -n server python -m pytest tests -q
|
||||||
@@ -67,6 +114,14 @@ docker compose -f infra/docker/docker-compose.yml config
|
|||||||
- `docker build`:构建后端镜像。
|
- `docker build`:构建后端镜像。
|
||||||
- `docker compose config`:检查 compose 配置和变量展开。
|
- `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`。
|
- Python 文件、函数、变量、Pydantic 字段、JSON body 字段和 query 参数使用 `snake_case`。
|
||||||
@@ -117,6 +172,19 @@ conda run -n server python scripts/publish_fengxian_map_layers.py --database tjw
|
|||||||
|
|
||||||
发布命令需要配置 `GEOSERVER_URL`、`GEOSERVER_USERNAME` 和 `GEOSERVER_PASSWORD`。其中 7 个基础管网图层仍由项目供应流程创建,奉贤发布脚本追加 9 个原始 GIS 图层和 1 个水质监测点图层。
|
发布命令需要配置 `GEOSERVER_URL`、`GEOSERVER_USERNAME` 和 `GEOSERVER_PASSWORD`。其中 7 个基础管网图层仍由项目供应流程创建,奉贤发布脚本追加 9 个原始 GIS 图层和 1 个水质监测点图层。
|
||||||
|
|
||||||
|
## 工况任务工作进程
|
||||||
|
|
||||||
|
调查任务 worker 和外围工单同步器是独立进程,不随 API 进程自动启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda run -n server python scripts/run_operational_worker.py
|
||||||
|
conda run -n server python scripts/sync_peripheral_work_orders.py
|
||||||
|
```
|
||||||
|
|
||||||
|
`OPERATIONAL_WORKER_PROJECT_IDS` 是逗号分隔的显式项目 UUID 列表,非空时优先于单项目兼容变量 `OPERATIONAL_WORKER_PROJECT_ID`。如需覆盖所有已启用项目,应将这些项目的 UUID 全部明确写入列表。进程不会自动枚举项目,配置列表也不会自动授权;服务身份必须是每个目标项目的有效成员。
|
||||||
|
|
||||||
|
每个项目保持独立的事件游标、会话、消息游标和 OAuth 缓存。`OPERATIONAL_WORKER_MAX_CONCURRENCY` 是每个项目的上限,而不是全局上限。更改项目列表后,需要同时重启 worker 和外围同步器。完整配置、授权和重试边界见 [工况工作进程说明](docs/operational-task-workers.md)。
|
||||||
|
|
||||||
## 测试与发布
|
## 测试与发布
|
||||||
|
|
||||||
提交前根据改动范围运行最小有效测试:
|
提交前根据改动范围运行最小有效测试:
|
||||||
@@ -131,7 +199,15 @@ conda run -n server python -m pytest tests/unit tests/auth -q
|
|||||||
docker build -t tjwater-server:local .
|
docker build -t tjwater-server:local .
|
||||||
```
|
```
|
||||||
|
|
||||||
Gitea 包工作流位于 `.gitea/workflows/package.yml`,通常由 tag 触发构建、推送镜像并通知部署 webhook。
|
Dockerfile 的 `test` 阶段会执行 Python 编译检查、应用导入、OpenAPI 检查和完整 pytest。
|
||||||
|
|
||||||
|
Gitea Actions 工作流位于 `.gitea/workflows/package.yml`,可手动触发,也会在推送 `v*` tag 时运行。工作流构建 `test` 阶段,推送不可变的 `sha-*` 镜像,然后通过受限 SSH 账号对 `next-backend` 执行候选验证。只有 tag 工作流会在验证通过后提升正式版本。仓库需要以下 Actions secrets:
|
||||||
|
|
||||||
|
- `REGISTRY_USERNAME`
|
||||||
|
- `REGISTRY_PASSWORD`
|
||||||
|
- `DEV_DEPLOY_SSH_KEY`
|
||||||
|
|
||||||
|
这些密钥只用于镜像仓库和受限开发机部署,不替代后端应用的运行环境配置。
|
||||||
|
|
||||||
## 安全规则
|
## 安全规则
|
||||||
|
|
||||||
@@ -139,4 +215,19 @@ Gitea 包工作流位于 `.gitea/workflows/package.yml`,通常由 tag 触发
|
|||||||
|
|
||||||
## 产品 MCP 查询
|
## 产品 MCP 查询
|
||||||
|
|
||||||
Server 可选启用 MCP 2026-07-28 的25项项目查询及10项受控分析,默认关闭。它使用专用 OAuth resource Bearer、宿主绑定上下文及现有项目/任务权限,调查查询返回前持久化证据。启用需更新依赖、应用项目 SQL 迁移和配置 Keycloak token exchange;这些生产操作尚未执行。详见 [实施与启用](docs/mcp-query-implementation.md) 和 [协议/旧 CLI 能力对照](docs/mcp-query-design.md)。
|
Server 可选启用 MCP 2026-07-28,默认关闭。目录包含查询、分析计算、预览式写入和计算任务管理工具;可用项以 `app/mcp/catalog.py` 及当前授权目录为准。MCP 使用专用 OAuth resource Bearer、宿主绑定上下文和现有项目或任务权限,不提供自由命令执行。
|
||||||
|
|
||||||
|
写入工具默认只执行 `dry_run`。真实写入必须使用同一 `request_id`,带上预览返回的 `expected_digest`,并通过宿主原生许可和项目权限检查。超时或结果不明时先查询操作状态,不盲目重试。实施、启用和授权边界见 [MCP 实施与启用](docs/mcp-query-implementation.md) 和 [协议与旧 CLI 能力对照](docs/mcp-query-design.md)。
|
||||||
|
|
||||||
|
## 后台计算队列
|
||||||
|
|
||||||
|
长计算通过项目数据库队列和独立计算 Worker 执行,API 保持4个进程,HTTP超时不延长。接入需要新增数据库迁移和独立服务,详见[计算队列部署与MCP契约](docs/calculation-queue.md)。
|
||||||
|
|
||||||
|
|
||||||
|
后台调查通过 `POST /api/v1/operational-tasks/{task_id}/questions` 保存结构化问题,并沿现有任务通知展示。当前 run 可在没有最终报告时正常结束,任务进入等待人工;具备 `operational_task.write` 权限的用户通过 `questions/{question_id}/answer` 回答,原任务排队新一轮。回答保存为交互记录,不自动转为现场证据或审批。问题支持最多三个单选、多选或自定义回答,版本校验和请求幂等保护重复提交。任务详情返回最近 100 组提问,完整历史由 MCP 的 `get_task_context(section="questions")` 分页读取。
|
||||||
|
|
||||||
|
部署任务提问功能前,在各项目业务库执行 `resources/sql/028_task_questions.sql`,再同步 Server、Agent 与前端版本。迁移不修改既有调查记录;更新代码不会自动迁移数据库或重启服务。
|
||||||
|
|
||||||
|
外围工单来源统一为系统数据。部署对应版本前,在各项目业务库执行 `resources/sql/029_peripheral_work_orders_system_data.sql`,再同步 Server、同步器、Agent 与前端版本。迁移会修正既有外围源工单、修订记录、任务快照和外围反馈证据,不修改测试数据或模拟监测数据。
|
||||||
|
|
||||||
|
多 Agent 后台调查按实例归属运行。部署对应版本前,在每个启用调查 Worker 的项目 BizDB 执行 `resources/sql/030_operational_run_agent_ownership.sql`,再同步 Server、Agent 和 Worker,并为各 Agent 配置唯一且重启稳定的 `AGENT_INSTANCE_ID`。迁移只增加运行归属列和活跃运行索引;旧运行不猜测归属,待其结束或租约恢复后,新一代运行开始记录实例。
|
||||||
|
|||||||
@@ -3,6 +3,14 @@ import os
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
def calculation_temp_directory(kind: str) -> str:
|
||||||
|
"""Keep native outputs inside the supervisor-owned job directory."""
|
||||||
|
root = os.environ.get("TJWATER_CALCULATION_JOB_DIR", "temp")
|
||||||
|
path = os.path.abspath(os.path.join(root, kind))
|
||||||
|
os.makedirs(path, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
def fill_time_gaps(
|
def fill_time_gaps(
|
||||||
data: pd.DataFrame,
|
data: pd.DataFrame,
|
||||||
time_col: str = "time",
|
time_col: str = "time",
|
||||||
|
|||||||
@@ -8,15 +8,14 @@ import sys
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
import wntr
|
import wntr
|
||||||
|
|
||||||
from app.algorithms._utils import _cleanup_temp_files
|
from app.algorithms._utils import _cleanup_temp_files, calculation_temp_directory
|
||||||
|
|
||||||
_PIPE2LEAKNODE = None
|
_PIPE2LEAKNODE = None
|
||||||
_SIGNATURE_WORKER_DATA = {}
|
_SIGNATURE_WORKER_DATA = {}
|
||||||
|
|
||||||
|
|
||||||
def _make_temp_prefix(tag):
|
def _make_temp_prefix(tag):
|
||||||
temp_dir = os.path.abspath(os.path.join("temp", "burst_location"))
|
temp_dir = calculation_temp_directory("burst_location")
|
||||||
os.makedirs(temp_dir, exist_ok=True)
|
|
||||||
safe_tag = str(tag).replace(os.sep, "_").replace(" ", "_")
|
safe_tag = str(tag).replace(os.sep, "_").replace(" ", "_")
|
||||||
return os.path.join(temp_dir, f"{safe_tag}_{os.getpid()}")
|
return os.path.join(temp_dir, f"{safe_tag}_{os.getpid()}")
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from pymoo.optimize import minimize as pymoo_minimize
|
|||||||
from pymoo.termination.default import DefaultSingleObjectiveTermination
|
from pymoo.termination.default import DefaultSingleObjectiveTermination
|
||||||
|
|
||||||
|
|
||||||
from app.algorithms._utils import _cleanup_temp_files
|
from app.algorithms._utils import _cleanup_temp_files, calculation_temp_directory
|
||||||
|
|
||||||
_worker_data: dict[str, Any] = {}
|
_worker_data: dict[str, Any] = {}
|
||||||
DEFAULT_N_WORKERS = max(1, min(cpu_count() - 1, 4))
|
DEFAULT_N_WORKERS = max(1, min(cpu_count() - 1, 4))
|
||||||
@@ -92,7 +92,7 @@ def _worker_evaluate(raw_ratios: np.ndarray) -> float:
|
|||||||
demand_obj.base_value = original_val + per_node_leak
|
demand_obj.base_value = original_val + per_node_leak
|
||||||
modifications.append((demand_obj, original_val))
|
modifications.append((demand_obj, original_val))
|
||||||
|
|
||||||
temp_dir = os.path.abspath(os.path.join("temp", "leakage"))
|
temp_dir = calculation_temp_directory("leakage")
|
||||||
os.makedirs(temp_dir, exist_ok=True)
|
os.makedirs(temp_dir, exist_ok=True)
|
||||||
prefix = os.path.join(temp_dir, f"temp_{os.getpid()}")
|
prefix = os.path.join(temp_dir, f"temp_{os.getpid()}")
|
||||||
|
|
||||||
@@ -569,7 +569,7 @@ class LeakageProblem(Problem):
|
|||||||
modifications.append((demand_obj, original_val))
|
modifications.append((demand_obj, original_val))
|
||||||
|
|
||||||
# 结果保存在根目录的temp/leakage文件夹中
|
# 结果保存在根目录的temp/leakage文件夹中
|
||||||
temp_dir = os.path.abspath(os.path.join("temp", "leakage"))
|
temp_dir = calculation_temp_directory("leakage")
|
||||||
os.makedirs(temp_dir, exist_ok=True)
|
os.makedirs(temp_dir, exist_ok=True)
|
||||||
prefix = os.path.join(temp_dir, f"temp_{os.getpid()}")
|
prefix = os.path.join(temp_dir, f"temp_{os.getpid()}")
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"""Topology-only valve isolation search."""
|
"""Topology-only valve isolation search."""
|
||||||
|
|
||||||
from collections import defaultdict, deque
|
from collections import defaultdict, deque
|
||||||
from typing import Any, Iterable
|
from typing import Any, Iterable, Literal
|
||||||
|
|
||||||
|
|
||||||
VALVE_LINK_TYPE = "valve"
|
VALVE_LINK_TYPE = "valve"
|
||||||
|
AccidentElementType = Literal["auto", "link", "node"]
|
||||||
|
|
||||||
|
|
||||||
def _parse_link_entry(link_entry: str) -> tuple[str, str, str, str]:
|
def _parse_link_entry(link_entry: str) -> tuple[str, str, str, str]:
|
||||||
@@ -14,12 +15,37 @@ def _parse_link_entry(link_entry: str) -> tuple[str, str, str, str]:
|
|||||||
return parts[0], parts[1], parts[2], parts[3]
|
return parts[0], parts[1], parts[2], parts[3]
|
||||||
|
|
||||||
|
|
||||||
|
def _reachable_nodes(
|
||||||
|
adjacency: dict[str, list[tuple[str, str]]],
|
||||||
|
start_nodes: Iterable[str],
|
||||||
|
blocked_links: set[str],
|
||||||
|
) -> set[str]:
|
||||||
|
visited = set(start_nodes)
|
||||||
|
queue = deque(visited)
|
||||||
|
while queue:
|
||||||
|
for neighbor, link_id in adjacency.get(queue.popleft(), ()):
|
||||||
|
if link_id not in blocked_links and neighbor not in visited:
|
||||||
|
visited.add(neighbor)
|
||||||
|
queue.append(neighbor)
|
||||||
|
return visited
|
||||||
|
|
||||||
|
|
||||||
def valve_isolation_analysis(
|
def valve_isolation_analysis(
|
||||||
link_entries: Iterable[str],
|
link_entries: Iterable[str],
|
||||||
accident_elements: str | list[str],
|
accident_elements: str | list[str],
|
||||||
disabled_valves: list[str] | None = None,
|
disabled_valves: list[str] | None = None,
|
||||||
|
*,
|
||||||
|
accident_element_type: AccidentElementType = "auto",
|
||||||
|
water_source_nodes: Iterable[str] | None = None,
|
||||||
|
closed_links: Iterable[str] = (),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Determine boundary valves and affected nodes from a topology snapshot."""
|
"""Find nearest valve boundaries, then check source connectivity.
|
||||||
|
|
||||||
|
Legacy untyped IDs prefer links; explicit node mode handles cross-type ID
|
||||||
|
collisions. Supply impact is a before/after connectivity difference, not
|
||||||
|
a hydraulic pressure or demand calculation. Without source metadata only
|
||||||
|
the provisional boundary search is available.
|
||||||
|
"""
|
||||||
|
|
||||||
disabled_valves_set = set(disabled_valves or [])
|
disabled_valves_set = set(disabled_valves or [])
|
||||||
target_elements = (
|
target_elements = (
|
||||||
@@ -27,8 +53,13 @@ def valve_isolation_analysis(
|
|||||||
if isinstance(accident_elements, str)
|
if isinstance(accident_elements, str)
|
||||||
else accident_elements
|
else accident_elements
|
||||||
)
|
)
|
||||||
|
if not target_elements:
|
||||||
|
raise ValueError("At least one accident element is required")
|
||||||
|
if accident_element_type not in {"auto", "link", "node"}:
|
||||||
|
raise ValueError(f"Invalid accident element type: {accident_element_type}")
|
||||||
|
closed_links_set = set(closed_links)
|
||||||
|
|
||||||
pipe_adj: dict[str, set[str]] = defaultdict(set)
|
adjacency: dict[str, list[tuple[str, str]]] = defaultdict(list)
|
||||||
all_valves: dict[str, tuple[str, str]] = {}
|
all_valves: dict[str, tuple[str, str]] = {}
|
||||||
link_lookup: dict[str, tuple[str, str, str]] = {}
|
link_lookup: dict[str, tuple[str, str, str]] = {}
|
||||||
node_set: set[str] = set()
|
node_set: set[str] = set()
|
||||||
@@ -39,46 +70,31 @@ def valve_isolation_analysis(
|
|||||||
node_set.update((node1, node2))
|
node_set.update((node1, node2))
|
||||||
if link_type_name == VALVE_LINK_TYPE:
|
if link_type_name == VALVE_LINK_TYPE:
|
||||||
all_valves[link_id] = (node1, node2)
|
all_valves[link_id] = (node1, node2)
|
||||||
else:
|
if link_id not in closed_links_set:
|
||||||
pipe_adj[node1].add(node2)
|
adjacency[node1].append((node2, link_id))
|
||||||
pipe_adj[node2].add(node1)
|
adjacency[node2].append((node1, link_id))
|
||||||
|
|
||||||
|
unknown_disabled = disabled_valves_set - all_valves.keys()
|
||||||
|
if unknown_disabled:
|
||||||
|
raise ValueError(f"Disabled valve was not found in topology: {sorted(unknown_disabled)}")
|
||||||
|
|
||||||
start_nodes: set[str] = set()
|
start_nodes: set[str] = set()
|
||||||
for element in target_elements:
|
for element in target_elements:
|
||||||
if element in node_set:
|
if accident_element_type != "node" and element in link_lookup:
|
||||||
start_nodes.add(element)
|
|
||||||
elif element in link_lookup:
|
|
||||||
node1, node2, _ = link_lookup[element]
|
node1, node2, _ = link_lookup[element]
|
||||||
start_nodes.update((node1, node2))
|
start_nodes.update((node1, node2))
|
||||||
|
elif accident_element_type != "link" and element in node_set:
|
||||||
|
start_nodes.add(element)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Accident element {element} was not found in topology")
|
raise ValueError(f"Accident element {element} was not found in topology")
|
||||||
|
|
||||||
extra_adj: dict[str, list[str]] = defaultdict(list)
|
closable_valves = set(all_valves) - disabled_valves_set - closed_links_set
|
||||||
boundary_valves: dict[str, tuple[str, str]] = {}
|
affected_nodes = _reachable_nodes(adjacency, start_nodes, closable_valves)
|
||||||
for valve_id, (node1, node2) in all_valves.items():
|
|
||||||
if valve_id in disabled_valves_set:
|
|
||||||
extra_adj[node1].append(node2)
|
|
||||||
extra_adj[node2].append(node1)
|
|
||||||
else:
|
|
||||||
boundary_valves[valve_id] = (node1, node2)
|
|
||||||
|
|
||||||
affected_nodes: set[str] = set()
|
|
||||||
queue = deque(start_nodes)
|
|
||||||
while queue:
|
|
||||||
node = queue.popleft()
|
|
||||||
if node in affected_nodes:
|
|
||||||
continue
|
|
||||||
affected_nodes.add(node)
|
|
||||||
queue.extend(pipe_adj.get(node, set()) - affected_nodes)
|
|
||||||
queue.extend(
|
|
||||||
neighbor
|
|
||||||
for neighbor in extra_adj.get(node, ())
|
|
||||||
if neighbor not in affected_nodes
|
|
||||||
)
|
|
||||||
|
|
||||||
must_close_valves: list[str] = []
|
must_close_valves: list[str] = []
|
||||||
optional_valves: list[str] = []
|
optional_valves: list[str] = []
|
||||||
for valve_id, (node1, node2) in boundary_valves.items():
|
for valve_id in closable_valves:
|
||||||
|
node1, node2 = all_valves[valve_id]
|
||||||
node1_affected = node1 in affected_nodes
|
node1_affected = node1 in affected_nodes
|
||||||
node2_affected = node2 in affected_nodes
|
node2_affected = node2 in affected_nodes
|
||||||
if node1_affected and node2_affected:
|
if node1_affected and node2_affected:
|
||||||
@@ -89,14 +105,37 @@ def valve_isolation_analysis(
|
|||||||
must_close_valves.sort()
|
must_close_valves.sort()
|
||||||
optional_valves.sort()
|
optional_valves.sort()
|
||||||
isolatable = bool(must_close_valves)
|
isolatable = bool(must_close_valves)
|
||||||
|
failure_reason = None if isolatable else "no_boundary_valves"
|
||||||
|
sources = set(water_source_nodes or ())
|
||||||
|
source_check_performed = bool(sources)
|
||||||
|
supply_affected_nodes = None
|
||||||
|
if water_source_nodes is not None:
|
||||||
|
if not sources:
|
||||||
|
isolatable = False
|
||||||
|
failure_reason = "water_sources_unavailable"
|
||||||
|
else:
|
||||||
|
after_closure = _reachable_nodes(adjacency, sources, set(must_close_valves))
|
||||||
|
if affected_nodes & after_closure:
|
||||||
|
isolatable = False
|
||||||
|
failure_reason = "connected_to_water_source"
|
||||||
|
if isolatable:
|
||||||
|
before_closure = _reachable_nodes(adjacency, sources, set())
|
||||||
|
supply_affected_nodes = sorted(before_closure - after_closure)
|
||||||
result: dict[str, Any] = {
|
result: dict[str, Any] = {
|
||||||
"accident_elements": target_elements,
|
"accident_elements": target_elements,
|
||||||
|
"accident_element_type": accident_element_type,
|
||||||
"disabled_valves": disabled_valves,
|
"disabled_valves": disabled_valves,
|
||||||
"affected_nodes": sorted(affected_nodes) if isolatable else [],
|
"affected_nodes": sorted(affected_nodes) if isolatable else [],
|
||||||
"affected_node_count": len(affected_nodes),
|
"affected_node_count": len(affected_nodes),
|
||||||
"must_close_valves": must_close_valves,
|
"must_close_valves": must_close_valves,
|
||||||
"optional_valves": optional_valves,
|
"optional_valves": optional_valves,
|
||||||
"isolatable": isolatable,
|
"isolatable": isolatable,
|
||||||
|
"source_check_performed": source_check_performed,
|
||||||
|
"isolation_failure_reason": failure_reason,
|
||||||
|
"supply_affected_nodes": supply_affected_nodes,
|
||||||
|
"supply_affected_node_count": (
|
||||||
|
len(supply_affected_nodes) if supply_affected_nodes is not None else None
|
||||||
|
),
|
||||||
}
|
}
|
||||||
if len(target_elements) == 1:
|
if len(target_elements) == 1:
|
||||||
result["accident_element"] = target_elements[0]
|
result["accident_element"] = target_elements[0]
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ def _project_response(project: models.Project) -> AdminProjectResponse:
|
|||||||
gs_workspace=project.gs_workspace,
|
gs_workspace=project.gs_workspace,
|
||||||
map_extent=project.map_extent,
|
map_extent=project.map_extent,
|
||||||
map_config=getattr(project, "map_config", {}) or {},
|
map_config=getattr(project, "map_config", {}) or {},
|
||||||
|
settings=getattr(project, "settings", {}) or {},
|
||||||
status=project.status,
|
status=project.status,
|
||||||
created_at=project.created_at,
|
created_at=project.created_at,
|
||||||
updated_at=project.updated_at,
|
updated_at=project.updated_at,
|
||||||
@@ -276,6 +277,7 @@ async def create_admin_project(
|
|||||||
gs_workspace=payload.gs_workspace,
|
gs_workspace=payload.gs_workspace,
|
||||||
map_extent=payload.map_extent,
|
map_extent=payload.map_extent,
|
||||||
map_config=payload.map_config.model_dump(exclude_unset=True),
|
map_config=payload.map_config.model_dump(exclude_unset=True),
|
||||||
|
settings=payload.settings.model_dump(exclude_unset=True),
|
||||||
status=payload.status,
|
status=payload.status,
|
||||||
creator_user_id=current_user.id,
|
creator_user_id=current_user.id,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ async def get_project_metadata(
|
|||||||
gs_workspace=project.gs_workspace,
|
gs_workspace=project.gs_workspace,
|
||||||
map_extent=project.map_extent,
|
map_extent=project.map_extent,
|
||||||
map_config=getattr(project, "map_config", {}) or {},
|
map_config=getattr(project, "map_config", {}) or {},
|
||||||
|
settings=getattr(project, "settings", {}) or {},
|
||||||
status=project.status,
|
status=project.status,
|
||||||
project_role=ctx.project_role,
|
project_role=ctx.project_role,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -320,6 +320,7 @@ async def provision_project(
|
|||||||
gs_workspace=project.gs_workspace,
|
gs_workspace=project.gs_workspace,
|
||||||
map_extent=project.map_extent,
|
map_extent=project.map_extent,
|
||||||
map_config=getattr(project, "map_config", {}) or {},
|
map_config=getattr(project, "map_config", {}) or {},
|
||||||
|
settings=getattr(project, "settings", {}) or {},
|
||||||
status=project.status,
|
status=project.status,
|
||||||
created_at=project.created_at,
|
created_at=project.created_at,
|
||||||
updated_at=project.updated_at,
|
updated_at=project.updated_at,
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ from uuid import UUID
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
|
||||||
from psycopg import AsyncConnection
|
from psycopg import AsyncConnection
|
||||||
|
from app.auth.metadata_dependencies import get_metadata_repository
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
from app.auth.permissions import (
|
from app.auth.permissions import (
|
||||||
OPERATIONAL_TASK_EXECUTE,
|
OPERATIONAL_TASK_EXECUTE,
|
||||||
OPERATIONAL_TASK_EXTERNAL_SYNC,
|
OPERATIONAL_TASK_EXTERNAL_SYNC,
|
||||||
@@ -22,10 +24,13 @@ from app.auth.project_dependencies import (
|
|||||||
ProjectContext,
|
ProjectContext,
|
||||||
get_project_context,
|
get_project_context,
|
||||||
get_project_pg_connection,
|
get_project_pg_connection,
|
||||||
|
get_project_timescale_connection,
|
||||||
)
|
)
|
||||||
from app.domain.domain_errors import DomainError
|
from app.domain.domain_errors import DomainError
|
||||||
from app.domain.schemas.workbench_runtime import WorkbenchDateTime
|
from app.domain.schemas.workbench_runtime import WorkbenchDateTime
|
||||||
from app.domain.schemas.operational_data import TaskDataQuery, TaskDataSnapshot
|
from app.domain.schemas.operational_data import TaskDataQuery, TaskDataSnapshot
|
||||||
|
from app.domain.schemas.task_question import TaskQuestionCreate, TaskQuestionReply, TaskQuestionRequest
|
||||||
|
from app.services.task_questions import create_question, answer_question
|
||||||
from app.domain.schemas.operational_task import (
|
from app.domain.schemas.operational_task import (
|
||||||
ActionDecisionCommand,
|
ActionDecisionCommand,
|
||||||
ActionExecuteCommand,
|
ActionExecuteCommand,
|
||||||
@@ -74,6 +79,7 @@ from app.domain.schemas.operational_task import (
|
|||||||
TaskPresentationEventCommand,
|
TaskPresentationEventCommand,
|
||||||
TaskPresentationEventResult,
|
TaskPresentationEventResult,
|
||||||
TaskPresentationCursor,
|
TaskPresentationCursor,
|
||||||
|
TaskPresentationHistoryPage,
|
||||||
)
|
)
|
||||||
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
|
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
|
||||||
from app.infra.integrations.operational_actions import (
|
from app.infra.integrations.operational_actions import (
|
||||||
@@ -87,11 +93,37 @@ from app.services.operational_tasks import OperationalTaskService
|
|||||||
router = APIRouter()
|
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:
|
def _service(ctx: ProjectContext, conn: AsyncConnection) -> OperationalTaskService:
|
||||||
return OperationalTaskService(
|
return OperationalTaskService(
|
||||||
OperationalTaskRepository(conn, ctx.project_id),
|
OperationalTaskRepository(conn, ctx.project_id),
|
||||||
ctx,
|
ctx,
|
||||||
_task_permissions(ctx),
|
_task_permissions(ctx),
|
||||||
|
execution_scope=settings.operational_task_execution_scope,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -521,6 +553,21 @@ async def get_operational_task_presentation_cursor(
|
|||||||
_raise_domain(exc)
|
_raise_domain(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/operational-tasks/{task_id}/presentation-events", response_model=TaskPresentationHistoryPage)
|
||||||
|
async def get_operational_task_presentation_history(
|
||||||
|
task_id: UUID,
|
||||||
|
after: int = Query(default=0, ge=0),
|
||||||
|
through: int | None = Query(default=None, ge=0),
|
||||||
|
limit: int = Query(default=100, ge=1, le=200),
|
||||||
|
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await _service(ctx, conn).presentation_history(task_id, after=after, through=through, limit=limit)
|
||||||
|
except DomainError as exc:
|
||||||
|
_raise_domain(exc)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/operational-tasks/{task_id}/presentation-events", response_model=TaskPresentationEventResult)
|
@router.post("/operational-tasks/{task_id}/presentation-events", response_model=TaskPresentationEventResult)
|
||||||
async def publish_operational_task_presentation_event(
|
async def publish_operational_task_presentation_event(
|
||||||
task_id: UUID,
|
task_id: UUID,
|
||||||
@@ -549,6 +596,20 @@ async def query_operational_task_data(
|
|||||||
if not await service.can_read_source(task_id, query.source):
|
if not await service.can_read_source(task_id, query.source):
|
||||||
raise DomainError("PERMISSION_DENIED", "Task has no approved access to this data source", status=403)
|
raise DomainError("PERMISSION_DENIED", "Task has no approved access to this data source", status=403)
|
||||||
source_permission = SCADA_VIEW if query.source.startswith("scada_") else SIMULATION_VIEW
|
source_permission = SCADA_VIEW if query.source.startswith("scada_") else SIMULATION_VIEW
|
||||||
|
from app.services.scada_replay_queries import replay_snapshot_rows
|
||||||
|
replay_context = await service.replay_context(task_id)
|
||||||
|
if replay_context:
|
||||||
|
if query.source != "scada_readings":
|
||||||
|
raise HTTPException(403, "回放调查数据接口仅开放本轮 SCADA 快照,请通过 MCP 查询设备目录")
|
||||||
|
rows = await replay_snapshot_rows(conn, ctx.project_id, replay_context, query.device_ids,
|
||||||
|
query.start, query.end, 1001)
|
||||||
|
from app.services.content_digest import digest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
snapshot = TaskDataSnapshot(
|
||||||
|
source="scada_readings", source_ref=f"scada_replay:{replay_context['replay_id']}:{digest(rows)}",
|
||||||
|
queried_at=datetime.now(timezone.utc), origin="synthetic", data=rows[:1000], truncated=len(rows) > 1000,
|
||||||
|
)
|
||||||
|
else:
|
||||||
snapshot = await read_task_data(
|
snapshot = await read_task_data(
|
||||||
context=ctx, query=query, credential=authorization,
|
context=ctx, query=query, credential=authorization,
|
||||||
delegated=source_permission not in _task_permissions(ctx),
|
delegated=source_permission not in _task_permissions(ctx),
|
||||||
@@ -771,3 +832,27 @@ async def withdraw_operational_task_external_source(
|
|||||||
return await _service(ctx, conn).withdraw_external_source(task_id, command)
|
return await _service(ctx, conn).withdraw_external_source(task_id, command)
|
||||||
except DomainError as exc:
|
except DomainError as exc:
|
||||||
_raise_domain(exc)
|
_raise_domain(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/operational-tasks/{task_id}/questions", response_model=TaskQuestionRequest)
|
||||||
|
async def create_operational_task_question(
|
||||||
|
task_id: UUID, command: TaskQuestionCreate,
|
||||||
|
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await create_question(_service(ctx, conn), task_id, command)
|
||||||
|
except DomainError as exc:
|
||||||
|
_raise_domain(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/operational-tasks/{task_id}/questions/{question_id}/answer", response_model=OperationalTaskDetail)
|
||||||
|
async def answer_operational_task_question(
|
||||||
|
task_id: UUID, question_id: UUID, command: TaskQuestionReply,
|
||||||
|
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await answer_question(_service(ctx, conn), task_id, question_id, command)
|
||||||
|
except DomainError as exc:
|
||||||
|
_raise_domain(exc)
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ async def get_project_info_endpoint(
|
|||||||
gs_workspace=project_detail.gs_workspace,
|
gs_workspace=project_detail.gs_workspace,
|
||||||
map_extent=project_detail.map_extent,
|
map_extent=project_detail.map_extent,
|
||||||
map_config=getattr(project_detail, "map_config", {}) or {},
|
map_config=getattr(project_detail, "map_config", {}) or {},
|
||||||
|
settings=getattr(project_detail, "settings", {}) or {},
|
||||||
status=project_detail.status,
|
status=project_detail.status,
|
||||||
project_role="viewer", # Default role for public access
|
project_role="viewer", # Default role for public access
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""SCADA replay controls. Manual steps are processed by the command request."""
|
||||||
|
from uuid import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||||
|
|
||||||
|
from app.auth.metadata_dependencies import get_metadata_repository
|
||||||
|
from app.auth.permissions import SCADA_VIEW, SCADA_CLEAN, require_permission, permissions_for_context
|
||||||
|
from app.auth.project_dependencies import ProjectContext, get_project_pg_connection, get_project_timescale_connection
|
||||||
|
from app.domain.schemas.scada_replay import (
|
||||||
|
ScadaReplayCapabilities, ScadaReplayCreate, ScadaReplayCommand, ScadaReplaySnapshot,
|
||||||
|
ScadaReplayEventsPage, ScadaReplayReadings,
|
||||||
|
)
|
||||||
|
from app.infra.db.postgresql.scada_replays import ScadaReplayRepository
|
||||||
|
from app.domain.schemas.project_settings import ProjectSettings
|
||||||
|
from app.services.scada_replay import ScadaReplayService, snapshot
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/scada-replays", tags=["SCADA Replay"])
|
||||||
|
|
||||||
|
|
||||||
|
async def repository(
|
||||||
|
ctx: ProjectContext = Depends(require_permission(SCADA_VIEW)),
|
||||||
|
connection=Depends(get_project_pg_connection),
|
||||||
|
):
|
||||||
|
return ScadaReplayRepository(connection, ctx.project_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def project_settings(
|
||||||
|
ctx: ProjectContext = Depends(require_permission(SCADA_VIEW)),
|
||||||
|
metadata_repo=Depends(get_metadata_repository),
|
||||||
|
) -> ProjectSettings:
|
||||||
|
project = await metadata_repo.get_project_by_id(ctx.project_id)
|
||||||
|
if project is None:
|
||||||
|
raise HTTPException(404, "当前项目不存在")
|
||||||
|
return ProjectSettings.model_validate(project.settings or {})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/capabilities", response_model=ScadaReplayCapabilities)
|
||||||
|
async def replay_capabilities(repo=Depends(repository), ctx=Depends(require_permission(SCADA_VIEW)),
|
||||||
|
settings=Depends(project_settings)):
|
||||||
|
available = await repo.available()
|
||||||
|
current = await repo.current() if available else None
|
||||||
|
monitoring = settings.scada_monitoring
|
||||||
|
monitoring_ready = bool(
|
||||||
|
monitoring
|
||||||
|
and len(monitoring.pressure_device_ids) >= 5
|
||||||
|
and (monitoring.decision_policy == "weighted" or monitoring.outlet_flow_device_ids)
|
||||||
|
)
|
||||||
|
if monitoring_ready:
|
||||||
|
monitoring_reason = None
|
||||||
|
elif monitoring is None:
|
||||||
|
monitoring_reason = "项目尚未配置压力监测角色及组合判定规则"
|
||||||
|
elif len(monitoring.pressure_device_ids) < 5:
|
||||||
|
monitoring_reason = "爆管侦测至少需要 5 个压力监测设备"
|
||||||
|
else:
|
||||||
|
monitoring_reason = "项目尚未配置出厂边界流量设备"
|
||||||
|
return ScadaReplayCapabilities(
|
||||||
|
available=available, can_write=SCADA_CLEAN in permissions_for_context(ctx),
|
||||||
|
reason=None if available else "需先部署 SCADA 回放存储结构",
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ScadaReplaySnapshot, status_code=201)
|
||||||
|
async def create_replay(command: ScadaReplayCreate, repo=Depends(repository),
|
||||||
|
ctx=Depends(require_permission(SCADA_CLEAN)), settings=Depends(project_settings)):
|
||||||
|
return await ScadaReplayService(repo, ctx).create(
|
||||||
|
command,
|
||||||
|
monitoring_interval_seconds=settings.scada_monitoring_interval_seconds,
|
||||||
|
monitoring_config=settings.scada_monitoring,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{replay_id}", response_model=ScadaReplaySnapshot)
|
||||||
|
async def get_replay(replay_id: UUID, repo=Depends(repository)):
|
||||||
|
await ScadaReplayService(repo, None).ensure_storage()
|
||||||
|
return snapshot(await repo.get(replay_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{replay_id}/commands", response_model=ScadaReplaySnapshot)
|
||||||
|
async def replay_command(replay_id: UUID, command: ScadaReplayCommand, repo=Depends(repository),
|
||||||
|
ctx=Depends(require_permission(SCADA_CLEAN)),
|
||||||
|
timescale=Depends(get_project_timescale_connection)):
|
||||||
|
service = ScadaReplayService(repo, ctx, timescale)
|
||||||
|
result = await service.command(replay_id, command)
|
||||||
|
if command.action in {"step", "monitor", "retry"} and result.step_pending:
|
||||||
|
return await service.process_pending_step(replay_id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{replay_id}/events", response_model=ScadaReplayEventsPage)
|
||||||
|
async def replay_events(replay_id: UUID, offset: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=200),
|
||||||
|
repo=Depends(repository)):
|
||||||
|
await ScadaReplayService(repo, None).ensure_storage()
|
||||||
|
await repo.get(replay_id)
|
||||||
|
items = await repo.events(replay_id, offset, limit + 1)
|
||||||
|
return ScadaReplayEventsPage(items=items[:limit], next_offset=offset + limit if len(items) > limit else None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{replay_id}/observations", response_model=ScadaReplayReadings)
|
||||||
|
async def replay_observations(replay_id: UUID, through: datetime, repo=Depends(repository)):
|
||||||
|
await ScadaReplayService(repo, None).ensure_storage()
|
||||||
|
document = await repo.get(replay_id)
|
||||||
|
if (through.tzinfo is None or not document.get("delivered_until")
|
||||||
|
or through > datetime.fromisoformat(document["delivered_until"])
|
||||||
|
or through < datetime.fromisoformat(document["start_time"])):
|
||||||
|
raise HTTPException(422, "回看仅允许本轮已读取的时间区间")
|
||||||
|
return ScadaReplayReadings(
|
||||||
|
items=await repo.latest_outputs(replay_id, through),
|
||||||
|
through=through,
|
||||||
|
)
|
||||||
@@ -161,6 +161,9 @@ def valve_isolation_endpoint(
|
|||||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
accident_element: List[str] = Query(..., description="发生事故的管段/节点ID列表"),
|
accident_element: List[str] = Query(..., description="发生事故的管段/节点ID列表"),
|
||||||
disabled_valves: List[str] = Query(None, description="已故障的阀门ID列表(可选)"),
|
disabled_valves: List[str] = Query(None, description="已故障的阀门ID列表(可选)"),
|
||||||
|
accident_element_type: Literal["auto", "link", "node"] = Query(
|
||||||
|
"auto", description="事故要素类型;auto优先管段再节点;分析同名节点须指定node,纯管段分析可指定link",
|
||||||
|
),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
阀门隔离分析
|
阀门隔离分析
|
||||||
@@ -168,13 +171,19 @@ def valve_isolation_endpoint(
|
|||||||
- **network**: 管网名称(或数据库名称)
|
- **network**: 管网名称(或数据库名称)
|
||||||
- **accident_element**: 发生事故的管段/节点ID列表
|
- **accident_element**: 发生事故的管段/节点ID列表
|
||||||
- **disabled_valves**: 已故障的阀门ID列表(可选)
|
- **disabled_valves**: 已故障的阀门ID列表(可选)
|
||||||
|
- **accident_element_type**: auto优先管段;link仅匹配管段;node仅匹配节点
|
||||||
|
|
||||||
返回隔离方案,包括:
|
返回隔离方案,包括:
|
||||||
- must_close_valves: 必须关闭的阀门列表
|
- must_close_valves: 必须关闭的阀门列表
|
||||||
- optional_valves: 可选关闭的阀门列表
|
- optional_valves: 可选关闭的阀门列表
|
||||||
- affected_nodes: 受影响的节点列表;不可隔离时为空列表
|
- affected_nodes / affected_node_count: 最近阀门围成的直接隔离区;不可隔离时列表为空,计数保留
|
||||||
- affected_node_count: 受影响的节点总数
|
- supply_affected_nodes / supply_affected_node_count: 按建议关阀后新增失去水源连接的节点;未验证或不可隔离时为null
|
||||||
- isolatable: 是否可以有效隔离
|
- source_check_performed: 是否已执行水源连通检查
|
||||||
|
- isolation_failure_reason: 不可隔离原因;可隔离时为null
|
||||||
|
- isolatable: 存在关阀边界,且直接隔离区不再连通任何水源
|
||||||
|
|
||||||
|
使用模型初始开关状态进行无向拓扑分析,水池按潜在水源处理。
|
||||||
|
不包含时变控制、水池蓄水量或压力流量计算,不是现场停水范围的水力证明。
|
||||||
"""
|
"""
|
||||||
# result = {
|
# result = {
|
||||||
# "accident_element": "P461309",
|
# "accident_element": "P461309",
|
||||||
@@ -193,7 +202,10 @@ def valve_isolation_endpoint(
|
|||||||
# "must_close_valves": ["210521658", "V12974", "V12986", "V12993"],
|
# "must_close_valves": ["210521658", "V12974", "V12986", "V12993"],
|
||||||
# "optional_valves": [],
|
# "optional_valves": [],
|
||||||
# }
|
# }
|
||||||
result = analyze_valve_isolation(network, accident_element, disabled_valves)
|
result = analyze_valve_isolation(
|
||||||
|
network, accident_element, disabled_valves,
|
||||||
|
accident_element_type=accident_element_type,
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path, Body
|
from fastapi import APIRouter, Depends, HTTPException, Query, Path, Body
|
||||||
from typing import List
|
from typing import List
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
from psycopg import AsyncConnection
|
from psycopg import AsyncConnection
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
from app.domain.schemas.scada import ScadaLatestReadingsResponse
|
from app.domain.schemas.scada import ScadaLatestReadingsResponse, ScadaRawPage
|
||||||
|
from app.auth.project_dependencies import get_project_context, ProjectContext
|
||||||
|
from app.services.scada_raw import read_raw_page, validate_window
|
||||||
from app.infra.db.postgresql.scada import ScadaInfoRepository
|
from app.infra.db.postgresql.scada import ScadaInfoRepository
|
||||||
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
|
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
|
||||||
from .dependencies import get_postgres_connection, get_timescale_connection
|
from .dependencies import get_postgres_connection, get_timescale_connection
|
||||||
@@ -13,6 +16,46 @@ router = APIRouter()
|
|||||||
SCADA_BATCH_MAX_ITEMS = 10_000
|
SCADA_BATCH_MAX_ITEMS = 10_000
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/timeseries/scada-readings/raw", response_model=ScadaRawPage,
|
||||||
|
summary="按时间顺序分页读取 SCADA 原始值")
|
||||||
|
async def get_scada_raw(
|
||||||
|
start_time: datetime, end_time: datetime, device_ids: str,
|
||||||
|
limit: int = Query(500, ge=1, le=500),
|
||||||
|
cursor: str | None = Query(None, max_length=2048),
|
||||||
|
replay_id: UUID | None = None,
|
||||||
|
conn: AsyncConnection = Depends(get_timescale_connection),
|
||||||
|
postgres_conn: AsyncConnection = Depends(get_postgres_connection),
|
||||||
|
ctx: ProjectContext = Depends(get_project_context),
|
||||||
|
) -> ScadaRawPage:
|
||||||
|
try:
|
||||||
|
validate_window(start_time, end_time)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(422, str(exc)) from exc
|
||||||
|
ids = list(dict.fromkeys(item.strip() for item in device_ids.split(",") if item.strip()))
|
||||||
|
if not ids or len(ids) > 500:
|
||||||
|
raise HTTPException(422, "请选择 1 至 500 个 SCADA 设备")
|
||||||
|
existing = await ScadaInfoRepository.get_existing_device_ids(postgres_conn, ids)
|
||||||
|
if set(ids) != existing:
|
||||||
|
raise HTTPException(422, "查询包含当前项目未配置的 SCADA 设备")
|
||||||
|
try:
|
||||||
|
through = None
|
||||||
|
if replay_id is not None:
|
||||||
|
from app.infra.db.postgresql.scada_replays import ScadaReplayRepository
|
||||||
|
from app.services.scada_replay import ScadaReplayService
|
||||||
|
repo = ScadaReplayRepository(postgres_conn, ctx.project_id)
|
||||||
|
await ScadaReplayService(repo, ctx).ensure_storage()
|
||||||
|
replay = await repo.get(replay_id)
|
||||||
|
if not set(ids) <= set(replay["config"]["device_ids"]):
|
||||||
|
raise HTTPException(403, "设备超出回放范围")
|
||||||
|
if start_time < datetime.fromisoformat(replay["start_time"]) or end_time > datetime.fromisoformat(replay["end_time"]):
|
||||||
|
raise HTTPException(422, "查询超出选定回放区间")
|
||||||
|
through = datetime.fromisoformat(replay["virtual_now"])
|
||||||
|
return await read_raw_page(conn, project_id=str(ctx.project_id), device_ids=ids,
|
||||||
|
start=start_time, end=end_time, limit=limit, cursor=cursor, through=through)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(422, str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
class ScadaReadingBatchItem(BaseModel):
|
class ScadaReadingBatchItem(BaseModel):
|
||||||
time: datetime
|
time: datetime
|
||||||
device_id: str = Field(min_length=1)
|
device_id: str = Field(min_length=1)
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ def _service(ctx: ProjectContext, conn: AsyncConnection) -> OperationalTaskServi
|
|||||||
OperationalTaskRepository(conn, ctx.project_id),
|
OperationalTaskRepository(conn, ctx.project_id),
|
||||||
ctx,
|
ctx,
|
||||||
permissions_for_context(ctx),
|
permissions_for_context(ctx),
|
||||||
|
execution_scope=settings.operational_task_execution_scope,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -61,8 +61,10 @@ from app.auth.permissions import (
|
|||||||
require_method_permission,
|
require_method_permission,
|
||||||
require_permission,
|
require_permission,
|
||||||
)
|
)
|
||||||
|
from app.api.v1.endpoints import scada_replay
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
api_router.include_router(scada_replay.router)
|
||||||
|
|
||||||
webgis_access = Depends(
|
webgis_access = Depends(
|
||||||
require_method_permission(
|
require_method_permission(
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
PROJECT_NAME: str = "TJWater Server"
|
PROJECT_NAME: str = "TJWater Server"
|
||||||
ENVIRONMENT: str = "production"
|
ENVIRONMENT: str = "production"
|
||||||
|
OPERATIONAL_TASK_EXECUTION_SCOPE: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def operational_task_execution_scope(self) -> str:
|
||||||
|
return self.OPERATIONAL_TASK_EXECUTION_SCOPE.strip() or self.ENVIRONMENT
|
||||||
|
|
||||||
API_V1_STR: str = "/api/v1"
|
API_V1_STR: str = "/api/v1"
|
||||||
NETWORK_NAME: str = "default_network"
|
NETWORK_NAME: str = "default_network"
|
||||||
# 敏感配置加密密钥 (Fernet)
|
# 敏感配置加密密钥 (Fernet)
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ from datetime import datetime
|
|||||||
from typing import Literal
|
from typing import Literal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||||
|
|
||||||
from app.domain.schemas.project_map_config import ProjectMapConfig, ProjectMapConfigUpdate
|
from app.domain.schemas.project_map_config import ProjectMapConfig, ProjectMapConfigUpdate
|
||||||
|
from app.domain.schemas.project_settings import ProjectSettings
|
||||||
|
|
||||||
|
|
||||||
BusinessRole = Literal["admin", "service", "user"]
|
BusinessRole = Literal["admin", "service", "user"]
|
||||||
@@ -72,6 +73,7 @@ class ProjectMemberResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class AdminProjectCreateRequest(BaseModel):
|
class AdminProjectCreateRequest(BaseModel):
|
||||||
|
settings: ProjectSettings = Field(default_factory=ProjectSettings)
|
||||||
name: str = Field(..., min_length=1, max_length=100)
|
name: str = Field(..., min_length=1, max_length=100)
|
||||||
code: str = Field(..., min_length=1, max_length=50)
|
code: str = Field(..., min_length=1, max_length=50)
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
@@ -82,6 +84,10 @@ class AdminProjectCreateRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class AdminProjectUpdateRequest(ProjectMapConfigUpdate):
|
class AdminProjectUpdateRequest(ProjectMapConfigUpdate):
|
||||||
|
settings: ProjectSettings | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="按顶层键合并项目配置;省略的键保持原值,嵌套对象整体替换",
|
||||||
|
)
|
||||||
name: str | None = Field(default=None, min_length=1, max_length=100)
|
name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||||
code: str | None = Field(default=None, min_length=1, max_length=50)
|
code: str | None = Field(default=None, min_length=1, max_length=50)
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
@@ -89,8 +95,16 @@ class AdminProjectUpdateRequest(ProjectMapConfigUpdate):
|
|||||||
map_extent: dict | None = None
|
map_extent: dict | None = None
|
||||||
status: ProjectStatus | None = None
|
status: ProjectStatus | None = None
|
||||||
|
|
||||||
|
@field_validator("settings")
|
||||||
|
@classmethod
|
||||||
|
def reject_null_settings(cls, value: ProjectSettings | None) -> ProjectSettings:
|
||||||
|
if value is None:
|
||||||
|
raise ValueError("settings must be an object")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class AdminProjectResponse(BaseModel):
|
class AdminProjectResponse(BaseModel):
|
||||||
|
settings: ProjectSettings = Field(default_factory=ProjectSettings)
|
||||||
project_id: UUID
|
project_id: UUID
|
||||||
name: str
|
name: str
|
||||||
code: str
|
code: str
|
||||||
|
|||||||
@@ -3,11 +3,27 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated, Any, Literal
|
from typing import Annotated, Any, Literal
|
||||||
|
|
||||||
from jsonschema import Draft7Validator
|
from jsonschema import Draft7Validator
|
||||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
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(
|
_catalog = json.loads(
|
||||||
(Path(__file__).resolve().parents[3] / "contracts/answer-ui-catalog.json").read_text()
|
(Path(__file__).resolve().parents[3] / "contracts/answer-ui-catalog.json").read_text()
|
||||||
@@ -34,9 +50,24 @@ class AnswerUiSpec(UiModel):
|
|||||||
class AnswerUiBlock(UiModel):
|
class AnswerUiBlock(UiModel):
|
||||||
version: Literal["answer-ui@1"]
|
version: Literal["answer-ui@1"]
|
||||||
block_id: Key
|
block_id: Key
|
||||||
|
phase: Literal["process", "final"] | None = None
|
||||||
fallback_text: Annotated[str, Field(min_length=1, max_length=4000)]
|
fallback_text: Annotated[str, Field(min_length=1, max_length=4000)]
|
||||||
spec: AnswerUiSpec
|
spec: AnswerUiSpec
|
||||||
|
|
||||||
|
@field_validator("phase", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def validate_explicit_phase(cls, value):
|
||||||
|
if value is None:
|
||||||
|
raise ValueError("UI phase must be process or final when supplied")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@model_serializer(mode="wrap")
|
||||||
|
def serialize_optional_phase(self, handler):
|
||||||
|
data = handler(self)
|
||||||
|
if self.phase is None:
|
||||||
|
data.pop("phase", None)
|
||||||
|
return data
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_components(self):
|
def validate_components(self):
|
||||||
spec = self.spec
|
spec = self.spec
|
||||||
@@ -56,8 +87,18 @@ class AnswerUiBlock(UiModel):
|
|||||||
if element.type == "Chart":
|
if element.type == "Chart":
|
||||||
if any(len(series["data"]) != len(props["x_data"]) for series in props["series"]):
|
if any(len(series["data"]) != len(props["x_data"]) for series in props["series"]):
|
||||||
raise ValueError("Chart series must match the axis")
|
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")
|
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"]):
|
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")
|
raise ValueError("Table row width must match its columns")
|
||||||
if element.type == "FollowUpForm":
|
if element.type == "FollowUpForm":
|
||||||
@@ -83,6 +124,12 @@ class ReportUiBlock(UiModel):
|
|||||||
block: AnswerUiBlock
|
block: AnswerUiBlock
|
||||||
evidence_refs: list[Annotated[str, Field(min_length=1, max_length=128)]] = Field(min_length=1, max_length=100)
|
evidence_refs: list[Annotated[str, Field(min_length=1, max_length=128)]] = Field(min_length=1, max_length=100)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_report_phase(self):
|
||||||
|
if self.block.phase == "process":
|
||||||
|
raise ValueError("Report UI blocks must contain final results")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
def validate_report_ui_blocks(blocks: list[ReportUiBlock], evidence_refs: list[str]) -> None:
|
def validate_report_ui_blocks(blocks: list[ReportUiBlock], evidence_refs: list[str]) -> None:
|
||||||
ids = [item.block.block_id for item in blocks]
|
ids = [item.block.block_id for item in blocks]
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ from uuid import UUID
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.domain.schemas.project_map_config import ProjectMapConfig
|
from app.domain.schemas.project_map_config import ProjectMapConfig
|
||||||
|
from app.domain.schemas.project_settings import ProjectSettings
|
||||||
|
|
||||||
|
|
||||||
class ProjectMetaResponse(BaseModel):
|
class ProjectMetaResponse(BaseModel):
|
||||||
|
settings: ProjectSettings = Field(default_factory=ProjectSettings)
|
||||||
project_id: UUID
|
project_id: UUID
|
||||||
name: str
|
name: str
|
||||||
code: str
|
code: str
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ McpSnapshotSource = Literal[
|
|||||||
'cancel_task_run',
|
'cancel_task_run',
|
||||||
'run_simulation',
|
'run_simulation',
|
||||||
'run_contaminant_simulation',
|
'run_contaminant_simulation',
|
||||||
|
'run_contaminant_source_trace',
|
||||||
'analyze_valve_closure',
|
'analyze_valve_closure',
|
||||||
'analyze_flushing',
|
'analyze_flushing',
|
||||||
'analyze_pressure_regulation',
|
'analyze_pressure_regulation',
|
||||||
@@ -58,6 +59,7 @@ McpSnapshotSource = Literal[
|
|||||||
'run_burst_detection',
|
'run_burst_detection',
|
||||||
'run_burst_location',
|
'run_burst_location',
|
||||||
'analyze_valve_isolation',
|
'analyze_valve_isolation',
|
||||||
|
'generate_node_coverage',
|
||||||
'run_leakage_identification',
|
'run_leakage_identification',
|
||||||
'get_element_properties',
|
'get_element_properties',
|
||||||
'list_elements',
|
'list_elements',
|
||||||
|
|||||||
@@ -5,10 +5,16 @@ import re
|
|||||||
from typing import Annotated, Literal
|
from typing import Annotated, Literal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import Field, JsonValue, model_validator
|
from pydantic import Field, JsonValue, StringConstraints, field_validator, model_serializer, model_validator
|
||||||
from app.domain.schemas.answer_ui import ReportUiBlock, validate_report_ui_blocks
|
from app.domain.schemas.answer_ui import (
|
||||||
|
CHART_SAMPLE_TIME_PATTERN, ReportUiBlock, validate_chart_sample_time, validate_report_ui_blocks,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.domain.schemas.task_question import TaskQuestionRequest
|
||||||
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictBool, StrictModel
|
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictBool, StrictModel
|
||||||
|
from app.domain.schemas.report_authoring import (
|
||||||
|
validate_presentation_command_text, validate_report_text, validate_report_ui_text,
|
||||||
|
)
|
||||||
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
|
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
|
||||||
from app.domain.schemas.workbench_runtime import (
|
from app.domain.schemas.workbench_runtime import (
|
||||||
WorkbenchDateTime,
|
WorkbenchDateTime,
|
||||||
@@ -40,12 +46,19 @@ ExecutionStatus = Literal[
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ScadaReplayTaskContext(StrictModel):
|
||||||
|
replay_id: UUID
|
||||||
|
knowledge_cutoff: WorkbenchDateTime
|
||||||
|
batch_sequence: PositiveInt
|
||||||
|
|
||||||
|
|
||||||
class TaskSource(StrictModel):
|
class TaskSource(StrictModel):
|
||||||
kind: TaskSourceKind
|
kind: TaskSourceKind
|
||||||
external_id: NonEmptyStr | None = None
|
external_id: NonEmptyStr | None = None
|
||||||
external_revision: PositiveInt | None = None
|
external_revision: PositiveInt | None = None
|
||||||
event_kind: NonEmptyStr | None = None
|
event_kind: NonEmptyStr | None = None
|
||||||
deleted_at: WorkbenchDateTime | None = None
|
deleted_at: WorkbenchDateTime | None = None
|
||||||
|
replay_context: ScadaReplayTaskContext | None = None
|
||||||
|
|
||||||
|
|
||||||
class TaskTrigger(StrictModel):
|
class TaskTrigger(StrictModel):
|
||||||
@@ -212,21 +225,65 @@ class InvestigationMapTarget(StrictModel):
|
|||||||
feature_id: TaskFeatureId
|
feature_id: TaskFeatureId
|
||||||
|
|
||||||
|
|
||||||
class InvestigationDiagramNodeRef(StrictModel):
|
InvestigationNodeAnnotation = Literal["reference", "recorded", "needs_input", "failed", "not_taken"]
|
||||||
|
LEGACY_INVESTIGATION_NODE_ANNOTATIONS = {
|
||||||
|
"pending": "reference", "running": "reference", "completed": "recorded",
|
||||||
|
"waiting": "needs_input", "waiting_human": "needs_input",
|
||||||
|
"skipped": "not_taken", "cancelled": "not_taken",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _InvestigationDiagramNode(StrictModel):
|
||||||
node_id: Annotated[str, Field(pattern=r"^[A-Za-z_][A-Za-z0-9_-]{0,79}$")]
|
node_id: Annotated[str, Field(pattern=r"^[A-Za-z_][A-Za-z0-9_-]{0,79}$")]
|
||||||
label: NonEmptyStr
|
label: NonEmptyStr
|
||||||
|
detail: Annotated[str, Field(min_length=1, max_length=2000)] | None = None
|
||||||
evidence_refs: list[NonEmptyStr] = Field(default_factory=list)
|
evidence_refs: list[NonEmptyStr] = Field(default_factory=list)
|
||||||
action_refs: list[UUID] = Field(default_factory=list)
|
action_refs: list[UUID] = Field(default_factory=list)
|
||||||
status: Literal[
|
|
||||||
"pending", "running", "completed", "waiting_human", "failed", "skipped", "cancelled"
|
|
||||||
] | None = None
|
|
||||||
map_targets: list[InvestigationMapTarget] = Field(default_factory=list, max_length=100)
|
map_targets: list[InvestigationMapTarget] = Field(default_factory=list, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
|
class InvestigationDiagramNodeRef(_InvestigationDiagramNode):
|
||||||
|
status: InvestigationNodeAnnotation | None = Field(default=None, description=(
|
||||||
|
"A saved process-reference annotation, not live execution state. "
|
||||||
|
"recorded means a finding exists, not that a task or field action completed."
|
||||||
|
))
|
||||||
|
|
||||||
|
@field_validator("status", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def read_legacy_annotation(cls, value: object) -> object:
|
||||||
|
return LEGACY_INVESTIGATION_NODE_ANNOTATIONS.get(value, value) if isinstance(value, str) else value
|
||||||
|
|
||||||
|
|
||||||
|
class InvestigationGraphNodeUpdate(_InvestigationDiagramNode):
|
||||||
|
label: NonEmptyStr | None = Field(default=None, description=(
|
||||||
|
"Required for a new node. Omit or use null to retain an existing node's label. "
|
||||||
|
"Use the same node_id to revise status or detail without adding another step."
|
||||||
|
))
|
||||||
|
status: InvestigationNodeAnnotation | None = Field(default=None, description=(
|
||||||
|
"Optional reference annotation: reference, recorded finding, needs_input, failed attempt, or not_taken. "
|
||||||
|
"No live node/task binding or per-step synchronization is required."
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
class InvestigationGraphEdge(StrictModel):
|
||||||
|
source: Annotated[str, Field(pattern=r"^[A-Za-z_][A-Za-z0-9_-]{0,79}$")]
|
||||||
|
target: Annotated[str, Field(pattern=r"^[A-Za-z_][A-Za-z0-9_-]{0,79}$")]
|
||||||
|
label: Annotated[str, Field(min_length=1, max_length=200)] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class InvestigationGraphUpdate(StrictModel):
|
||||||
|
title: Annotated[str, Field(min_length=1, max_length=200)] | None = None
|
||||||
|
nodes: list[InvestigationGraphNodeUpdate] = Field(default_factory=list, max_length=100)
|
||||||
|
edges: list[InvestigationGraphEdge] = Field(default_factory=list, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
class InvestigationDiagram(StrictModel):
|
class InvestigationDiagram(StrictModel):
|
||||||
title: NonEmptyStr
|
title: NonEmptyStr
|
||||||
mermaid: Annotated[str, Field(min_length=1, max_length=20000)]
|
mermaid: Annotated[str, Field(min_length=1, max_length=20000)]
|
||||||
node_refs: list[InvestigationDiagramNodeRef] = Field(default_factory=list, max_length=100)
|
node_refs: list[InvestigationDiagramNodeRef] = Field(default_factory=list, max_length=100)
|
||||||
|
# Present only on a graph maintained by the host. Legacy Mermaid stays readable.
|
||||||
|
edges: list[InvestigationGraphEdge] | None = Field(default=None, max_length=200)
|
||||||
|
source_mermaid: Annotated[str, Field(min_length=1, max_length=20000)] | None = None
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_description(self):
|
def validate_description(self):
|
||||||
@@ -240,6 +297,21 @@ class InvestigationDiagram(StrictModel):
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
def _undeclared_diagram_node_refs(diagram: InvestigationDiagram | None) -> list[str]:
|
||||||
|
if diagram is None:
|
||||||
|
return []
|
||||||
|
missing: list[str] = []
|
||||||
|
for node in diagram.node_refs:
|
||||||
|
escaped = re.escape(node.node_id)
|
||||||
|
if not re.search(
|
||||||
|
rf"(?<![A-Za-z0-9_-]){escaped}(?=\s*(?:[\[({{@;]|:::|--|==|-\.|$))",
|
||||||
|
diagram.mermaid,
|
||||||
|
re.MULTILINE,
|
||||||
|
):
|
||||||
|
missing.append(node.node_id)
|
||||||
|
return missing
|
||||||
|
|
||||||
|
|
||||||
class InvestigationReport(StrictModel):
|
class InvestigationReport(StrictModel):
|
||||||
"""Read model: absent historical facts remain unknown, never inferred."""
|
"""Read model: absent historical facts remain unknown, never inferred."""
|
||||||
|
|
||||||
@@ -255,6 +327,7 @@ class InvestigationReport(StrictModel):
|
|||||||
data_gaps: list[InvestigationDataGap] | None = None
|
data_gaps: list[InvestigationDataGap] | None = None
|
||||||
diagram: InvestigationDiagram | None = None
|
diagram: InvestigationDiagram | None = None
|
||||||
ui_blocks: list["ReportUiBlock"] = Field(default_factory=list, max_length=8)
|
ui_blocks: list["ReportUiBlock"] = Field(default_factory=list, max_length=8)
|
||||||
|
scada_device_ids: list[TaskFeatureId] | None = Field(default=None, max_length=1000)
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_result(self):
|
def validate_result(self):
|
||||||
@@ -279,12 +352,44 @@ class InvestigationReport(StrictModel):
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class InvestigationDataGapInput(InvestigationDataGap):
|
||||||
|
"""New gap descriptions use business prose; references remain opaque."""
|
||||||
|
|
||||||
|
missing_data: NonEmptyStr = Field(max_length=4000, description="缺失的业务资料,不写执行字段")
|
||||||
|
scope: NonEmptyStr = Field(max_length=4000, description="缺口涉及的对象、地点与当地时间范围")
|
||||||
|
impact: NonEmptyStr = Field(max_length=4000, description="资料缺项对当前判断的实际影响")
|
||||||
|
required_data: NonEmptyStr = Field(max_length=4000, description="需要补充的具体业务资料")
|
||||||
|
|
||||||
|
@field_validator("missing_data", "scope", "impact", "required_data")
|
||||||
|
@classmethod
|
||||||
|
def validate_reader_text(cls, value: str) -> str:
|
||||||
|
return validate_report_text(value)
|
||||||
|
|
||||||
|
|
||||||
class InvestigationReportInput(InvestigationReport):
|
class InvestigationReportInput(InvestigationReport):
|
||||||
"""Write model: every new report explicitly declares its business result."""
|
"""Write model: every new report explicitly declares its business result."""
|
||||||
|
|
||||||
|
title: NonEmptyStr = Field(max_length=80, description="简短中文业务标题,写明对象和当前结果,不含执行轮次、流程版本或原始时间戳")
|
||||||
|
current_conclusion: NonEmptyStr = Field(max_length=300, description="先写当前业务判断,再写关键依据与下一步,建议 150 字以内;完整数值和对象比较使用 ui_blocks.Table")
|
||||||
|
conclusion_boundary: NonEmptyStr = Field(max_length=240, description="只写影响判断的证据范围、不确定性和适用限制,不复述结论、执行步骤或参数")
|
||||||
|
recommendations: list[Annotated[NonEmptyStr, Field(max_length=160)]] = Field(default_factory=list)
|
||||||
|
pending_items: list[Annotated[NonEmptyStr, Field(max_length=160)]] = Field(default_factory=list)
|
||||||
|
stop_reason: Annotated[NonEmptyStr, Field(max_length=160)] | None = None
|
||||||
stage: Literal["interim", "final"]
|
stage: Literal["interim", "final"]
|
||||||
outcome: Literal["concluded", "insufficient_data"]
|
outcome: Literal["concluded", "insufficient_data"]
|
||||||
data_gaps: list[InvestigationDataGap]
|
data_gaps: list[InvestigationDataGapInput]
|
||||||
|
|
||||||
|
@field_validator("title", "current_conclusion", "conclusion_boundary", "stop_reason")
|
||||||
|
@classmethod
|
||||||
|
def validate_reader_text(cls, value: str | None) -> str | None:
|
||||||
|
return validate_report_text(value) if value is not None else value
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_authoring(self):
|
||||||
|
for value in [*self.recommendations, *self.pending_items]:
|
||||||
|
validate_report_text(value)
|
||||||
|
validate_report_ui_text(self.ui_blocks)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class InvestigationProgress(StrictModel):
|
class InvestigationProgress(StrictModel):
|
||||||
@@ -294,6 +399,22 @@ class InvestigationProgress(StrictModel):
|
|||||||
diagram: InvestigationDiagram | None = None
|
diagram: InvestigationDiagram | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class InvestigationProgressInput(InvestigationProgress):
|
||||||
|
summary: NonEmptyStr = Field(max_length=300, description="用简短中文说明本轮新增发现、当前依赖和下一步;不复制报告、执行字段、版本号或证据编号")
|
||||||
|
graph_update: InvestigationGraphUpdate | None = None
|
||||||
|
|
||||||
|
@field_validator("summary")
|
||||||
|
@classmethod
|
||||||
|
def validate_reader_summary(cls, value: str) -> str:
|
||||||
|
return validate_report_text(value)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_graph_input(self):
|
||||||
|
if self.graph_update is not None and self.diagram is not None:
|
||||||
|
raise ValueError("use graph_update without a generated Mermaid diagram")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class InvestigationProgressSnapshot(StrictModel):
|
class InvestigationProgressSnapshot(StrictModel):
|
||||||
revision: PositiveInt
|
revision: PositiveInt
|
||||||
run_id: UUID
|
run_id: UUID
|
||||||
@@ -338,12 +459,24 @@ class AgentArtifactsCommand(StrictModel):
|
|||||||
evidence: list[EvidenceInput] = Field(default_factory=list, max_length=100)
|
evidence: list[EvidenceInput] = Field(default_factory=list, max_length=100)
|
||||||
plan: ExecutionPlanInput | None = None
|
plan: ExecutionPlanInput | None = None
|
||||||
report: InvestigationReportInput | None = None
|
report: InvestigationReportInput | None = None
|
||||||
progress: InvestigationProgress | None = None
|
progress: InvestigationProgressInput | None = None
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def require_artifact(self):
|
def validate_artifacts(self):
|
||||||
if not self.evidence and self.plan is None and self.report is None and self.progress is None:
|
if not self.evidence and self.plan is None and self.report is None and self.progress is None:
|
||||||
raise ValueError("at least one artifact must be supplied")
|
raise ValueError("at least one artifact must be supplied")
|
||||||
|
for diagram in (
|
||||||
|
self.report.diagram if self.report is not None else None,
|
||||||
|
self.progress.diagram if self.progress is not None else None,
|
||||||
|
):
|
||||||
|
if diagram is not None and (diagram.edges is not None or diagram.source_mermaid is not None):
|
||||||
|
raise ValueError("structured graph metadata is host-owned; use progress.graph_update")
|
||||||
|
missing = _undeclared_diagram_node_refs(diagram)
|
||||||
|
if missing:
|
||||||
|
raise ValueError(
|
||||||
|
"diagram node references must use ids declared in Mermaid source: "
|
||||||
|
+ ", ".join(missing)
|
||||||
|
)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
@@ -502,7 +635,7 @@ class ExternalWorkOrderSnapshot(StrictModel):
|
|||||||
scenario_code: NonEmptyStr | None = None
|
scenario_code: NonEmptyStr | None = None
|
||||||
feedback_requirements: list[NonEmptyStr] = Field(default_factory=list)
|
feedback_requirements: list[NonEmptyStr] = Field(default_factory=list)
|
||||||
activity: list[WorkOrderActivity] = Field(default_factory=list)
|
activity: list[WorkOrderActivity] = Field(default_factory=list)
|
||||||
origin: Literal["synthetic"]
|
origin: Literal["system_data"]
|
||||||
updated_at: WorkbenchDateTime
|
updated_at: WorkbenchDateTime
|
||||||
|
|
||||||
@model_validator(mode="before")
|
@model_validator(mode="before")
|
||||||
@@ -521,6 +654,7 @@ class OperationalTaskRun(StrictModel):
|
|||||||
status: OperationalTaskRunStatus
|
status: OperationalTaskRunStatus
|
||||||
revision: PositiveInt
|
revision: PositiveInt
|
||||||
agent_session_id: NonEmptyStr | None = None
|
agent_session_id: NonEmptyStr | None = None
|
||||||
|
agent_instance_id: NonEmptyStr | None = None
|
||||||
lease_expires_at: WorkbenchDateTime | None = None
|
lease_expires_at: WorkbenchDateTime | None = None
|
||||||
heartbeat_at: WorkbenchDateTime | None = None
|
heartbeat_at: WorkbenchDateTime | None = None
|
||||||
started_at: WorkbenchDateTime | None = None
|
started_at: WorkbenchDateTime | None = None
|
||||||
@@ -534,6 +668,7 @@ class OperationalTaskRun(StrictModel):
|
|||||||
|
|
||||||
|
|
||||||
class OperationalTaskSummary(StrictModel):
|
class OperationalTaskSummary(StrictModel):
|
||||||
|
execution_scope: NonEmptyStr
|
||||||
project_id: UUID
|
project_id: UUID
|
||||||
task_id: UUID
|
task_id: UUID
|
||||||
task_type: TaskType
|
task_type: TaskType
|
||||||
@@ -550,6 +685,7 @@ class OperationalTaskSummary(StrictModel):
|
|||||||
execution_request_id: UUID
|
execution_request_id: UUID
|
||||||
title: NonEmptyStr
|
title: NonEmptyStr
|
||||||
summary: NonEmptyStr
|
summary: NonEmptyStr
|
||||||
|
handling_context: dict[str, JsonValue] = Field(default_factory=dict)
|
||||||
source: TaskSource
|
source: TaskSource
|
||||||
trigger: TaskTrigger
|
trigger: TaskTrigger
|
||||||
scheduled_for: WorkbenchDateTime | None
|
scheduled_for: WorkbenchDateTime | None
|
||||||
@@ -570,6 +706,7 @@ class OperationalTaskPermissions(StrictModel):
|
|||||||
can_decide_commands: bool
|
can_decide_commands: bool
|
||||||
can_execute_commands: bool
|
can_execute_commands: bool
|
||||||
can_decide_access: bool = False
|
can_decide_access: bool = False
|
||||||
|
can_answer_questions: bool = False
|
||||||
|
|
||||||
|
|
||||||
class OperationalTaskDetail(OperationalTaskSummary):
|
class OperationalTaskDetail(OperationalTaskSummary):
|
||||||
@@ -579,6 +716,7 @@ class OperationalTaskDetail(OperationalTaskSummary):
|
|||||||
evidence: list[EvidenceRecord]
|
evidence: list[EvidenceRecord]
|
||||||
actions: list[HumanApprovalAction]
|
actions: list[HumanApprovalAction]
|
||||||
access_requests: list[TaskAccessRequest] = Field(default_factory=list)
|
access_requests: list[TaskAccessRequest] = Field(default_factory=list)
|
||||||
|
questions: list[TaskQuestionRequest] = Field(default_factory=list)
|
||||||
permissions: OperationalTaskPermissions
|
permissions: OperationalTaskPermissions
|
||||||
work_order_snapshot: ExternalWorkOrderSnapshot | None = None
|
work_order_snapshot: ExternalWorkOrderSnapshot | None = None
|
||||||
|
|
||||||
@@ -605,6 +743,7 @@ class AgentRunCommand(StrictModel):
|
|||||||
request_id: UUID
|
request_id: UUID
|
||||||
expected_revision: PositiveInt
|
expected_revision: PositiveInt
|
||||||
source_session_id: NonEmptyStr
|
source_session_id: NonEmptyStr
|
||||||
|
agent_instance_id: NonEmptyStr | None = None
|
||||||
|
|
||||||
|
|
||||||
class AgentRunFailureCommand(AgentRunCommand):
|
class AgentRunFailureCommand(AgentRunCommand):
|
||||||
@@ -649,17 +788,64 @@ class AgentArtifactsResult(StrictModel):
|
|||||||
progress_revision: PositiveInt | None = None
|
progress_revision: PositiveInt | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ScadaChartGap(StrictModel):
|
||||||
|
device_id: TaskFeatureId
|
||||||
|
day_offset: Annotated[int, Field(ge=0, le=7)]
|
||||||
|
missing_count: Annotated[int, Field(ge=1, le=100)]
|
||||||
|
reason: Literal["no_readings", "outside_frozen_scope", "outside_frozen_window", "no_scheduled_samples"]
|
||||||
|
|
||||||
|
|
||||||
|
class ScadaChartContext(StrictModel):
|
||||||
|
role: Literal["current", "comparison"]
|
||||||
|
metric: Literal["pressure", "flow", "quality"]
|
||||||
|
unit: Annotated[str, Field(min_length=1, max_length=128)]
|
||||||
|
window_start: WorkbenchDateTime
|
||||||
|
window_end: WorkbenchDateTime
|
||||||
|
value_field: Literal["monitored_value", "cleaned_value"] = "monitored_value"
|
||||||
|
gaps: list[ScadaChartGap] = Field(default_factory=list, max_length=8)
|
||||||
|
|
||||||
|
|
||||||
class TaskSafeChartSeries(StrictModel):
|
class TaskSafeChartSeries(StrictModel):
|
||||||
name: Annotated[str, Field(max_length=128)]
|
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)]
|
device_id: TaskFeatureId | None = None
|
||||||
|
day_offset: Annotated[int, Field(ge=0, le=7)] | None = None
|
||||||
|
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):
|
class TaskSafeChart(StrictModel):
|
||||||
title: Annotated[str, Field(max_length=200)]
|
title: Annotated[str, Field(max_length=200)]
|
||||||
chart_type: Literal["line", "bar"]
|
scada: ScadaChartContext | None = None
|
||||||
|
chart_type: Literal["line", "bar", "time_comparison"]
|
||||||
|
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)]
|
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)]
|
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")
|
@model_validator(mode="after")
|
||||||
def validate_alignment(self):
|
def validate_alignment(self):
|
||||||
if any(len(series.data) != len(self.x_data) for series in self.series):
|
if any(len(series.data) != len(self.x_data) for series in self.series):
|
||||||
@@ -671,7 +857,68 @@ class TaskHighlightFeatures(StrictModel):
|
|||||||
action: Literal["highlight_features"]
|
action: Literal["highlight_features"]
|
||||||
layer: TaskMapLayer
|
layer: TaskMapLayer
|
||||||
feature_ids: Annotated[list[TaskFeatureId], Field(min_length=1, max_length=100)]
|
feature_ids: Annotated[list[TaskFeatureId], Field(min_length=1, max_length=100)]
|
||||||
effect: Literal["steady", "pulse"] = "steady"
|
effect: Literal["steady", "pulse"]
|
||||||
|
|
||||||
|
|
||||||
|
class TaskLocateFeatures(StrictModel):
|
||||||
|
action: Literal["locate_features"]
|
||||||
|
event_kind: Literal["burst", "pollution"] | None = Field(default=None,
|
||||||
|
description="定位事件语义:爆管候选传 burst,污染源传 pollution;普通资产定位省略")
|
||||||
|
layer: TaskMapLayer
|
||||||
|
feature_ids: Annotated[list[TaskFeatureId], Field(min_length=1, max_length=100)]
|
||||||
|
|
||||||
|
@model_serializer(mode="wrap")
|
||||||
|
def serialize_optional_kind(self, handler):
|
||||||
|
value = handler(self)
|
||||||
|
if self.event_kind is None:
|
||||||
|
value.pop("event_kind", None)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class TaskZoomToMap(StrictModel):
|
||||||
|
action: Literal["zoom_to_map"]
|
||||||
|
center: tuple[
|
||||||
|
Annotated[float, Field(strict=True, ge=-180, le=180, allow_inf_nan=False)],
|
||||||
|
Annotated[float, Field(strict=True, ge=-90, le=90, allow_inf_nan=False)],
|
||||||
|
]
|
||||||
|
zoom: Annotated[float, Field(strict=True, ge=0, le=24, allow_inf_nan=False)]
|
||||||
|
|
||||||
|
|
||||||
|
class TaskScadaAnalysisItem(StrictModel):
|
||||||
|
device_id: TaskFeatureId
|
||||||
|
level: Literal["high", "medium", "low", "unrated"]
|
||||||
|
|
||||||
|
|
||||||
|
class TaskRenderScadaAnalysis(StrictModel):
|
||||||
|
action: Literal["render_scada_analysis"]
|
||||||
|
items: Annotated[list[TaskScadaAnalysisItem], Field(min_length=1, max_length=100)]
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_unique_devices(self):
|
||||||
|
if len({item.device_id for item in self.items}) != len(self.items):
|
||||||
|
raise ValueError("SCADA analysis device IDs must be unique")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class TaskClearScadaAnalysis(StrictModel):
|
||||||
|
action: Literal["clear_scada_analysis"]
|
||||||
|
|
||||||
|
|
||||||
|
class TaskOverlayGeometry(StrictModel):
|
||||||
|
type: Literal["Point", "MultiPoint", "LineString", "MultiLineString", "Polygon", "MultiPolygon"]
|
||||||
|
coordinates: list[JsonValue]
|
||||||
|
|
||||||
|
|
||||||
|
class TaskOverlayFeature(StrictModel):
|
||||||
|
type: Literal["Feature"]
|
||||||
|
geometry: TaskOverlayGeometry
|
||||||
|
properties: dict[str, JsonValue] | None
|
||||||
|
id: Annotated[str, Field(strict=True)] | Annotated[float, Field(strict=True, allow_inf_nan=False)] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TaskOverlayData(StrictModel):
|
||||||
|
type: Literal["FeatureCollection"]
|
||||||
|
features: Annotated[list[TaskOverlayFeature], Field(min_length=1, max_length=1000)]
|
||||||
|
|
||||||
|
|
||||||
class TaskClearHighlights(StrictModel):
|
class TaskClearHighlights(StrictModel):
|
||||||
@@ -696,6 +943,7 @@ class TaskSetOverlay(StrictModel):
|
|||||||
title: Annotated[str, Field(min_length=1, max_length=200)]
|
title: Annotated[str, Field(min_length=1, max_length=200)]
|
||||||
mode: Literal["geometry", "heatmap"]
|
mode: Literal["geometry", "heatmap"]
|
||||||
evidence_id: NonEmptyStr
|
evidence_id: NonEmptyStr
|
||||||
|
data: TaskOverlayData | None = None
|
||||||
|
|
||||||
|
|
||||||
class TaskClearOverlay(StrictModel):
|
class TaskClearOverlay(StrictModel):
|
||||||
@@ -704,16 +952,78 @@ class TaskClearOverlay(StrictModel):
|
|||||||
|
|
||||||
|
|
||||||
TaskPresentationCommand = Annotated[
|
TaskPresentationCommand = Annotated[
|
||||||
TaskHighlightFeatures | TaskClearHighlights | TaskOpenChart | TaskCloseChart | TaskSetOverlay | TaskClearOverlay,
|
TaskHighlightFeatures | TaskLocateFeatures | TaskZoomToMap | TaskRenderScadaAnalysis
|
||||||
|
| TaskClearScadaAnalysis | TaskClearHighlights | TaskOpenChart | TaskCloseChart | TaskSetOverlay | TaskClearOverlay,
|
||||||
Field(discriminator="action"),
|
Field(discriminator="action"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TaskPresentationNarration(StrictModel):
|
||||||
|
stage: Literal[
|
||||||
|
"anomaly_detection", "evidence_verification", "asset_localization",
|
||||||
|
"event_impact", "source_control", "isolation_plan", "flushing_plan",
|
||||||
|
"field_feedback", "clearance_verification", "recovery_verification",
|
||||||
|
]
|
||||||
|
summary: Annotated[str, Field(strict=True, min_length=1, max_length=160)]
|
||||||
|
evidence_refs: Annotated[list[TaskFeatureId], Field(min_length=1, max_length=100)]
|
||||||
|
next_step: Annotated[str, Field(strict=True, min_length=1, max_length=120)] | None = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_narration(self):
|
||||||
|
if not self.summary.strip() or (self.next_step is not None and not self.next_step.strip()):
|
||||||
|
raise ValueError("presentation narration must contain readable text")
|
||||||
|
if len(set(self.evidence_refs)) != len(self.evidence_refs):
|
||||||
|
raise ValueError("presentation narration evidence IDs must be unique")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
TaskPresentationPurpose = Annotated[
|
||||||
|
str, StringConstraints(strict=True, strip_whitespace=True, min_length=1, max_length=160),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TaskPresentationNarrationInput(TaskPresentationNarration):
|
||||||
|
"""New live findings use business prose; saved narration remains readable."""
|
||||||
|
|
||||||
|
summary: Annotated[str, Field(strict=True, min_length=1, max_length=160,
|
||||||
|
description="本步已取得的关键发现,建议 80 字以内;区分观测、模型候选和现场反馈,不复述动作或核查目的")]
|
||||||
|
next_step: Annotated[str, Field(strict=True, min_length=1, max_length=120,
|
||||||
|
description="实际下一步或等待条件,建议 60 字以内;无后续时省略,不把展示当作现场执行")] | None = None
|
||||||
|
|
||||||
|
@field_validator("summary", "next_step")
|
||||||
|
@classmethod
|
||||||
|
def validate_reader_text(cls, value: str | None) -> str | None:
|
||||||
|
return validate_report_text(value) if value is not None else value
|
||||||
|
|
||||||
|
|
||||||
class TaskPresentationEventCommand(StrictModel):
|
class TaskPresentationEventCommand(StrictModel):
|
||||||
request_id: UUID
|
request_id: UUID
|
||||||
run_id: UUID
|
run_id: UUID
|
||||||
source_session_id: NonEmptyStr
|
source_session_id: NonEmptyStr
|
||||||
command: TaskPresentationCommand
|
command: TaskPresentationCommand
|
||||||
|
narration: TaskPresentationNarrationInput | None = None
|
||||||
|
purpose: TaskPresentationPurpose | None = Field(default=None,
|
||||||
|
description="一句中文说明本步为何查看这些对象,建议 60 字以内;不堆砌编号、执行参数或复述当前发现")
|
||||||
|
|
||||||
|
@field_validator("purpose")
|
||||||
|
@classmethod
|
||||||
|
def validate_reader_purpose(cls, value: str | None) -> str | None:
|
||||||
|
return validate_report_text(value) if value is not None else value
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_visible_labels(self):
|
||||||
|
validate_presentation_command_text(self.command.model_dump())
|
||||||
|
return self
|
||||||
|
|
||||||
|
@model_serializer(mode="wrap")
|
||||||
|
def serialize_legacy_command(self, handler):
|
||||||
|
value = handler(self)
|
||||||
|
# Keep old idempotency digests stable when no narration was supplied.
|
||||||
|
if self.narration is None:
|
||||||
|
value.pop("narration", None)
|
||||||
|
if self.purpose is None:
|
||||||
|
value.pop("purpose", None)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class TaskPresentationEventResult(StrictModel):
|
class TaskPresentationEventResult(StrictModel):
|
||||||
@@ -736,6 +1046,25 @@ class TaskPresentationCursor(StrictModel):
|
|||||||
event_cursor: int = Field(ge=0)
|
event_cursor: int = Field(ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskPresentationHistoryItem(StrictModel):
|
||||||
|
event_seq: PositiveInt
|
||||||
|
run_id: UUID
|
||||||
|
execution_generation: PositiveInt
|
||||||
|
occurred_at: WorkbenchDateTime
|
||||||
|
# Stored records are validated against the current command contract by the
|
||||||
|
# reader; an obsolete record must not prevent reading the rest of the page.
|
||||||
|
command: dict[str, JsonValue]
|
||||||
|
narration: TaskPresentationNarration | None = None
|
||||||
|
purpose: TaskPresentationPurpose | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TaskPresentationHistoryPage(StrictModel):
|
||||||
|
task_id: UUID
|
||||||
|
event_cursor: int = Field(ge=0)
|
||||||
|
items: list[TaskPresentationHistoryItem]
|
||||||
|
next_after: int | None = Field(default=None, ge=0)
|
||||||
|
|
||||||
|
|
||||||
class TaskDataQueryResult(StrictModel):
|
class TaskDataQueryResult(StrictModel):
|
||||||
evidence: EvidenceRecord
|
evidence: EvidenceRecord
|
||||||
snapshot: TaskDataSnapshot
|
snapshot: TaskDataSnapshot
|
||||||
@@ -803,6 +1132,13 @@ class HumanEvidenceCommand(StrictModel):
|
|||||||
evidence: EvidenceInput
|
evidence: EvidenceInput
|
||||||
|
|
||||||
|
|
||||||
|
class WorkOrderNotificationReference(StrictModel):
|
||||||
|
source_event_id: NonEmptyStr
|
||||||
|
source_revision: PositiveInt
|
||||||
|
evidence_id: NonEmptyStr | None = None
|
||||||
|
status: Literal["scheduled", "assigned", "in_progress", "awaiting_feedback", "completed", "cancelled", "failed"]
|
||||||
|
|
||||||
|
|
||||||
class OperationalTaskNotification(StrictModel):
|
class OperationalTaskNotification(StrictModel):
|
||||||
notification_id: UUID
|
notification_id: UUID
|
||||||
task_id: UUID
|
task_id: UUID
|
||||||
@@ -810,15 +1146,17 @@ class OperationalTaskNotification(StrictModel):
|
|||||||
"task_created", "action_pending", "report_updated", "task_completed",
|
"task_created", "action_pending", "report_updated", "task_completed",
|
||||||
"task_failed", "run_delivery_degraded", "access_approval_pending",
|
"task_failed", "run_delivery_degraded", "access_approval_pending",
|
||||||
"action_approval_pending", "human_input_required",
|
"action_approval_pending", "human_input_required",
|
||||||
"external_coordination_failed"
|
"external_coordination_failed", "work_order_feedback"
|
||||||
]
|
]
|
||||||
title: NonEmptyStr
|
title: NonEmptyStr
|
||||||
description: NonEmptyStr
|
description: NonEmptyStr
|
||||||
required_permission: NonEmptyStr | None = None
|
required_permission: NonEmptyStr | None = None
|
||||||
lifecycle_status: Literal["active", "resolved"] = "active"
|
lifecycle_status: Literal["active", "resolved"] = "active"
|
||||||
subject_type: Literal["task", "run", "access_request", "action"] = "task"
|
subject_type: Literal["task", "run", "access_request", "action", "question"] = "task"
|
||||||
subject_id: UUID | None = None
|
subject_id: UUID | None = None
|
||||||
report_reference: InvestigationReportReference | None = None
|
report_reference: InvestigationReportReference | None = None
|
||||||
|
work_order_action: HumanApprovalAction | None = None
|
||||||
|
work_order_reference: WorkOrderNotificationReference | None = None
|
||||||
resolved_at: WorkbenchDateTime | None = None
|
resolved_at: WorkbenchDateTime | None = None
|
||||||
resolution_reason: NonEmptyStr | None = None
|
resolution_reason: NonEmptyStr | None = None
|
||||||
status: Literal["unread", "read"]
|
status: Literal["unread", "read"]
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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-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):
|
||||||
|
for values in (self.pressure_device_ids, self.outlet_flow_device_ids):
|
||||||
|
if len(set(values)) != len(values) or any(not value.strip() for value in values):
|
||||||
|
raise ValueError("SCADA 监测角色中的设备标识不能为空或重复")
|
||||||
|
if set(self.pressure_device_ids) & set(self.outlet_flow_device_ids):
|
||||||
|
raise ValueError("同一设备不能同时作为压力与出厂边界流量")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectSettings(BaseModel):
|
||||||
|
"""Extensible, non-secret project parameters stored in MetaDB as JSON."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="allow", allow_inf_nan=False)
|
||||||
|
|
||||||
|
__pydantic_extra__: dict[str, JsonValue] = Field(init=False)
|
||||||
|
scada_monitoring_interval_seconds: int | None = Field(
|
||||||
|
default=None,
|
||||||
|
strict=True,
|
||||||
|
ge=60,
|
||||||
|
le=31_536_000,
|
||||||
|
description="SCADA 主动监测演示步长,单位秒;null 表示未配置",
|
||||||
|
)
|
||||||
|
scada_monitoring: ScadaMonitoringSettings | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="主动监测使用的项目设备角色;不创建定时任务",
|
||||||
|
)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Reader-facing text checks for new publications, never historical reads."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.domain.schemas.answer_ui import ReportUiBlock
|
||||||
|
|
||||||
|
|
||||||
|
_execution_text = re.compile(
|
||||||
|
r"\b(?:execution_generation|handling_context|verification_ready|waiting_work_order|"
|
||||||
|
r"waiting_scada|returned_at|verified_through|required_samples|interval_seconds|"
|
||||||
|
r"knowledge_cutoff|batch_sequence|query_complete|evidence_id|action_id|run_id|"
|
||||||
|
r"task_id|source_session_id|approval_status|execution_status|verification_evidence_id|"
|
||||||
|
r"normal_day_count|baseline_method|selection_method|get_task_context|query_scada_readings|"
|
||||||
|
r"run_burst_location|publish_task_report|publish_task_progress)\b|"
|
||||||
|
r"\b(?:status|phase|interval)\s*[:=]|\b(?:mcp|recovery|sha256):|"
|
||||||
|
r"\bskill(?:\.(?:name|version))?\s*[:=]|\bskill\s+[\w-]+\s+v?\d|"
|
||||||
|
r"\b(?:final|interim)/concluded\b|"
|
||||||
|
r"[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}",
|
||||||
|
re.IGNORECASE | re.ASCII,
|
||||||
|
)
|
||||||
|
_source_time = re.compile(
|
||||||
|
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}|UTC\s*[+-]\s*\d|Asia/Shanghai|上海时间|"
|
||||||
|
r"\d{2}:\d{2}(?::\d{2})?\s*\+\d{2}:\d{2}",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_unrendered_markup = re.compile(r"`|\*\*|</?[A-Za-z][^>]*>|^\s{0,3}#{1,6}\s", re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_report_text(value: str) -> str:
|
||||||
|
if _execution_text.search(value):
|
||||||
|
raise ValueError("请将执行字段和状态码改写为中文业务说明,完整编号保留在结构化引用中")
|
||||||
|
if _source_time.search(value):
|
||||||
|
raise ValueError("请将来源时间转换为当地日期和时刻,正文省略时区名称、偏移和 ISO 时间戳")
|
||||||
|
if _unrendered_markup.search(value):
|
||||||
|
raise ValueError("可见文字使用纯文本;重点与明细使用对应结果组件,不写 Markdown 或 HTML")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def validate_report_ui_text(blocks: list[ReportUiBlock]) -> None:
|
||||||
|
for item in blocks:
|
||||||
|
validate_report_text(item.block.fallback_text)
|
||||||
|
for element in item.block.spec.elements.values():
|
||||||
|
props = element.props
|
||||||
|
for key in ("title", "summary", "text", "description", "x_axis_name", "y_axis_name"):
|
||||||
|
if isinstance(props.get(key), str):
|
||||||
|
validate_report_text(props[key])
|
||||||
|
for value in props.get("columns", []):
|
||||||
|
validate_report_text(value)
|
||||||
|
for row in props.get("rows", []):
|
||||||
|
for value in row:
|
||||||
|
validate_report_text(value)
|
||||||
|
for value in props.get("x_data", []):
|
||||||
|
validate_report_text(value)
|
||||||
|
for series in props.get("series", []):
|
||||||
|
validate_report_text(series["name"])
|
||||||
|
|
||||||
|
|
||||||
|
def validate_presentation_command_text(command: dict[str, Any]) -> None:
|
||||||
|
"""Check visible labels without touching geometry, references or timestamps."""
|
||||||
|
if command["action"] == "set_overlay":
|
||||||
|
validate_report_text(command["title"])
|
||||||
|
elif command["action"] == "open_chart":
|
||||||
|
chart = command["chart"]
|
||||||
|
for key in ("title", "x_axis_name", "y_axis_name"):
|
||||||
|
if isinstance(chart.get(key), str):
|
||||||
|
validate_report_text(chart[key])
|
||||||
|
for value in chart["x_data"]:
|
||||||
|
validate_report_text(value)
|
||||||
|
for series in chart["series"]:
|
||||||
|
validate_report_text(series["name"])
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
class ScadaDeviceResponse(BaseModel):
|
class ScadaDeviceResponse(BaseModel):
|
||||||
"""Project SCADA metadata keyed by the canonical device identifier."""
|
"""Project SCADA metadata keyed by the canonical device identifier."""
|
||||||
|
|
||||||
@@ -47,3 +47,20 @@ class ScadaLatestReadingsResponse(BaseModel):
|
|||||||
offset: int = Field(ge=0)
|
offset: int = Field(ge=0)
|
||||||
observed_count: int = Field(ge=0)
|
observed_count: int = Field(ge=0)
|
||||||
latest_observed_at: datetime | None = None
|
latest_observed_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ScadaRawReading(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
|
||||||
|
|
||||||
|
time: datetime
|
||||||
|
device_id: str
|
||||||
|
monitored_value: float | None
|
||||||
|
quality: Literal["unvalidated", "missing", "invalid"] = "unvalidated"
|
||||||
|
|
||||||
|
|
||||||
|
class ScadaRawPage(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
|
||||||
|
|
||||||
|
items: list[ScadaRawReading]
|
||||||
|
next_cursor: str | None
|
||||||
|
scanned_through: datetime
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""Persisted replay controls and explicit, unit-aware demonstration rules."""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Literal
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
class ScadaReplayRule(ReplayModel):
|
||||||
|
rule_id: str = Field(min_length=1, max_length=80, pattern=r"^[a-zA-Z0-9_-]+$")
|
||||||
|
device_ids: list[str] = Field(min_length=1, max_length=500)
|
||||||
|
kind: Literal["relative_change", "bounds"]
|
||||||
|
direction: Literal["both", "increase", "decrease"] = "both"
|
||||||
|
threshold: float | None = Field(None, gt=0)
|
||||||
|
lower: float | None = None
|
||||||
|
upper: float | None = None
|
||||||
|
unit: str = Field(min_length=1, max_length=40)
|
||||||
|
metric_name: str = Field(default="", max_length=80)
|
||||||
|
minimum_samples: int = Field(3, ge=1, le=96)
|
||||||
|
window_samples: int = Field(4, ge=1, le=96)
|
||||||
|
consecutive_samples: int = Field(1, ge=1, le=20)
|
||||||
|
recovery_samples: int = Field(2, ge=1, le=20)
|
||||||
|
minimum_devices: int = Field(1, ge=1, le=500)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def parameters(self):
|
||||||
|
if len(set(self.device_ids)) != len(self.device_ids):
|
||||||
|
raise ValueError("规则内设备不能重复")
|
||||||
|
if self.minimum_devices > len(self.device_ids):
|
||||||
|
raise ValueError("同时异常设备数超过规则设备数")
|
||||||
|
if self.kind == "relative_change" and (self.threshold is None or self.minimum_samples > self.window_samples):
|
||||||
|
raise ValueError("变化率规则需要阈值和足够的历史窗口")
|
||||||
|
if self.kind == "bounds" and self.lower is None and self.upper is None:
|
||||||
|
raise ValueError("上下限规则至少配置一个阈值")
|
||||||
|
if self.lower is not None and self.upper is not None and self.lower >= self.upper:
|
||||||
|
raise ValueError("规则下限必须小于上限")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class ScadaQualityRange(ReplayModel):
|
||||||
|
lower: float | None = None
|
||||||
|
upper: float | None = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def bounds(self):
|
||||||
|
if self.lower is not None and self.upper is not None and self.lower >= self.upper:
|
||||||
|
raise ValueError("仪表量程下限必须小于上限")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class ScadaReplayCreate(ReplayModel):
|
||||||
|
request_id: UUID
|
||||||
|
start_time: datetime
|
||||||
|
end_time: datetime
|
||||||
|
device_ids: list[str] = Field(min_length=1, max_length=500)
|
||||||
|
rules: list[ScadaReplayRule] = Field(min_length=1, max_length=30)
|
||||||
|
quality_ranges: dict[str, ScadaQualityRange] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def configuration(self):
|
||||||
|
if (self.start_time.tzinfo is None or self.end_time.tzinfo is None
|
||||||
|
or not timedelta(0) < self.end_time - self.start_time <= timedelta(days=31)):
|
||||||
|
raise ValueError("请选择带时区、最长 31 天的有效时间区间")
|
||||||
|
ids = set(self.device_ids)
|
||||||
|
if len(ids) != len(self.device_ids) or any(not value.strip() for value in ids):
|
||||||
|
raise ValueError("设备标识不能为空或重复")
|
||||||
|
if len({rule.rule_id for rule in self.rules}) != len(self.rules):
|
||||||
|
raise ValueError("规则标识不能重复")
|
||||||
|
if any(not set(rule.device_ids) <= ids for rule in self.rules) or not set(self.quality_ranges) <= ids:
|
||||||
|
raise ValueError("规则及量程设备必须属于本次回放")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class ScadaReplayCommand(ReplayModel):
|
||||||
|
request_id: UUID
|
||||||
|
expected_revision: int = Field(ge=1)
|
||||||
|
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):
|
||||||
|
monitoring_id: UUID
|
||||||
|
period_start: datetime
|
||||||
|
period_end: datetime
|
||||||
|
knowledge_cutoff: datetime
|
||||||
|
batch_sequence: int = Field(ge=1)
|
||||||
|
rule_version: str = Field(min_length=1, max_length=80)
|
||||||
|
status: Literal["normal", "attention", "insufficient_data", "suspected_burst"]
|
||||||
|
detail: str = Field(min_length=1, max_length=1000)
|
||||||
|
task_id: UUID | None = None
|
||||||
|
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):
|
||||||
|
replay_id: UUID
|
||||||
|
project_id: UUID
|
||||||
|
revision: int
|
||||||
|
status: Literal["ready", "paused", "completed", "stopped", "failed"]
|
||||||
|
start_time: datetime
|
||||||
|
end_time: datetime
|
||||||
|
monitoring_interval_seconds: int | None = Field(None, ge=60, le=31_536_000)
|
||||||
|
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
|
||||||
|
processed_count: int = 0
|
||||||
|
anomaly_count: int = 0
|
||||||
|
devices: list[dict]
|
||||||
|
rules: list[ScadaReplayRule]
|
||||||
|
latest: list[dict]
|
||||||
|
active_events: list[dict]
|
||||||
|
error: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ScadaReplayCapabilities(ReplayModel):
|
||||||
|
available: bool
|
||||||
|
can_write: bool
|
||||||
|
reason: str | None = None
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class ScadaReplayEventsPage(ReplayModel):
|
||||||
|
items: list[dict]
|
||||||
|
next_offset: int | None
|
||||||
|
|
||||||
|
|
||||||
|
class ScadaReplayReadings(ReplayModel):
|
||||||
|
items: list[dict]
|
||||||
|
through: datetime
|
||||||
@@ -5,7 +5,7 @@ from typing import Literal
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictModel
|
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictModel
|
||||||
from app.domain.schemas.workbench_runtime import WorkbenchAlertSource, WorkbenchDateTime
|
from app.domain.schemas.workbench_runtime import WorkbenchAlertSource, WorkbenchScadaReplaySource, WorkbenchDateTime
|
||||||
|
|
||||||
|
|
||||||
class TaskAlertCommand(StrictModel):
|
class TaskAlertCommand(StrictModel):
|
||||||
@@ -14,7 +14,7 @@ class TaskAlertCommand(StrictModel):
|
|||||||
event_type: Literal["raised", "acknowledged", "resolved"]
|
event_type: Literal["raised", "acknowledged", "resolved"]
|
||||||
occurred_at: WorkbenchDateTime
|
occurred_at: WorkbenchDateTime
|
||||||
condition_id: NonEmptyStr | None
|
condition_id: NonEmptyStr | None
|
||||||
source: WorkbenchAlertSource
|
source: WorkbenchAlertSource | WorkbenchScadaReplaySource
|
||||||
title: NonEmptyStr
|
title: NonEmptyStr
|
||||||
description: NonEmptyStr
|
description: NonEmptyStr
|
||||||
severity: Literal["warning", "critical"]
|
severity: Literal["warning", "critical"]
|
||||||
@@ -29,7 +29,7 @@ class TaskAlertState(StrictModel):
|
|||||||
revision: PositiveInt
|
revision: PositiveInt
|
||||||
producer_id: NonEmptyStr
|
producer_id: NonEmptyStr
|
||||||
condition_id: NonEmptyStr | None
|
condition_id: NonEmptyStr | None
|
||||||
source: WorkbenchAlertSource
|
source: WorkbenchAlertSource | WorkbenchScadaReplaySource
|
||||||
title: NonEmptyStr
|
title: NonEmptyStr
|
||||||
description: NonEmptyStr
|
description: NonEmptyStr
|
||||||
occurred_at: WorkbenchDateTime
|
occurred_at: WorkbenchDateTime
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Durable investigation questions; answers are user input, not field evidence."""
|
||||||
|
from typing import Annotated, Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import Field, model_validator
|
||||||
|
|
||||||
|
from app.domain.schemas.shared import StrictModel, PositiveInt
|
||||||
|
from app.domain.schemas.workbench_runtime import WorkbenchDateTime
|
||||||
|
|
||||||
|
Id = Annotated[str, Field(min_length=1, max_length=128)]
|
||||||
|
|
||||||
|
|
||||||
|
class TaskQuestionOption(StrictModel):
|
||||||
|
id: Id
|
||||||
|
label: Annotated[str, Field(min_length=1, max_length=200)]
|
||||||
|
|
||||||
|
|
||||||
|
class TaskQuestion(StrictModel):
|
||||||
|
question_id: Id
|
||||||
|
title: Annotated[str, Field(min_length=1, max_length=200)]
|
||||||
|
body: Annotated[str, Field(min_length=1, max_length=2000)]
|
||||||
|
options: list[TaskQuestionOption] = Field(max_length=12)
|
||||||
|
mode: Literal["single", "multiple"]
|
||||||
|
allow_custom: bool
|
||||||
|
required: bool
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def valid_options(self):
|
||||||
|
if len({o.id for o in self.options}) != len(self.options):
|
||||||
|
raise ValueError("duplicate option ids")
|
||||||
|
if not self.options and not self.allow_custom:
|
||||||
|
raise ValueError("question must offer an answer input")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class TaskQuestionAnswer(StrictModel):
|
||||||
|
question_id: Id
|
||||||
|
selected_option_ids: list[Id] = Field(max_length=12)
|
||||||
|
custom_text: Annotated[str, Field(max_length=2000)]
|
||||||
|
|
||||||
|
|
||||||
|
class TaskQuestionCreate(StrictModel):
|
||||||
|
request_id: UUID
|
||||||
|
source_session_id: Id
|
||||||
|
run_id: UUID
|
||||||
|
questions: list[TaskQuestion] = Field(min_length=1, max_length=3)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def unique_questions(self):
|
||||||
|
if len({q.question_id for q in self.questions}) != len(self.questions):
|
||||||
|
raise ValueError("duplicate question ids")
|
||||||
|
if len(self.model_dump_json().encode()) > 32768:
|
||||||
|
raise ValueError("question definition too large")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class TaskQuestionReply(StrictModel):
|
||||||
|
request_id: UUID
|
||||||
|
expected_version: PositiveInt
|
||||||
|
answers: list[TaskQuestionAnswer] = Field(max_length=3)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskQuestionRequest(StrictModel):
|
||||||
|
question_request_id: UUID
|
||||||
|
task_id: UUID
|
||||||
|
run_id: UUID
|
||||||
|
execution_generation: PositiveInt
|
||||||
|
source_session_id: Id
|
||||||
|
questions: list[TaskQuestion]
|
||||||
|
status: Literal["pending", "answered", "cancelled"]
|
||||||
|
version: PositiveInt
|
||||||
|
created_at: WorkbenchDateTime
|
||||||
|
answered_at: WorkbenchDateTime | None = None
|
||||||
|
answered_by: str | None = None
|
||||||
|
answers: list[TaskQuestionAnswer] = Field(default_factory=list)
|
||||||
@@ -49,10 +49,17 @@ class WorkbenchAlertSource(StrictModel):
|
|||||||
run_id: NonEmptyStr
|
run_id: NonEmptyStr
|
||||||
|
|
||||||
|
|
||||||
|
class WorkbenchScadaReplaySource(StrictModel):
|
||||||
|
kind: Literal["scada_replay"]
|
||||||
|
replay_id: NonEmptyStr
|
||||||
|
rule_id: NonEmptyStr
|
||||||
|
batch_sequence: int = Field(ge=1)
|
||||||
|
|
||||||
|
|
||||||
class WorkbenchRuntimeAlert(StrictModel):
|
class WorkbenchRuntimeAlert(StrictModel):
|
||||||
id: NonEmptyStr
|
id: NonEmptyStr
|
||||||
condition_id: NonEmptyStr | None
|
condition_id: NonEmptyStr | None
|
||||||
source: WorkbenchAlertSource
|
source: WorkbenchAlertSource | WorkbenchScadaReplaySource
|
||||||
title: NonEmptyStr
|
title: NonEmptyStr
|
||||||
description: NonEmptyStr
|
description: NonEmptyStr
|
||||||
occurred_at: WorkbenchDateTime
|
occurred_at: WorkbenchDateTime
|
||||||
@@ -62,7 +69,7 @@ class WorkbenchRuntimeAlert(StrictModel):
|
|||||||
class WorkbenchPersistedAlert(StrictModel):
|
class WorkbenchPersistedAlert(StrictModel):
|
||||||
alert_id: NonEmptyStr
|
alert_id: NonEmptyStr
|
||||||
condition_id: NonEmptyStr | None
|
condition_id: NonEmptyStr | None
|
||||||
source: WorkbenchAlertSource
|
source: WorkbenchAlertSource | WorkbenchScadaReplaySource
|
||||||
title: NonEmptyStr
|
title: NonEmptyStr
|
||||||
description: NonEmptyStr
|
description: NonEmptyStr
|
||||||
occurred_at: WorkbenchDateTime
|
occurred_at: WorkbenchDateTime
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Database resources owned by the standalone calculation worker."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
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."""
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.infra.db.dynamic_manager import project_connection_manager
|
||||||
|
from app.infra.db.metadb.database import SessionLocal
|
||||||
|
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
|
||||||
|
from app.infra.db.postgresql.calculation_jobs import CalculationJobRepository
|
||||||
|
|
||||||
|
project_uuid = UUID(str(project_id))
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
metadata = MetadataRepository(session)
|
||||||
|
project = await metadata.get_project_by_id(project_uuid)
|
||||||
|
if not project or project.status != "active":
|
||||||
|
raise ValueError("Calculation project is not active")
|
||||||
|
routing = await metadata.get_project_db_routing(project_uuid, "biz_data")
|
||||||
|
if not routing or routing.db_type != "postgresql":
|
||||||
|
raise ValueError("Project business PostgreSQL routing is unavailable")
|
||||||
|
await session.commit()
|
||||||
|
pool_min_size = (
|
||||||
|
routing.pool_min_size
|
||||||
|
if routing.pool_min_size is not None
|
||||||
|
else settings.PROJECT_PG_POOL_MIN_SIZE
|
||||||
|
)
|
||||||
|
pool_max_size = (
|
||||||
|
routing.pool_max_size
|
||||||
|
if routing.pool_max_size is not None
|
||||||
|
else settings.PROJECT_PG_POOL_SIZE
|
||||||
|
)
|
||||||
|
async with project_connection_manager.pg_connection(
|
||||||
|
project_uuid,
|
||||||
|
"biz_data",
|
||||||
|
routing.dsn,
|
||||||
|
pool_min_size,
|
||||||
|
pool_max_size,
|
||||||
|
) as connection:
|
||||||
|
yield CalculationJobRepository(connection, project_id)
|
||||||
|
|
||||||
|
|
||||||
|
class CalculationSupervisorLease:
|
||||||
|
"""Keep the singleton advisory lock alive on one metadata connection."""
|
||||||
|
|
||||||
|
def __init__(self, connection):
|
||||||
|
self.connection = connection
|
||||||
|
|
||||||
|
async def ping(self) -> None:
|
||||||
|
await self.connection.execute(text("SELECT 1"))
|
||||||
|
await self.connection.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def calculation_supervisor_lease():
|
||||||
|
"""Acquire and release the session-level calculation supervisor lock."""
|
||||||
|
from app.infra.db.metadb.database import engine
|
||||||
|
|
||||||
|
async with engine.connect() as connection:
|
||||||
|
acquired = await connection.scalar(text("SELECT pg_try_advisory_lock(1748531781, 2)"))
|
||||||
|
await connection.commit()
|
||||||
|
if not acquired:
|
||||||
|
raise RuntimeError("Another calculation supervisor is already active")
|
||||||
|
try:
|
||||||
|
yield CalculationSupervisorLease(connection)
|
||||||
|
finally:
|
||||||
|
await connection.execute(text("SELECT pg_advisory_unlock(1748531781, 2)"))
|
||||||
|
await connection.commit()
|
||||||
@@ -44,6 +44,7 @@ class Project(Base):
|
|||||||
gs_workspace: Mapped[str] = mapped_column(String(100), unique=True)
|
gs_workspace: Mapped[str] = mapped_column(String(100), unique=True)
|
||||||
map_extent: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
map_extent: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
map_config: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
|
map_config: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
|
||||||
|
settings: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
|
||||||
status: Mapped[str] = mapped_column(String(20), default="active")
|
status: Mapped[str] = mapped_column(String(20), default="active")
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), default=datetime.utcnow
|
DateTime(timezone=True), default=datetime.utcnow
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ class ProjectDetail:
|
|||||||
map_extent: Optional[dict]
|
map_extent: Optional[dict]
|
||||||
status: str
|
status: str
|
||||||
map_config: dict = field(default_factory=dict)
|
map_config: dict = field(default_factory=dict)
|
||||||
|
settings: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -223,6 +224,7 @@ class MetadataRepository:
|
|||||||
status: str,
|
status: str,
|
||||||
creator_user_id: UUID | None = None,
|
creator_user_id: UUID | None = None,
|
||||||
map_config: dict | None = None,
|
map_config: dict | None = None,
|
||||||
|
settings: dict | None = None,
|
||||||
) -> models.Project:
|
) -> models.Project:
|
||||||
project = models.Project(
|
project = models.Project(
|
||||||
id=uuid4(),
|
id=uuid4(),
|
||||||
@@ -233,6 +235,7 @@ class MetadataRepository:
|
|||||||
map_extent=map_extent,
|
map_extent=map_extent,
|
||||||
status=status,
|
status=status,
|
||||||
map_config=map_config or {},
|
map_config=map_config or {},
|
||||||
|
settings=settings or {},
|
||||||
created_at=_utcnow(),
|
created_at=_utcnow(),
|
||||||
updated_at=_utcnow(),
|
updated_at=_utcnow(),
|
||||||
)
|
)
|
||||||
@@ -263,6 +266,7 @@ class MetadataRepository:
|
|||||||
timescale_dsn: str,
|
timescale_dsn: str,
|
||||||
pool_min_size: int = 1,
|
pool_min_size: int = 1,
|
||||||
pool_max_size: int = 4,
|
pool_max_size: int = 4,
|
||||||
|
settings: dict | None = None,
|
||||||
) -> models.Project:
|
) -> models.Project:
|
||||||
"""Atomically expose a fully provisioned project and both DB routes."""
|
"""Atomically expose a fully provisioned project and both DB routes."""
|
||||||
business_secret = _encrypt_database_secret(business_dsn)
|
business_secret = _encrypt_database_secret(business_dsn)
|
||||||
@@ -276,6 +280,7 @@ class MetadataRepository:
|
|||||||
map_extent=map_extent,
|
map_extent=map_extent,
|
||||||
status="active",
|
status="active",
|
||||||
map_config={},
|
map_config={},
|
||||||
|
settings=settings or {},
|
||||||
created_at=_utcnow(),
|
created_at=_utcnow(),
|
||||||
updated_at=_utcnow(),
|
updated_at=_utcnow(),
|
||||||
)
|
)
|
||||||
@@ -333,6 +338,8 @@ class MetadataRepository:
|
|||||||
setattr(project, field, updates[field])
|
setattr(project, field, updates[field])
|
||||||
if "map_config" in updates:
|
if "map_config" in updates:
|
||||||
project.map_config = {**(project.map_config or {}), **updates["map_config"]}
|
project.map_config = {**(project.map_config or {}), **updates["map_config"]}
|
||||||
|
if "settings" in updates:
|
||||||
|
project.settings = {**(project.settings or {}), **updates["settings"]}
|
||||||
project.updated_at = _utcnow()
|
project.updated_at = _utcnow()
|
||||||
await self.session.commit()
|
await self.session.commit()
|
||||||
await self.session.refresh(project)
|
await self.session.refresh(project)
|
||||||
@@ -350,6 +357,7 @@ class MetadataRepository:
|
|||||||
gs_workspace=project.gs_workspace,
|
gs_workspace=project.gs_workspace,
|
||||||
map_extent=project.map_extent,
|
map_extent=project.map_extent,
|
||||||
map_config=project.map_config or {},
|
map_config=project.map_config or {},
|
||||||
|
settings=project.settings or {},
|
||||||
status=project.status,
|
status=project.status,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"""Durable project calculation queue. Every worker update is generation fenced."""
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from psycopg.types.json import Jsonb
|
||||||
|
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,result_expires_at"
|
||||||
|
|
||||||
|
|
||||||
|
PUBLIC_JOB_COLUMNS = "job_id,status,tool,error,created_at,updated_at,started_at,finished_at,result_expires_at"
|
||||||
|
|
||||||
|
|
||||||
|
class CalculationJobRepository:
|
||||||
|
def __init__(self, connection, project_id):
|
||||||
|
self.connection = connection
|
||||||
|
self.project_id = str(project_id)
|
||||||
|
|
||||||
|
async def maintain(self):
|
||||||
|
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()""",
|
||||||
|
(self.project_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def enqueue(self, *, owner_user_id, request_key, tool, parameters, binding, timeout_seconds,
|
||||||
|
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,
|
||||||
|
max_active_jobs=max_active_jobs)
|
||||||
|
|
||||||
|
async def _enqueue(self, *, owner_user_id, request_key, tool, parameters, binding,
|
||||||
|
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,))
|
||||||
|
await self.maintain()
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
f"SELECT {JOB_COLUMNS} FROM workbench.calculation_jobs WHERE project_id=%s AND owner_user_id=%s AND request_key=%s",
|
||||||
|
(self.project_id, owner_user_id, request_key),
|
||||||
|
)
|
||||||
|
existing = await cursor.fetchone()
|
||||||
|
if existing:
|
||||||
|
row = dict(existing)
|
||||||
|
if (row['tool'] != tool
|
||||||
|
or {key: value for key, value in row['parameters'].items() if key != 'timeout_seconds'}
|
||||||
|
!= {key: value for key, value in parameters.items() if key != 'timeout_seconds'}
|
||||||
|
or row['binding'] != binding):
|
||||||
|
raise HTTPException(409, 'Calculation request_key was used with different inputs')
|
||||||
|
return row
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT count(*) AS active FROM workbench.calculation_jobs WHERE project_id=%s
|
||||||
|
AND status IN ('queued','running','cancel_requested')""", (self.project_id,))
|
||||||
|
if (await cursor.fetchone())['active'] >= max_active_jobs:
|
||||||
|
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)
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
return dict(await cursor.fetchone())
|
||||||
|
|
||||||
|
async def get(self, job_id, owner_user_id=None, *, include_result=False):
|
||||||
|
await self.maintain()
|
||||||
|
columns = JOB_COLUMNS + (",result" if include_result else "")
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
f"""SELECT {columns} FROM workbench.calculation_jobs WHERE project_id=%s AND job_id=%s
|
||||||
|
AND (%s::uuid IS NULL OR owner_user_id=%s)""",
|
||||||
|
(self.project_id, job_id, owner_user_id, owner_user_id),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
async def list(self, *, owner_user_id, binding, limit, offset, status=None):
|
||||||
|
await self.maintain()
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
f"""SELECT {PUBLIC_JOB_COLUMNS} FROM workbench.calculation_jobs WHERE project_id=%s AND owner_user_id=%s
|
||||||
|
AND binding @> %s AND (%s::text IS NULL OR status=%s) ORDER BY created_at DESC,job_id LIMIT %s OFFSET %s""",
|
||||||
|
(self.project_id, owner_user_id, Jsonb(binding), status, status, limit, offset),
|
||||||
|
)
|
||||||
|
return [dict(row) for row in await cursor.fetchall()]
|
||||||
|
|
||||||
|
async def cancel(self, job_id, owner_user_id):
|
||||||
|
await self.connection.execute(
|
||||||
|
"""UPDATE workbench.calculation_jobs SET status=CASE WHEN status='queued' THEN 'cancelled'
|
||||||
|
ELSE 'cancel_requested' END,finished_at=CASE WHEN status='queued' THEN clock_timestamp() ELSE finished_at END,updated_at=clock_timestamp()
|
||||||
|
WHERE project_id=%s AND job_id=%s AND owner_user_id=%s AND status IN ('queued','running')""",
|
||||||
|
(self.project_id, job_id, owner_user_id),
|
||||||
|
)
|
||||||
|
return await self.get(job_id, owner_user_id)
|
||||||
|
|
||||||
|
async def recover(self):
|
||||||
|
await self.maintain()
|
||||||
|
# A crashed write is never automatically replayed.
|
||||||
|
await self.connection.execute(
|
||||||
|
"""UPDATE workbench.calculation_jobs SET status='interrupted',
|
||||||
|
error='{"code":"WORKER_INTERRUPTED"}'::jsonb,finished_at=clock_timestamp(),updated_at=clock_timestamp()
|
||||||
|
WHERE project_id=%s AND status IN ('running','cancel_requested')
|
||||||
|
AND lease_expires_at < clock_timestamp()""", (self.project_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def claim(self, owner, lease_seconds):
|
||||||
|
await self.maintain()
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""UPDATE workbench.calculation_jobs j SET status='running',started_at=clock_timestamp(),lease_owner=%s,
|
||||||
|
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' ORDER BY created_at,job_id
|
||||||
|
FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING j.*""",
|
||||||
|
(owner, lease_seconds, self.project_id),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
async def heartbeat(self, row, owner, lease_seconds):
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""UPDATE workbench.calculation_jobs SET lease_expires_at=clock_timestamp()+%s*interval '1 second',
|
||||||
|
updated_at=clock_timestamp() WHERE project_id=%s AND job_id=%s AND lease_owner=%s
|
||||||
|
AND lease_generation=%s AND lease_expires_at>clock_timestamp()
|
||||||
|
AND status IN ('running','cancel_requested') RETURNING status""",
|
||||||
|
(lease_seconds, self.project_id, row['job_id'], owner, row['lease_generation']),
|
||||||
|
)
|
||||||
|
result = await cursor.fetchone()
|
||||||
|
return result['status'] if result else None
|
||||||
|
|
||||||
|
async def finish(self, row, owner, status, *, result=None, error=None, result_retention_seconds=604800):
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""UPDATE workbench.calculation_jobs SET status=%s,result=%s,error=%s,finished_at=clock_timestamp(),
|
||||||
|
result_expires_at=clock_timestamp()+%s*interval '1 second',updated_at=clock_timestamp()
|
||||||
|
WHERE project_id=%s AND job_id=%s AND lease_owner=%s AND lease_generation=%s
|
||||||
|
AND lease_expires_at>clock_timestamp() AND status IN ('running','cancel_requested')
|
||||||
|
AND (status!='cancel_requested' OR %s IN ('cancelled','interrupted')
|
||||||
|
OR (%s='failed' AND %s='CLEANUP_FAILED')) RETURNING job_id""",
|
||||||
|
(status, Jsonb(result) if result is not None else None, Jsonb(error) if error else None, result_retention_seconds,
|
||||||
|
self.project_id, row['job_id'], owner, row['lease_generation'], status, status, (error or {}).get('code')),
|
||||||
|
)
|
||||||
|
return await cursor.fetchone() is not None
|
||||||
@@ -6,6 +6,24 @@ from psycopg import AsyncConnection
|
|||||||
|
|
||||||
|
|
||||||
class NetworkAssetRepository:
|
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
|
@staticmethod
|
||||||
async def get_pipes_by_ids(
|
async def get_pipes_by_ids(
|
||||||
conn: AsyncConnection,
|
conn: AsyncConnection,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class OperationalTaskRepository:
|
|||||||
def __init__(self, connection: AsyncConnection, project_id):
|
def __init__(self, connection: AsyncConnection, project_id):
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
self.project_id = str(project_id)
|
self.project_id = str(project_id)
|
||||||
|
self._agent_ownership_available: bool | None = None
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def transaction(self):
|
async def transaction(self):
|
||||||
@@ -75,6 +76,48 @@ class OperationalTaskRepository:
|
|||||||
status=409,
|
status=409,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def pending_calculation_jobs(
|
||||||
|
self, task_id: str, run_id: str, execution_request_id: str,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Only the exact original run blocks final artifacts and completion."""
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT job_id FROM workbench.calculation_jobs
|
||||||
|
WHERE project_id=%s AND binding->>'task_id'=%s
|
||||||
|
AND binding->>'run_id'=%s AND binding->>'execution_request_id'=%s
|
||||||
|
AND status IN ('queued','running','cancel_requested')
|
||||||
|
ORDER BY created_at,job_id LIMIT 100""",
|
||||||
|
(self.project_id, task_id, run_id, execution_request_id),
|
||||||
|
)
|
||||||
|
return [str(row["job_id"]) for row in await cursor.fetchall()]
|
||||||
|
|
||||||
|
async def list_questions(self, task_id: str) -> list[dict]:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT document FROM workbench.operational_task_questions
|
||||||
|
WHERE project_id=%s AND task_id=%s
|
||||||
|
ORDER BY document->>'created_at' DESC, question_request_id DESC LIMIT 100""",
|
||||||
|
(self.project_id, task_id),
|
||||||
|
)
|
||||||
|
return [row["document"] for row in await cursor.fetchall()]
|
||||||
|
|
||||||
|
async def get_question(self, task_id: str, question_id: str) -> dict | None:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT document FROM workbench.operational_task_questions
|
||||||
|
WHERE project_id=%s AND task_id=%s AND question_request_id=%s""",
|
||||||
|
(self.project_id, task_id, question_id),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return row["document"] if row else None
|
||||||
|
|
||||||
|
async def save_question(self, task_id: str, document: dict) -> None:
|
||||||
|
# All writers hold the owning task advisory lock.
|
||||||
|
await self.connection.execute(
|
||||||
|
"""INSERT INTO workbench.operational_task_questions
|
||||||
|
(project_id,task_id,question_request_id,document) VALUES (%s,%s,%s,%s)
|
||||||
|
ON CONFLICT (project_id,question_request_id) DO UPDATE SET document=excluded.document
|
||||||
|
WHERE operational_task_questions.task_id=excluded.task_id""",
|
||||||
|
(self.project_id, task_id, document["question_request_id"], Jsonb(document)),
|
||||||
|
)
|
||||||
|
|
||||||
async def get_task(self, task_id: str) -> dict | None:
|
async def get_task(self, task_id: str) -> dict | None:
|
||||||
cursor = await self.connection.execute(
|
cursor = await self.connection.execute(
|
||||||
"""SELECT document FROM workbench.operational_tasks
|
"""SELECT document FROM workbench.operational_tasks
|
||||||
@@ -87,7 +130,8 @@ class OperationalTaskRepository:
|
|||||||
async def get_active_run(self, task_id: str) -> dict | None:
|
async def get_active_run(self, task_id: str) -> dict | None:
|
||||||
cursor = await self.connection.execute(
|
cursor = await self.connection.execute(
|
||||||
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
|
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
|
||||||
agent_session_id,lease_expires_at,heartbeat_at,started_at,finished_at,
|
agent_session_id,to_jsonb(operational_task_runs)->>'agent_instance_id' AS agent_instance_id,
|
||||||
|
lease_expires_at,heartbeat_at,started_at,finished_at,
|
||||||
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
|
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
|
||||||
created_at,updated_at
|
created_at,updated_at
|
||||||
FROM workbench.operational_task_runs
|
FROM workbench.operational_task_runs
|
||||||
@@ -102,7 +146,8 @@ class OperationalTaskRepository:
|
|||||||
async def get_run(self, run_id: str) -> dict | None:
|
async def get_run(self, run_id: str) -> dict | None:
|
||||||
cursor = await self.connection.execute(
|
cursor = await self.connection.execute(
|
||||||
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
|
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
|
||||||
agent_session_id,lease_expires_at,heartbeat_at,started_at,finished_at,
|
agent_session_id,to_jsonb(operational_task_runs)->>'agent_instance_id' AS agent_instance_id,
|
||||||
|
lease_expires_at,heartbeat_at,started_at,finished_at,
|
||||||
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
|
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
|
||||||
created_at,updated_at
|
created_at,updated_at
|
||||||
FROM workbench.operational_task_runs
|
FROM workbench.operational_task_runs
|
||||||
@@ -119,7 +164,7 @@ class OperationalTaskRepository:
|
|||||||
ORDER BY generation DESC LIMIT %s OFFSET %s""",
|
ORDER BY generation DESC LIMIT %s OFFSET %s""",
|
||||||
(self.project_id, task_id, limit, offset),
|
(self.project_id, task_id, limit, offset),
|
||||||
)
|
)
|
||||||
return [{key: value for key, value in row.items() if key != "project_id"}
|
return [{key: value for key, value in row.items() if key not in {"project_id", "artifact_manifest"}}
|
||||||
for row in await cursor.fetchall()]
|
for row in await cursor.fetchall()]
|
||||||
|
|
||||||
async def get_latest_run(self, task_id: str) -> dict | None:
|
async def get_latest_run(self, task_id: str) -> dict | None:
|
||||||
@@ -129,13 +174,23 @@ class OperationalTaskRepository:
|
|||||||
async def require_execution_storage(self) -> None:
|
async def require_execution_storage(self) -> None:
|
||||||
"""Reject claims before changing state if the inbox cannot be used."""
|
"""Reject claims before changing state if the inbox cannot be used."""
|
||||||
cursor = await self.connection.execute(
|
cursor = await self.connection.execute(
|
||||||
"SELECT to_regclass('workbench.agent_messages') IS NOT NULL AS available"
|
"""SELECT to_regclass('workbench.agent_messages') IS NOT NULL
|
||||||
|
AND to_regclass('workbench.operational_task_questions') IS NOT NULL AS available,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM pg_attribute
|
||||||
|
WHERE attrelid=to_regclass('workbench.operational_task_runs')
|
||||||
|
AND attname='agent_instance_id' AND NOT attisdropped
|
||||||
|
) AS agent_ownership_available"""
|
||||||
)
|
)
|
||||||
if not (await cursor.fetchone())["available"]:
|
readiness = await cursor.fetchone()
|
||||||
|
self._agent_ownership_available = bool(readiness.get("agent_ownership_available"))
|
||||||
|
if not readiness["available"] or not self._agent_ownership_available:
|
||||||
raise DomainError(
|
raise DomainError(
|
||||||
"DEPENDENCY_UNAVAILABLE",
|
"DEPENDENCY_UNAVAILABLE",
|
||||||
"Agent message storage is not provisioned; apply "
|
"Agent execution storage is not provisioned; apply "
|
||||||
"resources/sql/024_agent_messages.sql to this project database before starting runs",
|
"resources/sql/024_agent_messages.sql, resources/sql/028_task_questions.sql, "
|
||||||
|
"and resources/sql/030_operational_run_agent_ownership.sql "
|
||||||
|
"to this project database before starting runs",
|
||||||
status=503,
|
status=503,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -176,14 +231,26 @@ class OperationalTaskRepository:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def update_run(self, run: dict, expected_revision: int) -> None:
|
async def update_run(self, run: dict, expected_revision: int) -> None:
|
||||||
|
if self._agent_ownership_available is None:
|
||||||
cursor = await self.connection.execute(
|
cursor = await self.connection.execute(
|
||||||
"""UPDATE workbench.operational_task_runs SET
|
"""SELECT EXISTS (
|
||||||
status=%s,revision=%s,agent_session_id=%s,lease_expires_at=%s,
|
SELECT 1 FROM pg_attribute
|
||||||
|
WHERE attrelid=to_regclass('workbench.operational_task_runs')
|
||||||
|
AND attname='agent_instance_id' AND NOT attisdropped
|
||||||
|
) AS available"""
|
||||||
|
)
|
||||||
|
self._agent_ownership_available = bool((await cursor.fetchone())["available"])
|
||||||
|
owner_assignment = "agent_instance_id=%s," if self._agent_ownership_available else ""
|
||||||
|
owner_values = (run.get("agent_instance_id"),) if self._agent_ownership_available else ()
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
f"""UPDATE workbench.operational_task_runs SET
|
||||||
|
status=%s,revision=%s,agent_session_id=%s,{owner_assignment}lease_expires_at=%s,
|
||||||
heartbeat_at=%s,started_at=%s,finished_at=%s,failure_code=%s,
|
heartbeat_at=%s,started_at=%s,finished_at=%s,failure_code=%s,
|
||||||
failure_detail=%s,dispatch_attempts=%s,next_dispatch_at=%s,updated_at=%s
|
failure_detail=%s,dispatch_attempts=%s,next_dispatch_at=%s,updated_at=%s
|
||||||
WHERE project_id=%s AND run_id=%s AND revision=%s""",
|
WHERE project_id=%s AND run_id=%s AND revision=%s""",
|
||||||
(
|
(
|
||||||
run["status"], run["revision"], run.get("agent_session_id"),
|
run["status"], run["revision"], run.get("agent_session_id"),
|
||||||
|
*owner_values,
|
||||||
run.get("lease_expires_at"), run.get("heartbeat_at"),
|
run.get("lease_expires_at"), run.get("heartbeat_at"),
|
||||||
run.get("started_at"), run.get("finished_at"), run.get("failure_code"),
|
run.get("started_at"), run.get("finished_at"), run.get("failure_code"),
|
||||||
run.get("failure_detail"), run.get("dispatch_attempts", 0),
|
run.get("failure_detail"), run.get("dispatch_attempts", 0),
|
||||||
@@ -197,10 +264,12 @@ class OperationalTaskRepository:
|
|||||||
async def list_runs(
|
async def list_runs(
|
||||||
self, *, status: str | None, limit: int, cursor_created_at=None, cursor_run_id=None,
|
self, *, status: str | None, limit: int, cursor_created_at=None, cursor_run_id=None,
|
||||||
task_type: str | None = None, task_created_from: datetime | None = None,
|
task_type: str | None = None, task_created_from: datetime | None = None,
|
||||||
|
execution_scope: str = "production",
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
result = await self.connection.execute(
|
result = await self.connection.execute(
|
||||||
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
|
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
|
||||||
agent_session_id,lease_expires_at,heartbeat_at,started_at,finished_at,
|
agent_session_id,to_jsonb(operational_task_runs)->>'agent_instance_id' AS agent_instance_id,
|
||||||
|
lease_expires_at,heartbeat_at,started_at,finished_at,
|
||||||
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
|
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
|
||||||
created_at,updated_at
|
created_at,updated_at
|
||||||
FROM workbench.operational_task_runs
|
FROM workbench.operational_task_runs
|
||||||
@@ -214,22 +283,25 @@ class OperationalTaskRepository:
|
|||||||
AND t.task_id=operational_task_runs.task_id
|
AND t.task_id=operational_task_runs.task_id
|
||||||
AND (%s::text IS NULL OR t.task_type=%s::text)
|
AND (%s::text IS NULL OR t.task_type=%s::text)
|
||||||
AND (%s::timestamptz IS NULL OR t.created_at>=%s::timestamptz)
|
AND (%s::timestamptz IS NULL OR t.created_at>=%s::timestamptz)
|
||||||
|
AND t.document->>'execution_scope'=%s
|
||||||
)
|
)
|
||||||
ORDER BY created_at,run_id LIMIT %s""",
|
ORDER BY created_at,run_id LIMIT %s""",
|
||||||
(
|
(
|
||||||
self.project_id, status, status, cursor_created_at,
|
self.project_id, status, status, cursor_created_at,
|
||||||
cursor_created_at, cursor_run_id,
|
cursor_created_at, cursor_run_id,
|
||||||
task_type, task_type, task_created_from, task_created_from, limit,
|
task_type, task_type, task_created_from, task_created_from, execution_scope, limit,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return [dict(row) for row in await result.fetchall()]
|
return [dict(row) for row in await result.fetchall()]
|
||||||
|
|
||||||
async def claim_expired_runs(
|
async def claim_expired_runs(
|
||||||
self, limit: int, *, task_type: str | None = None, task_created_from: datetime | None = None,
|
self, limit: int, *, task_type: str | None = None, task_created_from: datetime | None = None,
|
||||||
|
execution_scope: str = "production",
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
cursor = await self.connection.execute(
|
cursor = await self.connection.execute(
|
||||||
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
|
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
|
||||||
agent_session_id,lease_expires_at,heartbeat_at,started_at,finished_at,
|
agent_session_id,to_jsonb(operational_task_runs)->>'agent_instance_id' AS agent_instance_id,
|
||||||
|
lease_expires_at,heartbeat_at,started_at,finished_at,
|
||||||
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
|
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
|
||||||
created_at,updated_at
|
created_at,updated_at
|
||||||
FROM workbench.operational_task_runs
|
FROM workbench.operational_task_runs
|
||||||
@@ -241,9 +313,10 @@ class OperationalTaskRepository:
|
|||||||
AND t.task_id=operational_task_runs.task_id
|
AND t.task_id=operational_task_runs.task_id
|
||||||
AND (%s::text IS NULL OR t.task_type=%s::text)
|
AND (%s::text IS NULL OR t.task_type=%s::text)
|
||||||
AND (%s::timestamptz IS NULL OR t.created_at>=%s::timestamptz)
|
AND (%s::timestamptz IS NULL OR t.created_at>=%s::timestamptz)
|
||||||
|
AND t.document->>'execution_scope'=%s
|
||||||
)
|
)
|
||||||
ORDER BY lease_expires_at,run_id FOR UPDATE SKIP LOCKED LIMIT %s""",
|
ORDER BY lease_expires_at,run_id FOR UPDATE SKIP LOCKED LIMIT %s""",
|
||||||
(self.project_id, task_type, task_type, task_created_from, task_created_from, limit),
|
(self.project_id, task_type, task_type, task_created_from, task_created_from, execution_scope, limit),
|
||||||
)
|
)
|
||||||
return [dict(row) for row in await cursor.fetchall()]
|
return [dict(row) for row in await cursor.fetchall()]
|
||||||
|
|
||||||
@@ -286,6 +359,46 @@ class OperationalTaskRepository:
|
|||||||
async def current_event_cursor(self) -> int:
|
async def current_event_cursor(self) -> int:
|
||||||
return await WorkbenchEventRepository(self.connection, self.project_id).current_cursor()
|
return await WorkbenchEventRepository(self.connection, self.project_id).current_cursor()
|
||||||
|
|
||||||
|
async def list_presentation_events(self, task_id: str, after: int, through: int, limit: int) -> list[dict]:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT event_seq,occurred_at,metadata
|
||||||
|
FROM workbench.domain_event_log
|
||||||
|
WHERE project_id=%s AND topic='operational.presentation.requested'
|
||||||
|
AND aggregate_type='operational_task_presentation' AND aggregate_id=%s
|
||||||
|
AND event_seq>%s AND event_seq<=%s
|
||||||
|
ORDER BY event_seq LIMIT %s""",
|
||||||
|
(self.project_id, task_id, after, through, limit),
|
||||||
|
)
|
||||||
|
return [dict(row) for row in await cursor.fetchall()]
|
||||||
|
|
||||||
|
async def get_scada_presentation(self, task_id: str, run_id: str) -> dict | None:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT EXISTS (SELECT 1 FROM pg_attribute
|
||||||
|
WHERE attrelid=to_regclass('workbench.operational_task_runs')
|
||||||
|
AND attname='artifact_manifest' AND NOT attisdropped) AS available"""
|
||||||
|
)
|
||||||
|
if not (await cursor.fetchone())["available"]:
|
||||||
|
raise DomainError("DEPENDENCY_UNAVAILABLE",
|
||||||
|
"SCADA display coverage requires resources/sql/035_scada_presentation_coverage.sql in this project database",
|
||||||
|
status=503)
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT artifact_manifest->'scada_presentation' AS document FROM workbench.operational_task_runs
|
||||||
|
WHERE project_id=%s AND task_id=%s AND run_id=%s""",
|
||||||
|
(self.project_id, task_id, run_id),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return row["document"] if row else None
|
||||||
|
|
||||||
|
async def save_scada_presentation(self, task_id: str, run_id: str, document: dict, now: datetime) -> None:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""UPDATE workbench.operational_task_runs
|
||||||
|
SET artifact_manifest=jsonb_set(artifact_manifest,'{scada_presentation}',%s::jsonb,true)
|
||||||
|
WHERE project_id=%s AND task_id=%s AND run_id=%s""",
|
||||||
|
(Jsonb({**document, "updated_at": now.isoformat()}), self.project_id, task_id, run_id),
|
||||||
|
)
|
||||||
|
if cursor.rowcount != 1:
|
||||||
|
raise DomainError("RUN_NOT_FOUND", "SCADA display coverage targets an unknown run", status=409)
|
||||||
|
|
||||||
async def get_task_by_create_request(self, request_id: str) -> dict | None:
|
async def get_task_by_create_request(self, request_id: str) -> dict | None:
|
||||||
cursor = await self.connection.execute(
|
cursor = await self.connection.execute(
|
||||||
"""SELECT document FROM workbench.operational_tasks
|
"""SELECT document FROM workbench.operational_tasks
|
||||||
@@ -320,9 +433,9 @@ class OperationalTaskRepository:
|
|||||||
cursor = await self.connection.execute(
|
cursor = await self.connection.execute(
|
||||||
"""SELECT task.document,
|
"""SELECT task.document,
|
||||||
CASE WHEN active_run.run_id IS NULL THEN NULL
|
CASE WHEN active_run.run_id IS NULL THEN NULL
|
||||||
ELSE to_jsonb(active_run) - 'project_id' END AS active_run,
|
ELSE to_jsonb(active_run) - 'project_id' - 'artifact_manifest' END AS active_run,
|
||||||
CASE WHEN latest_run.run_id IS NULL THEN NULL
|
CASE WHEN latest_run.run_id IS NULL THEN NULL
|
||||||
ELSE to_jsonb(latest_run) - 'project_id' END AS latest_run
|
ELSE to_jsonb(latest_run) - 'project_id' - 'artifact_manifest' END AS latest_run
|
||||||
FROM workbench.operational_tasks AS task
|
FROM workbench.operational_tasks AS task
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT run.* FROM workbench.operational_task_runs AS run
|
SELECT run.* FROM workbench.operational_task_runs AS run
|
||||||
@@ -543,6 +656,37 @@ class OperationalTaskRepository:
|
|||||||
row = await cursor.fetchone()
|
row = await cursor.fetchone()
|
||||||
return row["document"] if row else None
|
return row["document"] if row else None
|
||||||
|
|
||||||
|
async def get_latest_graph_progress(self, task_id: str) -> dict | None:
|
||||||
|
availability = await self.connection.execute(
|
||||||
|
"SELECT to_regclass('workbench.operational_task_progress') AS relation"
|
||||||
|
)
|
||||||
|
if not (await availability.fetchone())["relation"]:
|
||||||
|
return None
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT document FROM workbench.operational_task_progress
|
||||||
|
WHERE project_id=%s AND task_id=%s
|
||||||
|
AND jsonb_typeof(document#>'{progress,diagram,edges}')='array'
|
||||||
|
ORDER BY (document->>'execution_generation')::bigint DESC, revision DESC LIMIT 1""",
|
||||||
|
(self.project_id, task_id),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return row["document"] if row else None
|
||||||
|
|
||||||
|
async def get_latest_diagram(self, task_id: str) -> dict | None:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT diagram FROM (
|
||||||
|
SELECT document#>'{progress,diagram}' AS diagram, published_at AS occurred_at
|
||||||
|
FROM workbench.operational_task_progress WHERE project_id=%s AND task_id=%s
|
||||||
|
UNION ALL
|
||||||
|
SELECT document#>'{report,diagram}', created_at
|
||||||
|
FROM workbench.operational_task_reports WHERE project_id=%s AND task_id=%s
|
||||||
|
) AS diagrams WHERE jsonb_typeof(diagram)='object'
|
||||||
|
ORDER BY occurred_at DESC LIMIT 1""",
|
||||||
|
(self.project_id, task_id, self.project_id, task_id),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return row["diagram"] if row else None
|
||||||
|
|
||||||
async def save_progress(self, task_id: str, snapshot: dict) -> None:
|
async def save_progress(self, task_id: str, snapshot: dict) -> None:
|
||||||
cursor = await self.connection.execute(
|
cursor = await self.connection.execute(
|
||||||
"""INSERT INTO workbench.operational_task_progress
|
"""INSERT INTO workbench.operational_task_progress
|
||||||
@@ -630,6 +774,28 @@ class OperationalTaskRepository:
|
|||||||
row = await cursor.fetchone()
|
row = await cursor.fetchone()
|
||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
async def recovery_candidates(self, limit: int, created_from=None, execution_scope: str = "production") -> 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->>'execution_scope'=%s
|
||||||
|
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, execution_scope, 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:
|
async def upsert_feedback(self, task_id: str, feedback: dict, accepted_at) -> None:
|
||||||
await self.connection.execute(
|
await self.connection.execute(
|
||||||
"""INSERT INTO workbench.operational_task_external_feedback
|
"""INSERT INTO workbench.operational_task_external_feedback
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ from uuid import UUID
|
|||||||
from psycopg import AsyncConnection
|
from psycopg import AsyncConnection
|
||||||
from psycopg import sql
|
from psycopg import sql
|
||||||
from psycopg.rows import dict_row
|
from psycopg.rows import dict_row
|
||||||
|
from psycopg.types.json import Jsonb
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
from app.domain.schemas.operational_task import LEGACY_INVESTIGATION_NODE_ANNOTATIONS
|
||||||
from app.infra.db.postgresql.scada import _SCADA_VIEW_SELECT, _device
|
from app.infra.db.postgresql.scada import _SCADA_VIEW_SELECT, _device
|
||||||
from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository
|
from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository
|
||||||
from app.infra.db.timescaledb.repositories.analysis import AnalysisResultsRepository
|
from app.infra.db.timescaledb.repositories.analysis import AnalysisResultsRepository
|
||||||
@@ -312,6 +314,7 @@ async def task_context_summary(conn: AsyncConnection, *, project_id: UUID, task_
|
|||||||
'current_report_revision',t.current_report_revision,
|
'current_report_revision',t.current_report_revision,
|
||||||
'title_preview',left(t.document->>'title',160),'preview_only',true,
|
'title_preview',left(t.document->>'title',160),'preview_only',true,
|
||||||
'detail',jsonb_build_object('section','task')),
|
'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
|
'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),
|
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
|
'selected_report',CASE WHEN %s::int IS NULL THEN NULL ELSE
|
||||||
@@ -328,6 +331,29 @@ async def task_context_summary(conn: AsyncConnection, *, project_id: UUID, task_
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _reference_diagram_projection(stored_sql: str, stored_params: tuple, section: str) -> tuple[str, tuple]:
|
||||||
|
"""Normalize only saved diagram annotations before any data_path or page projection."""
|
||||||
|
if section not in {"progress", "report"}:
|
||||||
|
raise ValueError("reference diagrams only belong to progress or report sections")
|
||||||
|
path = "{data," + section + ",diagram,node_refs}"
|
||||||
|
statement = f"""
|
||||||
|
WITH reference_documents AS (
|
||||||
|
{stored_sql}
|
||||||
|
), reference_aliases AS (
|
||||||
|
SELECT %s::jsonb AS annotations
|
||||||
|
)
|
||||||
|
SELECT CASE WHEN jsonb_typeof(snapshot#>'{path}')='array' THEN
|
||||||
|
jsonb_set(snapshot,'{path}',COALESCE((
|
||||||
|
SELECT jsonb_agg(CASE WHEN annotations ? (node->>'status')
|
||||||
|
THEN jsonb_set(node,'{{status}}',annotations->(node->>'status'))
|
||||||
|
ELSE node END ORDER BY position)
|
||||||
|
FROM jsonb_array_elements(snapshot#>'{path}') WITH ORDINALITY AS nodes(node,position)
|
||||||
|
),'[]'::jsonb)) ELSE snapshot END AS snapshot
|
||||||
|
FROM reference_documents CROSS JOIN reference_aliases
|
||||||
|
"""
|
||||||
|
return statement, (*stored_params, Jsonb(LEGACY_INVESTIGATION_NODE_ANNOTATIONS))
|
||||||
|
|
||||||
|
|
||||||
async def task_context_page(
|
async def task_context_page(
|
||||||
conn: AsyncConnection, *, project_id: UUID, task_id: UUID, section: str,
|
conn: AsyncConnection, *, project_id: UUID, task_id: UUID, section: str,
|
||||||
report_revision: int | None, data_path: list[str], limit: int, offset: int,
|
report_revision: int | None, data_path: list[str], limit: int, offset: int,
|
||||||
@@ -337,6 +363,7 @@ async def task_context_page(
|
|||||||
"evidence": ("operational_task_evidence", "evidence_id"),
|
"evidence": ("operational_task_evidence", "evidence_id"),
|
||||||
"actions": ("operational_task_actions", "action_id"),
|
"actions": ("operational_task_actions", "action_id"),
|
||||||
"access_requests": ("operational_task_access_requests", "access_request_id"),
|
"access_requests": ("operational_task_access_requests", "access_request_id"),
|
||||||
|
"questions": ("operational_task_questions", "question_request_id"),
|
||||||
}
|
}
|
||||||
documents = {"task": "operational_tasks", "report": "operational_task_reports", "plan": "operational_task_plans"}
|
documents = {"task": "operational_tasks", "report": "operational_task_reports", "plan": "operational_task_plans"}
|
||||||
if section in {"access_requests", "progress"}:
|
if section in {"access_requests", "progress"}:
|
||||||
@@ -377,18 +404,23 @@ async def task_context_page(
|
|||||||
where = " ORDER BY revision DESC LIMIT 1"
|
where = " ORDER BY revision DESC LIMIT 1"
|
||||||
stored = f"SELECT jsonb_build_object('section',%s::text,'data',document) AS snapshot FROM {relation} WHERE project_id=%s AND task_id=%s{where}"
|
stored = f"SELECT jsonb_build_object('section',%s::text,'data',document) AS snapshot FROM {relation} WHERE project_id=%s AND task_id=%s{where}"
|
||||||
elif section == "run":
|
elif section == "run":
|
||||||
stored = """SELECT jsonb_build_object('section','run','data',to_jsonb(r)-'project_id') AS snapshot
|
stored = """SELECT jsonb_build_object('section','run','data',to_jsonb(r)-'project_id'-'artifact_manifest') AS snapshot
|
||||||
FROM workbench.operational_task_runs r WHERE project_id=%s AND task_id=%s ORDER BY generation DESC LIMIT 1"""
|
FROM workbench.operational_task_runs r WHERE project_id=%s AND task_id=%s ORDER BY generation DESC LIMIT 1"""
|
||||||
params = (project_id, task_id)
|
params = (project_id, task_id)
|
||||||
elif section == "progress":
|
elif section == "progress":
|
||||||
stored = """SELECT jsonb_build_object('section','progress','data',p.document) AS snapshot
|
stored = """SELECT jsonb_build_object('section','progress','data',p.document) AS snapshot
|
||||||
FROM workbench.operational_task_progress p
|
FROM workbench.operational_task_progress p
|
||||||
JOIN workbench.operational_task_runs r ON r.project_id=p.project_id AND r.task_id=p.task_id AND r.run_id=p.run_id
|
JOIN workbench.operational_task_runs r ON r.project_id=p.project_id AND r.task_id=p.task_id AND r.run_id=p.run_id
|
||||||
JOIN workbench.operational_tasks t ON t.project_id=p.project_id AND t.task_id=p.task_id AND t.execution_generation=r.generation
|
JOIN workbench.operational_tasks t ON t.project_id=p.project_id AND t.task_id=p.task_id
|
||||||
WHERE p.project_id=%s AND p.task_id=%s"""
|
WHERE p.project_id=%s AND p.task_id=%s AND r.generation<=t.execution_generation
|
||||||
|
AND (t.execution_generation=r.generation
|
||||||
|
OR jsonb_typeof(p.document#>'{progress,diagram,edges}')='array')
|
||||||
|
ORDER BY (t.execution_generation=r.generation) DESC,r.generation DESC,p.revision DESC LIMIT 1"""
|
||||||
params = (project_id, task_id)
|
params = (project_id, task_id)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(422, "Unknown task context section")
|
raise HTTPException(422, "Unknown task context section")
|
||||||
|
if section in {"progress", "report"}:
|
||||||
|
stored, params = _reference_diagram_projection(stored, params, section)
|
||||||
return await _snapshot_page(conn, stored_sql=stored, stored_params=params,
|
return await _snapshot_page(conn, stored_sql=stored, stored_params=params,
|
||||||
data_path=data_path, limit=limit, offset=projection_offset, mcp_sources=[], max_bytes=8000)
|
data_path=data_path, limit=limit, offset=projection_offset, mcp_sources=[], max_bytes=8000)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"""Short-transaction storage for replay control and resumable batches."""
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from psycopg.types.json import Jsonb
|
||||||
|
|
||||||
|
|
||||||
|
class ScadaReplayRepository:
|
||||||
|
def __init__(self, connection, project_id):
|
||||||
|
self.connection, self.project_id = connection, str(project_id)
|
||||||
|
self.lock_key = f"scada-replay:{self.project_id}"
|
||||||
|
|
||||||
|
async def available(self) -> bool:
|
||||||
|
async with self.connection.transaction():
|
||||||
|
cur = await self.connection.execute(
|
||||||
|
"SELECT to_regclass('peripheral_sample.scada_replays') IS NOT NULL "
|
||||||
|
"AND to_regclass('peripheral_sample.scada_replay_batches') IS NOT NULL AS ready"
|
||||||
|
)
|
||||||
|
return (await cur.fetchone())["ready"]
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def control(self):
|
||||||
|
async with self.connection.transaction():
|
||||||
|
await self.connection.execute("SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", (self.lock_key,))
|
||||||
|
yield
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def processing(self):
|
||||||
|
# Session lock outlives short transactions, never an open SQL transaction
|
||||||
|
# across Timescale IO. A dead connection automatically releases ownership.
|
||||||
|
async with self.connection.transaction():
|
||||||
|
cur = await self.connection.execute("SELECT pg_try_advisory_lock(hashtextextended(%s,0)) AS owned", (self.lock_key,))
|
||||||
|
owned = (await cur.fetchone())["owned"]
|
||||||
|
try:
|
||||||
|
yield owned
|
||||||
|
finally:
|
||||||
|
if owned:
|
||||||
|
async with self.connection.transaction():
|
||||||
|
await self.connection.execute("SELECT pg_advisory_unlock(hashtextextended(%s,0))", (self.lock_key,))
|
||||||
|
|
||||||
|
async def get(self, replay_id) -> dict:
|
||||||
|
async with self.connection.transaction():
|
||||||
|
cur = await self.connection.execute(
|
||||||
|
"SELECT document FROM peripheral_sample.scada_replays WHERE project_id=%s AND replay_id=%s",
|
||||||
|
(self.project_id, str(replay_id)),
|
||||||
|
)
|
||||||
|
row = await cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "当前项目不存在此 SCADA 回放")
|
||||||
|
return row["document"]
|
||||||
|
|
||||||
|
async def current(self) -> dict | None:
|
||||||
|
async with self.connection.transaction():
|
||||||
|
cur = await self.connection.execute(
|
||||||
|
"SELECT document FROM peripheral_sample.scada_replays WHERE project_id=%s"
|
||||||
|
" ORDER BY created_at DESC LIMIT 1", (self.project_id,),
|
||||||
|
)
|
||||||
|
row = await cur.fetchone()
|
||||||
|
return row["document"] if row else None
|
||||||
|
|
||||||
|
async def by_request(self, request_id) -> dict | None:
|
||||||
|
async with self.connection.transaction():
|
||||||
|
cur = await self.connection.execute(
|
||||||
|
"SELECT document FROM peripheral_sample.scada_replays WHERE project_id=%s AND request_id=%s",
|
||||||
|
(self.project_id, str(request_id)),
|
||||||
|
)
|
||||||
|
row = await cur.fetchone()
|
||||||
|
return row["document"] if row else None
|
||||||
|
|
||||||
|
async def active_other(self, replay_id) -> bool:
|
||||||
|
async with self.connection.transaction():
|
||||||
|
cur = await self.connection.execute(
|
||||||
|
"SELECT 1 FROM peripheral_sample.scada_replays WHERE project_id=%s AND replay_id<>%s "
|
||||||
|
"AND status IN ('ready','paused') LIMIT 1",
|
||||||
|
(self.project_id, str(replay_id)),
|
||||||
|
)
|
||||||
|
return await cur.fetchone() is not None
|
||||||
|
|
||||||
|
async def insert(self, document: dict):
|
||||||
|
await self.connection.execute(
|
||||||
|
"INSERT INTO peripheral_sample.scada_replays(project_id,replay_id,request_id,status,document) "
|
||||||
|
"VALUES(%s,%s,%s,%s,%s)",
|
||||||
|
(self.project_id, document["replay_id"], document["config"]["request_id"], document["status"], Jsonb(document)),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def save(self, document: dict):
|
||||||
|
document["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
await self.connection.execute(
|
||||||
|
"UPDATE peripheral_sample.scada_replays SET status=%s,document=%s,updated_at=now() "
|
||||||
|
"WHERE project_id=%s AND replay_id=%s",
|
||||||
|
(document["status"], Jsonb(document), self.project_id, document["replay_id"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def pending_batch(self, replay_id) -> dict | None:
|
||||||
|
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 sequence>0 AND stage<>'detected' ORDER BY sequence LIMIT 1",
|
||||||
|
(self.project_id, str(replay_id)),
|
||||||
|
)
|
||||||
|
row = await cur.fetchone()
|
||||||
|
return row["document"] if row else None
|
||||||
|
|
||||||
|
async def save_batch(self, replay_id, batch: dict):
|
||||||
|
await self.connection.execute(
|
||||||
|
"INSERT INTO peripheral_sample.scada_replay_batches(project_id,replay_id,sequence,stage,document) "
|
||||||
|
"VALUES(%s,%s,%s,%s,%s) ON CONFLICT(project_id,replay_id,sequence) "
|
||||||
|
"DO UPDATE SET stage=excluded.stage,document=excluded.document",
|
||||||
|
(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(
|
||||||
|
"SELECT e.value AS event FROM peripheral_sample.scada_replay_batches b "
|
||||||
|
"CROSS JOIN LATERAL jsonb_array_elements(COALESCE(b.document->'events','[]'::jsonb)) e "
|
||||||
|
"WHERE b.project_id=%s AND b.replay_id=%s ORDER BY b.sequence DESC LIMIT %s OFFSET %s",
|
||||||
|
(self.project_id, str(replay_id), limit, offset),
|
||||||
|
)
|
||||||
|
return [r["event"] for r in await cur.fetchall()]
|
||||||
|
|
||||||
|
async def monitoring_outputs(
|
||||||
|
self,
|
||||||
|
replay_id,
|
||||||
|
device_ids: list[str],
|
||||||
|
start: datetime,
|
||||||
|
end: datetime,
|
||||||
|
before_sequence: int,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Read only committed replay outputs available before the current batch."""
|
||||||
|
if not device_ids:
|
||||||
|
return []
|
||||||
|
async with self.connection.transaction():
|
||||||
|
cur = await self.connection.execute(
|
||||||
|
"SELECT 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='detected' AND b.sequence<%s "
|
||||||
|
"AND r.value->>'device_id'=ANY(%s) "
|
||||||
|
"AND (r.value->>'time')::timestamptz>=%s AND (r.value->>'time')::timestamptz<=%s "
|
||||||
|
"ORDER BY (r.value->>'time')::timestamptz,r.value->>'device_id'",
|
||||||
|
(self.project_id, str(replay_id), before_sequence, device_ids, start, end),
|
||||||
|
)
|
||||||
|
return [row["reading"] for row in await cur.fetchall()]
|
||||||
|
|
||||||
|
async def detected_outputs(
|
||||||
|
self,
|
||||||
|
replay_id,
|
||||||
|
device_ids: list[str],
|
||||||
|
start: datetime,
|
||||||
|
end: datetime,
|
||||||
|
before_sequence: int,
|
||||||
|
*,
|
||||||
|
limit: int | None = None,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Read immutable detector outputs available to a bounded replay investigation."""
|
||||||
|
statement = (
|
||||||
|
"SELECT 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.sequence<=%s AND b.stage='detected' "
|
||||||
|
"AND r.value->>'device_id'=ANY(%s) "
|
||||||
|
"AND (r.value->>'time')::timestamptz>=%s AND (r.value->>'time')::timestamptz<=%s "
|
||||||
|
"ORDER BY (r.value->>'time')::timestamptz,r.value->>'device_id'"
|
||||||
|
)
|
||||||
|
params: tuple = (
|
||||||
|
self.project_id, str(replay_id), before_sequence, device_ids, start, end,
|
||||||
|
)
|
||||||
|
if limit is not None:
|
||||||
|
statement += " LIMIT %s OFFSET %s"
|
||||||
|
params += (limit, offset)
|
||||||
|
async with self.connection.transaction():
|
||||||
|
cur = await self.connection.execute(statement, params)
|
||||||
|
return [row["reading"] for row in await cur.fetchall()]
|
||||||
|
|
||||||
|
async def latest_outputs(self, replay_id, through: datetime, limit: int = 500) -> list[dict]:
|
||||||
|
"""Return the latest committed cleaned output for each replay device."""
|
||||||
|
async with self.connection.transaction():
|
||||||
|
cur = await self.connection.execute(
|
||||||
|
"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.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),
|
||||||
|
)
|
||||||
|
return [row["reading"] for row in await cur.fetchall()]
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
from typing import List, Any
|
from typing import List, Any
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from psycopg import AsyncConnection, Connection, sql
|
from psycopg import AsyncConnection, Connection, sql
|
||||||
from psycopg.rows import dict_row
|
from psycopg.rows import dict_row
|
||||||
@@ -9,6 +9,91 @@ class ScadaRepository:
|
|||||||
VALUE_FIELDS = frozenset({"monitored_value", "cleaned_value"})
|
VALUE_FIELDS = frozenset({"monitored_value", "cleaned_value"})
|
||||||
RESULT_COLUMNS = "time, device_id, 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,
|
||||||
|
device_ids: list[str],
|
||||||
|
before: datetime,
|
||||||
|
per_device_limit: int,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Return the latest raw values before a replay starts for detector warmup."""
|
||||||
|
async with conn.cursor() as cur:
|
||||||
|
await cur.execute(
|
||||||
|
"SELECT d.device_id,h.monitored_value FROM unnest(%s::text[]) d(device_id) "
|
||||||
|
"CROSS JOIN LATERAL (SELECT monitored_value,time FROM scada.measurements "
|
||||||
|
"WHERE device_id=d.device_id AND time<%s ORDER BY time DESC LIMIT %s) h "
|
||||||
|
"ORDER BY d.device_id,h.time",
|
||||||
|
(device_ids, before, per_device_limit),
|
||||||
|
)
|
||||||
|
return [dict(row) for row in await cur.fetchall()]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_raw_page(
|
||||||
|
conn: AsyncConnection, device_ids: list[str], start: datetime,
|
||||||
|
end: datetime, through: datetime, limit: int,
|
||||||
|
after: tuple[datetime, str] | None = None, *, frame: bool = False,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Read raw only, with a stable composite key and a bounded frame/page."""
|
||||||
|
predicate = "device_id=ANY(%s) AND time>=%s AND time<%s AND time<=%s"
|
||||||
|
params: tuple = (device_ids, start, end, through)
|
||||||
|
if after is not None:
|
||||||
|
predicate += " AND (time,device_id)>(%s,%s)"
|
||||||
|
params += after
|
||||||
|
if frame:
|
||||||
|
statement = (
|
||||||
|
"WITH next_time AS (SELECT min(time) AS time FROM scada.measurements WHERE "
|
||||||
|
+ predicate + ") SELECT m.time,m.device_id,m.monitored_value,"
|
||||||
|
"md5(COALESCE(m.monitored_value::text,'NULL')) AS source_hash "
|
||||||
|
"FROM scada.measurements m WHERE m.device_id=ANY(%s) "
|
||||||
|
"AND m.time=(SELECT time FROM next_time) ORDER BY m.time,m.device_id LIMIT %s"
|
||||||
|
)
|
||||||
|
params += (device_ids, limit)
|
||||||
|
else:
|
||||||
|
statement = (
|
||||||
|
"SELECT time,device_id,monitored_value,"
|
||||||
|
"md5(COALESCE(monitored_value::text,'NULL')) AS source_hash "
|
||||||
|
"FROM scada.measurements WHERE " + predicate
|
||||||
|
+ " ORDER BY time,device_id LIMIT %s"
|
||||||
|
)
|
||||||
|
params += (limit,)
|
||||||
|
async with conn.cursor() as cur:
|
||||||
|
await cur.execute(statement, params)
|
||||||
|
return [dict(row) for row in await cur.fetchall()]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def apply_replay_cleaning(conn: AsyncConnection, rows: list[dict]) -> None:
|
||||||
|
"""Guard every raw version; update corrected values without inserting raw."""
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
async with conn.transaction():
|
||||||
|
async with conn.cursor() as cur:
|
||||||
|
await cur.execute(
|
||||||
|
"SELECT m.time,m.device_id,md5(COALESCE(m.monitored_value::text,'NULL')) AS source_hash "
|
||||||
|
"FROM scada.measurements m JOIN unnest(%s::timestamptz[],%s::text[]) "
|
||||||
|
"b(time,device_id) USING(time,device_id) FOR UPDATE OF m",
|
||||||
|
([r["time"] for r in rows], [r["device_id"] for r in rows]),
|
||||||
|
)
|
||||||
|
existing = {(r["time"].isoformat(), r["device_id"]): r["source_hash"]
|
||||||
|
for r in await cur.fetchall()}
|
||||||
|
if len(existing) != len(rows) or any(
|
||||||
|
existing.get((datetime.fromisoformat(r["time"]).isoformat(), r["device_id"]))
|
||||||
|
!= r["source_hash"] for r in rows
|
||||||
|
):
|
||||||
|
raise ValueError("SOURCE_CHANGED: SCADA 原始值在处理期间发生变化")
|
||||||
|
await cur.execute(
|
||||||
|
"UPDATE scada.measurements m SET cleaned_value=b.value "
|
||||||
|
"FROM unnest(%s::timestamptz[],%s::text[],%s::double precision[]) "
|
||||||
|
"b(time,device_id,value) WHERE m.time=b.time AND m.device_id=b.device_id "
|
||||||
|
"AND m.cleaned_value IS DISTINCT FROM b.value",
|
||||||
|
([r["time"] for r in rows], [r["device_id"] for r in rows],
|
||||||
|
[r["cleaned_value"] for r in rows]),
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def insert_scada_batch(conn: AsyncConnection, data: List[dict]):
|
async def insert_scada_batch(conn: AsyncConnection, data: List[dict]):
|
||||||
if not data:
|
if not data:
|
||||||
@@ -46,6 +131,32 @@ class ScadaRepository:
|
|||||||
)
|
)
|
||||||
return await cur.fetchall()
|
return await cur.fetchall()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_recovery_scada_readings(
|
||||||
|
conn: AsyncConnection, device_ids: list[str], incident_at: datetime,
|
||||||
|
returned_at: datetime, through: datetime, *, baseline_limit: int = 100_001,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Read the causal reference and exactly the first stored frame after T."""
|
||||||
|
if not device_ids or through <= returned_at:
|
||||||
|
return []
|
||||||
|
async with conn.cursor() as cur:
|
||||||
|
await cur.execute(
|
||||||
|
"WITH next_frame AS (SELECT min(time) AS time FROM scada.measurements "
|
||||||
|
"WHERE device_id=ANY(%s) AND time>%s AND time<=%s), "
|
||||||
|
"reference AS (SELECT m.time,m.device_id,m.monitored_value,m.cleaned_value "
|
||||||
|
"FROM scada.measurements m CROSS JOIN next_frame f "
|
||||||
|
"WHERE m.device_id=ANY(%s) AND m.time>=%s AND m.time<%s "
|
||||||
|
"AND mod(extract(epoch FROM m.time)::bigint,86400)="
|
||||||
|
"mod(extract(epoch FROM f.time)::bigint,86400) "
|
||||||
|
"ORDER BY m.time,m.device_id LIMIT %s) "
|
||||||
|
"SELECT time,device_id,monitored_value,cleaned_value FROM reference UNION ALL "
|
||||||
|
"SELECT m.time,m.device_id,m.monitored_value,m.cleaned_value FROM scada.measurements m "
|
||||||
|
"JOIN next_frame f ON m.time=f.time WHERE m.device_id=ANY(%s) ORDER BY time,device_id",
|
||||||
|
(device_ids, returned_at, through, device_ids, incident_at - timedelta(days=15),
|
||||||
|
incident_at, baseline_limit, device_ids),
|
||||||
|
)
|
||||||
|
return [dict(row) for row in await cur.fetchall()]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_latest_scada_readings(
|
async def get_latest_scada_readings(
|
||||||
conn: AsyncConnection,
|
conn: AsyncConnection,
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ class SampleWorkOrderReceipt(BaseModel):
|
|||||||
work_order_id: UUID
|
work_order_id: UUID
|
||||||
project_id: UUID
|
project_id: UUID
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
origin: Literal["synthetic"]
|
origin: Literal["system_data"]
|
||||||
status: Literal[
|
status: Literal[
|
||||||
"draft", "open", "in_progress", "awaiting_reply", "completed", "cancelled"
|
"draft", "open", "in_progress", "awaiting_reply", "completed", "cancelled"
|
||||||
]
|
]
|
||||||
@@ -194,8 +194,10 @@ class HttpOperationalActionDispatcher:
|
|||||||
raise ValueError("invalid external project or timestamp")
|
raise ValueError("invalid external project or timestamp")
|
||||||
receipt = ActionReceipt(
|
receipt = ActionReceipt(
|
||||||
adapter="peripheral_sample", receipt_id=str(order.work_order_id),
|
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,
|
submitted_at=order.created_at, completed_at=order.created_at,
|
||||||
external_status=order.status, detail="合成外围工单已创建,现场工作进度由外围系统继续同步",
|
external_status=order.status,
|
||||||
|
detail="外围工单已创建,现场工作进度由外围系统继续同步",
|
||||||
)
|
)
|
||||||
return ActionDispatchOutcome("succeeded", receipt.detail, receipt)
|
return ActionDispatchOutcome("succeeded", receipt.detail, receipt)
|
||||||
result = CommandGatewayReceipt.model_validate(value)
|
result = CommandGatewayReceipt.model_validate(value)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
@@ -33,6 +34,23 @@ class TaskDataError(Exception):
|
|||||||
self.detail = detail
|
self.detail = detail
|
||||||
|
|
||||||
|
|
||||||
|
def peripheral_records_origin(items: object) -> Literal["system_data", "synthetic"]:
|
||||||
|
"""Classify a record collection; mixed collections retain per-item provenance."""
|
||||||
|
if not isinstance(items, list) or any(
|
||||||
|
not isinstance(item, dict) or item.get("origin") not in {"system_data", "synthetic"}
|
||||||
|
for item in items
|
||||||
|
):
|
||||||
|
raise TaskDataError(502, "外围记录缺少明确来源,不能作为已核实证据")
|
||||||
|
return "synthetic" if items and all(item["origin"] == "synthetic" for item in items) else "system_data"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_peripheral_work_orders(items: object) -> list[dict[str, object]]:
|
||||||
|
"""Expose peripheral work orders only as project-owned system data."""
|
||||||
|
if not isinstance(items, list) or any(not isinstance(item, dict) for item in items):
|
||||||
|
raise TaskDataError(502, "外围工单格式无效,不能作为已核实证据")
|
||||||
|
return [{**item, "origin": "system_data"} for item in items]
|
||||||
|
|
||||||
|
|
||||||
def _matches_project(value: object, project_id: str) -> bool:
|
def _matches_project(value: object, project_id: str) -> bool:
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
return all(_matches_project(item, project_id) for item in value)
|
return all(_matches_project(item, project_id) for item in value)
|
||||||
@@ -147,7 +165,14 @@ async def read_task_data(
|
|||||||
data = _select_demo_observations(data, query, str(context.project_id))
|
data = _select_demo_observations(data, query, str(context.project_id))
|
||||||
elif not _matches_project(data, str(context.project_id)):
|
elif not _matches_project(data, str(context.project_id)):
|
||||||
raise TaskDataError(502, "数据源返回了其他项目的数据")
|
raise TaskDataError(502, "数据源返回了其他项目的数据")
|
||||||
origin = "synthetic" if peripheral else "system_data"
|
origin = "system_data"
|
||||||
|
if query.source == "peripheral_work_orders":
|
||||||
|
items = data.get("items") if isinstance(data, dict) else None
|
||||||
|
data = {**data, "items": normalize_peripheral_work_orders(items)}
|
||||||
|
elif query.source == "peripheral_test_data":
|
||||||
|
items = data.get("items") if isinstance(data, dict) else None
|
||||||
|
origin = peripheral_records_origin(items)
|
||||||
|
data = {**data, "provenance_scope": "per_record", "source_origins": sorted({item["origin"] for item in items})}
|
||||||
historical_replay = False
|
historical_replay = False
|
||||||
if query.source in {"peripheral_points", "peripheral_observations"}:
|
if query.source in {"peripheral_points", "peripheral_observations"}:
|
||||||
source_origin = data.get("origin") if isinstance(data, dict) else None
|
source_origin = data.get("origin") if isinstance(data, dict) else None
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from app.infra.db.metadb.database import close_metadata_engine
|
|||||||
from app.infra.db.timescaledb.sync_pool import close_all_timescale_pools
|
from app.infra.db.timescaledb.sync_pool import close_all_timescale_pools
|
||||||
from app.native.wndb.core.connection import close_all_project_pools
|
from app.native.wndb.core.connection import close_all_project_pools
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from samples.peripheral.api import create_app as create_peripheral_app
|
||||||
|
|
||||||
# 导入审计中间件
|
# 导入审计中间件
|
||||||
from app.infra.audit.middleware import AuditMiddleware
|
from app.infra.audit.middleware import AuditMiddleware
|
||||||
@@ -80,6 +81,7 @@ install_problem_details_handlers(app)
|
|||||||
from app.api.v1.endpoints.mcp_queries import router as mcp_context_router
|
from app.api.v1.endpoints.mcp_queries import router as mcp_context_router
|
||||||
app.state.mcp_runtime = mcp_runtime
|
app.state.mcp_runtime = mcp_runtime
|
||||||
app.include_router(mcp_context_router, prefix="/api/v1")
|
app.include_router(mcp_context_router, prefix="/api/v1")
|
||||||
|
app.mount("/peripheral", create_peripheral_app(), name="peripheral")
|
||||||
# Legacy routers without version prefix
|
# Legacy routers without version prefix
|
||||||
# app.include_router(api_router)
|
# app.include_router(api_router)
|
||||||
|
|
||||||
|
|||||||
+84
-21
@@ -4,23 +4,53 @@ from datetime import datetime, timedelta
|
|||||||
from typing import Annotated, Literal
|
from typing import Annotated, Literal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import Field, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
from app.mcp.models import Id, StrictInput
|
from app.mcp.models import Id, StrictInput
|
||||||
|
|
||||||
|
|
||||||
MAX_CALCULATION_TIMEOUT_SECONDS = 86400
|
MAX_REQUEST_TIMEOUT_SECONDS = 240
|
||||||
|
|
||||||
|
|
||||||
class CalculationInput(StrictInput):
|
class CalculationInput(StrictInput):
|
||||||
timeout_seconds: int = Field(
|
timeout_seconds: int = Field(
|
||||||
default=120, ge=1, le=MAX_CALCULATION_TIMEOUT_SECONDS, strict=True,
|
default=120, ge=1, le=MAX_REQUEST_TIMEOUT_SECONDS, strict=True,
|
||||||
description="本次计算等待预算,秒;由Agent按模型规模和任务时长选择,受工具目录中的服务端上限约束;与模拟duration不同,超时不保证已启动的计算停止或回滚",
|
description="本次HTTP请求响应等待预算,秒,默认120秒;后台执行时限由作业系统管理,受工具目录中的请求上限约束;后台任务通过状态和结果工具查询",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NodeCoverageGroup(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
group_id: Id = Field(max_length=80)
|
||||||
|
title: str = Field(min_length=1, max_length=200)
|
||||||
|
node_ids: list[Id] = Field(min_length=1, max_length=10000)
|
||||||
|
|
||||||
|
|
||||||
|
class NodeCoverageInput(CalculationInput):
|
||||||
|
groups: list[NodeCoverageGroup] = Field(min_length=1, max_length=8,
|
||||||
|
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="展示缓冲距离,米;用于几何展示")
|
||||||
|
split_by_connectivity: bool = Field(default=False, strict=True,
|
||||||
|
description="需要按模型连接分组时设true;地理分布比较默认false,供水关系按拓扑核验")
|
||||||
|
excluded_link_ids: list[Id] = Field(default_factory=list, max_length=200,
|
||||||
|
description="连通分组时额外视为断开的管段/阀门,例如已有隔离建议中的关阀ID;仅影响绘图分组")
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def bounded_groups(self):
|
||||||
|
if len({group.group_id for group in self.groups}) != len(self.groups):
|
||||||
|
raise ValueError("coverage group IDs must be distinct")
|
||||||
|
if len({node for group in self.groups for node in group.node_ids}) > 10000:
|
||||||
|
raise ValueError("coverage supports at most 10000 distinct nodes")
|
||||||
|
if self.excluded_link_ids and not self.split_by_connectivity:
|
||||||
|
raise ValueError("excluded_link_ids requires split_by_connectivity")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class BurstDetectionInput(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个完整传感器")
|
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)
|
sampling_interval_minutes: int = Field(default=15, ge=1, le=60)
|
||||||
|
|
||||||
@@ -37,14 +67,14 @@ class BurstDetectionInput(CalculationInput):
|
|||||||
|
|
||||||
class BurstLocationInput(CalculationInput):
|
class BurstLocationInput(CalculationInput):
|
||||||
data_source: Literal["monitoring", "simulation"] = "monitoring"
|
data_source: Literal["monitoring", "simulation"] = "monitoring"
|
||||||
simulation_run_id: UUID | None = Field(default=None, description="已有分析运行UUID,不是方案名称;simulation模式只读取已有异常结果及模型基线")
|
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;不是已知爆管管段,不能随意猜测")
|
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,不是设备查询编号;省略时自动读取当前项目SCADA配置中的压力测点,不需要猜测节点ID")
|
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,不是设备查询编号")
|
flow_scada_ids: list[Id] | None = Field(default=None, min_length=1, max_length=20, description="流量监测设备关联的模型要素ID,由设备映射取得")
|
||||||
scada_burst_start: datetime
|
scada_burst_start: datetime
|
||||||
scada_burst_end: datetime
|
scada_burst_end: datetime
|
||||||
scada_normal_start: datetime | None = Field(default=None, description="monitoring模式正常对照窗开始,须与结束时间成对提供;均省略时用爆管窗前一天相同时段,不代表该时段已确认正常")
|
scada_normal_start: datetime | None = Field(default=None, description="monitoring模式正常对照窗开始,须与结束时间成对提供;均省略时用爆管窗前一天相同时段,使用前须核对正常对照有效性")
|
||||||
scada_normal_end: datetime | None = Field(default=None, description="monitoring模式正常对照窗结束,须与开始时间成对提供;simulation模式不填写,使用同时间窗模型基线")
|
scada_normal_end: datetime | None = Field(default=None, description="monitoring模式正常对照窗结束,须与开始时间成对提供;simulation模式省略此项,使用同时间窗模型基线")
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def valid_windows(self):
|
def valid_windows(self):
|
||||||
@@ -67,16 +97,34 @@ class BurstLocationInput(CalculationInput):
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class ContaminantSourceTraceInput(CalculationInput):
|
||||||
|
candidate_node_ids: list[Id] = Field(min_length=1, max_length=20,
|
||||||
|
description="根据事件位置、附近资产或水质设备映射取得的候选模型节点;Server核验节点存在后稳定返回一个候选。当前为临时定位接口,尚未执行污染反演")
|
||||||
|
start: datetime = Field(description="带时区的事件核查窗口开始")
|
||||||
|
end: datetime = Field(description="带时区的事件核查窗口结束")
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def valid_window(self):
|
||||||
|
if self.start.tzinfo is None or self.end.tzinfo is None:
|
||||||
|
raise ValueError("timestamps require explicit timezone")
|
||||||
|
if not timedelta(0) < self.end - self.start <= timedelta(days=1):
|
||||||
|
raise ValueError("window must be positive and at most 24 hours")
|
||||||
|
if len(set(self.candidate_node_ids)) != len(self.candidate_node_ids):
|
||||||
|
raise ValueError("candidate_node_ids must be distinct")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class ValveIsolationInput(CalculationInput):
|
class ValveIsolationInput(CalculationInput):
|
||||||
accident_element: list[Id] = Field(min_length=1, max_length=20, description="经核实或明确作为候选的事故管段/节点ID")
|
accident_element: list[Id] = Field(min_length=1, max_length=20, description="经核实或明确作为候选的事故管段/节点ID")
|
||||||
disabled_valves: list[Id] = Field(default_factory=list, max_length=200, description="已知无法关闭的阀门;未提供不代表现场确认全部可用")
|
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="已知无法关闭的阀门;现场可操作性按执行前核验记录确认")
|
||||||
|
|
||||||
|
|
||||||
class LeakageIdentificationInput(CalculationInput):
|
class LeakageIdentificationInput(CalculationInput):
|
||||||
scada_start: datetime
|
scada_start: datetime
|
||||||
scada_end: datetime
|
scada_end: datetime
|
||||||
sensor_nodes: list[Id] = Field(min_length=1, max_length=20)
|
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时间窗及模型时钟对齐")
|
start_time: float = Field(ge=0, lt=24, allow_inf_nan=False, description="模型起算小时;需与SCADA时间窗及模型时钟对齐")
|
||||||
timestep: int = Field(default=15, ge=5, le=60, description="计算步长,分钟;需与观测采样对应")
|
timestep: int = Field(default=15, ge=5, le=60, description="计算步长,分钟;需与观测采样对应")
|
||||||
dma_count: int = Field(ge=1, le=20)
|
dma_count: int = Field(ge=1, le=20)
|
||||||
@@ -98,7 +146,7 @@ class LeakageIdentificationInput(CalculationInput):
|
|||||||
class ScenarioInput(CalculationInput):
|
class ScenarioInput(CalculationInput):
|
||||||
start_time: datetime
|
start_time: datetime
|
||||||
duration: int = Field(default=900, ge=60, le=86400, description="模拟时长,秒;首次优先短时间窗")
|
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)
|
output_link_ids: list[Id] = Field(default_factory=list, max_length=20)
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
@@ -109,19 +157,34 @@ class ScenarioInput(CalculationInput):
|
|||||||
|
|
||||||
|
|
||||||
class ContaminantSimulationInput(ScenarioInput):
|
class ContaminantSimulationInput(ScenarioInput):
|
||||||
source: Id = Field(description="假设污染源节点;这是正向传播模拟,不是自动反演污染源")
|
source: Id = Field(description="假设污染源节点;这是正向传播模拟,结果按给定源解释")
|
||||||
concentration: float = Field(gt=0, allow_inf_nan=False, description="假设源浓度,mg/L")
|
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;省略时采用持续恒定源")
|
||||||
|
include_impact_coverage: bool = Field(default=False, strict=True,
|
||||||
|
description="估计处置前的污染传播范围;在筛选展示节点前汇总全网节点整个模拟时段的浓度,保存影响节点、首个超过阈值的报告时刻、峰值及地图覆盖")
|
||||||
|
impact_threshold_mg_l: float = Field(default=0, ge=0, allow_inf_nan=False,
|
||||||
|
description="模型影响范围的浓度阈值,mg/L,按严格大于判定;默认0表示模型中出现正浓度,仅为情景展示条件,不是饮用水安全限值。沿用模型初始水质与反应条件")
|
||||||
|
|
||||||
|
|
||||||
class ValveClosureInput(ScenarioInput):
|
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):
|
class FlushingInput(ScenarioInput):
|
||||||
drainage_node_id: Id
|
drainage_node_id: Id = Field(description="已核实的冲洗排放节点;清洁水来源、流向及污染水处置须在方案中说明")
|
||||||
flushing_flow_m3h: float = Field(gt=0, allow_inf_nan=False, description="明确的冲洗流量,m³/h;不使用无依据的自动排放量")
|
flushing_flow_m3h: float = Field(gt=0, allow_inf_nan=False, description="明确的冲洗流量,m³/h;提供流量来源与估算依据")
|
||||||
closed_valves: list[Id] = Field(default_factory=list, max_length=20)
|
closed_valves: list[Id] = Field(default_factory=list, max_length=20,
|
||||||
|
description="本次冲洗情景中保持关闭的完整阀门清单,用于隔离用户或约束流向")
|
||||||
|
opened_valves: list[Id] = Field(default_factory=list, max_length=20,
|
||||||
|
description="本次冲洗情景中明确打开的阀门,如清洁水进水和导流阀;不表示现场已开阀或恢复用户供水。未列阀门沿用当前模型状态,不继承上次独立模拟")
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def distinct_valve_states(self):
|
||||||
|
if any(len(values) != len(set(values)) for values in (self.closed_valves, self.opened_valves)):
|
||||||
|
raise ValueError("flushing valve lists must contain distinct IDs")
|
||||||
|
if set(self.closed_valves) & set(self.opened_valves):
|
||||||
|
raise ValueError("a flushing valve cannot be both opened and closed")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
NonnegativeValue = Annotated[float, Field(ge=0, allow_inf_nan=False)]
|
NonnegativeValue = Annotated[float, Field(ge=0, allow_inf_nan=False)]
|
||||||
@@ -129,7 +192,7 @@ PumpSeries = Annotated[list[NonnegativeValue], Field(min_length=1, max_length=96
|
|||||||
|
|
||||||
|
|
||||||
class PressureRegulationInput(ScenarioInput):
|
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="水箱初始水位,须在模型允许范围内")
|
tank_init_level: dict[Id, Annotated[float, Field(gt=0, allow_inf_nan=False)]] = Field(default_factory=dict, max_length=20, description="水箱初始水位,须在模型允许范围内")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,10 @@ def _calculate(name: str, parameters: dict[str, Any], network: str, username: st
|
|||||||
from app.services.valve_isolation import analyze_valve_isolation
|
from app.services.valve_isolation import analyze_valve_isolation
|
||||||
|
|
||||||
return analyze_valve_isolation(network=network, **parameters)
|
return analyze_valve_isolation(network=network, **parameters)
|
||||||
|
if name == "generate_node_coverage":
|
||||||
|
from app.services.node_coverage import generate_node_coverage
|
||||||
|
|
||||||
|
return generate_node_coverage(network=network, **parameters)
|
||||||
raise ValueError("Calculation is not registered")
|
raise ValueError("Calculation is not registered")
|
||||||
finally:
|
finally:
|
||||||
_slots.release()
|
_slots.release()
|
||||||
@@ -94,3 +98,55 @@ async def bounded_calculation(function, *args, **kwargs):
|
|||||||
finally:
|
finally:
|
||||||
_slots.release()
|
_slots.release()
|
||||||
return await asyncio.to_thread(run)
|
return await asyncio.to_thread(run)
|
||||||
|
|
||||||
|
|
||||||
|
async def calculate_replay_burst_location(
|
||||||
|
parameters: dict[str, Any], *, network: str, username: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Run location with Server-built frozen replay series and no live SCADA query."""
|
||||||
|
from app.services.burst_location import run_burst_location_by_network
|
||||||
|
|
||||||
|
return await bounded_calculation(
|
||||||
|
run_burst_location_by_network,
|
||||||
|
network=network,
|
||||||
|
username=username,
|
||||||
|
scheme_name=None,
|
||||||
|
final_candidates_csv_path=None,
|
||||||
|
**parameters,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def calculate_replay_hydraulic_scenario(name: str, args: BaseModel, *, network: str, username: str) -> dict[str, Any]:
|
||||||
|
"""Evaluate model-based hydraulic alternatives without live SCADA substitution."""
|
||||||
|
return await bounded_calculation(
|
||||||
|
calculate_scenario, name,
|
||||||
|
args.model_dump(exclude={"context_handle", "timeout_seconds"}, exclude_none=True),
|
||||||
|
network=network, username=username, model_only=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def calculate_replay_burst_simulation(args: BaseModel, *, network: str, username: str) -> dict[str, Any]:
|
||||||
|
"""An independent current-model scenario, with no live SCADA substitution."""
|
||||||
|
return await bounded_calculation(
|
||||||
|
calculate_scenario, "run_burst_simulation",
|
||||||
|
args.model_dump(exclude={"context_handle", "timeout_seconds"}, exclude_none=True),
|
||||||
|
network=network, username=username, model_only=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def calculate_replay_contaminant_simulation(args: BaseModel, *, network: str, username: str) -> dict[str, Any]:
|
||||||
|
"""Estimate propagation in a current-model scenario without live SCADA substitution."""
|
||||||
|
return await bounded_calculation(
|
||||||
|
calculate_scenario, "run_contaminant_simulation",
|
||||||
|
args.model_dump(exclude={"context_handle", "timeout_seconds"}, exclude_none=True),
|
||||||
|
network=network, username=username, model_only=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def calculate_replay_flushing(args: BaseModel, *, network: str, username: str) -> dict[str, Any]:
|
||||||
|
"""Evaluate a model-only flushing plan without live SCADA or field execution."""
|
||||||
|
return await bounded_calculation(
|
||||||
|
calculate_scenario, "analyze_flushing",
|
||||||
|
args.model_dump(exclude={"context_handle", "timeout_seconds"}, exclude_none=True),
|
||||||
|
network=network, username=username, model_only=True,
|
||||||
|
)
|
||||||
|
|||||||
+43
-7
@@ -32,6 +32,8 @@ from app.mcp.catalog import TOOLS
|
|||||||
from app.mcp.config import McpSettings
|
from app.mcp.config import McpSettings
|
||||||
from app.mcp.contexts import resolve_context, verify_task
|
from app.mcp.contexts import resolve_context, verify_task
|
||||||
from app.mcp.models import QueryResult
|
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.errors import CalculationCapacityExceeded
|
||||||
from app.mcp.scope import project_scope
|
from app.mcp.scope import project_scope
|
||||||
from app.mcp.query_service import execute_query, task_service, authorize_query
|
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):
|
def tool_error(code, message):
|
||||||
|
message = display_time_text(message)
|
||||||
return types.CallToolResult(
|
return types.CallToolResult(
|
||||||
isError=True,
|
isError=True,
|
||||||
content=[types.TextContent(type="text", text=message)],
|
content=[types.TextContent(type="text", text=message)],
|
||||||
@@ -203,7 +206,11 @@ class McpQueryApplication:
|
|||||||
return None
|
return None
|
||||||
schema = tool.model.model_json_schema()
|
schema = tool.model.model_json_schema()
|
||||||
if tool.computation or tool.mutating:
|
if tool.computation or tool.mutating:
|
||||||
schema["properties"]["timeout_seconds"]["maximum"] = self.settings.max_calculation_timeout_seconds
|
schema["properties"]["timeout_seconds"]["maximum"] = self.settings.max_request_timeout_seconds
|
||||||
|
schema["properties"]["timeout_seconds"]["default"] = min(
|
||||||
|
self.settings.query_timeout_seconds,
|
||||||
|
self.settings.max_request_timeout_seconds,
|
||||||
|
)
|
||||||
return schema
|
return schema
|
||||||
|
|
||||||
async def list_tools(self, ctx, params):
|
async def list_tools(self, ctx, params):
|
||||||
@@ -213,6 +220,10 @@ class McpQueryApplication:
|
|||||||
for name in sorted(TOOLS):
|
for name in sorted(TOOLS):
|
||||||
tool = TOOLS[name].protocol_tool()
|
tool = TOOLS[name].protocol_tool()
|
||||||
tool.input_schema.update(self.tool_input_schema(name))
|
tool.input_schema.update(self.tool_input_schema(name))
|
||||||
|
tool.meta = {**(tool.meta or {}), "io.tjwater/request-timeout": {
|
||||||
|
"defaultSeconds": self.settings.query_timeout_seconds,
|
||||||
|
"maxSeconds": self.settings.max_request_timeout_seconds,
|
||||||
|
}}
|
||||||
tools.append(tool)
|
tools.append(tool)
|
||||||
return types.ListToolsResult(
|
return types.ListToolsResult(
|
||||||
tools=tools,
|
tools=tools,
|
||||||
@@ -291,11 +302,13 @@ class McpQueryApplication:
|
|||||||
return tool_error(
|
return tool_error(
|
||||||
"INVALID_QUERY", "Query parameters do not match the tool schema"
|
"INVALID_QUERY", "Query parameters do not match the tool schema"
|
||||||
)
|
)
|
||||||
|
if (tool.computation or tool.mutating) and "timeout_seconds" not in args.model_fields_set:
|
||||||
|
args.timeout_seconds = min(self.settings.query_timeout_seconds, self.settings.max_request_timeout_seconds)
|
||||||
timeout_seconds = (
|
timeout_seconds = (
|
||||||
args.timeout_seconds if tool.computation or tool.mutating else self.settings.query_timeout_seconds
|
args.timeout_seconds if tool.computation or tool.mutating else self.settings.query_timeout_seconds
|
||||||
)
|
)
|
||||||
if (tool.computation or tool.mutating) and timeout_seconds > self.settings.max_calculation_timeout_seconds:
|
if (tool.computation or tool.mutating) and timeout_seconds > self.settings.max_request_timeout_seconds:
|
||||||
return tool_error("INVALID_QUERY", "Calculation timeout exceeds the server limit")
|
return tool_error("INVALID_QUERY", "HTTP request timeout exceeds the server limit")
|
||||||
request = ctx.request
|
request = ctx.request
|
||||||
project = request.headers.get("x-project-id", "") if request else ""
|
project = request.headers.get("x-project-id", "") if request else ""
|
||||||
meta = ctx.meta or {}
|
meta = ctx.meta or {}
|
||||||
@@ -346,6 +359,14 @@ class McpQueryApplication:
|
|||||||
409, "Call identifier has different query parameters"
|
409, "Call identifier has different query parameters"
|
||||||
)
|
)
|
||||||
result = QueryResult.model_validate(previous["result"])
|
result = QueryResult.model_validate(previous["result"])
|
||||||
|
else:
|
||||||
|
from app.mcp.calculation_jobs import should_queue, submit_calculation
|
||||||
|
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,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
result = (
|
result = (
|
||||||
await self.executor(
|
await self.executor(
|
||||||
@@ -360,7 +381,8 @@ class McpQueryApplication:
|
|||||||
"RESULT_TOO_LARGE",
|
"RESULT_TOO_LARGE",
|
||||||
"Narrow the query or request a smaller page",
|
"Narrow the query or request a smaller page",
|
||||||
)
|
)
|
||||||
if binding.task_id 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_context",
|
||||||
"get_task_evidence",
|
"get_task_evidence",
|
||||||
}:
|
}:
|
||||||
@@ -404,11 +426,20 @@ class McpQueryApplication:
|
|||||||
parameters,
|
parameters,
|
||||||
result.model_dump(mode="json"),
|
result.model_dump(mode="json"),
|
||||||
)
|
)
|
||||||
output = result.model_dump(mode="json")
|
output = display_query_result(result.model_dump(mode="json"))
|
||||||
content = []
|
content = []
|
||||||
from app.mcp.extended_service import ARTIFACT_TOOLS
|
from app.mcp.extended_service import ARTIFACT_TOOLS
|
||||||
|
# Complete SCADA inputs must reach the model intact, not as a first-page preview.
|
||||||
|
# The shared 8 MiB result ceiling above still applies before persistence and output.
|
||||||
|
complete_scada = name == "query_scada_readings" and args.device_ids is None
|
||||||
|
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 (
|
if (
|
||||||
not include_full
|
not include_full
|
||||||
|
and not complete_scada
|
||||||
and name not in ARTIFACT_TOOLS
|
and name not in ARTIFACT_TOOLS
|
||||||
and len(json.dumps(output, ensure_ascii=False).encode()) > 12000
|
and len(json.dumps(output, ensure_ascii=False).encode()) > 12000
|
||||||
):
|
):
|
||||||
@@ -423,6 +454,10 @@ class McpQueryApplication:
|
|||||||
"resource_uri": uri,
|
"resource_uri": uri,
|
||||||
"preview": "The complete query snapshot is saved in task evidence",
|
"preview": "The complete query snapshot is saved in task evidence",
|
||||||
}
|
}
|
||||||
|
from app.mcp.presentation_preview import saved_presentation_preview
|
||||||
|
presentation = saved_presentation_preview(result)
|
||||||
|
if presentation is not None:
|
||||||
|
output["data"]["presentation"] = display_query_result(presentation)
|
||||||
output["truncated"] = True
|
output["truncated"] = True
|
||||||
output["data_gaps"] = [
|
output["data_gaps"] = [
|
||||||
*result.data_gaps,
|
*result.data_gaps,
|
||||||
@@ -458,6 +493,7 @@ class McpQueryApplication:
|
|||||||
403: "QUERY_CONTEXT_DENIED",
|
403: "QUERY_CONTEXT_DENIED",
|
||||||
404: "NOT_FOUND",
|
404: "NOT_FOUND",
|
||||||
409: "QUERY_CONTEXT_STALE",
|
409: "QUERY_CONTEXT_STALE",
|
||||||
|
410: "RESULT_EXPIRED",
|
||||||
413: "RESULT_TOO_LARGE",
|
413: "RESULT_TOO_LARGE",
|
||||||
422: "INVALID_QUERY",
|
422: "INVALID_QUERY",
|
||||||
503: "SOURCE_UNAVAILABLE",
|
503: "SOURCE_UNAVAILABLE",
|
||||||
@@ -466,7 +502,7 @@ class McpQueryApplication:
|
|||||||
codes.get(exc.status_code, "QUERY_FAILED"),
|
codes.get(exc.status_code, "QUERY_FAILED"),
|
||||||
str(exc.detail)
|
str(exc.detail)
|
||||||
if isinstance(exc.detail, str)
|
if isinstance(exc.detail, str)
|
||||||
and exc.status_code in {403, 404, 409, 413, 422}
|
and exc.status_code in {403, 404, 409, 410, 413, 422}
|
||||||
else "Query is unavailable",
|
else "Query is unavailable",
|
||||||
)
|
)
|
||||||
except (UndefinedTable, InvalidSchemaName):
|
except (UndefinedTable, InvalidSchemaName):
|
||||||
@@ -475,7 +511,7 @@ class McpQueryApplication:
|
|||||||
"MCP project storage has not been provisioned",
|
"MCP project storage has not been provisioned",
|
||||||
)
|
)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
return tool_error("QUERY_TIMEOUT", "Query execution timed out")
|
return tool_error("QUERY_TIMEOUT", "HTTP request timed out")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return tool_error(
|
return tool_error(
|
||||||
"INVALID_QUERY", "Invalid query value or incompatible source data"
|
"INVALID_QUERY", "Invalid query value or incompatible source data"
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
"""Durable MCP calculation submission and owner-scoped result retrieval.
|
||||||
|
|
||||||
|
Only safe server-owned binding fields are persisted. OAuth tokens and context
|
||||||
|
handles are never used to resume a calculation.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from uuid import uuid5, NAMESPACE_URL
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from app.mcp.models import BoundContext, QueryResult, PageInfo
|
||||||
|
from app.infra.db.postgresql.calculation_jobs import CalculationJobRepository
|
||||||
|
|
||||||
|
QUEUED_TOOLS = frozenset({
|
||||||
|
"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",
|
||||||
|
})
|
||||||
|
CONTROL_TOOLS = frozenset({"get_calculation_job", "get_calculation_result", "cancel_calculation_job", "list_calculation_jobs"})
|
||||||
|
BINDING_FIELDS = {"project_id", "subject", "client_id", "native_session_id", "purpose", "parent_session_id", "task_id", "run_id", "execution_request_id", "tool_cap", "permission_cap", "task_scope_cap"}
|
||||||
|
|
||||||
|
|
||||||
|
def durable_binding(binding: BoundContext) -> dict:
|
||||||
|
return binding.model_dump(mode="json", include=BINDING_FIELDS)
|
||||||
|
|
||||||
|
|
||||||
|
def restore_binding(document: dict) -> BoundContext:
|
||||||
|
# Expiration governs admission via a context handle; durable work has its own
|
||||||
|
# lifecycle and must revalidate identity, membership and run on every use.
|
||||||
|
return BoundContext.model_validate({**document, "context_handle": "durable-calculation", "expires_at": datetime.max.replace(tzinfo=timezone.utc)})
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def envelope(source: str, data, *, job_id="list", page=None) -> QueryResult:
|
||||||
|
return QueryResult(source=source, source_ref="calculation:" + str(job_id), queried_at=datetime.now(timezone.utc), semantics="task", data=data, page=page, truncated=bool(page and (page.offset or page.has_more)),
|
||||||
|
data_gaps=["Calculation status and queue receipts are not analytical evidence."])
|
||||||
|
|
||||||
|
|
||||||
|
def public_job(row: dict) -> dict:
|
||||||
|
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))
|
||||||
|
return {**jsonable_encoder({key: row[key] for key in fields if key in row}),
|
||||||
|
"result_available": available, "poll_after_ms": 1500}
|
||||||
|
|
||||||
|
|
||||||
|
async def submit_calculation(scope, binding, tool, args, call_id: str, *, job_timeout_seconds: int = 3600) -> QueryResult:
|
||||||
|
from app.mcp.query_service import authorize_query
|
||||||
|
await authorize_query(scope, binding, tool, args)
|
||||||
|
if binding.purpose == "task_reader":
|
||||||
|
raise HTTPException(403, "Read-only task agents cannot start calculations")
|
||||||
|
if tool.mutating:
|
||||||
|
from app.mcp.mutation_service import (
|
||||||
|
preview_change, check_digest, mutation_arguments, retryable_capacity_rejection,
|
||||||
|
)
|
||||||
|
from app.infra.db.postgresql.mcp_queries import McpQueryRepository
|
||||||
|
mutation_repo = McpQueryRepository(scope.connection, scope.project.project_id)
|
||||||
|
mutation_key = "mutation:" + binding.subject + ":" + binding.native_session_id
|
||||||
|
approved_parameters = {"tool": tool.name, "arguments": mutation_arguments(args, preview=False)}
|
||||||
|
async with scope.connection.transaction():
|
||||||
|
await mutation_repo.lock_receipt(mutation_key, str(args.request_id))
|
||||||
|
previous = await mutation_repo.receipt(mutation_key, str(args.request_id))
|
||||||
|
if previous:
|
||||||
|
if previous["parameters"] != approved_parameters:
|
||||||
|
raise HTTPException(409, "request_id already has different parameters")
|
||||||
|
if previous["result"].get("status") == "completed":
|
||||||
|
return QueryResult.model_validate(previous["result"]["output"])
|
||||||
|
if not retryable_capacity_rejection(previous["result"]):
|
||||||
|
raise HTTPException(409, "Operation already started; inspect the existing calculation or mutation status")
|
||||||
|
check_digest(args, await preview_change(scope, binding, tool.name, args))
|
||||||
|
parameters = args.model_dump(mode="json", exclude={"context_handle", "timeout_seconds"})
|
||||||
|
request_id = str(args.request_id) if tool.mutating else call_id
|
||||||
|
key = str(uuid5(NAMESPACE_URL, ":".join([binding.subject, binding.client_id, binding.native_session_id, str(binding.task_id), str(binding.run_id), request_id])))
|
||||||
|
repo = CalculationJobRepository(scope.connection, scope.project.project_id)
|
||||||
|
if binding.task_id:
|
||||||
|
from app.mcp.query_service import task_service
|
||||||
|
from app.mcp.contexts import verify_task
|
||||||
|
task_repo = task_service(scope).repo
|
||||||
|
# Share the operational transaction lock with finalization. Admission
|
||||||
|
# cannot race a pending-job check and then publish work into a closed run.
|
||||||
|
async with task_repo.transaction():
|
||||||
|
await task_repo.lock(f"task:{binding.task_id}")
|
||||||
|
await task_repo.lock(f"run:{binding.run_id}")
|
||||||
|
await verify_task(scope, binding)
|
||||||
|
row = await repo.enqueue(
|
||||||
|
owner_user_id=scope.project.user_id, request_key=key, tool=tool.name,
|
||||||
|
parameters=parameters, binding=durable_binding(binding), timeout_seconds=job_timeout_seconds,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
row = await repo.enqueue(
|
||||||
|
owner_user_id=scope.project.user_id, request_key=key, tool=tool.name,
|
||||||
|
parameters=parameters, binding=durable_binding(binding), timeout_seconds=job_timeout_seconds,
|
||||||
|
)
|
||||||
|
await scope.connection.commit()
|
||||||
|
return envelope(tool.name, public_job(row), job_id=row["job_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def check_binding(binding, row):
|
||||||
|
original = row["binding"]
|
||||||
|
if any(str(original.get(key)) != str(getattr(binding, key)) for key in ("subject", "client_id", "native_session_id", "task_id", "run_id", "execution_request_id")):
|
||||||
|
raise HTTPException(403, "Calculation belongs to a different session or task run")
|
||||||
|
|
||||||
|
|
||||||
|
async def authorize_original(scope, row):
|
||||||
|
from app.mcp.catalog import TOOLS
|
||||||
|
from app.mcp.query_service import authorize_query
|
||||||
|
from app.mcp.contexts import verify_task
|
||||||
|
original = restore_binding(row["binding"])
|
||||||
|
tool = TOOLS[row["tool"]]
|
||||||
|
# Legacy queue rows stored the old HTTP timeout. It does not govern durable execution.
|
||||||
|
parameters = {key: value for key, value in row["parameters"].items() if key != "timeout_seconds"}
|
||||||
|
args = tool.model.model_validate({**parameters, "context_handle": "durable-calculation-context-handle"})
|
||||||
|
if (str(scope.project.user_id) != str(row["owner_user_id"])
|
||||||
|
or str(scope.project.project_id) != str(original.project_id)):
|
||||||
|
raise HTTPException(403, "Calculation owner is no longer authorized")
|
||||||
|
if original.task_id:
|
||||||
|
await verify_task(scope, original)
|
||||||
|
await authorize_query(scope, original, tool, args)
|
||||||
|
return original, tool, args
|
||||||
|
|
||||||
|
|
||||||
|
async def read_calculation_job(scope, binding, tool, args) -> QueryResult:
|
||||||
|
repo = CalculationJobRepository(scope.connection, scope.project.project_id)
|
||||||
|
if tool.name == "list_calculation_jobs":
|
||||||
|
identity_scope = binding.model_dump(mode="json", include={
|
||||||
|
"subject", "client_id", "native_session_id", "task_id", "run_id", "execution_request_id",
|
||||||
|
})
|
||||||
|
rows = await repo.list(owner_user_id=scope.project.user_id, binding=identity_scope, limit=args.limit + 1, offset=args.offset, status=args.status)
|
||||||
|
return envelope(tool.name, [public_job(row) for row in rows[:args.limit]], page=PageInfo(limit=args.limit, offset=args.offset, has_more=len(rows) > args.limit))
|
||||||
|
row = await repo.get(args.job_id, owner_user_id=scope.project.user_id,
|
||||||
|
include_result=tool.name == "get_calculation_result")
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(404, "Calculation was not found")
|
||||||
|
check_binding(binding, row)
|
||||||
|
await authorize_original(scope, row)
|
||||||
|
if tool.name == "cancel_calculation_job":
|
||||||
|
if binding.purpose == "task_reader":
|
||||||
|
raise HTTPException(403, "Read-only task agents cannot cancel calculations")
|
||||||
|
row = await repo.cancel(args.job_id, owner_user_id=scope.project.user_id)
|
||||||
|
await scope.connection.commit()
|
||||||
|
if tool.name != "get_calculation_result" or row["status"] != "succeeded":
|
||||||
|
return envelope(tool.name, public_job(row), job_id=row["job_id"])
|
||||||
|
expiry = row.get("result_expires_at")
|
||||||
|
if row.get("result") is None or (expiry is not None and expiry <= datetime.now(timezone.utc)):
|
||||||
|
raise HTTPException(410, "Calculation result retention period has expired")
|
||||||
|
result = QueryResult.model_validate(row["result"])
|
||||||
|
result = await attach_calculation_evidence(scope, row, result)
|
||||||
|
value = result.data
|
||||||
|
for part in args.data_path:
|
||||||
|
if isinstance(value, dict) and part in value:
|
||||||
|
value = value[part]
|
||||||
|
else:
|
||||||
|
raise HTTPException(422, "data_path must select an existing object field")
|
||||||
|
if isinstance(value, list):
|
||||||
|
result.data = value[args.offset:args.offset + args.limit]
|
||||||
|
result.page = PageInfo(limit=args.limit, offset=args.offset, total=len(value), has_more=args.offset + args.limit < len(value))
|
||||||
|
result.truncated = result.truncated or args.offset > 0 or result.page.has_more
|
||||||
|
elif args.offset:
|
||||||
|
raise HTTPException(422, "offset requires a collection data_path")
|
||||||
|
else:
|
||||||
|
result.data = value
|
||||||
|
if not args.data_path and binding.task_id:
|
||||||
|
from app.mcp.presentation_preview import saved_burst_result_preview, saved_presentation_preview
|
||||||
|
|
||||||
|
presentation = saved_presentation_preview(result)
|
||||||
|
if presentation is not None and isinstance(result.data, dict):
|
||||||
|
result.data = {**result.data, "presentation": presentation}
|
||||||
|
if len(result.model_dump_json().encode()) > 11000:
|
||||||
|
preview = saved_burst_result_preview(result, str(binding.task_id))
|
||||||
|
if preview is not None:
|
||||||
|
result.data = preview
|
||||||
|
result.truncated = True
|
||||||
|
result.data_gaps = [*result.data_gaps,
|
||||||
|
"Inline burst impact summary only; full node comparisons and geometry are saved in task evidence. Read the evidence resource or select a narrower data_path."]
|
||||||
|
if len(result.model_dump_json().encode()) > 11000:
|
||||||
|
raise HTTPException(413, "Select a narrower data_path or smaller result page")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_calculation_job(scope, row) -> QueryResult:
|
||||||
|
"""Worker entry point; success is persisted by the fenced supervisor."""
|
||||||
|
from app.mcp.query_service import execute_query
|
||||||
|
original, tool, args = await authorize_original(scope, row)
|
||||||
|
result = await execute_query(scope, original, tool, args)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def attach_calculation_evidence(scope, row, result):
|
||||||
|
from app.mcp.query_service import task_service
|
||||||
|
from app.mcp.contexts import verify_task
|
||||||
|
original, tool, _ = await authorize_original(scope, row)
|
||||||
|
if original.task_id:
|
||||||
|
from app.domain.schemas.operational_task import TaskDataSnapshot
|
||||||
|
from app.infra.db.postgresql.mcp_queries import McpQueryRepository
|
||||||
|
repo = McpQueryRepository(scope.connection, scope.project.project_id)
|
||||||
|
key, call_id = "calculation:" + str(row["job_id"]), "result"
|
||||||
|
# Result lookup can leave an implicit SELECT transaction open. End it so
|
||||||
|
# evidence and its receipt commit together before returning a response.
|
||||||
|
await scope.connection.commit()
|
||||||
|
task_repo = task_service(scope).repo
|
||||||
|
# Match operational finalization's lock order: event lock, then task.
|
||||||
|
async with task_repo.transaction():
|
||||||
|
await repo.lock_receipt(key, call_id)
|
||||||
|
previous = await repo.receipt(key, call_id)
|
||||||
|
if previous:
|
||||||
|
return QueryResult.model_validate(previous["result"])
|
||||||
|
await task_repo.lock(f"task:{original.task_id}")
|
||||||
|
await verify_task(scope, original)
|
||||||
|
result.source_ref = key
|
||||||
|
evidence, _ = await task_service(scope).record_data_snapshot(original.task_id, TaskDataSnapshot(
|
||||||
|
source=tool.name, source_ref=key, queried_at=result.queried_at, origin=result.origin,
|
||||||
|
data=result.model_dump(mode="json"), truncated=result.truncated))
|
||||||
|
result.evidence_id = evidence.evidence_id
|
||||||
|
await repo.save_receipt(key, call_id, {"job_id": str(row["job_id"])}, result.model_dump(mode="json"))
|
||||||
|
return result
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Bounded inputs for durable computation control."""
|
||||||
|
from typing import Literal
|
||||||
|
from uuid import UUID
|
||||||
|
from pydantic import Field
|
||||||
|
from app.mcp.models import StrictInput, PageInput, Id
|
||||||
|
|
||||||
|
CalculationStatus = Literal["queued", "running", "cancel_requested", "succeeded", "failed", "cancelled", "interrupted", "timed_out"]
|
||||||
|
|
||||||
|
|
||||||
|
class CalculationJobInput(StrictInput):
|
||||||
|
job_id: UUID
|
||||||
|
|
||||||
|
|
||||||
|
class CalculationResultInput(CalculationJobInput):
|
||||||
|
data_path: list[Id] = Field(default_factory=list, max_length=8)
|
||||||
|
limit: int = Field(default=20, ge=1, le=500)
|
||||||
|
offset: int = Field(default=0, ge=0, le=1000000)
|
||||||
|
|
||||||
|
|
||||||
|
class CalculationJobsInput(PageInput):
|
||||||
|
status: CalculationStatus | None = None
|
||||||
+60
-36
@@ -4,8 +4,9 @@ from dataclasses import dataclass
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from mcp import types
|
from mcp import types
|
||||||
from app.mcp import models as m
|
from app.mcp import models as m
|
||||||
|
from app.mcp import calculation_models as jobs
|
||||||
from app.mcp import extended_models as e
|
from app.mcp import extended_models as e
|
||||||
from app.mcp.analysis_models import BurstDetectionInput, BurstLocationInput, ValveIsolationInput, LeakageIdentificationInput
|
from app.mcp.analysis_models import BurstDetectionInput, BurstLocationInput, ContaminantSourceTraceInput, ValveIsolationInput, LeakageIdentificationInput, NodeCoverageInput
|
||||||
from app.mcp.analysis_models import ScenarioInput, ContaminantSimulationInput, ValveClosureInput, FlushingInput, PressureRegulationInput, SensorPlacementInput
|
from app.mcp.analysis_models import ScenarioInput, ContaminantSimulationInput, ValveClosureInput, FlushingInput, PressureRegulationInput, SensorPlacementInput
|
||||||
|
|
||||||
|
|
||||||
@@ -22,40 +23,57 @@ class QueryTool:
|
|||||||
|
|
||||||
def protocol_tool(self) -> types.Tool:
|
def protocol_tool(self) -> types.Tool:
|
||||||
if self.mutating:
|
if self.mutating:
|
||||||
suffix = "。默认dry_run只预览;执行必须携带预览摘要、相同request_id并通过宿主原生许可。仅项目普通聊天可用,后台调查和reader禁止;失败或超时先核实状态,不自动重试。"
|
suffix = "。默认dry_run只预览;执行必须携带预览摘要、相同request_id并通过宿主原生许可。由项目成员的普通聊天执行写入;失败或超时先核实状态,再按原请求接续。"
|
||||||
elif self.name == "run_simulation":
|
elif self.name == "run_simulation":
|
||||||
suffix = "。任务调用复用证据留存。"
|
suffix = "。任务调用复用证据留存。"
|
||||||
elif self.computation:
|
elif self.computation:
|
||||||
suffix = "。仅计算建议,不保存方案、不控制设备;任务调用复用证据留存。"
|
suffix = "。返回分析建议并留存任务证据;正式方案与现场操作按各自业务流程执行。"
|
||||||
elif self.name == "query_nearby_elements":
|
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核验。"
|
||||||
|
)
|
||||||
|
elif self.name == "query_scada_readings":
|
||||||
|
suffix = (
|
||||||
|
"。首次调查省略device_ids,Server读取完整设备属性并自动翻页读取窗口内全部授权观测,"
|
||||||
|
"返回devices、readings和coverage,包括缺测及冻结范围外设备;全量结果完整内联。"
|
||||||
|
"显式device_ids仅用于后续局部复核,不能替代全量核查。"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
suffix = (
|
suffix = (
|
||||||
"。先限定对象和时间范围,再读取明细;集合默认20条,按需逐页扩大,"
|
"。先限定对象和时间范围,再读取明细;集合默认20条,按需逐页扩大,"
|
||||||
"不要自动遍历全量;截断或非零offset不能证明完整范围无异常。"
|
"按当前目标读取必要页面,并说明截断和分页覆盖范围。"
|
||||||
)
|
)
|
||||||
return types.Tool(
|
return types.Tool(
|
||||||
name=self.name,
|
name=self.name,
|
||||||
description=self.description + suffix,
|
description=self.description + suffix + (
|
||||||
|
"。返回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 ""
|
||||||
|
),
|
||||||
inputSchema=self.model.model_json_schema(),
|
inputSchema=self.model.model_json_schema(),
|
||||||
outputSchema=m.QueryResult.model_json_schema(),
|
outputSchema=m.QueryResult.model_json_schema(),
|
||||||
annotations=types.ToolAnnotations(
|
annotations=types.ToolAnnotations(
|
||||||
readOnlyHint=not (self.computation or self.mutating), destructiveHint=self.mutating or self.name == "run_simulation", openWorldHint=self.name in {"search_project_places", "reverse_geocode"}
|
readOnlyHint=not (self.computation or self.mutating or self.name == "cancel_calculation_job"), destructiveHint=self.mutating or self.name == "run_simulation", openWorldHint=self.name in {"search_project_places", "reverse_geocode"}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
_specs = [
|
_specs = [
|
||||||
("run_simulation", "按水力步长在在线业务模型上执行指定时间段的实时模拟,不复制数据库;更新该时段的模型条件及实时基线,返回指定节点和管段结果;duration单位为秒,不创建方案或正式分析运行,结果不是实测", ScenarioInput, "simulation.run", "analysis", False, True),
|
("run_contaminant_source_trace", "水污染候选溯源:提交由事件位置或监测映射取得的候选模型节点及事件窗,快速返回确定的已核验节点。当前为临时稳定选择,正式反演算法后续接入;不生成置信度。取得located_node后先展示节点、保存阶段结论并提现场核查/采样处置工单;工单与污染传播模拟、accident_element_type=node的隔离和影响分析并行,结果补入同一调查,不因算法来源停止", ContaminantSourceTraceInput, "simulation.run", "analysis", False, True),
|
||||||
("run_contaminant_simulation", "在隔离模型中模拟假设污染源的浓度传播;返回指定节点/管段结果,不反演污染源,不代表实测污染范围", ContaminantSimulationInput, "simulation.run", "analysis", False, True),
|
("run_simulation", "按水力步长在在线业务模型上执行指定时间段的实时模拟,更新全网实时基线,返回指定节点和管段结果;调查发现同刻基线缺失时可自主补算,等待成功并读取结果后重试依赖分析。duration单位为秒,补单帧可用60秒;回放按当前模型补算,不读取实时SCADA,不替代冻结观测;正式分析成果通过方案工具保存", ScenarioInput, "simulation.run", "analysis", False, True),
|
||||||
("analyze_valve_closure", "在隔离模型中模拟关阀的水力影响,返回指定节点/管段结果;不操作真实阀门", ValveClosureInput, "simulation.run", "analysis", False, True),
|
("run_contaminant_simulation", "在隔离模型中模拟假设污染源的浓度传播;include_impact_coverage=true时汇总全网节点整个模拟时段超过impact_threshold_mg_l的累计范围、首个超阈值报告时刻和峰值,并保存地图证据。阈值为情景展示条件,非饮用水安全限值;回放按当前模型独立估计,不读取实时SCADA。定位后先提现场核查/采样处置工单,工单审核派发与本计算并行,结果补入同一调查", ContaminantSimulationInput, "simulation.run", "analysis", False, True),
|
||||||
("analyze_flushing", "在隔离模型中评估指定排放节点、冲洗流量和关阀组合的水力结果;不是现场冲洗或消毒合格证明", FlushingInput, "simulation.run", "analysis", False, True),
|
("analyze_valve_closure", "在隔离模型中模拟关阀的水力影响,返回指定节点/管段结果;现场阀门操作按业务审批执行", ValveClosureInput, "simulation.run", "analysis", False, True),
|
||||||
("analyze_pressure_regulation", "在隔离模型中评估给定调泵序列和水箱初始水位的压力影响;不自动优化调度,不实际控制泵", PressureRegulationInput, "simulation.run", "analysis", False, True),
|
("analyze_flushing", "在独立模型中评估指定排放节点、流量及明确开关阀组合的冲洗水力结果;未列阀门沿用当前模型,不继承上次模拟;不判定污染清除或恢复供水", FlushingInput, "simulation.run", "analysis", False, True),
|
||||||
("optimize_sensor_placement", "使用灵敏度或K-means算法推荐压力监测点;仅返回候选节点,不保存正式方案,不保证污染溯源可观测性", SensorPlacementInput, "optimization.run", "analysis", False, True),
|
("analyze_pressure_regulation", "在隔离模型中评估给定调泵序列和水箱初始水位的压力影响;输出为给定方案评估,现场调度按审批执行", PressureRegulationInput, "simulation.run", "analysis", False, True),
|
||||||
("run_leakage_identification", "基于SCADA压力与模型进行DMA漏损分配识别,需有依据的总漏损流量和时间对齐;返回区域估计,不是已确认漏点。内部执行遗传算法与水力计算", LeakageIdentificationInput, "burst.run", "analysis", False, True),
|
("optimize_sensor_placement", "使用灵敏度或K-means算法推荐压力监测点;返回候选节点,正式部署另行保存并核验观测能力", SensorPlacementInput, "optimization.run", "analysis", False, True),
|
||||||
("run_burst_detection", "爆管异常侦测:使用至少5个完整压力传感器此前15天数据;只提示压力异常,不定位、不证明爆管", BurstDetectionInput, "burst.run", "analysis", False, True),
|
("run_leakage_identification", "基于SCADA压力与模型进行DMA漏损分配识别,需有依据的总漏损流量和时间对齐;返回待现场核验的区域估计。内部执行遗传算法与水力计算", LeakageIdentificationInput, "burst.run", "analysis", False, True),
|
||||||
("run_burst_location", "爆管候选定位:对比异常与正常压力,可辅助流量;需有依据的漏水量假设。压力节点可省略,自动读取项目SCADA压力测点;monitoring正常窗可成对省略,默认前一天同时间段,须说明该对照假设。simulation须有已有分析运行UUID并读取同窗模型基线,不是实测;定位内部会执行水力计算,不创建新的事件方案", BurstLocationInput, "burst.run", "analysis", False, True),
|
("run_burst_detection", "爆管异常侦测:使用至少5个完整压力传感器此前15天数据;返回压力异常信号,候选管段由定位工具分析", BurstDetectionInput, "burst.run", "analysis", False, True),
|
||||||
("analyze_valve_isolation", "按事故候选要素分析拓扑隔离边界、建议关闭阀门和受影响节点;不是污染扩散或水力影响范围,不执行关阀,须现场核实", ValveIsolationInput, "simulation.run", "analysis", False, True),
|
("run_burst_location", "爆管候选定位:对比异常与正常压力,可辅助流量;依据现有异常形成候选,水力算法使用有依据的burst_leakage作为输入。任务上下文location_capability.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",
|
"get_element_properties",
|
||||||
"读取当前项目的单个管网元素属性",
|
"读取当前项目的单个管网元素属性",
|
||||||
@@ -65,14 +83,14 @@ _specs = [
|
|||||||
),
|
),
|
||||||
(
|
(
|
||||||
"list_elements",
|
"list_elements",
|
||||||
"分页读取管网元素,不自动读取全网",
|
"按查询目标分页读取管网元素",
|
||||||
m.ElementPageInput,
|
m.ElementPageInput,
|
||||||
"webgis.view",
|
"webgis.view",
|
||||||
"asset",
|
"asset",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"query_nearby_elements",
|
"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,
|
m.NearbyElementsInput,
|
||||||
"webgis.view",
|
"webgis.view",
|
||||||
"asset",
|
"asset",
|
||||||
@@ -94,7 +112,7 @@ _specs = [
|
|||||||
(
|
(
|
||||||
"list_scada_devices",
|
"list_scada_devices",
|
||||||
"分页读取SCADA设备和物理单位",
|
"分页读取SCADA设备和物理单位",
|
||||||
m.PageInput,
|
m.ScadaDeviceListInput,
|
||||||
"scada.view",
|
"scada.view",
|
||||||
"asset",
|
"asset",
|
||||||
),
|
),
|
||||||
@@ -108,7 +126,7 @@ _specs = [
|
|||||||
),
|
),
|
||||||
(
|
(
|
||||||
"query_model_baseline_series",
|
"query_model_baseline_series",
|
||||||
"读取最多20个元素的模型基线,不能称为实测",
|
"读取最多20个元素的模型基线,结果标注模型来源",
|
||||||
m.BaselineSeriesInput,
|
m.BaselineSeriesInput,
|
||||||
"simulation.view",
|
"simulation.view",
|
||||||
"model_baseline",
|
"model_baseline",
|
||||||
@@ -200,7 +218,7 @@ _specs = [
|
|||||||
),
|
),
|
||||||
(
|
(
|
||||||
"get_task_context",
|
"get_task_context",
|
||||||
"读取绑定任务的有界摘要及handling_skill元数据;section=skill读取Server按任务workflow分发的业务处置Skill正文与版本,属于流程指引而非事件证据;其他section选择task/report/evidence/actions/progress/access_requests/plan/run,data_path按返回路径读取嵌套字段,limit/offset分页;report_revision保留选定报告版本",
|
"读取绑定任务的有界摘要及handling_skill元数据;section=skill读取Server按任务workflow分发的业务处置Skill正文与版本,属于流程指引而非事件证据;其他section选择task/report/evidence/actions/progress/access_requests/plan/run,data_path按返回路径读取嵌套字段,limit/offset分页;progress/report图节点仅为流程参考标记,旧执行态兼容转换,不代表实时进度;实际执行状态读run、动作状态读actions;report_revision保留选定报告版本",
|
||||||
m.TaskContextInput,
|
m.TaskContextInput,
|
||||||
"operational_task.view",
|
"operational_task.view",
|
||||||
"task",
|
"task",
|
||||||
@@ -208,7 +226,7 @@ _specs = [
|
|||||||
),
|
),
|
||||||
(
|
(
|
||||||
"query_peripheral_data",
|
"query_peripheral_data",
|
||||||
"查询有限外围数据源;synthetic是合成证据",
|
"查询外围业务工单及监测数据;外围工单属于系统数据,监测数据按实际来源保留来源信息",
|
||||||
m.PeripheralInput,
|
m.PeripheralInput,
|
||||||
"simulation.view",
|
"simulation.view",
|
||||||
"peripheral",
|
"peripheral",
|
||||||
@@ -224,36 +242,42 @@ _specs = [
|
|||||||
]
|
]
|
||||||
_specs += [
|
_specs += [
|
||||||
("list_regions", "分页查询当前项目区域/DMA列表", m.PageInput, "webgis.view", "asset"),
|
("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_region_nodes", "分页查询区域配置的成员节点,未配置时使用区域几何筛选", e.RegionInput, "webgis.view", "asset"),
|
||||||
("get_node_connections", "分页查询节点直接连接的管线,不自动遍历全网", e.NodeConnectionsInput, "webgis.view", "asset"),
|
("get_node_connections", "按目标节点分页查询直接连接的管线", e.NodeConnectionsInput, "webgis.view", "asset"),
|
||||||
("query_network_extent", "按EPSG:4326矩形范围分页查询当前项目模型几何;无GIS配置不跨项目回退", e.ExtentInput, "webgis.view", "asset"),
|
("query_network_extent", "按EPSG:4326矩形范围分页查询当前项目模型几何;GIS缺失时返回当前项目的具体缺口", e.ExtentInput, "webgis.view", "asset"),
|
||||||
("list_major_nodes", "按关联管道最小管径查询骨干节点,经纬度EPSG:4326", e.MajorNodesInput, "webgis.view", "asset"),
|
("list_major_nodes", "按关联管道最小管径查询骨干节点,经纬度EPSG:4326", e.MajorNodesInput, "webgis.view", "asset"),
|
||||||
("get_model_configuration", "读取模式、曲线、控制规则、需水量、水质及反应等模型配置;集合按页返回", e.ModelConfigurationInput, "webgis.view", "asset"),
|
("get_model_configuration", "读取模式、曲线、控制规则、需水量、水质及反应等模型配置;集合按页返回", e.ModelConfigurationInput, "webgis.view", "asset"),
|
||||||
("list_model_configurations", "分页读取模式或曲线ID", e.ConfigurationListInput, "webgis.view", "asset"),
|
("list_model_configurations", "分页读取模式或曲线ID", e.ConfigurationListInput, "webgis.view", "asset"),
|
||||||
("search_project_places", "按项目配置的行政区搜索地名,最多10个候选,依赖天地图配置", e.PlaceSearchInput, "webgis.view", "asset"),
|
("search_project_places", "按项目配置的行政区搜索地名,最多10个候选,依赖天地图配置", e.PlaceSearchInput, "webgis.view", "asset"),
|
||||||
("reverse_geocode", "把EPSG:4326地图取点转为项目范围内地名,保留输入坐标", e.ReverseGeocodeInput, "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"),
|
("export_project_model", "从当前项目数据库导出INP或ChangeSet压缩文件,由宿主保存到会话文件", e.ProjectExportInput, "webgis.view", "asset"),
|
||||||
("get_sensor_placement_candidate", "读取监测点候选节点工程属性", e.SensorCandidateInput, "optimization.run", "asset"),
|
("get_sensor_placement_candidate", "读取监测点候选节点工程属性", e.SensorCandidateInput, "optimization.run", "asset"),
|
||||||
("export_sensor_placement", "导出已有监测点方案的Excel工程清单,由宿主保存到会话文件", e.SensorExportInput, "optimization.run", "analysis"),
|
("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_task_run_history", "分页读取指定项目任务的运行历史", e.TaskHistoryInput, "operational_task.view", "task"),
|
||||||
("get_mutation_status", "读取当前身份和会话内的写入执行状态;started只表示已启动,超时后须核实目标数据,不自动重试", e.MutationStatusInput, "webgis.view", "task"),
|
("get_mutation_status", "读取当前身份和会话内的写入执行状态;started只表示已启动,超时后先核实目标数据,再按原请求接续", e.MutationStatusInput, "webgis.view", "task"),
|
||||||
("calculate_demand_allocation", "按管长权重分配总需水量到节点、区域或全网,只计算不修改模型,按页返回", e.DemandAllocationInput, "webgis.view", "analysis", False, True),
|
("calculate_demand_allocation", "按管长权重分配总需水量到节点、区域或全网,返回分配建议,模型变更通过业务写入执行,按页返回", e.DemandAllocationInput, "webgis.view", "analysis", False, True),
|
||||||
("run_burst_simulation", "在隔离模型中模拟假设管道破口面积的水力影响;不是爆管侦测或定位,不保存正式方案", e.BurstSimulationInput, "simulation.run", "analysis", False, True),
|
("run_burst_simulation", "在隔离模型中模拟管道破口;include_impact_coverage=true时按起算同刻已有在线模拟计算全网节点压力下降及1/3/5米水头分档覆盖,保存为展示证据;回放仅用当前模型情景,不替代冻结观测", e.BurstSimulationInput, "simulation.run", "analysis", False, True),
|
||||||
("edit_model_element", "预览或增删改模型节点、管段、泵阀、水池和区域;仅修改模型,不控制现场设备", e.ModelElementMutationInput, "webgis.edit", "asset", False, False, True),
|
("edit_model_element", "预览或增删改模型节点、管段、泵阀、水池和区域;变更范围为业务模型,现场设备操作按对应流程执行", e.ModelElementMutationInput, "webgis.edit", "asset", False, False, True),
|
||||||
("edit_model_configuration", "预览或修改模型需水量、模式、曲线、控制规则、水质及模拟选项", e.ConfigurationMutationInput, "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),
|
("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),
|
("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),
|
("create_sensor_placement", "预览或计算并保存正式压力监测点选址方案", e.SensorCreateInput, "optimization.run", "analysis", False, False, True),
|
||||||
("update_sensor_placement", "预览或修改本人有权编辑的监测点方案,保留旧点位乐观校验", e.SensorUpdateInput, "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),
|
("create_task_schedule", "预览或创建定时任务配置", e.ScheduleCreateInput, "operational_task.write", "task", False, False, True),
|
||||||
("update_task_schedule", "预览或启停定时任务配置,校验版本", e.ScheduleUpdateInput, "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),
|
("cancel_task_run", "预览或取消指定任务运行,校验运行版本", e.TaskCancelInput, "operational_task.write", "task", False, False, True),
|
||||||
]
|
]
|
||||||
|
_specs += [
|
||||||
|
("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"),
|
||||||
|
]
|
||||||
TOOLS = {row[0]: QueryTool(*row) for row in _specs}
|
TOOLS = {row[0]: QueryTool(*row) for row in _specs}
|
||||||
|
|||||||
+4
-1
@@ -21,7 +21,8 @@ class McpSettings(BaseSettings):
|
|||||||
)
|
)
|
||||||
allowed_origins: list[str] = Field(default_factory=list)
|
allowed_origins: list[str] = Field(default_factory=list)
|
||||||
context_ttl_seconds: int = Field(default=600, ge=30, le=600)
|
context_ttl_seconds: int = Field(default=600, ge=30, le=600)
|
||||||
query_timeout_seconds: int = Field(default=30, ge=1, le=60)
|
query_timeout_seconds: int = Field(default=120, ge=1, le=240)
|
||||||
|
max_request_timeout_seconds: int = Field(default=240, ge=1, le=240)
|
||||||
max_calculation_timeout_seconds: int = Field(default=3600, ge=120, le=86400)
|
max_calculation_timeout_seconds: int = Field(default=3600, ge=120, le=86400)
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_prefix="TJWATER_MCP_",
|
env_prefix="TJWATER_MCP_",
|
||||||
@@ -32,6 +33,8 @@ class McpSettings(BaseSettings):
|
|||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_resource(self):
|
def validate_resource(self):
|
||||||
|
if self.query_timeout_seconds > self.max_request_timeout_seconds:
|
||||||
|
raise ValueError("query timeout exceeds the HTTP request timeout limit")
|
||||||
if self.dev_http_issuer_uri:
|
if self.dev_http_issuer_uri:
|
||||||
dev_url = urlsplit(self.dev_http_issuer_uri)
|
dev_url = urlsplit(self.dev_http_issuer_uri)
|
||||||
try:
|
try:
|
||||||
|
|||||||
+2
-1
@@ -30,6 +30,7 @@ async def verify_task(scope: QueryScope, binding: BoundContext) -> None:
|
|||||||
task = await repo.get_task(str(binding.task_id))
|
task = await repo.get_task(str(binding.task_id))
|
||||||
if not task:
|
if not task:
|
||||||
raise HTTPException(404, "Task is not in this project")
|
raise HTTPException(404, "Task is not in this project")
|
||||||
|
binding.replay_context = task.get("source", {}).get("replay_context")
|
||||||
if binding.purpose == "task_collaboration":
|
if binding.purpose == "task_collaboration":
|
||||||
return
|
return
|
||||||
if OPERATIONAL_TASK_EXECUTE not in permissions:
|
if OPERATIONAL_TASK_EXECUTE not in permissions:
|
||||||
@@ -70,7 +71,7 @@ async def bind_context(
|
|||||||
subject=principal.subject,
|
subject=principal.subject,
|
||||||
client_id=principal.client_id,
|
client_id=principal.client_id,
|
||||||
tool_cap=sorted(name for name, tool in TOOLS.items()
|
tool_cap=sorted(name for name, tool in TOOLS.items()
|
||||||
if (command.purpose != "task_reader" or not (tool.computation or tool.mutating))
|
if (command.purpose != "task_reader" or not (tool.computation or tool.mutating or name == "cancel_calculation_job"))
|
||||||
and (not tool.mutating or command.purpose == "ordinary_chat")),
|
and (not tool.mutating or command.purpose == "ordinary_chat")),
|
||||||
permission_cap=sorted(permissions_for_context(scope.project)),
|
permission_cap=sorted(permissions_for_context(scope.project)),
|
||||||
native_session_id=command.native_session_id,
|
native_session_id=command.native_session_id,
|
||||||
|
|||||||
@@ -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<fraction>\.\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"(?<![A-Za-z0-9_/+:.-])" + _INSTANT.pattern + r"(?![A-Za-z0-9_:+/-])",
|
||||||
|
lambda match: _display_instant(match.group()), parts[index],
|
||||||
|
)
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def display_query_result(value: Any, *, field: str = "") -> 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
|
||||||
@@ -28,7 +28,7 @@ class NodeConnectionsInput(PageInput):
|
|||||||
|
|
||||||
class ExtentInput(PageInput):
|
class ExtentInput(PageInput):
|
||||||
element_type: Literal["junction", "pipe", "valve", "pump", "tank", "reservoir"] = "pipe"
|
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")
|
@model_validator(mode="after")
|
||||||
def valid_bounds(self):
|
def valid_bounds(self):
|
||||||
@@ -116,8 +116,9 @@ class DemandAllocationInput(CalculationInput):
|
|||||||
|
|
||||||
|
|
||||||
class BurstSimulationInput(ScenarioInput):
|
class BurstSimulationInput(ScenarioInput):
|
||||||
|
include_impact_coverage: bool = Field(default=False, strict=True, description="比较起算时刻全网节点与已有在线模拟压力,按下降1≤Δ<3、3≤Δ<5、Δ≥5米水头生成事件影响覆盖;不重算或覆盖在线基线")
|
||||||
burst_pipe_ids: list[Id] = Field(min_length=1, max_length=20)
|
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")
|
@model_validator(mode="after")
|
||||||
def paired_bursts(self):
|
def paired_bursts(self):
|
||||||
@@ -177,7 +178,7 @@ class ConfigurationMutationInput(MutationInput):
|
|||||||
class ScadaCleaningInput(MutationInput):
|
class ScadaCleaningInput(MutationInput):
|
||||||
start: datetime
|
start: datetime
|
||||||
end: 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")
|
@model_validator(mode="after")
|
||||||
def valid_window(self):
|
def valid_window(self):
|
||||||
@@ -205,7 +206,7 @@ class ScadaRecord(BaseModel):
|
|||||||
class ScadaWriteInput(MutationInput):
|
class ScadaWriteInput(MutationInput):
|
||||||
operation: Literal["insert", "update"]
|
operation: Literal["insert", "update"]
|
||||||
records: list[ScadaRecord] = Field(min_length=1, max_length=500)
|
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")
|
@model_validator(mode="after")
|
||||||
def bounded_records(self):
|
def bounded_records(self):
|
||||||
|
|||||||
+21
-3
@@ -21,8 +21,12 @@ class PageInput(StrictInput):
|
|||||||
offset: int = Field(default=0, ge=0, le=10000)
|
offset: int = Field(default=0, ge=0, le=10000)
|
||||||
|
|
||||||
|
|
||||||
|
class ScadaDeviceListInput(PageInput):
|
||||||
|
limit: int = Field(default=100, ge=1, le=500)
|
||||||
|
|
||||||
|
|
||||||
class TaskContextInput(PageInput):
|
class TaskContextInput(PageInput):
|
||||||
section: Literal["summary", "task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill"] = "summary"
|
section: Literal["summary", "task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill", "questions"] = "summary"
|
||||||
report_revision: int | None = Field(default=None, ge=1)
|
report_revision: int | None = Field(default=None, ge=1)
|
||||||
data_path: list[Id] = Field(default_factory=list, max_length=8)
|
data_path: list[Id] = Field(default_factory=list, max_length=8)
|
||||||
|
|
||||||
@@ -102,8 +106,20 @@ class WindowInput(PageInput):
|
|||||||
|
|
||||||
|
|
||||||
class ScadaInput(WindowInput):
|
class ScadaInput(WindowInput):
|
||||||
device_ids: list[Id] = Field(min_length=1, max_length=20)
|
device_ids: list[Id] | None = Field(
|
||||||
field: Literal["monitored_value", "cleaned_value"] = "monitored_value"
|
default=None, min_length=1, max_length=20,
|
||||||
|
description="Omit to read the complete SCADA catalogue and all authorized readings in the window; explicit IDs select a paginated detail query.",
|
||||||
|
)
|
||||||
|
field: Literal["monitored_value", "cleaned_value"] = Field(
|
||||||
|
default="monitored_value",
|
||||||
|
description="Field for explicit-device detail queries; complete all-device reads return both raw and cleaned values.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def complete_query_starts_at_zero(self):
|
||||||
|
if self.device_ids is None and self.offset:
|
||||||
|
raise ValueError("All-device SCADA queries read every page internally; offset must be zero")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class BaselineSeriesInput(WindowInput):
|
class BaselineSeriesInput(WindowInput):
|
||||||
@@ -255,6 +271,7 @@ class PageInfo(BaseModel):
|
|||||||
|
|
||||||
class QueryResult(BaseModel):
|
class QueryResult(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
display_time_zone: Literal["Asia/Shanghai"] = "Asia/Shanghai"
|
||||||
source: str
|
source: str
|
||||||
source_ref: str
|
source_ref: str
|
||||||
queried_at: datetime
|
queried_at: datetime
|
||||||
@@ -291,5 +308,6 @@ class BoundContext(BaseModel):
|
|||||||
task_id: UUID | None = None
|
task_id: UUID | None = None
|
||||||
run_id: UUID | None = None
|
run_id: UUID | None = None
|
||||||
execution_request_id: UUID | None = None
|
execution_request_id: UUID | None = None
|
||||||
|
replay_context: dict | None = None
|
||||||
expires_at: datetime
|
expires_at: datetime
|
||||||
revoked_at: datetime | None = None
|
revoked_at: datetime | None = None
|
||||||
|
|||||||
@@ -189,11 +189,12 @@ def saved_scenario(network: str, username: str, args: BaseModel) -> Any:
|
|||||||
modify_valve_opening={valve: 0.0 for valve in parameters["valves"]})
|
modify_valve_opening={valve: 0.0 for valve in parameters["valves"]})
|
||||||
if args.scenario == "flushing":
|
if args.scenario == "flushing":
|
||||||
from app.native.wndb.model.options import get_option
|
from app.native.wndb.model.options import get_option
|
||||||
|
from app.mcp.scenario_service import flushing_valve_controls
|
||||||
if get_option(network).get("UNITS") not in {"LPS", "CMH"}:
|
if get_option(network).get("UNITS") not in {"LPS", "CMH"}:
|
||||||
raise ValueError("Flushing supports LPS or CMH models only")
|
raise ValueError("Flushing supports LPS or CMH models only")
|
||||||
return scenarios.flushing_analysis(**common, username=username,
|
return scenarios.flushing_analysis(**common, username=username,
|
||||||
drainage_node_ID=parameters["drainage_node_id"], flushing_flow=parameters["flushing_flow_m3h"],
|
drainage_node_ID=parameters["drainage_node_id"], flushing_flow=parameters["flushing_flow_m3h"],
|
||||||
modify_valve_opening={valve: 0.0 for valve in parameters["closed_valves"]})
|
valve_control=flushing_valve_controls(parameters))
|
||||||
mappings = query_corresponding_element_id_and_query_id(network)
|
mappings = query_corresponding_element_id_and_query_id(network)
|
||||||
controls = parameters["pump_control"]
|
controls = parameters["pump_control"]
|
||||||
if set(controls) - (set(mappings.fixed_pumps) | set(mappings.variable_pumps)):
|
if set(controls) - (set(mappings.fixed_pumps) | set(mappings.variable_pumps)):
|
||||||
|
|||||||
+18
-5
@@ -7,7 +7,12 @@ from app.infra.db.postgresql.read_queries import MAX_QUERY_BYTES, read_rows, sca
|
|||||||
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataQuery
|
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataQuery
|
||||||
from app.services.content_digest import digest
|
from app.services.content_digest import digest
|
||||||
from samples.peripheral.store import PostgresStore
|
from samples.peripheral.store import PostgresStore
|
||||||
from app.infra.integrations.operational_data import _select_demo_observations
|
from app.infra.integrations.operational_data import (
|
||||||
|
TaskDataError,
|
||||||
|
_select_demo_observations,
|
||||||
|
normalize_peripheral_work_orders,
|
||||||
|
peripheral_records_origin,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def read_peripheral_query(scope, args):
|
async def read_peripheral_query(scope, args):
|
||||||
@@ -37,16 +42,24 @@ async def read_peripheral_query(scope, args):
|
|||||||
truncated = len(rows) > args.limit
|
truncated = len(rows) > args.limit
|
||||||
if any(item.get("project_id") != project for item in items):
|
if any(item.get("project_id") != project for item in items):
|
||||||
raise HTTPException(502, "Peripheral project mismatch")
|
raise HTTPException(502, "Peripheral project mismatch")
|
||||||
|
try:
|
||||||
|
if args.source == "peripheral_work_orders":
|
||||||
|
items = normalize_peripheral_work_orders(items)
|
||||||
|
origin = "system_data"
|
||||||
|
else:
|
||||||
|
origin = peripheral_records_origin(items)
|
||||||
|
except TaskDataError as exc:
|
||||||
|
raise HTTPException(exc.status, exc.detail) from exc
|
||||||
data = {
|
data = {
|
||||||
"items": items,
|
"items": items,
|
||||||
"truncated": truncated,
|
"truncated": truncated,
|
||||||
"limit": args.limit,
|
"limit": args.limit,
|
||||||
"offset": args.offset,
|
"offset": args.offset,
|
||||||
}
|
}
|
||||||
origin = (
|
if args.source == "peripheral_test_data":
|
||||||
"synthetic"
|
data.update(
|
||||||
if items and any(item.get("origin") == "synthetic" for item in items)
|
provenance_scope="per_record",
|
||||||
else "system_data"
|
source_origins=sorted({item["origin"] for item in items}),
|
||||||
)
|
)
|
||||||
elif args.source == "peripheral_points":
|
elif args.source == "peripheral_points":
|
||||||
rows = await scada_devices(conn, limit=args.limit + 1, offset=args.offset)
|
rows = await scada_devices(conn, limit=args.limit + 1, offset=args.offset)
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Keep a saved geometry discoverable even when its inline response is shortened."""
|
||||||
|
|
||||||
|
from uuid import NAMESPACE_URL, uuid5
|
||||||
|
|
||||||
|
from app.mcp.models import QueryResult
|
||||||
|
|
||||||
|
|
||||||
|
def saved_burst_result_preview(result: QueryResult, task_id: str) -> dict | None:
|
||||||
|
"""Keep durable full-network results usable without copying node arrays inline."""
|
||||||
|
if result.source != "run_burst_simulation" or not result.evidence_id:
|
||||||
|
return None
|
||||||
|
data = result.data if isinstance(result.data, dict) else {}
|
||||||
|
analysis = data.get("result", {})
|
||||||
|
impact = analysis.get("impact") if isinstance(analysis, dict) else None
|
||||||
|
if not isinstance(impact, dict):
|
||||||
|
return None
|
||||||
|
summary = {key: value for key, value in impact.items()
|
||||||
|
if key not in {"comparisons", "grades", "unavailable_node_ids"}}
|
||||||
|
summary["unavailable_node_count"] = len(impact.get("unavailable_node_ids", []))
|
||||||
|
summary["grades"] = [
|
||||||
|
{**{key: value for key, value in grade.items() if key != "node_ids"},
|
||||||
|
"node_count": len(grade.get("node_ids", []))} for grade in impact.get("grades", [])
|
||||||
|
]
|
||||||
|
preview = {
|
||||||
|
"resource_uri": f"tjwater://tasks/{task_id}/evidence/{result.evidence_id}",
|
||||||
|
"parameters": data.get("parameters", {}), "result": {"impact": summary},
|
||||||
|
}
|
||||||
|
presentation = saved_presentation_preview(result)
|
||||||
|
if presentation is not None:
|
||||||
|
preview["presentation"] = presentation
|
||||||
|
return preview
|
||||||
|
|
||||||
|
|
||||||
|
def saved_presentation_preview(result: QueryResult) -> dict | None:
|
||||||
|
if result.source not in {"generate_node_coverage", "run_burst_simulation", "run_contaminant_simulation"} or not result.evidence_id:
|
||||||
|
return None
|
||||||
|
analysis = result.data.get("result") if isinstance(result.data, dict) else None
|
||||||
|
if not isinstance(analysis, dict):
|
||||||
|
return None
|
||||||
|
payload = analysis.get("map_payload")
|
||||||
|
if not isinstance(payload, dict) or not payload.get("features"):
|
||||||
|
return None
|
||||||
|
coverage = analysis.get("coverage", {})
|
||||||
|
burst = result.source == "run_burst_simulation"
|
||||||
|
contaminant = result.source == "run_contaminant_simulation"
|
||||||
|
if burst and analysis.get("impact", {}).get("phase") != "after_burst_before_intervention":
|
||||||
|
return None
|
||||||
|
if contaminant and analysis.get("impact", {}).get("phase") != "after_contamination_before_intervention":
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"command": {
|
||||||
|
"action": "set_overlay", "mode": "geometry", "evidence_id": result.evidence_id,
|
||||||
|
"overlay_id": f"node-coverage-{uuid5(NAMESPACE_URL, result.evidence_id)}",
|
||||||
|
"title": ("爆管后、处置前的预计影响范围(米水头)" if burst else
|
||||||
|
"水污染传播预计影响范围(处置前情景)" if contaminant else "节点空间覆盖示意"),
|
||||||
|
},
|
||||||
|
"display_only": True,
|
||||||
|
"coverage_status": coverage.get("status"),
|
||||||
|
"groups": {role: {
|
||||||
|
"node_count": len(group.get("node_ids", [])),
|
||||||
|
"mapped_node_count": group.get("mapped_node_count", 0),
|
||||||
|
"missing_node_count": len(group.get("missing_node_ids", [])),
|
||||||
|
} for role, group in coverage.get("groups", {}).items()},
|
||||||
|
}
|
||||||
+172
-6
@@ -12,6 +12,7 @@ from datetime import datetime, timezone
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from fastapi.encoders import jsonable_encoder
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
from app.core.config import settings
|
||||||
from app.auth.permissions import permissions_for_context
|
from app.auth.permissions import permissions_for_context
|
||||||
from app.infra.db.postgresql import read_queries as reads
|
from app.infra.db.postgresql import read_queries as reads
|
||||||
from app.infra.db.postgresql.spatial_queries import nearby_elements
|
from app.infra.db.postgresql.spatial_queries import nearby_elements
|
||||||
@@ -20,6 +21,7 @@ from app.infra.db.postgresql.gis_attributes import GisAttributeRepository
|
|||||||
from app.infra.db.postgresql.network_settings import NetworkSettingsRepository
|
from app.infra.db.postgresql.network_settings import NetworkSettingsRepository
|
||||||
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
|
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
|
||||||
from app.services.operational_tasks import OperationalTaskService
|
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.models import QueryResult, PageInfo
|
||||||
from app.mcp.scenario_service import SCENARIO_TOOLS
|
from app.mcp.scenario_service import SCENARIO_TOOLS
|
||||||
from app.mcp.work_order_skills import read_work_order_skill
|
from app.mcp.work_order_skills import read_work_order_skill
|
||||||
@@ -51,6 +53,7 @@ def task_service(scope: QueryScope) -> OperationalTaskService:
|
|||||||
OperationalTaskRepository(scope.connection, scope.project.project_id),
|
OperationalTaskRepository(scope.connection, scope.project.project_id),
|
||||||
scope.project,
|
scope.project,
|
||||||
set(permissions_for_context(scope.project)),
|
set(permissions_for_context(scope.project)),
|
||||||
|
execution_scope=settings.operational_task_execution_scope,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -66,8 +69,39 @@ def page_result(rows, args):
|
|||||||
async def authorize_query(
|
async def authorize_query(
|
||||||
scope: QueryScope, binding: BoundContext, tool: QueryTool, args: BaseModel
|
scope: QueryScope, binding: BoundContext, tool: QueryTool, args: BaseModel
|
||||||
) -> None:
|
) -> None:
|
||||||
|
replay_context = getattr(binding, "replay_context", None)
|
||||||
if tool.name not in binding.tool_cap:
|
if tool.name not in binding.tool_cap:
|
||||||
raise HTTPException(403, "Query is outside the captured tool catalog")
|
raise HTTPException(403, "Query is outside the captured tool catalog")
|
||||||
|
if replay_context:
|
||||||
|
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", "run_contaminant_source_trace",
|
||||||
|
"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", *SCENARIO_TOOLS,
|
||||||
|
"calculate_demand_allocation", "optimize_sensor_placement",
|
||||||
|
"query_model_baseline_series", "get_model_baseline_at_time", "query_model_baseline_field",
|
||||||
|
"get_calculation_job", "get_calculation_result", "list_calculation_jobs"}
|
||||||
|
asset_query = tool.semantics == "asset" and not tool.mutating and not tool.computation
|
||||||
|
if tool.name not in allowed and not asset_query:
|
||||||
|
raise HTTPException(403, "回放调查仅开放冻结 SCADA、资产、拓扑分析及指定模型情景;时序观测请使用 query_scada_readings")
|
||||||
|
if tool.name in SCENARIO_TOOLS:
|
||||||
|
check_replay_window(replay_context, args.start_time, args.start_time)
|
||||||
|
if tool.semantics == "model_baseline":
|
||||||
|
if tool.name == "query_model_baseline_series":
|
||||||
|
check_replay_window(replay_context, args.start, args.end)
|
||||||
|
else:
|
||||||
|
check_replay_window(replay_context, args.time, args.time)
|
||||||
|
if tool.name == "query_scada_readings":
|
||||||
|
check_replay_window(replay_context, args.start, args.end)
|
||||||
|
if tool.name == "run_contaminant_source_trace":
|
||||||
|
check_replay_window(replay_context, args.start, args.end)
|
||||||
|
if tool.name == "run_burst_location":
|
||||||
|
if args.data_source != "monitoring" or args.scada_normal_start is None or args.scada_normal_end is None:
|
||||||
|
raise HTTPException(422, "回放定位只支持显式异常窗和正常对照窗的 monitoring 输入")
|
||||||
|
check_replay_window(replay_context, args.scada_burst_start, args.scada_burst_end)
|
||||||
|
check_replay_window(replay_context, args.scada_normal_start, args.scada_normal_end)
|
||||||
permissions = permissions_for_context(scope.project)
|
permissions = permissions_for_context(scope.project)
|
||||||
if tool.mutating:
|
if tool.mutating:
|
||||||
if binding.purpose != "ordinary_chat" or binding.task_id is not None or scope.project.project_role != "member":
|
if binding.purpose != "ordinary_chat" or binding.task_id is not None or scope.project.project_role != "member":
|
||||||
@@ -85,6 +119,8 @@ async def authorize_query(
|
|||||||
required = {tool.permission}
|
required = {tool.permission}
|
||||||
if tool.name in SCENARIO_TOOLS:
|
if tool.name in SCENARIO_TOOLS:
|
||||||
required.add("scada.view")
|
required.add("scada.view")
|
||||||
|
if tool.name == "run_burst_simulation" and args.include_impact_coverage:
|
||||||
|
required.add("simulation.view")
|
||||||
if tool.name in {"run_burst_detection", "run_burst_location", "run_leakage_identification"}:
|
if tool.name in {"run_burst_detection", "run_burst_location", "run_leakage_identification"}:
|
||||||
required.add("simulation.view" if getattr(args, "data_source", "monitoring") == "simulation" else "scada.view")
|
required.add("simulation.view" if getattr(args, "data_source", "monitoring") == "simulation" else "scada.view")
|
||||||
if not required.issubset(permissions) or not required.issubset(binding.permission_cap):
|
if not required.issubset(permissions) or not required.issubset(binding.permission_cap):
|
||||||
@@ -109,8 +145,18 @@ async def authorize_query(
|
|||||||
if not granted and binding.task_id and source in binding.task_scope_cap:
|
if not granted and binding.task_id and source in binding.task_scope_cap:
|
||||||
async with scope.connection.transaction():
|
async with scope.connection.transaction():
|
||||||
granted = await task_service(scope).can_read_source(binding.task_id, source)
|
granted = await task_service(scope).can_read_source(binding.task_id, source)
|
||||||
|
if granted and tool.name == "query_scada_readings" and args.device_ids is None:
|
||||||
|
granted = (
|
||||||
|
"scada_devices" in binding.task_scope_cap
|
||||||
|
and await task_service(scope).can_read_source(binding.task_id, "scada_devices")
|
||||||
|
)
|
||||||
if not granted:
|
if not granted:
|
||||||
raise HTTPException(403, "Data source permission is required")
|
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 (
|
if (
|
||||||
tool.name == "query_element_history"
|
tool.name == "query_element_history"
|
||||||
and args.mode != "observed"
|
and args.mode != "observed"
|
||||||
@@ -131,6 +177,9 @@ async def execute_query(
|
|||||||
full_snapshot: bool = False,
|
full_snapshot: bool = False,
|
||||||
) -> QueryResult:
|
) -> QueryResult:
|
||||||
await authorize_query(scope, binding, tool, args)
|
await authorize_query(scope, binding, tool, args)
|
||||||
|
if tool.name in {"get_calculation_job", "get_calculation_result", "cancel_calculation_job", "list_calculation_jobs"}:
|
||||||
|
from app.mcp.calculation_jobs import read_calculation_job
|
||||||
|
return await read_calculation_job(scope, binding, tool, args)
|
||||||
if tool.mutating:
|
if tool.mutating:
|
||||||
from app.mcp.mutation_service import execute_mutation
|
from app.mcp.mutation_service import execute_mutation
|
||||||
return await execute_mutation(scope, binding, tool, args)
|
return await execute_mutation(scope, binding, tool, args)
|
||||||
@@ -146,7 +195,98 @@ async def execute_query(
|
|||||||
gaps = []
|
gaps = []
|
||||||
source_ref = "mcp:" + str(uuid4())
|
source_ref = "mcp:" + str(uuid4())
|
||||||
evidence_id = None
|
evidence_id = None
|
||||||
if tool.computation:
|
replay_context = getattr(binding, "replay_context", None)
|
||||||
|
if name == "run_contaminant_source_trace":
|
||||||
|
from app.services.contaminant_source_trace import trace_contaminant_source
|
||||||
|
|
||||||
|
data = {"parameters": args.model_dump(mode="json", exclude={"context_handle"}),
|
||||||
|
"result": await trace_contaminant_source(conn, args)}
|
||||||
|
origin = "synthetic"
|
||||||
|
if data["result"]["located_node"] is None:
|
||||||
|
gaps.append(data["result"]["notice"])
|
||||||
|
elif name == "query_scada_readings" and args.device_ids is None:
|
||||||
|
from app.mcp.scada_readings import read_complete_scada
|
||||||
|
data, units, gaps, origin = await read_complete_scada(scope, binding, args)
|
||||||
|
elif name == "query_scada_readings" and replay_context:
|
||||||
|
from app.services.scada_replay_queries import replay_snapshot_rows
|
||||||
|
rows = await replay_snapshot_rows(conn, scope.project.project_id, replay_context,
|
||||||
|
args.device_ids, args.start, args.end, args.limit + 1, args.offset)
|
||||||
|
data, page, truncated = page_result(rows, args)
|
||||||
|
origin = "synthetic"
|
||||||
|
elif name in {"run_simulation", "analyze_valve_closure", "analyze_pressure_regulation"} and replay_context:
|
||||||
|
from app.mcp.analysis_service import calculate_replay_hydraulic_scenario
|
||||||
|
|
||||||
|
await conn.commit()
|
||||||
|
async with scope.simulation_routing():
|
||||||
|
result = await calculate_replay_hydraulic_scenario(
|
||||||
|
name, args, network=scope.project.project_code, username=scope.user.username,
|
||||||
|
)
|
||||||
|
data = {"parameters": args.model_dump(mode="json", exclude={"context_handle"}), "result": result}
|
||||||
|
origin = "synthetic"
|
||||||
|
gaps = ["回放水力计算使用当前模型,不读取实时SCADA,不代表冻结观测或历史模型重建。在线模拟更新模型基线,关阀及调压仅评估隔离情景;未来模拟样本仅为预测。"]
|
||||||
|
elif name == "run_burst_simulation" and replay_context:
|
||||||
|
from app.mcp.analysis_service import calculate_replay_burst_simulation
|
||||||
|
|
||||||
|
await conn.commit()
|
||||||
|
async with scope.simulation_routing():
|
||||||
|
result = await calculate_replay_burst_simulation(
|
||||||
|
args, network=scope.project.project_code, username=scope.user.username,
|
||||||
|
)
|
||||||
|
data = {"parameters": args.model_dump(mode="json", exclude={"context_handle"}), "result": result}
|
||||||
|
origin = "synthetic"
|
||||||
|
gaps = ["回放爆管影响为当前模型的独立假设情景,不读取实时SCADA;同刻在线模拟为计算时读取的模型参考,非冻结观测或历史模型重建。未来模拟样本仅为预测。"]
|
||||||
|
elif name == "run_contaminant_simulation" and replay_context:
|
||||||
|
from app.mcp.analysis_service import calculate_replay_contaminant_simulation
|
||||||
|
|
||||||
|
await conn.commit()
|
||||||
|
async with scope.simulation_routing():
|
||||||
|
result = await calculate_replay_contaminant_simulation(
|
||||||
|
args, network=scope.project.project_code, username=scope.user.username,
|
||||||
|
)
|
||||||
|
data = {"parameters": args.model_dump(mode="json", exclude={"context_handle"}), "result": result}
|
||||||
|
origin = "synthetic"
|
||||||
|
gaps = ["回放污染传播为当前模型的独立假设情景,不读取实时SCADA,不代表冻结观测或历史模型重建。未来模拟样本仅为预测;浓度阈值仅用于情景展示,不是饮用水安全限值。"]
|
||||||
|
elif name == "analyze_flushing" and replay_context:
|
||||||
|
from app.mcp.analysis_service import calculate_replay_flushing
|
||||||
|
|
||||||
|
await conn.commit()
|
||||||
|
async with scope.simulation_routing():
|
||||||
|
result = await calculate_replay_flushing(
|
||||||
|
args, network=scope.project.project_code, username=scope.user.username,
|
||||||
|
)
|
||||||
|
data = {"parameters": args.model_dump(mode="json", exclude={"context_handle"}), "result": result}
|
||||||
|
origin = "synthetic"
|
||||||
|
gaps = ["回放冲洗为当前模型的独立水力情景,不读取实时SCADA,不继承上次模拟;未来模拟样本仅为预测,不验证污染清除、水质合格或现场操作。"]
|
||||||
|
elif name == "run_burst_location" and replay_context:
|
||||||
|
from app.mcp.analysis_service import calculate_replay_burst_location
|
||||||
|
from app.services.scada_replay_queries import replay_burst_location_inputs
|
||||||
|
|
||||||
|
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)
|
||||||
|
await conn.commit()
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
origin = "synthetic"
|
||||||
|
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
|
from app.mcp.analysis_service import TIMESCALE_CALCULATIONS, calculate
|
||||||
|
|
||||||
# Context/receipt reads may have started an implicit transaction. Release
|
# Context/receipt reads may have started an implicit transaction. Release
|
||||||
@@ -167,7 +307,7 @@ async def execute_query(
|
|||||||
}
|
}
|
||||||
gaps = [{
|
gaps = [{
|
||||||
"run_simulation": "Online hydraulic recalculation updated the realtime baseline; results cover requested elements only, not observed SCADA data. No standalone analysis run was saved.",
|
"run_simulation": "Online hydraulic recalculation updated the realtime baseline; results cover requested elements only, not observed SCADA data. No standalone analysis run was saved.",
|
||||||
"run_contaminant_simulation": "Forward model simulation for an assumed source, not source inversion or observed contamination; only requested elements are returned.",
|
"run_contaminant_simulation": "Forward model simulation for an assumed source, not source inversion or observed contamination. Selected series only; opt-in impact coverage uses all native nodes over the simulation window, with an explicit display threshold and existing model initial quality/reactions, not a drinking-water safety limit.",
|
||||||
"analyze_valve_closure": "Simulated hydraulic impact for requested elements only; no physical valves were operated.",
|
"analyze_valve_closure": "Simulated hydraulic impact for requested elements only; no physical valves were operated.",
|
||||||
"analyze_flushing": "Simulated flushing hydraulics only; no field action or water-quality clearance was verified.",
|
"analyze_flushing": "Simulated flushing hydraulics only; no field action or water-quality clearance was verified.",
|
||||||
"analyze_pressure_regulation": "Evaluation of a supplied pump schedule, not automatic optimization or device control; requested elements only.",
|
"analyze_pressure_regulation": "Evaluation of a supplied pump schedule, not automatic optimization or device control; requested elements only.",
|
||||||
@@ -176,8 +316,9 @@ async def execute_query(
|
|||||||
"run_burst_detection": "Pressure anomalies alone do not confirm a burst or locate a pipe.",
|
"run_burst_detection": "Pressure anomalies alone do not confirm a burst or locate a pipe.",
|
||||||
"run_burst_location": "Candidate ranking depends on the leakage assumption, model and sensor coverage; field confirmation is required.",
|
"run_burst_location": "Candidate ranking depends on the leakage assumption, model and sensor coverage; field confirmation is required.",
|
||||||
"analyze_valve_isolation": "Topology-only isolation advice; valve operability, hydraulic impact and field actions are not verified or executed.",
|
"analyze_valve_isolation": "Topology-only isolation advice; valve operability, hydraulic impact and field actions are not verified or executed.",
|
||||||
|
"generate_node_coverage": "Derived node coverage for display only; geometry does not establish a fault, contamination, outage or service boundary. Missing GIS positions are reported separately.",
|
||||||
"calculate_demand_allocation": "Pipe-length-weighted allocation only; model demands were not modified. Units follow the current model.",
|
"calculate_demand_allocation": "Pipe-length-weighted allocation only; model demands were not modified. Units follow the current model.",
|
||||||
"run_burst_simulation": "Forward hydraulic simulation for assumed orifice areas, not detection or confirmed burst location; only requested elements returned.",
|
"run_burst_simulation": "Forward hydraulic simulation for assumed orifice areas; selected series only. Opt-in impact coverage compares all native nodes at the start time with existing online results, using current model settings for baseline units. This is an estimated pre-intervention range, not a confirmed outage or historical model reconstruction.",
|
||||||
}[name]]
|
}[name]]
|
||||||
if name == "calculate_demand_allocation":
|
if name == "calculate_demand_allocation":
|
||||||
truncated = data["result"]["has_more"] or args.offset > 0
|
truncated = data["result"]["has_more"] or args.offset > 0
|
||||||
@@ -319,6 +460,9 @@ async def execute_query(
|
|||||||
if hasattr(args, "field") and args.field not in units:
|
if hasattr(args, "field") and args.field not in units:
|
||||||
gaps.append(f"Unit metadata is unavailable for field {args.field}")
|
gaps.append(f"Unit metadata is unavailable for field {args.field}")
|
||||||
else:
|
else:
|
||||||
|
# Keep the selected field in durable evidence; `value` alone cannot
|
||||||
|
# distinguish raw observations from cleaned observations.
|
||||||
|
data = [{**row, args.field: row["value"]} if "value" in row else row for row in data]
|
||||||
metadata = [
|
metadata = [
|
||||||
r
|
r
|
||||||
for device in ids
|
for device in ids
|
||||||
@@ -400,7 +544,8 @@ async def execute_query(
|
|||||||
data = await reads.task_context_summary(conn, project_id=binding.project_id,
|
data = await reads.task_context_summary(conn, project_id=binding.project_id,
|
||||||
task_id=binding.task_id, report_revision=args.report_revision)
|
task_id=binding.task_id, report_revision=args.report_revision)
|
||||||
skill = read_work_order_skill(
|
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":
|
if args.section == "skill":
|
||||||
data = {"section": "skill", "skill": skill}
|
data = {"section": "skill", "skill": skill}
|
||||||
@@ -409,14 +554,33 @@ async def execute_query(
|
|||||||
data["display_time_zone"] = "Asia/Shanghai"
|
data["display_time_zone"] = "Asia/Shanghai"
|
||||||
data["approved_data_scopes"] = binding.task_scope_cap
|
data["approved_data_scopes"] = binding.task_scope_cap
|
||||||
data["bound_run_id"] = str(binding.run_id) if binding.run_id else None
|
data["bound_run_id"] = str(binding.run_id) if binding.run_id else None
|
||||||
data["available_sections"] = ["task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill"]
|
data["available_sections"] = ["task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill", "questions"]
|
||||||
truncated = True
|
truncated = True
|
||||||
gaps.append("Task context is a bounded summary; use section, data_path, limit and offset for detail. Previews are not complete reports.")
|
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") == "water-pollution-event":
|
||||||
|
data["source_trace_capability"] = {
|
||||||
|
"available": "run_contaminant_source_trace" in binding.tool_cap,
|
||||||
|
"tool": "run_contaminant_source_trace",
|
||||||
|
"algorithm_status": "provisional",
|
||||||
|
"required_inputs": ["candidate_node_ids", "start", "end"],
|
||||||
|
"next_steps": ["present_node", "interim_report", "propose_task_action", "run_contaminant_simulation",
|
||||||
|
"source_control", "analyze_valve_isolation", "generate_node_coverage",
|
||||||
|
"analyze_flushing", "field_feedback", "clearance_verification", "report"],
|
||||||
|
"work_order_wait_blocks_analysis": False,
|
||||||
|
"simulation_parameters": {"include_impact_coverage": True},
|
||||||
|
}
|
||||||
|
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_capability"] = location_demo_context(
|
||||||
|
getattr(scope.project, "project_code", None), replay_context,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
projection = await reads.task_context_page(conn, project_id=binding.project_id, task_id=binding.task_id,
|
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)
|
section=args.section, report_revision=args.report_revision, data_path=args.data_path, limit=args.limit, offset=args.offset)
|
||||||
total = projection["total"]
|
total = projection["total"]
|
||||||
if args.section in {"evidence", "actions", "access_requests"} and not args.data_path:
|
if args.section in {"evidence", "actions", "access_requests", "questions"} and not args.data_path:
|
||||||
total = projection["snapshot_metadata"].get("total", total)
|
total = projection["snapshot_metadata"].get("total", total)
|
||||||
if projection["value_type"] in {"array", "object", "string"}:
|
if projection["value_type"] in {"array", "object", "string"}:
|
||||||
page = PageInfo(limit=args.limit, offset=args.offset, total=total, has_more=args.offset + args.limit < total)
|
page = PageInfo(limit=args.limit, offset=args.offset, total=total, has_more=args.offset + args.limit < total)
|
||||||
@@ -479,6 +643,8 @@ async def execute_query(
|
|||||||
truncated = snapshot.truncated
|
truncated = snapshot.truncated
|
||||||
else:
|
else:
|
||||||
raise ValueError("query handler is not registered")
|
raise ValueError("query handler is not registered")
|
||||||
|
if replay_context and tool.semantics == "model_baseline":
|
||||||
|
gaps.append("在线模型参考按本次查询时可用数据读取,非冻结SCADA观测;历史报告复用已保存证据。")
|
||||||
window = (
|
window = (
|
||||||
{"start": args.start, "end": args.end}
|
{"start": args.start, "end": args.end}
|
||||||
if hasattr(args, "start") and args.start is not None
|
if hasattr(args, "start") and args.start is not None
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""Complete project SCADA reads with explicit frozen-scope and missing-data accounting."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
|
||||||
|
from app.mcp.models import BoundContext, ScadaInput
|
||||||
|
from app.mcp.scope import QueryScope
|
||||||
|
from app.infra.db.postgresql import read_queries as reads
|
||||||
|
from app.infra.db.postgresql.scada_replays import ScadaReplayRepository
|
||||||
|
from app.services.scada_replay_queries import check_replay_window, check_snapshot_start
|
||||||
|
|
||||||
|
|
||||||
|
MAX_COMPLETE_SCADA_BYTES = 8 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
async def read_complete_scada(
|
||||||
|
scope: QueryScope, binding: BoundContext, args: ScadaInput,
|
||||||
|
) -> tuple[dict, dict, list[str], str]:
|
||||||
|
"""Exhaust source pages; never treat a first page or selected device subset as a full scan."""
|
||||||
|
devices = []
|
||||||
|
offset = 0
|
||||||
|
size = 0
|
||||||
|
|
||||||
|
def account(items):
|
||||||
|
nonlocal size
|
||||||
|
size += len(json.dumps(jsonable_encoder(items), ensure_ascii=False).encode())
|
||||||
|
if size > MAX_COMPLETE_SCADA_BYTES:
|
||||||
|
raise HTTPException(413, "Complete SCADA result exceeds 8 MiB; split the time window and retain every device")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
page = await reads.scada_devices(scope.connection, limit=501, offset=offset)
|
||||||
|
batch = page[:500]
|
||||||
|
account(batch)
|
||||||
|
devices.extend(batch)
|
||||||
|
if len(page) <= 500:
|
||||||
|
break
|
||||||
|
offset += len(batch)
|
||||||
|
|
||||||
|
project_device_count = len(devices)
|
||||||
|
context = getattr(binding, "replay_context", None)
|
||||||
|
catalog = {str(device["device_id"]): device for device in devices}
|
||||||
|
outside_scope = []
|
||||||
|
if context:
|
||||||
|
check_replay_window(context, args.start, args.end)
|
||||||
|
repository = ScadaReplayRepository(scope.connection, scope.project.project_id)
|
||||||
|
run = await repository.get(context["replay_id"])
|
||||||
|
if not run:
|
||||||
|
raise HTTPException(404, "SCADA replay was not found in the current project")
|
||||||
|
check_snapshot_start(run, args.start)
|
||||||
|
frozen = {str(device["device_id"]): device for device in run.get("devices", [])}
|
||||||
|
selected = list(dict.fromkeys(run["config"]["device_ids"]))
|
||||||
|
outside_scope = sorted(set(catalog) - set(selected))
|
||||||
|
# Retain devices removed from the current catalogue but present in the frozen source.
|
||||||
|
for device in selected:
|
||||||
|
if device not in catalog:
|
||||||
|
catalog[device] = frozen.get(device, {"device_id": device})
|
||||||
|
devices = list(catalog.values())
|
||||||
|
else:
|
||||||
|
selected = list(catalog)
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
offset = 0
|
||||||
|
# The public limit controls source page size, not completeness of this operation.
|
||||||
|
page_size = min(max(args.limit, 1000), 10000)
|
||||||
|
|
||||||
|
async def read_pages(connection=None):
|
||||||
|
nonlocal offset
|
||||||
|
while selected:
|
||||||
|
if context:
|
||||||
|
page = await repository.detected_outputs(
|
||||||
|
context["replay_id"], selected, args.start, args.end, context["batch_sequence"],
|
||||||
|
limit=page_size + 1, offset=offset,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
page = await reads.series_rows(
|
||||||
|
connection, source="scada", element_type="junction", ids=selected,
|
||||||
|
start=args.start, end=args.end, field=None,
|
||||||
|
limit=page_size + 1, offset=offset,
|
||||||
|
)
|
||||||
|
batch = page[:page_size]
|
||||||
|
if context:
|
||||||
|
# Match the existing frozen-reading contract; internal source hashes are not observations.
|
||||||
|
fields = ("time", "device_id", "monitored_value", "cleaned_value", "quality", "reason")
|
||||||
|
batch = [{key: row[key] for key in fields if key in row} for row in batch]
|
||||||
|
account(batch)
|
||||||
|
rows.extend(batch)
|
||||||
|
if len(page) <= page_size:
|
||||||
|
break
|
||||||
|
offset += len(batch)
|
||||||
|
|
||||||
|
if context:
|
||||||
|
await read_pages()
|
||||||
|
elif selected:
|
||||||
|
async with scope.timescale() as connection:
|
||||||
|
await read_pages(connection)
|
||||||
|
|
||||||
|
counts = Counter(str(row["device_id"]) for row in rows)
|
||||||
|
missing = sorted(set(selected) - counts.keys())
|
||||||
|
coverage = {
|
||||||
|
"mode": "all_scada", "query_complete": True,
|
||||||
|
"project_device_count": project_device_count, "queried_device_count": len(selected),
|
||||||
|
"devices_with_readings": len(counts), "reading_count": len(rows),
|
||||||
|
"queried_device_ids": selected, "missing_device_ids": missing,
|
||||||
|
"outside_frozen_scope_device_ids": outside_scope,
|
||||||
|
"readings_per_device": {device: counts[device] for device in selected},
|
||||||
|
"replay_id": context["replay_id"] if context else None,
|
||||||
|
"batch_sequence": context["batch_sequence"] if context else None,
|
||||||
|
}
|
||||||
|
if context:
|
||||||
|
coverage["frozen_start"] = (run.get("baseline") or {}).get("start_time") or run["start_time"]
|
||||||
|
data = {"devices": devices, "readings": rows, "coverage": coverage}
|
||||||
|
account([coverage])
|
||||||
|
gaps = []
|
||||||
|
if missing:
|
||||||
|
gaps.append(f"{len(missing)} queried SCADA devices have no readings in this window; see coverage.missing_device_ids")
|
||||||
|
if outside_scope:
|
||||||
|
gaps.append(f"{len(outside_scope)} project devices are outside this frozen replay; see coverage.outside_frozen_scope_device_ids")
|
||||||
|
units = {device: catalog[device]["measurement_unit"] for device in selected
|
||||||
|
if catalog[device].get("measurement_unit")}
|
||||||
|
return data, units, gaps, "synthetic" if context else "system_data"
|
||||||
@@ -10,7 +10,14 @@ SCENARIO_TOOLS = frozenset({
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, username: str) -> dict[str, Any]:
|
def flushing_valve_controls(parameters: dict[str, Any]) -> dict[str, dict[str, str]]:
|
||||||
|
"""Apply explicit states without changing regulating-valve settings."""
|
||||||
|
return {**{valve: {"status": "CLOSED"} for valve in parameters.get("closed_valves", [])},
|
||||||
|
**{valve: {"status": "OPEN"} for valve in parameters.get("opened_valves", [])}}
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, username: str,
|
||||||
|
model_only: bool = False) -> dict[str, Any]:
|
||||||
from app.services import simulation_scenarios as scenarios
|
from app.services import simulation_scenarios as scenarios
|
||||||
|
|
||||||
parameters = dict(parameters)
|
parameters = dict(parameters)
|
||||||
@@ -18,23 +25,34 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u
|
|||||||
links = parameters.pop("output_link_ids")
|
links = parameters.pop("output_link_ids")
|
||||||
start = parameters.pop("start_time")
|
start = parameters.pop("start_time")
|
||||||
duration = parameters.pop("duration")
|
duration = parameters.pop("duration")
|
||||||
|
include_impact = parameters.pop("include_impact_coverage", False)
|
||||||
|
impact_threshold = parameters.pop("impact_threshold_mg_l", 0)
|
||||||
common = dict(name=network, modify_pattern_start_time=start.isoformat(),
|
common = dict(name=network, modify_pattern_start_time=start.isoformat(),
|
||||||
modify_total_duration=duration, persist_results=False, scheme_name=None)
|
modify_total_duration=duration, persist_results=False, scheme_name=None)
|
||||||
|
boundary = {}
|
||||||
|
if model_only:
|
||||||
|
from app.infra.db.postgresql.scada import ScadaElementMappings
|
||||||
|
# Model scenarios may support replay decisions without consuming mutable
|
||||||
|
# live SCADA in place of the task's frozen observations.
|
||||||
|
boundary["scada_mappings"] = ScadaElementMappings(
|
||||||
|
reservoirs={}, tanks={}, fixed_pumps={}, variable_pumps={},
|
||||||
|
pressure={}, demand={}, quality={},
|
||||||
|
)
|
||||||
if name == "run_simulation":
|
if name == "run_simulation":
|
||||||
from app.services.simulation import run_online_simulation
|
from app.services.simulation import run_online_simulation
|
||||||
|
|
||||||
result = run_online_simulation(
|
result = run_online_simulation(
|
||||||
network, start, duration, output_node_ids=nodes, output_link_ids=links,
|
network, start, duration, output_node_ids=nodes, output_link_ids=links, **boundary,
|
||||||
)
|
)
|
||||||
elif name == "run_burst_simulation":
|
elif name == "run_burst_simulation":
|
||||||
result = scenarios.burst_analysis(**common, username=username,
|
result = scenarios.burst_analysis(**common, username=username,
|
||||||
burst_ID=parameters["burst_pipe_ids"], burst_size=parameters["orifice_areas_cm2"])
|
burst_ID=parameters["burst_pipe_ids"], burst_size=parameters["orifice_areas_cm2"], **boundary)
|
||||||
elif name == "run_contaminant_simulation":
|
elif name == "run_contaminant_simulation":
|
||||||
result = scenarios.contaminant_simulation(**common, username=username,
|
result = scenarios.contaminant_simulation(**common, username=username,
|
||||||
source=parameters["source"], concentration=parameters["concentration"], source_pattern=parameters.get("pattern"))
|
source=parameters["source"], concentration=parameters["concentration"], source_pattern=parameters.get("pattern"), **boundary)
|
||||||
elif name == "analyze_valve_closure":
|
elif name == "analyze_valve_closure":
|
||||||
result = scenarios.valve_close_analysis(**common,
|
result = scenarios.valve_close_analysis(**common,
|
||||||
modify_valve_opening={valve: 0.0 for valve in parameters["valves"]})
|
modify_valve_opening={valve: 0.0 for valve in parameters["valves"]}, **boundary)
|
||||||
elif name == "analyze_flushing":
|
elif name == "analyze_flushing":
|
||||||
# The service consumes m³/h (the older REST label incorrectly says L/s).
|
# The service consumes m³/h (the older REST label incorrectly says L/s).
|
||||||
# Reject unsupported conversion rather than silently ignore the flow.
|
# Reject unsupported conversion rather than silently ignore the flow.
|
||||||
@@ -43,7 +61,7 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u
|
|||||||
raise ValueError("Flushing currently supports LPS or CMH models only")
|
raise ValueError("Flushing currently supports LPS or CMH models only")
|
||||||
result = scenarios.flushing_analysis(**common, username=username,
|
result = scenarios.flushing_analysis(**common, username=username,
|
||||||
drainage_node_ID=parameters["drainage_node_id"], flushing_flow=parameters["flushing_flow_m3h"],
|
drainage_node_ID=parameters["drainage_node_id"], flushing_flow=parameters["flushing_flow_m3h"],
|
||||||
modify_valve_opening={valve: 0.0 for valve in parameters["closed_valves"]})
|
valve_control=flushing_valve_controls(parameters), **boundary)
|
||||||
elif name == "analyze_pressure_regulation":
|
elif name == "analyze_pressure_regulation":
|
||||||
from app.services.simulation import query_corresponding_element_id_and_query_id
|
from app.services.simulation import query_corresponding_element_id_and_query_id
|
||||||
mappings = query_corresponding_element_id_and_query_id(network)
|
mappings = query_corresponding_element_id_and_query_id(network)
|
||||||
@@ -55,14 +73,15 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u
|
|||||||
result = scenarios.pressure_regulation(**common,
|
result = scenarios.pressure_regulation(**common,
|
||||||
modify_fixed_pump_pattern={key: value for key, value in controls.items() if key in mappings.fixed_pumps},
|
modify_fixed_pump_pattern={key: value for key, value in controls.items() if key in mappings.fixed_pumps},
|
||||||
modify_variable_pump_pattern={key: value for key, value in controls.items() if key in mappings.variable_pumps},
|
modify_variable_pump_pattern={key: value for key, value in controls.items() if key in mappings.variable_pumps},
|
||||||
modify_tank_initial_level=parameters["tank_init_level"], scada_mappings=mappings)
|
modify_tank_initial_level=parameters["tank_init_level"],
|
||||||
|
scada_mappings=boundary.get("scada_mappings", mappings))
|
||||||
else:
|
else:
|
||||||
raise ValueError("Scenario is not registered")
|
raise ValueError("Scenario is not registered")
|
||||||
if not isinstance(result, dict):
|
if not isinstance(result, dict):
|
||||||
raise ValueError("Scenario did not return calculation results")
|
raise ValueError("Scenario did not return calculation results")
|
||||||
selected_nodes = [row for row in result.get("node_results", []) if str(row.get("node")) in nodes]
|
selected_nodes = [row for row in result.get("node_results", []) if str(row.get("node")) in nodes]
|
||||||
selected_links = [row for row in result.get("link_results", []) if str(row.get("link")) in links]
|
selected_links = [row for row in result.get("link_results", []) if str(row.get("link")) in links]
|
||||||
return {
|
output = {
|
||||||
"input_source": "online_model_simulation" if name == "run_simulation" else "isolated_model_simulation",
|
"input_source": "online_model_simulation" if name == "run_simulation" else "isolated_model_simulation",
|
||||||
"persisted_baseline": name == "run_simulation", "persisted_scheme": False,
|
"persisted_baseline": name == "run_simulation", "persisted_scheme": False,
|
||||||
"start_time": start.isoformat(), "duration_seconds": duration,
|
"start_time": start.isoformat(), "duration_seconds": duration,
|
||||||
@@ -72,3 +91,28 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u
|
|||||||
"missing_nodes": sorted(set(nodes) - {str(row["node"]) for row in selected_nodes}),
|
"missing_nodes": sorted(set(nodes) - {str(row["node"]) for row in selected_nodes}),
|
||||||
"missing_links": sorted(set(links) - {str(row["link"]) for row in selected_links})},
|
"missing_links": sorted(set(links) - {str(row["link"]) for row in selected_links})},
|
||||||
}
|
}
|
||||||
|
if name == "run_burst_simulation" and include_impact:
|
||||||
|
from app.services.burst_impact import estimate_burst_impact
|
||||||
|
impact = estimate_burst_impact(network, start, result, model_only=model_only)
|
||||||
|
impact["coverage"] = {**output["coverage"], **impact.get("coverage", {})}
|
||||||
|
output.update(impact)
|
||||||
|
if name == "run_contaminant_simulation" and include_impact:
|
||||||
|
from app.services.contaminant_impact import estimate_contaminant_impact
|
||||||
|
impact = estimate_contaminant_impact(network, start, duration, result,
|
||||||
|
threshold_mg_l=impact_threshold, model_only=model_only)
|
||||||
|
impact["coverage"] = {**output["coverage"], **impact.get("coverage", {})}
|
||||||
|
output.update(impact)
|
||||||
|
if name == "analyze_flushing":
|
||||||
|
output["flushing_plan"] = {
|
||||||
|
"opened_valves": parameters.get("opened_valves", []),
|
||||||
|
"closed_valves": parameters.get("closed_valves", []),
|
||||||
|
"drainage_node_id": parameters["drainage_node_id"],
|
||||||
|
"flushing_flow_m3h": parameters["flushing_flow_m3h"],
|
||||||
|
"estimated_discharge_volume_m3": parameters["flushing_flow_m3h"] * duration / 3600,
|
||||||
|
"state_basis": "independent_current_model_scenario",
|
||||||
|
"boundary_source": "model_only_no_live_scada" if model_only else "model_with_start_time_scada",
|
||||||
|
"result_scope": "hydraulic_only",
|
||||||
|
"field_execution_verified": False,
|
||||||
|
"water_quality_clearance_verified": False,
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
|||||||
+12
-2
@@ -3,6 +3,8 @@
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, AsyncIterator
|
from typing import Any, AsyncIterator
|
||||||
|
from uuid import UUID
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from psycopg import AsyncConnection
|
from psycopg import AsyncConnection
|
||||||
from app.auth.project_dependencies import ProjectContext
|
from app.auth.project_dependencies import ProjectContext
|
||||||
from app.infra.db.project_routing import ActiveProjectRouting
|
from app.infra.db.project_routing import ActiveProjectRouting
|
||||||
@@ -18,16 +20,24 @@ from app.auth.project_dependencies import (
|
|||||||
get_project_timescale_connection,
|
get_project_timescale_connection,
|
||||||
)
|
)
|
||||||
from app.infra.db.metadb.database import get_metadata_session
|
from app.infra.db.metadb.database import get_metadata_session
|
||||||
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
|
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository, ProjectDbRouting
|
||||||
from app.infra.db.project_routing import activate_project_routing
|
from app.infra.db.project_routing import activate_project_routing
|
||||||
|
|
||||||
|
|
||||||
class QueryMetadataRepository(MetadataRepository):
|
class QueryMetadataRepository(MetadataRepository):
|
||||||
async def get_project_db_routing(self, project_id, role):
|
def __init__(self, session: AsyncSession):
|
||||||
|
super().__init__(session)
|
||||||
|
self._routing: dict[tuple[UUID, str], ProjectDbRouting | None] = {}
|
||||||
|
|
||||||
|
async def get_project_db_routing(self, project_id: UUID, role: str) -> ProjectDbRouting | None:
|
||||||
|
key = (project_id, role)
|
||||||
|
if key in self._routing:
|
||||||
|
return self._routing[key]
|
||||||
routing = await super().get_project_db_routing(project_id, role)
|
routing = await super().get_project_db_routing(project_id, role)
|
||||||
# Existing connection generators obtain routing before awaiting a pool.
|
# Existing connection generators obtain routing before awaiting a pool.
|
||||||
# End that implicit SELECT transaction here, before the network wait.
|
# End that implicit SELECT transaction here, before the network wait.
|
||||||
await self.session.commit()
|
await self.session.commit()
|
||||||
|
self._routing[key] = routing
|
||||||
return routing
|
return routing
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,15 +9,25 @@ import yaml
|
|||||||
|
|
||||||
SKILL_ROOT = Path(__file__).resolve().parents[2] / "resources" / "work-order-skills"
|
SKILL_ROOT = Path(__file__).resolve().parents[2] / "resources" / "work-order-skills"
|
||||||
WORKFLOW_SKILLS = {
|
WORKFLOW_SKILLS = {
|
||||||
"burst_report": "burst-report",
|
"scada_anomaly": "scada-anomaly",
|
||||||
|
"water_pollution": "water-pollution-event",
|
||||||
|
"burst_report": "burst-event-handling",
|
||||||
"water_quality_complaint": "water-quality-complaint",
|
"water_quality_complaint": "water-quality-complaint",
|
||||||
"general": "appointment-follow-up",
|
"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."""
|
"""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:
|
if name is None:
|
||||||
return {"available": False, "workflow": workflow, "reason": "No business skill is configured for this workflow"}
|
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")
|
content = (SKILL_ROOT / name / "SKILL.md").read_text(encoding="utf-8")
|
||||||
|
|||||||
@@ -200,10 +200,24 @@ def temporary_project_name(project: str, purpose: str) -> str:
|
|||||||
@contextmanager
|
@contextmanager
|
||||||
def temporary_project_database(project: str, purpose: str):
|
def temporary_project_database(project: str, purpose: str):
|
||||||
"""Clone one project's runnable model into an isolated temporary database."""
|
"""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)
|
temporary_name = temporary_project_name(project, purpose)
|
||||||
|
from app.services.calculation_cleanup import record_calculation_database
|
||||||
|
record_calculation_database(temporary_name)
|
||||||
try:
|
try:
|
||||||
copy_project(
|
copy_project(
|
||||||
get_project_template_database_name(project),
|
template,
|
||||||
temporary_name,
|
temporary_name,
|
||||||
allow_template_source=True,
|
allow_template_source=True,
|
||||||
)
|
)
|
||||||
@@ -211,12 +225,15 @@ def temporary_project_database(project: str, purpose: str):
|
|||||||
finally:
|
finally:
|
||||||
if have_project(temporary_name):
|
if have_project(temporary_name):
|
||||||
delete_project(temporary_name)
|
delete_project(temporary_name)
|
||||||
|
record_calculation_database(temporary_name, removed=True)
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def temporary_template_database(name_hint: str, purpose: str):
|
def temporary_template_database(name_hint: str, purpose: str):
|
||||||
"""Create an empty schema-only temporary database from the fixed template."""
|
"""Create an empty schema-only temporary database from the fixed template."""
|
||||||
temporary_name = temporary_project_name(name_hint, purpose)
|
temporary_name = temporary_project_name(name_hint, purpose)
|
||||||
|
from app.services.calculation_cleanup import record_calculation_database
|
||||||
|
record_calculation_database(temporary_name)
|
||||||
try:
|
try:
|
||||||
copy_project(
|
copy_project(
|
||||||
get_schema_template_database_name(),
|
get_schema_template_database_name(),
|
||||||
@@ -227,6 +244,7 @@ def temporary_template_database(name_hint: str, purpose: str):
|
|||||||
finally:
|
finally:
|
||||||
if have_project(temporary_name):
|
if have_project(temporary_name):
|
||||||
delete_project(temporary_name)
|
delete_project(temporary_name)
|
||||||
|
record_calculation_database(temporary_name, removed=True)
|
||||||
|
|
||||||
|
|
||||||
def have_project(name: str) -> bool:
|
def have_project(name: str) -> bool:
|
||||||
|
|||||||
@@ -1,10 +1,39 @@
|
|||||||
"""Read-only projections backed by the GIS materialized-view query layer."""
|
"""Read-only GIS projections and authoritative network topology snapshots."""
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from ..core.database import read, read_all
|
from ..core.database import read, read_all
|
||||||
|
|
||||||
|
|
||||||
|
def get_node_geographic_positions(name: str, node_ids: list[str]) -> list[Any]:
|
||||||
|
"""Resolve only the selected project's stored node geometries to WGS84."""
|
||||||
|
if not node_ids:
|
||||||
|
return []
|
||||||
|
return read_all(name, """
|
||||||
|
SELECT node_id AS id,
|
||||||
|
ST_X(ST_Transform(geom,4326)) AS longitude,
|
||||||
|
ST_Y(ST_Transform(geom,4326)) AS latitude
|
||||||
|
FROM gis.node_geometries
|
||||||
|
WHERE node_id = ANY(%s) AND geom IS NOT NULL AND NOT ST_IsEmpty(geom)
|
||||||
|
ORDER BY node_id
|
||||||
|
""", (node_ids,))
|
||||||
|
|
||||||
|
|
||||||
|
def get_node_coverage_links(name: str, node_ids: list[str]) -> list[Any]:
|
||||||
|
"""Read the selected subgraph and initial statuses without changing the model."""
|
||||||
|
if not node_ids:
|
||||||
|
return []
|
||||||
|
return read_all(name, """
|
||||||
|
SELECT l.id, 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
|
||||||
|
WHERE l.start_node_id = ANY(%s) AND l.end_node_id = ANY(%s)
|
||||||
|
ORDER BY l.id
|
||||||
|
""", (node_ids, node_ids))
|
||||||
|
|
||||||
|
|
||||||
def _node_coord_rows(
|
def _node_coord_rows(
|
||||||
rows: list[Any],
|
rows: list[Any],
|
||||||
) -> dict[str, dict[str, Any]]:
|
) -> dict[str, dict[str, Any]]:
|
||||||
@@ -69,6 +98,46 @@ def get_network_link_nodes(name: str) -> list[str]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_valve_isolation_topology(name: str) -> tuple[list[str], list[str], list[str]]:
|
||||||
|
"""Read links, closed statuses and sources from one consistent SQL snapshot.
|
||||||
|
|
||||||
|
Isolation must include model links without published geometries, and use
|
||||||
|
initial statuses rather than treating every link as open. UNION ALL keeps
|
||||||
|
source nodes in the same statement snapshot without repeating them for
|
||||||
|
every link or relying on materialized view refresh timing.
|
||||||
|
"""
|
||||||
|
rows = read_all(
|
||||||
|
name,
|
||||||
|
"""
|
||||||
|
SELECT l.id, l.link_type::text AS element_type,
|
||||||
|
l.start_node_id, l.end_node_id,
|
||||||
|
COALESCE(s.status::text, p.status::text) AS status
|
||||||
|
FROM network.links AS l
|
||||||
|
LEFT JOIN network.pipes AS p ON p.link_id = l.id
|
||||||
|
LEFT JOIN network.link_initial_settings AS s ON s.link_id = l.id
|
||||||
|
UNION ALL
|
||||||
|
SELECT n.id, n.node_type::text AS element_type,
|
||||||
|
NULL, NULL, NULL
|
||||||
|
FROM network.nodes AS n
|
||||||
|
WHERE n.node_type::text IN ('reservoir', 'tank')
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
link_entries: list[str] = []
|
||||||
|
source_nodes: list[str] = []
|
||||||
|
closed_links: list[str] = []
|
||||||
|
for row in rows:
|
||||||
|
element_id = str(row["id"])
|
||||||
|
if row["element_type"] in {"reservoir", "tank"}:
|
||||||
|
source_nodes.append(element_id)
|
||||||
|
else:
|
||||||
|
link_entries.append(
|
||||||
|
f"{element_id}:{row['element_type']}:{row['start_node_id']}:{row['end_node_id']}"
|
||||||
|
)
|
||||||
|
if row["status"] == "CLOSED":
|
||||||
|
closed_links.append(element_id)
|
||||||
|
return link_entries, source_nodes, closed_links
|
||||||
|
|
||||||
|
|
||||||
def get_major_pipe_nodes(name: str, diameter: int) -> list[str]:
|
def get_major_pipe_nodes(name: str, diameter: int) -> list[str]:
|
||||||
"""Return large pipes in the established API wire format."""
|
"""Return large pipes in the established API wire format."""
|
||||||
rows = read_all(
|
rows = read_all(
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""Estimate pre-intervention burst pressure loss against a read-only online snapshot."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from math import isfinite
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.infra.db.timescaledb.internal_queries import InternalQueries
|
||||||
|
from app.native.wndb.model.options import get_option, get_option_v3
|
||||||
|
from app.services.node_coverage import MAX_COVERAGE_NODES, generate_node_coverage
|
||||||
|
|
||||||
|
GRADES = (
|
||||||
|
("drop_1_3", "压力下降 1≤Δ<3 m", 1, 3),
|
||||||
|
("drop_3_5", "压力下降 3≤Δ<5 m", 3, 5),
|
||||||
|
("drop_5_plus", "压力下降 Δ≥5 m", 5, None),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _head_factor(unit: Any) -> float | None:
|
||||||
|
# Standard water head; unsupported or absent units must not become zero loss.
|
||||||
|
return {"m": 1.0, "mtr": 1.0, "meters": 1.0, "metres": 1.0,
|
||||||
|
"kpa": 1 / 9.80665, "psi": 6894.757293168 / 9806.65,
|
||||||
|
"bar": 100000 / 9806.65, "mpa": 1000000 / 9806.65,
|
||||||
|
"ft": 0.3048}.get(str(unit).strip().lower())
|
||||||
|
|
||||||
|
|
||||||
|
def _number(value: Any) -> float | None:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
result = float(value)
|
||||||
|
except (ValueError, TypeError, OverflowError):
|
||||||
|
return None
|
||||||
|
return result if isfinite(result) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _instant(value: Any) -> datetime | None:
|
||||||
|
try:
|
||||||
|
result = value if isinstance(value, datetime) else datetime.fromisoformat(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
return result if result.tzinfo is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_burst_impact(network: str, start: datetime, simulation: dict[str, Any], *,
|
||||||
|
model_only: bool = False) -> dict[str, Any]:
|
||||||
|
"""Compare every native node at t=0, before the adapter filters requested outputs.
|
||||||
|
|
||||||
|
The online snapshot is a model reference read at calculation time, not frozen
|
||||||
|
SCADA or a reconstructed historical model. All numerical inputs are retained
|
||||||
|
in task evidence so playback does not repeat this mutable reference query.
|
||||||
|
"""
|
||||||
|
impact: dict[str, Any] = {
|
||||||
|
"status": "unavailable", "phase": "after_burst_before_intervention",
|
||||||
|
"comparison_time": start.isoformat(), "pressure_drop_unit": "m",
|
||||||
|
"baseline_source": "existing_online_simulation",
|
||||||
|
"baseline_read_at": datetime.now().astimezone().isoformat(),
|
||||||
|
"baseline_units_source": "current_model_settings",
|
||||||
|
"model_temporality": "current_model", "baseline_frozen": False,
|
||||||
|
"boundary_source": "model_only_no_live_scada" if model_only else "model_with_start_time_scada",
|
||||||
|
"boundary": "假设破口情景与同刻在线模拟的节点压力差估计;覆盖面为节点空间包络,非现场确认停水或用户服务边界。在线基线及其单位按计算时可用数据和模型设置读取。",
|
||||||
|
"grades": [{"group_id": key, "title": title, "min_inclusive_m": low,
|
||||||
|
"max_exclusive_m": high, "node_ids": []} for key, title, low, high in GRADES],
|
||||||
|
}
|
||||||
|
output = {"impact": impact}
|
||||||
|
# Never silently shift t=0 to an unknown/delayed report sample or another day.
|
||||||
|
times = simulation.get("times", {})
|
||||||
|
sample_times = times.get("sample_times")
|
||||||
|
if ((sample_times and _instant(sample_times[0]) != start)
|
||||||
|
or (not sample_times and times.get("report_start") != 0)):
|
||||||
|
impact["reason"] = "simulation_has_no_verified_start_time_sample"
|
||||||
|
return output
|
||||||
|
options = get_option(network)
|
||||||
|
options_v3 = get_option_v3(network)
|
||||||
|
baseline_unit = options_v3.get("PRESSURE_UNITS") or options.get("PRESSURE")
|
||||||
|
impact["baseline_units_source"] = (
|
||||||
|
"current_v3_model_settings" if options_v3.get("PRESSURE_UNITS") else "current_legacy_model_settings"
|
||||||
|
)
|
||||||
|
impact["model_conditions"] = {
|
||||||
|
"baseline_demand_model": options_v3.get("DEMAND_MODEL") or options.get("DEMAND MODEL"),
|
||||||
|
"burst_demand_model": "PDA", "burst_required_pressure_model_units": 10,
|
||||||
|
"note": "爆管模拟采用压力驱动需水;与在线模型的需水方式差异也可能影响压力差。",
|
||||||
|
}
|
||||||
|
scenario_unit = simulation.get("units", {}).get("pressure")
|
||||||
|
baseline_factor, scenario_factor = _head_factor(baseline_unit), _head_factor(scenario_unit)
|
||||||
|
impact["input_pressure_units"] = {"baseline": baseline_unit, "burst": scenario_unit}
|
||||||
|
if baseline_factor is None or scenario_factor is None:
|
||||||
|
impact["reason"] = "unsupported_or_missing_pressure_units"
|
||||||
|
return output
|
||||||
|
nodes = simulation.get("node_results", [])
|
||||||
|
ids = [str(row["node"]) for row in nodes]
|
||||||
|
if not ids or len(ids) != len(set(ids)):
|
||||||
|
impact["reason"] = "missing_or_duplicate_simulation_nodes"
|
||||||
|
return output
|
||||||
|
baseline = InternalQueries.query_realtime_simulation_by_ids_timerange(
|
||||||
|
ids, start, start, "node", "pressure", db_name=network,
|
||||||
|
)
|
||||||
|
comparisons, missing = [], []
|
||||||
|
for row in nodes:
|
||||||
|
node_id = str(row["node"])
|
||||||
|
samples = row.get("result") or []
|
||||||
|
burst = _number(samples[0].get("pressure")) if samples and isinstance(samples[0], dict) else None
|
||||||
|
exact = [item for item in baseline.get(node_id, []) if _instant(item.get("time")) == start]
|
||||||
|
normal = _number(exact[0].get("value")) if len(exact) == 1 else None
|
||||||
|
if burst is None or normal is None:
|
||||||
|
missing.append(node_id)
|
||||||
|
continue
|
||||||
|
normal_m, burst_m = normal * baseline_factor, burst * scenario_factor
|
||||||
|
drop = normal_m - burst_m
|
||||||
|
if not all(isfinite(value) for value in (normal_m, burst_m, drop)):
|
||||||
|
missing.append(node_id)
|
||||||
|
continue
|
||||||
|
comparisons.append({"node_id": node_id, "baseline_m": normal_m,
|
||||||
|
"burst_m": burst_m, "pressure_drop_m": drop})
|
||||||
|
for group in impact["grades"]:
|
||||||
|
if drop >= group["min_inclusive_m"] and (
|
||||||
|
group["max_exclusive_m"] is None or drop < group["max_exclusive_m"]
|
||||||
|
):
|
||||||
|
group["node_ids"].append(node_id)
|
||||||
|
break
|
||||||
|
impact.update(status="partial" if missing else "ready", node_count=len(ids),
|
||||||
|
compared_node_count=len(comparisons), unavailable_node_ids=missing,
|
||||||
|
comparisons=comparisons)
|
||||||
|
affected = sum(len(group["node_ids"]) for group in impact["grades"])
|
||||||
|
impact["affected_node_count"] = affected
|
||||||
|
impact["below_threshold_node_count"] = len(comparisons) - affected
|
||||||
|
if not comparisons:
|
||||||
|
impact.update(status="unavailable", reason="no_exact_comparable_online_snapshot")
|
||||||
|
impact["baseline_recovery"] = {
|
||||||
|
"tool": "run_simulation",
|
||||||
|
"parameters": {
|
||||||
|
"start_time": start.isoformat(), "duration": 60, "output_node_ids": ids[:1],
|
||||||
|
},
|
||||||
|
"next_step": "自主补算同刻在线基线;等待作业成功并读取结果后,按原参数重跑爆管影响计算,再发布降压覆盖。返回节点仅用于核验,在线基线按全网写入。",
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
if affected > MAX_COVERAGE_NODES:
|
||||||
|
impact["coverage_status"] = "unavailable"
|
||||||
|
impact["coverage_reason"] = "affected_nodes_exceed_10000_coverage_limit"
|
||||||
|
return output
|
||||||
|
groups = [{key: group[key] for key in ("group_id", "title", "node_ids")}
|
||||||
|
for group in impact["grades"]]
|
||||||
|
# GRADES are ordered from low to high. Keep the numerical classes intact
|
||||||
|
# while removing overlap between their independently derived envelopes.
|
||||||
|
geometry = generate_node_coverage(network, groups, exclusive_groups=True)
|
||||||
|
impact["coverage_status"] = geometry["coverage"]["status"]
|
||||||
|
geometry["coverage"]["boundary"] = impact["boundary"]
|
||||||
|
geometry["coverage"]["boundary"] += "分档包络互斥展示,重叠区域优先显示较高降压档,不改变节点分档或表示精确水力等值边界。"
|
||||||
|
for feature in geometry["map_payload"]["features"]:
|
||||||
|
feature["properties"].update(
|
||||||
|
impact_phase=impact["phase"], pressure_drop_unit="m",
|
||||||
|
pressure_drop_grade=feature["properties"]["coverage_group"],
|
||||||
|
comparison_time=start.isoformat(), model_estimate=True,
|
||||||
|
)
|
||||||
|
if geometry["coverage"]["status"] == "partial":
|
||||||
|
impact["status"] = "partial"
|
||||||
|
output.update(geometry)
|
||||||
|
return output
|
||||||
@@ -405,25 +405,6 @@ def _store_burst_scheme(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_scada_windows(
|
|
||||||
*,
|
|
||||||
scada_burst_start: datetime | str | None,
|
|
||||||
scada_burst_end: datetime | str | None,
|
|
||||||
) -> tuple[datetime, datetime]:
|
|
||||||
values = [scada_burst_start, scada_burst_end]
|
|
||||||
if any(v is None for v in values):
|
|
||||||
raise ValueError(
|
|
||||||
"使用后端 SCADA 查询时,必须同时提供 scada_burst_start/scada_burst_end。"
|
|
||||||
)
|
|
||||||
burst_start_dt = _to_datetime(scada_burst_start)
|
|
||||||
burst_end_dt = _to_datetime(scada_burst_end)
|
|
||||||
if burst_start_dt >= burst_end_dt:
|
|
||||||
raise ValueError(
|
|
||||||
"爆管时段 SCADA 时间窗非法:scada_burst_start 必须早于 scada_burst_end。"
|
|
||||||
)
|
|
||||||
return burst_start_dt, burst_end_dt
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_time_window(
|
def _validate_time_window(
|
||||||
*,
|
*,
|
||||||
start_value: datetime | str | None,
|
start_value: datetime | str | None,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Record only job-owned native databases so abrupt cancellation is recoverable."""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
||||||
|
def _names(directory: Path) -> list[str]:
|
||||||
|
path = directory / "databases.json"
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
if path.is_symlink() or path.stat().st_size > 65536:
|
||||||
|
raise ValueError("Invalid calculation database ledger")
|
||||||
|
names = json.loads(path.read_text())
|
||||||
|
if not isinstance(names, list) or len(names) > 100 or any(
|
||||||
|
not isinstance(name, str) or not re.fullmatch(r"tjw_tmp_[a-z0-9_]+_[a-f0-9]{32}", name)
|
||||||
|
or len(name) > 63 for name in names
|
||||||
|
):
|
||||||
|
raise ValueError("Invalid calculation database ledger")
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def _write(directory: Path, names: list[str]) -> None:
|
||||||
|
path = directory / "databases.json"
|
||||||
|
staging = directory / "databases.json.new"
|
||||||
|
with staging.open("w") as output:
|
||||||
|
json.dump(names, output)
|
||||||
|
output.flush()
|
||||||
|
os.fsync(output.fileno())
|
||||||
|
staging.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
def record_calculation_database(name: str, *, removed: bool = False) -> None:
|
||||||
|
"""Called before clone creation and after successful normal cleanup."""
|
||||||
|
root = os.environ.get("TJWATER_CALCULATION_JOB_DIR")
|
||||||
|
if not root:
|
||||||
|
return
|
||||||
|
directory = Path(root)
|
||||||
|
names = _names(directory)
|
||||||
|
if removed:
|
||||||
|
names = [value for value in names if value != name]
|
||||||
|
elif name not in names:
|
||||||
|
names.append(name)
|
||||||
|
_write(directory, names)
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_calculation_databases(project_id: str, job_directory: str) -> None:
|
||||||
|
directory = Path(job_directory)
|
||||||
|
names = _names(directory)
|
||||||
|
if not names:
|
||||||
|
return
|
||||||
|
from app.infra.db.metadb.database import SessionLocal
|
||||||
|
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
|
||||||
|
from app.infra.db.project_routing import ActiveProjectRouting, activate_project_routing
|
||||||
|
from app.native.wndb.core.projects import have_project, delete_project
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
metadata = MetadataRepository(session)
|
||||||
|
project = await metadata.get_project_by_id(UUID(str(project_id)))
|
||||||
|
route = await metadata.get_project_db_routing(UUID(str(project_id)), "biz_data")
|
||||||
|
if not project or not route or route.db_type != "postgresql":
|
||||||
|
raise ValueError("Calculation cleanup routing unavailable")
|
||||||
|
routing = ActiveProjectRouting(project.code, route.dsn, project_id=project.id)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
def cleanup():
|
||||||
|
with activate_project_routing(routing):
|
||||||
|
for name in names[:]:
|
||||||
|
if have_project(name):
|
||||||
|
delete_project(name)
|
||||||
|
names.remove(name)
|
||||||
|
_write(directory, names)
|
||||||
|
await asyncio.to_thread(cleanup)
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
"""Independent, singleton calculation supervisor and isolated job subprocesses.
|
||||||
|
|
||||||
|
Never imported by API lifespan. Child output contains the original QueryResult;
|
||||||
|
algorithm stdout/stderr is discarded rather than interpreted as a protocol.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
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
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WorkerSettings:
|
||||||
|
max_concurrency: int = 2
|
||||||
|
poll_seconds: float = 2
|
||||||
|
lease_seconds: int = 30
|
||||||
|
heartbeat_seconds: float = 5
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_environment(cls):
|
||||||
|
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 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
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def queue_repository(project_id):
|
||||||
|
"""Resolve current routing from metadata, never from a job payload."""
|
||||||
|
from app.infra.db.calculation_worker import queue_repository as open_repository
|
||||||
|
|
||||||
|
async with open_repository(project_id) as repository:
|
||||||
|
yield repository
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def execution_scope(row):
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from app.mcp.scope import QueryMetadataRepository, QueryScope
|
||||||
|
from app.auth.project_dependencies import (resolve_project_context,
|
||||||
|
resolve_project_business_routing, get_project_pg_connection)
|
||||||
|
from app.infra.db.metadb.database import SessionLocal
|
||||||
|
from app.infra.db.project_routing import activate_project_routing
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
metadata = QueryMetadataRepository(session)
|
||||||
|
user = await metadata.get_user_by_id(UUID(str(row['owner_user_id'])))
|
||||||
|
if not user or not user.is_active:
|
||||||
|
raise HTTPException(403, 'Calculation owner is inactive')
|
||||||
|
project = await resolve_project_context(str(row['project_id']), user, metadata)
|
||||||
|
routing = await resolve_project_business_routing(project, metadata)
|
||||||
|
await session.commit()
|
||||||
|
async with asynccontextmanager(get_project_pg_connection)(project, metadata) as conn:
|
||||||
|
await session.commit()
|
||||||
|
with activate_project_routing(routing):
|
||||||
|
yield QueryScope(project, user, conn, routing, metadata)
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_child(input_path: str, output_path: str):
|
||||||
|
from app.mcp.calculation_jobs import execute_calculation_job
|
||||||
|
row = json.loads(Path(input_path).read_text())
|
||||||
|
try:
|
||||||
|
async with execution_scope(row) as scope:
|
||||||
|
result = await execute_calculation_job(scope, row)
|
||||||
|
payload = {'status': 'succeeded', 'result': result.model_dump(mode='json')}
|
||||||
|
except Exception as exc:
|
||||||
|
# Do not persist exception text: DSNs, SQL, credentials or model data can occur there.
|
||||||
|
category = 'CALCULATION_FAILED'
|
||||||
|
if getattr(exc, 'status_code', None) in (401, 403):
|
||||||
|
category = 'AUTHORIZATION_DENIED'
|
||||||
|
elif getattr(exc, 'status_code', None) == 409:
|
||||||
|
category = 'EXECUTION_CONFLICT'
|
||||||
|
elif getattr(exc, 'status_code', None) in (404, 410):
|
||||||
|
category = 'EXECUTION_CONTEXT_UNAVAILABLE'
|
||||||
|
payload = {'status': 'failed', 'error': {'code': category}}
|
||||||
|
encoded = json.dumps(payload, allow_nan=False)
|
||||||
|
# Keep the complete calculation for persistence. MCP result reads apply
|
||||||
|
# their own summary/page budgets after the result has been saved.
|
||||||
|
Path(output_path).write_text(encoded)
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_process_group(process):
|
||||||
|
"""Always kill the group, even if its original leader already exited."""
|
||||||
|
try:
|
||||||
|
os.killpg(process.pid, signal.SIGTERM)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(process.wait(), 2)
|
||||||
|
except TimeoutError:
|
||||||
|
pass
|
||||||
|
# Native pools can ignore SIGTERM or survive the Python leader.
|
||||||
|
try:
|
||||||
|
os.killpg(process.pid, signal.SIGKILL)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
await process.wait()
|
||||||
|
if process.stdin is not None:
|
||||||
|
process.stdin.close()
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def job_directory(row):
|
||||||
|
root = Path(os.getenv('CALCULATION_WORKER_STATE_DIR', 'temp/calculation-jobs'))
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
directory = Path(tempfile.mkdtemp(prefix=f"{row.get('job_id', 'job')}-", dir=root))
|
||||||
|
directory.chmod(0o700)
|
||||||
|
(directory / 'scope.json').write_text(json.dumps({'project_id': str(row['project_id']), 'job_id': str(row.get('job_id', 'job')),
|
||||||
|
'lease_generation': row.get('lease_generation')}))
|
||||||
|
try:
|
||||||
|
yield str(directory.resolve())
|
||||||
|
finally:
|
||||||
|
# Failed external resource cleanup must remain operator-recoverable.
|
||||||
|
if not (directory / 'cleanup-failed').exists():
|
||||||
|
shutil.rmtree(directory)
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_job_resources(row, directory):
|
||||||
|
from app.services.calculation_cleanup import cleanup_calculation_databases
|
||||||
|
try:
|
||||||
|
await cleanup_calculation_databases(row['project_id'], directory)
|
||||||
|
except BaseException:
|
||||||
|
(Path(directory) / 'cleanup-failed').touch()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def recover_job_resources(project_ids):
|
||||||
|
"""Reconcile only this worker's explicit scopes after obtaining the singleton."""
|
||||||
|
root = Path(os.getenv('CALCULATION_WORKER_STATE_DIR', 'temp/calculation-jobs'))
|
||||||
|
if not root.exists():
|
||||||
|
return
|
||||||
|
for directory in root.iterdir():
|
||||||
|
if directory.is_symlink() or not directory.is_dir():
|
||||||
|
continue
|
||||||
|
scope_file = directory / 'scope.json'
|
||||||
|
if not scope_file.exists():
|
||||||
|
continue
|
||||||
|
row = json.loads(scope_file.read_text())
|
||||||
|
if str(row.get('project_id')) not in project_ids:
|
||||||
|
continue
|
||||||
|
job_id = str(UUID(row['job_id']))
|
||||||
|
if not directory.name.startswith(job_id + '-'):
|
||||||
|
raise RuntimeError('Calculation state directory identity is invalid')
|
||||||
|
async with queue_repository(row['project_id']) as repository:
|
||||||
|
stored = await repository.get(job_id)
|
||||||
|
if not stored or stored['lease_generation'] != row['lease_generation']:
|
||||||
|
raise RuntimeError('Calculation state ownership cannot be verified')
|
||||||
|
expiry = stored.get('lease_expires_at')
|
||||||
|
if expiry and expiry > datetime.now(timezone.utc) and stored['status'] in ('running', 'cancel_requested'):
|
||||||
|
raise RuntimeError('Previous calculation lease has not expired')
|
||||||
|
await cleanup_job_resources(row, str(directory.resolve()))
|
||||||
|
async with queue_repository(row['project_id']) as repository:
|
||||||
|
await repository.recover()
|
||||||
|
shutil.rmtree(directory)
|
||||||
|
|
||||||
|
|
||||||
|
class CalculationWorker:
|
||||||
|
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):
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
with job_directory(row) as directory:
|
||||||
|
input_path = Path(directory) / 'input.json'
|
||||||
|
output_path = Path(directory) / 'result.json'
|
||||||
|
input_path.write_text(json.dumps(jsonable_encoder(row)))
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
sys.executable, '-m', 'app.services.calculation_worker',
|
||||||
|
'--guard', str(input_path), str(output_path), start_new_session=True,
|
||||||
|
stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
env={**os.environ, 'TMPDIR': directory, 'TJWATER_CALCULATION_JOB_DIR': directory},
|
||||||
|
)
|
||||||
|
started = time.monotonic()
|
||||||
|
cleaned = False
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
async with asyncio.timeout(self.settings.heartbeat_seconds):
|
||||||
|
async with self.repository(row['project_id']) as repository:
|
||||||
|
status = await repository.heartbeat(row, self.owner, self.settings.lease_seconds)
|
||||||
|
if status is None:
|
||||||
|
return # Finally stops all owned computation on a lost lease.
|
||||||
|
terminal = None
|
||||||
|
if status == 'cancel_requested':
|
||||||
|
terminal = {'status': 'cancelled'}
|
||||||
|
elif time.monotonic() - started >= row['timeout_seconds']:
|
||||||
|
terminal = {'status': 'timed_out', 'error': {'code': 'EXECUTION_TIMEOUT'}}
|
||||||
|
elif process.returncode is not None:
|
||||||
|
try:
|
||||||
|
terminal = json.loads(output_path.read_text())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
terminal = {'status': 'failed', 'error': {'code': 'CHILD_PROCESS_FAILED'}}
|
||||||
|
if terminal:
|
||||||
|
await stop_process_group(process)
|
||||||
|
try:
|
||||||
|
await cleanup_job_resources(row, directory)
|
||||||
|
cleaned = True
|
||||||
|
except Exception:
|
||||||
|
terminal = {'status': 'failed', 'error': {'code': 'CLEANUP_FAILED'}}
|
||||||
|
cleaned = True
|
||||||
|
async with self.repository(row['project_id']) as repository:
|
||||||
|
finished = await repository.finish(row, self.owner, **terminal)
|
||||||
|
if not finished:
|
||||||
|
status = await repository.heartbeat(row, self.owner, self.settings.lease_seconds)
|
||||||
|
if status == 'cancel_requested':
|
||||||
|
await repository.finish(row, self.owner, 'cancelled')
|
||||||
|
return
|
||||||
|
await asyncio.sleep(self.settings.heartbeat_seconds)
|
||||||
|
finally:
|
||||||
|
await stop_process_group(process)
|
||||||
|
if not cleaned:
|
||||||
|
await cleanup_job_resources(row, directory)
|
||||||
|
|
||||||
|
async def run(self):
|
||||||
|
from app.infra.db.calculation_worker import calculation_supervisor_lease
|
||||||
|
|
||||||
|
# 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 = {}
|
||||||
|
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(await self.project_provider())
|
||||||
|
while True:
|
||||||
|
async with asyncio.timeout(self.settings.heartbeat_seconds):
|
||||||
|
await lease.ping()
|
||||||
|
for task, identity in tuple(tasks.items()):
|
||||||
|
if task.done():
|
||||||
|
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
|
||||||
|
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:
|
||||||
|
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.keys(), return_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def guard_child(input_path, output_path):
|
||||||
|
"""A parent-death pipe kills the whole group even when the supervisor is SIGKILLed."""
|
||||||
|
reader = asyncio.StreamReader()
|
||||||
|
protocol = asyncio.StreamReaderProtocol(reader)
|
||||||
|
transport, _ = await asyncio.get_running_loop().connect_read_pipe(lambda: protocol, sys.stdin)
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
sys.executable, '-m', 'app.services.calculation_worker', input_path, output_path,
|
||||||
|
stdin=asyncio.subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
completed = asyncio.create_task(process.wait())
|
||||||
|
parent_gone = asyncio.create_task(reader.read(1))
|
||||||
|
try:
|
||||||
|
done, _ = await asyncio.wait((completed, parent_gone), return_when=asyncio.FIRST_COMPLETED)
|
||||||
|
if parent_gone in done:
|
||||||
|
os.killpg(os.getpgrp(), signal.SIGKILL)
|
||||||
|
await completed
|
||||||
|
finally:
|
||||||
|
parent_gone.cancel()
|
||||||
|
transport.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
if sys.argv[1] == '--guard':
|
||||||
|
asyncio.run(guard_child(sys.argv[2], sys.argv[3]))
|
||||||
|
else:
|
||||||
|
asyncio.run(execute_child(sys.argv[1], sys.argv[2]))
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Estimate a scenario's time-cumulative concentration coverage before output filtering."""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from math import isfinite
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.services.node_coverage import MAX_COVERAGE_NODES, generate_node_coverage
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_times(start: datetime, duration: int, metadata: dict) -> list[datetime]:
|
||||||
|
explicit = metadata.get("sample_times")
|
||||||
|
if explicit is not None:
|
||||||
|
times = [datetime.fromisoformat(value) for value in explicit]
|
||||||
|
else:
|
||||||
|
offset, step, count = (metadata.get(key) for key in ("report_start", "report_step", "num_periods"))
|
||||||
|
if (any(type(value) is not int for value in (offset, step, count))
|
||||||
|
or offset < 0 or step <= 0 or not 0 < count <= 86401):
|
||||||
|
raise ValueError("missing or invalid report grid")
|
||||||
|
times = [start + timedelta(seconds=offset + index * step) for index in range(count)]
|
||||||
|
if (not times or len(times) > 86401 or any(at.tzinfo is None for at in times)
|
||||||
|
or any(not start <= at <= start + timedelta(seconds=duration) for at in times)
|
||||||
|
or any(left >= right for left, right in zip(times, times[1:]))):
|
||||||
|
raise ValueError("invalid simulation sample times")
|
||||||
|
return times
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_contaminant_impact(network: str, start: datetime, duration: int,
|
||||||
|
simulation: dict[str, Any], *, threshold_mg_l: float = 0,
|
||||||
|
model_only: bool = False) -> dict[str, Any]:
|
||||||
|
"""Use every native node and report sample; never infer propagation from requested nodes."""
|
||||||
|
impact: dict[str, Any] = {
|
||||||
|
"status": "unavailable", "phase": "after_contamination_before_intervention",
|
||||||
|
"window_start": start.isoformat(), "window_end": (start + timedelta(seconds=duration)).isoformat(),
|
||||||
|
"threshold_mg_l": threshold_mg_l, "threshold_operator": ">", "concentration_unit": "mg/L",
|
||||||
|
"threshold_purpose": "scenario_display_not_drinking_water_standard",
|
||||||
|
"model_temporality": "current_model", "quantity": "total_model_concentration",
|
||||||
|
"boundary_source": "model_only_no_live_scada" if model_only else "model_with_start_time_scada",
|
||||||
|
"boundary": "给定源浓度、模型初始水质及反应条件下,整个模拟时段内超过展示阈值的节点覆盖;"
|
||||||
|
"首达时间为首个超过阈值的报告采样时刻。不是污染确诊、源独立贡献、现场污染边界或饮用水合格判定。",
|
||||||
|
}
|
||||||
|
output = {"impact": impact}
|
||||||
|
unit = simulation.get("units", {}).get("quality")
|
||||||
|
factor = {"mgl": 1.0, "mg/l": 1.0, "ugl": 0.001, "ug/l": 0.001}.get(
|
||||||
|
str(unit).strip().lower().replace("μ", "u").replace("µ", "u"))
|
||||||
|
impact["input_concentration_unit"] = unit
|
||||||
|
if factor is None:
|
||||||
|
impact["reason"] = "unsupported_or_missing_concentration_unit"
|
||||||
|
return output
|
||||||
|
try:
|
||||||
|
times = _sample_times(start, duration, simulation.get("times", {}))
|
||||||
|
except (TypeError, ValueError, OverflowError):
|
||||||
|
impact["reason"] = "simulation_has_no_verified_sample_times"
|
||||||
|
return output
|
||||||
|
rows = simulation.get("node_results", [])
|
||||||
|
ids = [str(row["node"]) for row in rows]
|
||||||
|
if not ids or len(ids) != len(set(ids)):
|
||||||
|
impact["reason"] = "missing_or_duplicate_simulation_nodes"
|
||||||
|
return output
|
||||||
|
affected, unavailable, incomplete = [], [], []
|
||||||
|
for row in rows:
|
||||||
|
node = str(row["node"])
|
||||||
|
samples = row.get("result") or []
|
||||||
|
if len(samples) > len(times):
|
||||||
|
impact["reason"] = "simulation_sample_count_mismatch"
|
||||||
|
return output
|
||||||
|
valid = []
|
||||||
|
for at, sample in zip(times, samples):
|
||||||
|
value = sample.get("quality") if isinstance(sample, dict) else None
|
||||||
|
if isinstance(value, bool) or value is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
concentration = float(value) * factor
|
||||||
|
except (TypeError, ValueError, OverflowError):
|
||||||
|
continue
|
||||||
|
if isfinite(concentration) and concentration >= 0:
|
||||||
|
valid.append((at, concentration))
|
||||||
|
if not valid:
|
||||||
|
unavailable.append(node)
|
||||||
|
continue
|
||||||
|
if len(valid) != len(times):
|
||||||
|
incomplete.append(node)
|
||||||
|
exceedances = [(at, value) for at, value in valid if value > threshold_mg_l]
|
||||||
|
if exceedances:
|
||||||
|
peak_at, peak = max(valid, key=lambda item: item[1])
|
||||||
|
affected.append({"node_id": node, "first_exceedance_sample_at": exceedances[0][0].isoformat(),
|
||||||
|
"peak_mg_l": peak, "peak_at": peak_at.isoformat(),
|
||||||
|
"valid_sample_count": len(valid), "complete": len(valid) == len(times)})
|
||||||
|
impact.update(status="partial" if unavailable or incomplete else "ready",
|
||||||
|
node_count=len(ids), report_sample_count=len(times),
|
||||||
|
reported_from=times[0].isoformat(), reported_through=times[-1].isoformat(),
|
||||||
|
unavailable_node_ids=unavailable, incomplete_node_ids=incomplete,
|
||||||
|
affected_node_count=len(affected), nodes=affected,
|
||||||
|
affected_node_ids=[item["node_id"] for item in affected])
|
||||||
|
if len(unavailable) == len(ids):
|
||||||
|
impact.update(status="unavailable", reason="no_valid_concentration_samples")
|
||||||
|
return output
|
||||||
|
if len(affected) > MAX_COVERAGE_NODES:
|
||||||
|
impact.update(coverage_status="unavailable", coverage_reason="affected_nodes_exceed_10000_coverage_limit")
|
||||||
|
return output
|
||||||
|
geometry = generate_node_coverage(network, [{
|
||||||
|
"group_id": "contaminant_exposure", "title": f"模拟浓度>{threshold_mg_l:g} mg/L的累计节点范围",
|
||||||
|
"node_ids": impact["affected_node_ids"],
|
||||||
|
}])
|
||||||
|
impact["coverage_status"] = geometry["coverage"]["status"]
|
||||||
|
geometry["coverage"]["boundary"] = impact["boundary"]
|
||||||
|
for feature in geometry["map_payload"]["features"]:
|
||||||
|
feature["properties"].update(impact_phase=impact["phase"], concentration_unit="mg/L",
|
||||||
|
threshold_mg_l=threshold_mg_l, model_estimate=True,
|
||||||
|
window_start=impact["window_start"], window_end=impact["window_end"])
|
||||||
|
if geometry["coverage"]["status"] == "partial":
|
||||||
|
impact["status"] = "partial"
|
||||||
|
output.update(geometry)
|
||||||
|
return output
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Temporary deterministic candidate selection behind the source-tracing contract.
|
||||||
|
|
||||||
|
Replace this selection with source inversion when the algorithm is available.
|
||||||
|
Only project-owned nodes supplied from the investigation are eligible. No
|
||||||
|
observation fit, confidence or contamination extent is inferred here.
|
||||||
|
"""
|
||||||
|
from psycopg import AsyncConnection
|
||||||
|
|
||||||
|
from app.infra.db.postgresql.read_queries import read_rows
|
||||||
|
from app.mcp.analysis_models import ContaminantSourceTraceInput
|
||||||
|
|
||||||
|
|
||||||
|
async def trace_contaminant_source(connection: AsyncConnection, args: ContaminantSourceTraceInput) -> dict:
|
||||||
|
rows = await read_rows(connection, """
|
||||||
|
SELECT id, node_type::text AS kind FROM network.nodes
|
||||||
|
WHERE id = ANY(%s) ORDER BY id
|
||||||
|
""", (args.candidate_node_ids,))
|
||||||
|
# Sort in Python as well so selection does not depend on DB collation.
|
||||||
|
nodes = sorted({str(row["id"]) for row in rows})
|
||||||
|
selected = nodes[0] if nodes else None
|
||||||
|
return {
|
||||||
|
"located_node": selected,
|
||||||
|
"top_candidates": [{"node_id": selected}] if selected else [],
|
||||||
|
"exit_condition": "candidate_selected" if selected else "no_candidate",
|
||||||
|
"method": "deterministic_candidate_selection",
|
||||||
|
"algorithm_status": "provisional",
|
||||||
|
"selection_basis": "lexicographically_first_existing_candidate_node",
|
||||||
|
"validated_node_ids": nodes,
|
||||||
|
"missing_node_ids": sorted(set(args.candidate_node_ids) - set(nodes)),
|
||||||
|
"notice": ("已返回待现场核验的候选节点;先展示节点、保存阶段结论并提出现场核查/采样处置工单,"
|
||||||
|
"工单审核派发与污染传播模拟、节点隔离及影响分析并行,结果补入同一调查。"
|
||||||
|
"正式污染反演算法尚未接入,本次选择不代表污染源确诊或污染传播范围。" if selected else
|
||||||
|
"提交的候选节点均不在当前项目模型中;核对事件位置及资产映射后重新提交,继续不依赖源节点的调查。"),
|
||||||
|
}
|
||||||
@@ -188,74 +188,6 @@ def _build_area_map_by_topology(
|
|||||||
return area_map, areas, node_coords
|
return area_map, areas, node_coords
|
||||||
|
|
||||||
|
|
||||||
def _build_area_node_map(area_map: dict[str, str]) -> dict[str, list[str]]:
|
|
||||||
area_node_map: dict[str, list[str]] = {}
|
|
||||||
for node_id, area_id in area_map.items():
|
|
||||||
area_node_map.setdefault(area_id, []).append(node_id)
|
|
||||||
for area_id in list(area_node_map.keys()):
|
|
||||||
area_node_map[area_id] = sorted(area_node_map[area_id])
|
|
||||||
return area_node_map
|
|
||||||
|
|
||||||
|
|
||||||
def _build_node_visual_payload(
|
|
||||||
area_map: dict[str, str],
|
|
||||||
node_coords: dict[str, dict[str, float]],
|
|
||||||
rows: list[dict[str, Any]],
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
area_leakage_map = _build_area_leakage_map(rows)
|
|
||||||
max_leakage = max(area_leakage_map.values(), default=0.0)
|
|
||||||
features: list[dict[str, Any]] = []
|
|
||||||
for node_id, area_id in area_map.items():
|
|
||||||
coord = node_coords.get(node_id)
|
|
||||||
if not coord:
|
|
||||||
continue
|
|
||||||
leakage_flow = float(area_leakage_map.get(area_id, 0.0))
|
|
||||||
leakage_level = _classify_leakage_level(leakage_flow, max_leakage)
|
|
||||||
features.append(
|
|
||||||
{
|
|
||||||
"type": "Feature",
|
|
||||||
"properties": {
|
|
||||||
"node_id": node_id,
|
|
||||||
"area_id": area_id,
|
|
||||||
"leakage_flow_m3_per_s": leakage_flow,
|
|
||||||
"leakage_level": leakage_level,
|
|
||||||
},
|
|
||||||
"geometry": {
|
|
||||||
"type": "Point",
|
|
||||||
"coordinates": [float(coord["x"]), float(coord["y"])],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return {"type": "FeatureCollection", "features": features}
|
|
||||||
|
|
||||||
|
|
||||||
def _build_area_leakage_map(rows: list[dict[str, Any]]) -> dict[str, float]:
|
|
||||||
area_leakage_map: dict[str, float] = {}
|
|
||||||
for row in rows:
|
|
||||||
area_id = str(row.get("Area", "")).strip()
|
|
||||||
if not area_id:
|
|
||||||
continue
|
|
||||||
area_leakage_map[area_id] = float(row.get("LeakageFlow_m3_per_s", 0.0))
|
|
||||||
return area_leakage_map
|
|
||||||
|
|
||||||
|
|
||||||
def _classify_leakage_level(leakage_flow: float, max_leakage: float) -> str:
|
|
||||||
if max_leakage <= 0:
|
|
||||||
return "normal"
|
|
||||||
ratio = leakage_flow / max_leakage
|
|
||||||
if ratio >= 0.75:
|
|
||||||
return "high"
|
|
||||||
if ratio >= 0.4:
|
|
||||||
return "medium"
|
|
||||||
if ratio > 0:
|
|
||||||
return "low"
|
|
||||||
return "normal"
|
|
||||||
|
|
||||||
|
|
||||||
def _build_drawing_payload(node_visual_payload: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
return node_visual_payload
|
|
||||||
|
|
||||||
|
|
||||||
def _build_observed_pressure_from_scada(
|
def _build_observed_pressure_from_scada(
|
||||||
network: str,
|
network: str,
|
||||||
sensor_nodes: list[str],
|
sensor_nodes: list[str],
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""Accumulate explanatory investigation paths and generate Mermaid deterministically."""
|
||||||
|
import re
|
||||||
|
from app.domain.domain_errors import require
|
||||||
|
from app.domain.schemas.operational_task import (
|
||||||
|
InvestigationDiagram, InvestigationDiagramNodeRef, InvestigationGraphUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _label(value: str) -> str:
|
||||||
|
# Encode Mermaid delimiters and HTML before placing untrusted text in quotes.
|
||||||
|
replacements = {"#": "#35;", "&": "#38;", '"': "#quot;", "<": "#lt;",
|
||||||
|
">": "#gt;", "`": "#96;", "%": "#37;", "|": "#124;", "\\": "#92;"}
|
||||||
|
return "".join(replacements.get(c, " " if c in "\r\n" else c) for c in value)
|
||||||
|
|
||||||
|
|
||||||
|
def adopt_legacy_investigation_graph(diagram: InvestigationDiagram) -> InvestigationDiagram:
|
||||||
|
"""Make a saved legacy graph cumulative without parsing or changing its source."""
|
||||||
|
if diagram.edges is not None:
|
||||||
|
return diagram
|
||||||
|
return diagram.model_copy(deep=True, update={"edges": [], "source_mermaid": diagram.mermaid})
|
||||||
|
|
||||||
|
|
||||||
|
def update_investigation_graph(
|
||||||
|
previous: InvestigationDiagram | None, update: InvestigationGraphUpdate,
|
||||||
|
) -> InvestigationDiagram:
|
||||||
|
"""Patch saved nodes or add labelled nodes and explicit edges; never erase a path.
|
||||||
|
|
||||||
|
Unspecified fields retain their saved values. A failed attempt remains
|
||||||
|
failed, and a retry uses a new node id. Relationships impose no execution
|
||||||
|
ordering or completion requirements on the Agent.
|
||||||
|
"""
|
||||||
|
if previous is not None:
|
||||||
|
previous = adopt_legacy_investigation_graph(previous)
|
||||||
|
source = previous.source_mermaid if previous else None
|
||||||
|
def in_source(node_id: str) -> bool:
|
||||||
|
return bool(source and re.search(
|
||||||
|
rf"(?<![A-Za-z0-9_-]){re.escape(node_id)}(?=\s*(?:[\[({{@;]|:::|--|==|-\.|$))",
|
||||||
|
source, re.MULTILINE,
|
||||||
|
))
|
||||||
|
|
||||||
|
nodes = {node.node_id: node.model_copy(deep=True) for node in previous.node_refs} if previous else {}
|
||||||
|
saved_ids = set(nodes)
|
||||||
|
require(len({node.node_id for node in update.nodes}) == len(update.nodes),
|
||||||
|
"GRAPH_NODE_DUPLICATE", "Graph update contains duplicate node ids", status=422)
|
||||||
|
for node in update.nodes:
|
||||||
|
require(node.node_id.lower() not in {"end", "graph", "flowchart", "subgraph"},
|
||||||
|
"GRAPH_NODE_ID_INVALID", "Use a descriptive graph node id", status=422)
|
||||||
|
existing = nodes.get(node.node_id)
|
||||||
|
if existing is None:
|
||||||
|
require(not in_source(node.node_id), "GRAPH_LEGACY_NODE_CONFLICT",
|
||||||
|
"This id belongs to the original Mermaid; use a new node id", status=409)
|
||||||
|
require(node.label is not None, "GRAPH_NODE_LABEL_REQUIRED",
|
||||||
|
"A new graph node requires a label; use an existing node_id for a partial update", status=422)
|
||||||
|
nodes[node.node_id] = InvestigationDiagramNodeRef.model_validate(node.model_dump(exclude_unset=True))
|
||||||
|
continue
|
||||||
|
require(not in_source(node.node_id) or node.label is None or existing.label == node.label,
|
||||||
|
"GRAPH_LEGACY_NODE_CONFLICT", "Keep the original node label; use a new node for a new step", status=409)
|
||||||
|
require(existing.status != "failed" or (
|
||||||
|
("status" not in node.model_fields_set or node.status == "failed")
|
||||||
|
and (node.label is None or existing.label == node.label)
|
||||||
|
), "GRAPH_FAILED_ATTEMPT_IMMUTABLE", "Keep the failed attempt; use a new node for a retry", status=409)
|
||||||
|
changes = node.model_dump(exclude_unset=True)
|
||||||
|
if node.label is None:
|
||||||
|
changes.pop("label", None)
|
||||||
|
if existing.status == "failed" and existing.detail and not changes.get("detail"):
|
||||||
|
changes.pop("detail", None)
|
||||||
|
for field in ("evidence_refs", "action_refs", "map_targets"):
|
||||||
|
combined = list(getattr(existing, field))
|
||||||
|
for value in getattr(node, field):
|
||||||
|
if value not in combined:
|
||||||
|
combined.append(value)
|
||||||
|
changes[field] = combined
|
||||||
|
nodes[node.node_id] = InvestigationDiagramNodeRef.model_validate({
|
||||||
|
**existing.model_dump(), **changes,
|
||||||
|
})
|
||||||
|
require(bool(nodes), "GRAPH_NODE_REQUIRED", "Add a node before publishing a graph", status=422)
|
||||||
|
edges = list(previous.edges or []) if previous else []
|
||||||
|
for edge in update.edges:
|
||||||
|
require(edge.source in nodes and edge.target in nodes, "GRAPH_EDGE_NODE_NOT_FOUND",
|
||||||
|
"Every graph edge must reference saved or newly added nodes", status=422)
|
||||||
|
if edge not in edges:
|
||||||
|
edges.append(edge.model_copy(deep=True))
|
||||||
|
require(len(nodes) <= 100 and len(edges) <= 200, "GRAPH_CAPACITY_EXCEEDED",
|
||||||
|
"Investigation graph exceeds 100 nodes or 200 edges; existing paths were retained", status=422)
|
||||||
|
# Validate only newly added steps. Historical snapshots and status-only
|
||||||
|
# patches stay valid, even if an earlier publisher omitted relationships.
|
||||||
|
new_ids = set(nodes) - saved_ids
|
||||||
|
if new_ids and len(nodes) > 1:
|
||||||
|
neighbours = {node_id: set() for node_id in nodes}
|
||||||
|
for edge in edges:
|
||||||
|
neighbours[edge.source].add(edge.target)
|
||||||
|
neighbours[edge.target].add(edge.source)
|
||||||
|
connected = set(saved_ids) or {next(iter(nodes))}
|
||||||
|
pending = list(connected)
|
||||||
|
while pending:
|
||||||
|
for node_id in neighbours[pending.pop()] - connected:
|
||||||
|
connected.add(node_id)
|
||||||
|
pending.append(node_id)
|
||||||
|
missing = sorted(new_ids - connected)
|
||||||
|
require(not missing, "GRAPH_RELATION_REQUIRED",
|
||||||
|
"Add explicit edges connecting these new steps to the investigation narrative: "
|
||||||
|
+ ", ".join(missing)
|
||||||
|
+ ". Describe branches, parallel findings or retries; edges do not schedule execution.",
|
||||||
|
status=422)
|
||||||
|
lines = [source or "flowchart TD"]
|
||||||
|
for node in nodes.values():
|
||||||
|
if in_source(node.node_id):
|
||||||
|
continue # Preserve original branches, shapes and labels byte for byte.
|
||||||
|
label = node.label + ("(失败)" if node.status == "failed" else "")
|
||||||
|
lines.append(f' {node.node_id}["{_label(label)}"]')
|
||||||
|
for edge in edges:
|
||||||
|
connector = f' -->|"{_label(edge.label)}"| ' if edge.label else " --> "
|
||||||
|
lines.append(f" {edge.source}{connector}{edge.target}")
|
||||||
|
mermaid = "\n".join(lines)
|
||||||
|
require(len(mermaid) <= 20000, "GRAPH_CAPACITY_EXCEEDED",
|
||||||
|
"Investigation Mermaid exceeds 20000 characters; existing paths were retained", status=422)
|
||||||
|
return InvestigationDiagram(
|
||||||
|
title=update.title or (previous.title if previous else "调查脉络"),
|
||||||
|
mermaid=mermaid, node_refs=list(nodes.values()), edges=edges, source_mermaid=source,
|
||||||
|
)
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""Reusable geometric coverage of verified project nodes, independent of business analysis."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from math import isfinite
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pyproj import CRS, Transformer
|
||||||
|
from shapely import MultiPoint, concave_hull, get_num_coordinates
|
||||||
|
from shapely.geometry import GeometryCollection, mapping, shape
|
||||||
|
from shapely.ops import transform, unary_union
|
||||||
|
|
||||||
|
from app.native.wndb.gis.network_views import get_node_coverage_links, get_node_geographic_positions
|
||||||
|
|
||||||
|
CONCAVE_RATIO = 0.25
|
||||||
|
BUFFER_METERS = 25.0
|
||||||
|
SIMPLIFY_METERS = 2.0
|
||||||
|
MAX_COVERAGE_NODES = 10000
|
||||||
|
|
||||||
|
|
||||||
|
def exclusive_coverage_features(features: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""Clip display envelopes, with later groups taking precedence; retain input order.
|
||||||
|
|
||||||
|
This changes only derived geometry, never node membership or model values.
|
||||||
|
No post-clipping buffer or simplification may reintroduce an overlap.
|
||||||
|
"""
|
||||||
|
occupied = GeometryCollection()
|
||||||
|
result = []
|
||||||
|
summaries = {}
|
||||||
|
for feature in features:
|
||||||
|
properties = feature["properties"]
|
||||||
|
summaries.update(properties.get("coverage_group_summary", {}))
|
||||||
|
for feature in features:
|
||||||
|
properties = feature["properties"]
|
||||||
|
summaries[properties["coverage_group"]] = {
|
||||||
|
key: properties.get(key) for key in ("node_count", "mapped_node_count", "missing_node_count")
|
||||||
|
}
|
||||||
|
for feature in reversed(features):
|
||||||
|
original = shape(feature["geometry"])
|
||||||
|
visible = original.difference(occupied)
|
||||||
|
occupied = unary_union([occupied, original])
|
||||||
|
if visible.is_empty:
|
||||||
|
continue
|
||||||
|
if (visible.geom_type not in {"Polygon", "MultiPolygon"} or not visible.is_valid
|
||||||
|
or get_num_coordinates(visible) > 20000):
|
||||||
|
raise ValueError("Invalid exclusive derived coverage")
|
||||||
|
result.append({
|
||||||
|
**feature,
|
||||||
|
"properties": {**feature["properties"], "overlap_policy": "higher_priority_group_first"},
|
||||||
|
"geometry": mapping(visible),
|
||||||
|
})
|
||||||
|
visible_groups = {feature["properties"]["coverage_group"] for feature in result}
|
||||||
|
summaries = {key: {**value, "display_area_available": key in visible_groups}
|
||||||
|
for key, value in summaries.items()}
|
||||||
|
for feature in result:
|
||||||
|
# Retain counts even when that group's entire display envelope was
|
||||||
|
# clipped away. Overlay transport deliberately sends only GeoJSON.
|
||||||
|
feature["properties"]["coverage_group_summary"] = summaries
|
||||||
|
return list(reversed(result))
|
||||||
|
|
||||||
|
|
||||||
|
def _components(node_ids: list[str], links: list[dict[str, Any]], closed: set[str]) -> list[list[str]]:
|
||||||
|
parent = {node_id: node_id for node_id in node_ids}
|
||||||
|
|
||||||
|
def root(node: str) -> str:
|
||||||
|
while parent[node] != node:
|
||||||
|
parent[node] = parent[parent[node]]
|
||||||
|
node = parent[node]
|
||||||
|
return node
|
||||||
|
|
||||||
|
for link in links:
|
||||||
|
start, end = link["start_node_id"], link["end_node_id"]
|
||||||
|
if link["id"] not in closed and link.get("status") != "CLOSED" and start in parent and end in parent:
|
||||||
|
parent[root(start)] = root(end)
|
||||||
|
groups: dict[str, list[str]] = {}
|
||||||
|
for node_id in node_ids:
|
||||||
|
groups.setdefault(root(node_id), []).append(node_id)
|
||||||
|
return list(groups.values())
|
||||||
|
|
||||||
|
|
||||||
|
def build_node_coverage(
|
||||||
|
groups: list[dict[str, Any]], positions: list[dict[str, Any]],
|
||||||
|
links: list[dict[str, Any]], *, split_by_connectivity: bool = False,
|
||||||
|
excluded_link_ids: list[str] | None = None,
|
||||||
|
concave_ratio: float = CONCAVE_RATIO, buffer_m: float = BUFFER_METERS,
|
||||||
|
exclusive_groups: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Keep caller-defined comparison groups and derive geometry without assigning business meaning."""
|
||||||
|
coordinates = {}
|
||||||
|
for row in positions:
|
||||||
|
longitude, latitude = row.get("longitude"), row.get("latitude")
|
||||||
|
if (isinstance(longitude, (int, float)) and not isinstance(longitude, bool)
|
||||||
|
and isinstance(latitude, (int, float)) and not isinstance(latitude, bool)
|
||||||
|
and isfinite(longitude) and isfinite(latitude)
|
||||||
|
and -180 <= longitude <= 180 and -90 < latitude < 90):
|
||||||
|
coordinates[str(row["id"])] = (longitude, latitude)
|
||||||
|
|
||||||
|
features, groups_metadata = [], {}
|
||||||
|
closed = set(excluded_link_ids or [])
|
||||||
|
simplify_m = min(SIMPLIFY_METERS, buffer_m / 4)
|
||||||
|
for group in groups:
|
||||||
|
group_id, label = group["group_id"], group["title"]
|
||||||
|
ids = sorted(set(group["node_ids"]))
|
||||||
|
mapped = [node for node in ids if node in coordinates]
|
||||||
|
missing = [node for node in ids if node not in coordinates]
|
||||||
|
metadata = {"node_ids": ids, "mapped_node_count": len(mapped), "missing_node_ids": missing}
|
||||||
|
groups_metadata[group_id] = {"title": label, **metadata}
|
||||||
|
metadata = groups_metadata[group_id]
|
||||||
|
if not mapped:
|
||||||
|
metadata["component_count"] = 0
|
||||||
|
continue
|
||||||
|
points = [coordinates[node] for node in mapped]
|
||||||
|
longitude = sum(point[0] for point in points) / len(points)
|
||||||
|
latitude = sum(point[1] for point in points) / len(points)
|
||||||
|
local = CRS.from_proj4(
|
||||||
|
f"+proj=aeqd +lat_0={latitude} +lon_0={longitude} +datum=WGS84 +units=m"
|
||||||
|
)
|
||||||
|
project = Transformer.from_crs(4326, local, always_xy=True).transform
|
||||||
|
unproject = Transformer.from_crs(local, 4326, always_xy=True).transform
|
||||||
|
envelopes = []
|
||||||
|
components = _components(ids, links, closed) if split_by_connectivity else [ids]
|
||||||
|
for nodes in components:
|
||||||
|
component_points = [coordinates[node] for node in nodes if node in coordinates]
|
||||||
|
if not component_points:
|
||||||
|
continue
|
||||||
|
cloud = transform(project, MultiPoint(component_points))
|
||||||
|
# A point or collinear cloud remains drawable through the same metric buffer.
|
||||||
|
envelope = concave_hull(cloud, ratio=concave_ratio).buffer(buffer_m)
|
||||||
|
envelopes.append(envelope)
|
||||||
|
metadata["component_count"] = len(envelopes)
|
||||||
|
geometry = unary_union(envelopes).simplify(simplify_m, preserve_topology=True)
|
||||||
|
if geometry.is_empty or not geometry.is_valid or get_num_coordinates(geometry) > 20000:
|
||||||
|
raise ValueError("Invalid derived coverage")
|
||||||
|
feature = {
|
||||||
|
"type": "Feature", "id": group_id,
|
||||||
|
"properties": {
|
||||||
|
"coverage_group": group_id, "label": label, "derived": True, "display_only": True,
|
||||||
|
"node_count": len(ids), "mapped_node_count": len(mapped),
|
||||||
|
"missing_node_count": len(missing), "component_count": len(envelopes),
|
||||||
|
},
|
||||||
|
"geometry": mapping(transform(unproject, geometry)),
|
||||||
|
}
|
||||||
|
features.append(feature)
|
||||||
|
|
||||||
|
if exclusive_groups:
|
||||||
|
features = exclusive_coverage_features(features)
|
||||||
|
visible_groups = {feature["properties"]["coverage_group"] for feature in features}
|
||||||
|
for group_id, metadata in groups_metadata.items():
|
||||||
|
metadata["display_area_available"] = group_id in visible_groups
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "partial" if any(group["missing_node_ids"] for group in groups_metadata.values())
|
||||||
|
else "ready" if features else "empty",
|
||||||
|
"method": "concave_hull_buffer",
|
||||||
|
"parameters": {"concave_ratio": concave_ratio, "buffer_m": buffer_m,
|
||||||
|
"split_by_connectivity": split_by_connectivity,
|
||||||
|
"excluded_link_ids": sorted(closed),
|
||||||
|
"simplify_m": simplify_m, "projection": "local_azimuthal_equidistant",
|
||||||
|
"exclusive_groups": exclusive_groups},
|
||||||
|
"output_crs": "EPSG:4326", "display_only": True,
|
||||||
|
"position_source": "gis.node_geometries",
|
||||||
|
"boundary": "节点空间覆盖示意;不推断故障、污染、停水、水力影响或用户服务边界。",
|
||||||
|
"groups": groups_metadata,
|
||||||
|
"map_payload": {"type": "FeatureCollection", "features": features},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_node_coverage(network: str, groups: list[dict[str, Any]], **parameters: Any) -> dict[str, Any]:
|
||||||
|
node_ids = sorted({node for group in groups for node in group["node_ids"]})
|
||||||
|
if len(node_ids) > MAX_COVERAGE_NODES:
|
||||||
|
raise ValueError("Node coverage supports at most 10000 distinct nodes")
|
||||||
|
positions = get_node_geographic_positions(network, node_ids)
|
||||||
|
links = get_node_coverage_links(network, node_ids) if parameters.get("split_by_connectivity") else []
|
||||||
|
coverage = build_node_coverage(groups, positions, links, **parameters)
|
||||||
|
payload = coverage.pop("map_payload")
|
||||||
|
if len(json.dumps(payload, ensure_ascii=False).encode("utf-8")) > 400 * 1024:
|
||||||
|
raise ValueError("Coverage exceeds presentation budget; narrow the node selection")
|
||||||
|
return {"coverage": coverage, "map_payload": payload}
|
||||||
@@ -51,12 +51,19 @@ def _geometry(value):
|
|||||||
def resolve_overlay(snapshot: TaskDataSnapshot, mode: str) -> dict:
|
def resolve_overlay(snapshot: TaskDataSnapshot, mode: str) -> dict:
|
||||||
"""Use recorded GeoJSON or recorded coordinates; never geocode or infer a boundary."""
|
"""Use recorded GeoJSON or recorded coordinates; never geocode or infer a boundary."""
|
||||||
data = snapshot.data
|
data = snapshot.data
|
||||||
|
# MCP persists its complete QueryResult envelope, unlike direct data queries.
|
||||||
|
if (isinstance(data, dict) and data.get("source") == snapshot.source
|
||||||
|
and isinstance(data.get("data"), (dict, list))):
|
||||||
|
data = data["data"]
|
||||||
candidates = [data]
|
candidates = [data]
|
||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
candidates.extend([data.get("map_payload"), data.get("drawing_payload")])
|
candidates.extend([data.get("map_payload"), data.get("drawing_payload")])
|
||||||
latest = data.get("latest_analysis")
|
latest = data.get("latest_analysis")
|
||||||
if isinstance(latest, dict):
|
if isinstance(latest, dict):
|
||||||
candidates.append(latest.get("map_payload"))
|
candidates.append(latest.get("map_payload"))
|
||||||
|
result = data.get("result")
|
||||||
|
if isinstance(result, dict):
|
||||||
|
candidates.append(result.get("map_payload"))
|
||||||
collection = next((item for item in candidates
|
collection = next((item for item in candidates
|
||||||
if isinstance(item, dict) and item.get("type") == "FeatureCollection"), None)
|
if isinstance(item, dict) and item.get("type") == "FeatureCollection"), None)
|
||||||
if collection is not None:
|
if collection is not None:
|
||||||
@@ -88,7 +95,7 @@ def resolve_overlay(snapshot: TaskDataSnapshot, mode: str) -> dict:
|
|||||||
properties = feature.get("properties") or {}
|
properties = feature.get("properties") or {}
|
||||||
require(isinstance(properties, dict), "OVERLAY_GEOMETRY_INVALID", "Feature properties are invalid", status=422)
|
require(isinstance(properties, dict), "OVERLAY_GEOMETRY_INVALID", "Feature properties are invalid", status=422)
|
||||||
normalized = {"type": "Feature", "geometry": geometry, "properties": dict(properties)}
|
normalized = {"type": "Feature", "geometry": geometry, "properties": dict(properties)}
|
||||||
if isinstance(feature.get("id"), (str, int)) and not isinstance(feature.get("id"), bool):
|
if isinstance(feature.get("id"), str) or _number(feature.get("id")):
|
||||||
normalized["id"] = feature["id"]
|
normalized["id"] = feature["id"]
|
||||||
if mode == "heatmap":
|
if mode == "heatmap":
|
||||||
weight = properties.get("weight", 1)
|
weight = properties.get("weight", 1)
|
||||||
@@ -97,3 +104,17 @@ def resolve_overlay(snapshot: TaskDataSnapshot, mode: str) -> dict:
|
|||||||
normalized["properties"]["weight"] = weight
|
normalized["properties"]["weight"] = weight
|
||||||
features.append(normalized)
|
features.append(normalized)
|
||||||
return {"type": "FeatureCollection", "features": features}
|
return {"type": "FeatureCollection", "features": features}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_overlay_stage(data: dict, stage: str | None) -> None:
|
||||||
|
"""An event-impact claim must be supported by the saved scenario geometry."""
|
||||||
|
if stage != "event_impact":
|
||||||
|
return
|
||||||
|
features = data.get("features", [])
|
||||||
|
require(bool(features) and all(
|
||||||
|
(feature.get("properties") or {}).get("impact_phase") in {
|
||||||
|
"after_burst_before_intervention", "after_contamination_before_intervention",
|
||||||
|
} for feature in features
|
||||||
|
), "OVERLAY_IMPACT_PHASE_MISMATCH",
|
||||||
|
"事件影响阶段须引用处置前情景覆盖;关阀及供水失源覆盖请使用 isolation_plan,不能替代不可用的事件降压结果",
|
||||||
|
status=422)
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
"""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", "water_pollution"}
|
||||||
|
|
||||||
|
|
||||||
|
def track_work_order(task: dict, action: dict) -> bool:
|
||||||
|
if task.get("workflow") not in RECOVERY_WORKFLOWS or action["kind"] != "create_work_order":
|
||||||
|
return False
|
||||||
|
receipt = action.get("receipt") or {}
|
||||||
|
if action.get("execution_status") != "succeeded" or receipt.get("adapter") != "peripheral_sample":
|
||||||
|
return False
|
||||||
|
context = task.setdefault("handling_context", {})
|
||||||
|
orders = context.setdefault("work_orders", {})
|
||||||
|
order_id = receipt["receipt_id"]
|
||||||
|
if order_id not in orders:
|
||||||
|
proposed_at = action.get("proposed_at")
|
||||||
|
for prior in orders.values():
|
||||||
|
terminal_at = prior.get("terminal_recorded_at")
|
||||||
|
# Only a new decision after the failed field attempt replaces it.
|
||||||
|
# A delayed receipt for an older independent dispatch does not.
|
||||||
|
if (not prior.get("superseded_by") and prior["status"] in {"failed", "cancelled"}
|
||||||
|
and proposed_at and terminal_at
|
||||||
|
and datetime.fromisoformat(proposed_at) > datetime.fromisoformat(terminal_at)):
|
||||||
|
prior["superseded_by"] = order_id
|
||||||
|
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)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def accept_completion(task: dict, feedback: dict, *, recorded_at: datetime | None = None) -> 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"])
|
||||||
|
if feedback["status"] in {"failed", "cancelled"}:
|
||||||
|
if recorded_at is not None and not order.get("terminal_recorded_at"):
|
||||||
|
order["terminal_recorded_at"] = recorded_at.isoformat()
|
||||||
|
else:
|
||||||
|
order.pop("terminal_recorded_at", None)
|
||||||
|
# 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"]
|
||||||
|
if task.get("workflow") == "water_pollution":
|
||||||
|
order["feedback_evidence_id"] = (feedback.get("evidence") or {}).get("evidence_id")
|
||||||
|
else:
|
||||||
|
order.pop("returned_at", None)
|
||||||
|
if order.get("superseded_by"):
|
||||||
|
return True
|
||||||
|
orders = [item for item in context["work_orders"].values() if not item.get("superseded_by")]
|
||||||
|
if orders and all(item["status"] == "completed" for item in orders):
|
||||||
|
returned_at = max((item["returned_at"] for item in orders), key=datetime.fromisoformat)
|
||||||
|
if task.get("workflow") == "water_pollution":
|
||||||
|
# A completion receipt resumes review, never the pressure-based
|
||||||
|
# automatic recovery calculation. Source revisions may carry new
|
||||||
|
# sampling or retest facts even when the field time is unchanged.
|
||||||
|
context.update(phase="field_review", title="水污染处置反馈复核", returned_at=returned_at)
|
||||||
|
context.pop("verification_evidence_id", None)
|
||||||
|
return True
|
||||||
|
previous = context.get("returned_at")
|
||||||
|
if (previous is None or datetime.fromisoformat(previous) != datetime.fromisoformat(returned_at)
|
||||||
|
or context.get("phase") == "waiting_work_order"):
|
||||||
|
context.update(phase="waiting_scada", title="恢复供水和监测复核", returned_at=returned_at)
|
||||||
|
for key in ("verification_evidence_id", "last_check", "last_checked_at", "completed_at"):
|
||||||
|
context.pop(key, None)
|
||||||
|
else:
|
||||||
|
context.update(phase="waiting_work_order", title="等待现场工单返回")
|
||||||
|
context.pop("verification_evidence_id", None)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_recovery(rows: list[dict], *, device_ids: list[str], returned_at: datetime,
|
||||||
|
incident_at: datetime, interval_seconds: int) -> dict:
|
||||||
|
"""Verify the first SCADA sample strictly after the field feedback time T."""
|
||||||
|
base = {"status": "waiting", "device_ids": device_ids, "returned_at": returned_at.isoformat(),
|
||||||
|
"required_samples": 1, "interval_seconds": interval_seconds,
|
||||||
|
"selection_method": "first_sample_after_feedback",
|
||||||
|
"baseline_method": "normal_same_phase_range_plus_3_robust_sigma"}
|
||||||
|
if not device_ids:
|
||||||
|
return {**base, "reason": "monitoring_configuration_missing"}
|
||||||
|
if returned_at < incident_at:
|
||||||
|
return {**base, "reason": "completion_precedes_incident"}
|
||||||
|
selected = set(device_ids)
|
||||||
|
baseline: dict[tuple[str, int], list[float]] = {}
|
||||||
|
observations: dict[datetime, dict[str, float]] = {}
|
||||||
|
origin = incident_at - timedelta(days=15)
|
||||||
|
for row in rows:
|
||||||
|
if row["device_id"] not in selected:
|
||||||
|
continue
|
||||||
|
at = row["time"] if isinstance(row["time"], datetime) else datetime.fromisoformat(row["time"])
|
||||||
|
if at > returned_at:
|
||||||
|
# Keep the nearest frame even when it contains null/invalid values;
|
||||||
|
# never skip it in order to find a later normal-looking sample.
|
||||||
|
observations.setdefault(at, {})
|
||||||
|
if row.get("quality") not in {None, "valid", "corrected"}:
|
||||||
|
continue
|
||||||
|
value = row.get("cleaned_value")
|
||||||
|
if value is None:
|
||||||
|
value = row.get("monitored_value")
|
||||||
|
if value is None or not isfinite(float(value)):
|
||||||
|
continue
|
||||||
|
value = float(value)
|
||||||
|
phase = int(at.timestamp()) % 86400
|
||||||
|
if origin <= at < incident_at:
|
||||||
|
baseline.setdefault((row["device_id"], phase), []).append(value)
|
||||||
|
elif at > returned_at:
|
||||||
|
observations[at][row["device_id"]] = value
|
||||||
|
if not observations:
|
||||||
|
return {**base, "reason": "post_completion_reading_missing"}
|
||||||
|
at = min(observations)
|
||||||
|
values = observations[at]
|
||||||
|
phase = int(at.timestamp()) % 86400
|
||||||
|
checks = []
|
||||||
|
for device in device_ids:
|
||||||
|
reference = baseline.get((device, phase), [])
|
||||||
|
if device not in values or len(reference) < 15:
|
||||||
|
checks.append({"device_id": device, "status": "missing_data"})
|
||||||
|
continue
|
||||||
|
center = median(reference)
|
||||||
|
sigma = 1.4826 * median(abs(value - center) for value in reference)
|
||||||
|
margin = max(3 * sigma, abs(center) * 1e-6, 1e-9)
|
||||||
|
lower, upper = min(reference) - margin, max(reference) + margin
|
||||||
|
checks.append({"device_id": device, "value": values[device], "lower": lower, "upper": upper,
|
||||||
|
"normal_day_count": len(reference),
|
||||||
|
"status": "normal" if lower <= values[device] <= upper else "abnormal"})
|
||||||
|
sample = {"at": at.isoformat(), "devices": checks}
|
||||||
|
if all(item["status"] == "normal" for item in checks):
|
||||||
|
return {**base, "status": "passed", "verified_through": at.isoformat(), "samples": [sample]}
|
||||||
|
return {**base, "reason": "nearest_post_completion_reading_not_normal_or_incomplete", "sample": sample}
|
||||||
|
|
||||||
|
|
||||||
|
async def reconcile_recoveries(service: OperationalTaskService, timescale: AsyncConnection,
|
||||||
|
settings: ProjectSettings, *, limit: int = 20,
|
||||||
|
created_from: datetime | None = None) -> int:
|
||||||
|
"""Poll only persisted recovery waits; publish evidence before waking the Agent."""
|
||||||
|
from app.domain.schemas.operational_task import OperationalTaskSummary, StoredEvidenceRecord
|
||||||
|
from app.domain.schemas.operational_data import TaskDataSnapshot
|
||||||
|
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
|
||||||
|
|
||||||
|
async with service.repo.transaction():
|
||||||
|
candidates = await service.repo.recovery_candidates(limit, created_from, service.execution_scope)
|
||||||
|
resumed = 0
|
||||||
|
for task in candidates:
|
||||||
|
if task.get("execution_scope") != service.execution_scope:
|
||||||
|
continue
|
||||||
|
context = task["handling_context"]
|
||||||
|
returned_at = datetime.fromisoformat(context["returned_at"])
|
||||||
|
incident_at = datetime.fromisoformat(task["trigger"]["occurred_at"])
|
||||||
|
interval = settings.scada_monitoring_interval_seconds
|
||||||
|
roles = settings.scada_monitoring
|
||||||
|
devices = list(dict.fromkeys(roles.pressure_device_ids + roles.outlet_flow_device_ids)) if roles else []
|
||||||
|
source = "scada_readings"
|
||||||
|
through = service._now()
|
||||||
|
report_revision = task.get("current_report_revision")
|
||||||
|
if (context.get("device_ids") and context.get("device_selection_version") == 2
|
||||||
|
and context.get("source_report_revision") == report_revision):
|
||||||
|
devices = context["device_ids"]
|
||||||
|
interval = context.get("interval_seconds", interval)
|
||||||
|
else:
|
||||||
|
# Full-network browsing is discovery, not a declaration that every
|
||||||
|
# device must recover for this incident. Prefer the report's scope.
|
||||||
|
report = await service.repo.get_latest_report(task["task_id"])
|
||||||
|
declared = ((report or {}).get("report") or {}).get("scada_device_ids") or []
|
||||||
|
devices.extend(declared)
|
||||||
|
for evidence in ([] if declared else await service.repo.list_evidence(task["task_id"])):
|
||||||
|
saved = evidence.get("data_snapshot") or {}
|
||||||
|
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():
|
||||||
|
# Field completion authorizes a separate recovery observation even
|
||||||
|
# when the incident was found in a frozen replay. Keep that replay's
|
||||||
|
# query window and batch boundary unchanged.
|
||||||
|
rows = await ScadaRepository.get_recovery_scada_readings(
|
||||||
|
timescale, devices, incident_at, returned_at, through, baseline_limit=100_001,
|
||||||
|
) if through > returned_at and devices else []
|
||||||
|
result = ({"status": "waiting", "reason": "reading_budget_exceeded"} if len(rows) > 100_000 else
|
||||||
|
evaluate_recovery(rows, device_ids=devices, returned_at=returned_at,
|
||||||
|
incident_at=incident_at, interval_seconds=interval or 0))
|
||||||
|
async with service.repo.transaction():
|
||||||
|
await service.repo.lock(f"task:{task['task_id']}")
|
||||||
|
current = await service._task(UUID(task["task_id"]))
|
||||||
|
# A new receipt, cancellation or another poll may have won the race.
|
||||||
|
if (current.get("handling_context") != context or current["status"] in {"cancelled", "failed"}
|
||||||
|
or current.get("current_report_revision") != report_revision):
|
||||||
|
continue
|
||||||
|
now = service._now()
|
||||||
|
if devices:
|
||||||
|
current["handling_context"].update(device_ids=devices, interval_seconds=interval or 0,
|
||||||
|
device_selection_version=2, source_report_revision=report_revision)
|
||||||
|
current["handling_context"]["last_checked_at"] = now.isoformat()
|
||||||
|
current["handling_context"]["last_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
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Require persisted complete SCADA input before final investigation conclusions."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.domain.domain_errors import require
|
||||||
|
|
||||||
|
|
||||||
|
def require_complete_scada_evidence(task: dict, evidence: list[dict], refs: list[str]) -> None:
|
||||||
|
replay = task.get("source", {}).get("replay_context")
|
||||||
|
if task.get("workflow") not in {"scada_anomaly", "water_pollution"} and not replay:
|
||||||
|
return
|
||||||
|
target = datetime.fromisoformat(
|
||||||
|
replay["knowledge_cutoff"] if replay else task["trigger"]["occurred_at"]
|
||||||
|
)
|
||||||
|
for item in evidence:
|
||||||
|
if item["evidence_id"] not in refs:
|
||||||
|
continue
|
||||||
|
snapshot = item.get("data_snapshot") or {}
|
||||||
|
if snapshot.get("source") != "query_scada_readings" or snapshot.get("truncated"):
|
||||||
|
continue
|
||||||
|
result = snapshot.get("data") or {}
|
||||||
|
data = result.get("data") if isinstance(result, dict) else None
|
||||||
|
coverage = data.get("coverage") if isinstance(data, dict) else None
|
||||||
|
if not coverage or coverage.get("mode") != "all_scada" or coverage.get("query_complete") is not True:
|
||||||
|
continue
|
||||||
|
window = result.get("window") or {}
|
||||||
|
if not window.get("start") or not window.get("end"):
|
||||||
|
continue
|
||||||
|
start, end = (datetime.fromisoformat(window[key]) for key in ("start", "end"))
|
||||||
|
if not start <= target <= end:
|
||||||
|
continue
|
||||||
|
if replay and (coverage.get("replay_id") != replay["replay_id"]
|
||||||
|
or coverage.get("batch_sequence") != replay["batch_sequence"]
|
||||||
|
or end != target):
|
||||||
|
continue
|
||||||
|
return
|
||||||
|
require(False, "SCADA_COVERAGE_REQUIRED",
|
||||||
|
"最终SCADA报告必须引用本轮全量读取证据。调用query_scada_readings并省略device_ids,"
|
||||||
|
"在任务时间窗内读取全部授权设备、完整属性与观测,核对coverage和缺测清单后再发布。",
|
||||||
|
status=409)
|
||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Iterable
|
from typing import TYPE_CHECKING, Iterable
|
||||||
from uuid import NAMESPACE_URL, UUID, uuid4, uuid5
|
from uuid import NAMESPACE_URL, UUID, uuid4, uuid5
|
||||||
|
|
||||||
from app.auth.permissions import (
|
from app.auth.permissions import (
|
||||||
@@ -19,6 +19,7 @@ from app.auth.permissions import (
|
|||||||
SIMULATION_VIEW,
|
SIMULATION_VIEW,
|
||||||
)
|
)
|
||||||
from app.domain.domain_errors import DomainError, require
|
from app.domain.domain_errors import DomainError, require
|
||||||
|
from app.services import task_questions
|
||||||
from app.domain.schemas.operational_task import (
|
from app.domain.schemas.operational_task import (
|
||||||
ActionDecisionCommand,
|
ActionDecisionCommand,
|
||||||
ActionExecuteCommand,
|
ActionExecuteCommand,
|
||||||
@@ -48,11 +49,14 @@ from app.domain.schemas.operational_task import (
|
|||||||
InvestigationReportRevision,
|
InvestigationReportRevision,
|
||||||
InvestigationReportReference,
|
InvestigationReportReference,
|
||||||
InvestigationProgressSnapshot,
|
InvestigationProgressSnapshot,
|
||||||
|
InvestigationProgress,
|
||||||
|
InvestigationDiagram,
|
||||||
IssueCommandPayload,
|
IssueCommandPayload,
|
||||||
NotificationAcknowledgeCommand,
|
NotificationAcknowledgeCommand,
|
||||||
OperationalTaskCreate,
|
OperationalTaskCreate,
|
||||||
OperationalTaskDetail,
|
OperationalTaskDetail,
|
||||||
OperationalTaskNotification,
|
OperationalTaskNotification,
|
||||||
|
WorkOrderNotificationReference,
|
||||||
OperationalTaskPermissions,
|
OperationalTaskPermissions,
|
||||||
OperationalTaskSchedule,
|
OperationalTaskSchedule,
|
||||||
OperationalTaskScheduleCreate,
|
OperationalTaskScheduleCreate,
|
||||||
@@ -65,10 +69,21 @@ from app.domain.schemas.operational_task import (
|
|||||||
TaskPresentationEventCommand,
|
TaskPresentationEventCommand,
|
||||||
TaskPresentationEventResult,
|
TaskPresentationEventResult,
|
||||||
TaskPresentationCursor,
|
TaskPresentationCursor,
|
||||||
|
TaskPresentationHistoryItem,
|
||||||
|
TaskPresentationHistoryPage,
|
||||||
)
|
)
|
||||||
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
|
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
|
||||||
from app.services.content_digest import digest
|
from app.services.content_digest import digest
|
||||||
from app.services.operational_presentation import resolve_overlay
|
from app.services.investigation_graph import adopt_legacy_investigation_graph, update_investigation_graph
|
||||||
|
from app.services.operational_presentation import resolve_overlay, validate_overlay_stage
|
||||||
|
from app.services.operational_recovery import accept_completion, track_work_order
|
||||||
|
from app.services.operational_scada_coverage import require_complete_scada_evidence
|
||||||
|
from app.services import scada_presentation_coverage
|
||||||
|
from app.services.time_comparison_chart import validate_time_comparison_chart
|
||||||
|
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
|
||||||
|
|
||||||
|
|
||||||
LEASE_DURATION = timedelta(seconds=60)
|
LEASE_DURATION = timedelta(seconds=60)
|
||||||
@@ -83,11 +98,13 @@ def _task_json(model: OperationalTaskSummary) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
class OperationalTaskService:
|
class OperationalTaskService:
|
||||||
def __init__(self, repository, context, permissions: Iterable[str], *, clock=None):
|
def __init__(self, repository: OperationalTaskRepository, context, permissions: Iterable[str], *, clock=None,
|
||||||
|
execution_scope: str = "production"):
|
||||||
self.repo = repository
|
self.repo = repository
|
||||||
self.context = context
|
self.context = context
|
||||||
self.permissions = frozenset(permissions)
|
self.permissions = frozenset(permissions)
|
||||||
self.clock = clock or (lambda: datetime.now(timezone.utc))
|
self.clock = clock or (lambda: datetime.now(timezone.utc))
|
||||||
|
self.execution_scope = execution_scope
|
||||||
|
|
||||||
def _now(self) -> datetime:
|
def _now(self) -> datetime:
|
||||||
now = self.clock()
|
now = self.clock()
|
||||||
@@ -97,47 +114,29 @@ class OperationalTaskService:
|
|||||||
def _actor(self) -> str:
|
def _actor(self) -> str:
|
||||||
return f"principal:{self.context.user_id}"
|
return f"principal:{self.context.user_id}"
|
||||||
|
|
||||||
@property
|
@staticmethod
|
||||||
def _supports_runs(self) -> bool:
|
def _require_run_owner(run: dict, command: AgentRunCommand) -> None:
|
||||||
return hasattr(self.repo, "get_active_run") and hasattr(self.repo, "update_run")
|
require(run.get("agent_session_id") == command.source_session_id,
|
||||||
|
"SESSION_MISMATCH", "Agent session does not own this run", status=409)
|
||||||
|
owner = run.get("agent_instance_id")
|
||||||
|
if owner is not None:
|
||||||
|
require(owner == command.agent_instance_id,
|
||||||
|
"AGENT_INSTANCE_MISMATCH", "Agent instance does not own this run", status=409)
|
||||||
|
|
||||||
async def _active_run(self, task: dict) -> dict | None:
|
async def _active_run(self, task: dict) -> dict | None:
|
||||||
if self._supports_runs:
|
|
||||||
return await self.repo.get_active_run(str(task["task_id"]))
|
return await self.repo.get_active_run(str(task["task_id"]))
|
||||||
if task.get("status") not in {"queued", "running"}:
|
|
||||||
return None
|
|
||||||
now = self._now()
|
|
||||||
return _json(OperationalTaskRun(
|
|
||||||
run_id=uuid5(NAMESPACE_URL, f"legacy-run:{task['task_id']}:{task['execution_generation']}"),
|
|
||||||
task_id=task["task_id"],
|
|
||||||
generation=task["execution_generation"],
|
|
||||||
execution_request_id=task["execution_request_id"],
|
|
||||||
status="running" if task["status"] == "running" else "queued",
|
|
||||||
revision=task["revision"],
|
|
||||||
agent_session_id=task.get("agent_session_id"),
|
|
||||||
lease_expires_at=task.get("agent_lease_expires_at"),
|
|
||||||
heartbeat_at=task.get("updated_at") if task["status"] == "running" else None,
|
|
||||||
started_at=task.get("created_at") if task["status"] == "running" else None,
|
|
||||||
dispatch_attempts=0,
|
|
||||||
next_dispatch_at=task.get("created_at") or now,
|
|
||||||
created_at=task.get("created_at") or now,
|
|
||||||
updated_at=task.get("updated_at") or now,
|
|
||||||
))
|
|
||||||
|
|
||||||
async def _append_event(self, **kwargs) -> int | None:
|
async def _append_event(self, **kwargs) -> int:
|
||||||
append_event = getattr(self.repo, "append_event", None)
|
return await self.repo.append_event(**kwargs)
|
||||||
return await append_event(**kwargs) if append_event is not None else None
|
|
||||||
|
|
||||||
async def _list_access_requests(self, task_id: UUID | str) -> list[dict]:
|
async def _list_access_requests(self, task_id: UUID | str) -> list[dict]:
|
||||||
list_access_requests = getattr(self.repo, "list_access_requests", None)
|
return await self.repo.list_access_requests(str(task_id))
|
||||||
if list_access_requests is None:
|
|
||||||
return []
|
|
||||||
return await list_access_requests(str(task_id))
|
|
||||||
|
|
||||||
def _permission_view(self) -> OperationalTaskPermissions:
|
def _permission_view(self) -> OperationalTaskPermissions:
|
||||||
permissions = self.permissions
|
permissions = self.permissions
|
||||||
return OperationalTaskPermissions(
|
return OperationalTaskPermissions(
|
||||||
can_publish_artifacts=OPERATIONAL_TASK_EXECUTE in permissions,
|
can_publish_artifacts=OPERATIONAL_TASK_EXECUTE in permissions,
|
||||||
|
can_answer_questions=OPERATIONAL_TASK_WRITE in permissions,
|
||||||
can_propose_actions=OPERATIONAL_TASK_WRITE in permissions,
|
can_propose_actions=OPERATIONAL_TASK_WRITE in permissions,
|
||||||
can_sync_external_feedback=OPERATIONAL_TASK_EXTERNAL_SYNC in permissions,
|
can_sync_external_feedback=OPERATIONAL_TASK_EXTERNAL_SYNC in permissions,
|
||||||
can_decide_work_orders=OPERATIONAL_ACTION_WORK_ORDER_APPROVE in permissions,
|
can_decide_work_orders=OPERATIONAL_ACTION_WORK_ORDER_APPROVE in permissions,
|
||||||
@@ -148,6 +147,14 @@ class OperationalTaskService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def create(self, command: OperationalTaskCreate) -> OperationalTaskDetail:
|
async def create(self, command: OperationalTaskCreate) -> OperationalTaskDetail:
|
||||||
|
if command.source.replay_context:
|
||||||
|
from app.infra.db.postgresql.scada_replays import ScadaReplayRepository
|
||||||
|
replay_context = command.source.replay_context
|
||||||
|
replay_doc = await ScadaReplayRepository(self.repo.connection, self.context.project_id).get(replay_context.replay_id)
|
||||||
|
require(replay_doc.get("delivered_until") is not None
|
||||||
|
and replay_context.knowledge_cutoff <= datetime.fromisoformat(replay_doc["delivered_until"])
|
||||||
|
and replay_context.batch_sequence <= replay_doc["batch_sequence"],
|
||||||
|
"REPLAY_SCOPE_INVALID", "回放调查不能使用尚未读取的批次", status=422)
|
||||||
body = _json(command)
|
body = _json(command)
|
||||||
request_id = str(command.request_id)
|
request_id = str(command.request_id)
|
||||||
scope = "task:create"
|
scope = "task:create"
|
||||||
@@ -172,6 +179,7 @@ class OperationalTaskService:
|
|||||||
now = self._now()
|
now = self._now()
|
||||||
task_id = uuid4()
|
task_id = uuid4()
|
||||||
task = OperationalTaskSummary(
|
task = OperationalTaskSummary(
|
||||||
|
execution_scope=self.execution_scope,
|
||||||
project_id=self.context.project_id,
|
project_id=self.context.project_id,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
task_type=command.task_type,
|
task_type=command.task_type,
|
||||||
@@ -206,6 +214,27 @@ class OperationalTaskService:
|
|||||||
await self._task_event(task_document, now)
|
await self._task_event(task_document, now)
|
||||||
return await self.detail(task_id)
|
return await self.detail(task_id)
|
||||||
|
|
||||||
|
async def advance_replay_investigation(self, task_id: UUID, replay_context: dict, request_id: UUID) -> None:
|
||||||
|
"""A recovery is new evidence, coalesced through the existing run lifecycle."""
|
||||||
|
scope = f"task:{task_id}:replay-evidence"
|
||||||
|
async with self.repo.transaction():
|
||||||
|
if await self._request_replay(scope, request_id, replay_context):
|
||||||
|
return
|
||||||
|
await self.repo.lock(f"task:{task_id}")
|
||||||
|
task = await self._task(task_id)
|
||||||
|
prior = task["source"].get("replay_context")
|
||||||
|
require(prior and prior["replay_id"] == replay_context["replay_id"], "REPLAY_SCOPE_INVALID",
|
||||||
|
"Replay evidence belongs to another investigation", status=409)
|
||||||
|
require(datetime.fromisoformat(prior["knowledge_cutoff"]) <= datetime.fromisoformat(replay_context["knowledge_cutoff"]),
|
||||||
|
"REPLAY_SCOPE_INVALID", "Replay evidence cannot move backwards", status=409)
|
||||||
|
task["source"]["replay_context"] = replay_context
|
||||||
|
task["source"]["external_revision"] = task["source"].get("external_revision", 1) + 1
|
||||||
|
now = self._now()
|
||||||
|
await self._requeue_task(task, now)
|
||||||
|
await self.repo.update_task(_task_json(OperationalTaskSummary.model_validate(task)))
|
||||||
|
await self._task_event(task, now)
|
||||||
|
await self._save_request(scope, request_id, replay_context, {"task_id": str(task_id)})
|
||||||
|
|
||||||
async def list(
|
async def list(
|
||||||
self, *, task_type: str | None, status: str | None, limit: int, offset: int,
|
self, *, task_type: str | None, status: str | None, limit: int, offset: int,
|
||||||
peripheral_work_order_id: UUID | None = None,
|
peripheral_work_order_id: UUID | None = None,
|
||||||
@@ -238,9 +267,10 @@ class OperationalTaskService:
|
|||||||
actions = await self.repo.list_actions(str(task_id))
|
actions = await self.repo.list_actions(str(task_id))
|
||||||
access_requests = await self._list_access_requests(task_id)
|
access_requests = await self._list_access_requests(task_id)
|
||||||
active_run = await self._active_run(task)
|
active_run = await self._active_run(task)
|
||||||
latest_run = (await self.repo.get_latest_run(str(task_id))
|
latest_run = await self.repo.get_latest_run(str(task_id))
|
||||||
if hasattr(self.repo, "get_latest_run") else active_run)
|
|
||||||
progress = await self.repo.get_latest_progress(str(task_id), task["execution_generation"])
|
progress = await self.repo.get_latest_progress(str(task_id), task["execution_generation"])
|
||||||
|
if progress is None:
|
||||||
|
progress = await self.repo.get_latest_graph_progress(str(task_id))
|
||||||
work_order_snapshot = None
|
work_order_snapshot = None
|
||||||
if task["task_type"] == "external_work_order" and task["source"].get("external_id"):
|
if task["task_type"] == "external_work_order" and task["source"].get("external_id"):
|
||||||
feedback = await self.repo.get_feedback(
|
feedback = await self.repo.get_feedback(
|
||||||
@@ -258,6 +288,7 @@ class OperationalTaskService:
|
|||||||
evidence=evidence,
|
evidence=evidence,
|
||||||
actions=actions,
|
actions=actions,
|
||||||
access_requests=access_requests,
|
access_requests=access_requests,
|
||||||
|
questions=await task_questions.list_questions(self, task_id),
|
||||||
permissions=self._permission_view(),
|
permissions=self._permission_view(),
|
||||||
work_order_snapshot=work_order_snapshot,
|
work_order_snapshot=work_order_snapshot,
|
||||||
)
|
)
|
||||||
@@ -322,6 +353,9 @@ class OperationalTaskService:
|
|||||||
selected_report=await self.report(task_id, report_revision) if report_revision is not None else None,
|
selected_report=await self.report(task_id, report_revision) if report_revision is not None else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def replay_context(self, task_id: UUID) -> dict | None:
|
||||||
|
return (await self._task(task_id)).get("source", {}).get("replay_context")
|
||||||
|
|
||||||
async def can_read_source(self, task_id: UUID, source: TaskDataSource) -> bool:
|
async def can_read_source(self, task_id: UUID, source: TaskDataSource) -> bool:
|
||||||
task = await self._task(task_id)
|
task = await self._task(task_id)
|
||||||
permission = SCADA_VIEW if source.startswith("scada_") else SIMULATION_VIEW
|
permission = SCADA_VIEW if source.startswith("scada_") else SIMULATION_VIEW
|
||||||
@@ -345,9 +379,8 @@ class OperationalTaskService:
|
|||||||
replay = await self._request_replay(scope, command.request_id, body)
|
replay = await self._request_replay(scope, command.request_id, body)
|
||||||
if replay:
|
if replay:
|
||||||
return TaskAccessRequest.model_validate(replay["access_request"])
|
return TaskAccessRequest.model_validate(replay["access_request"])
|
||||||
availability = getattr(self.repo, "access_requests_available", None)
|
|
||||||
require(
|
require(
|
||||||
availability is None or await availability(),
|
await self.repo.access_requests_available(),
|
||||||
"DEPENDENCY_UNAVAILABLE",
|
"DEPENDENCY_UNAVAILABLE",
|
||||||
"Task data access requests are not enabled for this project database",
|
"Task data access requests are not enabled for this project database",
|
||||||
status=503,
|
status=503,
|
||||||
@@ -441,6 +474,7 @@ class OperationalTaskService:
|
|||||||
task_type: str | None = None, task_created_from: datetime | None = None,
|
task_type: str | None = None, task_created_from: datetime | None = None,
|
||||||
) -> tuple[list[OperationalTaskRun], str | None]:
|
) -> tuple[list[OperationalTaskRun], str | None]:
|
||||||
rows = await self.repo.list_runs(
|
rows = await self.repo.list_runs(
|
||||||
|
execution_scope=self.execution_scope,
|
||||||
status=status,
|
status=status,
|
||||||
limit=limit + 1,
|
limit=limit + 1,
|
||||||
cursor_created_at=cursor_created_at,
|
cursor_created_at=cursor_created_at,
|
||||||
@@ -462,6 +496,9 @@ class OperationalTaskService:
|
|||||||
scope = f"run:{run_id}:claim"
|
scope = f"run:{run_id}:claim"
|
||||||
async with self.repo.transaction():
|
async with self.repo.transaction():
|
||||||
await self.repo.require_execution_storage()
|
await self.repo.require_execution_storage()
|
||||||
|
task = await self._task(task_id)
|
||||||
|
require(task.get("execution_scope") == self.execution_scope,
|
||||||
|
"EXECUTION_SCOPE_MISMATCH", "当前环境不能领取其他执行范围的任务", status=403)
|
||||||
replay = await self._request_replay(scope, command.request_id, body)
|
replay = await self._request_replay(scope, command.request_id, body)
|
||||||
if replay:
|
if replay:
|
||||||
return OperationalTaskRun.model_validate(replay["run"])
|
return OperationalTaskRun.model_validate(replay["run"])
|
||||||
@@ -483,6 +520,7 @@ class OperationalTaskService:
|
|||||||
status="running",
|
status="running",
|
||||||
revision=expected + 1,
|
revision=expected + 1,
|
||||||
agent_session_id=command.source_session_id,
|
agent_session_id=command.source_session_id,
|
||||||
|
agent_instance_id=command.agent_instance_id,
|
||||||
lease_expires_at=now + LEASE_DURATION,
|
lease_expires_at=now + LEASE_DURATION,
|
||||||
heartbeat_at=now,
|
heartbeat_at=now,
|
||||||
started_at=run.get("started_at") or now,
|
started_at=run.get("started_at") or now,
|
||||||
@@ -523,12 +561,13 @@ class OperationalTaskService:
|
|||||||
return OperationalTaskRun.model_validate(replay["run"])
|
return OperationalTaskRun.model_validate(replay["run"])
|
||||||
await self.repo.lock(f"run:{run_id}")
|
await self.repo.lock(f"run:{run_id}")
|
||||||
run = await self._run(task_id, run_id)
|
run = await self._run(task_id, run_id)
|
||||||
|
require(run.get("failure_code") != "SUPERSEDED_BY_REQUEUE", "RUN_SUPERSEDED",
|
||||||
|
"New task input superseded this run; the replacement run will continue", status=409)
|
||||||
require(run["revision"] == command.expected_revision, "VERSION_CONFLICT",
|
require(run["revision"] == command.expected_revision, "VERSION_CONFLICT",
|
||||||
"Run revision changed", status=409)
|
"Run revision changed", status=409)
|
||||||
require(run["status"] in {"claimed", "running"}, "INVALID_TRANSITION",
|
require(run["status"] in {"claimed", "running"}, "INVALID_TRANSITION",
|
||||||
"Only an active run has a lease", status=409)
|
"Only an active run has a lease", status=409)
|
||||||
require(run.get("agent_session_id") == command.source_session_id,
|
self._require_run_owner(run, command)
|
||||||
"SESSION_MISMATCH", "Agent session does not own this run", status=409)
|
|
||||||
now = self._now()
|
now = self._now()
|
||||||
self._require_active_run_lease(run, now)
|
self._require_active_run_lease(run, now)
|
||||||
expected = run["revision"]
|
expected = run["revision"]
|
||||||
@@ -560,7 +599,6 @@ class OperationalTaskService:
|
|||||||
return OperationalTaskRun.model_validate(await self._run(task_id, run_id))
|
return OperationalTaskRun.model_validate(await self._run(task_id, run_id))
|
||||||
|
|
||||||
async def _fail_run_messages(self, run_id, reason: str, now: datetime) -> None:
|
async def _fail_run_messages(self, run_id, reason: str, now: datetime) -> None:
|
||||||
if hasattr(self.repo, "fail_run_messages"):
|
|
||||||
await self.repo.fail_run_messages(str(run_id), reason, now)
|
await self.repo.fail_run_messages(str(run_id), reason, now)
|
||||||
|
|
||||||
async def fail_run(
|
async def fail_run(
|
||||||
@@ -580,6 +618,7 @@ class OperationalTaskService:
|
|||||||
replay = await self._request_replay(scope, command.request_id, body)
|
replay = await self._request_replay(scope, command.request_id, body)
|
||||||
if replay:
|
if replay:
|
||||||
return await self.detail(task_id)
|
return await self.detail(task_id)
|
||||||
|
await self.repo.lock(f"task:{task_id}")
|
||||||
await self.repo.lock(f"run:{run_id}")
|
await self.repo.lock(f"run:{run_id}")
|
||||||
run = await self._run(task_id, run_id)
|
run = await self._run(task_id, run_id)
|
||||||
require(run["revision"] == command.expected_revision, "VERSION_CONFLICT",
|
require(run["revision"] == command.expected_revision, "VERSION_CONFLICT",
|
||||||
@@ -587,6 +626,7 @@ class OperationalTaskService:
|
|||||||
require(run["status"] in {"queued", "claimed", "running"},
|
require(run["status"] in {"queued", "claimed", "running"},
|
||||||
"INVALID_TRANSITION", "Run is already terminal", status=409)
|
"INVALID_TRANSITION", "Run is already terminal", status=409)
|
||||||
now = self._now()
|
now = self._now()
|
||||||
|
await task_questions.cancel_pending(self, task_id, now)
|
||||||
expected = run["revision"]
|
expected = run["revision"]
|
||||||
run.update(
|
run.update(
|
||||||
status="cancelled", revision=expected + 1, finished_at=now,
|
status="cancelled", revision=expected + 1, finished_at=now,
|
||||||
@@ -624,6 +664,7 @@ class OperationalTaskService:
|
|||||||
require(await self.repo.get_active_run(str(task_id)) is None,
|
require(await self.repo.get_active_run(str(task_id)) is None,
|
||||||
"ACTIVE_RUN_EXISTS", "Task already has an active run", status=409)
|
"ACTIVE_RUN_EXISTS", "Task already has an active run", status=409)
|
||||||
now = self._now()
|
now = self._now()
|
||||||
|
await task_questions.cancel_pending(self, task_id, now)
|
||||||
generation = task["execution_generation"] + 1
|
generation = task["execution_generation"] + 1
|
||||||
run = self._new_run(task_id, generation, now)
|
run = self._new_run(task_id, generation, now)
|
||||||
await self.repo.insert_run(_json(run))
|
await self.repo.insert_run(_json(run))
|
||||||
@@ -708,11 +749,12 @@ class OperationalTaskService:
|
|||||||
async with self.repo.transaction():
|
async with self.repo.transaction():
|
||||||
for run in await self.repo.claim_expired_runs(
|
for run in await self.repo.claim_expired_runs(
|
||||||
limit, task_type=task_type, task_created_from=task_created_from,
|
limit, task_type=task_type, task_created_from=task_created_from,
|
||||||
|
execution_scope=self.execution_scope,
|
||||||
):
|
):
|
||||||
# Lease recovery already holds a row lock. Try rather than wait
|
# Lease recovery already holds a row lock. Try rather than wait
|
||||||
# for the shared run lock to avoid reversing the lock order of
|
# for the shared run lock to avoid reversing the lock order of
|
||||||
# message/finish transactions; a busy run is checked next tick.
|
# message/finish transactions; a busy run is checked next tick.
|
||||||
if hasattr(self.repo, "try_lock") and not await self.repo.try_lock(f"run:{run['run_id']}"):
|
if not await self.repo.try_lock(f"run:{run['run_id']}"):
|
||||||
continue
|
continue
|
||||||
task_id = UUID(str(run["task_id"]))
|
task_id = UUID(str(run["task_id"]))
|
||||||
expected = run["revision"]
|
expected = run["revision"]
|
||||||
@@ -724,6 +766,7 @@ class OperationalTaskService:
|
|||||||
timed_out = OperationalTaskRun.model_validate(run)
|
timed_out = OperationalTaskRun.model_validate(run)
|
||||||
await self.repo.update_run(_json(timed_out), expected)
|
await self.repo.update_run(_json(timed_out), expected)
|
||||||
await self._fail_run_messages(run["run_id"], "Run lease expired", now)
|
await self._fail_run_messages(run["run_id"], "Run lease expired", now)
|
||||||
|
await task_questions.cancel_pending(self, task_id, now)
|
||||||
await self._run_event(timed_out, now)
|
await self._run_event(timed_out, now)
|
||||||
task = await self._task(task_id)
|
task = await self._task(task_id)
|
||||||
timeout_count = await self.repo.consecutive_timed_out_runs(str(task_id))
|
timeout_count = await self.repo.consecutive_timed_out_runs(str(task_id))
|
||||||
@@ -778,6 +821,15 @@ class OperationalTaskService:
|
|||||||
"Only a running run accepts Agent artifacts", status=409)
|
"Only a running run accepts Agent artifacts", status=409)
|
||||||
now = self._now()
|
now = self._now()
|
||||||
self._require_active_run_lease(active_run, now)
|
self._require_active_run_lease(active_run, now)
|
||||||
|
if command.report:
|
||||||
|
for item in command.report.ui_blocks:
|
||||||
|
for element in item.block.spec.elements.values():
|
||||||
|
if element.type == "Chart":
|
||||||
|
validate_time_comparison_chart(element.props)
|
||||||
|
if command.report and (
|
||||||
|
command.report.stage == "final" or command.report.outcome == "insufficient_data"
|
||||||
|
):
|
||||||
|
await self._require_no_pending_calculations(task_id, active_run)
|
||||||
previous_report = await self.repo.get_latest_report(str(task_id))
|
previous_report = await self.repo.get_latest_report(str(task_id))
|
||||||
require(not previous_report or previous_report.get("execution_generation") != task["execution_generation"]
|
require(not previous_report or previous_report.get("execution_generation") != task["execution_generation"]
|
||||||
or previous_report["report"].get("outcome") != "insufficient_data",
|
or previous_report["report"].get("outcome") != "insufficient_data",
|
||||||
@@ -807,11 +859,27 @@ class OperationalTaskService:
|
|||||||
for item in await self.repo.list_actions(str(task_id))
|
for item in await self.repo.list_actions(str(task_id))
|
||||||
}
|
}
|
||||||
progress_revision = None
|
progress_revision = None
|
||||||
|
saved_diagram = None
|
||||||
|
if command.progress is not None or command.report is not None:
|
||||||
|
stored = await self.repo.get_latest_graph_progress(str(task_id))
|
||||||
|
if stored is not None:
|
||||||
|
saved_diagram = InvestigationProgressSnapshot.model_validate(stored).progress.diagram
|
||||||
|
elif command.progress is not None:
|
||||||
|
legacy = await self.repo.get_latest_diagram(str(task_id))
|
||||||
|
saved_diagram = InvestigationDiagram.model_validate(legacy) if legacy else None
|
||||||
if command.progress is not None:
|
if command.progress is not None:
|
||||||
require(command.run_id is not None, "RUN_REQUIRED",
|
require(command.run_id is not None, "RUN_REQUIRED",
|
||||||
"Progress must identify its current execution run", status=422)
|
"Progress must identify its current execution run", status=422)
|
||||||
if command.progress.diagram:
|
diagram = command.progress.diagram
|
||||||
for node in command.progress.diagram.node_refs:
|
if command.progress.graph_update is not None:
|
||||||
|
diagram = update_investigation_graph(saved_diagram, command.progress.graph_update)
|
||||||
|
elif saved_diagram is not None:
|
||||||
|
require(diagram is None or saved_diagram.edges is None, "GRAPH_UPDATE_REQUIRED",
|
||||||
|
"Use graph_update to patch saved nodes or extend the investigation graph", status=422)
|
||||||
|
if diagram is None:
|
||||||
|
diagram = adopt_legacy_investigation_graph(saved_diagram)
|
||||||
|
if diagram:
|
||||||
|
for node in diagram.node_refs:
|
||||||
self._require_evidence_refs(node.evidence_refs, known_evidence)
|
self._require_evidence_refs(node.evidence_refs, known_evidence)
|
||||||
require(all(str(ref) in actions for ref in node.action_refs),
|
require(all(str(ref) in actions for ref in node.action_refs),
|
||||||
"ACTION_NOT_FOUND", "Progress references an unknown action", status=422)
|
"ACTION_NOT_FOUND", "Progress references an unknown action", status=422)
|
||||||
@@ -820,9 +888,11 @@ class OperationalTaskService:
|
|||||||
snapshot = InvestigationProgressSnapshot(
|
snapshot = InvestigationProgressSnapshot(
|
||||||
revision=progress_revision, run_id=active_run["run_id"],
|
revision=progress_revision, run_id=active_run["run_id"],
|
||||||
execution_generation=task["execution_generation"],
|
execution_generation=task["execution_generation"],
|
||||||
source_session_id=command.source_session_id, published_at=now, progress=command.progress,
|
source_session_id=command.source_session_id, published_at=now,
|
||||||
|
progress=InvestigationProgress(summary=command.progress.summary, diagram=diagram),
|
||||||
)
|
)
|
||||||
await self.repo.save_progress(str(task_id), _json(snapshot))
|
await self.repo.save_progress(str(task_id), _json(snapshot))
|
||||||
|
saved_diagram = diagram
|
||||||
await self._append_event(
|
await self._append_event(
|
||||||
topic="operational.progress.changed", aggregate_type="operational_task_progress",
|
topic="operational.progress.changed", aggregate_type="operational_task_progress",
|
||||||
aggregate_id=str(task_id), aggregate_revision=progress_revision, occurred_at=now,
|
aggregate_id=str(task_id), aggregate_revision=progress_revision, occurred_at=now,
|
||||||
@@ -874,17 +944,32 @@ class OperationalTaskService:
|
|||||||
await self.repo.insert_plan(str(task_id), _json(snapshot))
|
await self.repo.insert_plan(str(task_id), _json(snapshot))
|
||||||
current_plan = snapshot
|
current_plan = snapshot
|
||||||
if command.report:
|
if command.report:
|
||||||
self._require_evidence_refs(command.report.evidence_refs, known_evidence)
|
report_input = command.report
|
||||||
for gap in command.report.data_gaps:
|
if saved_diagram is not None:
|
||||||
require(set(gap.evidence_refs) <= set(command.report.evidence_refs),
|
diagram = adopt_legacy_investigation_graph(saved_diagram)
|
||||||
|
report_input = report_input.model_copy(update={
|
||||||
|
"diagram": diagram,
|
||||||
|
"evidence_refs": list(dict.fromkeys([
|
||||||
|
*report_input.evidence_refs,
|
||||||
|
*(ref for node in diagram.node_refs for ref in node.evidence_refs),
|
||||||
|
])),
|
||||||
|
})
|
||||||
|
self._require_evidence_refs(report_input.evidence_refs, known_evidence)
|
||||||
|
if report_input.stage == "final":
|
||||||
|
require_complete_scada_evidence(
|
||||||
|
task, await self.repo.list_evidence(str(task_id)), report_input.evidence_refs,
|
||||||
|
)
|
||||||
|
await self._verify_scada_report_presentation(task, active_run, _json(report_input), now)
|
||||||
|
for gap in report_input.data_gaps:
|
||||||
|
require(set(gap.evidence_refs) <= set(report_input.evidence_refs),
|
||||||
"REPORT_GAP_EVIDENCE_INVALID",
|
"REPORT_GAP_EVIDENCE_INVALID",
|
||||||
"Gap evidence must be included in report evidence", status=422)
|
"Gap evidence must be included in report evidence", status=422)
|
||||||
if command.report.diagram:
|
if report_input.diagram:
|
||||||
for node in command.report.diagram.node_refs:
|
for node in report_input.diagram.node_refs:
|
||||||
self._require_evidence_refs(node.evidence_refs, set(command.report.evidence_refs))
|
self._require_evidence_refs(node.evidence_refs, set(report_input.evidence_refs))
|
||||||
require(all(str(ref) in actions for ref in node.action_refs),
|
require(all(str(ref) in actions for ref in node.action_refs),
|
||||||
"ACTION_NOT_FOUND", "Diagram references an unknown action", status=422)
|
"ACTION_NOT_FOUND", "Diagram references an unknown action", status=422)
|
||||||
if command.report.outcome == "insufficient_data":
|
if report_input.outcome == "insufficient_data":
|
||||||
require(not any(action.approval_status == "pending" or (
|
require(not any(action.approval_status == "pending" or (
|
||||||
action.approval_status == "approved" and action.execution_status in {"not_submitted", "submitted", "unknown"}
|
action.approval_status == "approved" and action.execution_status in {"not_submitted", "submitted", "unknown"}
|
||||||
) for action in actions.values()), "REPORT_ACTION_PENDING",
|
) for action in actions.values()), "REPORT_ACTION_PENDING",
|
||||||
@@ -894,7 +979,7 @@ class OperationalTaskService:
|
|||||||
revision=report_revision,
|
revision=report_revision,
|
||||||
execution_generation=task["execution_generation"],
|
execution_generation=task["execution_generation"],
|
||||||
source_session_id=command.source_session_id,
|
source_session_id=command.source_session_id,
|
||||||
published_at=now, published_by=self._actor(), report=command.report,
|
published_at=now, published_by=self._actor(), report=report_input,
|
||||||
)
|
)
|
||||||
report = await self._report_with_progress_diagram(task_id, report)
|
report = await self._report_with_progress_diagram(task_id, report)
|
||||||
await self.repo.insert_report(str(task_id), _json(report))
|
await self.repo.insert_report(str(task_id), _json(report))
|
||||||
@@ -907,20 +992,21 @@ class OperationalTaskService:
|
|||||||
metadata={"task_id": str(task_id), "run_id": str(active_run["run_id"])},
|
metadata={"task_id": str(task_id), "run_id": str(active_run["run_id"])},
|
||||||
)
|
)
|
||||||
previous_result = previous_report["report"] if previous_report else None
|
previous_result = previous_report["report"] if previous_report else None
|
||||||
if command.report.stage == "final" and (
|
effective_report = _json(report.report)
|
||||||
|
if report.report.stage == "final" and (
|
||||||
previous_report is None
|
previous_report is None
|
||||||
or previous_report.get("execution_generation") != task["execution_generation"]
|
or previous_report.get("execution_generation") != task["execution_generation"]
|
||||||
or previous_result.get("stage") != "final"
|
or previous_result.get("stage") != "final"
|
||||||
or any(previous_result.get(key, [] if key == "ui_blocks" else None) != _json(command.report).get(key)
|
or any(previous_result.get(key, [] if key == "ui_blocks" else None) != effective_report.get(key)
|
||||||
for key in ("current_conclusion", "conclusion_boundary", "outcome", "stop_reason",
|
for key in ("current_conclusion", "conclusion_boundary", "outcome", "stop_reason",
|
||||||
"evidence_refs", "recommendations", "pending_items", "data_gaps", "ui_blocks"))
|
"evidence_refs", "recommendations", "pending_items", "data_gaps", "ui_blocks", "scada_device_ids"))
|
||||||
):
|
):
|
||||||
await self._resolve_notifications(
|
await self._resolve_notifications(
|
||||||
task_id, kinds=["report_updated"], subject_type="task", subject_id=task_id,
|
task_id, kinds=["report_updated"], subject_type="task", subject_id=task_id,
|
||||||
now=now, reason="report_superseded",
|
now=now, reason="report_superseded",
|
||||||
)
|
)
|
||||||
await self._notify(
|
await self._notify(
|
||||||
task_id, "report_updated", task["title"], command.report.current_conclusion, now,
|
task_id, "report_updated", task["title"], report.report.current_conclusion, now,
|
||||||
report_reference=InvestigationReportReference(
|
report_reference=InvestigationReportReference(
|
||||||
execution_generation=task["execution_generation"], revision=report_revision,
|
execution_generation=task["execution_generation"], revision=report_revision,
|
||||||
),
|
),
|
||||||
@@ -944,25 +1030,9 @@ class OperationalTaskService:
|
|||||||
lease_expires_at=now + LEASE_DURATION,
|
lease_expires_at=now + LEASE_DURATION,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
)
|
)
|
||||||
if self._supports_runs:
|
|
||||||
await self.repo.update_run(
|
await self.repo.update_run(
|
||||||
_json(OperationalTaskRun.model_validate(active_run)), run_expected
|
_json(OperationalTaskRun.model_validate(active_run)), run_expected
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
# 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"
|
|
||||||
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"}:
|
|
||||||
task["lifecycle_status"] = task["status"]
|
|
||||||
task["agent_lease_expires_at"] = (
|
|
||||||
now + LEASE_DURATION if task["status"] == "running" else None
|
|
||||||
)
|
|
||||||
await self.repo.update_task(
|
|
||||||
_task_json(OperationalTaskSummary.model_validate(task))
|
|
||||||
)
|
|
||||||
await self._task_event(task, now)
|
await self._task_event(task, now)
|
||||||
response = {"plan_revision": plan_revision, "report_revision": report_revision}
|
response = {"plan_revision": plan_revision, "report_revision": report_revision}
|
||||||
if progress_revision is not None:
|
if progress_revision is not None:
|
||||||
@@ -982,6 +1052,29 @@ class OperationalTaskService:
|
|||||||
event_cursor=cursor,
|
event_cursor=cursor,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def presentation_history(
|
||||||
|
self, task_id: UUID, *, after: int = 0, through: int | None = None, limit: int = 100,
|
||||||
|
) -> TaskPresentationHistoryPage:
|
||||||
|
require(0 <= after and (through is None or through >= 0) and 1 <= limit <= 200,
|
||||||
|
"INVALID_ARGUMENT", "Invalid presentation history page", status=422)
|
||||||
|
async with self.repo.transaction():
|
||||||
|
await self._task(task_id)
|
||||||
|
cursor = await self.repo.current_event_cursor()
|
||||||
|
boundary = min(through, cursor) if through is not None else cursor
|
||||||
|
rows = await self.repo.list_presentation_events(str(task_id), after, boundary, limit + 1)
|
||||||
|
items = [TaskPresentationHistoryItem(
|
||||||
|
event_seq=row["event_seq"], occurred_at=row["occurred_at"],
|
||||||
|
run_id=row["metadata"]["run_id"],
|
||||||
|
execution_generation=row["metadata"]["execution_generation"],
|
||||||
|
command=row["metadata"]["command"],
|
||||||
|
narration=row["metadata"].get("narration"),
|
||||||
|
purpose=row["metadata"].get("purpose"),
|
||||||
|
) for row in rows[:limit]]
|
||||||
|
return TaskPresentationHistoryPage(
|
||||||
|
task_id=task_id, event_cursor=boundary, items=items,
|
||||||
|
next_after=items[-1].event_seq if len(rows) > limit else None,
|
||||||
|
)
|
||||||
|
|
||||||
async def publish_presentation_event(
|
async def publish_presentation_event(
|
||||||
self, task_id: UUID, command: TaskPresentationEventCommand
|
self, task_id: UUID, command: TaskPresentationEventCommand
|
||||||
) -> TaskPresentationEventResult:
|
) -> TaskPresentationEventResult:
|
||||||
@@ -1006,7 +1099,14 @@ class OperationalTaskService:
|
|||||||
now = self._now()
|
now = self._now()
|
||||||
self._require_active_run_lease(run, now)
|
self._require_active_run_lease(run, now)
|
||||||
payload = _json(command.command)
|
payload = _json(command.command)
|
||||||
|
if command.narration is not None:
|
||||||
|
known = {item["evidence_id"] for item in await self.repo.list_evidence(str(task_id))}
|
||||||
|
self._require_evidence_refs(command.narration.evidence_refs, known)
|
||||||
|
await self._record_scada_presentation(task, run, payload, now)
|
||||||
if command.command.action == "open_chart":
|
if command.command.action == "open_chart":
|
||||||
|
validate_time_comparison_chart(payload["chart"], allow_missing_current=bool(
|
||||||
|
payload["chart"].get("scada") and scada_presentation_coverage.applies(task),
|
||||||
|
))
|
||||||
known = {item["evidence_id"] for item in await self.repo.list_evidence(str(task_id))}
|
known = {item["evidence_id"] for item in await self.repo.list_evidence(str(task_id))}
|
||||||
self._require_evidence_refs(command.command.evidence_refs, known)
|
self._require_evidence_refs(command.command.evidence_refs, known)
|
||||||
elif command.command.action == "set_overlay":
|
elif command.command.action == "set_overlay":
|
||||||
@@ -1015,9 +1115,23 @@ class OperationalTaskService:
|
|||||||
evidence = StoredEvidenceRecord.model_validate(stored)
|
evidence = StoredEvidenceRecord.model_validate(stored)
|
||||||
require(evidence.data_snapshot is not None, "SNAPSHOT_NOT_FOUND",
|
require(evidence.data_snapshot is not None, "SNAPSHOT_NOT_FOUND",
|
||||||
"Overlay requires a Server-ingested immutable data snapshot", status=422)
|
"Overlay requires a Server-ingested immutable data snapshot", status=422)
|
||||||
payload["data"] = resolve_overlay(evidence.data_snapshot, command.command.mode)
|
resolved = resolve_overlay(evidence.data_snapshot, command.command.mode)
|
||||||
|
if command.command.data is not None:
|
||||||
|
supplied = resolve_overlay(
|
||||||
|
evidence.data_snapshot.model_copy(update={
|
||||||
|
"data": command.command.data.model_dump(mode="json", exclude_unset=True),
|
||||||
|
}), command.command.mode,
|
||||||
|
)
|
||||||
|
require(supplied == resolved, "OVERLAY_DATA_MISMATCH",
|
||||||
|
"Overlay data must match the immutable evidence snapshot", status=422)
|
||||||
|
validate_overlay_stage(resolved, command.narration.stage if command.narration else None)
|
||||||
|
payload["data"] = resolved
|
||||||
metadata = {"task_id": str(task_id), "run_id": str(run["run_id"]),
|
metadata = {"task_id": str(task_id), "run_id": str(run["run_id"]),
|
||||||
"execution_generation": run["generation"], "command": payload}
|
"execution_generation": run["generation"], "command": payload}
|
||||||
|
if command.narration is not None:
|
||||||
|
metadata["narration"] = _json(command.narration)
|
||||||
|
if command.purpose is not None:
|
||||||
|
metadata["purpose"] = command.purpose
|
||||||
try:
|
try:
|
||||||
size = len(json.dumps(metadata, ensure_ascii=False, allow_nan=False).encode("utf-8"))
|
size = len(json.dumps(metadata, ensure_ascii=False, allow_nan=False).encode("utf-8"))
|
||||||
except (ValueError, TypeError) as exc:
|
except (ValueError, TypeError) as exc:
|
||||||
@@ -1035,6 +1149,61 @@ class OperationalTaskService:
|
|||||||
await self._save_request(scope, command.request_id, body, _json(result))
|
await self._save_request(scope, command.request_id, body, _json(result))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
async def _record_scada_presentation(self, task: dict, run: dict, payload: dict, now: datetime) -> None:
|
||||||
|
if not scada_presentation_coverage.applies(task):
|
||||||
|
return
|
||||||
|
action = payload["action"]
|
||||||
|
targets = []
|
||||||
|
if action == "render_scada_analysis":
|
||||||
|
targets = [item["device_id"] for item in payload["items"]]
|
||||||
|
elif action in {"locate_features", "highlight_features"} and payload["layer"] == "scada":
|
||||||
|
targets = payload["feature_ids"]
|
||||||
|
elif action != "open_chart":
|
||||||
|
return
|
||||||
|
evidence = await self.repo.list_evidence(task["task_id"])
|
||||||
|
observations = scada_presentation_coverage.ScadaObservations(task, evidence)
|
||||||
|
document = await self.repo.get_scada_presentation(task["task_id"], str(run["run_id"]))
|
||||||
|
if action == "open_chart":
|
||||||
|
if payload["chart"].get("scada"):
|
||||||
|
known = {item["evidence_id"] for item in evidence}
|
||||||
|
self._require_evidence_refs(payload["evidence_refs"], known)
|
||||||
|
chart = scada_presentation_coverage.verify_chart(task, payload["chart"], evidence, payload["evidence_refs"])
|
||||||
|
targets = chart["device_ids"]
|
||||||
|
duplicates = [chart_id for chart_id, stored in (document or {}).get("charts", {}).items()
|
||||||
|
if chart_id != payload["chart_id"] and stored["role"] == chart["role"] == "comparison"
|
||||||
|
and any(set(stored.get("coverage", {}).get(device, {}).get("covered", []))
|
||||||
|
& set(chart["coverage"][device]["covered"])
|
||||||
|
for device in set(stored["device_ids"]) & set(targets))]
|
||||||
|
require(not duplicates, "SCADA_CHART_DUPLICATE_DEVICE",
|
||||||
|
f"设备同期图已存在,请更新原chart_id:{', '.join(duplicates)}", refs=duplicates)
|
||||||
|
document = scada_presentation_coverage.add_targets(document, targets, observations)
|
||||||
|
document["charts"][payload["chart_id"]] = chart
|
||||||
|
elif document and payload["chart_id"] in document["charts"]:
|
||||||
|
# Replacing a typed chart with a generic one removes its coverage,
|
||||||
|
# while cumulative targets prevent shrinking the required set.
|
||||||
|
del document["charts"][payload["chart_id"]]
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
document = scada_presentation_coverage.add_targets(document, targets, observations)
|
||||||
|
await self.repo.save_scada_presentation(task["task_id"], str(run["run_id"]), document, now)
|
||||||
|
|
||||||
|
async def _verify_scada_report_presentation(self, task: dict, run: dict, report: dict, now: datetime) -> None:
|
||||||
|
if not scada_presentation_coverage.applies(task):
|
||||||
|
return
|
||||||
|
if report.get("stage") != "final" and not report.get("scada_device_ids"):
|
||||||
|
return
|
||||||
|
evidence = await self.repo.list_evidence(task["task_id"])
|
||||||
|
observations = scada_presentation_coverage.ScadaObservations(task, evidence)
|
||||||
|
document = await self.repo.get_scada_presentation(task["task_id"], str(run["run_id"]))
|
||||||
|
targets = list(report.get("scada_device_ids") or [])
|
||||||
|
if targets:
|
||||||
|
document = scada_presentation_coverage.add_targets(document, targets, observations)
|
||||||
|
if report.get("stage") == "final":
|
||||||
|
scada_presentation_coverage.require_complete(task, document, observations)
|
||||||
|
if document:
|
||||||
|
await self.repo.save_scada_presentation(task["task_id"], str(run["run_id"]), document, now)
|
||||||
|
|
||||||
async def propose_action(
|
async def propose_action(
|
||||||
self, task_id: UUID, command: ActionProposalCommand
|
self, task_id: UUID, command: ActionProposalCommand
|
||||||
) -> HumanApprovalAction:
|
) -> HumanApprovalAction:
|
||||||
@@ -1048,6 +1217,7 @@ class OperationalTaskService:
|
|||||||
await self.repo.lock(f"task:{task_id}")
|
await self.repo.lock(f"task:{task_id}")
|
||||||
task = await self._task(task_id)
|
task = await self._task(task_id)
|
||||||
self._require_actionable_task(task)
|
self._require_actionable_task(task)
|
||||||
|
self._require_real_action(task, command.kind)
|
||||||
self._validate_action_payload(command)
|
self._validate_action_payload(command)
|
||||||
if task["task_type"] == "event_investigation":
|
if task["task_type"] == "event_investigation":
|
||||||
report = await self.repo.get_latest_report(str(task_id))
|
report = await self.repo.get_latest_report(str(task_id))
|
||||||
@@ -1099,6 +1269,7 @@ class OperationalTaskService:
|
|||||||
task_id, "action_approval_pending", task["title"], command.reason, now,
|
task_id, "action_approval_pending", task["title"], command.reason, now,
|
||||||
required_permission=approval_permission,
|
required_permission=approval_permission,
|
||||||
subject_type="action", subject_id=action.action_id,
|
subject_type="action", subject_id=action.action_id,
|
||||||
|
work_order_action=action if action.kind == "create_work_order" else None,
|
||||||
)
|
)
|
||||||
await self._save_request(
|
await self._save_request(
|
||||||
scope, command.request_id, body, {"action_id": str(action.action_id)}
|
scope, command.request_id, body, {"action_id": str(action.action_id)}
|
||||||
@@ -1116,8 +1287,10 @@ class OperationalTaskService:
|
|||||||
return HumanApprovalAction.model_validate(replay["action"])
|
return HumanApprovalAction.model_validate(replay["action"])
|
||||||
await self.repo.lock(f"task:{task_id}")
|
await self.repo.lock(f"task:{task_id}")
|
||||||
await self.repo.lock(f"action:{action_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)
|
||||||
action = await self._action(task_id, action_id)
|
action = await self._action(task_id, action_id)
|
||||||
|
self._require_real_action(task, action.kind)
|
||||||
require(action.kind == "create_work_order", "INVALID_ACTION_KIND",
|
require(action.kind == "create_work_order", "INVALID_ACTION_KIND",
|
||||||
"Only work-order drafts can be edited", status=422)
|
"Only work-order drafts can be edited", status=422)
|
||||||
require(action.version == command.expected_version, "VERSION_CONFLICT",
|
require(action.version == command.expected_version, "VERSION_CONFLICT",
|
||||||
@@ -1170,8 +1343,10 @@ class OperationalTaskService:
|
|||||||
return HumanApprovalAction.model_validate(replay["action"])
|
return HumanApprovalAction.model_validate(replay["action"])
|
||||||
await self.repo.lock(f"task:{task_id}")
|
await self.repo.lock(f"task:{task_id}")
|
||||||
await self.repo.lock(f"action:{action_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)
|
||||||
action = await self._action(task_id, action_id)
|
action = await self._action(task_id, action_id)
|
||||||
|
self._require_real_action(task, action.kind)
|
||||||
require(action.version == command.expected_version, "VERSION_CONFLICT",
|
require(action.version == command.expected_version, "VERSION_CONFLICT",
|
||||||
"Action version changed", status=409)
|
"Action version changed", status=409)
|
||||||
require(action.approval_status == "pending", "INVALID_TRANSITION",
|
require(action.approval_status == "pending", "INVALID_TRANSITION",
|
||||||
@@ -1215,8 +1390,10 @@ class OperationalTaskService:
|
|||||||
return HumanApprovalAction.model_validate(replay["action"])
|
return HumanApprovalAction.model_validate(replay["action"])
|
||||||
await self.repo.lock(f"task:{task_id}")
|
await self.repo.lock(f"task:{task_id}")
|
||||||
await self.repo.lock(f"action:{action_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)
|
||||||
action = await self._action(task_id, action_id)
|
action = await self._action(task_id, action_id)
|
||||||
|
self._require_real_action(task, action.kind)
|
||||||
require(action.version == command.expected_version, "VERSION_CONFLICT",
|
require(action.version == command.expected_version, "VERSION_CONFLICT",
|
||||||
"Action version changed", status=409)
|
"Action version changed", status=409)
|
||||||
require(action.approval_status == "approved", "INVALID_TRANSITION",
|
require(action.approval_status == "approved", "INVALID_TRANSITION",
|
||||||
@@ -1276,6 +1453,7 @@ class OperationalTaskService:
|
|||||||
|
|
||||||
async with self.repo.transaction():
|
async with self.repo.transaction():
|
||||||
await self.repo.lock(f"request:{scope}:{command.request_id}")
|
await self.repo.lock(f"request:{scope}:{command.request_id}")
|
||||||
|
await self.repo.lock(f"task:{task_id}")
|
||||||
await self.repo.lock(f"action:{action_id}")
|
await self.repo.lock(f"action:{action_id}")
|
||||||
current = await self._action(task_id, action_id)
|
current = await self._action(task_id, action_id)
|
||||||
require(current.version == action.version and current.execution_status == "unknown",
|
require(current.version == action.version and current.execution_status == "unknown",
|
||||||
@@ -1311,6 +1489,11 @@ class OperationalTaskService:
|
|||||||
)
|
)
|
||||||
if outcome.status in {"succeeded", "failed", "unsupported"}:
|
if outcome.status in {"succeeded", "failed", "unsupported"}:
|
||||||
task_document = await self._task(task_id)
|
task_document = await self._task(task_id)
|
||||||
|
tracked = track_work_order(task_document, _json(current))
|
||||||
|
if (tracked or task_document["status"] == "cancelled"
|
||||||
|
or (task_document.get("source") or {}).get("deleted_at")):
|
||||||
|
await self._keep_work_order_wait(task_document, outcome_recorded_at)
|
||||||
|
else:
|
||||||
await self._requeue_task(task_document, outcome_recorded_at)
|
await self._requeue_task(task_document, outcome_recorded_at)
|
||||||
await self.repo.update_task(
|
await self.repo.update_task(
|
||||||
_task_json(OperationalTaskSummary.model_validate(task_document))
|
_task_json(OperationalTaskSummary.model_validate(task_document))
|
||||||
@@ -1347,6 +1530,7 @@ class OperationalTaskService:
|
|||||||
replay = await self._request_replay(scope, command.request_id, body)
|
replay = await self._request_replay(scope, command.request_id, body)
|
||||||
if replay:
|
if replay:
|
||||||
return HumanApprovalAction.model_validate(replay["action"])
|
return HumanApprovalAction.model_validate(replay["action"])
|
||||||
|
await self.repo.lock(f"task:{task_id}")
|
||||||
await self.repo.lock(f"action:{action_id}")
|
await self.repo.lock(f"action:{action_id}")
|
||||||
current = await self._action(task_id, action_id)
|
current = await self._action(task_id, action_id)
|
||||||
require(current.version == action.version, "VERSION_CONFLICT",
|
require(current.version == action.version, "VERSION_CONFLICT",
|
||||||
@@ -1384,6 +1568,11 @@ class OperationalTaskService:
|
|||||||
)
|
)
|
||||||
if outcome.status in {"succeeded", "failed", "unsupported"}:
|
if outcome.status in {"succeeded", "failed", "unsupported"}:
|
||||||
task_document = await self._task(task_id)
|
task_document = await self._task(task_id)
|
||||||
|
tracked = track_work_order(task_document, _json(current))
|
||||||
|
if (tracked or task_document["status"] == "cancelled"
|
||||||
|
or (task_document.get("source") or {}).get("deleted_at")):
|
||||||
|
await self._keep_work_order_wait(task_document, outcome_recorded_at)
|
||||||
|
else:
|
||||||
await self._requeue_task(task_document, outcome_recorded_at)
|
await self._requeue_task(task_document, outcome_recorded_at)
|
||||||
await self.repo.update_task(
|
await self.repo.update_task(
|
||||||
_task_json(OperationalTaskSummary.model_validate(task_document))
|
_task_json(OperationalTaskSummary.model_validate(task_document))
|
||||||
@@ -1400,11 +1589,13 @@ class OperationalTaskService:
|
|||||||
body = _json(command)
|
body = _json(command)
|
||||||
scope = f"task:{task_id}:external-feedback"
|
scope = f"task:{task_id}:external-feedback"
|
||||||
async with self.repo.transaction():
|
async with self.repo.transaction():
|
||||||
|
await self.repo.lock(f"task:{task_id}")
|
||||||
|
task = await self._task(task_id)
|
||||||
|
require(task.get("execution_scope") == self.execution_scope,
|
||||||
|
"EXECUTION_SCOPE_MISMATCH", "当前环境不能同步其他执行范围的任务", status=403)
|
||||||
replay = await self._request_replay(scope, command.request_id, body)
|
replay = await self._request_replay(scope, command.request_id, body)
|
||||||
if replay:
|
if replay:
|
||||||
return ExternalFeedbackResult(task=await self.detail(task_id), **replay)
|
return ExternalFeedbackResult(task=await self.detail(task_id), **replay)
|
||||||
await self.repo.lock(f"task:{task_id}")
|
|
||||||
task = await self._task(task_id)
|
|
||||||
if task["task_type"] == "event_investigation":
|
if task["task_type"] == "event_investigation":
|
||||||
self._require_actionable_task(task)
|
self._require_actionable_task(task)
|
||||||
own_source = task["source"].get("external_id")
|
own_source = task["source"].get("external_id")
|
||||||
@@ -1486,8 +1677,8 @@ class OperationalTaskService:
|
|||||||
"EVIDENCE_CONFLICT", "Evidence ids are immutable", status=409)
|
"EVIDENCE_CONFLICT", "Evidence ids are immutable", status=409)
|
||||||
else:
|
else:
|
||||||
await self.repo.insert_evidence(str(task_id), _json(evidence))
|
await self.repo.insert_evidence(str(task_id), _json(evidence))
|
||||||
self._apply_feedback_status(task, command.status)
|
|
||||||
if task["task_type"] == "external_work_order":
|
if task["task_type"] == "external_work_order":
|
||||||
|
self._apply_feedback_status(task, command.status)
|
||||||
task["source"] = {
|
task["source"] = {
|
||||||
**task["source"],
|
**task["source"],
|
||||||
"external_revision": command.source_revision,
|
"external_revision": command.source_revision,
|
||||||
@@ -1504,8 +1695,29 @@ class OperationalTaskService:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
if task["task_type"] == "event_investigation":
|
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)
|
||||||
|
linked_work_order = accept_completion(task, body, recorded_at=now)
|
||||||
if matching_source:
|
if matching_source:
|
||||||
task["source"] = {**task["source"], "external_revision": command.source_revision}
|
task["source"] = {**task["source"], "external_revision": command.source_revision}
|
||||||
|
feedback_order = (task.get("handling_context") or {}).get("work_orders", {}).get(
|
||||||
|
command.source_event_id.removeprefix("peripheral_sample:"), {})
|
||||||
|
if linked_work_order and (feedback_order.get("superseded_by")
|
||||||
|
or task["handling_context"].get("phase") == "completed"):
|
||||||
|
# A receipt edit with the same completion time does not
|
||||||
|
# reopen an incident that already passed recovery review.
|
||||||
|
task.update(revision=task["revision"] + 1, updated_at=now)
|
||||||
|
elif (linked_work_order and command.status not in {"failed", "cancelled"}
|
||||||
|
and task["handling_context"].get("phase") in {"waiting_work_order", "waiting_scada"}):
|
||||||
|
# Progress is persisted, not a reason to restart analysis.
|
||||||
|
# Completed field work waits for causal SCADA evidence;
|
||||||
|
# reconcile_recoveries wakes the same task when it is ready.
|
||||||
|
await self._keep_work_order_wait(task, now)
|
||||||
|
elif (linked_work_order and task["handling_context"].get("phase") == "verification_ready"
|
||||||
|
and await self.repo.get_active_run(str(task_id)) is not None):
|
||||||
|
await self._keep_work_order_wait(task, now)
|
||||||
|
else:
|
||||||
await self._requeue_task(task, now)
|
await self._requeue_task(task, now)
|
||||||
else:
|
else:
|
||||||
task.update(revision=task["revision"] + 1, updated_at=now)
|
task.update(revision=task["revision"] + 1, updated_at=now)
|
||||||
@@ -1543,6 +1755,29 @@ class OperationalTaskService:
|
|||||||
task_id, "task_failed",
|
task_id, "task_failed",
|
||||||
task["title"], command.detail, now,
|
task["title"], command.detail, now,
|
||||||
)
|
)
|
||||||
|
if command.source_revision > 1 and task["task_type"] in {"external_work_order", "event_investigation"}:
|
||||||
|
# The feedback, its notification and outbox event commit together.
|
||||||
|
# Duplicate request/source versions return above, before publishing.
|
||||||
|
work_order_action = next((item for item in await self.repo.list_actions(str(task_id))
|
||||||
|
if item["kind"] == "create_work_order" and item.get("receipt")
|
||||||
|
and command.source_event_id == f"{item['receipt']['adapter']}:{item['receipt']['receipt_id']}"), None)
|
||||||
|
is_work_order_feedback = (task["task_type"] == "external_work_order"
|
||||||
|
or work_order_action is not None
|
||||||
|
or (task["task_type"] == "event_investigation" and matching_source))
|
||||||
|
if is_work_order_feedback:
|
||||||
|
await self._notify(
|
||||||
|
task_id, "work_order_feedback", "收到工单反馈",
|
||||||
|
command.evidence.summary if command.evidence else command.detail, now,
|
||||||
|
subject_type="action" if work_order_action else "task",
|
||||||
|
subject_id=UUID(str(work_order_action["action_id"])) if work_order_action else task_id,
|
||||||
|
work_order_action=HumanApprovalAction.model_validate(work_order_action) if work_order_action else None,
|
||||||
|
work_order_reference=WorkOrderNotificationReference(
|
||||||
|
source_event_id=command.source_event_id,
|
||||||
|
source_revision=command.source_revision,
|
||||||
|
evidence_id=command.evidence.evidence_id if command.evidence else None,
|
||||||
|
status=command.status,
|
||||||
|
),
|
||||||
|
)
|
||||||
response = {
|
response = {
|
||||||
"source_event_id": command.source_event_id,
|
"source_event_id": command.source_event_id,
|
||||||
"source_revision": command.source_revision,
|
"source_revision": command.source_revision,
|
||||||
@@ -1557,11 +1792,13 @@ class OperationalTaskService:
|
|||||||
body = _json(command)
|
body = _json(command)
|
||||||
scope = f"task:{task_id}:withdraw-external-source"
|
scope = f"task:{task_id}:withdraw-external-source"
|
||||||
async with self.repo.transaction():
|
async with self.repo.transaction():
|
||||||
|
await self.repo.lock(f"task:{task_id}")
|
||||||
|
task = await self._task(task_id)
|
||||||
|
require(task.get("execution_scope") == self.execution_scope,
|
||||||
|
"EXECUTION_SCOPE_MISMATCH", "当前环境不能同步其他执行范围的任务", status=403)
|
||||||
replay = await self._request_replay(scope, command.request_id, body)
|
replay = await self._request_replay(scope, command.request_id, body)
|
||||||
if replay:
|
if replay:
|
||||||
return await self.detail(task_id)
|
return await self.detail(task_id)
|
||||||
await self.repo.lock(f"task:{task_id}")
|
|
||||||
task = await self._task(task_id)
|
|
||||||
source = task["source"]
|
source = task["source"]
|
||||||
require(
|
require(
|
||||||
task["task_type"] == "external_work_order"
|
task["task_type"] == "external_work_order"
|
||||||
@@ -1866,6 +2103,14 @@ class OperationalTaskService:
|
|||||||
task_ids.append(task_id)
|
task_ids.append(task_id)
|
||||||
return DispatchDueSchedulesResult(task_ids=task_ids, dispatched_at=now)
|
return DispatchDueSchedulesResult(task_ids=task_ids, dispatched_at=now)
|
||||||
|
|
||||||
|
async def _require_no_pending_calculations(self, task_id: UUID, run: dict) -> None:
|
||||||
|
pending = await self.repo.pending_calculation_jobs(
|
||||||
|
str(task_id), str(run["run_id"]), str(run["execution_request_id"]),
|
||||||
|
)
|
||||||
|
require(not pending, "PENDING_CALCULATIONS",
|
||||||
|
"Wait for submitted calculations before publishing a final report or completing the run",
|
||||||
|
refs=pending, status=409, recovery="retry_same_request")
|
||||||
|
|
||||||
async def _finish_run(
|
async def _finish_run(
|
||||||
self, task_id: UUID, run_id: UUID, command: AgentRunCommand,
|
self, task_id: UUID, run_id: UUID, command: AgentRunCommand,
|
||||||
*, outcome: str, failure_code: str | None = None,
|
*, outcome: str, failure_code: str | None = None,
|
||||||
@@ -1877,20 +2122,23 @@ class OperationalTaskService:
|
|||||||
replay = await self._request_replay(scope, command.request_id, body)
|
replay = await self._request_replay(scope, command.request_id, body)
|
||||||
if replay:
|
if replay:
|
||||||
return await self.detail(task_id)
|
return await self.detail(task_id)
|
||||||
|
await self.repo.lock(f"task:{task_id}")
|
||||||
await self.repo.lock(f"run:{run_id}")
|
await self.repo.lock(f"run:{run_id}")
|
||||||
run = await self._run(task_id, run_id)
|
run = await self._run(task_id, run_id)
|
||||||
require(run["revision"] == command.expected_revision, "VERSION_CONFLICT",
|
require(run["revision"] == command.expected_revision, "VERSION_CONFLICT",
|
||||||
"Run revision changed", status=409)
|
"Run revision changed", status=409)
|
||||||
require(run["status"] in {"claimed", "running"}, "INVALID_TRANSITION",
|
require(run["status"] in {"claimed", "running"}, "INVALID_TRANSITION",
|
||||||
"Only an active run can finish", status=409)
|
"Only an active run can finish", status=409)
|
||||||
require(run.get("agent_session_id") == command.source_session_id,
|
self._require_run_owner(run, command)
|
||||||
"SESSION_MISMATCH", "Agent session does not own this run", status=409)
|
|
||||||
now = self._now()
|
now = self._now()
|
||||||
self._require_active_run_lease(run, now)
|
self._require_active_run_lease(run, now)
|
||||||
task = await self._task(task_id)
|
task = await self._task(task_id)
|
||||||
final_outcome = outcome
|
final_outcome = outcome
|
||||||
|
pending_question = any(q["status"] == "pending" and str(q["run_id"]) == str(run_id)
|
||||||
|
for q in await task_questions.list_questions(self, task_id))
|
||||||
|
actions = []
|
||||||
if outcome == "succeeded":
|
if outcome == "succeeded":
|
||||||
if hasattr(self.repo, "pending_run_messages"):
|
await self._require_no_pending_calculations(task_id, run)
|
||||||
pending_messages = await self.repo.pending_run_messages(str(run_id))
|
pending_messages = await self.repo.pending_run_messages(str(run_id))
|
||||||
require(not pending_messages, "PENDING_AGENT_MESSAGES",
|
require(not pending_messages, "PENDING_AGENT_MESSAGES",
|
||||||
"Run has messages not yet entered into Agent context",
|
"Run has messages not yet entered into Agent context",
|
||||||
@@ -1915,7 +2163,23 @@ class OperationalTaskService:
|
|||||||
and plan.source_session_id == command.source_session_id
|
and plan.source_session_id == command.source_session_id
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
if not same_generation or report.report.stage != "final":
|
actions = [HumanApprovalAction.model_validate(item)
|
||||||
|
for item in await self.repo.list_actions(str(task_id))]
|
||||||
|
work_order_wait = task["task_type"] == "event_investigation" and (
|
||||||
|
(task.get("handling_context") or {}).get("phase") in {"waiting_work_order", "waiting_scada"}
|
||||||
|
or any(action.kind == "create_work_order" and (
|
||||||
|
action.approval_status == "pending" or (
|
||||||
|
action.approval_status == "approved"
|
||||||
|
and action.execution_status in {"not_submitted", "submitted", "unknown"}
|
||||||
|
)) for action in actions)
|
||||||
|
)
|
||||||
|
if pending_question:
|
||||||
|
lifecycle = "waiting_human"
|
||||||
|
elif same_generation and work_order_wait and report.report.stage == "interim":
|
||||||
|
# Ending a run is distinct from closing an incident. Keep
|
||||||
|
# the current stage report and release the execution lease.
|
||||||
|
lifecycle = "waiting_human"
|
||||||
|
elif not same_generation or report.report.stage != "final":
|
||||||
final_outcome = "failed"
|
final_outcome = "failed"
|
||||||
lifecycle = "failed"
|
lifecycle = "failed"
|
||||||
failure_code = "ARTIFACT_INCOMPLETE"
|
failure_code = "ARTIFACT_INCOMPLETE"
|
||||||
@@ -1923,16 +2187,33 @@ class OperationalTaskService:
|
|||||||
"Run requires a final report from the same generation"
|
"Run requires a final report from the same generation"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
actions = [
|
await self._verify_scada_report_presentation(task, run, _json(report.report), now)
|
||||||
HumanApprovalAction.model_validate(item)
|
|
||||||
for item in await self.repo.list_actions(str(task_id))
|
|
||||||
]
|
|
||||||
access_requests = [
|
access_requests = [
|
||||||
TaskAccessRequest.model_validate(item)
|
TaskAccessRequest.model_validate(item)
|
||||||
for item in await self._list_access_requests(task_id)
|
for item in await self._list_access_requests(task_id)
|
||||||
]
|
]
|
||||||
if task["task_type"] == "event_investigation":
|
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:
|
||||||
|
if task.get("workflow") == "water_pollution":
|
||||||
|
orders = [item for item in recovery.get("work_orders", {}).values()
|
||||||
|
if not item.get("superseded_by")]
|
||||||
|
feedback_refs = [item.get("feedback_evidence_id") for item in orders]
|
||||||
|
require(recovery.get("phase") in {"field_review", "completed"} and bool(orders)
|
||||||
|
and all(item["status"] == "completed" for item in orders)
|
||||||
|
and all(ref and ref in report.report.evidence_refs for ref in feedback_refs),
|
||||||
|
"POLLUTION_FEEDBACK_REQUIRED", "水污染最终报告必须复核并引用全部最新现场反馈证据", status=409)
|
||||||
|
verification_id = feedback_refs[-1]
|
||||||
|
else:
|
||||||
|
verification_id = recovery.get("verification_evidence_id")
|
||||||
|
require(verification_id in report.report.evidence_refs,
|
||||||
|
"RECOVERY_EVIDENCE_REQUIRED", "最终复核报告必须引用恢复监测证据", status=409)
|
||||||
|
require(report.report.outcome == "concluded", "RECOVERY_EVIDENCE_REQUIRED",
|
||||||
|
"恢复复核通过后才能结束事件", status=409)
|
||||||
|
recovery.update(phase="completed", title=("水污染处置反馈已复核" if task.get("workflow") == "water_pollution"
|
||||||
|
else "恢复供水复核成功"), completed_at=now.isoformat())
|
||||||
|
await self._resolve_investigation_alerts(str(task_id), verification_id)
|
||||||
elif report.report.outcome == "insufficient_data":
|
elif report.report.outcome == "insufficient_data":
|
||||||
terminal = all(node.status in {"completed", "skipped", "cancelled"}
|
terminal = all(node.status in {"completed", "skipped", "cancelled"}
|
||||||
for node in plan.plan.nodes)
|
for node in plan.plan.nodes)
|
||||||
@@ -1960,6 +2241,7 @@ class OperationalTaskService:
|
|||||||
run_model = OperationalTaskRun.model_validate(run)
|
run_model = OperationalTaskRun.model_validate(run)
|
||||||
await self.repo.update_run(_json(run_model), expected)
|
await self.repo.update_run(_json(run_model), expected)
|
||||||
if final_outcome != "succeeded":
|
if final_outcome != "succeeded":
|
||||||
|
await task_questions.cancel_pending(self, task_id, now)
|
||||||
await self._fail_run_messages(run_id, failure_code or "Run failed", now)
|
await self._fail_run_messages(run_id, failure_code or "Run failed", now)
|
||||||
task.update(
|
task.update(
|
||||||
lifecycle_status=lifecycle,
|
lifecycle_status=lifecycle,
|
||||||
@@ -1980,7 +2262,8 @@ class OperationalTaskService:
|
|||||||
failure_detail or "工况任务执行失败",
|
failure_detail or "工况任务执行失败",
|
||||||
now,
|
now,
|
||||||
)
|
)
|
||||||
elif lifecycle == "waiting_human":
|
elif (lifecycle == "waiting_human" and not pending_question
|
||||||
|
and (task.get("handling_context") or {}).get("phase") not in {"waiting_work_order", "waiting_scada"}):
|
||||||
pending_actions = any(
|
pending_actions = any(
|
||||||
action.approval_status == "pending" for action in actions
|
action.approval_status == "pending" for action in actions
|
||||||
)
|
)
|
||||||
@@ -2007,11 +2290,31 @@ class OperationalTaskService:
|
|||||||
)
|
)
|
||||||
return run
|
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
|
||||||
|
|
||||||
|
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:
|
async def _task(self, task_id: UUID) -> dict:
|
||||||
task = await self.repo.get_task(str(task_id))
|
task = await self.repo.get_task(str(task_id))
|
||||||
require(task is not None, "TASK_NOT_FOUND", "Operational task not found", status=404)
|
require(task is not None, "TASK_NOT_FOUND", "Operational task not found", status=404)
|
||||||
return task
|
return task
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _require_real_action(task: dict, kind: str) -> None:
|
||||||
|
require(kind == "create_work_order" or not (task.get("source") or {}).get("replay_context"),
|
||||||
|
"REPLAY_ACTION_DISABLED",
|
||||||
|
"回放任务不支持设备命令;可生成工单草稿,由有权限的用户确认发布", status=409)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _require_actionable_task(task: dict) -> None:
|
def _require_actionable_task(task: dict) -> None:
|
||||||
source = task.get("source") or {}
|
source = task.get("source") or {}
|
||||||
@@ -2075,8 +2378,10 @@ class OperationalTaskService:
|
|||||||
return "running"
|
return "running"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _investigation_status(actions: Iterable[HumanApprovalAction]) -> str:
|
def _investigation_status(actions: Iterable[HumanApprovalAction], task: dict | None = None) -> str:
|
||||||
"""Only recorded business actions can keep an investigation waiting."""
|
"""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(
|
return "waiting_human" if any(
|
||||||
action.approval_status == "pending" or (
|
action.approval_status == "pending" or (
|
||||||
action.approval_status == "approved"
|
action.approval_status == "approved"
|
||||||
@@ -2192,8 +2497,23 @@ class OperationalTaskService:
|
|||||||
}[status]
|
}[status]
|
||||||
task["agent_lease_expires_at"] = None
|
task["agent_lease_expires_at"] = None
|
||||||
|
|
||||||
|
async def _keep_work_order_wait(self, task: dict, now: datetime) -> None:
|
||||||
|
"""Persist a field wait without revoking a run or allocating a generation."""
|
||||||
|
if task["status"] != "cancelled" and not (task.get("source") or {}).get("deleted_at"):
|
||||||
|
active = await self.repo.get_active_run(str(task["task_id"]))
|
||||||
|
if active is not None:
|
||||||
|
task.update(
|
||||||
|
lifecycle_status="open", status="queued" if active["status"] == "queued" else "running",
|
||||||
|
agent_session_id=active.get("agent_session_id"),
|
||||||
|
agent_lease_expires_at=active.get("lease_expires_at"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
task.update(lifecycle_status="waiting_human", status="waiting_human",
|
||||||
|
agent_session_id=None, agent_lease_expires_at=None)
|
||||||
|
task.update(revision=task["revision"] + 1, updated_at=now)
|
||||||
|
|
||||||
async def _requeue_task(self, task: dict, now: datetime) -> None:
|
async def _requeue_task(self, task: dict, now: datetime) -> None:
|
||||||
if self._supports_runs:
|
await task_questions.cancel_pending(self, UUID(str(task["task_id"])), now)
|
||||||
active_run = await self.repo.get_active_run(str(task["task_id"]))
|
active_run = await self.repo.get_active_run(str(task["task_id"]))
|
||||||
while active_run is not None:
|
while active_run is not None:
|
||||||
await self.repo.lock(f"run:{active_run['run_id']}")
|
await self.repo.lock(f"run:{active_run['run_id']}")
|
||||||
@@ -2238,7 +2558,6 @@ class OperationalTaskService:
|
|||||||
agent_session_id=None, agent_lease_expires_at=None, updated_at=now,
|
agent_session_id=None, agent_lease_expires_at=None, updated_at=now,
|
||||||
)
|
)
|
||||||
run = self._new_run(task_id, generation, now)
|
run = self._new_run(task_id, generation, now)
|
||||||
if self._supports_runs:
|
|
||||||
await self.repo.insert_run(_json(run))
|
await self.repo.insert_run(_json(run))
|
||||||
await self._run_event(run, now, queued=True)
|
await self._run_event(run, now, queued=True)
|
||||||
|
|
||||||
@@ -2247,6 +2566,8 @@ class OperationalTaskService:
|
|||||||
*, required_permission: str | None = None,
|
*, required_permission: str | None = None,
|
||||||
subject_type: str = "task", subject_id: UUID | None = None,
|
subject_type: str = "task", subject_id: UUID | None = None,
|
||||||
report_reference: InvestigationReportReference | None = None,
|
report_reference: InvestigationReportReference | None = None,
|
||||||
|
work_order_reference: WorkOrderNotificationReference | None = None,
|
||||||
|
work_order_action: HumanApprovalAction | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
notification = OperationalTaskNotification(
|
notification = OperationalTaskNotification(
|
||||||
notification_id=uuid4(), task_id=task_id, kind=kind,
|
notification_id=uuid4(), task_id=task_id, kind=kind,
|
||||||
@@ -2255,6 +2576,8 @@ class OperationalTaskService:
|
|||||||
lifecycle_status="active", subject_type=subject_type,
|
lifecycle_status="active", subject_type=subject_type,
|
||||||
subject_id=subject_id or task_id,
|
subject_id=subject_id or task_id,
|
||||||
report_reference=report_reference,
|
report_reference=report_reference,
|
||||||
|
work_order_reference=work_order_reference,
|
||||||
|
work_order_action=work_order_action,
|
||||||
)
|
)
|
||||||
await self.repo.insert_notification(_json(notification))
|
await self.repo.insert_notification(_json(notification))
|
||||||
await self._append_event(
|
await self._append_event(
|
||||||
@@ -2276,10 +2599,7 @@ class OperationalTaskService:
|
|||||||
now: datetime,
|
now: datetime,
|
||||||
reason: str,
|
reason: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
resolve = getattr(self.repo, "resolve_notifications", None)
|
notifications = await self.repo.resolve_notifications(
|
||||||
if resolve is None:
|
|
||||||
return
|
|
||||||
notifications = await resolve(
|
|
||||||
str(task_id),
|
str(task_id),
|
||||||
kinds=kinds,
|
kinds=kinds,
|
||||||
subject_type=subject_type,
|
subject_type=subject_type,
|
||||||
@@ -2364,6 +2684,7 @@ class OperationalTaskService:
|
|||||||
due_at: datetime, now: datetime,
|
due_at: datetime, now: datetime,
|
||||||
) -> OperationalTaskSummary:
|
) -> OperationalTaskSummary:
|
||||||
return OperationalTaskSummary(
|
return OperationalTaskSummary(
|
||||||
|
execution_scope=self.execution_scope,
|
||||||
project_id=self.context.project_id, task_id=task_id,
|
project_id=self.context.project_id, task_id=task_id,
|
||||||
task_type="scheduled_flow", workflow=schedule.schedule_key,
|
task_type="scheduled_flow", workflow=schedule.schedule_key,
|
||||||
lifecycle_status="open", status="queued", priority=schedule.priority, revision=1,
|
lifecycle_status="open", status="queued", priority=schedule.priority, revision=1,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from collections.abc import Iterator
|
|||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import NamedTemporaryFile
|
from tempfile import NamedTemporaryFile
|
||||||
|
import os
|
||||||
|
|
||||||
from app.native.wndb.inp.exporter import dump_inp
|
from app.native.wndb.inp.exporter import dump_inp
|
||||||
|
|
||||||
@@ -17,9 +18,11 @@ def temporary_project_inp(
|
|||||||
version: str = "2",
|
version: str = "2",
|
||||||
) -> Iterator[Path]:
|
) -> Iterator[Path]:
|
||||||
"""Export one project model to an isolated INP and remove it afterwards."""
|
"""Export one project model to an isolated INP and remove it afterwards."""
|
||||||
PROJECT_INP_DIRECTORY.mkdir(parents=True, exist_ok=True)
|
job_directory = os.environ.get("TJWATER_CALCULATION_JOB_DIR")
|
||||||
|
directory = Path(job_directory) / "inp" if job_directory else PROJECT_INP_DIRECTORY
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
with NamedTemporaryFile(
|
with NamedTemporaryFile(
|
||||||
dir=PROJECT_INP_DIRECTORY,
|
dir=directory,
|
||||||
prefix=f"{purpose}_",
|
prefix=f"{purpose}_",
|
||||||
suffix=".inp",
|
suffix=".inp",
|
||||||
delete=False,
|
delete=False,
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""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 pollution_signal(document: dict, event: dict) -> bool:
|
||||||
|
"""Water-quality measurements have their own incident investigation workflow."""
|
||||||
|
if event.get("category") == "data_quality":
|
||||||
|
return False
|
||||||
|
devices = {item["device_id"]: item.get("device_type") for item in document.get("devices", [])}
|
||||||
|
ids = event.get("device_ids") or next((item.get("device_ids", [])
|
||||||
|
for item in (document.get("config") or {}).get("rules", [])
|
||||||
|
if item["rule_id"] == event.get("rule_id")), [])
|
||||||
|
return bool(ids) and all(devices.get(device) in {"quality", "water_quality"} for device in ids)
|
||||||
|
|
||||||
|
|
||||||
|
def diagnostic_signal(document: dict, event: dict) -> bool:
|
||||||
|
"""Never revive a persisted fixed-percentage event as new diagnosis evidence."""
|
||||||
|
if event.get("category") == "data_quality" or pollution_signal(document, event):
|
||||||
|
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)
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
"""Verify monitoring charts against observations and require run-local device coverage."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from math import isclose
|
||||||
|
import re
|
||||||
|
|
||||||
|
from app.domain.domain_errors import require
|
||||||
|
|
||||||
|
|
||||||
|
DISPLAY_TIMEZONE = timezone(timedelta(hours=8))
|
||||||
|
METRICS = {"pressure": "pressure", "pipe_flow": "flow", "flow": "flow", "quality": "quality", "water_quality": "quality"}
|
||||||
|
|
||||||
|
|
||||||
|
def applies(task: dict) -> bool:
|
||||||
|
if task.get("workflow") not in {"scada_anomaly", "water_pollution"} and not task.get("source", {}).get("replay_context"):
|
||||||
|
return False
|
||||||
|
# Recovery has its own Server-verified post-repair evidence and finalization gate.
|
||||||
|
return (task.get("handling_context") or {}).get("phase") not in {"verification_ready", "field_review", "completed"}
|
||||||
|
|
||||||
|
|
||||||
|
def cutoff(task: dict) -> datetime:
|
||||||
|
replay = task.get("source", {}).get("replay_context") or {}
|
||||||
|
return datetime.fromisoformat(replay.get("knowledge_cutoff") or task["trigger"]["occurred_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def _unit(value: str) -> str:
|
||||||
|
return value.replace("³", "3").replace(" ", "").lower()
|
||||||
|
|
||||||
|
|
||||||
|
class ScadaObservations:
|
||||||
|
def __init__(self, task: dict, evidence: list[dict], refs: list[str] | None = None):
|
||||||
|
self.devices: dict[str, dict] = {}
|
||||||
|
self.values: dict[tuple[str, datetime], dict] = {}
|
||||||
|
self.windows: list[tuple[set[str], set[str], datetime, datetime]] = []
|
||||||
|
self.frozen_limits: list[tuple[set[str], datetime]] = []
|
||||||
|
target = cutoff(task)
|
||||||
|
replay = task.get("source", {}).get("replay_context") or {}
|
||||||
|
for item in evidence:
|
||||||
|
snapshot = item.get("data_snapshot") or {}
|
||||||
|
result = snapshot.get("data") or {}
|
||||||
|
if snapshot.get("source") != "query_scada_readings" or not isinstance(result, dict):
|
||||||
|
continue
|
||||||
|
data = result.get("data")
|
||||||
|
coverage = data.get("coverage", {}) if isinstance(data, dict) else {}
|
||||||
|
if replay and coverage and (coverage.get("replay_id") != replay["replay_id"]
|
||||||
|
or coverage.get("batch_sequence") != replay["batch_sequence"]):
|
||||||
|
continue
|
||||||
|
window = result.get("window") or {}
|
||||||
|
if not window.get("start") or not window.get("end"):
|
||||||
|
continue
|
||||||
|
start, end = (datetime.fromisoformat(window[key]) for key in ("start", "end"))
|
||||||
|
if end > target:
|
||||||
|
continue
|
||||||
|
devices = data.get("devices", []) if isinstance(data, dict) else []
|
||||||
|
for device in devices:
|
||||||
|
self.devices[str(device["device_id"])] = device
|
||||||
|
if refs is not None and item["evidence_id"] not in refs:
|
||||||
|
continue
|
||||||
|
rows = data.get("readings", []) if isinstance(data, dict) else data
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
continue
|
||||||
|
ids = {str(row["device_id"]) for row in rows if "device_id" in row}
|
||||||
|
outside = set(coverage.get("outside_frozen_scope_device_ids", []))
|
||||||
|
if coverage.get("query_complete") is True:
|
||||||
|
ids.update(coverage.get("queried_device_ids", []))
|
||||||
|
ids.update(str(device["device_id"]) for device in devices)
|
||||||
|
page = result.get("page") or {}
|
||||||
|
complete = not (snapshot.get("truncated") or result.get("truncated")
|
||||||
|
or page.get("has_more") or page.get("offset", 0))
|
||||||
|
if complete:
|
||||||
|
self.windows.append((ids, outside, start, end))
|
||||||
|
if replay and coverage.get("query_complete") and coverage.get("frozen_start"):
|
||||||
|
self.frozen_limits.append((ids, datetime.fromisoformat(coverage["frozen_start"])))
|
||||||
|
for row in rows:
|
||||||
|
if "time" in row and "device_id" in row:
|
||||||
|
at = datetime.fromisoformat(row["time"])
|
||||||
|
if start <= at <= end:
|
||||||
|
observed = self.values.setdefault((str(row["device_id"]), at), {})
|
||||||
|
observed.update({field: row[field] for field in ("monitored_value", "cleaned_value") if field in row})
|
||||||
|
|
||||||
|
def absent_reason(self, device: str, at: datetime) -> str | None:
|
||||||
|
for ids, outside, start, end in self.windows:
|
||||||
|
if device in ids and start <= at <= end:
|
||||||
|
return "outside_frozen_scope" if device in outside else "no_readings"
|
||||||
|
for ids, frozen_start in self.frozen_limits:
|
||||||
|
if device in ids and at < frozen_start:
|
||||||
|
return "outside_frozen_window"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def window_is_empty(self, device: str, start: datetime, end: datetime) -> bool:
|
||||||
|
if any(key == device and start <= at <= end for key, at in self.values):
|
||||||
|
return False
|
||||||
|
intervals = [(left, right) for ids, _, left, right in self.windows if device in ids]
|
||||||
|
intervals.extend((start, frozen_start) for ids, frozen_start in self.frozen_limits if device in ids)
|
||||||
|
through = start
|
||||||
|
for left, right in sorted(intervals):
|
||||||
|
if left > through:
|
||||||
|
break
|
||||||
|
through = max(through, right)
|
||||||
|
if through >= end:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def period(self, device: str, start: datetime, end: datetime) -> timedelta:
|
||||||
|
frequency = str(self.devices[device].get("transmission_frequency") or "")
|
||||||
|
match = re.fullmatch(r"(\d+):([0-5]\d):([0-5]\d)", frequency)
|
||||||
|
if match:
|
||||||
|
hours, minutes, seconds = map(int, match.groups())
|
||||||
|
period = timedelta(hours=hours, minutes=minutes, seconds=seconds)
|
||||||
|
else:
|
||||||
|
minutes = re.fullmatch(r"(\d+)\s*(?:min|分钟)", frequency)
|
||||||
|
times = sorted(at for key, at in self.values if key == device and start <= at <= end)
|
||||||
|
period = (timedelta(minutes=int(minutes[1])) if minutes else
|
||||||
|
min((right - left for left, right in zip(times, times[1:])), default=timedelta(0)))
|
||||||
|
require(period > timedelta(0), "SCADA_CHART_EVIDENCE_REQUIRED",
|
||||||
|
f"设备 {device} 缺少可核实的采样周期,请读取完整设备属性", refs=[device])
|
||||||
|
return period
|
||||||
|
|
||||||
|
def slots(self, device: str, start: datetime, end: datetime) -> list[datetime]:
|
||||||
|
"""Keep the business bounds exact, aligning slots to the observed sampling phase."""
|
||||||
|
period = self.period(device, start, end)
|
||||||
|
observed = sorted(at for key, at in self.values if key == device and start <= at <= end)
|
||||||
|
historical = sorted(at for key, at in self.values if key == device and at < start)
|
||||||
|
# Referenced historical observations preserve phase even if current data is absent.
|
||||||
|
anchor = (observed[0] if observed else historical[-1] if historical else
|
||||||
|
start.astimezone(DISPLAY_TIMEZONE).replace(hour=0, minute=0, second=0, microsecond=0))
|
||||||
|
first = start + (anchor - start) % period
|
||||||
|
return [first + period * index for index in range(int((end - first) // period) + 1)]
|
||||||
|
|
||||||
|
|
||||||
|
def add_targets(document: dict | None, devices: list[str], observations: ScadaObservations) -> dict:
|
||||||
|
result = deepcopy(document) if document else {"version": 1, "targets": [], "charts": {}}
|
||||||
|
targets = set(result["targets"])
|
||||||
|
for device in devices:
|
||||||
|
require(device in observations.devices, "SCADA_CHART_EVIDENCE_REQUIRED",
|
||||||
|
f"设备 {device} 未出现在本轮截止时间内的完整设备证据中", refs=[device])
|
||||||
|
if observations.devices[device].get("device_type") in METRICS:
|
||||||
|
targets.add(device)
|
||||||
|
result["targets"] = sorted(targets)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def verify_chart(task: dict, chart: dict, evidence: list[dict], refs: list[str]) -> dict:
|
||||||
|
"""Return verified coverage; mutate only the outgoing chart's Server-derived gaps."""
|
||||||
|
context = chart["scada"]
|
||||||
|
end = cutoff(task)
|
||||||
|
start = end - timedelta(hours=3)
|
||||||
|
require(datetime.fromisoformat(context["window_start"]) == start
|
||||||
|
and datetime.fromisoformat(context["window_end"]) == end,
|
||||||
|
"SCADA_CHART_WINDOW_INVALID", "SCADA图表必须使用本轮截止时刻之前的完整三小时窗口")
|
||||||
|
observations = ScadaObservations(task, evidence, refs)
|
||||||
|
series = chart["series"]
|
||||||
|
ids = {item.get("device_id") for item in series}
|
||||||
|
require(None not in ids and all(device in observations.devices for device in ids),
|
||||||
|
"SCADA_CHART_EVIDENCE_REQUIRED", "每条SCADA序列须绑定已核实的 device_id", refs=sorted(ids - {None}))
|
||||||
|
comparison = context["role"] == "comparison"
|
||||||
|
require(chart["chart_type"] == ("time_comparison" if comparison else "line"),
|
||||||
|
"SCADA_CHART_INVALID", "SCADA总览须使用line,同期图须使用time_comparison")
|
||||||
|
bindings = [(item["device_id"], item.get("day_offset")) for item in series]
|
||||||
|
require(len(bindings) == len(set(bindings)), "SCADA_CHART_INVALID", "SCADA序列的设备与日期绑定不能重复")
|
||||||
|
require((len(ids) == 1 and {day for _, day in bindings} == set(range(8))) if comparison
|
||||||
|
else all(day == 0 for _, day in bindings),
|
||||||
|
"SCADA_CHART_INVALID", "总览每个设备绑定day_offset=0;同期图每个设备须有当天及此前七天共8组序列")
|
||||||
|
field = context.get("value_field", "monitored_value")
|
||||||
|
gaps = []
|
||||||
|
coverage = {}
|
||||||
|
for item in series:
|
||||||
|
device, day = item["device_id"], item["day_offset"]
|
||||||
|
metadata = observations.devices[device]
|
||||||
|
require(METRICS.get(metadata.get("device_type")) == context["metric"]
|
||||||
|
and _unit(metadata.get("measurement_unit") or "") == _unit(context["unit"]),
|
||||||
|
"SCADA_CHART_INVALID", f"设备 {device} 的指标或单位与完整设备证据不一致", refs=[device])
|
||||||
|
period = observations.period(device, start, end)
|
||||||
|
full_slots = observations.slots(device, start, end)
|
||||||
|
no_schedule = not full_slots
|
||||||
|
if no_schedule:
|
||||||
|
require(observations.window_is_empty(device, start - timedelta(days=day), end - timedelta(days=day)),
|
||||||
|
"SCADA_CHART_EVIDENCE_REQUIRED", f"设备 {device} 无计划采样槽,须完整读取对应三小时窗口并证明无读数", refs=[device])
|
||||||
|
# This is an empty display entry, never an observation or a missed scheduled sample.
|
||||||
|
full_slots = [end]
|
||||||
|
labels = [at.astimezone(DISPLAY_TIMEZONE).strftime("%H:%M:%S" if period.total_seconds() % 60 else "%H:%M")
|
||||||
|
for at in full_slots]
|
||||||
|
first = labels.index(chart["x_data"][0]) if chart["x_data"][0] in labels else -1
|
||||||
|
count = len(chart["x_data"])
|
||||||
|
require(first >= 0 and chart["x_data"] == labels[first:first + count]
|
||||||
|
and len(item.get("sample_times") or []) == count,
|
||||||
|
"SCADA_CHART_INVALID", f"设备 {device} 须保留连续真实采样槽和sample_times;超限按时间分图", refs=[device])
|
||||||
|
slots = full_slots[first:first + count]
|
||||||
|
coverage[device] = {"covered": [at.isoformat() for at in slots]}
|
||||||
|
require((item.get("type") or "line") == ("scatter" if day else "line"),
|
||||||
|
"SCADA_CHART_INVALID", "当前序列须为line,历史序列须为scatter")
|
||||||
|
missing = 0
|
||||||
|
reason = "no_readings"
|
||||||
|
expected = {at - timedelta(days=day) for at in (full_slots[-3:] if day else full_slots)}
|
||||||
|
# Off-grid observations cannot be silently discarded by a regular-looking chart.
|
||||||
|
observed = {at for key, at in observations.values if key == device
|
||||||
|
and min(expected) <= at <= max(expected)}
|
||||||
|
require(observed <= expected, "SCADA_CHART_INVALID",
|
||||||
|
f"设备 {device} 含非规则采样时刻,须明确处理,不能静默丢点", refs=[device])
|
||||||
|
for index, slot in enumerate(slots):
|
||||||
|
at = slot - timedelta(days=day)
|
||||||
|
value, sample_time = item["data"][index], item["sample_times"][index]
|
||||||
|
if at not in expected:
|
||||||
|
require(value is None and sample_time is None, "SCADA_CHART_INVALID",
|
||||||
|
"历史散点只保留每日末三个对应时刻")
|
||||||
|
continue
|
||||||
|
record = observations.values.get((device, at))
|
||||||
|
require(record is None or field in record, "SCADA_CHART_EVIDENCE_REQUIRED",
|
||||||
|
f"设备 {device} 的证据未标明 {field},请重新读取明确的监测字段", refs=[device])
|
||||||
|
actual = record.get(field) if record is not None else None
|
||||||
|
if actual is None:
|
||||||
|
absent = observations.absent_reason(device, at)
|
||||||
|
require(record is not None or absent is not None, "SCADA_CHART_EVIDENCE_REQUIRED",
|
||||||
|
f"设备 {device} 的 {at.isoformat()} 未被完整读取,不能将未查询当作缺测", refs=[device])
|
||||||
|
require(value is None and sample_time is None, "SCADA_CHART_DATA_MISMATCH",
|
||||||
|
f"设备 {device} 缺测槽必须保留null,不能补零或推算", refs=[device])
|
||||||
|
missing += 1
|
||||||
|
if absent in {"outside_frozen_scope", "outside_frozen_window"}:
|
||||||
|
reason = absent
|
||||||
|
elif no_schedule:
|
||||||
|
reason = "no_scheduled_samples"
|
||||||
|
else:
|
||||||
|
require(value is not None and sample_time is not None
|
||||||
|
and datetime.fromisoformat(sample_time) == at
|
||||||
|
and (isclose(value, actual, rel_tol=1e-9, abs_tol=1e-9)
|
||||||
|
or any(isclose(value, round(actual, digits), rel_tol=0, abs_tol=1e-9)
|
||||||
|
for digits in range(1, 7))),
|
||||||
|
"SCADA_CHART_DATA_MISMATCH",
|
||||||
|
f"设备 {device} 的 {at.isoformat()} 图表值或采样时间与证据不符", refs=[device])
|
||||||
|
if missing:
|
||||||
|
gaps.append({"device_id": device, "day_offset": day, "missing_count": missing, "reason": reason})
|
||||||
|
context["gaps"] = gaps
|
||||||
|
return {"role": context["role"], "metric": context["metric"], "device_ids": sorted(ids),
|
||||||
|
"window_start": context["window_start"], "window_end": context["window_end"], "gaps": gaps,
|
||||||
|
"coverage": coverage}
|
||||||
|
|
||||||
|
|
||||||
|
def require_complete(task: dict, document: dict | None, observations: ScadaObservations) -> None:
|
||||||
|
targets = set((document or {}).get("targets", []))
|
||||||
|
supported = {key for key, device in observations.devices.items() if device.get("device_type") in METRICS}
|
||||||
|
if not supported and not targets:
|
||||||
|
return
|
||||||
|
require(bool(targets), "SCADA_PRESENTATION_INCOMPLETE",
|
||||||
|
"请声明本轮SCADA目标设备并发布当前总览与七天同期图;旧格式图表不计入设备覆盖", status=409)
|
||||||
|
end = cutoff(task)
|
||||||
|
start = end - timedelta(hours=3)
|
||||||
|
covered = {"current": {}, "comparison": {}}
|
||||||
|
for chart in document["charts"].values():
|
||||||
|
if (datetime.fromisoformat(chart["window_start"]) == start
|
||||||
|
and datetime.fromisoformat(chart["window_end"]) == end):
|
||||||
|
for device, slots in chart.get("coverage", {}).items():
|
||||||
|
if device not in chart["device_ids"]:
|
||||||
|
continue
|
||||||
|
covered[chart["role"]].setdefault(device, set()).update(
|
||||||
|
datetime.fromisoformat(at) for at in slots["covered"])
|
||||||
|
missing = {}
|
||||||
|
for role in covered:
|
||||||
|
missing[role] = sorted(device for device in targets
|
||||||
|
if not covered[role].get(device) or not set(observations.slots(device, start, end) or [end])
|
||||||
|
<= covered[role][device])
|
||||||
|
missing_current, missing_comparison = missing["current"], missing["comparison"]
|
||||||
|
require(not missing_current and not missing_comparison, "SCADA_PRESENTATION_INCOMPLETE",
|
||||||
|
f"本轮SCADA目标共{len(targets)}个;缺少当前总览设备:{', '.join(missing_current) or '无'};"
|
||||||
|
f"缺少七天同期对比设备:{', '.join(missing_comparison) or '无'}。请补图后重试;仅作参考不能豁免展示。",
|
||||||
|
refs=sorted(set(missing_current + missing_comparison)), status=409,
|
||||||
|
recovery="retry_same_request")
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Read-only chronological SCADA paging shared by REST and replay processing."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.domain.schemas.scada import ScadaRawPage, ScadaRawReading
|
||||||
|
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
|
||||||
|
|
||||||
|
|
||||||
|
def validate_window(start: datetime, end: datetime) -> None:
|
||||||
|
if start.tzinfo is None or end.tzinfo is None or start >= end:
|
||||||
|
raise ValueError("需要带时区、结束晚于开始的 SCADA 时间区间")
|
||||||
|
|
||||||
|
|
||||||
|
def raw_reading(row: dict) -> ScadaRawReading:
|
||||||
|
value = row["monitored_value"]
|
||||||
|
invalid = value is not None and not math.isfinite(float(value))
|
||||||
|
return ScadaRawReading(
|
||||||
|
time=row["time"], device_id=row["device_id"],
|
||||||
|
monitored_value=None if invalid else value,
|
||||||
|
quality="invalid" if invalid else "missing" if value is None else "unvalidated",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def read_raw_page(
|
||||||
|
conn, *, project_id: str, device_ids: list[str], start: datetime,
|
||||||
|
end: datetime, limit: int = 500, cursor: str | None = None,
|
||||||
|
through: datetime | None = None,
|
||||||
|
) -> ScadaRawPage:
|
||||||
|
validate_window(start, end)
|
||||||
|
ids = sorted(set(device_ids))
|
||||||
|
if not ids or len(ids) > 500 or not 1 <= limit <= 500:
|
||||||
|
raise ValueError("请选择 1 至 500 个设备,每页最多 500 条")
|
||||||
|
cap = min(through or end, end)
|
||||||
|
if cap.tzinfo is None or cap < start:
|
||||||
|
raise ValueError("模拟时间不在可读取区间内")
|
||||||
|
signature = hashlib.sha256(json.dumps(
|
||||||
|
[project_id, ids, start.isoformat(), end.isoformat()], separators=(",", ":")
|
||||||
|
).encode()).hexdigest()
|
||||||
|
after = None
|
||||||
|
if cursor:
|
||||||
|
try:
|
||||||
|
data = json.loads(base64.urlsafe_b64decode(cursor.encode()))
|
||||||
|
frozen = datetime.fromisoformat(data["through"])
|
||||||
|
last = datetime.fromisoformat(data["time"])
|
||||||
|
if (data["query"] != signature or frozen.tzinfo is None or last.tzinfo is None
|
||||||
|
or frozen > cap or not start <= last <= frozen or data["device"] not in ids):
|
||||||
|
raise ValueError()
|
||||||
|
cap, after = frozen, (last, data["device"])
|
||||||
|
except (ValueError, KeyError, TypeError, UnicodeError) as exc:
|
||||||
|
raise ValueError("SCADA 分页游标无效或查询范围已改变") from exc
|
||||||
|
rows = await ScadaRepository.get_raw_page(conn, ids, start, end, cap, limit + 1, after)
|
||||||
|
selected = rows[:limit]
|
||||||
|
next_cursor = None
|
||||||
|
if len(rows) > limit:
|
||||||
|
last = selected[-1]
|
||||||
|
next_cursor = base64.urlsafe_b64encode(json.dumps({
|
||||||
|
"query": signature, "through": cap.isoformat(),
|
||||||
|
"time": last["time"].isoformat(), "device": last["device_id"],
|
||||||
|
}, separators=(",", ":")).encode()).decode()
|
||||||
|
return ScadaRawPage(items=[raw_reading(row) for row in selected],
|
||||||
|
next_cursor=next_cursor, scanned_through=cap)
|
||||||
@@ -0,0 +1,597 @@
|
|||||||
|
"""Durable, project-scoped SCADA playback; raw is never inserted or rewritten."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import json
|
||||||
|
from uuid import NAMESPACE_URL, uuid4, uuid5
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.auth.permissions import permissions_for_context
|
||||||
|
|
||||||
|
from app.domain.schemas.project_settings import ScadaMonitoringSettings
|
||||||
|
from app.domain.schemas.scada_replay import ScadaReplayCreate, ScadaReplayCommand, ScadaReplaySnapshot
|
||||||
|
from app.infra.db.postgresql.scada import ScadaInfoRepository
|
||||||
|
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:
|
||||||
|
return datetime.fromisoformat(value)
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot(document: dict) -> ScadaReplaySnapshot:
|
||||||
|
return ScadaReplaySnapshot(
|
||||||
|
**{key: document[key] for key in (
|
||||||
|
"replay_id", "project_id", "revision", "status", "start_time", "end_time",
|
||||||
|
"virtual_now", "created_at", "updated_at",
|
||||||
|
)},
|
||||||
|
delivered_until=document.get("delivered_until"), processed_until=document.get("processed_until"),
|
||||||
|
processed_count=document.get("processed_count", 0), anomaly_count=document.get("anomaly_count", 0),
|
||||||
|
devices=document["devices"], rules=document["config"]["rules"],
|
||||||
|
latest=list(document.get("latest", {}).values()),
|
||||||
|
active_events=[state["event"] for state in document.get("rule_states", {}).values() if state.get("event")],
|
||||||
|
error=document.get("error"),
|
||||||
|
monitoring_interval_seconds=document.get("monitoring_interval_seconds"),
|
||||||
|
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"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ScadaReplayService:
|
||||||
|
def __init__(self, repository, context, timescale=None, *, clock=None):
|
||||||
|
self.repo, self.context, self.timescale = repository, context, timescale
|
||||||
|
self.clock = clock or (lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
async def ensure_storage(self):
|
||||||
|
if not await self.repo.available():
|
||||||
|
raise HTTPException(503, "SCADA 回放存储未配置,需要应用 031_scada_replays.sql")
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self,
|
||||||
|
command: ScadaReplayCreate,
|
||||||
|
*,
|
||||||
|
monitoring_interval_seconds: int | None,
|
||||||
|
monitoring_config: ScadaMonitoringSettings | None = None,
|
||||||
|
):
|
||||||
|
await self.ensure_storage()
|
||||||
|
async with self.repo.control():
|
||||||
|
previous = await self.repo.by_request(command.request_id)
|
||||||
|
config = command.model_dump(mode="json")
|
||||||
|
if previous:
|
||||||
|
if previous["config"] != config:
|
||||||
|
raise HTTPException(409, "创建请求号已用于不同回放配置")
|
||||||
|
return snapshot(previous)
|
||||||
|
if monitoring_interval_seconds is None:
|
||||||
|
raise HTTPException(422, "请先配置项目 settings.scada_monitoring_interval_seconds,再创建演示")
|
||||||
|
replay_id = str(uuid4())
|
||||||
|
if await self.repo.active_other(replay_id):
|
||||||
|
raise HTTPException(409, "当前项目已有回放,请先停止原回放再新建")
|
||||||
|
metadata = {row["device_id"]: row for row in await ScadaInfoRepository.get_scadas(self.repo.connection)}
|
||||||
|
if not set(command.device_ids) <= metadata.keys():
|
||||||
|
raise HTTPException(422, "设备不属于当前项目")
|
||||||
|
for rule in command.rules:
|
||||||
|
if any(metadata[device]["measurement_unit"] != rule.unit for device in rule.device_ids):
|
||||||
|
raise HTTPException(422, "规则单位与设备单位不一致,请分组配置")
|
||||||
|
if any(metadata[device]["device_type"] == "quality" for device in rule.device_ids) and not rule.metric_name.strip():
|
||||||
|
raise HTTPException(422, "水质规则必须明确指标名称,不能仅凭单位解释读数")
|
||||||
|
if monitoring_config:
|
||||||
|
role_ids = set(monitoring_config.pressure_device_ids) | set(monitoring_config.outlet_flow_device_ids)
|
||||||
|
if not role_ids <= metadata.keys():
|
||||||
|
raise HTTPException(422, "项目 SCADA 监测角色包含不存在的设备")
|
||||||
|
if any(metadata[device]["device_type"] != "pressure" for device in monitoring_config.pressure_device_ids):
|
||||||
|
raise HTTPException(422, "压力监测角色必须引用 pressure 设备")
|
||||||
|
if any(metadata[device]["device_type"] not in {"pipe_flow", "flow"}
|
||||||
|
for device in monitoring_config.outlet_flow_device_ids):
|
||||||
|
raise HTTPException(422, "出厂边界流量角色必须引用流量设备")
|
||||||
|
now = self.clock().isoformat()
|
||||||
|
document = {
|
||||||
|
"replay_id": replay_id, "project_id": str(self.context.project_id),
|
||||||
|
"created_by": str(self.context.user_id), "config": config,
|
||||||
|
"revision": 1, "status": "ready", "start_time": config["start_time"],
|
||||||
|
"end_time": config["end_time"], "virtual_now": config["start_time"],
|
||||||
|
"created_at": now, "updated_at": now,
|
||||||
|
"monitoring_interval_seconds": monitoring_interval_seconds,
|
||||||
|
"monitoring_config": monitoring_config.model_dump(mode="json") if monitoring_config else None,
|
||||||
|
"devices": [metadata[device] for device in command.device_ids],
|
||||||
|
"batch_sequence": 0, "processed_count": 0, "anomaly_count": 0,
|
||||||
|
"monitoring_count": 0, "latest": {}, "histories": {}, "rule_states": {}, "receipts": {},
|
||||||
|
}
|
||||||
|
await self.repo.insert(document)
|
||||||
|
return snapshot(document)
|
||||||
|
|
||||||
|
async def command(self, replay_id, command: ScadaReplayCommand):
|
||||||
|
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", exclude_none=True)
|
||||||
|
receipt = document.setdefault("receipts", {}).get(key)
|
||||||
|
if receipt:
|
||||||
|
if receipt != body:
|
||||||
|
raise HTTPException(409, "命令请求号已用于不同内容")
|
||||||
|
return snapshot(document)
|
||||||
|
if command.expected_revision != document["revision"]:
|
||||||
|
raise HTTPException(409, "回放控制状态已更新,请刷新后重试")
|
||||||
|
action, status = command.action, document["status"]
|
||||||
|
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", "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")
|
||||||
|
if interval is None:
|
||||||
|
raise HTTPException(409, "旧回放未固定监测周期,请停止后新建演示")
|
||||||
|
start = parse_time(document["start_time"])
|
||||||
|
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)
|
||||||
|
document.pop("step_start", None)
|
||||||
|
elif action == "retry" and status == "failed":
|
||||||
|
if "SOURCE_CHANGED" in (document.get("error") or ""):
|
||||||
|
raise HTTPException(409, "原始数据发生变化,需要停止并新建回放")
|
||||||
|
document["status"], document["error"] = "paused", None
|
||||||
|
document["step_pending"] = bool(document.get("step_target"))
|
||||||
|
else:
|
||||||
|
raise HTTPException(409, "当前回放状态不支持此操作")
|
||||||
|
document["revision"] += 1
|
||||||
|
document["receipts"][key] = body
|
||||||
|
if len(document["receipts"]) > 256:
|
||||||
|
del document["receipts"][next(iter(document["receipts"]))]
|
||||||
|
await self.repo.save(document)
|
||||||
|
return snapshot(document)
|
||||||
|
|
||||||
|
async def process_pending_step(self, replay_id):
|
||||||
|
await self.ensure_storage()
|
||||||
|
async with self.repo.processing() as owned:
|
||||||
|
if not owned:
|
||||||
|
raise HTTPException(409, "当前时间步正在由另一请求处理")
|
||||||
|
while True:
|
||||||
|
document = await self.repo.get(replay_id)
|
||||||
|
if not document.get("step_pending"):
|
||||||
|
return snapshot(document)
|
||||||
|
try:
|
||||||
|
await self._advance(document)
|
||||||
|
except Exception as exc:
|
||||||
|
# Failure never discards the prepared batch; retry resumes it.
|
||||||
|
document = await self.repo.get(replay_id)
|
||||||
|
document["status"], document["step_pending"] = "failed", False
|
||||||
|
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
|
||||||
|
|
||||||
|
async def _advance(self, document):
|
||||||
|
config = ScadaReplayCreate.model_validate(document["config"])
|
||||||
|
replay_id = document["replay_id"]
|
||||||
|
if not document.get("warmed"):
|
||||||
|
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)
|
||||||
|
virtual = parse_time(document["virtual_now"])
|
||||||
|
through = parse_time(document["step_target"]) if step else virtual
|
||||||
|
after = ((parse_time(document["cursor"][0]), document["cursor"][1])
|
||||||
|
if document.get("cursor") else None)
|
||||||
|
async with self.timescale.transaction():
|
||||||
|
rows = await ScadaRepository.get_raw_page(
|
||||||
|
self.timescale, config.device_ids, config.start_time, config.end_time,
|
||||||
|
through, 501, after, frame=True,
|
||||||
|
)
|
||||||
|
if len(rows) > 500:
|
||||||
|
raise ValueError("SCADA frame exceeds selected device bound")
|
||||||
|
at = rows[0]["time"] if rows else through
|
||||||
|
if step:
|
||||||
|
virtual = through
|
||||||
|
document["virtual_now"] = virtual.isoformat()
|
||||||
|
if not rows and document.get("processed_until") == at.isoformat() and not step:
|
||||||
|
async with self.repo.connection.transaction():
|
||||||
|
await self.repo.save(document)
|
||||||
|
return
|
||||||
|
sequence = document["batch_sequence"] + 1
|
||||||
|
refs = [{"time": row["time"].isoformat(), "device_id": row["device_id"],
|
||||||
|
"source_hash": row["source_hash"]} for row in rows]
|
||||||
|
batch = {"sequence": sequence, "stage": "selected", "at": at.isoformat(),
|
||||||
|
"refs": refs, "input_digest": digest(refs), "events": []}
|
||||||
|
async with self.repo.connection.transaction():
|
||||||
|
document["batch_sequence"] = sequence
|
||||||
|
document["delivered_until"] = at.isoformat()
|
||||||
|
await self.repo.save_batch(replay_id, batch)
|
||||||
|
await self.repo.save(document)
|
||||||
|
else:
|
||||||
|
rows = None
|
||||||
|
if batch["stage"] == "selected":
|
||||||
|
if rows is None:
|
||||||
|
# Only crash recovery reloads the selected raw frame.
|
||||||
|
at = parse_time(batch["at"])
|
||||||
|
async with self.timescale.transaction():
|
||||||
|
rows = await ScadaRepository.get_raw_page(
|
||||||
|
self.timescale, config.device_ids, at, config.end_time, at, 501,
|
||||||
|
) if batch["refs"] else []
|
||||||
|
actual = [{"time": row["time"].isoformat(), "device_id": row["device_id"],
|
||||||
|
"source_hash": row["source_hash"]} for row in rows]
|
||||||
|
if digest(actual) != batch["input_digest"]:
|
||||||
|
raise ValueError("SOURCE_CHANGED: selected raw frame differs")
|
||||||
|
batch["outputs"] = prepare_cleaning(rows, config)
|
||||||
|
batch["stage"] = "clean_prepared"
|
||||||
|
async with self.repo.connection.transaction():
|
||||||
|
await self.repo.save_batch(replay_id, batch)
|
||||||
|
if batch["stage"] == "clean_prepared":
|
||||||
|
await ScadaRepository.apply_replay_cleaning(self.timescale, batch["outputs"])
|
||||||
|
batch["stage"] = "cleaned"
|
||||||
|
async with self.repo.connection.transaction():
|
||||||
|
await self.repo.save_batch(replay_id, batch)
|
||||||
|
if batch["stage"] == "cleaned":
|
||||||
|
result, changes = detect_frame(document, batch["outputs"], config, parse_time(batch["at"]))
|
||||||
|
result["processed_until"] = batch["at"]
|
||||||
|
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"]))
|
||||||
|
period_end = parse_time(target)
|
||||||
|
role = result.get("monitoring_config") or {}
|
||||||
|
role_ids = list(dict.fromkeys(
|
||||||
|
role.get("pressure_device_ids", []) + role.get("outlet_flow_device_ids", [])
|
||||||
|
))
|
||||||
|
previous = await self.repo.monitoring_outputs(
|
||||||
|
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
|
||||||
|
monitoring = await bounded_calculation(
|
||||||
|
evaluate_monitoring_period,
|
||||||
|
result,
|
||||||
|
previous + batch["outputs"],
|
||||||
|
period_start=period_start,
|
||||||
|
period_end=period_end,
|
||||||
|
batch_sequence=batch["sequence"],
|
||||||
|
)
|
||||||
|
# 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"]]
|
||||||
|
if parse_time(batch["at"]) >= config.end_time:
|
||||||
|
result["status"] = "completed"
|
||||||
|
batch["stage"], batch["events"] = "detected", changes
|
||||||
|
if monitoring is not None:
|
||||||
|
batch["monitoring"] = monitoring
|
||||||
|
async with self.repo.connection.transaction():
|
||||||
|
await self._create_investigations(result, changes, batch, create_tasks=not was_step)
|
||||||
|
if monitoring is not None:
|
||||||
|
await self._create_monitoring_task(result, monitoring, batch)
|
||||||
|
batch["monitoring"] = monitoring
|
||||||
|
result["last_monitoring"] = monitoring
|
||||||
|
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.services.scada_normal_profile import pollution_signal
|
||||||
|
from app.domain.schemas.operational_task import OperationalTaskCreate, TaskSource, TaskTrigger
|
||||||
|
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
|
||||||
|
from app.services.operational_tasks import OperationalTaskService
|
||||||
|
from app.domain.schemas.task_alert import TaskAlertEvent
|
||||||
|
from app.infra.db.postgresql.task_alerts import TaskAlertRepository
|
||||||
|
from app.services.task_alerts import TaskAlertService
|
||||||
|
from uuid import UUID
|
||||||
|
producer = "scada-replay:" + document["replay_id"]
|
||||||
|
alerts = TaskAlertService(TaskAlertRepository(self.repo.connection, self.context.project_id),
|
||||||
|
self.context, internal_producer_id=producer)
|
||||||
|
tasks = OperationalTaskService(OperationalTaskRepository(self.repo.connection, self.context.project_id),
|
||||||
|
self.context, permissions_for_context(self.context),
|
||||||
|
execution_scope=settings.operational_task_execution_scope)
|
||||||
|
for event in changes:
|
||||||
|
pollution = pollution_signal(document, event)
|
||||||
|
# Pressure/flow rules share period diagnosis during manual steps.
|
||||||
|
# Water-quality incidents and telemetry-quality alerts stay independent.
|
||||||
|
if (not create_tasks and not pollution and event.get("category") != "data_quality"
|
||||||
|
and (event["change"] == "raised" or event.get("notification_suppressed"))):
|
||||||
|
if event["change"] == "raised":
|
||||||
|
event["batch_sequence"] = batch["sequence"]
|
||||||
|
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.get("state_key", event["rule_id"])]["event"]["batch_sequence"] = batch["sequence"]
|
||||||
|
if event["change"] == "raised" and event.get("category") != "data_quality" and (create_tasks or pollution):
|
||||||
|
external = f"scada_replay:{document['replay_id']}:event:{event['event_id']}"
|
||||||
|
command = OperationalTaskCreate(
|
||||||
|
request_id=uuid5(NAMESPACE_URL, external), task_type="event_investigation",
|
||||||
|
workflow="water_pollution" if pollution else "scada_anomaly",
|
||||||
|
title=f"{'水污染事件调查' if pollution else 'SCADA 回放调查'} · {event['rule_id']}",
|
||||||
|
summary=event["detail"], source=TaskSource(
|
||||||
|
kind="external_system", external_id=external, external_revision=1,
|
||||||
|
event_kind="water_pollution" if pollution else "scada_anomaly",
|
||||||
|
replay_context={"replay_id": document["replay_id"],
|
||||||
|
"knowledge_cutoff": batch["at"], "batch_sequence": batch["sequence"]},
|
||||||
|
),
|
||||||
|
trigger=TaskTrigger(kind="event", occurred_at=event["occurred_at"], detail=(
|
||||||
|
"水质监测规则检出异常,启动水污染事件调查;污染源及影响待核验" if pollution else
|
||||||
|
"已存 SCADA 历史的回放检测信号,原因尚待调查")),
|
||||||
|
)
|
||||||
|
task = await tasks.create(command)
|
||||||
|
event["task_id"] = str(task.task_id)
|
||||||
|
document["rule_states"][event.get("state_key", event["rule_id"])]["event"]["task_id"] = event["task_id"]
|
||||||
|
await alerts.push(TaskAlertEvent(
|
||||||
|
event_id=uuid5(NAMESPACE_URL, event["event_id"] + ":" + event["change"]),
|
||||||
|
alert_id=event["event_id"], producer_id=producer, event_type=event["change"],
|
||||||
|
occurred_at=event["occurred_at"],
|
||||||
|
condition_id=f"operational-task:{event['task_id']}" if pollution and event.get("task_id") else None,
|
||||||
|
source={"kind": "scada_replay", "replay_id": document["replay_id"],
|
||||||
|
"rule_id": event["rule_id"], "batch_sequence": event["batch_sequence"]},
|
||||||
|
title=f"{'水污染侦测异常' if pollution else 'SCADA 回放异常'} · {event['rule_id']}", description=event["detail"], severity="warning",
|
||||||
|
))
|
||||||
|
if event["change"] != "raised":
|
||||||
|
if event.get("task_id"):
|
||||||
|
await tasks.advance_replay_investigation(UUID(event["task_id"]), {
|
||||||
|
"replay_id": document["replay_id"], "knowledge_cutoff": batch["at"],
|
||||||
|
"batch_sequence": batch["sequence"],
|
||||||
|
}, uuid5(NAMESPACE_URL, event["event_id"] + ":recovery"))
|
||||||
|
continue
|
||||||
|
|
||||||
|
async def _create_monitoring_task(self, document, monitoring, batch):
|
||||||
|
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),
|
||||||
|
execution_scope=settings.operational_task_execution_scope,
|
||||||
|
)
|
||||||
|
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
|
||||||
|
from app.services.operational_tasks import OperationalTaskService
|
||||||
|
tasks = OperationalTaskService(
|
||||||
|
OperationalTaskRepository(self.repo.connection, self.context.project_id),
|
||||||
|
self.context,
|
||||||
|
permissions_for_context(self.context),
|
||||||
|
execution_scope=settings.operational_task_execution_scope,
|
||||||
|
)
|
||||||
|
external = incident["external_id"]
|
||||||
|
pressure = monitoring["pressure"]
|
||||||
|
flow = monitoring["outlet_flow"]
|
||||||
|
findings = []
|
||||||
|
if pressure.get("hit_device_ids"):
|
||||||
|
findings.append(f"{len(pressure['hit_device_ids'])} 个压力测点下降")
|
||||||
|
if flow.get("hit_device_ids"):
|
||||||
|
findings.append(f"{len(flow['hit_device_ids'])} 个出厂边界流量测点增加")
|
||||||
|
summary = f"SCADA 监测发现{'、'.join(findings) or '指标异常'},已启动调查。"
|
||||||
|
if monitoring.get("anomaly_signals"):
|
||||||
|
signals = monitoring["anomaly_signals"]
|
||||||
|
devices = {device for item in signals for device in item.get("device_ids", [])}
|
||||||
|
summary += f"本次异常涉及 {len(devices)} 个测点。"
|
||||||
|
summary += ("现有监测结果提示爆管嫌疑,需结合管网与现场核验。"
|
||||||
|
if monitoring["status"] == "suspected_burst" else "异常原因及影响范围待核查。")
|
||||||
|
event_time = parse_time(monitoring["period_end"]).astimezone(ZoneInfo("Asia/Shanghai"))
|
||||||
|
command = OperationalTaskCreate(
|
||||||
|
request_id=uuid5(NAMESPACE_URL, external),
|
||||||
|
task_type="event_investigation",
|
||||||
|
workflow="scada_anomaly",
|
||||||
|
title=f"SCADA 异常调查 · {event_time:%Y-%m-%d %H:%M}",
|
||||||
|
summary=summary,
|
||||||
|
source=TaskSource(
|
||||||
|
kind="external_system",
|
||||||
|
external_id=external,
|
||||||
|
external_revision=1,
|
||||||
|
event_kind="scada_anomaly",
|
||||||
|
replay_context={
|
||||||
|
"replay_id": document["replay_id"],
|
||||||
|
"knowledge_cutoff": monitoring["knowledge_cutoff"],
|
||||||
|
"batch_sequence": batch["sequence"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
trigger=TaskTrigger(
|
||||||
|
kind="event",
|
||||||
|
occurred_at=monitoring["period_end"],
|
||||||
|
detail="SCADA 任一指标异常启动调查,核验数据后进行爆管诊断;异常不等于已确诊爆管",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
task = await tasks.create(command)
|
||||||
|
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),
|
||||||
|
))
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
"""Deterministic monitoring summaries built only from frozen replay outputs."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from statistics import median
|
||||||
|
from typing import Any
|
||||||
|
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,
|
||||||
|
TARGET_RANDOM_STATE,
|
||||||
|
TARGET_SCORE_THRESHOLD,
|
||||||
|
run_burst_detection,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _time(value: str | datetime) -> datetime:
|
||||||
|
return value if isinstance(value, datetime) else datetime.fromisoformat(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_rows(rows: list[dict], device_id: str) -> list[dict]:
|
||||||
|
return sorted(
|
||||||
|
(
|
||||||
|
row for row in rows
|
||||||
|
if row.get("device_id") == device_id
|
||||||
|
and row.get("quality") in {"valid", "corrected"}
|
||||||
|
and row.get("cleaned_value") is not None
|
||||||
|
),
|
||||||
|
key=lambda row: _time(row["time"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _relative_indicator(
|
||||||
|
rows: list[dict],
|
||||||
|
config: ScadaReplayCreate,
|
||||||
|
device_ids: list[str],
|
||||||
|
period_start: datetime,
|
||||||
|
period_end: datetime,
|
||||||
|
*,
|
||||||
|
direction: str,
|
||||||
|
calibration: dict | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
details: list[dict[str, Any]] = []
|
||||||
|
gaps: list[dict[str, str]] = []
|
||||||
|
for device_id in device_ids:
|
||||||
|
rule = next((
|
||||||
|
item for item in config.rules
|
||||||
|
if item.kind == "relative_change" and device_id in item.device_ids
|
||||||
|
and item.threshold is not None
|
||||||
|
), None)
|
||||||
|
if rule is None:
|
||||||
|
gaps.append({"device_id": device_id, "reason": "relative_change_rule_missing"})
|
||||||
|
continue
|
||||||
|
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
|
||||||
|
value = float(latest["cleaned_value"])
|
||||||
|
change = (value - reference) / abs(reference) * 100
|
||||||
|
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": 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 {
|
||||||
|
"status": "ready" if not gaps else "insufficient_data",
|
||||||
|
"hit": any(item["hit"] for item in details),
|
||||||
|
"hit_device_ids": [item["device_id"] for item in details if item["hit"]],
|
||||||
|
"devices": details,
|
||||||
|
"gaps": gaps,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _burst_detection(
|
||||||
|
rows: list[dict],
|
||||||
|
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,
|
||||||
|
"required_sensors": 5, "available_sensors": len(pressure_device_ids)}
|
||||||
|
if interval_seconds % 60 or 86400 % interval_seconds:
|
||||||
|
return {"status": "unsupported_sampling_interval", "hit": False,
|
||||||
|
"interval_seconds": interval_seconds}
|
||||||
|
points_per_day = 86400 // interval_seconds
|
||||||
|
count = 15 * points_per_day
|
||||||
|
series_by_device: dict[str, dict[datetime, float]] = {}
|
||||||
|
common_times: set[datetime] | None = None
|
||||||
|
for device_id in pressure_device_ids:
|
||||||
|
values = {_time(row["time"]): float(row["cleaned_value"])
|
||||||
|
for row in _valid_rows(rows, device_id) if _time(row["time"]) <= period_end}
|
||||||
|
series_by_device[device_id] = values
|
||||||
|
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 = 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()
|
||||||
|
if any(at not in values for at in expected)]
|
||||||
|
if incomplete:
|
||||||
|
return {"status": "baseline_not_ready", "hit": False, "required_samples": count,
|
||||||
|
"incomplete_device_ids": incomplete}
|
||||||
|
frame = pd.DataFrame({device_id: [series_by_device[device_id][at] for at in expected]
|
||||||
|
for device_id in pressure_device_ids}, index=expected)
|
||||||
|
try:
|
||||||
|
result = run_burst_detection(
|
||||||
|
network="scada-replay",
|
||||||
|
username="scada-replay",
|
||||||
|
observed_pressure_data=frame,
|
||||||
|
points_per_day=points_per_day,
|
||||||
|
mu=TARGET_MU,
|
||||||
|
iforest_params={
|
||||||
|
"n_estimators": TARGET_N_ESTIMATORS,
|
||||||
|
"random_state": TARGET_RANDOM_STATE,
|
||||||
|
"contamination": "auto",
|
||||||
|
},
|
||||||
|
sensor_nodes=pressure_device_ids,
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
return {"status": "analysis_failed", "hit": False, "reason": str(exc)[:300]}
|
||||||
|
latest = result["rows"][-1]
|
||||||
|
score = float(latest["Score"])
|
||||||
|
return {
|
||||||
|
"status": "ready",
|
||||||
|
"hit": score <= score_threshold,
|
||||||
|
"target_time": target.isoformat(),
|
||||||
|
"target_score": score,
|
||||||
|
"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", []),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_monitoring_period(
|
||||||
|
document: dict,
|
||||||
|
rows: list[dict],
|
||||||
|
*,
|
||||||
|
period_start: datetime,
|
||||||
|
period_end: datetime,
|
||||||
|
batch_sequence: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
config = ScadaReplayCreate.model_validate(document["config"])
|
||||||
|
monitoring = document.get("monitoring_config")
|
||||||
|
rule_version = (monitoring or {}).get("rule_version", "unconfigured")
|
||||||
|
monitoring_id = str(uuid5(
|
||||||
|
NAMESPACE_URL,
|
||||||
|
f"{document['replay_id']}:{period_end.isoformat()}:{rule_version}",
|
||||||
|
))
|
||||||
|
base = {
|
||||||
|
"monitoring_id": monitoring_id,
|
||||||
|
"period_start": period_start.isoformat(),
|
||||||
|
"period_end": period_end.isoformat(),
|
||||||
|
"knowledge_cutoff": period_end.isoformat(),
|
||||||
|
"batch_sequence": batch_sequence,
|
||||||
|
"rule_version": rule_version,
|
||||||
|
"task_id": None,
|
||||||
|
}
|
||||||
|
if not monitoring:
|
||||||
|
return {**base, "status": "insufficient_data", "detail": "项目未配置 SCADA 监测设备角色",
|
||||||
|
"pressure": {"status": "unconfigured", "hit": False},
|
||||||
|
"outlet_flow": {"status": "unconfigured", "hit": False},
|
||||||
|
"burst_detection": {"status": "not_run", "hit": False}}
|
||||||
|
pressure_ids = monitoring.get("pressure_device_ids", [])
|
||||||
|
flow_ids = monitoring.get("outlet_flow_device_ids", [])
|
||||||
|
selected = set(config.device_ids)
|
||||||
|
role_gaps = sorted((set(pressure_ids) | set(flow_ids)) - selected)
|
||||||
|
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}}
|
||||||
|
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:
|
||||||
|
status, detail = "insufficient_data", "监测数据、设备角色或爆管侦测基线不足"
|
||||||
|
elif all(hits):
|
||||||
|
status, detail = "suspected_burst", "爆管侦测、压力下降与出厂边界流量增加相互支持"
|
||||||
|
elif any(hits):
|
||||||
|
status, detail = "attention", "仅部分爆管相关指标命中,需要继续观察或人工核查"
|
||||||
|
else:
|
||||||
|
status, detail = "normal", "本周期爆管相关指标均未命中"
|
||||||
|
return {**base, "status": status, "detail": detail, "pressure": pressure,
|
||||||
|
"outlet_flow": outlet_flow, "burst_detection": detector}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"""Causal batch quality checks and grouped anomaly episodes, no database IO."""
|
||||||
|
from copy import deepcopy
|
||||||
|
from datetime import datetime
|
||||||
|
from math import isfinite
|
||||||
|
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]:
|
||||||
|
result = []
|
||||||
|
for row in rows:
|
||||||
|
value = row["monitored_value"]
|
||||||
|
quality, reason = "valid", "原始值通过数值与已配置量程校验"
|
||||||
|
if value is None:
|
||||||
|
quality, reason = "missing", "原始读数缺失"
|
||||||
|
elif not isfinite(float(value)):
|
||||||
|
quality, reason = "invalid", "原始读数不是有限数值"
|
||||||
|
else:
|
||||||
|
bounds = config.quality_ranges.get(row["device_id"])
|
||||||
|
if bounds and ((bounds.lower is not None and value < bounds.lower)
|
||||||
|
or (bounds.upper is not None and value > bounds.upper)):
|
||||||
|
quality, reason = "invalid", "超出本次明确配置的仪表量程"
|
||||||
|
result.append({
|
||||||
|
"time": row["time"].isoformat() if isinstance(row["time"], datetime) else row["time"],
|
||||||
|
"device_id": row["device_id"], "source_hash": row["source_hash"],
|
||||||
|
"monitored_value": value if value is not None and isfinite(float(value)) else None,
|
||||||
|
"cleaned_value": value if quality == "valid" else None,
|
||||||
|
"quality": quality, "reason": reason,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def detect_frame(document: dict, rows: list[dict], config: ScadaReplayCreate, at: datetime) -> tuple[dict, list[dict]]:
|
||||||
|
"""Consume a complete timestamp; never treat missing data as recovery."""
|
||||||
|
result = deepcopy(document)
|
||||||
|
histories = result.setdefault("histories", {})
|
||||||
|
states = result.setdefault("rule_states", {})
|
||||||
|
latest = result.setdefault("latest", {})
|
||||||
|
for row in rows:
|
||||||
|
latest[row["device_id"]] = row
|
||||||
|
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"
|
||||||
|
previous = latest.get(device_id)
|
||||||
|
frequency = str(devices.get(device_id, {}).get("transmission_frequency", ""))
|
||||||
|
match = re.fullmatch(r"(\d+):(\d+):(\d+)", frequency)
|
||||||
|
if match:
|
||||||
|
seconds = int(match[1]) * 3600 + int(match[2]) * 60 + int(match[3])
|
||||||
|
else:
|
||||||
|
minutes = re.fullmatch(r"(\d+)\s*(?:min|分钟)", frequency)
|
||||||
|
seconds = int(minutes[1]) * 60 if minutes else 0
|
||||||
|
prior_time = datetime.fromisoformat(previous["time"]) if previous else config.start_time
|
||||||
|
missing = not row and seconds > 0 and (at - prior_time).total_seconds() >= seconds * 2
|
||||||
|
key = "quality-" + device_id
|
||||||
|
state = states.setdefault(key, {"event": None, "recoveries": 0})
|
||||||
|
if invalid or missing:
|
||||||
|
state["recoveries"] = 0
|
||||||
|
if state["event"] is None:
|
||||||
|
event = {"event_id": str(uuid5(NAMESPACE_URL, f"{result['replay_id']}:{key}:{at.isoformat()}")),
|
||||||
|
"rule_id": key, "device_ids": [device_id], "occurred_at": at.isoformat(),
|
||||||
|
"last_observed_at": at.isoformat(), "status": "active", "category": "data_quality",
|
||||||
|
"detail": row["reason"] if invalid else "连续两个设备周期未读取到观测",
|
||||||
|
"task_id": None}
|
||||||
|
state["event"] = event
|
||||||
|
changes.append({**event, "change": "raised"})
|
||||||
|
result["anomaly_count"] = result.get("anomaly_count", 0) + 1
|
||||||
|
elif row and row["quality"] == "valid" and state["event"]:
|
||||||
|
state["recoveries"] += 1
|
||||||
|
if state["recoveries"] >= 2:
|
||||||
|
changes.append({**state["event"], "status": "resolved", "resolved_at": at.isoformat(), "change": "resolved"})
|
||||||
|
state["event"] = None
|
||||||
|
for rule in config.rules:
|
||||||
|
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)
|
||||||
|
if not row or row["quality"] != "valid":
|
||||||
|
continue
|
||||||
|
value = row["cleaned_value"]
|
||||||
|
if rule.kind == "bounds":
|
||||||
|
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:
|
||||||
|
continue
|
||||||
|
baseline = median(previous)
|
||||||
|
if baseline == 0:
|
||||||
|
continue
|
||||||
|
change = (value - baseline) / abs(baseline) * 100
|
||||||
|
triggered = (abs(change) >= rule.threshold if rule.direction == "both"
|
||||||
|
else change >= rule.threshold if rule.direction == "increase"
|
||||||
|
else change <= -rule.threshold)
|
||||||
|
reason = f"{device_id} 较历史中位值变化 {change:.1f}%,阈值 {rule.threshold}%"
|
||||||
|
usable.append(device_id)
|
||||||
|
if triggered:
|
||||||
|
hits.append(device_id)
|
||||||
|
reasons.append(reason)
|
||||||
|
if len(hits) >= rule.minimum_devices:
|
||||||
|
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']}:{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
|
||||||
|
elif state["event"]:
|
||||||
|
state["event"]["last_observed_at"] = at.isoformat()
|
||||||
|
elif len(usable) == len(rule.device_ids):
|
||||||
|
state["hits"] = 0
|
||||||
|
if state["event"]:
|
||||||
|
state["recoveries"] += 1
|
||||||
|
if state["recoveries"] >= rule.recovery_samples:
|
||||||
|
event = {**state["event"], "status": "resolved", "resolved_at": at.isoformat(),
|
||||||
|
"change": "resolved"}
|
||||||
|
changes.append(event)
|
||||||
|
state["event"] = None
|
||||||
|
state["recoveries"] = 0
|
||||||
|
# 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[
|
||||||
|
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:
|
||||||
|
history = histories.setdefault(row["device_id"], [])
|
||||||
|
history.append(row["cleaned_value"])
|
||||||
|
histories[row["device_id"]] = history[-96:]
|
||||||
|
result["processed_count"] = result.get("processed_count", 0) + len(rows)
|
||||||
|
return result, changes
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""Server-enforced replay knowledge bounds and immutable corrected snapshots."""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.infra.db.postgresql.scada_replays import ScadaReplayRepository
|
||||||
|
|
||||||
|
|
||||||
|
def check_replay_window(context, start, end):
|
||||||
|
cap = datetime.fromisoformat(context["knowledge_cutoff"])
|
||||||
|
if start is None or end is None or start.tzinfo is None or end.tzinfo is None or start > end or end > cap:
|
||||||
|
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, "设备或回放不属于当前调查")
|
||||||
|
check_snapshot_start(run, start)
|
||||||
|
rows = await repository.detected_outputs(
|
||||||
|
context["replay_id"], device_ids, start, end, context["batch_sequence"],
|
||||||
|
limit=limit, offset=offset,
|
||||||
|
)
|
||||||
|
return [{key: row[key] for key in (
|
||||||
|
"time", "device_id", "monitored_value", "cleaned_value", "quality", "reason"
|
||||||
|
)} for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def replay_burst_location_inputs(connection, project_id, context, args):
|
||||||
|
"""Build location series from immutable replay outputs, never live TSDB rows."""
|
||||||
|
if args.data_source != "monitoring":
|
||||||
|
raise HTTPException(422, "回放定位只支持 monitoring 数据源")
|
||||||
|
if args.scada_normal_start is None or args.scada_normal_end is None:
|
||||||
|
raise HTTPException(422, "回放定位必须显式提供已处理范围内的正常对照窗口")
|
||||||
|
check_replay_window(context, args.scada_burst_start, args.scada_burst_end)
|
||||||
|
check_replay_window(context, args.scada_normal_start, args.scada_normal_end)
|
||||||
|
repository = ScadaReplayRepository(connection, project_id)
|
||||||
|
run = await repository.get(context["replay_id"])
|
||||||
|
if run:
|
||||||
|
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", [])
|
||||||
|
pressure_map = {str(devices[item]["node_id"]): item for item in pressure_devices
|
||||||
|
if item in devices and devices[item].get("node_id")}
|
||||||
|
requested_pressure = args.pressure_scada_ids or list(pressure_map)
|
||||||
|
if not requested_pressure or not set(requested_pressure) <= set(pressure_map):
|
||||||
|
raise HTTPException(422, "压力节点必须来自本轮已配置的回放监测角色")
|
||||||
|
flow_devices = monitoring.get("outlet_flow_device_ids", [])
|
||||||
|
flow_map = {str(devices[item]["link_id"]): item for item in flow_devices
|
||||||
|
if item in devices and devices[item].get("link_id")}
|
||||||
|
requested_flow = args.flow_scada_ids or []
|
||||||
|
if not set(requested_flow) <= set(flow_map):
|
||||||
|
raise HTTPException(422, "流量要素必须来自本轮已配置的出厂边界流量角色")
|
||||||
|
selected = [pressure_map[item] for item in requested_pressure] + [flow_map[item] for item in requested_flow]
|
||||||
|
rows = await repository.detected_outputs(
|
||||||
|
context["replay_id"], selected,
|
||||||
|
min(args.scada_burst_start, args.scada_normal_start),
|
||||||
|
max(args.scada_burst_end, args.scada_normal_end),
|
||||||
|
context["batch_sequence"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def averages(mapping, start, end):
|
||||||
|
result = {}
|
||||||
|
for element_id, device_id in mapping.items():
|
||||||
|
values = [float(row["cleaned_value"]) for row in rows
|
||||||
|
if row.get("device_id") == device_id
|
||||||
|
and row.get("quality") in {"valid", "corrected"}
|
||||||
|
and row.get("cleaned_value") is not None
|
||||||
|
and start <= datetime.fromisoformat(row["time"]) <= end]
|
||||||
|
if not values:
|
||||||
|
raise HTTPException(422, f"回放窗口缺少 {device_id} 的有效清洗观测")
|
||||||
|
result[element_id] = sum(values) / len(values)
|
||||||
|
return result
|
||||||
|
|
||||||
|
pressure_mapping = {item: pressure_map[item] for item in requested_pressure}
|
||||||
|
flow_mapping = {item: flow_map[item] for item in requested_flow}
|
||||||
|
return {
|
||||||
|
"data_source": "monitoring",
|
||||||
|
"burst_leakage": args.burst_leakage,
|
||||||
|
"pressure_scada_ids": requested_pressure,
|
||||||
|
"burst_pressure": averages(pressure_mapping, args.scada_burst_start, args.scada_burst_end),
|
||||||
|
"normal_pressure": averages(pressure_mapping, args.scada_normal_start, args.scada_normal_end),
|
||||||
|
**({
|
||||||
|
"flow_scada_ids": requested_flow,
|
||||||
|
"burst_flow": averages(flow_mapping, args.scada_burst_start, args.scada_burst_end),
|
||||||
|
"normal_flow": averages(flow_mapping, args.scada_normal_start, args.scada_normal_end),
|
||||||
|
"use_scada_flow": True,
|
||||||
|
} if requested_flow else {}),
|
||||||
|
}
|
||||||
@@ -157,6 +157,7 @@ def run_online_simulation(
|
|||||||
*,
|
*,
|
||||||
output_node_ids: list[str] | None = None,
|
output_node_ids: list[str] | None = None,
|
||||||
output_link_ids: list[str] | None = None,
|
output_link_ids: list[str] | None = None,
|
||||||
|
scada_mappings: ScadaElementMappings | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Recalculate online baseline snapshots without cloning a model database."""
|
"""Recalculate online baseline snapshots without cloning a model database."""
|
||||||
if start_time.tzinfo is None or duration_seconds <= 0:
|
if start_time.tzinfo is None or duration_seconds <= 0:
|
||||||
@@ -166,7 +167,10 @@ def run_online_simulation(
|
|||||||
)
|
)
|
||||||
if step <= 0:
|
if step <= 0:
|
||||||
raise ValueError("HYDRAULIC TIMESTEP must be greater than 0.")
|
raise ValueError("HYDRAULIC TIMESTEP must be greater than 0.")
|
||||||
mappings = query_corresponding_element_id_and_query_id(name)
|
mappings = (
|
||||||
|
scada_mappings if scada_mappings is not None
|
||||||
|
else query_corresponding_element_id_and_query_id(name)
|
||||||
|
)
|
||||||
selected = {
|
selected = {
|
||||||
"node": {node: [] for node in output_node_ids or []},
|
"node": {node: [] for node in output_node_ids or []},
|
||||||
"link": {link: [] for link in output_link_ids or []},
|
"link": {link: [] for link in output_link_ids or []},
|
||||||
@@ -179,13 +183,13 @@ def run_online_simulation(
|
|||||||
output = run_simulation(
|
output = run_simulation(
|
||||||
name=name,
|
name=name,
|
||||||
simulation_type="realtime",
|
simulation_type="realtime",
|
||||||
modify_pattern_start_time=current.isoformat(timespec="seconds"),
|
modify_pattern_start_time=current.isoformat(),
|
||||||
scada_mappings=mappings,
|
scada_mappings=mappings,
|
||||||
)
|
)
|
||||||
if not isinstance(output, dict):
|
if not isinstance(output, dict):
|
||||||
raise RuntimeError("Online simulation did not return calculation results")
|
raise RuntimeError("Online simulation did not return calculation results")
|
||||||
units = output.get("units", {})
|
units = output.get("units", {})
|
||||||
sample_times.append(current.isoformat(timespec="seconds"))
|
sample_times.append(current.isoformat())
|
||||||
for kind, elements in selected.items():
|
for kind, elements in selected.items():
|
||||||
if not elements:
|
if not elements:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from app.native.wndb.model.sources import (
|
|||||||
)
|
)
|
||||||
from app.native.wndb.model.times import get_time, set_time
|
from app.native.wndb.model.times import get_time, set_time
|
||||||
from app.domain.time import parse_clock_duration_seconds
|
from app.domain.time import parse_clock_duration_seconds
|
||||||
|
from app.infra.db.postgresql.scada import ScadaElementMappings
|
||||||
|
|
||||||
|
|
||||||
def _isolated_analysis(purpose: str):
|
def _isolated_analysis(purpose: str):
|
||||||
@@ -79,6 +80,7 @@ def burst_analysis(
|
|||||||
username: str | None = None,
|
username: str | None = None,
|
||||||
persist_results: bool = True,
|
persist_results: bool = True,
|
||||||
_temporary_project: str | None = None,
|
_temporary_project: str | None = None,
|
||||||
|
scada_mappings: ScadaElementMappings | None = None,
|
||||||
) -> dict | None:
|
) -> dict | None:
|
||||||
"""
|
"""
|
||||||
爆管模拟
|
爆管模拟
|
||||||
@@ -123,6 +125,7 @@ def burst_analysis(
|
|||||||
name=new_name,
|
name=new_name,
|
||||||
simulation_type="manually_temporary",
|
simulation_type="manually_temporary",
|
||||||
modify_pattern_start_time=modify_pattern_start_time,
|
modify_pattern_start_time=modify_pattern_start_time,
|
||||||
|
scada_mappings=scada_mappings,
|
||||||
)
|
)
|
||||||
print(
|
print(
|
||||||
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
|
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
@@ -203,6 +206,7 @@ def burst_analysis(
|
|||||||
modify_variable_pump_pattern=modify_variable_pump_pattern,
|
modify_variable_pump_pattern=modify_variable_pump_pattern,
|
||||||
modify_valve_opening=modify_valve_opening,
|
modify_valve_opening=modify_valve_opening,
|
||||||
scheme_type="burst_analysis",
|
scheme_type="burst_analysis",
|
||||||
|
scada_mappings=scada_mappings,
|
||||||
scheme_name=scheme_name,
|
scheme_name=scheme_name,
|
||||||
result_db_name=name,
|
result_db_name=name,
|
||||||
scheme_username=username,
|
scheme_username=username,
|
||||||
@@ -227,6 +231,7 @@ def valve_close_analysis(
|
|||||||
_temporary_project: str | None = None,
|
_temporary_project: str | None = None,
|
||||||
persist_results: bool = True,
|
persist_results: bool = True,
|
||||||
username: str = "system",
|
username: str = "system",
|
||||||
|
scada_mappings: ScadaElementMappings | None = None,
|
||||||
) -> dict | str | None:
|
) -> dict | str | None:
|
||||||
"""
|
"""
|
||||||
关阀模拟
|
关阀模拟
|
||||||
@@ -287,6 +292,7 @@ def valve_close_analysis(
|
|||||||
result_db_name=name,
|
result_db_name=name,
|
||||||
persist_results=persist_results,
|
persist_results=persist_results,
|
||||||
scheme_username=username,
|
scheme_username=username,
|
||||||
|
scada_mappings=scada_mappings,
|
||||||
)
|
)
|
||||||
return result if not persist_results else None
|
return result if not persist_results else None
|
||||||
# step 3. restore the base model
|
# step 3. restore the base model
|
||||||
@@ -312,6 +318,7 @@ def flushing_analysis(
|
|||||||
valve_control: dict[str, dict] = None,
|
valve_control: dict[str, dict] = None,
|
||||||
_temporary_project: str | None = None,
|
_temporary_project: str | None = None,
|
||||||
persist_results: bool = True,
|
persist_results: bool = True,
|
||||||
|
scada_mappings: ScadaElementMappings | None = None,
|
||||||
) -> dict | str | None:
|
) -> dict | str | None:
|
||||||
"""
|
"""
|
||||||
管道冲洗模拟
|
管道冲洗模拟
|
||||||
@@ -441,6 +448,7 @@ def flushing_analysis(
|
|||||||
modify_total_duration=modify_total_duration,
|
modify_total_duration=modify_total_duration,
|
||||||
modify_valve_opening=modify_valve_opening,
|
modify_valve_opening=modify_valve_opening,
|
||||||
valve_control=valve_control,
|
valve_control=valve_control,
|
||||||
|
scada_mappings=scada_mappings,
|
||||||
scheme_type="flushing_analysis",
|
scheme_type="flushing_analysis",
|
||||||
scheme_name=scheme_name,
|
scheme_name=scheme_name,
|
||||||
result_db_name=name,
|
result_db_name=name,
|
||||||
@@ -469,6 +477,7 @@ def contaminant_simulation(
|
|||||||
username: str | None = None,
|
username: str | None = None,
|
||||||
_temporary_project: str | None = None,
|
_temporary_project: str | None = None,
|
||||||
persist_results: bool = True,
|
persist_results: bool = True,
|
||||||
|
scada_mappings: ScadaElementMappings | None = None,
|
||||||
) -> dict | str | None:
|
) -> dict | str | None:
|
||||||
"""
|
"""
|
||||||
污染模拟
|
污染模拟
|
||||||
@@ -591,6 +600,7 @@ def contaminant_simulation(
|
|||||||
scheme_username=username,
|
scheme_username=username,
|
||||||
scheme_detail=scheme_detail,
|
scheme_detail=scheme_detail,
|
||||||
persist_results=persist_results,
|
persist_results=persist_results,
|
||||||
|
scada_mappings=scada_mappings,
|
||||||
)
|
)
|
||||||
return result if not persist_results else None
|
return result if not persist_results else None
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""Question lifecycle sharing the operational task transaction and notification bus."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from app.auth.permissions import OPERATIONAL_TASK_EXECUTE, OPERATIONAL_TASK_WRITE
|
||||||
|
from app.domain.domain_errors import require
|
||||||
|
from app.domain.schemas.operational_task import OperationalTaskDetail, OperationalTaskSummary
|
||||||
|
from app.domain.schemas.task_question import TaskQuestionCreate, TaskQuestionReply, TaskQuestionRequest
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.services.operational_tasks import OperationalTaskService
|
||||||
|
|
||||||
|
|
||||||
|
async def list_questions(service: "OperationalTaskService", task_id: UUID) -> list[dict]:
|
||||||
|
return await service.repo.list_questions(str(task_id))
|
||||||
|
|
||||||
|
|
||||||
|
async def cancel_pending(service: "OperationalTaskService", task_id: UUID, now: datetime) -> None:
|
||||||
|
for raw in await list_questions(service, task_id):
|
||||||
|
question = TaskQuestionRequest.model_validate(raw)
|
||||||
|
if question.status != "pending":
|
||||||
|
continue
|
||||||
|
question = question.model_copy(update={"status": "cancelled", "version": question.version + 1})
|
||||||
|
await service.repo.save_question(str(task_id), question.model_dump(mode="json"))
|
||||||
|
await service._resolve_notifications(task_id, kinds=["human_input_required"],
|
||||||
|
subject_type="question", subject_id=question.question_request_id,
|
||||||
|
now=now, reason="question_superseded")
|
||||||
|
|
||||||
|
|
||||||
|
async def create_question(service: "OperationalTaskService", task_id: UUID,
|
||||||
|
command: TaskQuestionCreate) -> TaskQuestionRequest:
|
||||||
|
require(OPERATIONAL_TASK_EXECUTE in service.permissions, "PERMISSION_DENIED",
|
||||||
|
"Current user cannot publish task questions", status=403)
|
||||||
|
body = command.model_dump(mode="json")
|
||||||
|
scope = f"task:{task_id}:question:create"
|
||||||
|
async with service.repo.transaction():
|
||||||
|
await service.repo.lock(f"task:{task_id}")
|
||||||
|
replay = await service._request_replay(scope, command.request_id, body)
|
||||||
|
if replay:
|
||||||
|
return TaskQuestionRequest.model_validate(await service.repo.get_question(
|
||||||
|
str(task_id), replay["question_request_id"]))
|
||||||
|
task = await service._task(task_id)
|
||||||
|
service._require_actionable_task(task)
|
||||||
|
require(task["task_type"] == "event_investigation", "INVALID_TASK_TYPE",
|
||||||
|
"Questions require an investigation task", status=409)
|
||||||
|
await service.repo.lock(f"run:{command.run_id}")
|
||||||
|
run = await service._run(task_id, command.run_id)
|
||||||
|
require(run["status"] in {"claimed", "running"}
|
||||||
|
and run["generation"] == task["execution_generation"]
|
||||||
|
and run.get("agent_session_id") == command.source_session_id,
|
||||||
|
"SESSION_MISMATCH", "Question must belong to the active investigation run", status=409)
|
||||||
|
now = service._now()
|
||||||
|
service._require_active_run_lease(run, now)
|
||||||
|
await service._require_no_pending_calculations(task_id, run)
|
||||||
|
require(not any(q["status"] == "pending" for q in await list_questions(service, task_id)),
|
||||||
|
"QUESTION_PENDING", "Answer the existing questions first", status=409)
|
||||||
|
question = TaskQuestionRequest(question_request_id=uuid4(), task_id=task_id,
|
||||||
|
run_id=command.run_id, execution_generation=run["generation"],
|
||||||
|
source_session_id=command.source_session_id, questions=command.questions,
|
||||||
|
status="pending", version=1, created_at=now)
|
||||||
|
await service.repo.save_question(str(task_id), question.model_dump(mode="json"))
|
||||||
|
task.update(revision=task["revision"] + 1, updated_at=now)
|
||||||
|
await service.repo.update_task(OperationalTaskSummary.model_validate(task).model_dump(mode="json", exclude={"active_run", "latest_run"}))
|
||||||
|
await service._task_event(task, now)
|
||||||
|
await service._notify(task_id, "human_input_required", task["title"],
|
||||||
|
";".join(q.title for q in question.questions), now,
|
||||||
|
required_permission=OPERATIONAL_TASK_WRITE, subject_type="question",
|
||||||
|
subject_id=question.question_request_id)
|
||||||
|
await service._save_request(scope, command.request_id, body,
|
||||||
|
{"question_request_id": str(question.question_request_id)})
|
||||||
|
return question
|
||||||
|
|
||||||
|
|
||||||
|
async def answer_question(service: "OperationalTaskService", task_id: UUID, question_id: UUID,
|
||||||
|
command: TaskQuestionReply) -> OperationalTaskDetail:
|
||||||
|
require(OPERATIONAL_TASK_WRITE in service.permissions, "PERMISSION_DENIED",
|
||||||
|
"Current user cannot answer task questions", status=403)
|
||||||
|
body = command.model_dump(mode="json")
|
||||||
|
scope = f"task:{task_id}:question:{question_id}:answer"
|
||||||
|
async with service.repo.transaction():
|
||||||
|
await service.repo.lock(f"task:{task_id}")
|
||||||
|
if await service._request_replay(scope, command.request_id, body):
|
||||||
|
return await service.detail(task_id)
|
||||||
|
task = await service._task(task_id)
|
||||||
|
service._require_actionable_task(task)
|
||||||
|
raw = await service.repo.get_question(str(task_id), str(question_id))
|
||||||
|
require(raw is not None, "QUESTION_NOT_FOUND", "Question not found", status=404)
|
||||||
|
question = TaskQuestionRequest.model_validate(raw)
|
||||||
|
require(question.status == "pending" and question.version == command.expected_version
|
||||||
|
and question.execution_generation == task["execution_generation"],
|
||||||
|
"VERSION_CONFLICT", "Question is no longer pending in the current generation", status=409)
|
||||||
|
require(task["lifecycle_status"] == "waiting_human"
|
||||||
|
and await service.repo.get_active_run(str(task_id)) is None,
|
||||||
|
"RUN_NOT_SETTLED", "Wait until the investigation has entered waiting_human", status=409)
|
||||||
|
answers = {a.question_id: a for a in command.answers}
|
||||||
|
known = {q.question_id for q in question.questions}
|
||||||
|
require(len(answers) == len(command.answers) and set(answers) <= known,
|
||||||
|
"ANSWER_INVALID", "Unknown or duplicate questions", status=422)
|
||||||
|
for q in question.questions:
|
||||||
|
answer = answers.get(q.question_id)
|
||||||
|
selected = answer.selected_option_ids if answer else []
|
||||||
|
text = answer.custom_text.strip() if answer else ""
|
||||||
|
require(len(set(selected)) == len(selected) and set(selected) <= {o.id for o in q.options}
|
||||||
|
and (q.mode == "multiple" or len(selected) <= 1)
|
||||||
|
and (q.allow_custom or not text) and (not q.required or bool(selected or text)),
|
||||||
|
"ANSWER_INVALID", "Answer does not satisfy the question", status=422)
|
||||||
|
now = service._now()
|
||||||
|
question = question.model_copy(update={"status": "answered", "version": question.version + 1,
|
||||||
|
"answers": command.answers, "answered_at": now, "answered_by": service._actor()})
|
||||||
|
await service.repo.save_question(str(task_id), question.model_dump(mode="json"))
|
||||||
|
await service._resolve_notifications(task_id, kinds=["human_input_required"],
|
||||||
|
subject_type="question", subject_id=question_id, now=now, reason="question_answered")
|
||||||
|
await service._requeue_task(task, now)
|
||||||
|
await service.repo.update_task(OperationalTaskSummary.model_validate(task).model_dump(mode="json", exclude={"active_run", "latest_run"}))
|
||||||
|
await service._task_event(task, now)
|
||||||
|
await service._save_request(scope, command.request_id, body, {"task_id": str(task_id)})
|
||||||
|
return await service.detail(task_id)
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Publication-only validation for same-time-of-day line/scatter comparisons."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.domain.domain_errors import require
|
||||||
|
|
||||||
|
|
||||||
|
DISPLAY_TIMEZONE = timezone(timedelta(hours=8))
|
||||||
|
DAY_SECONDS = 86400
|
||||||
|
|
||||||
|
|
||||||
|
def validate_time_comparison_chart(chart: Mapping[str, Any], *, allow_missing_current: bool = False) -> None:
|
||||||
|
"""Validate new publications; persisted historical charts keep their read contract."""
|
||||||
|
chart_type = chart["chart_type"]
|
||||||
|
series = chart["series"]
|
||||||
|
kinds = {item.get("type") or chart_type for item in series}
|
||||||
|
if chart_type != "time_comparison":
|
||||||
|
require(not {"line", "scatter"} <= kinds, "TIME_COMPARISON_INVALID",
|
||||||
|
"曲线与历史散点复合图须使用 chart_type=time_comparison", status=422)
|
||||||
|
return
|
||||||
|
|
||||||
|
def check(condition: bool, message: str) -> None:
|
||||||
|
require(condition, "TIME_COMPARISON_INVALID", message, status=422)
|
||||||
|
|
||||||
|
kinds = {item.get("type") or "line" for item in series}
|
||||||
|
check(kinds == {"line", "scatter"},
|
||||||
|
"time_comparison 的 series 必须包含当前 line 和历史 scatter,不能混入 bar")
|
||||||
|
labels = chart["x_data"]
|
||||||
|
check(all(re.fullmatch(r"(?:[01][0-9]|2[0-3]):[0-5][0-9](?::[0-5][0-9])?", label)
|
||||||
|
for label in labels),
|
||||||
|
"time_comparison.x_data 只接受 HH:mm 或 HH:mm:ss 时刻;历史日期保留在 sample_times,不能追加到横轴")
|
||||||
|
check(len({len(label) for label in labels}) == 1, "x_data 必须使用一致的时刻精度")
|
||||||
|
seconds = [sum(int(part) * factor for part, factor in zip(label.split(":"), (3600, 60, 1)))
|
||||||
|
for label in labels]
|
||||||
|
offsets = [0]
|
||||||
|
for index in range(1, len(seconds)):
|
||||||
|
step = (seconds[index] - seconds[index - 1]) % DAY_SECONDS
|
||||||
|
check(step > 0, f"x_data[{index}] 重复了上一时刻;历史点应复用当前槽位")
|
||||||
|
offsets.append(offsets[-1] + step)
|
||||||
|
check(offsets[-1] < DAY_SECONDS, "x_data 必须按时间递增且小于一天,不能追加多天历史槽位")
|
||||||
|
|
||||||
|
current_bases: set[datetime] = set()
|
||||||
|
historical_bases: list[datetime] = []
|
||||||
|
for series_index, item in enumerate(series):
|
||||||
|
times = item.get("sample_times")
|
||||||
|
path = f"series[{series_index}]"
|
||||||
|
check(times is not None and len(times) == len(labels), f"{path}.sample_times 必须提供并与 x_data 等长")
|
||||||
|
bases: set[datetime] = set()
|
||||||
|
for index, (value, at) in enumerate(zip(item["data"], times, strict=True)):
|
||||||
|
check((value is None) == (at is None),
|
||||||
|
f"{path}.sample_times[{index}]:每个有效值必须保留真实带时区时间,缺测值和时间均填 null")
|
||||||
|
if at is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
local = datetime.fromisoformat(at).astimezone(DISPLAY_TIMEZONE)
|
||||||
|
base = local - timedelta(seconds=offsets[index])
|
||||||
|
except OverflowError:
|
||||||
|
check(False, f"{path}.sample_times[{index}] 超出同期对比可表达的日期范围")
|
||||||
|
label = labels[index]
|
||||||
|
expected = local.strftime("%H:%M:%S" if len(label) == 8 else "%H:%M")
|
||||||
|
check(expected == label and local.microsecond == 0 and (len(label) == 8 or local.second == 0),
|
||||||
|
f"{path}.sample_times[{index}] 换算为 UTC+8 后须与 x_data[{index}]={label} 对齐")
|
||||||
|
bases.add(base)
|
||||||
|
check(len(bases) <= 1, f"{path}.sample_times 必须按同一窗口对齐,不能将不同日期拼入同一组")
|
||||||
|
if (item.get("type") or "line") == "line":
|
||||||
|
current_bases.update(bases)
|
||||||
|
else:
|
||||||
|
historical_bases.extend(bases)
|
||||||
|
scada = chart.get("scada")
|
||||||
|
empty_scada = allow_missing_current and not current_bases and scada and scada["role"] == "comparison"
|
||||||
|
check(len(current_bases) == 1 or bool(empty_scada),
|
||||||
|
"当前 line 必须包含真实采样时间,且所有当前曲线使用同一窗口")
|
||||||
|
# SCADA publication separately verifies every null against persisted reads.
|
||||||
|
if empty_scada:
|
||||||
|
window_start = datetime.fromisoformat(scada["window_start"]).astimezone(DISPLAY_TIMEZONE)
|
||||||
|
start_seconds = window_start.hour * 3600 + window_start.minute * 60 + window_start.second
|
||||||
|
current = window_start.replace(microsecond=0) + timedelta(seconds=(seconds[0] - start_seconds) % DAY_SECONDS)
|
||||||
|
else:
|
||||||
|
current = next(iter(current_bases))
|
||||||
|
for historical in historical_bases:
|
||||||
|
delta = (current - historical).total_seconds()
|
||||||
|
check(delta > 0 and delta % DAY_SECONDS == 0,
|
||||||
|
"历史 scatter 必须来自当前窗口之前的对应日期,不能使用当天或未来采样")
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from psycopg import AsyncConnection
|
from psycopg import AsyncConnection
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from app.algorithms.pipe_health_prediction.survival_predictor import (
|
from app.algorithms.pipe_health_prediction.survival_predictor import (
|
||||||
PipeHealthSurvivalPredictor,
|
PipeHealthSurvivalPredictor,
|
||||||
@@ -15,14 +14,11 @@ from app.algorithms.scada_cleaning.pressure_series import clean_pressure_data_df
|
|||||||
from app.infra.db.postgresql.network_assets import NetworkAssetRepository
|
from app.infra.db.postgresql.network_assets import NetworkAssetRepository
|
||||||
from app.infra.db.postgresql.scada import ScadaInfoRepository
|
from app.infra.db.postgresql.scada import ScadaInfoRepository
|
||||||
from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository
|
from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository
|
||||||
from app.infra.db.timescaledb.repositories.analysis import AnalysisResultsRepository
|
|
||||||
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
|
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
|
||||||
|
|
||||||
|
|
||||||
class TimeseriesAnalysisService:
|
class TimeseriesAnalysisService:
|
||||||
"""
|
"""SCADA 清洗与管道健康预测;历史查询由 TimeseriesHistoryService 负责。"""
|
||||||
复合查询类,提供跨表查询功能
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _get_project_scada_index(
|
async def _get_project_scada_index(
|
||||||
@@ -31,294 +27,6 @@ class TimeseriesAnalysisService:
|
|||||||
scadas = await ScadaInfoRepository.get_scadas(postgres_conn)
|
scadas = await ScadaInfoRepository.get_scadas(postgres_conn)
|
||||||
return {scada["device_id"]: scada for scada in scadas}
|
return {scada["device_id"]: scada for scada in scadas}
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_scada_associated_realtime_simulation_data(
|
|
||||||
timescale_conn: AsyncConnection,
|
|
||||||
postgres_conn: AsyncConnection,
|
|
||||||
device_ids: List[str],
|
|
||||||
start_time: datetime,
|
|
||||||
end_time: datetime,
|
|
||||||
) -> Dict[str, List[Dict[str, Any]]]:
|
|
||||||
"""
|
|
||||||
获取 SCADA 关联的 link/node 模拟值
|
|
||||||
|
|
||||||
根据传入的 SCADA device_ids,找到关联的 link/node,
|
|
||||||
并根据对应的 type,查询对应的模拟数据
|
|
||||||
|
|
||||||
Args:
|
|
||||||
timescale_conn: TimescaleDB 异步连接
|
|
||||||
postgres_conn: PostgreSQL 异步连接
|
|
||||||
device_ids: SCADA 设备ID列表
|
|
||||||
start_time: 开始时间
|
|
||||||
end_time: 结束时间
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
模拟数据字典,以 device_id 为键,值为数据列表,每个数据包含 time, value 和 scada_id
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: 当 SCADA 设备未找到或字段无效时
|
|
||||||
"""
|
|
||||||
scada_by_id = await TimeseriesAnalysisService._get_project_scada_index(postgres_conn)
|
|
||||||
link_devices: dict[str, str] = {}
|
|
||||||
node_devices: dict[str, str] = {}
|
|
||||||
for device_id in device_ids:
|
|
||||||
target_scada = scada_by_id.get(device_id)
|
|
||||||
if not target_scada:
|
|
||||||
raise ValueError(f"SCADA device {device_id} not found")
|
|
||||||
scada_type = target_scada["device_type"]
|
|
||||||
if scada_type in {"pipe_flow", "flow"}:
|
|
||||||
link_devices[device_id] = target_scada["link_id"]
|
|
||||||
elif scada_type == "pressure":
|
|
||||||
node_devices[device_id] = target_scada["node_id"]
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown SCADA type: {scada_type}")
|
|
||||||
|
|
||||||
link_series = await RealtimeRepository.get_link_fields_by_ids_time_range(
|
|
||||||
timescale_conn, start_time, end_time,
|
|
||||||
list(dict.fromkeys(link_devices.values())), "flow",
|
|
||||||
)
|
|
||||||
node_series = await RealtimeRepository.get_node_fields_by_ids_time_range(
|
|
||||||
timescale_conn, start_time, end_time,
|
|
||||||
list(dict.fromkeys(node_devices.values())), "pressure",
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
device_id: [
|
|
||||||
{**item, "scada_id": device_id}
|
|
||||||
for item in (
|
|
||||||
link_series.get(element_id, [])
|
|
||||||
if device_id in link_devices
|
|
||||||
else node_series.get(element_id, [])
|
|
||||||
)
|
|
||||||
]
|
|
||||||
for device_id, element_id in (link_devices | node_devices).items()
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_scada_associated_analysis_simulation_data(
|
|
||||||
timescale_conn: AsyncConnection,
|
|
||||||
postgres_conn: AsyncConnection,
|
|
||||||
device_ids: List[str],
|
|
||||||
start_time: datetime,
|
|
||||||
end_time: datetime,
|
|
||||||
run_id: UUID,
|
|
||||||
) -> Dict[str, List[Dict[str, Any]]]:
|
|
||||||
"""
|
|
||||||
获取 SCADA 关联的 link/node 分析模拟值
|
|
||||||
|
|
||||||
根据传入的 SCADA device_ids,找到关联的 link/node,
|
|
||||||
并根据对应的 type,查询对应的模拟数据
|
|
||||||
|
|
||||||
Args:
|
|
||||||
timescale_conn: TimescaleDB 异步连接
|
|
||||||
postgres_conn: PostgreSQL 异步连接
|
|
||||||
device_ids: SCADA 设备ID列表
|
|
||||||
start_time: 开始时间
|
|
||||||
end_time: 结束时间
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
模拟数据字典,以 device_id 为键,值为数据列表,每个数据包含 time, value 和 scada_id
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: 当 SCADA 设备未找到或字段无效时
|
|
||||||
"""
|
|
||||||
scada_by_id = await TimeseriesAnalysisService._get_project_scada_index(postgres_conn)
|
|
||||||
link_devices: dict[str, str] = {}
|
|
||||||
node_devices: dict[str, str] = {}
|
|
||||||
for device_id in device_ids:
|
|
||||||
target_scada = scada_by_id.get(device_id)
|
|
||||||
if not target_scada:
|
|
||||||
raise ValueError(f"SCADA device {device_id} not found")
|
|
||||||
|
|
||||||
scada_type = target_scada["device_type"]
|
|
||||||
if scada_type in {"pipe_flow", "flow"}:
|
|
||||||
link_devices[device_id] = target_scada["link_id"]
|
|
||||||
elif scada_type == "pressure":
|
|
||||||
node_devices[device_id] = target_scada["node_id"]
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown SCADA type: {scada_type}")
|
|
||||||
|
|
||||||
link_series = await AnalysisResultsRepository.get_series_by_ids(
|
|
||||||
timescale_conn, run_id, "link",
|
|
||||||
list(dict.fromkeys(link_devices.values())), start_time, end_time, "flow",
|
|
||||||
)
|
|
||||||
node_series = await AnalysisResultsRepository.get_series_by_ids(
|
|
||||||
timescale_conn, run_id, "node",
|
|
||||||
list(dict.fromkeys(node_devices.values())), start_time, end_time, "pressure",
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
device_id: [
|
|
||||||
{**item, "scada_id": device_id}
|
|
||||||
for item in (
|
|
||||||
link_series.get(element_id, [])
|
|
||||||
if device_id in link_devices
|
|
||||||
else node_series.get(element_id, [])
|
|
||||||
)
|
|
||||||
]
|
|
||||||
for device_id, element_id in (link_devices | node_devices).items()
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_realtime_simulation_data(
|
|
||||||
timescale_conn: AsyncConnection,
|
|
||||||
feature_infos: List[Tuple[str, str]],
|
|
||||||
start_time: datetime,
|
|
||||||
end_time: datetime,
|
|
||||||
) -> Dict[str, List[Dict[str, Any]]]:
|
|
||||||
"""
|
|
||||||
获取 link/node 模拟值
|
|
||||||
|
|
||||||
根据传入的 feature_infos,找到关联的 link/node,
|
|
||||||
并根据对应的 type,查询对应的模拟数据
|
|
||||||
|
|
||||||
Args:
|
|
||||||
timescale_conn: TimescaleDB 异步连接
|
|
||||||
feature_infos: 传入的 feature 信息列表,包含 (element_id, type)
|
|
||||||
start_time: 开始时间
|
|
||||||
end_time: 结束时间
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
模拟数据字典,以 feature_id 为键,值为数据列表,每个数据包含 time, value 和 feature_id
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: 当 SCADA 设备未找到或字段无效时
|
|
||||||
"""
|
|
||||||
pipe_ids: list[str] = []
|
|
||||||
junction_ids: list[str] = []
|
|
||||||
for feature_id, feature_type in feature_infos:
|
|
||||||
if feature_type.lower() == "pipe":
|
|
||||||
pipe_ids.append(feature_id)
|
|
||||||
elif feature_type.lower() == "junction":
|
|
||||||
junction_ids.append(feature_id)
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown type: {feature_type}")
|
|
||||||
link_series = await RealtimeRepository.get_link_fields_by_ids_time_range(
|
|
||||||
timescale_conn, start_time, end_time, list(dict.fromkeys(pipe_ids)), "flow"
|
|
||||||
)
|
|
||||||
node_series = await RealtimeRepository.get_node_fields_by_ids_time_range(
|
|
||||||
timescale_conn, start_time, end_time,
|
|
||||||
list(dict.fromkeys(junction_ids)), "pressure",
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
feature_id: [
|
|
||||||
{**item, "feature_id": feature_id}
|
|
||||||
for item in (
|
|
||||||
link_series.get(feature_id, [])
|
|
||||||
if feature_type.lower() == "pipe"
|
|
||||||
else node_series.get(feature_id, [])
|
|
||||||
)
|
|
||||||
]
|
|
||||||
for feature_id, feature_type in feature_infos
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_analysis_simulation_data(
|
|
||||||
timescale_conn: AsyncConnection,
|
|
||||||
feature_infos: List[Tuple[str, str]],
|
|
||||||
start_time: datetime,
|
|
||||||
end_time: datetime,
|
|
||||||
run_id: UUID,
|
|
||||||
) -> Dict[str, List[Dict[str, Any]]]:
|
|
||||||
"""
|
|
||||||
获取 link/node 分析模拟值
|
|
||||||
|
|
||||||
根据传入的 feature_infos,找到关联的 link/node,
|
|
||||||
并根据对应的 type,查询对应的模拟数据
|
|
||||||
|
|
||||||
Args:
|
|
||||||
timescale_conn: TimescaleDB 异步连接
|
|
||||||
feature_infos: 传入的 feature 信息列表,包含 (element_id, type)
|
|
||||||
start_time: 开始时间
|
|
||||||
end_time: 结束时间
|
|
||||||
run_id: 分析运行 ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
模拟数据字典,以 feature_id 为键,值为数据列表,每个数据包含 time, value 和 feature_id
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: 当类型无效时
|
|
||||||
"""
|
|
||||||
pipe_ids: list[str] = []
|
|
||||||
junction_ids: list[str] = []
|
|
||||||
for feature_id, feature_type in feature_infos:
|
|
||||||
if feature_type.lower() == "pipe":
|
|
||||||
pipe_ids.append(feature_id)
|
|
||||||
elif feature_type.lower() == "junction":
|
|
||||||
junction_ids.append(feature_id)
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown type: {feature_type}")
|
|
||||||
link_series = await AnalysisResultsRepository.get_series_by_ids(
|
|
||||||
timescale_conn, run_id, "link", list(dict.fromkeys(pipe_ids)),
|
|
||||||
start_time, end_time, "flow",
|
|
||||||
)
|
|
||||||
node_series = await AnalysisResultsRepository.get_series_by_ids(
|
|
||||||
timescale_conn, run_id, "node", list(dict.fromkeys(junction_ids)),
|
|
||||||
start_time, end_time, "pressure",
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
feature_id: [
|
|
||||||
{**item, "feature_id": feature_id}
|
|
||||||
for item in (
|
|
||||||
link_series.get(feature_id, [])
|
|
||||||
if feature_type.lower() == "pipe"
|
|
||||||
else node_series.get(feature_id, [])
|
|
||||||
)
|
|
||||||
]
|
|
||||||
for feature_id, feature_type in feature_infos
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_element_associated_scada_data(
|
|
||||||
timescale_conn: AsyncConnection,
|
|
||||||
postgres_conn: AsyncConnection,
|
|
||||||
element_id: str,
|
|
||||||
start_time: datetime,
|
|
||||||
end_time: datetime,
|
|
||||||
use_cleaned: bool = False,
|
|
||||||
) -> Optional[Any]:
|
|
||||||
"""
|
|
||||||
获取 link/node 关联的 SCADA 监测值
|
|
||||||
|
|
||||||
根据传入的 link/node id,匹配 SCADA 信息,
|
|
||||||
如果存在关联的 SCADA device_id,获取实际的监测数据
|
|
||||||
|
|
||||||
Args:
|
|
||||||
timescale_conn: TimescaleDB 异步连接
|
|
||||||
postgres_conn: PostgreSQL 异步连接
|
|
||||||
element_id: link 或 node 的 ID
|
|
||||||
start_time: 开始时间
|
|
||||||
end_time: 结束时间
|
|
||||||
use_cleaned: 是否使用清洗后的数据 (True: "cleaned_value", False: "monitored_value")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
SCADA 监测数据值,如果没有找到则返回 None
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: 当元素类型无效时
|
|
||||||
"""
|
|
||||||
|
|
||||||
scada_by_id = await TimeseriesAnalysisService._get_project_scada_index(postgres_conn)
|
|
||||||
associated_scada = next(
|
|
||||||
(
|
|
||||||
scada
|
|
||||||
for scada in scada_by_id.values()
|
|
||||||
if (scada.get("node_id") or scada.get("link_id")) == element_id
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not associated_scada:
|
|
||||||
return None
|
|
||||||
|
|
||||||
device_id = associated_scada["device_id"]
|
|
||||||
|
|
||||||
data_field = "cleaned_value" if use_cleaned else "monitored_value"
|
|
||||||
|
|
||||||
res = await ScadaRepository.get_scada_field_by_id_time_range(
|
|
||||||
timescale_conn, [device_id], start_time, end_time, data_field
|
|
||||||
)
|
|
||||||
|
|
||||||
return {element_id: res.get(device_id, [])}
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def clean_scada_data(
|
async def clean_scada_data(
|
||||||
timescale_conn: AsyncConnection,
|
timescale_conn: AsyncConnection,
|
||||||
|
|||||||
@@ -57,6 +57,33 @@ def _points(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _history_series(
|
||||||
|
*, element_type: HistoryElementType, element_id: str,
|
||||||
|
source: HistorySource, source_unit: str, device_id: str | None = None,
|
||||||
|
unit_inferred: bool = False, points: list[HistoryPoint] | None = None,
|
||||||
|
) -> ElementHistorySeries:
|
||||||
|
metric = _target_metric(element_type)
|
||||||
|
return ElementHistorySeries(
|
||||||
|
element_id=element_id, element_type=element_type, device_id=device_id,
|
||||||
|
metric=metric, source=source, source_unit=source_unit,
|
||||||
|
display_unit=display_unit_for_metric(metric.value),
|
||||||
|
unit_inferred=unit_inferred, points=points if points is not None else [],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_result_units(
|
||||||
|
postgres_conn: AsyncConnection, query: ElementHistoryQuery,
|
||||||
|
) -> tuple[dict[str, Any], bool]:
|
||||||
|
units = await NetworkSettingsRepository.get_result_units(postgres_conn)
|
||||||
|
if query.mode.value != "analysis_comparison":
|
||||||
|
return units, False
|
||||||
|
run = await AnalysisRepository.get_run(postgres_conn, query.run_id)
|
||||||
|
if run is None:
|
||||||
|
raise ValueError(f"analysis run does not exist: {query.run_id}")
|
||||||
|
recorded = (run.get("parameters") or {}).get("result_units")
|
||||||
|
return (recorded, False) if isinstance(recorded, dict) else (units, True)
|
||||||
|
|
||||||
|
|
||||||
async def _query_page(timescale_conn, postgres_conn, query, selected_devices, limit, offset):
|
async def _query_page(timescale_conn, postgres_conn, query, selected_devices, limit, offset):
|
||||||
descriptors = []
|
descriptors = []
|
||||||
series = []
|
series = []
|
||||||
@@ -66,16 +93,14 @@ async def _query_page(timescale_conn, postgres_conn, query, selected_devices, li
|
|||||||
"source": source, "element_type": element_type,
|
"source": source, "element_type": element_type,
|
||||||
"id": device_id if source == "scada" else element_id, "field": field,
|
"id": device_id if source == "scada" else element_id, "field": field,
|
||||||
})
|
})
|
||||||
metric = _target_metric(element_type)
|
series.append(_history_series(
|
||||||
series.append(ElementHistorySeries(
|
|
||||||
element_id=element_id, element_type=element_type, device_id=device_id,
|
element_id=element_id, element_type=element_type, device_id=device_id,
|
||||||
metric=metric, source=(
|
source=(
|
||||||
HistorySource.SCADA_RAW if field == "monitored_value" else HistorySource.SCADA_CLEANED
|
HistorySource.SCADA_RAW if field == "monitored_value" else HistorySource.SCADA_CLEANED
|
||||||
) if source == "scada" else (
|
) if source == "scada" else (
|
||||||
HistorySource.ANALYSIS_SIMULATION if source == "analysis" else HistorySource.REALTIME_SIMULATION
|
HistorySource.ANALYSIS_SIMULATION if source == "analysis" else HistorySource.REALTIME_SIMULATION
|
||||||
),
|
),
|
||||||
source_unit=unit, display_unit=display_unit_for_metric(metric.value),
|
source_unit=unit, unit_inferred=unit_inferred,
|
||||||
unit_inferred=unit_inferred, points=[],
|
|
||||||
))
|
))
|
||||||
|
|
||||||
# Stable series ordering makes a page crossing raw/cleaned/model boundaries repeatable.
|
# Stable series ordering makes a page crossing raw/cleaned/model boundaries repeatable.
|
||||||
@@ -84,17 +109,8 @@ async def _query_page(timescale_conn, postgres_conn, query, selected_devices, li
|
|||||||
add(source="scada", element_type=element_type, element_id=element_id,
|
add(source="scada", element_type=element_type, element_id=element_id,
|
||||||
device_id=device_id, field=field, unit=device["measurement_unit"])
|
device_id=device_id, field=field, unit=device["measurement_unit"])
|
||||||
if query.mode.value != "observed":
|
if query.mode.value != "observed":
|
||||||
units = await NetworkSettingsRepository.get_result_units(postgres_conn)
|
units, inferred = await _resolve_result_units(postgres_conn, query)
|
||||||
source = "analysis" if query.mode.value == "analysis_comparison" else "realtime"
|
source = "analysis" if query.mode.value == "analysis_comparison" else "realtime"
|
||||||
inferred = False
|
|
||||||
if source == "analysis":
|
|
||||||
run = await AnalysisRepository.get_run(postgres_conn, query.run_id)
|
|
||||||
if run is None:
|
|
||||||
raise ValueError(f"analysis run does not exist: {query.run_id}")
|
|
||||||
recorded = (run.get("parameters") or {}).get("result_units")
|
|
||||||
inferred = not isinstance(recorded, dict)
|
|
||||||
if not inferred:
|
|
||||||
units = recorded
|
|
||||||
targets = {(t.element_type, t.element_id) for t in query.elements}
|
targets = {(t.element_type, t.element_id) for t in query.elements}
|
||||||
for element_type, element_id in sorted(targets):
|
for element_type, element_id in sorted(targets):
|
||||||
metric = _target_metric(element_type)
|
metric = _target_metric(element_type)
|
||||||
@@ -202,21 +218,18 @@ class TimeseriesHistoryService:
|
|||||||
rows = scada_by_device.get(device_id, [])
|
rows = scada_by_device.get(device_id, [])
|
||||||
if not rows:
|
if not rows:
|
||||||
continue
|
continue
|
||||||
metric = _target_metric(element_type)
|
|
||||||
unit = device["measurement_unit"]
|
unit = device["measurement_unit"]
|
||||||
for source, field in (
|
for source, field in (
|
||||||
(HistorySource.SCADA_RAW, "monitored_value"),
|
(HistorySource.SCADA_RAW, "monitored_value"),
|
||||||
(HistorySource.SCADA_CLEANED, "cleaned_value"),
|
(HistorySource.SCADA_CLEANED, "cleaned_value"),
|
||||||
):
|
):
|
||||||
series.append(
|
series.append(
|
||||||
ElementHistorySeries(
|
_history_series(
|
||||||
element_id=element_id,
|
element_id=element_id,
|
||||||
element_type=element_type,
|
element_type=element_type,
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
metric=metric,
|
|
||||||
source=source,
|
source=source,
|
||||||
source_unit=unit,
|
source_unit=unit,
|
||||||
display_unit=display_unit_for_metric(metric.value),
|
|
||||||
points=_points(
|
points=_points(
|
||||||
rows,
|
rows,
|
||||||
value_field=field,
|
value_field=field,
|
||||||
@@ -227,15 +240,8 @@ class TimeseriesHistoryService:
|
|||||||
if query.mode.value == "observed":
|
if query.mode.value == "observed":
|
||||||
return ElementHistoryResponse(series=series)
|
return ElementHistoryResponse(series=series)
|
||||||
|
|
||||||
current_units = await NetworkSettingsRepository.get_result_units(postgres_conn)
|
result_units, unit_inferred = await _resolve_result_units(postgres_conn, query)
|
||||||
if query.mode.value == "analysis_comparison":
|
if query.mode.value == "analysis_comparison":
|
||||||
run = await AnalysisRepository.get_run(postgres_conn, query.run_id)
|
|
||||||
if run is None:
|
|
||||||
raise ValueError(f"analysis run does not exist: {query.run_id}")
|
|
||||||
parameters = run.get("parameters") or {}
|
|
||||||
recorded_units = parameters.get("result_units")
|
|
||||||
unit_inferred = not isinstance(recorded_units, dict)
|
|
||||||
result_units = recorded_units if not unit_inferred else current_units
|
|
||||||
link_values = await AnalysisResultsRepository.get_series_by_ids(
|
link_values = await AnalysisResultsRepository.get_series_by_ids(
|
||||||
timescale_conn,
|
timescale_conn,
|
||||||
query.run_id,
|
query.run_id,
|
||||||
@@ -256,8 +262,6 @@ class TimeseriesHistoryService:
|
|||||||
)
|
)
|
||||||
source = HistorySource.ANALYSIS_SIMULATION
|
source = HistorySource.ANALYSIS_SIMULATION
|
||||||
else:
|
else:
|
||||||
unit_inferred = False
|
|
||||||
result_units = current_units
|
|
||||||
link_values = await RealtimeRepository.get_link_fields_by_ids_time_range(
|
link_values = await RealtimeRepository.get_link_fields_by_ids_time_range(
|
||||||
timescale_conn,
|
timescale_conn,
|
||||||
query.start_time,
|
query.start_time,
|
||||||
@@ -285,13 +289,11 @@ class TimeseriesHistoryService:
|
|||||||
continue
|
continue
|
||||||
source_unit = str(result_units.get(metric.value, "")).strip()
|
source_unit = str(result_units.get(metric.value, "")).strip()
|
||||||
series.append(
|
series.append(
|
||||||
ElementHistorySeries(
|
_history_series(
|
||||||
element_id=target.element_id,
|
element_id=target.element_id,
|
||||||
element_type=target.element_type,
|
element_type=target.element_type,
|
||||||
metric=metric,
|
|
||||||
source=source,
|
source=source,
|
||||||
source_unit=source_unit,
|
source_unit=source_unit,
|
||||||
display_unit=display_unit_for_metric(metric.value),
|
|
||||||
unit_inferred=unit_inferred,
|
unit_inferred=unit_inferred,
|
||||||
points=_points(
|
points=_points(
|
||||||
rows,
|
rows,
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.algorithms.valve_isolation.topology_search import valve_isolation_analysis
|
from app.algorithms.valve_isolation.topology_search import (
|
||||||
from app.native.wndb.gis.network_views import get_network_link_nodes
|
AccidentElementType,
|
||||||
|
valve_isolation_analysis,
|
||||||
|
)
|
||||||
|
from app.native.wndb.gis.network_views import get_valve_isolation_topology
|
||||||
|
|
||||||
|
|
||||||
def analyze_valve_isolation(
|
def analyze_valve_isolation(
|
||||||
network: str,
|
network: str,
|
||||||
accident_element: str | list[str],
|
accident_element: str | list[str],
|
||||||
disabled_valves: list[str] = None,
|
disabled_valves: list[str] | None = None,
|
||||||
|
accident_element_type: AccidentElementType = "auto",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
link_entries = get_network_link_nodes(network)
|
link_entries, source_nodes, closed_links = get_valve_isolation_topology(network)
|
||||||
return valve_isolation_analysis(link_entries, accident_element, disabled_valves)
|
return valve_isolation_analysis(
|
||||||
|
link_entries, accident_element, disabled_valves,
|
||||||
|
accident_element_type=accident_element_type,
|
||||||
|
water_source_nodes=source_nodes,
|
||||||
|
closed_links=closed_links,
|
||||||
|
)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"Stack",
|
"Stack",
|
||||||
"Section",
|
"Section",
|
||||||
"Text",
|
"Text",
|
||||||
|
"Result",
|
||||||
"Metric",
|
"Metric",
|
||||||
"Table",
|
"Table",
|
||||||
"Chart",
|
"Chart",
|
||||||
@@ -66,6 +67,7 @@
|
|||||||
"Stack",
|
"Stack",
|
||||||
"Section",
|
"Section",
|
||||||
"Text",
|
"Text",
|
||||||
|
"Result",
|
||||||
"Metric",
|
"Metric",
|
||||||
"Table",
|
"Table",
|
||||||
"Chart",
|
"Chart",
|
||||||
@@ -90,6 +92,21 @@
|
|||||||
"compact",
|
"compact",
|
||||||
"normal"
|
"normal"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"intent": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"response",
|
||||||
|
"comparison",
|
||||||
|
"report"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"density": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"compact",
|
||||||
|
"comfortable"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -116,6 +133,15 @@
|
|||||||
"type": "null"
|
"type": "null"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"result",
|
||||||
|
"analysis",
|
||||||
|
"evidence",
|
||||||
|
"follow_up"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -139,6 +165,10 @@
|
|||||||
"info",
|
"info",
|
||||||
"warning"
|
"warning"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 200
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -148,6 +178,47 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#"
|
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||||
},
|
},
|
||||||
|
"Result": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 200
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 4000
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"success",
|
||||||
|
"partial",
|
||||||
|
"unconfirmed",
|
||||||
|
"error"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"title",
|
||||||
|
"summary",
|
||||||
|
"status",
|
||||||
|
"source"
|
||||||
|
],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||||
|
},
|
||||||
"Metric": {
|
"Metric": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -239,9 +310,18 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"line",
|
"line",
|
||||||
"bar"
|
"bar",
|
||||||
|
"time_comparison"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"x_axis_name": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 128
|
||||||
|
},
|
||||||
|
"y_axis_name": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 128
|
||||||
|
},
|
||||||
"x_data": {
|
"x_data": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@@ -260,11 +340,44 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"maxLength": 200
|
"maxLength": 200
|
||||||
},
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"line",
|
||||||
|
"bar",
|
||||||
|
"scatter"
|
||||||
|
]
|
||||||
|
},
|
||||||
"data": {
|
"data": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
"type": "number"
|
"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,
|
"minItems": 1,
|
||||||
"maxItems": 100
|
"maxItems": 100
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"contracts": {
|
"contracts": {
|
||||||
"server": {
|
"server": {
|
||||||
"file": "server-v1.openapi.json",
|
"file": "server-v1.openapi.json",
|
||||||
"sha256": "66fbf1105aa966a590f93fac176cbc77d22a4a6cf0a94dfcd1d3c27273f73692"
|
"sha256": "9801087d40ac0e1f67871f9a1db850cbcce47614cceec0a7e8771cdf58385f53"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@
|
|||||||
"$ref": "#/components/schemas/ManagedResourceCapabilities"
|
"$ref": "#/components/schemas/ManagedResourceCapabilities"
|
||||||
},
|
},
|
||||||
"origin": {
|
"origin": {
|
||||||
"const": "synthetic",
|
"const": "system_data",
|
||||||
"title": "Origin",
|
"title": "Origin",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -273,6 +273,12 @@
|
|||||||
],
|
],
|
||||||
"title": "Location"
|
"title": "Location"
|
||||||
},
|
},
|
||||||
|
"origin": {
|
||||||
|
"const": "system_data",
|
||||||
|
"default": "system_data",
|
||||||
|
"title": "Origin",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"priority": {
|
"priority": {
|
||||||
"default": "normal",
|
"default": "normal",
|
||||||
"enum": [
|
"enum": [
|
||||||
@@ -1681,6 +1687,18 @@
|
|||||||
],
|
],
|
||||||
"title": "Location"
|
"title": "Location"
|
||||||
},
|
},
|
||||||
|
"occurred_at": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"format": "date-time",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Occurred At"
|
||||||
|
},
|
||||||
"priority": {
|
"priority": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -1843,7 +1861,7 @@
|
|||||||
"title": "Location"
|
"title": "Location"
|
||||||
},
|
},
|
||||||
"origin": {
|
"origin": {
|
||||||
"const": "synthetic",
|
"const": "system_data",
|
||||||
"title": "Origin",
|
"title": "Origin",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -2036,8 +2054,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"description": "测试监测与核查反馈样板,不是正式工单平台或设备控制服务。",
|
"description": "按项目管理业务工单与反馈、读取监测历史;测试数据保留独立来源标记。",
|
||||||
"title": "TJWater 外围系统模拟接入",
|
"title": "TJWater 外围系统接入",
|
||||||
"version": "1.0.0"
|
"version": "1.0.0"
|
||||||
},
|
},
|
||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
|
|||||||
+4006
-15
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user