后端服务将通过tjwater-cli形式访问

This commit is contained in:
2026-06-02 15:31:21 +08:00
parent 20329bb771
commit 5b285ad7a5
65 changed files with 1288 additions and 2286 deletions
@@ -0,0 +1,210 @@
---
name: tjwater-workflow-simulation-diagnosis
description: 单步长水力计算与管网问题诊断工作流。
version: 3.0.0
---
# Simulation Diagnosis Workflow Skill
## 简介
`simulation run` → 查询模拟结果 → 快速综合诊断 → 结构化报告。覆盖超流速、低压区、高水损、死水管段、Closed 阀门等常见运行问题。
**仅依赖模拟结果**(无需管径/长度/粗糙度等属性),适用于快速巡检、初筛、事故后评估。如需结合管径做详细瓶颈评分与改造建议,走 `bottleneck-analysis` 工作流。
---
## 执行速览(Golden Path
"跑一下水力计算看看管网有什么问题" 的标准执行路径:
```
┌──────────────────────────────────────────────────────────────┐
│ 1. tjwater_cli(command="simulation run --start-time ... │
│ --duration 30") │
│ ↓ 模拟结果写入服务端时序库 │
│ 2. tjwater_cli(command="data timeseries realtime links │
│ --start-time ... --end-time ...") │
│ ↓ (写为本地 JSON) │
│ 3. python diagnose_simulation.py result.json │
│ ↓ (stdout 结构化报告) │
│ 4. 解读脚本输出 → 按严重级别组织 Markdown 报告 │
│ ↓ (可选) │
│ 5. show_chart / locate_features / view_scada │
└──────────────────────────────────────────────────────────────┘
```
> **关键原则**:排序/筛选/聚合全部在 Python 脚本或本地完成。前端工具仅用于渲染。
---
## 适用场景
| 场景 | 说明 |
|------|------|
| "跑一次水力计算看看管网有什么问题" | 直接适用,走 Golden Path |
| 管道属性接口不可用或数据不全 | 仅依赖模拟结果,无需属性数据 |
| 快速巡检 / 定期健康检查 | 低开销,脚本一秒给出问题概览 |
| 爆管/事故后的快速评估 | 单步稳态模拟,快速定位异常区域 |
| 作为 bottleneck-analysis 的前置筛查 | 先定位问题区域,再深入分析 |
---
## 阶段一:触发模拟运行
### CLI 调用
```
tjwater_cli:
command: "simulation run --start-time 2025-01-02T03:04:05+08:00 --duration 30"
```
- `--start-time`:模拟起始绝对时间,必须显式带时区(UTC+8)
- `--duration`:模拟持续分钟数
- 结果写入服务端时序库,不直接返回
### 查询模拟结果
```
tjwater_cli:
command: "data timeseries realtime links --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00"
timeout: 300
```
```
tjwater_cli:
command: "data timeseries realtime nodes --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00"
timeout: 300
```
### 返回数据结构
输出为 JSON`data` 字段包含结果数组。
**links** — 管段水力状态:
| 字段 | 单位 | 说明 |
|------|------|------|
| link_id | — | 管段 ID |
| flow | LPS | 流量(正负表示流向) |
| velocity | m/s | 流速 |
| headloss | m | 水头损失 |
| status | — | Open / Closed / Critical |
**nodes** — 节点水力状态:
| 字段 | 单位 | 说明 |
|------|------|------|
| node_id | — | 节点 ID |
| demand | LPS | 需水量 |
| head | m | 水头 |
| pressure | KPA | 压力 |
---
## 阶段二:全局异常检查(优先!)
**在进入局部分析之前**,必须检查以下全局信号——如果命中则直接输出针对性诊断:
| 全局信号 | 判定条件 | 含义 | 处理 |
|---------|---------|------|------|
| **模拟断流** | 所有 link velocity ≈ 0 | 阀门全关 / 水源缺失 / 模型断连 | 检查模型配置,不做局部分析 |
| **模拟未收敛** | 所有 node pressure = 0 | 水力方程未收敛 | 检查 INP 设定,不做局部分析 |
| **系统性低压** | >70% 节点 pressure < 28 KPA | 水源水头不足 / 泵站扬程不够 | 输出"全局性低压"结论,排查水源/泵站 |
| **结果不完整** | links 或 nodes 结果为空 | 接口异常或模型损坏 | 报告数据不可用,提示重试 |
---
## 阶段三:自动诊断
### 使用 `diagnose_simulation.py`
脚本路径:`scripts/diagnose_simulation.py`
```bash
# 基本用法
python scripts/diagnose_simulation.py /tmp/opencode/sim_result.json
# 自定义阈值
python scripts/diagnose_simulation.py /tmp/opencode/sim_result.json \
--velocity-warn 1.2 \
--velocity-critical 2.0 \
--pressure-warn 40 \
--pressure-critical 28 \
--headloss-top 30 \
--flow-top 20
# 仅输出摘要
python scripts/diagnose_simulation.py /tmp/opencode/sim_result.json --summary-only
```
脚本自动完成:
- 全局统计(管段/节点总数,流速/压力/流量/水损的 max/mean/分位数)
- 超流速管段 TOP 20
- 高水损管段 TOP 30
- 低压节点统计
- 零流量管段、Closed 管段
- 高流量主干管 TOP 20
---
## 阶段四:报告生成与可视化
### 报告结构
```
---
## 管网运行诊断报告
### 一、模拟概况
- 时间窗口 / 管网规模 / 单位
### 二、主要发现(按严重程度排序)
🚨 紧急 / ⚡ 重点 / 📋 关注
每项含:问题标题 + 受影响数 + TOP 示例(3-5条含数据) + 可能原因 + 建议
### 三、数据局限
- 区分"数据直接支持的结论"和"工程经验的推测"
- 标注管径数据是否缺失及其对结论的影响
```
### 可视化(可选)
- `show_chart`:流速/压力分布柱状图
- `locate_features`:地图定位 TOP 问题管段
- `view_scada`:问题区域实时监测
- `render_junctions` + `store_render_ref`:按压力区间着色
---
## 诊断指标参考阈值
| 指标 | 阈值 | 依据 |
|------|------|------|
| 流速安全上限 | 1.2 m/s | 行业管道冲刷防护上限 |
| 流速紧急上限 | 2.0 m/s | 严重超速临界值 |
| 最低供水压力 | 28 KPA | 国标 GB 50268 |
| 正常压力下限 | 40 KPA | 居民供水压力下限 |
> 所有阈值为可调启发式,脚本支持参数覆写。
---
## 下游衔接:何时升级到 bottleneck-analysis
| 诊断发现 | 行动 |
|---------|------|
| 系统性低压(>70% 节点 <28 KPA | **不**进入 bottleneck-analysis;排查水源/泵站 |
| TOP 20 超速管段中 ≥5 条流速 >1.5 m/s | 升级到 bottleneck-analysis |
| 存在多维度交叉异常管段 | 升级分析 |
| 仅少量告警、无紧急项 | 结束,不升级 |
**数据移交**:从 diagnosis 结果提取 TOP 问题管段 ID 列表,传入 bottleneck-analysis。
---
## Learned Patterns
- [d1f826184d8709db1988b00a] **渐进式 Top-K 分析**:大结果集先算全局统计量定位异常范围,再对 Top-K 做明细检查。
- [a19477bebe442e5d87bc78bc] **多准则交叉印证**:同时分析 velocity / headloss / flow / pressure 四个维度,紧急管段需满足多项异常条件。
- [cf50cc317b9bfe254426482d] **API 数据降级**:管道属性不可用时不停流程,用 velocity / headloss 作为代理指标。
- [1cd621a60d7481a1c072a54f] **系统性低压优先诊断**:全网 >70% 节点 pressure<28KPA 时定位为全局性问题。
- [a7c30102cf5c2a5c0e8cc8dd] **诊断脚本标准化**`diagnose_simulation.py` 是分析主力,支持自定义阈值。
- [8b533e5bfc43c014ebcc90cf] **压力单位验证**:验证 pressure 字段实际单位(KPA vs m H₂O),阈值随单位调整。
@@ -0,0 +1,341 @@
#!/usr/bin/env python3
"""
diagnose_simulation.py — 水力模拟结果快速诊断脚本
用途:对 runprojectreturndict 返回的 link_results + node_results 做多维度分析。
输出排序后的问题清单与统计摘要,供仿真诊断工作流使用。
用法:
python diagnose_simulation.py <input_json> [options]
输入 JSON 格式(runprojectreturndict 返回结构):
{
"link_results": [
{"link_id": "...", "flow": float, "velocity": float,
"headloss": float, "status": "...", "friction": float},
...
],
"node_results": [
{"node_id": "...", "demand": float, "head": float, "pressure": float},
...
},
... // 其他字段(如 tanks, pumps, valves 等)可选
}
Options:
--velocity-warn FLOAT 超速预警阈值 (默认 1.2 m/s)
--velocity-critical FLOAT 超速严重阈值 (默认 2.0 m/s)
--pressure-warn FLOAT 低压预警阈值 (默认 40 KPA)
--pressure-critical FLOAT 低压严重阈值 (默认 28 KPA)
--headloss-top INT 高水损 TOP N (默认 30)
--flow-top INT 高流量主干管 TOP N (默认 20)
--output FILE 输出结果到 JSON 文件 (可选)
--summary-only 只输出统计摘要,不输出明细列表
输出:
stdout 打印结构化诊断报告,含各维度问题清单和全局统计。
"""
import json
import sys
import argparse
from collections import Counter
# ── 默认阈值 ──────────────────────────────────────────────
DEFAULT_VELOCITY_WARN = 1.2 # m/s
DEFAULT_VELOCITY_CRITICAL = 2.0 # m/s
DEFAULT_PRESSURE_WARN = 40.0 # KPA
DEFAULT_PRESSURE_CRITICAL = 28.0 # KPA
DEFAULT_HEADLOSS_TOP = 30
DEFAULT_FLOW_TOP = 20
def load_input(path: str) -> dict:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def analyze_links(link_results: list, args):
"""分析管段维度:超流速、高水损、零流量、Closed 状态。"""
issues = {}
# ── 超流速管段 ──
over_velocity_warn = []
over_velocity_critical = []
zero_flow = []
closed_links = []
for link in link_results:
v = link.get("velocity", 0) or 0
q = abs(link.get("flow", 0) or 0)
status = (link.get("status") or "").strip().lower()
hl = link.get("headloss", 0) or 0
# 流速分级
if v > args.velocity_critical:
over_velocity_critical.append(link)
elif v > args.velocity_warn:
over_velocity_warn.append(link)
# 零流量(仅统计 Open 状态的)
if q < 0.001 and status == "open":
zero_flow.append(link)
# Closed 管段
if status != "open":
closed_links.append(link)
# 按流速降序排序
over_velocity_critical.sort(key=lambda x: x.get("velocity", 0), reverse=True)
over_velocity_warn.sort(key=lambda x: x.get("velocity", 0), reverse=True)
issues["over_velocity_critical"] = {
"count": len(over_velocity_critical),
"threshold": f">{args.velocity_critical} m/s",
"items": over_velocity_critical[:20],
"top_velocity": over_velocity_critical[0]["velocity"] if over_velocity_critical else 0,
}
issues["over_velocity_warn"] = {
"count": len(over_velocity_warn),
"threshold": f">{args.velocity_warn} m/s",
"items": over_velocity_warn[:20],
}
# ── 高水损管段(按绝对值降序) ──
high_headloss = sorted(link_results, key=lambda x: abs(x.get("headloss", 0) or 0), reverse=True)
issues["high_headloss"] = {
"count": len([x for x in link_results if abs(x.get("headloss", 0) or 0) > 0.5]),
"items": high_headloss[:args.headloss_top],
}
# ── 零流量管段 ──
issues["zero_flow_open"] = {
"count": len(zero_flow),
}
# ── Closed 管段 ──
issues["closed_links"] = {
"count": len(closed_links),
"items": closed_links[:20],
}
# ── 高流量主干管 ──
high_flow = sorted(link_results, key=lambda x: abs(x.get("flow", 0) or 0), reverse=True)
issues["high_flow_trunk"] = {
"items": high_flow[:args.flow_top],
}
return issues
def analyze_nodes(node_results: list, args):
"""分析节点维度:低压、高压。"""
issues = {}
pressure_critical = []
pressure_warn = []
high_pressure = []
for node in node_results:
p = node.get("pressure", 0) or 0
if p < args.pressure_critical:
pressure_critical.append(node)
elif p < args.pressure_warn:
pressure_warn.append(node)
if p > 200:
high_pressure.append(node)
pressure_critical.sort(key=lambda x: x.get("pressure", 0))
pressure_warn.sort(key=lambda x: x.get("pressure", 0))
total = len(node_results)
issues["pressure_critical"] = {
"count": len(pressure_critical),
"pct": round(len(pressure_critical) / total * 100, 1) if total else 0,
"threshold": f"<{args.pressure_critical} KPA",
"items": pressure_critical[:20],
}
issues["pressure_warn"] = {
"count": len(pressure_warn),
"pct": round(len(pressure_warn) / total * 100, 1) if total else 0,
"threshold": f"<{args.pressure_warn} KPA (>= {args.pressure_critical})",
"items": pressure_warn[:20],
}
issues["high_pressure"] = {
"count": len(high_pressure),
"items": high_pressure[:20],
}
return issues, total
def global_stats(link_results, node_results):
"""计算全局统计量。"""
velocities = [x.get("velocity", 0) or 0 for x in link_results]
flows = [abs(x.get("flow", 0) or 0) for x in link_results]
headlosses = [abs(x.get("headloss", 0) or 0) for x in link_results]
pressures = [x.get("pressure", 0) or 0 for x in node_results]
demands = [x.get("demand", 0) or 0 for x in node_results]
# 状态分布
status_counter = Counter((x.get("status") or "").strip().lower() for x in link_results)
stats = {
"link_count": len(link_results),
"node_count": len(node_results),
"velocity": {
"max": round(max(velocities), 4),
"mean": round(sum(velocities) / len(velocities), 4) if velocities else 0,
"p95": sorted(velocities)[int(len(velocities) * 0.95)] if velocities else 0,
},
"pressure": {
"max": round(max(pressures), 2),
"min": round(min(pressures), 2),
"mean": round(sum(pressures) / len(pressures), 2) if pressures else 0,
"p5": sorted(pressures)[int(len(pressures) * 0.05)] if pressures else 0,
},
"flow": {
"max": round(max(flows), 4),
},
"headloss": {
"max": round(max(headlosses), 4),
},
"status_distribution": dict(status_counter.most_common()),
}
return stats
def format_report(stats, link_issues, node_issues, node_total, summary_only=False):
"""输出格式化的诊断报告。"""
lines = []
lines.append("=" * 60)
lines.append(" 水力模拟诊断报告")
lines.append("=" * 60)
lines.append("")
# ── 全局统计 ──
lines.append("【一、全局统计】")
lines.append(f" 管段总数: {stats['link_count']}")
lines.append(f" 节点总数: {stats['node_count']}")
lines.append(f" 流速: 最大 {stats['velocity']['max']} m/s, "
f"平均 {stats['velocity']['mean']} m/s, P95 {stats['velocity']['p95']} m/s")
lines.append(f" 压力: 最大 {stats['pressure']['max']} KPA, "
f"最小 {stats['pressure']['min']} KPA, "
f"平均 {stats['pressure']['mean']} KPA, P5 {stats['pressure']['p5']} KPA")
lines.append(f" 最大流量: {stats['flow']['max']} LPS")
lines.append(f" 最大水损: {stats['headloss']['max']} m")
lines.append(f" 状态分布: {stats['status_distribution']}")
lines.append("")
# ── 超流速 ──
crit_v = link_issues["over_velocity_critical"]
warn_v = link_issues["over_velocity_warn"]
lines.append("【二、超流速管段】")
lines.append(f" 🔴 严重超速 (> {crit_v['threshold']}): {crit_v['count']} 条, "
f"最高 {crit_v['top_velocity']} m/s")
if crit_v["count"] > 0 and not summary_only:
for item in crit_v["items"][:10]:
lines.append(f" - {item['link_id']}: {item['velocity']} m/s, "
f"flow={item.get('flow', 'N/A')} LPS, status={item.get('status', 'N/A')}")
lines.append(f" 🟡 流速预警 (> {warn_v['threshold']}): {warn_v['count']}")
lines.append("")
# ── 高水损 ──
hl = link_issues["high_headloss"]
lines.append("【三、高水损管段 TOP】")
lines.append(f" 水损 > 0.5m 的管段: {hl['count']}")
if hl["items"] and not summary_only:
for item in hl["items"][:10]:
lines.append(f" - {item['link_id']}: headloss={item.get('headloss', 0)} m, "
f"velocity={item.get('velocity', 0)} m/s")
lines.append("")
# ── 低压节点 ──
pc = node_issues["pressure_critical"]
pw = node_issues["pressure_warn"]
lines.append("【四、低压节点】")
lines.append(f" 🔴 严重低压 ({pc['threshold']}): {pc['count']}"
f"({pc['pct']}%)")
if pc["count"] > 0 and not summary_only:
for item in pc["items"][:10]:
lines.append(f" - {item['node_id']}: {item['pressure']} KPA, "
f"demand={item.get('demand', 0)} LPS, head={item.get('head', 0)} m")
lines.append(f" 🟡 低压预警 ({pw['threshold']}): {pw['count']}"
f"({pw['pct']}%)")
lines.append("")
# ── 其他发现 ──
lines.append("【五、其他发现】")
zf = link_issues["zero_flow_open"]
cl = link_issues["closed_links"]
hp = node_issues["high_pressure"]
lines.append(f" - 零流量 (Open 状态但 flow≈0): {zf['count']}")
lines.append(f" - Closed 管段/阀门: {cl['count']}")
if hp["count"] > 0:
lines.append(f" - 超压节点 (> 200 KPA): {hp['count']}")
lines.append("")
# ── 高流量主干 ──
hft = link_issues["high_flow_trunk"]
lines.append("【六、高流量主干管 TOP】")
if hft["items"] and not summary_only:
for item in hft["items"][:10]:
lines.append(f" - {item['link_id']}: |flow|={abs(item.get('flow', 0))} LPS, "
f"velocity={item.get('velocity', 0)} m/s")
lines.append("")
lines.append("=" * 60)
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(description="水力模拟结果快速诊断")
parser.add_argument("input", help="输入 JSON 文件路径 (runprojectreturndict 结果)")
parser.add_argument("--velocity-warn", type=float, default=DEFAULT_VELOCITY_WARN)
parser.add_argument("--velocity-critical", type=float, default=DEFAULT_VELOCITY_CRITICAL)
parser.add_argument("--pressure-warn", type=float, default=DEFAULT_PRESSURE_WARN)
parser.add_argument("--pressure-critical", type=float, default=DEFAULT_PRESSURE_CRITICAL)
parser.add_argument("--headloss-top", type=int, default=DEFAULT_HEADLOSS_TOP)
parser.add_argument("--flow-top", type=int, default=DEFAULT_FLOW_TOP)
parser.add_argument("--output", help="输出 JSON 结果到文件")
parser.add_argument("--summary-only", action="store_true", help="仅输出统计摘要")
args = parser.parse_args()
data = load_input(args.input)
link_results = data.get("link_results", [])
node_results = data.get("node_results", [])
if not link_results and not node_results:
print("错误: 输入数据中既无 link_results 也无 node_results", file=sys.stderr)
sys.exit(1)
# 执行分析
stats = global_stats(link_results, node_results)
link_issues = analyze_links(link_results, args)
node_issues, node_total = analyze_nodes(node_results, args)
# 输出报告
report = format_report(stats, link_issues, node_issues, node_total, args.summary_only)
print(report)
# 输出 JSON(可选)
if args.output:
output_data = {
"stats": stats,
"link_issues": {
k: {kk: vv for kk, vv in v.items() if kk != "items"}
for k, v in link_issues.items()
},
"node_issues": {
k: {kk: vv for kk, vv in v.items() if kk != "items"}
for k, v in node_issues.items()
},
}
with open(args.output, "w", encoding="utf-8") as f:
json.dump(output_data, f, ensure_ascii=False, indent=2)
print(f"详细结果已输出到: {args.output}")
if __name__ == "__main__":
main()