后端服务将通过tjwater-cli形式访问
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
---
|
||||
name: tjwater-workflow-source-service-area-analysis
|
||||
description: 水源服务范围分区分析工作流。
|
||||
version: 2.0.0
|
||||
---
|
||||
|
||||
# Source Service Area Analysis Workflow Skill
|
||||
|
||||
## 简介
|
||||
基于水力模拟结果的流向数据构建有向图,通过下游遍历确定每个节点的供水水源归属,生成分区渲染数据并在地图上以不同颜色展示各水源的服务范围。
|
||||
|
||||
## 前置依赖
|
||||
|
||||
### 依赖 1:水库(水源)列表
|
||||
从模拟结果的 node_results 中识别 type=reservoir 的节点,或逐条查询已知水库节点。
|
||||
|
||||
### 依赖 2:管道拓扑数据
|
||||
```
|
||||
tjwater_cli:
|
||||
command: "network get-link-properties --link P1"
|
||||
```
|
||||
逐条获取管道 ID 的 node1(起端)、node2(终端),构建管网拓扑。
|
||||
|
||||
### 依赖 3:水力模拟结果
|
||||
```
|
||||
tjwater_cli:
|
||||
command: "data timeseries realtime links --start-time TIME --end-time TIME"
|
||||
timeout: 300
|
||||
```
|
||||
获取各管段的 `flow`(LPS) 和 `status`,确定水流方向。
|
||||
- `flow > 0` → node1 → node2
|
||||
- `flow < 0` → node2 → node1
|
||||
- `flow ≈ 0` 或 `status != "Open"` → 忽略
|
||||
|
||||
---
|
||||
|
||||
## 工作流步骤
|
||||
|
||||
### 第 1 步:获取三大数据源
|
||||
并行调用 CLI 命令获取模拟结果、管道拓扑和水库信息,写入本地临时 JSON 文件。
|
||||
|
||||
### 第 2 步:构建有向图并计算服务范围
|
||||
|
||||
```bash
|
||||
python scripts/compute_service_areas.py \
|
||||
/tmp/opencode/sim_result.json \
|
||||
/tmp/opencode/pipes.json \
|
||||
/tmp/opencode/reservoirs.json \
|
||||
--output /tmp/opencode/service_area_result.json
|
||||
```
|
||||
|
||||
脚本核心逻辑:
|
||||
1. **构建无向拓扑图**:从管道属性提取 `(node1, node2)`,构建邻接表
|
||||
2. **确定流向**:从模拟结果读取 flow 和 status
|
||||
- flow > 0 → node1 → node2
|
||||
- flow < 0 → node2 → node1
|
||||
- flow ≈ 0 或 status != "Open" → 忽略
|
||||
3. **获取水源集合**:从水库列表提取所有水库 ID 作为 source nodes
|
||||
4. **多源 BFS 下游遍历**:从所有水源节点同时出发,首次访问归属策略
|
||||
5. **统计输出**:各水源服务节点数、占比、未分配节点数
|
||||
6. **生成渲染数据**:`node_area_map: { node_id: area_id }`,每个水源分配唯一颜色
|
||||
|
||||
### 第 3 步:结果验证
|
||||
- **服务覆盖率**:正常管网应 > 80%
|
||||
- **水源孤立**:某水源服务节点数为 0,检查出口管道是否 Closed
|
||||
- **大区未分配**:检查区域与水源之间管道是否 Closed
|
||||
|
||||
### 第 4 步:前端可视化
|
||||
1. `store_render_ref` 将 `node_area_map` 迁移为受控 `render_ref`
|
||||
2. `render_junctions` 地图着色
|
||||
3. `locate_features` 高亮水源位置
|
||||
4. `show_chart` 各水源服务节点数柱状图(可选)
|
||||
|
||||
### 第 5 步:报告输出
|
||||
|
||||
```
|
||||
---
|
||||
## 水源服务范围分析报告
|
||||
|
||||
### 一、管网概况
|
||||
- 总节点数 / 总管段数 / 水源数 / 覆盖率
|
||||
|
||||
### 二、各水源服务范围
|
||||
| 水源 ID | 服务节点数 | 占比 | 颜色 |
|
||||
|
||||
### 三、未分配节点分析
|
||||
- 数量、可能原因
|
||||
|
||||
### 四、假设与局限
|
||||
- 流向基于稳态模拟,不代表所有工况
|
||||
- 有向图忽略零流量管段
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 颜色调色板
|
||||
脚本内置 20 色:
|
||||
```
|
||||
#1f77b4, #ff7f0e, #2ca02c, #d62728, #9467bd, #8c564b, #e377c2,
|
||||
#7f7f7f, #bcbd22, #17becf, #aec7e8, #ffbb78, #98df8a, #ff9896,
|
||||
#c5b0d5, #c49c94, #f7b6d2, #c7c7c7, #dbdb8d, #9edae5
|
||||
```
|
||||
|
||||
## 证据约束
|
||||
- 计算脚本的输出是唯一数据真相源
|
||||
- 未完成数据回读时不得执行分析
|
||||
- 零流量管段视为断连,需在报告中说明
|
||||
|
||||
## Learned Patterns
|
||||
- 核心数据流:模拟结果(流向) + 管道属性(拓扑) + 水库列表(起点) → 有向图 BFS → 分区渲染
|
||||
- 有向图遍历前务必过滤 Closed 和零流量管段
|
||||
- 渲染数据必须通过 `store_render_ref` 迁移再传给 `render_junctions`
|
||||
- 多水源 BFS 首次访问归属策略隐含"距离最近水源"语义
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
compute_service_areas.py — 水源服务范围分析核心脚本
|
||||
|
||||
输入:
|
||||
sim_result.json — runprojectreturndict 的水力模拟结果
|
||||
pipes.json — getallpipeproperties 的管道属性列表
|
||||
reservoirs.json — getallreservoirproperties 的水库属性列表
|
||||
|
||||
输出:
|
||||
service_area_result.json — 包含 node_area_map 和统计信息
|
||||
|
||||
算法:
|
||||
1. 从 pipes 构建无向拓扑图 (node1 <-> node2)
|
||||
2. 从 sim link_results 确定流向,生成有向图
|
||||
3. 从 reservoirs 获取所有水源节点
|
||||
4. 多源 BFS 下游遍历,首次访问即归属性
|
||||
5. 生成渲染数据与统计
|
||||
|
||||
用法:
|
||||
python compute_service_areas.py sim_result.json pipes.json reservoirs.json \
|
||||
--output service_area_result.json --network tjwater
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections import deque
|
||||
|
||||
# ── 高区分度 20 色调色板 ──────────────────────────────────────────
|
||||
PALETTE = [
|
||||
"#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd",
|
||||
"#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf",
|
||||
"#aec7e8", "#ffbb78", "#98df8a", "#ff9896", "#c5b0d5",
|
||||
"#c49c94", "#f7b6d2", "#c7c7c7", "#dbdb8d", "#9edae5",
|
||||
]
|
||||
|
||||
|
||||
def load_json(path: str) -> dict | list:
|
||||
"""加载 JSON 文件,支持引用格式(含 result_ref 字段)或列表。"""
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
# 如果顶层是引用包装,展开到内部的列表
|
||||
if isinstance(data, dict) and "data" in data:
|
||||
data = data["data"]
|
||||
if isinstance(data, dict) and "items" in data:
|
||||
data = data["items"]
|
||||
return data
|
||||
|
||||
|
||||
def build_undirected_graph(pipes: list) -> tuple[dict, dict]:
|
||||
"""
|
||||
从管道列表构建无向拓扑图。
|
||||
返回:
|
||||
adj: { node_id: set(neighbor_node_ids) }
|
||||
edge_map: { (node1, node2): pipe_id } 用于查找管道 ID
|
||||
"""
|
||||
adj = {}
|
||||
edge_map = {}
|
||||
for p in pipes:
|
||||
pipe_id = str(p.get("id", p.get("pipe_id", p.get("name", ""))))
|
||||
n1 = str(p.get("node1", ""))
|
||||
n2 = str(p.get("node2", ""))
|
||||
if not n1 or not n2:
|
||||
continue
|
||||
adj.setdefault(n1, set()).add(n2)
|
||||
adj.setdefault(n2, set()).add(n1)
|
||||
edge_map[(n1, n2)] = pipe_id
|
||||
edge_map[(n2, n1)] = pipe_id
|
||||
return adj, edge_map
|
||||
|
||||
|
||||
def build_directed_graph(adj: dict, edge_map: dict, link_results: list) -> dict:
|
||||
"""
|
||||
基于模拟流向构建有向图。
|
||||
link_results 中每条记录含 link_id, flow, status。
|
||||
流向判定:
|
||||
flow > 0 → node1 → node2
|
||||
flow < 0 → node2 → node1
|
||||
flow ≈ 0 或 status != "Open" → 忽略(视为断连)
|
||||
|
||||
注意: link_results 中没有 node1/node2,需要通过 link_id 反查 edge_map。
|
||||
由于 edge_map 只有 (node1,node2) → pipe_id 的映射,
|
||||
这里需要先建立 pipe_id → (node1, node2) 的反向索引。
|
||||
"""
|
||||
# 建立 pipe_id → (node1, node2) 反向索引
|
||||
# 注意:edge_map 含有 (n1,n2) 和 (n2,n1) 两个方向,只保留第一个以避免反向覆写
|
||||
pipe_to_nodes = {}
|
||||
for (n1, n2), pid in edge_map.items():
|
||||
if pid not in pipe_to_nodes:
|
||||
pipe_to_nodes[pid] = (n1, n2)
|
||||
|
||||
digraph = {}
|
||||
missing = 0
|
||||
closed_or_zero = 0
|
||||
|
||||
for link in link_results:
|
||||
lid = str(link.get("link_id", link.get("id", "")))
|
||||
flow = link.get("flow", 0)
|
||||
status = str(link.get("status", "Open"))
|
||||
|
||||
# 忽略非 Open 管段和零流量管段(大小写不敏感)
|
||||
if status.upper() != "OPEN":
|
||||
closed_or_zero += 1
|
||||
continue
|
||||
if abs(flow) < 1e-6:
|
||||
closed_or_zero += 1
|
||||
continue
|
||||
|
||||
# 查找管段对应的节点对
|
||||
pair = pipe_to_nodes.get(lid)
|
||||
if pair is None:
|
||||
missing += 1
|
||||
continue
|
||||
|
||||
n1, n2 = pair
|
||||
if flow > 0:
|
||||
digraph.setdefault(n1, set()).add(n2)
|
||||
else:
|
||||
digraph.setdefault(n2, set()).add(n1)
|
||||
|
||||
if missing > 0:
|
||||
print(f"[WARN] {missing} 条模拟管段未在管道拓扑中找到对应节点对", file=sys.stderr)
|
||||
if closed_or_zero > 0:
|
||||
print(f"[INFO] 忽略 {closed_or_zero} 条 Closed/零流量管段", file=sys.stderr)
|
||||
|
||||
return digraph
|
||||
|
||||
|
||||
def bfs_from_sources(sources: list, digraph: dict) -> dict:
|
||||
"""
|
||||
多水源 BFS 下游遍历。首次访问的节点归属到对应水源。
|
||||
返回: { node_id: source_id }
|
||||
"""
|
||||
node_area = {}
|
||||
queue = deque()
|
||||
|
||||
# 初始化:所有水源入队,归属自身
|
||||
for src in sources:
|
||||
node_area[src] = src
|
||||
queue.append(src)
|
||||
|
||||
while queue:
|
||||
cur = queue.popleft()
|
||||
cur_source = node_area[cur]
|
||||
for nb in digraph.get(cur, set()):
|
||||
if nb not in node_area:
|
||||
node_area[nb] = cur_source
|
||||
queue.append(nb)
|
||||
|
||||
return node_area
|
||||
|
||||
|
||||
def generate_render_data(node_area: dict, sources: list) -> dict:
|
||||
"""
|
||||
生成 render_junctions 所需的渲染数据。
|
||||
返回:
|
||||
node_area_map: { node_id: area_id }
|
||||
area_ids: 水源 ID 列表
|
||||
area_colors: { area_id: color_hex }
|
||||
"""
|
||||
# 为每个水源分配颜色
|
||||
area_colors = {}
|
||||
for i, src in enumerate(sources):
|
||||
area_colors[src] = PALETTE[i % len(PALETTE)]
|
||||
|
||||
return {
|
||||
"node_area_map": node_area,
|
||||
"area_ids": sources,
|
||||
"area_colors": area_colors,
|
||||
}
|
||||
|
||||
|
||||
def compute_stats(node_area: dict, sources: list, all_nodes: set) -> dict:
|
||||
"""计算各水源服务节点数和覆盖率统计。"""
|
||||
source_counts = {}
|
||||
for src in sources:
|
||||
source_counts[src] = sum(1 for v in node_area.values() if v == src)
|
||||
|
||||
total = len(all_nodes)
|
||||
assigned = len(node_area)
|
||||
unassigned = total - assigned
|
||||
coverage = (assigned / total * 100) if total > 0 else 0
|
||||
|
||||
return {
|
||||
"total_nodes": total,
|
||||
"assigned_nodes": assigned,
|
||||
"unassigned_nodes": unassigned,
|
||||
"coverage_pct": round(coverage, 2),
|
||||
"source_counts": source_counts,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="水源服务范围分析 — 基于水力模拟流向的多源 BFS 分区"
|
||||
)
|
||||
parser.add_argument("sim_file", help="runprojectreturndict 模拟结果 JSON")
|
||||
parser.add_argument("pipes_file", help="getallpipeproperties 管道属性 JSON")
|
||||
parser.add_argument("reservoirs_file", help="getallreservoirproperties 水库属性 JSON")
|
||||
parser.add_argument("--output", "-o", default="/tmp/opencode/service_area_result.json",
|
||||
help="输出 JSON 文件路径")
|
||||
parser.add_argument("--network", "-n", default="tjwater",
|
||||
help="管网名称(仅用于报告标注)")
|
||||
args = parser.parse_args()
|
||||
|
||||
# ── 1. 加载数据 ──
|
||||
print(f"[INFO] 加载模拟结果: {args.sim_file}")
|
||||
sim_data = load_json(args.sim_file)
|
||||
link_results = sim_data.get("link_results", [])
|
||||
|
||||
print(f"[INFO] 加载管道属性: {args.pipes_file}")
|
||||
pipes = load_json(args.pipes_file)
|
||||
|
||||
print(f"[INFO] 加载水库列表: {args.reservoirs_file}")
|
||||
reservoirs = load_json(args.reservoirs_file)
|
||||
|
||||
# 提取水源 ID 列表
|
||||
sources = []
|
||||
if isinstance(reservoirs, list):
|
||||
sources = [str(r.get("id", r.get("name", ""))) for r in reservoirs if r.get("id") or r.get("name")]
|
||||
elif isinstance(reservoirs, dict):
|
||||
# 可能是 { data: [...] } 格式
|
||||
for key in ["data", "items", "reservoirs"]:
|
||||
if key in reservoirs and isinstance(reservoirs[key], list):
|
||||
sources = [str(r.get("id", r.get("name", ""))) for r in reservoirs[key] if r.get("id") or r.get("name")]
|
||||
break
|
||||
if not sources:
|
||||
# 也可能是 { "551656": {...}, ... } 格式
|
||||
sources = [str(k) for k in reservoirs if isinstance(reservoirs[k], dict)]
|
||||
|
||||
if not sources:
|
||||
print("[ERROR] 未能从水库数据中提取水源 ID 列表", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"[INFO] 水源数: {len(sources)}")
|
||||
print(f"[INFO] 水源列表: {sources}")
|
||||
|
||||
# ── 2. 构建无向图 ──
|
||||
adj, edge_map = build_undirected_graph(pipes)
|
||||
all_nodes = set(adj.keys())
|
||||
|
||||
# ── 3. 构建有向图 ──
|
||||
digraph = build_directed_graph(adj, edge_map, link_results)
|
||||
|
||||
# ── 4. BFS 下游遍历 ──
|
||||
print("[INFO] 执行多源 BFS 下游遍历...")
|
||||
node_area = bfs_from_sources(sources, digraph)
|
||||
|
||||
# ── 5. 统计 ──
|
||||
stats = compute_stats(node_area, sources, all_nodes)
|
||||
print(f"[INFO] 总节点数: {stats['total_nodes']}")
|
||||
print(f"[INFO] 已分配: {stats['assigned_nodes']} ({stats['coverage_pct']:.1f}%)")
|
||||
print(f"[INFO] 未分配: {stats['unassigned_nodes']}")
|
||||
|
||||
print("\n各水源服务节点数 (按数量降序):")
|
||||
for src, count in sorted(stats["source_counts"].items(), key=lambda x: -x[1]):
|
||||
pct = (count / stats["assigned_nodes"] * 100) if stats["assigned_nodes"] > 0 else 0
|
||||
print(f" {src}: {count} ({pct:.1f}%)")
|
||||
|
||||
# ── 6. 生成渲染数据 ──
|
||||
render = generate_render_data(node_area, sources)
|
||||
|
||||
# ── 7. 输出 ──
|
||||
output = {
|
||||
"network": args.network,
|
||||
"stats": stats,
|
||||
"render": render,
|
||||
"sources": sources,
|
||||
}
|
||||
|
||||
os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True)
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
json.dump(output, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"\n[OK] 结果已写入: {args.output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user