feat(agent): sandbox conversation analysis
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
---
|
||||
name: hydraulic-bottleneck-analysis
|
||||
description: 基于实时水力数据的管网水力瓶颈识别与改造建议。复合评分法(流速×水头损失)定位瓶颈管段,输出分级改造方案。
|
||||
---
|
||||
|
||||
# 水力瓶颈分析工作流
|
||||
|
||||
## 概述
|
||||
|
||||
本工作流通过复合评分法(流速分级 × 水头损失百分位)从全管网管道中识别水力瓶颈管段,并结合节点压力、管径、粗糙系数给出分级改造建议。
|
||||
|
||||
适用场景:管网运行评估、管网改造优先级排序、泵站阀站运行诊断。
|
||||
|
||||
## 评分方法论
|
||||
|
||||
### 双维度复合评分
|
||||
|
||||
| 维度 | 判定标准 | 分值 |
|
||||
|------|----------|------|
|
||||
| **流速** | >3.0 m/s = 极危 | 3 |
|
||||
| | 2.0–3.0 m/s = 严重 | 2 |
|
||||
| | 1.5–2.0 m/s = 偏高 | 1 |
|
||||
| | <1.5 m/s = 正常 | 0 |
|
||||
| **水头损失** | >P90 = 严重 | 2 |
|
||||
| | P80–P90 = 中度 | 1 |
|
||||
| | <P80 = 正常 | 0 |
|
||||
|
||||
**瓶颈判定**:`(流速≥1 且 水损≥1)` 或 `(流速≥2)` —— 即双侧超标或单侧流速严重。
|
||||
|
||||
**复合评分** = 流速分值 + 水损分值(最高 5 分),按降序排列。
|
||||
|
||||
### 辅助指标
|
||||
|
||||
| 指标 | 阈值 | 含义 |
|
||||
|------|------|------|
|
||||
| 节点压力 < 20m | — | 低压区域,需增压 |
|
||||
| 节点压力 20–25m | — | 压力偏低 |
|
||||
| roughness > 130 | — | 管壁粗糙,建议内衬修复 |
|
||||
|
||||
> ⚠️ **setting 字段无效**:`data timeseries realtime links` 返回的 `setting` 字段为无效值,不可用于阀门节流或水泵出口判定。若需确定阀门/泵状态,应通过 `network get-link-properties` 逐条查询。
|
||||
|
||||
## 数据依赖
|
||||
|
||||
| 步骤 | 命令 | 数据量 | 超时 | 关键字段 |
|
||||
|------|------|--------|------|----------|
|
||||
| ① 管道属性 | `network get-all-pipes-properties` | ~11.7MB / 91K条 | 120s | id, node1, node2, length, diameter, roughness |
|
||||
| ② 管道水力 | `data timeseries realtime links --start-time T --end-time T+15min` | ~39MB / 182K条 | 300s | id, flow, velocity, headloss, time |
|
||||
| ③ 节点压力 | `data timeseries realtime nodes --start-time T --end-time T+15min` | ~28MB / 176K条 | 300s | id, pressure, time |
|
||||
|
||||
> **时间窗口说明**:模拟步长 15 分钟,查询窗口取 `T~T+15min` 可覆盖 1–2 个时间步。脚本内部按 `--target-time` 精确筛选目标时刻的记录。
|
||||
|
||||
> **大结果集处理**:三份调用都使用 `store_result=true`,结果保存到当前对话的 `tool-data/` 目录。脚本直接读取每次返回的 `data_file.file_path`,不得访问全局 `tool-output/`。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
### 第 1 步:拉取三份数据
|
||||
|
||||
并行发起 3 个 `tjwater_cli` 调用(互不依赖):
|
||||
|
||||
```bash
|
||||
# ① 管道静态属性
|
||||
tjwater_cli(command="network get-all-pipes-properties", timeout=120, store_result=true)
|
||||
|
||||
# ② 目标时刻管道水力数据
|
||||
tjwater_cli(command="data timeseries realtime links --start-time 2026-04-01T08:00:00+08:00 --end-time 2026-04-01T08:15:00+08:00", timeout=300, store_result=true)
|
||||
|
||||
# ③ 目标时刻节点压力数据
|
||||
tjwater_cli(command="data timeseries realtime nodes --start-time 2026-04-01T08:00:00+08:00 --end-time 2026-04-01T08:15:00+08:00", timeout=300, store_result=true)
|
||||
```
|
||||
|
||||
### 第 2 步:运行分析脚本
|
||||
|
||||
```bash
|
||||
python3 <skill_dir>/scripts/bottleneck_analysis.py \
|
||||
--pipe-props <data_file.file_path-①> \
|
||||
--realtime <data_file.file_path-②> \
|
||||
--node-pressures <data_file.file_path-③> \
|
||||
--target-time '2026-04-01T08:00:00+08:00' \
|
||||
--top 50 \
|
||||
2>./bottleneck_report.txt
|
||||
```
|
||||
|
||||
**脚本参数**:
|
||||
- `--pipe-props`:管道属性 JSON 文件路径(必填)
|
||||
- `--realtime`:实时管道水力 JSON 文件路径(必填)
|
||||
- `--node-pressures`:实时节点压力 JSON 文件路径(必填)
|
||||
- `--target-time`:目标时刻 ISO8601(必填,如 `2026-04-01T08:00:00+08:00`)
|
||||
- `--top`:输出 Top N 瓶颈管段(默认 100)
|
||||
|
||||
**输出**:
|
||||
- **stderr**:文本摘要 + Top 30 表格(可重定向到文件查看)
|
||||
- **stdout**:完整 JSON 结果,包含 `summary` 和 `top_bottlenecks`(含每条的建议 `suggestions`)
|
||||
|
||||
### 第 3 步:结果解读与分类
|
||||
|
||||
按瓶颈严重程度和根因分类:
|
||||
|
||||
| 类别 | 判定条件 | 优先处理方案 |
|
||||
|------|----------|--------------|
|
||||
| 🔴 极危 | 流速>3.0m/s 且 水损>P90 | 检查模型/扩容/分流 |
|
||||
| 🔵 串联瓶颈 | 连续多根瓶颈管段共享节点 | 统一规划扩径,一次性解除 |
|
||||
| 🟣 低压区 | 端节点压力<20m | 扩径 + 评估中途加压 |
|
||||
| 🟡 高粗糙度 | roughness>130 的瓶颈管段 | 内衬修复降阻 |
|
||||
| 🟢 短管异常 | length<10m 且 headloss>P95 | 检查模型是否存在局部阻塞 |
|
||||
|
||||
### 第 4 步:可视化(可选)
|
||||
|
||||
1. **地图定位**:将 Top N 瓶颈管段用 `locate_features` 高亮到地图
|
||||
2. **统计图表**:用 `show_chart` 展示管径分布柱状图 + 流速等级柱状图
|
||||
3. **样式渲染**:可对 pipes 图层按 velocity 或 headloss 属性做分层设色
|
||||
|
||||
## 改造建议生成逻辑
|
||||
|
||||
脚本自动为每条瓶颈管段生成建议,规则如下:
|
||||
|
||||
```
|
||||
if velocity > 2.0 → "流速X.XXm/s过高,需扩容或分流"
|
||||
elif velocity > 1.5 → "流速X.XXm/s偏高"
|
||||
|
||||
if headloss > P95 → "水头损失X.XXm(>P95)严重超标"
|
||||
elif headloss > P90 → "水头损失X.XXm(>P90)"
|
||||
|
||||
if diameter < 100 → "管径Xmm偏小,建议扩径至≥150mm"
|
||||
elif diameter < 200 → "管径Xmm,评估扩容至250-300mm"
|
||||
|
||||
if roughness > 130 → "粗糙系数X偏高,建议内衬修复"
|
||||
|
||||
if min_pressure < 20 → "端节点压力X.Xm(<20m),低压区域需增压"
|
||||
elif min_pressure < 25 → "端节点压力X.Xm偏低"
|
||||
|
||||
if length < 0.01 and headloss > 0.5 → "短管高水损,检查是否存在模型异常或局部阻塞"
|
||||
```
|
||||
|
||||
## 参考数据规模(实测)
|
||||
|
||||
基于 91,000 管段 / 88,000 节点规模的管网模型:
|
||||
|
||||
| 指标 | 实测值 |
|
||||
|------|--------|
|
||||
| 管道属性数据量 | 91,052 条 / ~11.7MB |
|
||||
| 实时管道数据量 | 182,108 条(2步)/ ~38.7MB |
|
||||
| 实时节点数据量 | 175,814 条(2步)/ ~28.2MB |
|
||||
| 分析脚本处理时间 | 约 10-20 秒 |
|
||||
| 典型瓶颈数量 | 50-100 条(占总管数 0.05%-0.1%) |
|
||||
|
||||
## 已知限制
|
||||
|
||||
- 水头损失百分位阈值(P80/P90)基于**全管网**统计,如果管网上游存在极端水损(如 400m+),会拉高整体 P 值,导致部分中高水损管段被漏判。极端场景下可考虑对水损做分位数裁剪(如排除 >P99.9 的离群值)后再计算 P80/P90。
|
||||
- **setting 字段不可用**:`data timeseries realtime links` 返回的 `setting` 值为无效数据,本工作流已移除所有基于 setting 的阀门节流 / 水泵出口判定。若需要此类判定,应通过 `network get-link-properties` 逐条获取属性中的 setting 作为替代。
|
||||
- 脚本读取全量 JSON 入内存,峰值内存约 200-300MB,需确保执行环境有足够内存。
|
||||
@@ -0,0 +1,80 @@
|
||||
# 数据源与字段映射
|
||||
|
||||
## CLI 命令清单
|
||||
|
||||
### ① 管道静态属性
|
||||
|
||||
```bash
|
||||
tjwater-cli network get-all-pipes-properties
|
||||
```
|
||||
|
||||
返回字段:
|
||||
|
||||
| 字段 | 类型 | 说明 | 分析用途 |
|
||||
|------|------|------|----------|
|
||||
| id | string | 管段 ID | 主键,关联水力数据 |
|
||||
| node1 | string | 起始节点 ID | 拓扑,定位压力 |
|
||||
| node2 | string | 终止节点 ID | 拓扑,定位压力 |
|
||||
| length | float | 管长 (m) | 短管高水损检测 |
|
||||
| diameter | int | 管径 (mm) | 管径分级,扩容建议 |
|
||||
| roughness | int | 粗糙系数 | 内衬修复判定 |
|
||||
| minor_loss | float | 局部水头损失系数 | 暂未使用 |
|
||||
| status | string | OPEN/CLOSED | 管道状态 |
|
||||
|
||||
### ② 管道实时水力
|
||||
|
||||
```bash
|
||||
tjwater-cli data timeseries realtime links --start-time <T> --end-time <T+15min>
|
||||
```
|
||||
|
||||
返回字段:
|
||||
|
||||
| 字段 | 类型 | 说明 | 分析用途 |
|
||||
|------|------|------|----------|
|
||||
| time | string | 时间戳 ISO8601 | 筛选目标时刻 |
|
||||
| id | string | 管段 ID | 关联静态属性 |
|
||||
| flow | float | 流量 | 辅助参考 |
|
||||
| velocity | float | 流速 (m/s) | **核心评分指标** |
|
||||
| headloss | float | 水头损失 (m) | **核心评分指标** |
|
||||
| setting | float | ⚠️ 无效值 | 时序 API 返回的 setting 为无效值,不可用于判定 |
|
||||
| friction | float | 摩擦系数 | 暂未使用 |
|
||||
| quality | float | 水质 | 暂未使用 |
|
||||
| reaction | float | 反应速率 | 暂未使用 |
|
||||
| status | string | OPEN/CLOSED | 管道状态 |
|
||||
|
||||
### ③ 节点实时压力
|
||||
|
||||
```bash
|
||||
tjwater-cli data timeseries realtime nodes --start-time <T> --end-time <T+15min>
|
||||
```
|
||||
|
||||
返回字段:
|
||||
|
||||
| 字段 | 类型 | 说明 | 分析用途 |
|
||||
|------|------|------|----------|
|
||||
| time | string | 时间戳 ISO8601 | 筛选目标时刻 |
|
||||
| id | string | 节点 ID | 关联管段端点 |
|
||||
| pressure | float | 压力 (m) | **低压判定** |
|
||||
| total_head | float | 总水头 (m) | 含高程信息 |
|
||||
| actual_demand | float | 实际需水量 | 暂未使用 |
|
||||
| quality | float | 水质 | 暂未使用 |
|
||||
|
||||
## 数据合并逻辑
|
||||
|
||||
```
|
||||
管道属性 (pipe_map[id]) ←─id─→ 实时水力 (rt[time==TT])
|
||||
│
|
||||
node1, node2
|
||||
│
|
||||
↓
|
||||
节点压力 (node_pressure[id])
|
||||
```
|
||||
|
||||
合并时以**实时水力数据为主表**,左联管道属性,再通过 node1/node2 查找两端压力。
|
||||
|
||||
## 时间处理
|
||||
|
||||
- 模拟步长:15 分钟
|
||||
- 查询窗口建议:T 到 T+15min(覆盖 1-2 步)
|
||||
- 脚本内精确筛选:`r.get('time') == TT` 严格匹配字符串
|
||||
- 若目标时刻(如 08:00)无数据,需先触发 `simulation run --start-time T --duration 15`
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
水力瓶颈管道综合分析
|
||||
数据源:管道属性 + 实时水力 + 节点压力 → 复合评分 → 改造建议
|
||||
注:realtime links 的 setting 字段为无效值,已移除所有基于 setting 的判定。
|
||||
"""
|
||||
import json, sys, math, argparse
|
||||
from collections import defaultdict
|
||||
|
||||
VELOCITY_THRESHOLDS = {"critical": 3.0, "severe": 2.0, "high": 1.5}
|
||||
|
||||
def pct(d, v):
|
||||
if not d: return 0
|
||||
k = (v/100)*(len(d)-1); f=math.floor(k); c=math.ceil(k)
|
||||
return d[f] if f==c else d[f]*(c-k)+d[c]*(k-f)
|
||||
|
||||
def load_json(path):
|
||||
with open(path, encoding='utf-8') as f: raw = f.read()
|
||||
return json.loads(raw[raw.find('{'):])
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('--pipe-props', required=True)
|
||||
ap.add_argument('--realtime', required=True)
|
||||
ap.add_argument('--node-pressures', required=True)
|
||||
ap.add_argument('--target-time', required=True)
|
||||
ap.add_argument('--top', type=int, default=100)
|
||||
args = ap.parse_args()
|
||||
TT = args.target_time
|
||||
|
||||
# 1. Load
|
||||
print("[1/5] Loading data...", file=sys.stderr)
|
||||
props = load_json(args.pipe_props).get('data', [])
|
||||
pipe_map = {p['id']: p for p in props}
|
||||
|
||||
rt = load_json(args.realtime).get('data', [])
|
||||
rt = [r for r in rt if r.get('time') == TT]
|
||||
|
||||
np = load_json(args.node_pressures).get('data', [])
|
||||
np = [n for n in np if n.get('time') == TT]
|
||||
node_pressure = {n['id']: n.get('pressure', n.get('value',0)) for n in np}
|
||||
print(f" Pipes: {len(props)}, Realtime: {len(rt)}, Node pressures: {len(node_pressure)}", file=sys.stderr)
|
||||
|
||||
# 2. Merge
|
||||
print("[2/5] Merging...", file=sys.stderr)
|
||||
merged = []
|
||||
for r in rt:
|
||||
pid = r['id']; prop = pipe_map.get(pid)
|
||||
if prop:
|
||||
merged.append({**prop, **r, '_prop_id': prop['id'], '_rt_id': r['id']})
|
||||
print(f" Merged: {len(merged)}", file=sys.stderr)
|
||||
|
||||
# 3. Score
|
||||
print("[3/5] Scoring...", file=sys.stderr)
|
||||
hl_vals = sorted([abs(m['headloss']) for m in merged])
|
||||
p80 = pct(hl_vals, 80); p90 = pct(hl_vals, 90); p95 = pct(hl_vals, 95)
|
||||
|
||||
scored = []
|
||||
for m in merged:
|
||||
vel = abs(m['velocity']); hl = abs(m['headloss'])
|
||||
diam = m.get('diameter', 0)
|
||||
length = m.get('length', 0); roughness = m.get('roughness', 0)
|
||||
n1, n2 = m['node1'], m['node2']
|
||||
pid = m['id']
|
||||
|
||||
vs = 3 if vel>=3 else (2 if vel>=2 else (1 if vel>=1.5 else 0))
|
||||
vg = "极危" if vs==3 else ("严重" if vs==2 else ("偏高" if vs==1 else "正常"))
|
||||
hs = 2 if hl>p90 else (1 if hl>p80 else 0)
|
||||
hg = "严重" if hs==2 else ("中度" if hs==1 else "正常")
|
||||
composite = vs + hs
|
||||
is_bn = (vs>=1 and hs>=1) or (vs>=2)
|
||||
|
||||
# Node pressures
|
||||
p1 = node_pressure.get(n1); p2 = node_pressure.get(n2)
|
||||
min_p = min(p1, p2) if (p1 is not None and p2 is not None) else None
|
||||
|
||||
# Pipe category
|
||||
if diam <= 50: dcat = "微型(≤50mm)"
|
||||
elif diam <= 100: dcat = "小型(51-100mm)"
|
||||
elif diam <= 200: dcat = "中型(101-200mm)"
|
||||
elif diam <= 400: dcat = "大型(201-400mm)"
|
||||
elif diam <= 800: dcat = "主干(401-800mm)"
|
||||
else: dcat = "干管(>800mm)"
|
||||
|
||||
scored.append({
|
||||
'id': pid, 'node1': n1, 'node2': n2,
|
||||
'velocity': round(vel, 4), 'flow': round(m.get('flow',0), 4),
|
||||
'headloss': round(hl, 4), 'length': round(length, 4),
|
||||
'diameter': int(diam), 'roughness': int(roughness),
|
||||
'vel_grade': vg, 'hl_grade': hg, 'composite_score': composite,
|
||||
'is_bottleneck': is_bn,
|
||||
'n1_pressure': round(p1, 2) if p1 is not None else None,
|
||||
'n2_pressure': round(p2, 2) if p2 is not None else None,
|
||||
'min_pressure': round(min_p, 2) if min_p is not None else None,
|
||||
'diam_cat': dcat,
|
||||
})
|
||||
|
||||
# 4. Filter & sort
|
||||
print("[4/5] Filtering bottlenecks...", file=sys.stderr)
|
||||
bn = [s for s in scored if s['is_bottleneck']]
|
||||
bn.sort(key=lambda x: (-x['composite_score'], -x['velocity']))
|
||||
|
||||
critical = [b for b in bn if b['vel_grade']=='极危']
|
||||
severe = [b for b in bn if b['vel_grade']=='严重']
|
||||
high_vel = [b for b in bn if b['vel_grade']=='偏高']
|
||||
|
||||
low_p = sum(1 for b in bn if b['min_pressure'] and b['min_pressure'] < 20)
|
||||
lowish_p = sum(1 for b in bn if b['min_pressure'] and 20 <= b['min_pressure'] < 25)
|
||||
high_rough = sum(1 for b in bn if b['roughness'] > 130)
|
||||
|
||||
# Diam distribution
|
||||
dd = defaultdict(int)
|
||||
for b in bn:
|
||||
d = b['diameter']
|
||||
if d <= 50: dd['≤50mm']+=1
|
||||
elif d <= 100: dd['51-100mm']+=1
|
||||
elif d <= 200: dd['101-200mm']+=1
|
||||
elif d <= 400: dd['201-400mm']+=1
|
||||
elif d <= 800: dd['401-800mm']+=1
|
||||
else: dd['>800mm']+=1
|
||||
|
||||
# 5. Output
|
||||
print("[5/5] Generating report...", file=sys.stderr)
|
||||
|
||||
# Print text summary to stderr
|
||||
print(f"\n{'='*70}", file=sys.stderr)
|
||||
print(f" 水力瓶颈分析报告 - {TT}", file=sys.stderr)
|
||||
print(f"{'='*70}", file=sys.stderr)
|
||||
print(f" 总管道数: {len(scored)}", file=sys.stderr)
|
||||
print(f" 瓶颈管道: {len(bn)} ({len(bn)/len(scored)*100:.1f}%)", file=sys.stderr)
|
||||
print(f" 极危(>3.0m/s): {len(critical)} 条", file=sys.stderr)
|
||||
print(f" 严重(2.0-3.0): {len(severe)} 条", file=sys.stderr)
|
||||
print(f" 偏高(1.5-2.0): {len(high_vel)} 条", file=sys.stderr)
|
||||
print(f"\n 水头损失阈值: P80={p80:.4f}m P90={p90:.4f}m P95={p95:.4f}m", file=sys.stderr)
|
||||
print(f" 平均={sum(hl_vals)/len(hl_vals):.4f}m 最大={max(hl_vals):.4f}m", file=sys.stderr)
|
||||
print(f"\n 低压节点(<20m): {low_p} 条, 偏低(20-25m): {lowish_p} 条", file=sys.stderr)
|
||||
print(f" 高粗糙度(>130): {high_rough} 条", file=sys.stderr)
|
||||
|
||||
print(f"\n 瓶颈管径分布:", file=sys.stderr)
|
||||
for cat in ['≤50mm','51-100mm','101-200mm','201-400mm','401-800mm','>800mm']:
|
||||
print(f" {cat}: {dd.get(cat,0)} 条", file=sys.stderr)
|
||||
|
||||
# Text table (Top 30) to stderr
|
||||
print(f"\n{'='*120}", file=sys.stderr)
|
||||
print(f"{'排名':<5} {'管道ID':<10} {'流速(m/s)':<10} {'水损(m)':<10} {'管径(mm)':<9} {'管长(km)':<10} {'评分':<4} {'压力1':<8} {'压力2':<8} {'管径类别':<18}", file=sys.stderr)
|
||||
print('-'*120, file=sys.stderr)
|
||||
for i, b in enumerate(bn[:30]):
|
||||
p1s = f"{b['n1_pressure']:.1f}" if b['n1_pressure'] is not None else "-"
|
||||
p2s = f"{b['n2_pressure']:.1f}" if b['n2_pressure'] is not None else "-"
|
||||
print(f"{i+1:<5} {b['id']:<10} {b['velocity']:<10.4f} {b['headloss']:<10.2f} {b['diameter']:<9} {b['length']:<10.4f} {b['composite_score']:<4} {p1s:<8} {p2s:<8} {b['diam_cat']:<18}", file=sys.stderr)
|
||||
|
||||
# Generate suggestions for each bottleneck
|
||||
for b in bn:
|
||||
sug = []
|
||||
if b['velocity'] > 2.0:
|
||||
sug.append(f"流速{b['velocity']:.2f}m/s过高,需扩容或分流")
|
||||
elif b['velocity'] > 1.5:
|
||||
sug.append(f"流速{b['velocity']:.2f}m/s偏高")
|
||||
hl = b['headloss']
|
||||
if hl > p95:
|
||||
sug.append(f"水头损失{hl:.2f}m(>P95)严重超标")
|
||||
elif hl > p90:
|
||||
sug.append(f"水头损失{hl:.2f}m(>P90)")
|
||||
if b['diameter'] < 100:
|
||||
sug.append(f"管径{b['diameter']}mm偏小,建议扩径至≥150mm")
|
||||
elif b['diameter'] < 200:
|
||||
sug.append(f"管径{b['diameter']}mm,评估扩容至250-300mm")
|
||||
if b['roughness'] > 130:
|
||||
sug.append(f"粗糙系数{b['roughness']}偏高,建议内衬修复")
|
||||
if b['min_pressure'] is not None and b['min_pressure'] < 20:
|
||||
sug.append(f"端节点压力{b['min_pressure']:.1f}m(<20m),低压区域需增压")
|
||||
elif b['min_pressure'] is not None and b['min_pressure'] < 25:
|
||||
sug.append(f"端节点压力{b['min_pressure']:.1f}m偏低")
|
||||
if b['length'] < 0.01 and b['headloss'] > 0.5:
|
||||
sug.append("短管高水损,检查是否存在模型异常或局部阻塞")
|
||||
b['suggestions'] = sug
|
||||
|
||||
result = {
|
||||
'target_time': TT,
|
||||
'summary': {
|
||||
'total_pipes': len(scored),
|
||||
'bottleneck_count': len(bn),
|
||||
'critical': len(critical), 'severe': len(severe), 'high_vel': len(high_vel),
|
||||
'low_pressure_nodes': low_p, 'lowish_pressure_nodes': lowish_p,
|
||||
'high_roughness_pipes': high_rough,
|
||||
'headloss_p80': round(p80,4), 'headloss_p90': round(p90,4),
|
||||
'headloss_p95': round(p95,4),
|
||||
'diameter_distribution': dict(dd),
|
||||
},
|
||||
'top_bottlenecks': bn[:args.top],
|
||||
}
|
||||
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,146 @@
|
||||
---
|
||||
name: service-area-analysis
|
||||
description: 基于实时水力模拟数据的水源追溯供水服务范围分区。通过管段流量确定水流方向,从水库BFS追溯服务节点,环网/零流量节点用无向拓扑补充分配,输出分区可视化。
|
||||
---
|
||||
|
||||
# 供水服务范围分区工作流
|
||||
|
||||
## 概述
|
||||
|
||||
本工作流基于指定时刻的水力模拟结果,通过**流向追溯法**将全部管网节点分配到各水库的服务范围。核心思路:利用管段流量符号判定水流方向,构建有向图从水库逐级追溯,对环网和零流量节点用无向拓扑修正。
|
||||
|
||||
适用场景:供水服务范围评估、DMA分区规划、多水源供水格局分析、管网调度策略评估。
|
||||
|
||||
## 分区方法
|
||||
|
||||
### 第一步:水流方向判定
|
||||
|
||||
对于每条管段,根据实时流量 `flow` 判定水流方向:
|
||||
|
||||
| flow 值 | 水流方向 | 说明 |
|
||||
|---------|----------|------|
|
||||
| `flow > 1e-6` | node1 → node2 | 正向流量 |
|
||||
| `flow < -1e-6` | node2 → node1 | 反向流量 |
|
||||
| `|flow| ≤ 1e-6` | 无方向 | 零流量,不参与有向追溯 |
|
||||
|
||||
### 第二步:多源有向BFS
|
||||
|
||||
1. 以每个水库为根节点,沿水流方向执行 BFS
|
||||
2. 遍历到的节点归属该水库的服务范围
|
||||
3. **先到先得**:一个节点首次被访问到的水库即为归属
|
||||
4. 预期覆盖 **85–90%** 节点
|
||||
|
||||
### 第三步:无向拓扑修正
|
||||
|
||||
有向BFS不可达节点(通常 10–15%)通过无向图邻近性补充分配:
|
||||
|
||||
| 不可达原因 | 说明 |
|
||||
|------------|------|
|
||||
| 环状管网 | 水流回路中下游节点反向连回上游,有向遍历被阻断 |
|
||||
| 零流量管段 | `flow≈0` 的管段无方向,其下游节点断开 |
|
||||
| 多水源交汇 | 交汇区流向往复,非树状拓扑 |
|
||||
|
||||
### 输出统计
|
||||
|
||||
每个分区输出:
|
||||
- `node_count`:分区内节点总数
|
||||
- `total_demand`:总需水量(负数=净供水区)
|
||||
- `avg_pressure`/`min_pressure`/`max_pressure`:压力统计
|
||||
|
||||
## 数据依赖
|
||||
|
||||
| 步骤 | 命令 | 数据量 | 超时 | 关键字段 |
|
||||
|------|------|--------|------|----------|
|
||||
| ① 管道拓扑 | `network get-all-pipes-properties` | ~11.7MB / 91K条 | 120s | id, node1, node2 |
|
||||
| ② 水库属性 | `network get-all-reservoirs-properties` | ~小 | 120s | id, links |
|
||||
| ③ 管段流量 | `data timeseries realtime links --start-time T --end-time T+15min` | ~39MB / 182K条 | 300s | id, flow, time |
|
||||
| ④ 节点数据 | `data timeseries realtime nodes --start-time T --end-time T+15min` | ~28MB / 176K条 | 300s | id, pressure, actual_demand, time |
|
||||
|
||||
> **时间窗口**:模拟步长 15 分钟,查询 T~T+15min 覆盖 1–2 个时间步。脚本按 `--target-time` 精确筛选。
|
||||
|
||||
> **文件输入**:四份调用都使用 `store_result=true`,包括结果较小的水库属性。脚本读取每次返回的 `data_file.file_path`;文件都属于当前对话,禁止使用 `/tmp` 或全局 `tool-output/`。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
### 第 1 步:并行拉取数据
|
||||
|
||||
4 个 `tjwater_cli` 调用(互不依赖),可一次发起:
|
||||
|
||||
```bash
|
||||
# ① 管道静态拓扑
|
||||
tjwater_cli(command="network get-all-pipes-properties", timeout=120, store_result=true)
|
||||
|
||||
# ② 水库属性
|
||||
tjwater_cli(command="network get-all-reservoirs-properties", timeout=120, store_result=true)
|
||||
|
||||
# ③ 目标时刻管段流量
|
||||
tjwater_cli(command="data timeseries realtime links --start-time 2026-04-01T08:00:00+08:00 --end-time 2026-04-01T08:15:00+08:00", timeout=300, store_result=true)
|
||||
|
||||
# ④ 目标时刻节点数据
|
||||
tjwater_cli(command="data timeseries realtime nodes --start-time 2026-04-01T08:00:00+08:00 --end-time 2026-04-01T08:15:00+08:00", timeout=300, store_result=true)
|
||||
```
|
||||
|
||||
### 第 2 步:运行分区脚本
|
||||
|
||||
```bash
|
||||
python3 <skill_dir>/scripts/service_area_partition.py \
|
||||
--pipe-props <data_file.file_path-①> \
|
||||
--reservoirs <data_file.file_path-②> \
|
||||
--links <data_file.file_path-③> \
|
||||
--nodes <data_file.file_path-④> \
|
||||
--target-time '2026-04-01T08:00:00+08:00' \
|
||||
--output ./service_area_partition_wrapper.json
|
||||
```
|
||||
|
||||
**脚本参数**:
|
||||
|
||||
| 参数 | 说明 | 必填 |
|
||||
|------|------|------|
|
||||
| `--pipe-props` | 管道属性 JSON 文件路径 | 是 |
|
||||
| `--reservoirs` | 水库属性 JSON 文件路径 | 是 |
|
||||
| `--links` | 实时管段数据 JSON 文件路径 | 是 |
|
||||
| `--nodes` | 实时节点数据 JSON 文件路径 | 是 |
|
||||
| `--target-time` | 目标时刻 ISO8601 | 是 |
|
||||
| `--output` | 分区结果输出路径 | 是 |
|
||||
|
||||
**输出**:
|
||||
- **stderr**:处理日志 + 各分区统计表格
|
||||
- **stdout**:紧凑 JSON 摘要(total_nodes, reservoirs, areas)
|
||||
- **文件**:符合 `store_render_ref` 要求的 `{metadata, location, data}` 包装 JSON,其中 `data` 包含 `node_area_map`、`area_ids`、`area_colors` 和分析元数据
|
||||
|
||||
### 第 3 步:前端可视化
|
||||
|
||||
```bash
|
||||
# 持久化分区结果
|
||||
store_render_ref(file_path=<output-file>)
|
||||
|
||||
# 渲染节点分区
|
||||
render_junctions(render_ref="res-xxxxxxxx-xxxx-xx")
|
||||
|
||||
# 定位水库
|
||||
locate_features(ids=[...], feature_type="reservoir")
|
||||
|
||||
# 展示统计图表
|
||||
show_chart(title="各水源分区节点数/压力对比", chart_type="bar", ...)
|
||||
```
|
||||
|
||||
## 参考数据规模
|
||||
|
||||
基于 91,000 管段 / 88,000 节点规模的管网模型:
|
||||
|
||||
| 指标 | 实测值 |
|
||||
|------|--------|
|
||||
| 管道拓扑数据量 | 91,052 条 |
|
||||
| 水库数量 | 13 个 |
|
||||
| 总节点数 | 87,907 |
|
||||
| 有向BFS分配节点 | ~76,900 (87.5%) |
|
||||
| 无向修正节点 | ~11,000 (12.5%) |
|
||||
| 分区覆盖率 | 100% |
|
||||
| 脚本处理时间 | ~15-30 秒 |
|
||||
| 峰值内存 | ~400-500MB |
|
||||
|
||||
## 已知限制
|
||||
|
||||
- **水库顺序敏感**:多源 BFS 中先遍历到的水库优先分配,不同水库启动顺序可能影响边界区域分配结果
|
||||
- **单时刻快照**:分区仅反映目标时刻的水力工况,不同时段的泵站启停、阀门切换可能导致分区边界变化
|
||||
- **零流量阈值**:`1e-6` 阈值过滤极低流量管段,若管网有长期小流量管段可能漏判方向
|
||||
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
供水服务范围分析与分区 — 可复用脚本
|
||||
基于实时水力数据,从水库沿水流方向追溯服务范围,自动发现水库并分区。
|
||||
|
||||
用法:
|
||||
python3 service_area_partition.py \
|
||||
--pipe-props pipes.json \
|
||||
--reservoirs reservoirs.json \
|
||||
--links realtime_links.json \
|
||||
--nodes realtime_nodes.json \
|
||||
--target-time '2026-04-01T08:00:00+08:00' \
|
||||
--output ./service_area_partition_wrapper.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections import deque, defaultdict
|
||||
|
||||
COLORS = [
|
||||
"rgba(31,119,180,0.7)", "rgba(255,127,14,0.7)", "rgba(44,160,44,0.7)",
|
||||
"rgba(148,103,189,0.7)", "rgba(140,86,75,0.7)", "rgba(227,119,194,0.7)",
|
||||
"rgba(127,127,127,0.7)", "rgba(188,189,34,0.7)", "rgba(23,190,207,0.7)",
|
||||
"rgba(174,199,232,0.7)", "rgba(255,152,150,0.7)", "rgba(196,156,148,0.7)",
|
||||
"rgba(219,64,82,0.7)", "rgba(153,204,153,0.7)", "rgba(255,204,102,0.7)",
|
||||
"rgba(102,102,204,0.7)", "rgba(204,102,102,0.7)", "rgba(102,204,204,0.7)",
|
||||
"rgba(204,153,204,0.7)", "rgba(153,153,153,0.7)"
|
||||
]
|
||||
|
||||
def load_json(path, label):
|
||||
print(f"Loading {label}...", file=sys.stderr)
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="供水服务范围分区分析")
|
||||
parser.add_argument("--pipe-props", required=True, help="管道属性 JSON 文件")
|
||||
parser.add_argument("--reservoirs", required=True, help="水库属性 JSON 文件")
|
||||
parser.add_argument("--links", required=True, help="实时管段数据 JSON 文件")
|
||||
parser.add_argument("--nodes", required=True, help="实时节点数据 JSON 文件")
|
||||
parser.add_argument("--target-time", required=True, help="目标时刻 ISO8601")
|
||||
parser.add_argument("--output", required=True, help="分区结果输出 JSON 路径")
|
||||
args = parser.parse_args()
|
||||
|
||||
# --- Step 1: Load pipe topology ---
|
||||
pdata = load_json(args.pipe_props, "pipe topology")["data"]
|
||||
pipe_topology = {}
|
||||
node_neighbors = defaultdict(set)
|
||||
for p in pdata:
|
||||
pid = p["id"]
|
||||
n1, n2 = p["node1"], p["node2"]
|
||||
pipe_topology[pid] = (n1, n2)
|
||||
node_neighbors[n1].add(n2)
|
||||
node_neighbors[n2].add(n1)
|
||||
print(f" {len(pdata)} pipes, {len(node_neighbors)} unique nodes", file=sys.stderr)
|
||||
|
||||
# --- Step 2: Discover reservoirs ---
|
||||
rdata = load_json(args.reservoirs, "reservoirs")["data"]
|
||||
reservoirs = [r["id"] for r in rdata]
|
||||
print(f" {len(reservoirs)} reservoirs: {reservoirs}", file=sys.stderr)
|
||||
|
||||
# --- Step 3: Load link flow at target time ---
|
||||
ldata = load_json(args.links, "link flows")["data"]
|
||||
target_links = [l for l in ldata if l["time"] == args.target_time]
|
||||
flow_direction = {}
|
||||
pipe_flow = {}
|
||||
for l in target_links:
|
||||
lid = l["id"]
|
||||
flow_val = l["flow"]
|
||||
pipe_flow[lid] = abs(flow_val)
|
||||
if lid in pipe_topology:
|
||||
n1, n2 = pipe_topology[lid]
|
||||
if flow_val > 1e-6:
|
||||
flow_direction[lid] = (n1, n2)
|
||||
elif flow_val < -1e-6:
|
||||
flow_direction[lid] = (n2, n1)
|
||||
nonzero = len(flow_direction)
|
||||
print(f" {len(target_links)} link records, {nonzero} with non-zero flow", file=sys.stderr)
|
||||
|
||||
# --- Step 4: Load node data at target time ---
|
||||
ndata = load_json(args.nodes, "node data")["data"]
|
||||
target_nodes = [n for n in ndata if n["time"] == args.target_time]
|
||||
node_pressure = {}
|
||||
node_demand = {}
|
||||
for n in target_nodes:
|
||||
nid = n["id"]
|
||||
node_pressure[nid] = n.get("pressure", 0)
|
||||
node_demand[nid] = n.get("actual_demand", 0)
|
||||
print(f" {len(target_nodes)} nodes", file=sys.stderr)
|
||||
|
||||
# --- Step 5: Build downstream graph ---
|
||||
downstream = defaultdict(set)
|
||||
for _lid, (up, dn) in flow_direction.items():
|
||||
downstream[up].add(dn)
|
||||
print(f" downstream graph: {len(downstream)} source nodes", file=sys.stderr)
|
||||
|
||||
# --- Step 6: Multi-source BFS along flow direction ---
|
||||
reservoir_area = {}
|
||||
node_served_by = {}
|
||||
queue = deque()
|
||||
for rid in reservoirs:
|
||||
reservoir_area[rid] = {rid}
|
||||
node_served_by[rid] = rid
|
||||
queue.append((rid, rid, 0))
|
||||
|
||||
while queue:
|
||||
node, source, dist = queue.popleft()
|
||||
for neighbor in downstream.get(node, set()):
|
||||
if neighbor not in node_served_by:
|
||||
node_served_by[neighbor] = source
|
||||
reservoir_area[source].add(neighbor)
|
||||
queue.append((neighbor, source, dist + 1))
|
||||
|
||||
directed_count = len(node_served_by)
|
||||
unassigned = set(node_pressure.keys()) - set(node_served_by.keys())
|
||||
print(f" flow-tracing assigned: {directed_count}, unassigned: {len(unassigned)}", file=sys.stderr)
|
||||
|
||||
# --- Step 7: Undirected proximity fallback ---
|
||||
if unassigned:
|
||||
print(" running proximity fallback...", file=sys.stderr)
|
||||
ua_queue = deque()
|
||||
ua_visited = {}
|
||||
for nid, src in node_served_by.items():
|
||||
ua_visited[nid] = src
|
||||
ua_queue.append((nid, src, 0))
|
||||
|
||||
while ua_queue:
|
||||
node, source, dist = ua_queue.popleft()
|
||||
for neighbor in node_neighbors.get(node, set()):
|
||||
if neighbor not in ua_visited:
|
||||
ua_visited[neighbor] = source
|
||||
reservoir_area[source].add(neighbor)
|
||||
ua_queue.append((neighbor, source, dist + 1))
|
||||
|
||||
still = set(node_pressure.keys()) - set(ua_visited.keys())
|
||||
if still:
|
||||
print(f" WARNING: {len(still)} nodes still unassigned", file=sys.stderr)
|
||||
node_served_by = ua_visited
|
||||
|
||||
# --- Step 8: Compute statistics ---
|
||||
print(f"\n=== 供水服务范围分区统计 ===\n", file=sys.stderr)
|
||||
area_stats = []
|
||||
for rid in reservoirs:
|
||||
nodes_in = reservoir_area.get(rid, set())
|
||||
pressures = [node_pressure[n] for n in nodes_in if n in node_pressure]
|
||||
demands = [node_demand[n] for n in nodes_in if n in node_demand]
|
||||
area_stats.append({
|
||||
"reservoir": rid,
|
||||
"node_count": len(nodes_in),
|
||||
"total_demand": round(sum(demands), 4),
|
||||
"avg_pressure": round(sum(pressures)/len(pressures), 2) if pressures else 0,
|
||||
"min_pressure": round(min(pressures), 2) if pressures else 0,
|
||||
"max_pressure": round(max(pressures), 2) if pressures else 0,
|
||||
})
|
||||
|
||||
area_stats.sort(key=lambda x: x["node_count"], reverse=True)
|
||||
for s in area_stats:
|
||||
print(f" 水源 {s['reservoir']:>8s}: {s['node_count']:>6d} 节点 | "
|
||||
f"总需水={s['total_demand']:.2f} | "
|
||||
f"压力 avg={s['avg_pressure']:.1f}m [{s['min_pressure']:.1f}–{s['max_pressure']:.1f}m]",
|
||||
file=sys.stderr)
|
||||
|
||||
# --- Step 9: Assign colors and write output ---
|
||||
area_colors = {}
|
||||
for i, rid in enumerate(reservoirs):
|
||||
area_colors[rid] = COLORS[i % len(COLORS)]
|
||||
|
||||
output = {
|
||||
"node_area_map": node_served_by,
|
||||
"area_ids": reservoirs,
|
||||
"area_colors": area_colors,
|
||||
"metadata": {
|
||||
"analysis_time": args.target_time,
|
||||
"total_nodes": len(node_served_by),
|
||||
"reservoir_count": len(reservoirs),
|
||||
"directed_assigned": directed_count,
|
||||
"proximity_assigned": len(node_served_by) - directed_count,
|
||||
"method": "flow-direction-source-tracing"
|
||||
}
|
||||
}
|
||||
|
||||
absolute_output = os.path.abspath(args.output)
|
||||
wrapper = {
|
||||
"metadata": {
|
||||
"generated_by": "service_area_partition.py",
|
||||
"schema_version": 1,
|
||||
},
|
||||
"location": {"file_path": absolute_output},
|
||||
"data": output,
|
||||
}
|
||||
with open(absolute_output, "w", encoding="utf-8") as f:
|
||||
json.dump(wrapper, f, ensure_ascii=False)
|
||||
|
||||
summary = {
|
||||
"total_nodes": len(node_served_by),
|
||||
"reservoirs": len(reservoirs),
|
||||
"areas": area_stats,
|
||||
"output_file": absolute_output
|
||||
}
|
||||
print(json.dumps(summary, ensure_ascii=False))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user