Compare commits
12
Commits
eac6b78598
...
a7e1ce6ef4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7e1ce6ef4 | ||
|
|
4350612807 | ||
|
|
b0a23a8012 | ||
|
|
70c5b0e445 | ||
|
|
29f691731c | ||
|
|
1432934f12 | ||
|
|
b0e9d480ef | ||
|
|
87d922ea61 | ||
|
|
c126e99b60 | ||
|
|
b6a6527bab | ||
|
|
f010f071eb | ||
|
|
0be31869b5 |
@@ -45,6 +45,7 @@ METADATA_DB_PASSWORD="password"
|
||||
KEYCLOAK_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
|
||||
KEYCLOAK_ALGORITHM=RS256
|
||||
KEYCLOAK_AUDIENCE="account"
|
||||
KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS=900
|
||||
|
||||
|
||||
# ============================================
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
This repository contains the TJWater Python backend. Main application code lives in `app/`: API routes under `app/api`, authentication in `app/auth`, configuration in `app/core`, database and repository code in `app/infra`, domain models/schemas in `app/domain`, and business logic in `app/services` and `app/algorithms`.
|
||||
|
||||
Tests are under `tests/`, split into `tests/unit`, `tests/api`, and `tests/auth`. CLI code lives in `cli/tjwater_cli`, with CLI tests in `cli/tests`. SQL and sample assets are stored in `resources/`; deployment files are in `Dockerfile`, `.gitea/workflows/package.yml`, and `infra/docker/docker-compose.yml`. Local data directories such as `db_inp/`, `temp/`, `data/`, and `.env` are ignored and should not be committed.
|
||||
Tests are under `tests/`, split into `tests/unit`, `tests/api`, and `tests/auth`. SQL and sample assets are stored in `resources/`; deployment files are in `Dockerfile`, `.gitea/workflows/package.yml`, and `infra/docker/docker-compose.yml`. Local data directories such as `db_inp/`, `temp/`, `data/`, and `.env` are ignored and should not be committed.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
@@ -29,7 +29,7 @@ The project uses `pytest`. Name test files `test_*.py` and test functions `test_
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
History uses a mix of Conventional Commit prefixes and concise Chinese messages, for example `feat(api): add Tianditu geocoding`, `fix(cli): constrain timeseries option values`, or `更新 cli 命令...`. Prefer `feat(scope): ...`, `fix(scope): ...`, or a clear Chinese summary.
|
||||
History uses a mix of Conventional Commit prefixes and concise Chinese messages, for example `feat(api): add Tianditu geocoding` or `fix(auth): validate project context`. Prefer `feat(scope): ...`, `fix(scope): ...`, or a clear Chinese summary.
|
||||
|
||||
Pull requests should describe the behavior change, list verification commands, mention configuration or migration impacts, and link related issues. Include API examples or screenshots only when they clarify user-facing behavior.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# TJWaterServerBinary 内部后端
|
||||
|
||||
`TJWaterServerBinary` 是 TJWater 内部版 Python 后端,基于 FastAPI 提供认证、项目、管网、模拟、爆管、漏损、SCADA、地图服务集成和命令行工具能力。该仓库用于内部开发和完整功能维护。
|
||||
`TJWaterServerBinary` 是 TJWater 内部版 Python 后端,基于 FastAPI 提供认证、项目、管网、模拟、爆管、漏损、SCADA 和地图服务集成能力。该仓库用于内部开发和完整功能维护。
|
||||
|
||||
## 技术栈
|
||||
|
||||
@@ -23,7 +23,6 @@ app/infra/ 数据库、缓存、EPANET 和外部集成
|
||||
app/services/ 业务服务编排
|
||||
app/algorithms/ 管网算法、模拟、爆管、漏损、清洗和健康分析
|
||||
app/native/ 本地管网数据读写与转换
|
||||
cli/ tjwater-cli 命令行工具
|
||||
tests/ 后端测试
|
||||
resources/ SQL、模板和示例资源
|
||||
infra/docker/ Docker Compose 编排
|
||||
@@ -58,16 +57,6 @@ docker compose -f infra/docker/docker-compose.yml config
|
||||
- `docker build`:构建后端镜像。
|
||||
- `docker compose config`:检查 compose 配置和变量展开。
|
||||
|
||||
## CLI
|
||||
|
||||
CLI 位于 `cli/tjwater_cli`,说明见:
|
||||
|
||||
```text
|
||||
cli/README.md
|
||||
```
|
||||
|
||||
修改 CLI 参数、输出结构或后端接口适配时,应同步更新 CLI 测试和文档。
|
||||
|
||||
## 开发规范
|
||||
|
||||
- Python 文件、函数、变量、Pydantic 字段、JSON body 字段和 query 参数使用 `snake_case`。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -260,7 +260,7 @@ async def is_project_locked_endpoint(
|
||||
"""
|
||||
return network in lockedPrjs.keys()
|
||||
|
||||
@router.get("/projects/current/lock/ownership", summary="检查项目是否被当前用户锁定", description="检查指定项目是否被当前客户端 (IP) 锁定。")
|
||||
@router.get("/projects/current/lock/ownership", summary="检查项目是否被当前用户锁定", description="检查指定项目是否被当前访问地址 (IP) 锁定。")
|
||||
async def is_project_locked_by_me_endpoint(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
|
||||
@@ -2,7 +2,7 @@ import logging
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.algorithms.sensor import (
|
||||
from app.auth.metadata_dependencies import get_current_metadata_user
|
||||
from app.auth.project_dependencies import ProjectContext, get_project_context
|
||||
from app.domain.schemas.sensor_placement import (
|
||||
SensorPointResponse,
|
||||
SensorPlacementExportRequest,
|
||||
SensorPlacementOptimizeRequest,
|
||||
SensorPlacementSchemeResponse,
|
||||
@@ -24,6 +25,7 @@ from app.services.sensor_placement import (
|
||||
SensorPlacementValidationError,
|
||||
build_sensor_placement_workbook,
|
||||
can_edit_sensor_placement,
|
||||
get_sensor_placement_candidate,
|
||||
get_sensor_placement_scheme,
|
||||
update_sensor_placement_scheme,
|
||||
)
|
||||
@@ -94,6 +96,25 @@ def _get_scheme_response(
|
||||
raise _service_http_error(exc) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sensor-placement-candidates/{node_id}",
|
||||
response_model=SensorPointResponse,
|
||||
summary="获取监测点候选节点详情",
|
||||
)
|
||||
async def get_sensor_placement_candidate_detail(
|
||||
node_id: str = Path(..., min_length=1, max_length=32),
|
||||
project_context: ProjectContext = Depends(get_project_context),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return await run_in_threadpool(
|
||||
get_sensor_placement_candidate,
|
||||
project_context.project_code,
|
||||
node_id,
|
||||
)
|
||||
except SensorPlacementValidationError as exc:
|
||||
raise _service_http_error(exc) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sensor-placement-optimization-runs",
|
||||
response_model=SensorPlacementSchemeResponse,
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
from fastapi import APIRouter, Request, Query
|
||||
from typing import Any, List, Dict, Union
|
||||
from app.services.tjnetwork import Any, get_all_users, get_user, get_user_schema
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
###########################################################
|
||||
# user 39
|
||||
###########################################################
|
||||
|
||||
@router.get("/network-schemas/user", summary="获取用户模式", description="获取指定网络的用户模式定义")
|
||||
async def fastapi_get_user_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[Any, Any]]:
|
||||
"""
|
||||
获取用户模式定义
|
||||
|
||||
返回指定网络的用户模式结构定义
|
||||
"""
|
||||
return get_user_schema(network)
|
||||
|
||||
@router.get("/users/detail", summary="获取单个用户", description="获取指定网络中的单个用户信息")
|
||||
async def fastapi_get_user(network: str = Query(..., description="管网名称(或数据库名称)"), user_name: str = Query(..., description="用户名")) -> dict[Any, Any]:
|
||||
"""
|
||||
获取用户信息
|
||||
|
||||
返回指定网络中指定用户名的详细信息
|
||||
"""
|
||||
return get_user(network, user_name)
|
||||
|
||||
@router.get("/users", summary="获取所有用户", description="获取指定网络的所有用户列表")
|
||||
async def fastapi_get_all_users(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[dict[Any, Any]]:
|
||||
"""
|
||||
获取所有用户列表
|
||||
|
||||
返回指定网络中所有用户的信息
|
||||
"""
|
||||
return get_all_users(network)
|
||||
@@ -8,8 +8,10 @@ from functools import wraps
|
||||
from typing import Any, Generic, TypeVar, get_args, get_origin
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.routing import APIRoute
|
||||
from pydantic import BaseModel, JsonValue, create_model
|
||||
from starlette.responses import Response
|
||||
|
||||
from app.api.problem_details import ProblemDetails
|
||||
from app.api.v1.router import api_router as handler_api_router
|
||||
@@ -263,6 +265,21 @@ def _with_pagination(endpoint):
|
||||
return wrapper
|
||||
|
||||
|
||||
def _with_jsonable_response(endpoint):
|
||||
"""Normalize untyped handler results before JsonValue validation."""
|
||||
|
||||
@wraps(endpoint)
|
||||
async def wrapper(*args, **kwargs):
|
||||
result = endpoint(*args, **kwargs)
|
||||
if inspect.isawaitable(result):
|
||||
result = await result
|
||||
if isinstance(result, Response):
|
||||
return result
|
||||
return jsonable_encoder(result)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def _adapt_route(route: APIRoute) -> APIRoute:
|
||||
methods = route.methods or set()
|
||||
if len(methods) != 1:
|
||||
@@ -286,6 +303,9 @@ def _adapt_route(route: APIRoute) -> APIRoute:
|
||||
|
||||
endpoint = _with_header_project_context(route.endpoint, route.name)
|
||||
response_model = route.response_model
|
||||
has_untyped_response = response_model is None
|
||||
if has_untyped_response:
|
||||
endpoint = _with_jsonable_response(endpoint)
|
||||
if get_origin(response_model) is list:
|
||||
item_type = get_args(response_model)[0] if get_args(response_model) else JsonValue
|
||||
response_model = Page[item_type]
|
||||
|
||||
@@ -22,7 +22,6 @@ from app.api.v1.endpoints import (
|
||||
sensor_placement,
|
||||
simulation,
|
||||
snapshots,
|
||||
users,
|
||||
web_search,
|
||||
)
|
||||
from app.api.v1.endpoints.components import (
|
||||
@@ -152,11 +151,6 @@ api_router.include_router(
|
||||
tags=["Snapshots"],
|
||||
dependencies=[simulation_access],
|
||||
)
|
||||
api_router.include_router(
|
||||
users.router,
|
||||
tags=["Users"],
|
||||
dependencies=[webgis_view_access],
|
||||
)
|
||||
api_router.include_router(
|
||||
schemes.router,
|
||||
tags=["Schemes"],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# import logging
|
||||
import time
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
@@ -23,12 +23,22 @@ def _decode_keycloak_token(token: str) -> dict:
|
||||
|
||||
key = settings.KEYCLOAK_PUBLIC_KEY.replace("\\n", "\n")
|
||||
|
||||
return jwt.decode(
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
key,
|
||||
algorithms=[settings.KEYCLOAK_ALGORITHM],
|
||||
audience=settings.KEYCLOAK_AUDIENCE or None,
|
||||
)
|
||||
if settings.KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS <= 0:
|
||||
return payload
|
||||
|
||||
issued_at = payload.get("iat")
|
||||
if not isinstance(issued_at, (int, float)) or (
|
||||
time.time() >= issued_at + settings.KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS
|
||||
):
|
||||
raise JWTError("Keycloak access token is older than the allowed maximum age")
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
async def get_current_keycloak_payload(
|
||||
|
||||
@@ -53,6 +53,7 @@ class Settings(BaseSettings):
|
||||
KEYCLOAK_PUBLIC_KEY: str = ""
|
||||
KEYCLOAK_ALGORITHM: str = "RS256"
|
||||
KEYCLOAK_AUDIENCE: str = ""
|
||||
KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS: int = 900
|
||||
|
||||
# Bocha Web Search API
|
||||
BOCHA_API_KEY: str = ""
|
||||
|
||||
@@ -67,6 +67,10 @@ class SensorPlacementExportRequest(BaseModel):
|
||||
|
||||
class SensorPointResponse(BaseModel):
|
||||
node_id: str
|
||||
max_pipe_diameter: float | None = Field(
|
||||
...,
|
||||
description="节点关联管道的最大管径,单位:毫米",
|
||||
)
|
||||
project_x: float
|
||||
project_y: float
|
||||
map_x: float
|
||||
|
||||
@@ -460,8 +460,6 @@ from .s36_wda_cal import (
|
||||
# -----------------------------------------------------------------------------
|
||||
from .s38_scada_info import get_scada_info_schema, get_scada_info, get_all_scada_info
|
||||
|
||||
from .s39_user import get_user_schema, get_user, get_all_users
|
||||
|
||||
from .s40_schema import get_scheme_schema, get_scheme, get_all_schemes
|
||||
|
||||
from .s41_pipe_risk_probability import (
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
from .database import *
|
||||
from .s0_base import *
|
||||
|
||||
class User(object):
|
||||
def __init__(self, input: dict[str, Any]) -> None:
|
||||
self.type = 'user'
|
||||
self.id = str(input['user_id'])
|
||||
self.name = str(input['username'])
|
||||
self.password = str(input['password'])
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return { 'type': self.type, 'id': self.id, 'name': self.name, 'password': self.password }
|
||||
|
||||
def as_id_dict(self) -> dict[str, Any]:
|
||||
return { 'type': self.type, 'id': self.id }
|
||||
|
||||
|
||||
def get_user_schema(name: str) -> dict[str, dict[Any, Any]]:
|
||||
return { 'id' : {'type': 'str' , 'optional': False , 'readonly': True },
|
||||
'name' : {'type': 'str' , 'optional': False , 'readonly': False},
|
||||
'password' : {'type': 'str' , 'optional': False , 'readonly': False} }
|
||||
|
||||
def get_user(name: str, user_name: str) -> dict[Any, Any]:
|
||||
t = try_read(name, f"select * from users where username = '{user_name}'")
|
||||
if t == None:
|
||||
return {}
|
||||
|
||||
d = {}
|
||||
d['id'] = str(t['user_id'])
|
||||
d['name'] = str(t['username'])
|
||||
# d['password'] = str(t['password'])
|
||||
|
||||
return d
|
||||
|
||||
def get_all_users(name: str) -> list[dict[Any, Any]]:
|
||||
return read_all(name, "select * from users")
|
||||
|
||||
@@ -66,8 +66,22 @@ def get_sensor_placement_nodes(
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
WITH incident_pipe_diameters AS (
|
||||
SELECT node_id, MAX(diameter) AS max_pipe_diameter
|
||||
FROM (
|
||||
SELECT node1 AS node_id, diameter
|
||||
FROM pipes
|
||||
WHERE node1 = ANY(%s)
|
||||
UNION ALL
|
||||
SELECT node2 AS node_id, diameter
|
||||
FROM pipes
|
||||
WHERE node2 = ANY(%s)
|
||||
) AS incident_pipes
|
||||
GROUP BY node_id
|
||||
)
|
||||
SELECT DISTINCT ON (gj.id)
|
||||
gj.id AS node_id,
|
||||
ipd.max_pipe_diameter,
|
||||
gj.elevation,
|
||||
ST_X(c.coord) AS project_x,
|
||||
ST_Y(c.coord) AS project_y,
|
||||
@@ -75,10 +89,11 @@ def get_sensor_placement_nodes(
|
||||
ST_Y(gj.geom) AS map_y
|
||||
FROM geo_junctions_mat AS gj
|
||||
JOIN coordinates AS c ON c.node = gj.id
|
||||
LEFT JOIN incident_pipe_diameters AS ipd ON ipd.node_id = gj.id
|
||||
WHERE gj.id = ANY(%s)
|
||||
ORDER BY gj.id
|
||||
""",
|
||||
(node_ids,),
|
||||
(node_ids, node_ids, node_ids),
|
||||
)
|
||||
return list(cur.fetchall())
|
||||
|
||||
|
||||
@@ -11,53 +11,6 @@ from app.core.config import get_pgconn_string
|
||||
from app.services.time_api import parse_utc_time
|
||||
|
||||
|
||||
# 2025/03/23
|
||||
def create_user(name: str, username: str, password: str):
|
||||
"""
|
||||
创建用户
|
||||
:param name: 数据库名称
|
||||
:param username: 用户名
|
||||
:param password: 密码
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
# 动态替换数据库名称
|
||||
conn_string = get_pgconn_string(db_name=name)
|
||||
# 连接到 PostgreSQL 数据库(这里是数据库 "bb")
|
||||
with psycopg.connect(conn_string) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO users (username, password) VALUES (%s, %s)",
|
||||
(username, password),
|
||||
)
|
||||
# 提交事务
|
||||
conn.commit()
|
||||
print("新用户创建成功!")
|
||||
except Exception as e:
|
||||
print(f"创建用户出错:{e}")
|
||||
|
||||
|
||||
# 2025/03/23
|
||||
def delete_user(name: str, username: str):
|
||||
"""
|
||||
删除用户
|
||||
:param name: 数据库名称
|
||||
:param username: 用户名
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
# 动态替换数据库名称
|
||||
conn_string = get_pgconn_string(db_name=name)
|
||||
# 连接到 PostgreSQL 数据库(这里是数据库 "bb")
|
||||
with psycopg.connect(conn_string) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("DELETE FROM users WHERE username = %s", (username,))
|
||||
conn.commit()
|
||||
print(f"用户 {username} 删除成功!")
|
||||
except Exception as e:
|
||||
print(f"删除用户出错:{e}")
|
||||
|
||||
|
||||
# 2025/03/23
|
||||
def scheme_name_exists(name: str, scheme_name: str) -> bool:
|
||||
"""
|
||||
@@ -98,8 +51,8 @@ def store_scheme_info(
|
||||
:param name: 数据库名称
|
||||
:param scheme_name: 方案名称
|
||||
:param scheme_type: 方案类型
|
||||
:param username: 用户名(需在 users 表中已存在)
|
||||
:param scheme_start_time: 方案起始时间(字符串)
|
||||
:param username: MetaDB 中的用户名快照
|
||||
:param scheme_start_time: 带时区的方案起始时间;写入前统一转换为 UTC
|
||||
:param scheme_detail: 方案详情(字典,会转换为 JSON)
|
||||
:return:
|
||||
"""
|
||||
|
||||
@@ -80,6 +80,11 @@ def _sensor_points(
|
||||
points.append(
|
||||
{
|
||||
"node_id": node_id,
|
||||
"max_pipe_diameter": (
|
||||
float(node["max_pipe_diameter"])
|
||||
if node["max_pipe_diameter"] is not None
|
||||
else None
|
||||
),
|
||||
"project_x": project_x,
|
||||
"project_y": project_y,
|
||||
"map_x": map_x,
|
||||
@@ -92,6 +97,15 @@ def _sensor_points(
|
||||
return points
|
||||
|
||||
|
||||
def get_sensor_placement_candidate(
|
||||
network: str,
|
||||
node_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Return the authoritative editable point data for one junction."""
|
||||
|
||||
return _sensor_points(network, _normalize_locations([node_id]))[0]
|
||||
|
||||
|
||||
def validate_sensor_placement_nodes(
|
||||
network: str,
|
||||
sensor_location: list[str],
|
||||
|
||||
@@ -1290,19 +1290,6 @@ def get_scada_info(name: str, id: str) -> dict[str, Any]:
|
||||
def get_all_scada_info(name: str) -> list[dict[str, Any]]:
|
||||
return api.get_all_scada_info(name)
|
||||
|
||||
# DingZQ 2025-03-27
|
||||
############################################################
|
||||
# 39 users
|
||||
############################################################
|
||||
def get_user_schema(name: str) -> dict[str, dict[str, Any]]:
|
||||
return api.get_user_schema(name)
|
||||
|
||||
def get_user(name: str, user_name: str) -> dict[str, Any]:
|
||||
return api.get_user(name, user_name=user_name)
|
||||
|
||||
def get_all_users(name: str) -> list[dict[str, Any]]:
|
||||
return api.get_all_users(name)
|
||||
|
||||
############################################################
|
||||
# scheme 40
|
||||
############################################################
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
dist/
|
||||
build/
|
||||
__pycache__/
|
||||
@@ -1,79 +0,0 @@
|
||||
# TJWater CLI
|
||||
|
||||
独立于服务端主代码的 Python CLI 文件夹,放在 `TJWaterServerBinary/cli/` 下,供 agent 服务器使用**编译后的可执行文件**直接调用,并通过 stdout/stderr 参与管道。
|
||||
|
||||
## 构建可执行产物
|
||||
|
||||
```bash
|
||||
cd TJWaterServerBinary/cli
|
||||
python -m pip install -r requirements.txt
|
||||
python -m pip install -r requirements-build.txt
|
||||
chmod +x build.sh
|
||||
./build.sh
|
||||
```
|
||||
|
||||
构建完成后,直接使用编译产物:
|
||||
|
||||
```bash
|
||||
./dist/tjwater-cli/tjwater-cli help
|
||||
```
|
||||
|
||||
这个可执行文件可以直接参与管道:
|
||||
|
||||
```bash
|
||||
./dist/tjwater-cli/tjwater-cli help | jq
|
||||
```
|
||||
|
||||
当前采用 `PyInstaller onedir` 方式输出到 `dist/tjwater-cli/`,避免 onefile 在部分 agent/server 环境下依赖临时目录解包执行的问题。
|
||||
|
||||
如果需要在开发时直接走源码入口,也可以显式使用 Python:
|
||||
|
||||
```bash
|
||||
python -m tjwater_cli help
|
||||
```
|
||||
|
||||
## 部署到 agent 服务器
|
||||
|
||||
最简单的方式是把 `dist/tjwater-cli/` 整个目录同步到 agent 服务器,然后直接执行:
|
||||
|
||||
```bash
|
||||
./tjwater-cli/tjwater-cli help
|
||||
```
|
||||
|
||||
如果希望打包传输:
|
||||
|
||||
```bash
|
||||
cd TJWaterServerBinary/cli
|
||||
tar -C dist -czf tjwater-cli-linux-amd64.tar.gz tjwater-cli
|
||||
```
|
||||
|
||||
如果希望放到 PATH 中:
|
||||
|
||||
```bash
|
||||
ln -s /path/to/TJWaterServerBinary/cli/dist/tjwater-cli/tjwater-cli /usr/local/bin/tjwater-cli
|
||||
tjwater-cli help | jq
|
||||
```
|
||||
|
||||
## 运行与构建依赖
|
||||
|
||||
```bash
|
||||
cd TJWaterServerBinary/cli
|
||||
python -m pip install -r requirements.txt
|
||||
python -m pip install -r requirements-build.txt
|
||||
```
|
||||
|
||||
`requirements.txt` 仅包含运行 CLI 的依赖;`requirements-build.txt` 仅包含生成可执行文件所需的构建依赖。
|
||||
|
||||
## 认证上下文
|
||||
|
||||
CLI 通过 `--auth-context` 读取 JSON 文件。常用字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"server": "http://backend-host:8000",
|
||||
"access_token": "...",
|
||||
"project_id": "...",
|
||||
"network": "...",
|
||||
"username": "..."
|
||||
}
|
||||
```
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
if [ -n "${PYTHON:-}" ]; then
|
||||
PYTHON_BIN="$PYTHON"
|
||||
elif command -v python >/dev/null 2>&1; then
|
||||
PYTHON_BIN="python"
|
||||
else
|
||||
PYTHON_BIN="python3"
|
||||
fi
|
||||
|
||||
cd "$ROOT"
|
||||
|
||||
"$PYTHON_BIN" -m PyInstaller --noconfirm --clean tjwater.spec
|
||||
|
||||
BIN_PATH="$ROOT/dist/"
|
||||
if [ ! -x "$BIN_PATH" ]; then
|
||||
echo "build succeeded but executable was not created: $BIN_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
"$BIN_PATH" help >/dev/null
|
||||
|
||||
echo "built executable: $BIN_PATH"
|
||||
@@ -1,5 +0,0 @@
|
||||
from tjwater_cli.main import console_entry
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
console_entry()
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"include": [
|
||||
"tjwater_cli",
|
||||
"tests"
|
||||
],
|
||||
"executionEnvironments": [
|
||||
{
|
||||
"root": ".",
|
||||
"extraPaths": [
|
||||
"."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
pyinstaller>=6.11,<7
|
||||
@@ -1,3 +0,0 @@
|
||||
click>=8.1,<9
|
||||
requests>=2.31,<3
|
||||
typer>=0.12,<1
|
||||
@@ -1,6 +0,0 @@
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
@@ -1,930 +0,0 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from tjwater_cli import common, core
|
||||
from tjwater_cli.main import app, main
|
||||
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
class DummyResponse:
|
||||
def __init__(self, *, status_code=200, json_data=None, text="", headers=None, content=None):
|
||||
self.status_code = status_code
|
||||
self._json_data = json_data
|
||||
self.text = text
|
||||
self.headers = headers or {"content-type": "application/json"}
|
||||
self.content = content if content is not None else text.encode("utf-8")
|
||||
|
||||
@property
|
||||
def ok(self):
|
||||
return 200 <= self.status_code < 300
|
||||
|
||||
def json(self):
|
||||
if self._json_data is None:
|
||||
raise ValueError("no json")
|
||||
return self._json_data
|
||||
|
||||
|
||||
def test_load_auth_context_supports_aliases(monkeypatch):
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
monkeypatch.setenv("TJWATER_PROJECT_ID", "p1")
|
||||
|
||||
auth = core.load_auth_context(auth_stdin=False)
|
||||
|
||||
assert auth.server == "http://server"
|
||||
assert auth.access_token == "abc"
|
||||
assert auth.project_id == "p1"
|
||||
|
||||
|
||||
def test_build_runtime_context_uses_default_server(monkeypatch):
|
||||
monkeypatch.delenv("TJWATER_SERVER", raising=False)
|
||||
monkeypatch.delenv("TJWATER_ACCESS_TOKEN", raising=False)
|
||||
monkeypatch.delenv("TJWATER_PROJECT_ID", raising=False)
|
||||
monkeypatch.delenv("TJWATER_EXTRA_HEADERS", raising=False)
|
||||
|
||||
runtime = core.build_runtime_context(
|
||||
server=None,
|
||||
scheme=None,
|
||||
timeout=core.DEFAULT_TIMEOUT,
|
||||
request_id="req-1",
|
||||
)
|
||||
|
||||
assert runtime.server == core.DEFAULT_SERVER
|
||||
|
||||
|
||||
def test_auth_stdin_can_be_reused_with_runtime_context_cache(monkeypatch):
|
||||
observed_runtime_ids: list[int] = []
|
||||
|
||||
def fake_request_json(ctx, **kwargs):
|
||||
observed_runtime_ids.append(id(ctx))
|
||||
assert ctx.auth.access_token == "token-1"
|
||||
assert kwargs["params"] == {"junction": "11"}
|
||||
return {"id": "11"}, 5
|
||||
|
||||
monkeypatch.setattr(common, "request_json", fake_request_json)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["--auth-stdin", "network", "get-junction-properties", "--junction", "11"],
|
||||
input=json.dumps(
|
||||
{
|
||||
"server": "http://server",
|
||||
"access_token": "token-1",
|
||||
"project_id": "project-1",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"] == {"id": "11"}
|
||||
assert len(observed_runtime_ids) == 1
|
||||
|
||||
|
||||
def test_network_get_junction_properties_uses_network_context(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request_json(ctx, **kwargs):
|
||||
captured["access_token"] = ctx.auth.access_token
|
||||
captured["path"] = kwargs["path"]
|
||||
captured["params"] = kwargs["params"]
|
||||
return {"id": "J1"}, 5
|
||||
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
monkeypatch.setattr(common, "request_json", fake_request_json)
|
||||
|
||||
result = runner.invoke(app, ["network", "get-junction-properties", "--junction", "J1"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"] == {"id": "J1"}
|
||||
assert captured == {
|
||||
"access_token": "abc",
|
||||
"path": "/junctions/properties",
|
||||
"params": {"junction": "J1"},
|
||||
}
|
||||
|
||||
|
||||
def test_network_get_pipe_properties_uses_network_context(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request_json(ctx, **kwargs):
|
||||
captured["access_token"] = ctx.auth.access_token
|
||||
captured["path"] = kwargs["path"]
|
||||
captured["params"] = kwargs["params"]
|
||||
return {"id": "P1"}, 5
|
||||
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
monkeypatch.setattr(common, "request_json", fake_request_json)
|
||||
|
||||
result = runner.invoke(app, ["network", "get-pipe-properties", "--pipe", "P1"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"] == {"id": "P1"}
|
||||
assert captured == {
|
||||
"access_token": "abc",
|
||||
"path": "/pipes/properties",
|
||||
"params": {"pipe": "P1"},
|
||||
}
|
||||
|
||||
|
||||
def test_network_get_all_pipes_properties_uses_network_context(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request_json(ctx, **kwargs):
|
||||
captured["access_token"] = ctx.auth.access_token
|
||||
captured["path"] = kwargs["path"]
|
||||
captured["params"] = kwargs["params"]
|
||||
return [{"id": "P1"}], 5
|
||||
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
monkeypatch.setattr(common, "request_json", fake_request_json)
|
||||
|
||||
result = runner.invoke(app, ["network", "get-all-pipes-properties"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"] == [{"id": "P1"}]
|
||||
assert captured == {
|
||||
"access_token": "abc",
|
||||
"path": "/pipes",
|
||||
"params": {},
|
||||
}
|
||||
|
||||
|
||||
def test_network_get_reservoir_properties_uses_network_context(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request_json(ctx, **kwargs):
|
||||
captured["access_token"] = ctx.auth.access_token
|
||||
captured["path"] = kwargs["path"]
|
||||
captured["params"] = kwargs["params"]
|
||||
return {"id": "R1"}, 5
|
||||
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
monkeypatch.setattr(common, "request_json", fake_request_json)
|
||||
|
||||
result = runner.invoke(app, ["network", "get-reservoir-properties", "--reservoir", "R1"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"] == {"id": "R1"}
|
||||
assert captured == {
|
||||
"access_token": "abc",
|
||||
"path": "/reservoirs/properties",
|
||||
"params": {"reservoir": "R1"},
|
||||
}
|
||||
|
||||
|
||||
def test_network_get_all_reservoir_properties_uses_network_context(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request_json(ctx, **kwargs):
|
||||
captured["access_token"] = ctx.auth.access_token
|
||||
captured["path"] = kwargs["path"]
|
||||
captured["params"] = kwargs["params"]
|
||||
return [{"id": "R1"}], 5
|
||||
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
monkeypatch.setattr(common, "request_json", fake_request_json)
|
||||
|
||||
result = runner.invoke(app, ["network", "get-all-reservoirs-properties"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"] == [{"id": "R1"}]
|
||||
assert captured == {
|
||||
"access_token": "abc",
|
||||
"path": "/reservoirs",
|
||||
"params": {},
|
||||
}
|
||||
|
||||
|
||||
def test_network_get_tank_properties_uses_network_context(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request_json(ctx, **kwargs):
|
||||
captured["access_token"] = ctx.auth.access_token
|
||||
captured["path"] = kwargs["path"]
|
||||
captured["params"] = kwargs["params"]
|
||||
return {"id": "T1"}, 5
|
||||
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
monkeypatch.setattr(common, "request_json", fake_request_json)
|
||||
|
||||
result = runner.invoke(app, ["network", "get-tank-properties", "--tank", "T1"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"] == {"id": "T1"}
|
||||
assert captured == {
|
||||
"access_token": "abc",
|
||||
"path": "/tanks/properties",
|
||||
"params": {"tank": "T1"},
|
||||
}
|
||||
|
||||
|
||||
def test_network_get_all_tank_properties_uses_network_context(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request_json(ctx, **kwargs):
|
||||
captured["access_token"] = ctx.auth.access_token
|
||||
captured["path"] = kwargs["path"]
|
||||
captured["params"] = kwargs["params"]
|
||||
return [{"id": "T1"}], 5
|
||||
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
monkeypatch.setattr(common, "request_json", fake_request_json)
|
||||
|
||||
result = runner.invoke(app, ["network", "get-all-tanks-properties"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"] == [{"id": "T1"}]
|
||||
assert captured == {
|
||||
"access_token": "abc",
|
||||
"path": "/tanks",
|
||||
"params": {},
|
||||
}
|
||||
|
||||
|
||||
def test_network_get_pump_properties_uses_network_context(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request_json(ctx, **kwargs):
|
||||
captured["access_token"] = ctx.auth.access_token
|
||||
captured["path"] = kwargs["path"]
|
||||
captured["params"] = kwargs["params"]
|
||||
return {"id": "PU1"}, 5
|
||||
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
monkeypatch.setattr(common, "request_json", fake_request_json)
|
||||
|
||||
result = runner.invoke(app, ["network", "get-pump-properties", "--pump", "PU1"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"] == {"id": "PU1"}
|
||||
assert captured == {
|
||||
"access_token": "abc",
|
||||
"path": "/pumps/properties",
|
||||
"params": {"pump": "PU1"},
|
||||
}
|
||||
|
||||
|
||||
def test_network_get_all_pump_properties_uses_network_context(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request_json(ctx, **kwargs):
|
||||
captured["access_token"] = ctx.auth.access_token
|
||||
captured["path"] = kwargs["path"]
|
||||
captured["params"] = kwargs["params"]
|
||||
return [{"id": "PU1"}], 5
|
||||
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
monkeypatch.setattr(common, "request_json", fake_request_json)
|
||||
|
||||
result = runner.invoke(app, ["network", "get-all-pumps-properties"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"] == [{"id": "PU1"}]
|
||||
assert captured == {
|
||||
"access_token": "abc",
|
||||
"path": "/pumps",
|
||||
"params": {},
|
||||
}
|
||||
|
||||
|
||||
def test_network_get_valve_properties_uses_network_context(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request_json(ctx, **kwargs):
|
||||
captured["access_token"] = ctx.auth.access_token
|
||||
captured["path"] = kwargs["path"]
|
||||
captured["params"] = kwargs["params"]
|
||||
return {"id": "V1"}, 5
|
||||
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
monkeypatch.setattr(common, "request_json", fake_request_json)
|
||||
|
||||
result = runner.invoke(app, ["network", "get-valve-properties", "--valve", "V1"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"] == {"id": "V1"}
|
||||
assert captured == {
|
||||
"access_token": "abc",
|
||||
"path": "/valves/properties",
|
||||
"params": {"valve": "V1"},
|
||||
}
|
||||
|
||||
|
||||
def test_network_get_all_valve_properties_uses_network_context(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request_json(ctx, **kwargs):
|
||||
captured["access_token"] = ctx.auth.access_token
|
||||
captured["path"] = kwargs["path"]
|
||||
captured["params"] = kwargs["params"]
|
||||
return [{"id": "V1"}], 5
|
||||
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
monkeypatch.setattr(common, "request_json", fake_request_json)
|
||||
|
||||
result = runner.invoke(app, ["network", "get-all-valves-properties"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"] == [{"id": "V1"}]
|
||||
assert captured == {
|
||||
"access_token": "abc",
|
||||
"path": "/valves",
|
||||
"params": {},
|
||||
}
|
||||
|
||||
|
||||
def test_help_outputs_json_lists_commands():
|
||||
result = runner.invoke(app, ["help"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["schema_version"] == "tjwater-cli/v1"
|
||||
assert any(command["command"] == "analysis" for command in payload["commands"])
|
||||
assert all(command["command"] != "project" for command in payload["commands"])
|
||||
assert payload["menu_level"] == 1
|
||||
assert all(command["command"] != "project list" for command in payload["commands"])
|
||||
|
||||
|
||||
def test_help_option_json_is_removed():
|
||||
result = runner.invoke(app, ["help", "--json"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "No such option: --json" in result.output
|
||||
|
||||
|
||||
def test_simulation_help_lists_subcommands():
|
||||
result = runner.invoke(app, ["simulation", "help"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["summary"] == "模拟运行与调度相关命令。"
|
||||
commands = {command["command"]: command for command in payload["commands"]}
|
||||
assert commands["simulation run"]["summary"] == "触发指定绝对时间的模拟运行"
|
||||
assert commands["simulation run"]["usage"] == "tjwater-cli simulation run --start-time <START_TIME> --duration <DURATION>"
|
||||
assert "tjwater-cli" in commands["simulation run"]["example"]
|
||||
assert "simulation run" in commands["simulation run"]["example"]
|
||||
|
||||
|
||||
def test_nested_group_help_lists_examples():
|
||||
result = runner.invoke(app, ["analysis", "leakage", "help"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["summary"] == "漏损分析相关命令。"
|
||||
commands = {command["command"]: command for command in payload["commands"]}
|
||||
assert commands["analysis leakage identify"]["summary"] == "执行漏损识别"
|
||||
assert commands["analysis leakage identify"]["example"] == "tjwater-cli analysis leakage identify --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme leak_case_01"
|
||||
|
||||
|
||||
def test_analysis_help_uses_group_summaries_for_nested_groups():
|
||||
result = runner.invoke(app, ["analysis", "help"])
|
||||
payload = json.loads(result.stdout)
|
||||
commands = {command["command"]: command for command in payload["commands"]}
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert commands["analysis leakage"]["summary"] == "漏损分析相关命令。"
|
||||
assert commands["analysis burst-detection"]["summary"] == "爆管检测相关命令。"
|
||||
assert "analysis burst-location" not in commands
|
||||
assert "analysis risk" not in commands
|
||||
assert commands["analysis burst"]["example"] == "tjwater-cli analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 900 --burst-file ./burst.json --scheme burst_case_01"
|
||||
assert commands["analysis valve"]["example"] == "tjwater-cli analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --valve V2 --duration 900 --scheme valve_case_01"
|
||||
|
||||
|
||||
def test_bare_analysis_uses_typer_help_with_descriptions():
|
||||
result = runner.invoke(app, ["analysis"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "分析计算与诊断相关命令。" in result.stdout
|
||||
assert "burst 执行爆管分析" in result.stdout
|
||||
assert "valve" in result.stdout
|
||||
assert "leakage 漏损分析相关命令。" in result.stdout
|
||||
assert "burst-location" not in result.stdout
|
||||
assert "risk" not in result.stdout
|
||||
|
||||
|
||||
def test_leaf_help_outputs_json():
|
||||
result = runner.invoke(app, ["help", "simulation", "run"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["command"] == "simulation run"
|
||||
assert payload["output"] == "模拟触发结果;实时数据需通过 data timeseries 命令按时间段查询"
|
||||
assert payload["usage"] == "tjwater-cli simulation run --start-time <START_TIME> --duration <DURATION>"
|
||||
assert len(payload["examples"]) == 1
|
||||
assert "simulation run" in payload["examples"][0]
|
||||
|
||||
|
||||
def test_root_help_flag_uses_typer_style_with_examples():
|
||||
result = runner.invoke(app, ["--help"], prog_name="tjwater-cli")
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Usage: tjwater-cli" in result.stdout
|
||||
assert "Examples:" in result.stdout
|
||||
assert "tjwater-cli help simulation run" in result.stdout
|
||||
|
||||
|
||||
def test_leaf_help_flag_includes_usage_and_example():
|
||||
result = runner.invoke(app, ["simulation", "run", "--help"], prog_name="tjwater-cli")
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Usage: tjwater-cli simulation run [OPTIONS]" in result.stdout
|
||||
assert "Usage example:" in result.stdout
|
||||
assert "--start-time <START_TIME>" in result.stdout
|
||||
assert "--duration" in result.stdout
|
||||
assert "Examples:" in result.stdout
|
||||
assert "tjwater-cli simulation run" in result.stdout
|
||||
assert "START_TIME" in result.stdout
|
||||
assert "DURATION" in result.stdout
|
||||
|
||||
|
||||
def test_realtime_simulation_help_clarifies_type_values():
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["data", "timeseries", "realtime", "simulation-by-id-time", "--help"],
|
||||
prog_name="tjwater-cli",
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "links/nodes 是子命令" in result.stdout
|
||||
assert "pipe" in result.stdout
|
||||
assert "junction" in result.stdout
|
||||
|
||||
|
||||
def test_realtime_property_help_lists_supported_fields():
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["data", "timeseries", "realtime", "simulation-by-time-property", "--help"],
|
||||
prog_name="tjwater-cli",
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "flow" in result.stdout
|
||||
assert "pressure" in result.stdout
|
||||
assert "actual_demand" in result.stdout
|
||||
assert "velocity" in result.stdout
|
||||
|
||||
|
||||
def test_analysis_burst_returns_next_step_to_fetch_scheme(monkeypatch, tmp_path: Path):
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
burst_path = tmp_path / "burst.json"
|
||||
burst_path.write_text('[{"id":"P1","size":3.5}]', encoding="utf-8")
|
||||
|
||||
def fake_request(**kwargs):
|
||||
return DummyResponse(text="success", headers={"content-type": "text/plain"})
|
||||
|
||||
monkeypatch.setattr(core.requests, "request", fake_request)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"analysis",
|
||||
"burst",
|
||||
"--start-time",
|
||||
"2025-01-02T03:04:05+08:00",
|
||||
"--duration",
|
||||
"30",
|
||||
"--burst-file",
|
||||
str(burst_path),
|
||||
"--scheme",
|
||||
"burst_case_01",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert '"summary": "爆管分析执行成功"' in result.stdout
|
||||
assert "tjwater-cli data scheme get --name burst_case_01" in result.stdout
|
||||
assert "tjwater-cli data scheme list" in result.stdout
|
||||
|
||||
|
||||
def test_analysis_contaminant_sends_required_scheme_name(monkeypatch):
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
captured = {}
|
||||
|
||||
def fake_request(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return DummyResponse(text="success", headers={"content-type": "text/plain"})
|
||||
|
||||
monkeypatch.setattr(core.requests, "request", fake_request)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"analysis",
|
||||
"contaminant",
|
||||
"--start-time",
|
||||
"2025-01-02T03:04:05+08:00",
|
||||
"--duration",
|
||||
"900",
|
||||
"--source-node",
|
||||
"N1",
|
||||
"--concentration",
|
||||
"10.0",
|
||||
"--scheme",
|
||||
"contam_case_01",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert captured["params"] == {
|
||||
"start_time": "2025-01-02T03:04:05+08:00",
|
||||
"source": "N1",
|
||||
"concentration": 10.0,
|
||||
"duration": 900,
|
||||
"scheme_name": "contam_case_01",
|
||||
}
|
||||
|
||||
|
||||
def test_analysis_flushing_sends_required_scheme_name(monkeypatch, tmp_path: Path):
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
captured = {}
|
||||
valve_path = tmp_path / "valve.json"
|
||||
valve_path.write_text('[{"valve":"V1","opening":0.5}]', encoding="utf-8")
|
||||
|
||||
def fake_request(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return DummyResponse(text="success", headers={"content-type": "text/plain"})
|
||||
|
||||
monkeypatch.setattr(core.requests, "request", fake_request)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"analysis",
|
||||
"flushing",
|
||||
"--start-time",
|
||||
"2025-01-02T03:04:05+08:00",
|
||||
"--valve-setting-file",
|
||||
str(valve_path),
|
||||
"--drainage-node",
|
||||
"N1",
|
||||
"--flow",
|
||||
"100.0",
|
||||
"--duration",
|
||||
"900",
|
||||
"--scheme",
|
||||
"flush_case_01",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert captured["params"] == {
|
||||
"start_time": "2025-01-02T03:04:05+08:00",
|
||||
"valves": ["V1"],
|
||||
"valves_k": [0.5],
|
||||
"drainage_node_id": "N1",
|
||||
"flush_flow": 100.0,
|
||||
"duration": 900,
|
||||
"scheme_name": "flush_case_01",
|
||||
}
|
||||
|
||||
|
||||
def test_analysis_valve_close_sends_required_scheme_name(monkeypatch):
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
captured = {}
|
||||
|
||||
def fake_request(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return DummyResponse(text="success", headers={"content-type": "text/plain"})
|
||||
|
||||
monkeypatch.setattr(core.requests, "request", fake_request)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"analysis",
|
||||
"valve",
|
||||
"--mode",
|
||||
"close",
|
||||
"--start-time",
|
||||
"2025-01-02T03:04:05+08:00",
|
||||
"--valve",
|
||||
"V1",
|
||||
"--duration",
|
||||
"900",
|
||||
"--scheme",
|
||||
"valve_case_01",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert captured["params"] == {
|
||||
"start_time": "2025-01-02T03:04:05+08:00",
|
||||
"valves": ["V1"],
|
||||
"duration": 900,
|
||||
"scheme_name": "valve_case_01",
|
||||
}
|
||||
|
||||
|
||||
def test_analysis_contaminant_requires_scheme(monkeypatch, capsys):
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
|
||||
exit_code = main(
|
||||
[
|
||||
"analysis",
|
||||
"contaminant",
|
||||
"--start-time",
|
||||
"2025-01-02T03:04:05+08:00",
|
||||
"--duration",
|
||||
"900",
|
||||
"--source-node",
|
||||
"N1",
|
||||
"--concentration",
|
||||
"10.0",
|
||||
],
|
||||
)
|
||||
|
||||
stdout = capsys.readouterr().out
|
||||
|
||||
assert exit_code == 2
|
||||
assert '"code": "SCHEME_REQUIRED"' in stdout
|
||||
|
||||
|
||||
def test_analysis_flushing_requires_scheme(monkeypatch, tmp_path: Path, capsys):
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
valve_path = tmp_path / "valve.json"
|
||||
valve_path.write_text('[{"valve":"V1","opening":0.5}]', encoding="utf-8")
|
||||
|
||||
exit_code = main(
|
||||
[
|
||||
"analysis",
|
||||
"flushing",
|
||||
"--start-time",
|
||||
"2025-01-02T03:04:05+08:00",
|
||||
"--valve-setting-file",
|
||||
str(valve_path),
|
||||
"--drainage-node",
|
||||
"N1",
|
||||
"--flow",
|
||||
"100.0",
|
||||
],
|
||||
)
|
||||
|
||||
stdout = capsys.readouterr().out
|
||||
|
||||
assert exit_code == 2
|
||||
assert '"code": "SCHEME_REQUIRED"' in stdout
|
||||
|
||||
|
||||
def test_analysis_valve_close_requires_scheme(monkeypatch, capsys):
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
|
||||
exit_code = main(
|
||||
[
|
||||
"analysis",
|
||||
"valve",
|
||||
"--mode",
|
||||
"close",
|
||||
"--start-time",
|
||||
"2025-01-02T03:04:05+08:00",
|
||||
"--valve",
|
||||
"V1",
|
||||
"--duration",
|
||||
"900",
|
||||
],
|
||||
)
|
||||
|
||||
stdout = capsys.readouterr().out
|
||||
|
||||
assert exit_code == 2
|
||||
assert '"code": "SCHEME_REQUIRED"' in stdout
|
||||
|
||||
|
||||
def test_main_missing_option_error_includes_usage_and_next_step(capsys):
|
||||
exit_code = main(["simulation", "run"])
|
||||
stdout = capsys.readouterr().out
|
||||
|
||||
assert exit_code == 2
|
||||
assert '"summary": "缺少参数"' in stdout
|
||||
assert '"code": "MISSING_PARAMETER"' in stdout
|
||||
assert '"usage": "tjwater-cli simulation run --start-time <START_TIME> --duration <DURATION>"' in stdout
|
||||
assert '"tjwater-cli help simulation run"' in stdout
|
||||
|
||||
|
||||
def test_main_invalid_enum_value_is_rejected_before_request(capsys):
|
||||
exit_code = main(
|
||||
[
|
||||
"data",
|
||||
"timeseries",
|
||||
"realtime",
|
||||
"simulation-by-id-time",
|
||||
"--id",
|
||||
"J1",
|
||||
"--type",
|
||||
"links",
|
||||
"--time",
|
||||
"2025-01-02T03:30:00+08:00",
|
||||
]
|
||||
)
|
||||
stdout = capsys.readouterr().out
|
||||
|
||||
assert exit_code == 2
|
||||
assert '"summary": "参数无效"' in stdout
|
||||
assert '"code": "INVALID_PARAMETER"' in stdout
|
||||
assert "links" in stdout
|
||||
assert "pipe" in stdout
|
||||
assert "junction" in stdout
|
||||
|
||||
|
||||
def test_main_invalid_pipe_property_is_rejected_before_request(capsys):
|
||||
exit_code = main(
|
||||
[
|
||||
"data",
|
||||
"timeseries",
|
||||
"realtime",
|
||||
"simulation-by-time-property",
|
||||
"--type",
|
||||
"pipe",
|
||||
"--time",
|
||||
"2025-01-02T03:30:00+08:00",
|
||||
"--property",
|
||||
"pressure",
|
||||
]
|
||||
)
|
||||
stdout = capsys.readouterr().out
|
||||
|
||||
assert exit_code == 2
|
||||
assert '"code": "INVALID_PROPERTY"' in stdout
|
||||
assert "flow" in stdout
|
||||
assert "velocity" in stdout
|
||||
|
||||
|
||||
def test_main_invalid_scada_field_is_rejected_before_request(capsys):
|
||||
exit_code = main(
|
||||
[
|
||||
"data",
|
||||
"timeseries",
|
||||
"scada",
|
||||
"query",
|
||||
"--device-id",
|
||||
"D1",
|
||||
"--start-time",
|
||||
"2025-01-02T03:00:00+08:00",
|
||||
"--end-time",
|
||||
"2025-01-02T04:00:00+08:00",
|
||||
"--field",
|
||||
"flow",
|
||||
]
|
||||
)
|
||||
stdout = capsys.readouterr().out
|
||||
|
||||
assert exit_code == 2
|
||||
assert '"code": "INVALID_FIELD"' in stdout
|
||||
assert "monitored_value" in stdout
|
||||
assert "cleaned_value" in stdout
|
||||
|
||||
|
||||
def test_data_scada_get_rejects_removed_kind_before_request(capsys):
|
||||
exit_code = main(["data", "scada", "get", "--kind", "device", "--id", "D1"])
|
||||
stdout = capsys.readouterr().out
|
||||
|
||||
assert exit_code == 2
|
||||
assert '"code": "INVALID_PARAMETER"' in stdout
|
||||
assert "device" in stdout
|
||||
assert "info" in stdout
|
||||
|
||||
|
||||
def test_data_scada_list_help_only_shows_info_kind():
|
||||
result = runner.invoke(app, ["data", "scada", "list", "--help"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "info" in result.stdout
|
||||
assert "device" not in result.stdout
|
||||
assert "element" not in result.stdout
|
||||
|
||||
|
||||
def test_data_scada_help_no_longer_lists_schema():
|
||||
result = runner.invoke(app, ["data", "scada", "help"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
commands = {command["command"] for command in payload["commands"]}
|
||||
assert "data scada get" in commands
|
||||
assert "data scada list" in commands
|
||||
assert "data scada schema" not in commands
|
||||
|
||||
|
||||
def test_data_scada_schema_command_is_removed():
|
||||
result = runner.invoke(app, ["data", "scada", "schema", "--kind", "info"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "No such command 'schema'" in result.output
|
||||
|
||||
|
||||
def test_data_help_no_longer_lists_extension_or_misc():
|
||||
result = runner.invoke(app, ["data", "help"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
commands = {command["command"] for command in payload["commands"]}
|
||||
assert "data timeseries" in commands
|
||||
assert "data scada" in commands
|
||||
assert "data scheme" in commands
|
||||
assert "data extension" not in commands
|
||||
assert "data misc" not in commands
|
||||
|
||||
|
||||
def test_removed_data_extension_and_misc_commands_fail():
|
||||
extension_result = runner.invoke(app, ["data", "extension", "list"])
|
||||
misc_result = runner.invoke(app, ["data", "misc", "sensor-placements"])
|
||||
|
||||
assert extension_result.exit_code == 2
|
||||
assert "No such command 'extension'" in extension_result.output
|
||||
assert misc_result.exit_code == 2
|
||||
assert "No such command 'misc'" in misc_result.output
|
||||
|
||||
|
||||
def test_main_bare_analysis_returns_typer_help_without_json_error(capsys):
|
||||
exit_code = main(["analysis"])
|
||||
stdout = capsys.readouterr().out
|
||||
|
||||
assert exit_code == 0
|
||||
assert "Usage: tjwater-cli analysis" in stdout
|
||||
assert "分析计算与诊断相关命令。" in stdout
|
||||
assert '"ok": false' not in stdout
|
||||
|
||||
|
||||
def test_simulation_run_translates_rfc3339(monkeypatch):
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
captured = {}
|
||||
|
||||
def fake_request(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return DummyResponse(json_data={"status": "success", "message": "Simulation started"})
|
||||
|
||||
monkeypatch.setattr(core.requests, "request", fake_request)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"simulation",
|
||||
"run",
|
||||
"--start-time",
|
||||
"2025-01-02T03:04:05+08:00",
|
||||
"--duration",
|
||||
"30",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert captured["json"] == {
|
||||
"start_time": "2025-01-02T03:04:05+08:00",
|
||||
"duration": 30,
|
||||
}
|
||||
assert "tjwater-cli data timeseries realtime links" in result.stdout
|
||||
assert "tjwater-cli data timeseries realtime nodes" in result.stdout
|
||||
|
||||
|
||||
def test_removed_project_command_returns_not_found(capsys):
|
||||
exit_code = main(["project", "list"])
|
||||
stdout = capsys.readouterr().out
|
||||
|
||||
assert exit_code == 2
|
||||
assert '"code": "COMMAND_NOT_FOUND"' in stdout or "No such command: project" in stdout
|
||||
@@ -1,45 +0,0 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
|
||||
from PyInstaller.utils.hooks import collect_data_files
|
||||
|
||||
|
||||
datas = collect_data_files("certifi")
|
||||
|
||||
|
||||
a = Analysis(
|
||||
["entrypoint.py"],
|
||||
pathex=["."],
|
||||
binaries=[],
|
||||
datas=datas,
|
||||
hiddenimports=[],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
noarchive=False,
|
||||
optimize=0,
|
||||
)
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name="tjwater-cli",
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
console=True,
|
||||
)
|
||||
|
||||
coll = COLLECT(
|
||||
exe,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
name="tjwater-cli",
|
||||
)
|
||||
@@ -1,3 +0,0 @@
|
||||
from .main import app, main
|
||||
|
||||
__all__ = ["app", "main"]
|
||||
@@ -1,5 +0,0 @@
|
||||
from .main import console_entry
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
console_entry()
|
||||
@@ -1,76 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typer
|
||||
|
||||
from .formatters import TJWaterGroup
|
||||
|
||||
app = typer.Typer(help="TJWater agent CLI", add_completion=False, no_args_is_help=True, cls=TJWaterGroup)
|
||||
network_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
component_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
component_option_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
simulation_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
analysis_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
analysis_leakage_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
analysis_leakage_schemes_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
analysis_burst_detection_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
analysis_burst_detection_schemes_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
analysis_burst_location_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
analysis_burst_location_schemes_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
analysis_risk_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
analysis_sensor_placement_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
data_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
data_timeseries_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
data_timeseries_realtime_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
data_timeseries_scheme_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
data_timeseries_scada_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
data_timeseries_composite_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
data_scada_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
data_scheme_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
|
||||
app.add_typer(network_app, name="network")
|
||||
app.add_typer(component_app, name="component")
|
||||
component_app.add_typer(component_option_app, name="option")
|
||||
app.add_typer(simulation_app, name="simulation")
|
||||
app.add_typer(analysis_app, name="analysis")
|
||||
analysis_app.add_typer(analysis_sensor_placement_app, name="sensor-placement")
|
||||
analysis_app.add_typer(analysis_leakage_app, name="leakage")
|
||||
analysis_leakage_app.add_typer(analysis_leakage_schemes_app, name="schemes")
|
||||
analysis_app.add_typer(analysis_burst_detection_app, name="burst-detection")
|
||||
analysis_burst_detection_app.add_typer(analysis_burst_detection_schemes_app, name="schemes")
|
||||
analysis_app.add_typer(analysis_burst_location_app, name="burst-location")
|
||||
analysis_burst_location_app.add_typer(analysis_burst_location_schemes_app, name="schemes")
|
||||
analysis_app.add_typer(analysis_risk_app, name="risk")
|
||||
app.add_typer(data_app, name="data")
|
||||
data_app.add_typer(data_timeseries_app, name="timeseries")
|
||||
data_timeseries_app.add_typer(data_timeseries_realtime_app, name="realtime")
|
||||
data_timeseries_app.add_typer(data_timeseries_scheme_app, name="scheme")
|
||||
data_timeseries_app.add_typer(data_timeseries_scada_app, name="scada")
|
||||
data_timeseries_app.add_typer(data_timeseries_composite_app, name="composite")
|
||||
data_app.add_typer(data_scada_app, name="scada")
|
||||
data_app.add_typer(data_scheme_app, name="scheme")
|
||||
|
||||
GROUP_HELP_APPS: list[tuple[typer.Typer, tuple[str, ...]]] = [
|
||||
(network_app, ("network",)),
|
||||
(component_app, ("component",)),
|
||||
(component_option_app, ("component", "option")),
|
||||
(simulation_app, ("simulation",)),
|
||||
(analysis_app, ("analysis",)),
|
||||
(analysis_sensor_placement_app, ("analysis", "sensor-placement")),
|
||||
(analysis_leakage_app, ("analysis", "leakage")),
|
||||
(analysis_leakage_schemes_app, ("analysis", "leakage", "schemes")),
|
||||
(analysis_burst_detection_app, ("analysis", "burst-detection")),
|
||||
(analysis_burst_detection_schemes_app, ("analysis", "burst-detection", "schemes")),
|
||||
(analysis_burst_location_app, ("analysis", "burst-location")),
|
||||
(analysis_burst_location_schemes_app, ("analysis", "burst-location", "schemes")),
|
||||
(analysis_risk_app, ("analysis", "risk")),
|
||||
(data_app, ("data",)),
|
||||
(data_timeseries_app, ("data", "timeseries")),
|
||||
(data_timeseries_realtime_app, ("data", "timeseries", "realtime")),
|
||||
(data_timeseries_scheme_app, ("data", "timeseries", "scheme")),
|
||||
(data_timeseries_scada_app, ("data", "timeseries", "scada")),
|
||||
(data_timeseries_composite_app, ("data", "timeseries", "composite")),
|
||||
(data_scada_app, ("data", "scada")),
|
||||
(data_scheme_app, ("data", "scheme")),
|
||||
]
|
||||
|
||||
TOP_LEVEL_COMMANDS = {"help", "network", "component", "simulation", "analysis", "data"}
|
||||
@@ -1,496 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
|
||||
from .apps import (
|
||||
analysis_app,
|
||||
analysis_burst_detection_app,
|
||||
analysis_burst_detection_schemes_app,
|
||||
analysis_burst_location_app,
|
||||
analysis_burst_location_schemes_app,
|
||||
analysis_leakage_app,
|
||||
analysis_leakage_schemes_app,
|
||||
analysis_risk_app,
|
||||
analysis_sensor_placement_app,
|
||||
simulation_app,
|
||||
)
|
||||
from .common import emit_api, runtime_context
|
||||
from .core import (
|
||||
CLIError,
|
||||
emit_success,
|
||||
parse_burst_file,
|
||||
parse_optional_dataset_file,
|
||||
parse_time_with_timezone,
|
||||
parse_valve_setting_file,
|
||||
request_json,
|
||||
resolve_scheme,
|
||||
)
|
||||
from .option_types import DataSource, ValveMode
|
||||
|
||||
|
||||
@simulation_app.command("run")
|
||||
def simulation_run(
|
||||
ctx: typer.Context,
|
||||
start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")],
|
||||
duration: Annotated[int, typer.Option("--duration", help="持续分钟数")],
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
parsed = parse_time_with_timezone(start_time, option_name="--start-time")
|
||||
end_time = (parsed + timedelta(minutes=duration)).isoformat()
|
||||
body = {
|
||||
"start_time": parsed.replace(microsecond=0).isoformat(),
|
||||
"duration": duration,
|
||||
}
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="触发模拟成功",
|
||||
method="POST",
|
||||
path="/simulation-runs",
|
||||
json_body=body,
|
||||
require_auth=True,
|
||||
next_commands=[
|
||||
f"tjwater-cli data timeseries realtime links --start-time {parsed.isoformat()} --end-time {end_time}",
|
||||
f"tjwater-cli data timeseries realtime nodes --start-time {parsed.isoformat()} --end-time {end_time}",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@analysis_app.command("burst")
|
||||
def analysis_burst(
|
||||
ctx: typer.Context,
|
||||
start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")],
|
||||
duration: Annotated[int, typer.Option("--duration", help="持续秒数")],
|
||||
burst_file: Annotated[Path, typer.Option("--burst-file", help="爆管输入 JSON 文件")],
|
||||
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
ids, sizes = parse_burst_file(burst_file)
|
||||
scheme_name = resolve_scheme(runtime, scheme, required=True)
|
||||
params = {
|
||||
"modify_pattern_start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
|
||||
"burst_id": ids,
|
||||
"burst_size": sizes,
|
||||
"modify_total_duration": duration,
|
||||
"scheme_name": scheme_name,
|
||||
}
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="爆管分析执行成功",
|
||||
method="POST",
|
||||
path="/burst-analyses",
|
||||
params=params,
|
||||
require_auth=True,
|
||||
next_commands=[
|
||||
f"tjwater-cli data scheme get --name {scheme_name}",
|
||||
"tjwater-cli data scheme list",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@analysis_app.command("valve")
|
||||
def analysis_valve(
|
||||
ctx: typer.Context,
|
||||
mode: Annotated[ValveMode, typer.Option("--mode", help="分析模式,仅支持 close|isolation")],
|
||||
start_time: Annotated[str | None, typer.Option("--start-time", help="close 模式需要")] = None,
|
||||
valve: Annotated[list[str] | None, typer.Option("--valve", help="阀门 ID,可重复")] = None,
|
||||
element: Annotated[list[str] | None, typer.Option("--element", help="isolation 模式的事故元素,可重复")] = None,
|
||||
disabled_valve: Annotated[list[str] | None, typer.Option("--disabled-valve", help="故障阀门,可重复")] = None,
|
||||
duration: Annotated[int | None, typer.Option("--duration", help="close 模式持续秒数")] = None,
|
||||
scheme: Annotated[str | None, typer.Option("--scheme", help="close 模式的方案名称")] = None,
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
if mode == ValveMode.CLOSE:
|
||||
if not start_time or not valve:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
code="INVALID_VALVE_CLOSE_ARGS",
|
||||
message="close mode requires --start-time and at least one --valve",
|
||||
exit_code=2,
|
||||
)
|
||||
params = {
|
||||
"start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
|
||||
"valves": valve,
|
||||
"duration": duration or 900,
|
||||
"scheme_name": resolve_scheme(runtime, scheme, required=True),
|
||||
}
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="阀门关闭分析执行成功",
|
||||
method="POST",
|
||||
path="/valve-isolation-analyses",
|
||||
params=params,
|
||||
require_auth=True,
|
||||
)
|
||||
return
|
||||
if mode == ValveMode.ISOLATION:
|
||||
if not element:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
code="INVALID_VALVE_ISOLATION_ARGS",
|
||||
message="isolation mode requires at least one --element",
|
||||
exit_code=2,
|
||||
)
|
||||
params = {"accident_element": element}
|
||||
if disabled_valve:
|
||||
params["disabled_valves"] = disabled_valve
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="阀门隔离分析执行成功",
|
||||
method="POST",
|
||||
path="/valve-isolation-analyses",
|
||||
params=params,
|
||||
require_auth=True,
|
||||
)
|
||||
return
|
||||
raise AssertionError(f"unreachable valve mode: {mode}")
|
||||
|
||||
|
||||
@analysis_app.command("flushing")
|
||||
def analysis_flushing(
|
||||
ctx: typer.Context,
|
||||
start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")],
|
||||
valve_setting_file: Annotated[Path, typer.Option("--valve-setting-file", help="阀门开度 JSON 文件")],
|
||||
drainage_node: Annotated[str, typer.Option("--drainage-node", help="排污节点")],
|
||||
flow: Annotated[float, typer.Option("--flow", help="冲洗流量")],
|
||||
duration: Annotated[int | None, typer.Option("--duration", help="持续秒数")] = None,
|
||||
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
valves, openings = parse_valve_setting_file(valve_setting_file)
|
||||
params = {
|
||||
"start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
|
||||
"valves": valves,
|
||||
"valves_k": openings,
|
||||
"drainage_node_id": drainage_node,
|
||||
"flush_flow": flow,
|
||||
"duration": duration or 900,
|
||||
"scheme_name": resolve_scheme(runtime, scheme, required=True),
|
||||
}
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="冲洗分析执行成功",
|
||||
method="POST",
|
||||
path="/flushing-analyses",
|
||||
params=params,
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@analysis_app.command("age")
|
||||
def analysis_age(
|
||||
ctx: typer.Context,
|
||||
start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")],
|
||||
duration: Annotated[int, typer.Option("--duration", help="持续秒数")],
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="水龄分析执行成功",
|
||||
method="POST",
|
||||
path="/water-age-analyses",
|
||||
params={
|
||||
"start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
|
||||
"duration": duration,
|
||||
},
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@analysis_app.command("contaminant")
|
||||
def analysis_contaminant(
|
||||
ctx: typer.Context,
|
||||
start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")],
|
||||
duration: Annotated[int, typer.Option("--duration", help="持续秒数")],
|
||||
source_node: Annotated[str, typer.Option("--source-node", help="污染源节点")],
|
||||
concentration: Annotated[float, typer.Option("--concentration", help="浓度")],
|
||||
pattern: Annotated[str | None, typer.Option("--pattern", help="模式 ID")] = None,
|
||||
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
params = {
|
||||
"start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
|
||||
"source": source_node,
|
||||
"concentration": concentration,
|
||||
"duration": duration,
|
||||
"scheme_name": resolve_scheme(runtime, scheme, required=True),
|
||||
}
|
||||
if pattern:
|
||||
params["pattern"] = pattern
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="污染物模拟执行成功",
|
||||
method="POST",
|
||||
path="/contaminant-simulations",
|
||||
params=params,
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@analysis_sensor_placement_app.command("kmeans")
|
||||
def analysis_sensor_placement_kmeans(
|
||||
ctx: typer.Context,
|
||||
count: Annotated[int, typer.Option("--count", help="传感器数量")],
|
||||
min_diameter: Annotated[int, typer.Option("--min-diameter", help="最小管径")] = 0,
|
||||
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
body = {
|
||||
"scheme_name": resolve_scheme(runtime, scheme, required=True),
|
||||
"sensor_number": count,
|
||||
"min_diameter": min_diameter,
|
||||
}
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="传感器选址执行成功",
|
||||
method="POST",
|
||||
path="/pressure-sensor-placement-kmeans",
|
||||
json_body=body,
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@analysis_leakage_app.command("identify")
|
||||
def analysis_leakage_identify(
|
||||
ctx: typer.Context,
|
||||
start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")],
|
||||
end_time: Annotated[str, typer.Option("--end-time", help="RFC3339 结束时间")],
|
||||
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
body = {
|
||||
"scada_start": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
|
||||
"scada_end": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
|
||||
"scheme_name": resolve_scheme(runtime, scheme, required=True),
|
||||
}
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="漏损识别执行成功",
|
||||
method="POST",
|
||||
path="/leakage-identifications",
|
||||
json_body=body,
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@analysis_leakage_schemes_app.command("list")
|
||||
def analysis_leakage_schemes_list(ctx: typer.Context) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取漏损方案列表成功",
|
||||
method="GET",
|
||||
path="/schemes",
|
||||
params={
|
||||
"scheme_type": "dma_leak_identification",
|
||||
},
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@analysis_leakage_schemes_app.command("get")
|
||||
def analysis_leakage_schemes_get(
|
||||
ctx: typer.Context,
|
||||
scheme_name: Annotated[str, typer.Argument(help="方案名称")],
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取漏损方案详情成功",
|
||||
method="GET",
|
||||
path=f"/schemes/{scheme_name}",
|
||||
params={
|
||||
"scheme_type": "dma_leak_identification",
|
||||
},
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@analysis_burst_detection_app.command("detect")
|
||||
def analysis_burst_detection_detect(
|
||||
ctx: typer.Context,
|
||||
start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")],
|
||||
end_time: Annotated[str, typer.Option("--end-time", help="RFC3339 结束时间")],
|
||||
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
body = {
|
||||
"scada_start": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
|
||||
"scada_end": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
|
||||
"scheme_name": resolve_scheme(runtime, scheme, required=True),
|
||||
}
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="爆管检测执行成功",
|
||||
method="POST",
|
||||
path="/burst-detections",
|
||||
json_body=body,
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@analysis_burst_detection_schemes_app.command("list")
|
||||
def analysis_burst_detection_schemes_list(ctx: typer.Context) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取爆管检测方案列表成功",
|
||||
method="GET",
|
||||
path="/schemes",
|
||||
params={
|
||||
"scheme_type": "burst_detection",
|
||||
},
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@analysis_burst_detection_schemes_app.command("get")
|
||||
def analysis_burst_detection_schemes_get(
|
||||
ctx: typer.Context,
|
||||
scheme_name: Annotated[str, typer.Argument(help="方案名称")],
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取爆管检测方案详情成功",
|
||||
method="GET",
|
||||
path=f"/schemes/{scheme_name}",
|
||||
params={
|
||||
"scheme_type": "burst_detection",
|
||||
},
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@analysis_burst_location_app.command("locate")
|
||||
def analysis_burst_location_locate(
|
||||
ctx: typer.Context,
|
||||
start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")],
|
||||
end_time: Annotated[str, typer.Option("--end-time", help="RFC3339 结束时间")],
|
||||
burst_leakage: Annotated[float, typer.Option("--burst-leakage", help="爆管漏水量")],
|
||||
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
|
||||
data_source: Annotated[DataSource, typer.Option("--data-source", help="数据来源,仅支持 monitoring|simulation")] = DataSource.MONITORING,
|
||||
pressure_scada_id: Annotated[list[str] | None, typer.Option("--pressure-scada-id", help="压力 SCADA ID,可重复")] = None,
|
||||
flow_scada_id: Annotated[list[str] | None, typer.Option("--flow-scada-id", help="流量 SCADA ID,可重复")] = None,
|
||||
pressure_file: Annotated[Path | None, typer.Option("--pressure-file", help="包含 burst_pressure/normal_pressure 的 JSON 文件")] = None,
|
||||
flow_file: Annotated[Path | None, typer.Option("--flow-file", help="包含 burst_flow/normal_flow 的 JSON 文件")] = None,
|
||||
use_scada_flow: Annotated[bool, typer.Option("--use-scada-flow", help="启用 SCADA 流量")] = False,
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
pressure_payload = parse_optional_dataset_file(pressure_file, label="pressure") or {}
|
||||
flow_payload = parse_optional_dataset_file(flow_file, label="flow") or {}
|
||||
body = {
|
||||
"scheme_name": resolve_scheme(runtime, scheme, required=True),
|
||||
"data_source": data_source.value,
|
||||
"scada_burst_start": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
|
||||
"scada_burst_end": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
|
||||
"burst_leakage": burst_leakage,
|
||||
"use_scada_flow": use_scada_flow,
|
||||
}
|
||||
if pressure_scada_id:
|
||||
body["pressure_scada_ids"] = pressure_scada_id
|
||||
if flow_scada_id:
|
||||
body["flow_scada_ids"] = flow_scada_id
|
||||
if isinstance(pressure_payload, dict):
|
||||
body.update({key: value for key, value in pressure_payload.items() if key in {"burst_pressure", "normal_pressure"}})
|
||||
if isinstance(flow_payload, dict):
|
||||
body.update({key: value for key, value in flow_payload.items() if key in {"burst_flow", "normal_flow"}})
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="爆管定位执行成功",
|
||||
method="POST",
|
||||
path="/burst-locations",
|
||||
json_body=body,
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@analysis_burst_location_schemes_app.command("list")
|
||||
def analysis_burst_location_schemes_list(ctx: typer.Context) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取爆管定位方案列表成功",
|
||||
method="GET",
|
||||
path="/schemes",
|
||||
params={
|
||||
"scheme_type": "burst_location",
|
||||
},
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@analysis_burst_location_schemes_app.command("get")
|
||||
def analysis_burst_location_schemes_get(
|
||||
ctx: typer.Context,
|
||||
scheme_name: Annotated[str, typer.Argument(help="方案名称")],
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取爆管定位方案详情成功",
|
||||
method="GET",
|
||||
path=f"/schemes/{scheme_name}",
|
||||
params={
|
||||
"scheme_type": "burst_location",
|
||||
},
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@analysis_risk_app.command("pipe-now")
|
||||
def analysis_risk_pipe_now(
|
||||
ctx: typer.Context,
|
||||
pipe: Annotated[str, typer.Option("--pipe", help="管道 ID")],
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取当前管道风险成功",
|
||||
method="GET",
|
||||
path="/pipes/risk-probability-now",
|
||||
params={"pipe_id": pipe},
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@analysis_risk_app.command("pipe-history")
|
||||
def analysis_risk_pipe_history(
|
||||
ctx: typer.Context,
|
||||
pipe: Annotated[str, typer.Option("--pipe", help="管道 ID")],
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取历史管道风险成功",
|
||||
method="GET",
|
||||
path="/pipes/risk-probability",
|
||||
params={"pipe_id": pipe},
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@analysis_risk_app.command("network")
|
||||
def analysis_risk_network(ctx: typer.Context) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
probabilities, duration_prob = request_json(
|
||||
runtime,
|
||||
method="GET",
|
||||
path="/network-pipe-risk-probability-nows",
|
||||
require_auth=True,
|
||||
)
|
||||
geometries, duration_geo = request_json(
|
||||
runtime,
|
||||
method="GET",
|
||||
path="/pipes/risk-probability-geometries",
|
||||
require_auth=True,
|
||||
)
|
||||
emit_success(
|
||||
summary="读取全网风险成功",
|
||||
data={"probabilities": probabilities, "geometries": geometries},
|
||||
ctx=runtime,
|
||||
duration_ms=duration_prob + duration_geo,
|
||||
)
|
||||
@@ -1,498 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
|
||||
from .apps import (
|
||||
data_scada_app,
|
||||
data_scheme_app,
|
||||
data_timeseries_composite_app,
|
||||
data_timeseries_realtime_app,
|
||||
data_timeseries_scada_app,
|
||||
data_timeseries_scheme_app,
|
||||
)
|
||||
from .common import emit_api, runtime_context
|
||||
from .core import CLIError, parse_time_with_timezone, resolve_scheme
|
||||
from .option_types import (
|
||||
CompositeKind,
|
||||
ElementType,
|
||||
JUNCTION_TIMESERIES_FIELDS,
|
||||
SCADA_TIMESERIES_FIELDS,
|
||||
ScadaListKind,
|
||||
SimulationQuery,
|
||||
timeseries_fields_for_element_type,
|
||||
)
|
||||
|
||||
|
||||
def _scheme_type_option(scheme_type: str | None) -> str:
|
||||
return scheme_type or "simulation"
|
||||
|
||||
|
||||
def _validate_element_property(element_type: ElementType, property_name: str, *, option_name: str) -> str:
|
||||
valid_fields = timeseries_fields_for_element_type(element_type)
|
||||
if property_name not in valid_fields:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
code="INVALID_PROPERTY",
|
||||
message=f"{option_name} for --type {element_type.value} must be one of: {', '.join(valid_fields)}",
|
||||
exit_code=2,
|
||||
)
|
||||
return property_name
|
||||
|
||||
|
||||
def _validate_node_field(field_name: str, *, option_name: str) -> str:
|
||||
if field_name not in JUNCTION_TIMESERIES_FIELDS:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
code="INVALID_FIELD",
|
||||
message=f"{option_name} must be one of: {', '.join(JUNCTION_TIMESERIES_FIELDS)}",
|
||||
exit_code=2,
|
||||
)
|
||||
return field_name
|
||||
|
||||
|
||||
def _validate_scada_field(field_name: str, *, option_name: str) -> str:
|
||||
if field_name not in SCADA_TIMESERIES_FIELDS:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
code="INVALID_FIELD",
|
||||
message=f"{option_name} must be one of: {', '.join(SCADA_TIMESERIES_FIELDS)}",
|
||||
exit_code=2,
|
||||
)
|
||||
return field_name
|
||||
|
||||
|
||||
@data_timeseries_realtime_app.command("links")
|
||||
def data_realtime_links(
|
||||
ctx: typer.Context,
|
||||
start_time: Annotated[str, typer.Option("--start-time", help="开始时间")],
|
||||
end_time: Annotated[str, typer.Option("--end-time", help="结束时间")],
|
||||
) -> None:
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取实时管道数据成功",
|
||||
method="GET",
|
||||
path="/timeseries/realtime/links",
|
||||
params={
|
||||
"start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
|
||||
"end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
|
||||
},
|
||||
require_auth=True,
|
||||
require_project=True,
|
||||
)
|
||||
|
||||
|
||||
@data_timeseries_realtime_app.command("nodes")
|
||||
def data_realtime_nodes(
|
||||
ctx: typer.Context,
|
||||
start_time: Annotated[str, typer.Option("--start-time", help="开始时间")],
|
||||
end_time: Annotated[str, typer.Option("--end-time", help="结束时间")],
|
||||
) -> None:
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取实时节点数据成功",
|
||||
method="GET",
|
||||
path="/timeseries/realtime/nodes",
|
||||
params={
|
||||
"start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
|
||||
"end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
|
||||
},
|
||||
require_auth=True,
|
||||
require_project=True,
|
||||
)
|
||||
|
||||
|
||||
@data_timeseries_realtime_app.command("simulation-by-id-time")
|
||||
def data_realtime_simulation_by_id_time(
|
||||
ctx: typer.Context,
|
||||
id: Annotated[str, typer.Option("--id", help="元素 ID")],
|
||||
type: Annotated[ElementType, typer.Option("--type", help="元素类型,仅支持 pipe|junction;links/nodes 是子命令")],
|
||||
time: Annotated[str, typer.Option("--time", help="查询时间")],
|
||||
) -> None:
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取实时模拟数据成功",
|
||||
method="GET",
|
||||
path="/timeseries/realtime/simulation-results",
|
||||
params={
|
||||
"id": id,
|
||||
"type": type.value,
|
||||
"query_time": parse_time_with_timezone(time, option_name="--time").isoformat(),
|
||||
},
|
||||
require_auth=True,
|
||||
require_project=True,
|
||||
)
|
||||
|
||||
|
||||
@data_timeseries_realtime_app.command("simulation-by-time-property")
|
||||
def data_realtime_simulation_by_time_property(
|
||||
ctx: typer.Context,
|
||||
type: Annotated[ElementType, typer.Option("--type", help="元素类型,仅支持 pipe|junction;links/nodes 是子命令")],
|
||||
time: Annotated[str, typer.Option("--time", help="查询时间")],
|
||||
property: Annotated[str, typer.Option("--property", help="属性名;pipe: flow|friction|headloss|quality|reaction|setting|status|velocity;junction: actual_demand|total_head|pressure|quality")],
|
||||
) -> None:
|
||||
property = _validate_element_property(type, property, option_name="--property")
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取实时属性聚合数据成功",
|
||||
method="GET",
|
||||
path="/timeseries/realtime/records",
|
||||
params={
|
||||
"type": type.value,
|
||||
"query_time": parse_time_with_timezone(time, option_name="--time").isoformat(),
|
||||
"property": property,
|
||||
},
|
||||
require_auth=True,
|
||||
require_project=True,
|
||||
)
|
||||
|
||||
|
||||
@data_timeseries_scheme_app.command("links")
|
||||
def data_scheme_links(
|
||||
ctx: typer.Context,
|
||||
start_time: Annotated[str, typer.Option("--start-time", help="开始时间")],
|
||||
end_time: Annotated[str, typer.Option("--end-time", help="结束时间")],
|
||||
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
|
||||
scheme_type: Annotated[str | None, typer.Option("--scheme-type", help="方案类型")] = None,
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取方案管道数据成功",
|
||||
method="GET",
|
||||
path="/timeseries/schemes/links",
|
||||
params={
|
||||
"scheme_name": resolve_scheme(runtime, scheme, required=True),
|
||||
"scheme_type": _scheme_type_option(scheme_type),
|
||||
"start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
|
||||
"end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
|
||||
},
|
||||
require_auth=True,
|
||||
require_project=True,
|
||||
)
|
||||
|
||||
|
||||
@data_timeseries_scheme_app.command("node-field")
|
||||
def data_scheme_node_field(
|
||||
ctx: typer.Context,
|
||||
node: Annotated[str, typer.Option("--node", help="节点 ID")],
|
||||
field: Annotated[str, typer.Option("--field", help="字段名,仅支持 actual_demand|total_head|pressure|quality")],
|
||||
start_time: Annotated[str, typer.Option("--start-time", help="开始时间")],
|
||||
end_time: Annotated[str, typer.Option("--end-time", help="结束时间")],
|
||||
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
|
||||
scheme_type: Annotated[str | None, typer.Option("--scheme-type", help="方案类型")] = None,
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
field = _validate_node_field(field, option_name="--field")
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取方案节点字段成功",
|
||||
method="GET",
|
||||
path=f"/timeseries/schemes/nodes/{node}/field",
|
||||
params={
|
||||
"field": field,
|
||||
"scheme_name": resolve_scheme(runtime, scheme, required=True),
|
||||
"scheme_type": _scheme_type_option(scheme_type),
|
||||
"start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
|
||||
"end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
|
||||
},
|
||||
require_auth=True,
|
||||
require_project=True,
|
||||
)
|
||||
|
||||
|
||||
@data_timeseries_scheme_app.command("simulation")
|
||||
def data_scheme_simulation(
|
||||
ctx: typer.Context,
|
||||
query: Annotated[SimulationQuery, typer.Option("--query", help="查询模式,仅支持 by-id-time|by-scheme-time-property")],
|
||||
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
|
||||
scheme_type: Annotated[str | None, typer.Option("--scheme-type", help="方案类型")] = None,
|
||||
id: Annotated[str | None, typer.Option("--id", help="元素 ID")] = None,
|
||||
time: Annotated[str, typer.Option("--time", help="查询时间")] = "",
|
||||
type: Annotated[ElementType, typer.Option("--type", help="元素类型,仅支持 pipe|junction;links/nodes 是子命令")] = ElementType.PIPE,
|
||||
property: Annotated[str | None, typer.Option("--property", help="属性名;pipe: flow|friction|headloss|quality|reaction|setting|status|velocity;junction: actual_demand|total_head|pressure|quality")] = None,
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
params = {
|
||||
"scheme_name": resolve_scheme(runtime, scheme, required=True),
|
||||
"scheme_type": _scheme_type_option(scheme_type),
|
||||
"query_time": parse_time_with_timezone(time, option_name="--time").isoformat(),
|
||||
"type": type.value,
|
||||
}
|
||||
if query == SimulationQuery.BY_ID_TIME:
|
||||
if not id:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
code="ID_REQUIRED",
|
||||
message="--id is required for --query by-id-time",
|
||||
exit_code=2,
|
||||
)
|
||||
params["id"] = id
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取方案单点模拟数据成功",
|
||||
method="GET",
|
||||
path="/timeseries/schemes/simulation-results",
|
||||
params=params,
|
||||
require_auth=True,
|
||||
require_project=True,
|
||||
)
|
||||
return
|
||||
if query == SimulationQuery.BY_SCHEME_TIME_PROPERTY:
|
||||
if not property:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
code="PROPERTY_REQUIRED",
|
||||
message="--property is required for --query by-scheme-time-property",
|
||||
exit_code=2,
|
||||
)
|
||||
property = _validate_element_property(type, property, option_name="--property")
|
||||
params["property"] = property
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取方案属性聚合数据成功",
|
||||
method="GET",
|
||||
path="/timeseries/schemes/records",
|
||||
params=params,
|
||||
require_auth=True,
|
||||
require_project=True,
|
||||
)
|
||||
return
|
||||
raise AssertionError(f"unreachable query variant: {query}")
|
||||
|
||||
|
||||
@data_timeseries_scada_app.command("query")
|
||||
def data_scada_query(
|
||||
ctx: typer.Context,
|
||||
device_id: Annotated[list[str], typer.Option("--device-id", help="设备 ID,可重复")],
|
||||
start_time: Annotated[str, typer.Option("--start-time", help="开始时间")],
|
||||
end_time: Annotated[str, typer.Option("--end-time", help="结束时间")],
|
||||
field: Annotated[str | None, typer.Option("--field", help="字段名,仅支持 monitored_value|cleaned_value")] = None,
|
||||
) -> None:
|
||||
path = "/timeseries/scada-readings/fields" if field else "/timeseries/scada-readings"
|
||||
params = {
|
||||
"device_ids": ",".join(device_id),
|
||||
"start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
|
||||
"end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
|
||||
}
|
||||
if field:
|
||||
field = _validate_scada_field(field, option_name="--field")
|
||||
params["field"] = field
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取 SCADA 时序成功",
|
||||
method="GET",
|
||||
path=path,
|
||||
params=params,
|
||||
require_auth=True,
|
||||
require_project=True,
|
||||
)
|
||||
|
||||
|
||||
@data_timeseries_composite_app.callback(invoke_without_command=True)
|
||||
def data_timeseries_composite(
|
||||
ctx: typer.Context,
|
||||
kind: Annotated[CompositeKind | None, typer.Option("--kind", help="复合查询类型,仅支持 scada-simulation|element-simulation|element-scada")] = None,
|
||||
feature: Annotated[list[str] | None, typer.Option("--feature", help="特征值,可重复")] = None,
|
||||
start_time: Annotated[str | None, typer.Option("--start-time", help="开始时间")] = None,
|
||||
end_time: Annotated[str | None, typer.Option("--end-time", help="结束时间")] = None,
|
||||
pipe: Annotated[str | None, typer.Option("--pipe", help="pipeline-health 用管道 ID")] = None,
|
||||
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
|
||||
scheme_type: Annotated[str | None, typer.Option("--scheme-type", help="方案类型")] = None,
|
||||
use_cleaned: Annotated[bool, typer.Option("--use-cleaned", help="element-scada 使用清洗值")] = False,
|
||||
) -> None:
|
||||
_ = pipe
|
||||
if ctx.invoked_subcommand is not None:
|
||||
return
|
||||
if not kind or not start_time or not end_time:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
code="INVALID_COMPOSITE_ARGS",
|
||||
message="composite query requires --kind, --start-time, and --end-time",
|
||||
exit_code=2,
|
||||
)
|
||||
runtime = runtime_context(ctx)
|
||||
params = {
|
||||
"start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
|
||||
"end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
|
||||
}
|
||||
if kind == CompositeKind.SCADA_SIMULATION:
|
||||
if not feature:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
code="FEATURE_REQUIRED",
|
||||
message="--feature is required for scada-simulation",
|
||||
exit_code=2,
|
||||
)
|
||||
params["device_ids"] = ",".join(feature)
|
||||
scheme_name = resolve_scheme(runtime, scheme)
|
||||
if scheme_name:
|
||||
params["scheme_name"] = scheme_name
|
||||
params["scheme_type"] = _scheme_type_option(scheme_type)
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取复合 SCADA-模拟数据成功",
|
||||
method="GET",
|
||||
path="/timeseries/views/scada-simulations",
|
||||
params=params,
|
||||
require_auth=True,
|
||||
require_project=True,
|
||||
)
|
||||
return
|
||||
if kind == CompositeKind.ELEMENT_SIMULATION:
|
||||
if not feature:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
code="FEATURE_REQUIRED",
|
||||
message="--feature is required for element-simulation",
|
||||
exit_code=2,
|
||||
)
|
||||
params["feature_infos"] = ",".join(feature)
|
||||
scheme_name = resolve_scheme(runtime, scheme)
|
||||
if scheme_name:
|
||||
params["scheme_name"] = scheme_name
|
||||
params["scheme_type"] = _scheme_type_option(scheme_type)
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取复合元素模拟数据成功",
|
||||
method="GET",
|
||||
path="/timeseries/views/element-simulations",
|
||||
params=params,
|
||||
require_auth=True,
|
||||
require_project=True,
|
||||
)
|
||||
return
|
||||
if kind == CompositeKind.ELEMENT_SCADA:
|
||||
if not feature or len(feature) != 1:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
code="FEATURE_REQUIRED",
|
||||
message="element-scada requires exactly one --feature as element_id",
|
||||
exit_code=2,
|
||||
)
|
||||
params["element_id"] = feature[0]
|
||||
params["use_cleaned"] = use_cleaned
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取元素关联 SCADA 数据成功",
|
||||
method="GET",
|
||||
path="/timeseries/views/element-scada-readings",
|
||||
params=params,
|
||||
require_auth=True,
|
||||
require_project=True,
|
||||
)
|
||||
return
|
||||
raise AssertionError(f"unreachable composite kind: {kind}")
|
||||
|
||||
|
||||
@data_timeseries_composite_app.command("pipeline-health")
|
||||
def data_composite_pipeline_health(
|
||||
ctx: typer.Context,
|
||||
pipe: Annotated[str, typer.Option("--pipe", help="管道 ID")],
|
||||
start_time: Annotated[str, typer.Option("--start-time", help="开始时间")],
|
||||
end_time: Annotated[str, typer.Option("--end-time", help="结束时间")],
|
||||
) -> None:
|
||||
_ = pipe, start_time
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取管道健康预测成功",
|
||||
method="GET",
|
||||
path="/pipeline-health-predictions",
|
||||
params={
|
||||
"query_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
|
||||
},
|
||||
require_auth=True,
|
||||
require_project=True,
|
||||
)
|
||||
|
||||
|
||||
def _scada_mapping(kind: str, action: str) -> tuple[str, dict[str, str]]:
|
||||
mapping = {
|
||||
("info", "get"): ("/scada-info/detail", {"id_param": "id"}),
|
||||
("info", "list"): ("/scada-info", {}),
|
||||
}
|
||||
result = mapping.get((kind, action))
|
||||
if result is None:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
code="INVALID_SCADA_KIND",
|
||||
message=f"unsupported scada {action} kind: {kind}",
|
||||
exit_code=2,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@data_scada_app.command("get")
|
||||
def data_scada_get(
|
||||
ctx: typer.Context,
|
||||
kind: Annotated[ScadaListKind, typer.Option("--kind", help="SCADA 类型,仅支持 info")],
|
||||
id: Annotated[str, typer.Option("--id", help="记录 ID")],
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
path, meta = _scada_mapping(kind.value, "get")
|
||||
params = {meta["id_param"]: id}
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取 SCADA 数据成功",
|
||||
method="GET",
|
||||
path=path,
|
||||
params=params,
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@data_scada_app.command("list")
|
||||
def data_scada_list(
|
||||
ctx: typer.Context,
|
||||
kind: Annotated[ScadaListKind, typer.Option("--kind", help="SCADA 类型,仅支持 info")],
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
path, _ = _scada_mapping(kind.value, "list")
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取 SCADA 列表成功",
|
||||
method="GET",
|
||||
path=path,
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@data_scheme_app.command("schema")
|
||||
def data_scheme_schema(ctx: typer.Context) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取方案 schema 成功",
|
||||
method="GET",
|
||||
path="/network-schemas/scheme",
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@data_scheme_app.command("get")
|
||||
def data_scheme_get(
|
||||
ctx: typer.Context,
|
||||
name: Annotated[str, typer.Option("--name", help="方案名称")],
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取方案成功",
|
||||
method="GET",
|
||||
path="/schemes/detail",
|
||||
params={"schema_name": name},
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@data_scheme_app.command("list")
|
||||
def data_scheme_list(ctx: typer.Context) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取方案列表成功",
|
||||
method="GET",
|
||||
path="/schemes",
|
||||
require_auth=True,
|
||||
)
|
||||
@@ -1,229 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
|
||||
from .apps import component_option_app, network_app
|
||||
from .common import emit_api
|
||||
from .core import CLIError
|
||||
from .option_types import ComponentOptionKind
|
||||
|
||||
|
||||
@network_app.command("get-junction-properties")
|
||||
def network_get_junction_properties(
|
||||
ctx: typer.Context,
|
||||
junction: Annotated[str, typer.Option("--junction", help="节点 ID")],
|
||||
) -> None:
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取节点属性成功",
|
||||
method="GET",
|
||||
path="/junctions/properties",
|
||||
params={"junction": junction},
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@network_app.command("get-pipe-properties")
|
||||
def network_get_pipe_properties(
|
||||
ctx: typer.Context,
|
||||
pipe: Annotated[str, typer.Option("--pipe", help="管道 ID")],
|
||||
) -> None:
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取管道属性成功",
|
||||
method="GET",
|
||||
path="/pipes/properties",
|
||||
params={"pipe": pipe},
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@network_app.command("get-all-pipes-properties")
|
||||
def network_get_all_pipes_properties(ctx: typer.Context) -> None:
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取全部管道属性成功",
|
||||
method="GET",
|
||||
path="/pipes",
|
||||
params={},
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@network_app.command("get-reservoir-properties")
|
||||
def network_get_reservoir_properties(
|
||||
ctx: typer.Context,
|
||||
reservoir: Annotated[str, typer.Option("--reservoir", help="水库 ID")],
|
||||
) -> None:
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取水库属性成功",
|
||||
method="GET",
|
||||
path="/reservoirs/properties",
|
||||
params={"reservoir": reservoir},
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@network_app.command("get-all-reservoirs-properties")
|
||||
def network_get_all_reservoir_properties(ctx: typer.Context) -> None:
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取全部水库属性成功",
|
||||
method="GET",
|
||||
path="/reservoirs",
|
||||
params={},
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@network_app.command("get-tank-properties")
|
||||
def network_get_tank_properties(
|
||||
ctx: typer.Context,
|
||||
tank: Annotated[str, typer.Option("--tank", help="水箱 ID")],
|
||||
) -> None:
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取水箱属性成功",
|
||||
method="GET",
|
||||
path="/tanks/properties",
|
||||
params={"tank": tank},
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@network_app.command("get-all-tanks-properties")
|
||||
def network_get_all_tank_properties(ctx: typer.Context) -> None:
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取全部水箱属性成功",
|
||||
method="GET",
|
||||
path="/tanks",
|
||||
params={},
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@network_app.command("get-pump-properties")
|
||||
def network_get_pump_properties(
|
||||
ctx: typer.Context,
|
||||
pump: Annotated[str, typer.Option("--pump", help="水泵 ID")],
|
||||
) -> None:
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取水泵属性成功",
|
||||
method="GET",
|
||||
path="/pumps/properties",
|
||||
params={"pump": pump},
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@network_app.command("get-all-pumps-properties")
|
||||
def network_get_all_pump_properties(ctx: typer.Context) -> None:
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取全部水泵属性成功",
|
||||
method="GET",
|
||||
path="/pumps",
|
||||
params={},
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@network_app.command("get-valve-properties")
|
||||
def network_get_valve_properties(
|
||||
ctx: typer.Context,
|
||||
valve: Annotated[str, typer.Option("--valve", help="阀门 ID")],
|
||||
) -> None:
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取阀门属性成功",
|
||||
method="GET",
|
||||
path="/valves/properties",
|
||||
params={"valve": valve},
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@network_app.command("get-all-valves-properties")
|
||||
def network_get_all_valve_properties(ctx: typer.Context) -> None:
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取全部阀门属性成功",
|
||||
method="GET",
|
||||
path="/valves",
|
||||
params={},
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@component_option_app.command("schema")
|
||||
def component_option_schema(
|
||||
ctx: typer.Context,
|
||||
kind: Annotated[ComponentOptionKind, typer.Option("--kind", help="选项类型,仅支持 time|energy|pump-energy|network")],
|
||||
pump: Annotated[str | None, typer.Option("--pump", help="pump-energy 时需要的泵 ID")] = None,
|
||||
) -> None:
|
||||
path = _component_option_path(kind.value, schema=True)
|
||||
params: dict[str, str] = {}
|
||||
if kind == ComponentOptionKind.PUMP_ENERGY and pump:
|
||||
params["pump"] = pump
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取选项 schema 成功",
|
||||
method="GET",
|
||||
path=path,
|
||||
params=params,
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@component_option_app.command("get")
|
||||
def component_option_get(
|
||||
ctx: typer.Context,
|
||||
kind: Annotated[ComponentOptionKind, typer.Option("--kind", help="选项类型,仅支持 time|energy|pump-energy|network")],
|
||||
pump: Annotated[str | None, typer.Option("--pump", help="pump-energy 时需要的泵 ID")] = None,
|
||||
) -> None:
|
||||
path = _component_option_path(kind.value, schema=False)
|
||||
params: dict[str, str] = {}
|
||||
if kind == ComponentOptionKind.PUMP_ENERGY:
|
||||
if not pump:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
code="PUMP_REQUIRED",
|
||||
message="--pump is required when --kind pump-energy",
|
||||
exit_code=2,
|
||||
)
|
||||
params["pump"] = pump
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取选项属性成功",
|
||||
method="GET",
|
||||
path=path,
|
||||
params=params,
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
def _component_option_path(kind: str, *, schema: bool) -> str:
|
||||
routes = {
|
||||
("time", True): "/network-schemas/time",
|
||||
("time", False): "/network-options/time",
|
||||
("energy", True): "/network-schemas/energy",
|
||||
("energy", False): "/network-options/energy",
|
||||
("pump-energy", True): "/network-schemas/pump-energy",
|
||||
("pump-energy", False): "/network-options/pump-energy",
|
||||
("network", True): "/network-schemas/option",
|
||||
("network", False): "/network-options",
|
||||
}
|
||||
path = routes.get((kind, schema))
|
||||
if path is None:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
code="INVALID_KIND",
|
||||
message="--kind must be one of time, energy, pump-energy, network",
|
||||
exit_code=2,
|
||||
)
|
||||
return path
|
||||
@@ -1,59 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import typer
|
||||
|
||||
from .core import DEFAULT_TIMEOUT, build_runtime_context, emit_success, request_json
|
||||
|
||||
|
||||
def runtime_context(ctx: typer.Context):
|
||||
obj = ctx.obj
|
||||
if not isinstance(obj, dict):
|
||||
obj = {}
|
||||
ctx.obj = obj
|
||||
|
||||
cached_runtime = obj.get("_runtime_context")
|
||||
if cached_runtime is not None:
|
||||
return cached_runtime
|
||||
|
||||
runtime = build_runtime_context(
|
||||
server=obj.get("server"),
|
||||
auth_stdin=obj.get("auth_stdin", False),
|
||||
scheme=obj.get("scheme"),
|
||||
timeout=obj.get("timeout", DEFAULT_TIMEOUT),
|
||||
request_id=obj.get("request_id"),
|
||||
)
|
||||
obj["_runtime_context"] = runtime
|
||||
return runtime
|
||||
|
||||
|
||||
def emit_api(
|
||||
ctx: typer.Context,
|
||||
*,
|
||||
summary: str,
|
||||
method: str,
|
||||
path: str,
|
||||
params: dict[str, Any] | None = None,
|
||||
json_body: Any = None,
|
||||
require_auth: bool = True,
|
||||
require_project: bool = False,
|
||||
next_commands: list[str] | None = None,
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
data, duration_ms = request_json(
|
||||
runtime,
|
||||
method=method,
|
||||
path=path,
|
||||
params=params,
|
||||
json_body=json_body,
|
||||
require_auth=require_auth,
|
||||
require_project=require_project,
|
||||
)
|
||||
emit_success(
|
||||
summary=summary,
|
||||
data=data,
|
||||
ctx=runtime,
|
||||
duration_ms=duration_ms,
|
||||
next_commands=next_commands,
|
||||
)
|
||||
@@ -1,602 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
import requests
|
||||
import typer
|
||||
|
||||
SCHEMA_VERSION = "tjwater-cli/v1"
|
||||
CLI_NAME = "tjwater-cli"
|
||||
DEFAULT_TIMEOUT = 180
|
||||
DEFAULT_SERVER = "http://192.168.1.114:8000"
|
||||
class CLIError(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
summary: str,
|
||||
*,
|
||||
code: str,
|
||||
message: str,
|
||||
exit_code: int,
|
||||
retryable: bool = False,
|
||||
next_commands: list[str] | None = None,
|
||||
data: Any = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.summary = summary
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.exit_code = exit_code
|
||||
self.retryable = retryable
|
||||
self.next_commands = next_commands or []
|
||||
self.data = data
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthContext:
|
||||
server: str | None = None
|
||||
access_token: str | None = None
|
||||
project_id: str | None = None
|
||||
headers: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeContext:
|
||||
server: str | None
|
||||
auth: AuthContext
|
||||
scheme: str | None
|
||||
timeout: int
|
||||
request_id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommandOptionDoc:
|
||||
name: str
|
||||
description: str
|
||||
required: bool = False
|
||||
repeated: bool = False
|
||||
default: Any = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommandDoc:
|
||||
path: tuple[str, ...]
|
||||
summary: str
|
||||
description: str
|
||||
options: tuple[CommandOptionDoc, ...] = ()
|
||||
examples: tuple[str, ...] = ()
|
||||
next_commands: tuple[str, ...] = ()
|
||||
output: str = "标准 JSON 输出"
|
||||
|
||||
|
||||
def _pick(mapping: Mapping[str, Any], *keys: str) -> Any:
|
||||
for key in keys:
|
||||
value = mapping.get(key)
|
||||
if value not in (None, ""):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def load_auth_context(auth_stdin: bool = False) -> AuthContext:
|
||||
if auth_stdin:
|
||||
raw = json.loads(sys.stdin.read())
|
||||
else:
|
||||
extra_headers = os.getenv("TJWATER_EXTRA_HEADERS")
|
||||
raw = {
|
||||
"server": os.getenv("TJWATER_SERVER"),
|
||||
"access_token": os.getenv("TJWATER_ACCESS_TOKEN"),
|
||||
"project_id": os.getenv("TJWATER_PROJECT_ID"),
|
||||
"headers": json.loads(extra_headers) if extra_headers else {},
|
||||
}
|
||||
|
||||
headers = raw.get("headers") or {}
|
||||
if not isinstance(headers, dict):
|
||||
raise CLIError(
|
||||
"认证失败",
|
||||
code="AUTH_CONTEXT_INVALID",
|
||||
message="auth context headers must be a JSON object",
|
||||
exit_code=3,
|
||||
)
|
||||
|
||||
return AuthContext(
|
||||
server=_pick(raw, "server", "base_url"),
|
||||
access_token=_pick(raw, "access_token", "token", "accessToken"),
|
||||
project_id=_pick(raw, "project_id", "projectId", "x_project_id"),
|
||||
headers={str(key): str(value) for key, value in headers.items()},
|
||||
)
|
||||
|
||||
|
||||
def build_runtime_context(
|
||||
*,
|
||||
server: str | None,
|
||||
auth_stdin: bool = False,
|
||||
scheme: str | None,
|
||||
timeout: int,
|
||||
request_id: str | None,
|
||||
) -> RuntimeContext:
|
||||
auth = load_auth_context(auth_stdin=auth_stdin)
|
||||
resolved_request_id = request_id or str(uuid.uuid4())
|
||||
return RuntimeContext(
|
||||
server=server or auth.server or DEFAULT_SERVER,
|
||||
auth=auth,
|
||||
scheme=scheme,
|
||||
timeout=timeout,
|
||||
request_id=resolved_request_id,
|
||||
)
|
||||
|
||||
|
||||
def require_server(ctx: RuntimeContext) -> str:
|
||||
if ctx.server:
|
||||
return ctx.server.rstrip("/")
|
||||
raise CLIError(
|
||||
"认证失败",
|
||||
code="SERVER_REQUIRED",
|
||||
message="missing server URL; use --server or include server in auth context",
|
||||
exit_code=3,
|
||||
)
|
||||
|
||||
|
||||
def require_access_token(ctx: RuntimeContext) -> str:
|
||||
if ctx.auth.access_token:
|
||||
return ctx.auth.access_token
|
||||
raise CLIError(
|
||||
"认证失败",
|
||||
code="UNAUTHENTICATED",
|
||||
message="missing access token for agent context",
|
||||
exit_code=3,
|
||||
next_commands=["provide access_token via --auth-stdin or TJWATER_ACCESS_TOKEN env var"],
|
||||
)
|
||||
|
||||
|
||||
def require_project_id(ctx: RuntimeContext) -> str:
|
||||
if ctx.auth.project_id:
|
||||
return ctx.auth.project_id
|
||||
raise CLIError(
|
||||
"认证失败",
|
||||
code="PROJECT_CONTEXT_REQUIRED",
|
||||
message="missing project_id for agent context",
|
||||
exit_code=3,
|
||||
next_commands=["add project_id to auth context"],
|
||||
)
|
||||
|
||||
|
||||
def resolve_scheme(ctx: RuntimeContext, explicit_scheme: str | None, *, required: bool = False) -> str | None:
|
||||
scheme = explicit_scheme or ctx.scheme
|
||||
if required and not scheme:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
code="SCHEME_REQUIRED",
|
||||
message="missing scheme; use --scheme",
|
||||
exit_code=2,
|
||||
)
|
||||
return scheme
|
||||
|
||||
|
||||
def parse_time_with_timezone(value: str, *, option_name: str) -> datetime:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value)
|
||||
except ValueError as exc:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
code="INVALID_TIME",
|
||||
message=f"{option_name} must be a valid ISO 8601 / RFC 3339 timestamp",
|
||||
exit_code=2,
|
||||
) from exc
|
||||
if parsed.tzinfo is None:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
code="TIMEZONE_REQUIRED",
|
||||
message=f"{option_name} must include an explicit timezone offset",
|
||||
exit_code=2,
|
||||
)
|
||||
return parsed
|
||||
|
||||
|
||||
def read_json_input(path: Path, *, label: str) -> Any:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError as exc:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
code="INPUT_NOT_FOUND",
|
||||
message=f"{label} file not found: {path}",
|
||||
exit_code=2,
|
||||
) from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
code="INPUT_INVALID_JSON",
|
||||
message=f"{label} file must be valid JSON: {path}",
|
||||
exit_code=2,
|
||||
) from exc
|
||||
|
||||
|
||||
def parse_burst_file(path: Path) -> tuple[list[str], list[float]]:
|
||||
raw = read_json_input(path, label="burst")
|
||||
if isinstance(raw, dict) and "bursts" in raw:
|
||||
raw = raw["bursts"]
|
||||
if isinstance(raw, dict) and "burst_id" in raw and "burst_size" in raw:
|
||||
ids = [str(item) for item in raw["burst_id"]]
|
||||
sizes = [float(item) for item in raw["burst_size"]]
|
||||
if len(ids) != len(sizes):
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
code="BURST_FILE_INVALID",
|
||||
message="burst file burst_id and burst_size must have the same length",
|
||||
exit_code=2,
|
||||
)
|
||||
return ids, sizes
|
||||
if isinstance(raw, list):
|
||||
ids: list[str] = []
|
||||
sizes: list[float] = []
|
||||
for item in raw:
|
||||
if not isinstance(item, dict) or "id" not in item or "size" not in item:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
code="BURST_FILE_INVALID",
|
||||
message="burst file items must contain id and size",
|
||||
exit_code=2,
|
||||
)
|
||||
ids.append(str(item["id"]))
|
||||
sizes.append(float(item["size"]))
|
||||
return ids, sizes
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
code="BURST_FILE_INVALID",
|
||||
message="burst file must be a JSON array or object with burst_id/burst_size",
|
||||
exit_code=2,
|
||||
)
|
||||
|
||||
|
||||
def parse_valve_setting_file(path: Path) -> tuple[list[str], list[float]]:
|
||||
raw = read_json_input(path, label="valve-setting")
|
||||
if isinstance(raw, dict) and "valves" in raw and "valves_k" in raw:
|
||||
valves = [str(item) for item in raw["valves"]]
|
||||
openings = [float(item) for item in raw["valves_k"]]
|
||||
if len(valves) != len(openings):
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
code="VALVE_SETTING_INVALID",
|
||||
message="valves and valves_k must have the same length",
|
||||
exit_code=2,
|
||||
)
|
||||
return valves, openings
|
||||
if isinstance(raw, list):
|
||||
valves: list[str] = []
|
||||
openings: list[float] = []
|
||||
for item in raw:
|
||||
if not isinstance(item, dict) or "valve" not in item or "opening" not in item:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
code="VALVE_SETTING_INVALID",
|
||||
message="valve-setting items must contain valve and opening",
|
||||
exit_code=2,
|
||||
)
|
||||
valves.append(str(item["valve"]))
|
||||
openings.append(float(item["opening"]))
|
||||
return valves, openings
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
code="VALVE_SETTING_INVALID",
|
||||
message="valve-setting file must be a JSON array or object with valves/valves_k",
|
||||
exit_code=2,
|
||||
)
|
||||
|
||||
|
||||
def parse_optional_dataset_file(path: Path | None, *, label: str) -> Any:
|
||||
if path is None:
|
||||
return None
|
||||
return read_json_input(path, label=label)
|
||||
|
||||
|
||||
def build_headers(
|
||||
ctx: RuntimeContext,
|
||||
*,
|
||||
require_auth: bool,
|
||||
require_project: bool,
|
||||
) -> dict[str, str]:
|
||||
headers = {
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"X-Request-Id": ctx.request_id,
|
||||
}
|
||||
headers.update(ctx.auth.headers)
|
||||
if require_auth:
|
||||
headers["Authorization"] = f"Bearer {require_access_token(ctx)}"
|
||||
elif ctx.auth.access_token:
|
||||
headers["Authorization"] = f"Bearer {ctx.auth.access_token}"
|
||||
if require_project:
|
||||
headers["X-Project-Id"] = require_project_id(ctx)
|
||||
elif ctx.auth.project_id:
|
||||
headers["X-Project-Id"] = ctx.auth.project_id
|
||||
return headers
|
||||
|
||||
|
||||
def _extract_error_message(response: requests.Response) -> str:
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
text = response.text.strip()
|
||||
return text or f"http {response.status_code}"
|
||||
|
||||
if isinstance(payload, dict):
|
||||
detail = payload.get("detail")
|
||||
if isinstance(detail, str):
|
||||
return detail
|
||||
if isinstance(detail, list):
|
||||
return "; ".join(json.dumps(item, ensure_ascii=False) for item in detail)
|
||||
message = payload.get("message")
|
||||
if isinstance(message, str):
|
||||
return message
|
||||
return json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
|
||||
def map_http_status_to_exit_code(status_code: int) -> int:
|
||||
if status_code in (400, 422):
|
||||
return 2
|
||||
if status_code == 401:
|
||||
return 3
|
||||
if status_code == 403:
|
||||
return 4
|
||||
if status_code == 404:
|
||||
return 5
|
||||
if status_code in (409, 412):
|
||||
return 6
|
||||
return 7
|
||||
|
||||
|
||||
def _parse_response_body(response: requests.Response) -> Any:
|
||||
if response.status_code == 204 or not response.content:
|
||||
return {}
|
||||
content_type = response.headers.get("content-type", "").lower()
|
||||
if "application/json" in content_type:
|
||||
payload = response.json()
|
||||
if isinstance(payload, dict) and payload.get("status") == "error":
|
||||
raise CLIError(
|
||||
"服务端错误",
|
||||
code="SERVER_ERROR",
|
||||
message=str(payload.get("message") or "server returned error status"),
|
||||
exit_code=7,
|
||||
data=payload,
|
||||
)
|
||||
return payload
|
||||
text = response.text
|
||||
if text:
|
||||
return {"report": text}
|
||||
return {}
|
||||
|
||||
|
||||
def _prepare_public_request(
|
||||
method: str,
|
||||
path: str,
|
||||
params: dict[str, Any] | None,
|
||||
json_body: Any,
|
||||
) -> tuple[str, str, dict[str, Any] | None, Any]:
|
||||
return method.upper(), path.rstrip("/") or "/", params or None, json_body
|
||||
|
||||
|
||||
def request_json(
|
||||
ctx: RuntimeContext,
|
||||
*,
|
||||
method: str,
|
||||
path: str,
|
||||
params: dict[str, Any] | None = None,
|
||||
json_body: Any = None,
|
||||
require_auth: bool = True,
|
||||
require_project: bool = False,
|
||||
) -> tuple[Any, int]:
|
||||
require_server(ctx)
|
||||
method, path, params, json_body = _prepare_public_request(
|
||||
method,
|
||||
path,
|
||||
params,
|
||||
json_body,
|
||||
)
|
||||
url = f"{require_server(ctx)}/api/v1{path}"
|
||||
headers = build_headers(ctx, require_auth=require_auth, require_project=require_project)
|
||||
started = time.monotonic()
|
||||
try:
|
||||
response = requests.request(
|
||||
method=method.upper(),
|
||||
url=url,
|
||||
params=params,
|
||||
json=json_body,
|
||||
headers=headers,
|
||||
timeout=ctx.timeout,
|
||||
)
|
||||
except requests.Timeout as exc:
|
||||
raise CLIError(
|
||||
"请求超时",
|
||||
code="REQUEST_TIMEOUT",
|
||||
message=f"request timed out after {ctx.timeout} seconds",
|
||||
exit_code=7,
|
||||
retryable=True,
|
||||
) from exc
|
||||
except requests.RequestException as exc:
|
||||
raise CLIError(
|
||||
"连接失败",
|
||||
code="REQUEST_FAILED",
|
||||
message=str(exc),
|
||||
exit_code=7,
|
||||
retryable=True,
|
||||
) from exc
|
||||
duration_ms = int((time.monotonic() - started) * 1000)
|
||||
|
||||
if not response.ok:
|
||||
raise CLIError(
|
||||
"请求失败",
|
||||
code=f"HTTP_{response.status_code}",
|
||||
message=_extract_error_message(response),
|
||||
exit_code=map_http_status_to_exit_code(response.status_code),
|
||||
retryable=response.status_code >= 500,
|
||||
)
|
||||
return _parse_response_body(response), duration_ms
|
||||
|
||||
|
||||
def request_bytes(
|
||||
ctx: RuntimeContext,
|
||||
*,
|
||||
method: str,
|
||||
path: str,
|
||||
params: dict[str, Any] | None = None,
|
||||
require_auth: bool = True,
|
||||
require_project: bool = False,
|
||||
) -> tuple[bytes, int]:
|
||||
require_server(ctx)
|
||||
method, path, params, _ = _prepare_public_request(
|
||||
method,
|
||||
path,
|
||||
params,
|
||||
None,
|
||||
)
|
||||
url = f"{require_server(ctx)}/api/v1{path}"
|
||||
headers = build_headers(ctx, require_auth=require_auth, require_project=require_project)
|
||||
started = time.monotonic()
|
||||
try:
|
||||
response = requests.request(
|
||||
method=method.upper(),
|
||||
url=url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
timeout=ctx.timeout,
|
||||
)
|
||||
except requests.Timeout as exc:
|
||||
raise CLIError(
|
||||
"请求超时",
|
||||
code="REQUEST_TIMEOUT",
|
||||
message=f"request timed out after {ctx.timeout} seconds",
|
||||
exit_code=7,
|
||||
retryable=True,
|
||||
) from exc
|
||||
except requests.RequestException as exc:
|
||||
raise CLIError(
|
||||
"连接失败",
|
||||
code="REQUEST_FAILED",
|
||||
message=str(exc),
|
||||
exit_code=7,
|
||||
retryable=True,
|
||||
) from exc
|
||||
duration_ms = int((time.monotonic() - started) * 1000)
|
||||
|
||||
if not response.ok:
|
||||
raise CLIError(
|
||||
"请求失败",
|
||||
code=f"HTTP_{response.status_code}",
|
||||
message=_extract_error_message(response),
|
||||
exit_code=map_http_status_to_exit_code(response.status_code),
|
||||
retryable=response.status_code >= 500,
|
||||
)
|
||||
return response.content, duration_ms
|
||||
|
||||
|
||||
def build_success_payload(
|
||||
*,
|
||||
summary: str,
|
||||
data: Any,
|
||||
server: str | None,
|
||||
request_id: str,
|
||||
duration_ms: int,
|
||||
next_commands: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"ok": True,
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"summary": summary,
|
||||
"data": data,
|
||||
"metadata": {
|
||||
"request_id": request_id,
|
||||
"server": server,
|
||||
"duration_ms": duration_ms,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"),
|
||||
},
|
||||
"next_commands": next_commands or [],
|
||||
}
|
||||
|
||||
|
||||
def build_failure_payload(
|
||||
*,
|
||||
summary: str,
|
||||
code: str,
|
||||
message: str,
|
||||
retryable: bool,
|
||||
server: str | None,
|
||||
request_id: str | None,
|
||||
next_commands: list[str] | None = None,
|
||||
data: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"ok": False,
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"summary": summary,
|
||||
"error": {
|
||||
"code": code,
|
||||
"message": message,
|
||||
"retryable": retryable,
|
||||
},
|
||||
"data": data,
|
||||
"metadata": {
|
||||
"request_id": request_id,
|
||||
"server": server,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"),
|
||||
},
|
||||
"next_commands": next_commands or [],
|
||||
}
|
||||
|
||||
|
||||
def emit_success(
|
||||
*,
|
||||
summary: str,
|
||||
data: Any,
|
||||
ctx: RuntimeContext,
|
||||
duration_ms: int,
|
||||
next_commands: list[str] | None = None,
|
||||
) -> None:
|
||||
typer.echo(
|
||||
json.dumps(
|
||||
build_success_payload(
|
||||
summary=summary,
|
||||
data=data,
|
||||
server=ctx.server,
|
||||
request_id=ctx.request_id,
|
||||
duration_ms=duration_ms,
|
||||
next_commands=next_commands,
|
||||
),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def emit_failure(
|
||||
*,
|
||||
summary: str,
|
||||
code: str,
|
||||
message: str,
|
||||
exit_code: int,
|
||||
retryable: bool,
|
||||
server: str | None,
|
||||
request_id: str | None,
|
||||
next_commands: list[str] | None = None,
|
||||
data: Any = None,
|
||||
) -> int:
|
||||
typer.echo(
|
||||
json.dumps(
|
||||
build_failure_payload(
|
||||
summary=summary,
|
||||
code=code,
|
||||
message=message,
|
||||
retryable=retryable,
|
||||
server=server,
|
||||
request_id=request_id,
|
||||
next_commands=next_commands,
|
||||
data=data,
|
||||
),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
return exit_code
|
||||
@@ -1,15 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import click
|
||||
import typer.core
|
||||
|
||||
|
||||
class TJWaterGroup(typer.core.TyperGroup):
|
||||
def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
|
||||
super().format_help(ctx, formatter)
|
||||
from .helping import build_group_help_appendix
|
||||
|
||||
appendix = build_group_help_appendix(ctx)
|
||||
if appendix:
|
||||
formatter.write_paragraph()
|
||||
formatter.write_text(appendix)
|
||||
@@ -1,414 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Annotated, Any
|
||||
|
||||
import click
|
||||
import typer
|
||||
|
||||
from .apps import GROUP_HELP_APPS, TOP_LEVEL_COMMANDS, app
|
||||
from .core import CLIError
|
||||
from .registry import (
|
||||
get_command_doc,
|
||||
get_group_summary,
|
||||
has_subcommands,
|
||||
is_hidden_path,
|
||||
list_capabilities,
|
||||
list_subcommands,
|
||||
)
|
||||
|
||||
|
||||
def _click_root_command() -> click.Command:
|
||||
# Must stay lazy: the click tree is only complete after command modules import.
|
||||
return typer.main.get_command(app)
|
||||
|
||||
|
||||
def _normalize_command_path(tokens: list[str]) -> tuple[str, ...]:
|
||||
while tokens and tokens[0] not in TOP_LEVEL_COMMANDS:
|
||||
tokens = tokens[1:]
|
||||
return tuple(tokens)
|
||||
|
||||
|
||||
def context_command_path(click_ctx: click.Context | None) -> tuple[str, ...]:
|
||||
if click_ctx is None:
|
||||
return ()
|
||||
return _normalize_command_path(click_ctx.command_path.split())
|
||||
|
||||
|
||||
def _build_click_context(path: tuple[str, ...]) -> click.Context | None:
|
||||
root = _click_root_command()
|
||||
ctx: click.Context = click.Context(root, info_name="tjwater-cli")
|
||||
command: click.Command = root
|
||||
for token in path:
|
||||
if not isinstance(command, click.Group):
|
||||
return None
|
||||
next_command = command.commands.get(token)
|
||||
if next_command is None:
|
||||
return None
|
||||
ctx = click.Context(next_command, info_name=token, parent=ctx)
|
||||
command = next_command
|
||||
return ctx
|
||||
|
||||
|
||||
def build_usage(path: tuple[str, ...]) -> str | None:
|
||||
ctx = _build_click_context(path)
|
||||
if ctx is None:
|
||||
return None
|
||||
parts = ["tjwater-cli", *path]
|
||||
for parameter in ctx.command.params:
|
||||
if not isinstance(parameter, click.Option):
|
||||
continue
|
||||
if "--help" in parameter.opts:
|
||||
continue
|
||||
option_name = next((opt.lstrip("-") for opt in reversed(parameter.opts) if opt.startswith("--")), parameter.name or "")
|
||||
if parameter.is_flag:
|
||||
parts.append(f"--{option_name}" if parameter.required else f"[--{option_name}]")
|
||||
continue
|
||||
placeholder = option_name.upper().replace("-", "_")
|
||||
if parameter.required:
|
||||
parts.extend([f"--{option_name}", f"<{placeholder}>"])
|
||||
else:
|
||||
parts.append(f"[--{option_name} <{placeholder}>]")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _click_option_docs(path: tuple[str, ...]) -> list[dict[str, Any]]:
|
||||
ctx = _build_click_context(path)
|
||||
if ctx is None:
|
||||
return []
|
||||
options: list[dict[str, Any]] = []
|
||||
for parameter in ctx.command.params:
|
||||
if not isinstance(parameter, click.Option):
|
||||
continue
|
||||
if "--help" in parameter.opts:
|
||||
continue
|
||||
cli_name = next((opt.lstrip("-") for opt in reversed(parameter.opts) if opt.startswith("--")), parameter.name or "")
|
||||
options.append(
|
||||
{
|
||||
"name": cli_name,
|
||||
"description": parameter.help or "",
|
||||
"required": parameter.required,
|
||||
"repeated": parameter.multiple,
|
||||
"default": parameter.default,
|
||||
}
|
||||
)
|
||||
return options
|
||||
|
||||
|
||||
def _sample_option_value(path: tuple[str, ...], option_name: str) -> str:
|
||||
path_specific_samples: dict[tuple[tuple[str, ...], str], str] = {
|
||||
(("component", "option", "schema"), "kind"): "time",
|
||||
(("component", "option", "get"), "kind"): "time",
|
||||
(("data", "timeseries", "composite"), "kind"): "scada-simulation",
|
||||
(("data", "scada", "get"), "kind"): "info",
|
||||
(("data", "scada", "list"), "kind"): "info",
|
||||
}
|
||||
if (path, option_name) in path_specific_samples:
|
||||
return path_specific_samples[(path, option_name)]
|
||||
if option_name == "start-time":
|
||||
return "2025-01-02T03:04:05+08:00"
|
||||
if option_name == "end-time":
|
||||
return "2025-01-02T04:04:05+08:00"
|
||||
if option_name == "date":
|
||||
return "2025-01-02"
|
||||
if option_name == "duration":
|
||||
return "30"
|
||||
if option_name == "kind":
|
||||
return "time"
|
||||
if option_name == "mode":
|
||||
return "close"
|
||||
if option_name == "scheme":
|
||||
return "baseline"
|
||||
if option_name == "output":
|
||||
return "./demo.inp" if "export-inp" in path else "./output.json"
|
||||
if option_name == "pump":
|
||||
return "PUMP-1"
|
||||
if option_name == "node":
|
||||
return "J1"
|
||||
if option_name == "source-node":
|
||||
return "J1"
|
||||
if option_name == "drainage-node":
|
||||
return "J2"
|
||||
if option_name in {"link", "pipe", "pipe-id", "element-id", "element"}:
|
||||
return "P1"
|
||||
if option_name == "flow":
|
||||
return "120.5"
|
||||
if option_name == "concentration":
|
||||
return "0.8"
|
||||
if option_name == "device-id":
|
||||
return "SCADA-001"
|
||||
if option_name == "burst-file":
|
||||
return "./burst.json"
|
||||
if option_name == "valve-setting-file":
|
||||
return "./valves.json"
|
||||
if option_name.endswith("-file"):
|
||||
return "./input.json"
|
||||
if option_name.endswith("-id"):
|
||||
return "demo-id"
|
||||
return "demo"
|
||||
|
||||
|
||||
def _build_example(path: tuple[str, ...], *, existing_examples: list[str] | None = None) -> str:
|
||||
ctx = _build_click_context(path)
|
||||
required_option_names: list[str] = []
|
||||
if ctx is not None:
|
||||
required_option_names = [
|
||||
next((opt.lstrip("-") for opt in reversed(parameter.opts) if opt.startswith("--")), parameter.name or "")
|
||||
for parameter in ctx.command.params
|
||||
if isinstance(parameter, click.Option) and "--help" not in parameter.opts and parameter.required
|
||||
]
|
||||
if existing_examples:
|
||||
for example in existing_examples:
|
||||
has_required_options = all(f"--{option_name}" in example for option_name in required_option_names)
|
||||
if has_required_options:
|
||||
return example
|
||||
parts = ["tjwater-cli", *path]
|
||||
if ctx is None:
|
||||
return " ".join(parts)
|
||||
for parameter in ctx.command.params:
|
||||
if not isinstance(parameter, click.Option):
|
||||
continue
|
||||
if "--help" in parameter.opts or not parameter.required:
|
||||
continue
|
||||
option_name = next((opt.lstrip("-") for opt in reversed(parameter.opts) if opt.startswith("--")), parameter.name or "")
|
||||
parts.extend([f"--{option_name}", _sample_option_value(path, option_name)])
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _enrich_leaf_payload(payload: dict[str, Any], path: tuple[str, ...]) -> dict[str, Any]:
|
||||
enriched = dict(payload)
|
||||
enriched["usage"] = build_usage(path) or payload.get("usage")
|
||||
click_options = _click_option_docs(path)
|
||||
if click_options:
|
||||
enriched["options"] = click_options
|
||||
enriched["examples"] = payload.get("examples") or []
|
||||
if not enriched["examples"] or all("<" in example and ">" in example for example in enriched["examples"]):
|
||||
enriched["examples"] = [_build_example(path, existing_examples=enriched["examples"])]
|
||||
return enriched
|
||||
|
||||
|
||||
def _enrich_index_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
enriched = dict(payload)
|
||||
commands: list[dict[str, Any]] = []
|
||||
for command in payload.get("commands", []):
|
||||
command_item = dict(command)
|
||||
path = tuple(command_item["command"].split())
|
||||
doc = get_command_doc(path)
|
||||
if doc is None and has_subcommands(path):
|
||||
command_item["usage"] = f"tjwater-cli {' '.join(path)} help"
|
||||
command_item["example"] = f"tjwater-cli {' '.join(path)} help"
|
||||
else:
|
||||
existing_examples = [] if doc is None else list(doc.get("examples", []))
|
||||
command_item["usage"] = build_usage(path) or command_item.get("usage")
|
||||
command_item["example"] = _build_example(path, existing_examples=existing_examples)
|
||||
commands.append(command_item)
|
||||
enriched["commands"] = commands
|
||||
return enriched
|
||||
|
||||
|
||||
def resolve_help_payload(path: tuple[str, ...]) -> tuple[dict[str, Any] | None, bool]:
|
||||
if not path:
|
||||
return list_capabilities(), True
|
||||
payload = get_command_doc(path)
|
||||
if payload is not None:
|
||||
return _enrich_leaf_payload(payload, path), False
|
||||
if has_subcommands(path):
|
||||
return _enrich_index_payload(list_subcommands(path, get_group_summary(path))), True
|
||||
return None, False
|
||||
|
||||
|
||||
def emit_help_payload(payload: dict[str, Any]) -> None:
|
||||
typer.echo(json.dumps(payload, ensure_ascii=False))
|
||||
|
||||
|
||||
def merge_next_commands(*groups: list[str] | None) -> list[str]:
|
||||
merged: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for group in groups:
|
||||
for command in group or []:
|
||||
if command in seen:
|
||||
continue
|
||||
seen.add(command)
|
||||
merged.append(command)
|
||||
return merged
|
||||
|
||||
|
||||
def merge_error_data(primary: Any, secondary: Any) -> Any:
|
||||
if primary is None:
|
||||
return secondary
|
||||
if secondary is None:
|
||||
return primary
|
||||
if isinstance(primary, dict) and isinstance(secondary, dict):
|
||||
return {**secondary, **primary}
|
||||
return primary
|
||||
|
||||
|
||||
def build_error_guidance(click_ctx: click.Context | None) -> tuple[Any, list[str]]:
|
||||
command_path = context_command_path(click_ctx)
|
||||
usage = build_usage(command_path) if command_path else None
|
||||
if command_path:
|
||||
if command_path[-1] == "help":
|
||||
group_path = command_path[:-1]
|
||||
if group_path:
|
||||
return (
|
||||
{
|
||||
"command_group": " ".join(group_path),
|
||||
"usage": f"tjwater-cli {' '.join(group_path)} help",
|
||||
"examples": [f"tjwater-cli {' '.join(group_path)} help", f"tjwater-cli help {' '.join(group_path)}"],
|
||||
},
|
||||
merge_next_commands(
|
||||
[f"tjwater-cli {' '.join(group_path)} help", f"tjwater-cli help {' '.join(group_path)}"],
|
||||
["tjwater-cli help"],
|
||||
),
|
||||
)
|
||||
payload, is_index = resolve_help_payload(command_path)
|
||||
if payload is not None and not is_index:
|
||||
return (
|
||||
{
|
||||
"command": payload["command"],
|
||||
"usage": payload.get("usage") or usage,
|
||||
"examples": payload.get("examples", []),
|
||||
},
|
||||
merge_next_commands([f"tjwater-cli help {' '.join(command_path)}"], ["tjwater-cli help"]),
|
||||
)
|
||||
if payload is not None and is_index:
|
||||
return (
|
||||
{
|
||||
"command_group": " ".join(command_path),
|
||||
"usage": f"tjwater-cli {' '.join(command_path)} help",
|
||||
"examples": [f"tjwater-cli {' '.join(command_path)} help", f"tjwater-cli help {' '.join(command_path)}"],
|
||||
},
|
||||
merge_next_commands(
|
||||
[f"tjwater-cli {' '.join(command_path)} help", f"tjwater-cli help {' '.join(command_path)}"],
|
||||
["tjwater-cli help"],
|
||||
),
|
||||
)
|
||||
return ({"usage": usage} if usage else None, ["tjwater-cli help"])
|
||||
|
||||
|
||||
def classify_click_error(exc: click.ClickException) -> tuple[str, str]:
|
||||
if isinstance(exc, click.NoSuchOption):
|
||||
return "未知选项", "UNKNOWN_OPTION"
|
||||
if isinstance(exc, click.MissingParameter):
|
||||
return "缺少参数", "MISSING_PARAMETER"
|
||||
if isinstance(exc, click.BadParameter):
|
||||
return "参数无效", "INVALID_PARAMETER"
|
||||
message = exc.format_message()
|
||||
if "No such command" in message:
|
||||
return "未找到命令", "COMMAND_NOT_FOUND"
|
||||
return "CLI 参数错误", "USAGE_ERROR"
|
||||
|
||||
|
||||
def _build_root_help_epilog() -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
"\b",
|
||||
"Examples:",
|
||||
" tjwater-cli help",
|
||||
" tjwater-cli help simulation run",
|
||||
" tjwater-cli simulation run --help",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _build_leaf_help_epilog(path: tuple[str, ...], payload: dict[str, Any]) -> str:
|
||||
lines = ["\b"]
|
||||
description = payload.get("description")
|
||||
usage = payload.get("usage")
|
||||
examples = payload.get("examples", [])
|
||||
next_commands = payload.get("next_commands", [])
|
||||
if description:
|
||||
lines.extend([f"Description: {description}", ""])
|
||||
if usage:
|
||||
lines.extend([f"Usage example: {usage}", ""])
|
||||
if examples:
|
||||
lines.append("Examples:")
|
||||
lines.extend(f" {example}" for example in examples)
|
||||
lines.append("")
|
||||
if next_commands:
|
||||
lines.append("Next steps:")
|
||||
lines.extend(f" {command}" for command in next_commands)
|
||||
lines.append("")
|
||||
lines.extend(["Structured JSON:", f" tjwater-cli help {' '.join(path)}"])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_group_help_epilog(path: tuple[str, ...], payload: dict[str, Any]) -> str:
|
||||
lines = ["\b", "Examples:", f" tjwater-cli help {' '.join(path)}"]
|
||||
for command in payload.get("commands", [])[:2]:
|
||||
example = command.get("example")
|
||||
if example:
|
||||
lines.append(f" {example}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_group_help_appendix(click_ctx: click.Context | None) -> str | None:
|
||||
path = context_command_path(click_ctx)
|
||||
if not path:
|
||||
return _build_root_help_epilog()
|
||||
payload, is_index = resolve_help_payload(path)
|
||||
if payload is None or not is_index:
|
||||
return None
|
||||
return _build_group_help_epilog(path, payload)
|
||||
|
||||
|
||||
def make_group_help_handler(path_prefix: tuple[str, ...]):
|
||||
def group_help() -> None:
|
||||
payload, is_index = resolve_help_payload(path_prefix)
|
||||
if payload is None:
|
||||
raise CLIError(
|
||||
"未找到命令",
|
||||
code="COMMAND_NOT_FOUND",
|
||||
message=f"unknown command path: {' '.join(path_prefix)}",
|
||||
exit_code=2,
|
||||
next_commands=["tjwater-cli help"],
|
||||
)
|
||||
emit_help_payload(payload)
|
||||
|
||||
group_help.__name__ = f"{'_'.join(path_prefix)}_help"
|
||||
return group_help
|
||||
|
||||
|
||||
def register_group_help_commands() -> None:
|
||||
for group_app, path_prefix in GROUP_HELP_APPS:
|
||||
group_app.command("help")(make_group_help_handler(path_prefix))
|
||||
|
||||
|
||||
def apply_typer_help_metadata() -> None:
|
||||
app.help = "\n".join(
|
||||
[
|
||||
"TJWater agent CLI",
|
||||
"",
|
||||
"Examples:",
|
||||
" tjwater-cli help",
|
||||
" tjwater-cli help simulation run",
|
||||
" tjwater-cli simulation run --help",
|
||||
]
|
||||
)
|
||||
app.short_help = "TJWater agent CLI"
|
||||
for group_app, path_prefix in GROUP_HELP_APPS:
|
||||
for command_info in group_app.registered_commands:
|
||||
command_path = (*path_prefix, command_info.name)
|
||||
if command_info.name == "help":
|
||||
command_info.help = f"输出 {' '.join(path_prefix)} 的 JSON 帮助信息。"
|
||||
command_info.short_help = command_info.help
|
||||
command_info.epilog = "\n".join(["\b", "Example:", f" tjwater-cli help {' '.join(path_prefix)}"])
|
||||
command_info.hidden = False
|
||||
continue
|
||||
payload = get_command_doc(command_path)
|
||||
command_info.help = None if payload is None else str(payload.get("summary", ""))
|
||||
command_info.short_help = command_info.help
|
||||
command_info.epilog = None if payload is None else _build_leaf_help_epilog(command_path, payload)
|
||||
command_info.hidden = is_hidden_path(command_path)
|
||||
for group_info in group_app.registered_groups:
|
||||
group_path = (*path_prefix, group_info.name)
|
||||
summary = get_group_summary(group_path)
|
||||
group_info.help = summary
|
||||
group_info.short_help = summary
|
||||
group_info.hidden = is_hidden_path(group_path)
|
||||
for group_info in app.registered_groups:
|
||||
group_path = (group_info.name,)
|
||||
summary = get_group_summary(group_path)
|
||||
group_info.help = summary
|
||||
group_info.short_help = summary
|
||||
group_info.hidden = is_hidden_path(group_path)
|
||||
@@ -1,112 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
import click
|
||||
import typer
|
||||
from click.exceptions import NoArgsIsHelpError
|
||||
|
||||
from . import commands_analysis, commands_data, commands_readonly # noqa: F401
|
||||
from .apps import app
|
||||
from .core import CLIError, DEFAULT_SERVER, DEFAULT_TIMEOUT, emit_failure
|
||||
from .helping import (
|
||||
apply_typer_help_metadata,
|
||||
build_error_guidance,
|
||||
classify_click_error,
|
||||
emit_help_payload,
|
||||
merge_error_data,
|
||||
merge_next_commands,
|
||||
register_group_help_commands,
|
||||
resolve_help_payload,
|
||||
)
|
||||
|
||||
|
||||
@app.callback()
|
||||
def root_callback(
|
||||
ctx: typer.Context,
|
||||
server: Annotated[str | None, typer.Option("--server", help=f"服务端地址,默认 {DEFAULT_SERVER}")] = None,
|
||||
auth_stdin: Annotated[bool, typer.Option("--auth-stdin", help="从标准输入读取认证上下文 JSON")] = False,
|
||||
scheme: Annotated[str | None, typer.Option("--scheme", help="全局方案标识")] = None,
|
||||
timeout: Annotated[int, typer.Option("--timeout", help="请求超时秒数")] = DEFAULT_TIMEOUT,
|
||||
request_id: Annotated[str | None, typer.Option("--request-id", help="显式请求 ID")] = None,
|
||||
) -> None:
|
||||
ctx.obj = {
|
||||
"server": server,
|
||||
"auth_stdin": auth_stdin,
|
||||
"scheme": scheme,
|
||||
"timeout": timeout,
|
||||
"request_id": request_id,
|
||||
}
|
||||
|
||||
|
||||
register_group_help_commands()
|
||||
|
||||
|
||||
@app.command("help", context_settings={"allow_extra_args": True})
|
||||
def help_command(ctx: typer.Context) -> None:
|
||||
command_path = list(ctx.args)
|
||||
payload, is_index = resolve_help_payload(tuple(command_path))
|
||||
if payload is None:
|
||||
emit_failure(
|
||||
summary="未找到命令",
|
||||
code="COMMAND_NOT_FOUND",
|
||||
message=f"unknown command path: {' '.join(command_path)}",
|
||||
exit_code=2,
|
||||
retryable=False,
|
||||
server=None,
|
||||
request_id=None,
|
||||
data={
|
||||
"usage": "tjwater-cli help <command-path>",
|
||||
"examples": ["tjwater-cli help simulation run", "tjwater-cli simulation help"],
|
||||
},
|
||||
next_commands=["tjwater-cli help", "tjwater-cli help simulation"],
|
||||
)
|
||||
raise typer.Exit(code=2)
|
||||
emit_help_payload(payload)
|
||||
|
||||
|
||||
# Must run at import time because tests call runner.invoke(app, ...) directly.
|
||||
apply_typer_help_metadata()
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
try:
|
||||
app(args=argv if argv is not None else sys.argv[1:], prog_name="tjwater-cli", standalone_mode=False)
|
||||
return 0
|
||||
except CLIError as exc:
|
||||
click_ctx = click.get_current_context(silent=True)
|
||||
error_data, next_commands = build_error_guidance(click_ctx)
|
||||
return emit_failure(
|
||||
summary=exc.summary,
|
||||
code=exc.code,
|
||||
message=exc.message,
|
||||
exit_code=exc.exit_code,
|
||||
retryable=exc.retryable,
|
||||
server=None,
|
||||
request_id=None,
|
||||
next_commands=merge_next_commands(exc.next_commands, next_commands),
|
||||
data=merge_error_data(exc.data, error_data),
|
||||
)
|
||||
except NoArgsIsHelpError:
|
||||
return 0
|
||||
except click.ClickException as exc:
|
||||
click_ctx = click.get_current_context(silent=True) or exc.ctx
|
||||
error_data, next_commands = build_error_guidance(click_ctx)
|
||||
summary, code = classify_click_error(exc)
|
||||
return emit_failure(
|
||||
summary=summary,
|
||||
code=code,
|
||||
message=exc.format_message(),
|
||||
exit_code=2,
|
||||
retryable=False,
|
||||
server=None,
|
||||
request_id=None,
|
||||
next_commands=next_commands,
|
||||
data=error_data,
|
||||
)
|
||||
|
||||
|
||||
def console_entry() -> None:
|
||||
raise SystemExit(main())
|
||||
@@ -1,72 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ElementType(str, Enum):
|
||||
PIPE = "pipe"
|
||||
JUNCTION = "junction"
|
||||
|
||||
|
||||
class SimulationQuery(str, Enum):
|
||||
BY_ID_TIME = "by-id-time"
|
||||
BY_SCHEME_TIME_PROPERTY = "by-scheme-time-property"
|
||||
|
||||
|
||||
class CompositeKind(str, Enum):
|
||||
SCADA_SIMULATION = "scada-simulation"
|
||||
ELEMENT_SIMULATION = "element-simulation"
|
||||
ELEMENT_SCADA = "element-scada"
|
||||
|
||||
|
||||
class ComponentOptionKind(str, Enum):
|
||||
TIME = "time"
|
||||
ENERGY = "energy"
|
||||
PUMP_ENERGY = "pump-energy"
|
||||
NETWORK = "network"
|
||||
|
||||
|
||||
class ValveMode(str, Enum):
|
||||
CLOSE = "close"
|
||||
ISOLATION = "isolation"
|
||||
|
||||
|
||||
class DataSource(str, Enum):
|
||||
MONITORING = "monitoring"
|
||||
SIMULATION = "simulation"
|
||||
|
||||
|
||||
class ScadaListKind(str, Enum):
|
||||
INFO = "info"
|
||||
|
||||
|
||||
PIPE_TIMESERIES_FIELDS: tuple[str, ...] = (
|
||||
"flow",
|
||||
"friction",
|
||||
"headloss",
|
||||
"quality",
|
||||
"reaction",
|
||||
"setting",
|
||||
"status",
|
||||
"velocity",
|
||||
)
|
||||
|
||||
JUNCTION_TIMESERIES_FIELDS: tuple[str, ...] = (
|
||||
"actual_demand",
|
||||
"total_head",
|
||||
"pressure",
|
||||
"quality",
|
||||
)
|
||||
|
||||
SCADA_TIMESERIES_FIELDS: tuple[str, ...] = (
|
||||
"monitored_value",
|
||||
"cleaned_value",
|
||||
)
|
||||
|
||||
|
||||
def timeseries_fields_for_element_type(element_type: ElementType) -> tuple[str, ...]:
|
||||
if element_type == ElementType.PIPE:
|
||||
return PIPE_TIMESERIES_FIELDS
|
||||
if element_type == ElementType.JUNCTION:
|
||||
return JUNCTION_TIMESERIES_FIELDS
|
||||
raise AssertionError(f"unreachable element type: {element_type}")
|
||||
@@ -1,626 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .core import CommandDoc, CommandOptionDoc, SCHEMA_VERSION
|
||||
|
||||
GROUP_SUMMARIES: dict[tuple[str, ...], str] = {
|
||||
("network",): "管网节点、管线等基础属性查询命令。",
|
||||
("component",): "组件选项与配置读取命令。",
|
||||
("component", "option"): "组件选项查询命令。",
|
||||
("simulation",): "模拟运行与调度相关命令。",
|
||||
("analysis",): "分析计算与诊断相关命令。",
|
||||
("analysis", "leakage"): "漏损分析相关命令。",
|
||||
("analysis", "leakage", "schemes"): "漏损方案查询命令。",
|
||||
("analysis", "burst-detection"): "爆管检测相关命令。",
|
||||
("analysis", "burst-detection", "schemes"): "爆管检测方案查询命令。",
|
||||
("analysis", "burst-location"): "爆管定位相关命令。",
|
||||
("analysis", "burst-location", "schemes"): "爆管定位方案查询命令。",
|
||||
("analysis", "risk"): "风险分析相关命令。",
|
||||
("analysis", "sensor-placement"): "传感器选址相关命令。",
|
||||
("data",): "时序、SCADA 和方案数据查询命令。",
|
||||
("data", "timeseries"): "时序数据查询命令。",
|
||||
("data", "timeseries", "realtime"): "实时模拟时序查询命令。",
|
||||
("data", "timeseries", "scheme"): "方案时序查询命令。",
|
||||
("data", "timeseries", "scada"): "SCADA 时序查询命令。",
|
||||
("data", "timeseries", "composite"): "复合时序查询命令。",
|
||||
("data", "scada"): "SCADA 元数据查询命令。",
|
||||
("data", "scheme"): "方案数据查询命令。",
|
||||
}
|
||||
|
||||
HIDDEN_PATH_PREFIXES: tuple[tuple[str, ...], ...] = (
|
||||
("analysis", "burst-location"),
|
||||
("analysis", "risk"),
|
||||
)
|
||||
|
||||
COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
|
||||
("network", "get-junction-properties"): CommandDoc(
|
||||
path=("network", "get-junction-properties"),
|
||||
summary="读取节点属性",
|
||||
description="调用 GET /api/v1/junctions/{junction_id}/properties。",
|
||||
options=(CommandOptionDoc("junction", "节点 ID", required=True),),
|
||||
examples=("tjwater-cli network get-junction-properties --junction J1",),
|
||||
),
|
||||
("network", "get-pipe-properties"): CommandDoc(
|
||||
path=("network", "get-pipe-properties"),
|
||||
summary="读取管道属性",
|
||||
description="调用 GET /api/v1/pipes/{pipe_id}/properties。",
|
||||
options=(CommandOptionDoc("pipe", "管道 ID", required=True),),
|
||||
examples=("tjwater-cli network get-pipe-properties --pipe P1",),
|
||||
),
|
||||
("network", "get-all-pipes-properties"): CommandDoc(
|
||||
path=("network", "get-all-pipes-properties"),
|
||||
summary="读取全部管道属性",
|
||||
description="调用 GET /api/v1/pipes/properties。",
|
||||
examples=("tjwater-cli network get-all-pipes-properties",),
|
||||
),
|
||||
("network", "get-reservoir-properties"): CommandDoc(
|
||||
path=("network", "get-reservoir-properties"),
|
||||
summary="读取水库属性",
|
||||
description="调用 GET /api/v1/reservoirs/{reservoir_id}/properties。",
|
||||
options=(CommandOptionDoc("reservoir", "水库 ID", required=True),),
|
||||
examples=("tjwater-cli network get-reservoir-properties --reservoir R1",),
|
||||
),
|
||||
("network", "get-all-reservoirs-properties"): CommandDoc(
|
||||
path=("network", "get-all-reservoirs-properties"),
|
||||
summary="读取全部水库属性",
|
||||
description="调用 GET /api/v1/reservoirs/properties。",
|
||||
examples=("tjwater-cli network get-all-reservoirs-properties",),
|
||||
),
|
||||
("network", "get-tank-properties"): CommandDoc(
|
||||
path=("network", "get-tank-properties"),
|
||||
summary="读取水箱属性",
|
||||
description="调用 GET /api/v1/tanks/{tank_id}/properties。",
|
||||
options=(CommandOptionDoc("tank", "水箱 ID", required=True),),
|
||||
examples=("tjwater-cli network get-tank-properties --tank T1",),
|
||||
),
|
||||
("network", "get-all-tanks-properties"): CommandDoc(
|
||||
path=("network", "get-all-tanks-properties"),
|
||||
summary="读取全部水箱属性",
|
||||
description="调用 GET /api/v1/tanks/properties。",
|
||||
examples=("tjwater-cli network get-all-tanks-properties",),
|
||||
),
|
||||
("network", "get-pump-properties"): CommandDoc(
|
||||
path=("network", "get-pump-properties"),
|
||||
summary="读取水泵属性",
|
||||
description="调用 GET /api/v1/pumps/{pump_id}/properties。",
|
||||
options=(CommandOptionDoc("pump", "水泵 ID", required=True),),
|
||||
examples=("tjwater-cli network get-pump-properties --pump PU1",),
|
||||
),
|
||||
("network", "get-all-pumps-properties"): CommandDoc(
|
||||
path=("network", "get-all-pumps-properties"),
|
||||
summary="读取全部水泵属性",
|
||||
description="调用 GET /api/v1/pumps/properties。",
|
||||
examples=("tjwater-cli network get-all-pumps-properties",),
|
||||
),
|
||||
("network", "get-valve-properties"): CommandDoc(
|
||||
path=("network", "get-valve-properties"),
|
||||
summary="读取阀门属性",
|
||||
description="调用 GET /api/v1/valves/{valve_id}/properties。",
|
||||
options=(CommandOptionDoc("valve", "阀门 ID", required=True),),
|
||||
examples=("tjwater-cli network get-valve-properties --valve V1",),
|
||||
),
|
||||
("network", "get-all-valves-properties"): CommandDoc(
|
||||
path=("network", "get-all-valves-properties"),
|
||||
summary="读取全部阀门属性",
|
||||
description="调用 GET /api/v1/valves/properties。",
|
||||
examples=("tjwater-cli network get-all-valves-properties",),
|
||||
),
|
||||
("component", "option", "schema"): CommandDoc(
|
||||
path=("component", "option", "schema"),
|
||||
summary="读取选项 schema",
|
||||
description="kind 支持 time、energy、pump-energy、network。",
|
||||
options=(
|
||||
CommandOptionDoc("kind", "选项类型", required=True),
|
||||
CommandOptionDoc("pump", "pump-energy 时需要的泵 ID"),
|
||||
),
|
||||
examples=(
|
||||
"tjwater-cli component option schema --kind time",
|
||||
"tjwater-cli component option schema --kind energy",
|
||||
"tjwater-cli component option schema --kind pump-energy --pump PUMP1",
|
||||
"tjwater-cli component option schema --kind network",
|
||||
),
|
||||
),
|
||||
("component", "option", "get"): CommandDoc(
|
||||
path=("component", "option", "get"),
|
||||
summary="读取选项属性",
|
||||
description="kind 支持 time、energy、pump-energy、network。",
|
||||
options=(
|
||||
CommandOptionDoc("kind", "选项类型", required=True),
|
||||
CommandOptionDoc("pump", "pump-energy 时需要的泵 ID"),
|
||||
),
|
||||
examples=(
|
||||
"tjwater-cli component option get --kind time",
|
||||
"tjwater-cli component option get --kind energy",
|
||||
"tjwater-cli component option get --kind pump-energy --pump PUMP1",
|
||||
"tjwater-cli component option get --kind network",
|
||||
),
|
||||
),
|
||||
("simulation", "run"): CommandDoc(
|
||||
path=("simulation", "run"),
|
||||
summary="触发指定绝对时间的模拟运行",
|
||||
description="把显式带时区的 RFC3339 start-time 直接传给 POST /api/v1/simulation-runs;服务端按带时区时间处理并统一按 UTC 存储结果,实时数据需后续通过 data timeseries 在对应时间段查询。duration 单位为分钟。",
|
||||
options=(
|
||||
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||
CommandOptionDoc("duration", "持续分钟数", required=True),
|
||||
),
|
||||
examples=("tjwater-cli simulation run --start-time 2025-01-02T03:04:05+08:00 --duration 30",),
|
||||
next_commands=(
|
||||
"tjwater-cli data timeseries realtime links --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00",
|
||||
"tjwater-cli data timeseries realtime nodes --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00",
|
||||
),
|
||||
output="模拟触发结果;实时数据需通过 data timeseries 命令按时间段查询",
|
||||
),
|
||||
("analysis", "burst"): CommandDoc(
|
||||
path=("analysis", "burst"),
|
||||
summary="执行爆管分析",
|
||||
description="读取 burst-file 的 burst_id[] / burst_size[] 并调用 POST /api/v1/burst-analyses;接口本身只返回分析执行结果,方案数据需后续通过 data scheme 命令获取。duration 单位为秒。",
|
||||
options=(
|
||||
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||
CommandOptionDoc("duration", "持续秒数", required=True),
|
||||
CommandOptionDoc("burst-file", "爆管输入 JSON 文件", required=True),
|
||||
CommandOptionDoc("scheme", "方案名称"),
|
||||
),
|
||||
examples=(
|
||||
"tjwater-cli analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 900 --burst-file ./burst.json --scheme burst_case_01",
|
||||
"tjwater-cli data scheme get --name burst_case_01",
|
||||
"tjwater-cli data scheme list",
|
||||
),
|
||||
),
|
||||
("analysis", "valve"): CommandDoc(
|
||||
path=("analysis", "valve"),
|
||||
summary="阀门工况分析。",
|
||||
description="close 模式按指定阀门关闭执行定时长模拟;isolation 模式按指定事故元素计算关阀隔离方案。duration 单位为秒。",
|
||||
options=(
|
||||
CommandOptionDoc(name="mode", description="阀门操作模式:'close' 或 'isolation'", required=True),
|
||||
CommandOptionDoc(name="start-time", description="close 模式需要的起始绝对时间,必须显式带时区偏移"),
|
||||
CommandOptionDoc(name="valve", description="close 模式下需关闭的阀门 ID(可多次指定)", repeated=True),
|
||||
CommandOptionDoc(name="element", description="isolation 模式下的事故元素 ID(可多次指定)", repeated=True),
|
||||
CommandOptionDoc(name="disabled-valve", description="isolation 模式下需排除的故障阀门 ID(可多次指定)", repeated=True),
|
||||
CommandOptionDoc(name="duration", description="close 模式持续秒数,默认 900"),
|
||||
CommandOptionDoc(name="scheme", description="close 模式方案名称"),
|
||||
),
|
||||
examples=(
|
||||
"tjwater-cli analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --valve V2 --duration 900 --scheme valve_case_01",
|
||||
"tjwater-cli analysis valve --mode isolation --element E1 --element E2",
|
||||
"tjwater-cli analysis valve --mode isolation --element E1 --disabled-valve V3",
|
||||
),
|
||||
),
|
||||
("analysis", "flushing"): CommandDoc(
|
||||
path=("analysis", "flushing"),
|
||||
summary="执行冲洗分析",
|
||||
description="读取 valve-setting-file 并转换为 valves[] / valves_k[]。duration 单位为秒,默认 900。",
|
||||
options=(
|
||||
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||
CommandOptionDoc("valve-setting-file", "阀门开度 JSON 文件", required=True),
|
||||
CommandOptionDoc("drainage-node", "排污节点 ID", required=True),
|
||||
CommandOptionDoc("flow", "冲洗流量", required=True),
|
||||
CommandOptionDoc("duration", "持续秒数,默认 900"),
|
||||
CommandOptionDoc("scheme", "方案名称", required=True),
|
||||
),
|
||||
examples=("tjwater-cli analysis flushing --start-time 2025-01-02T03:04:05+08:00 --valve-setting-file ./valve.json --drainage-node N1 --flow 100.0 --duration 900 --scheme flush_case_01",),
|
||||
),
|
||||
("analysis", "age"): CommandDoc(
|
||||
path=("analysis", "age"),
|
||||
summary="执行水龄分析",
|
||||
description="调用 POST /api/v1/water-age-analyses。duration 单位为秒。",
|
||||
options=(
|
||||
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||
CommandOptionDoc("duration", "持续秒数", required=True),
|
||||
),
|
||||
examples=("tjwater-cli analysis age --start-time 2025-01-02T03:04:05+08:00 --duration 900",),
|
||||
),
|
||||
("analysis", "contaminant"): CommandDoc(
|
||||
path=("analysis", "contaminant"),
|
||||
summary="执行污染物模拟",
|
||||
description="调用 POST /api/v1/contaminant-simulations。duration 单位为秒。",
|
||||
options=(
|
||||
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||
CommandOptionDoc("duration", "持续秒数", required=True),
|
||||
CommandOptionDoc("source-node", "污染源节点 ID", required=True),
|
||||
CommandOptionDoc("concentration", "浓度值", required=True),
|
||||
CommandOptionDoc("pattern", "模式 ID"),
|
||||
CommandOptionDoc("scheme", "方案名称", required=True),
|
||||
),
|
||||
examples=("tjwater-cli analysis contaminant --start-time 2025-01-02T03:04:05+08:00 --duration 900 --source-node N1 --concentration 10.0 --scheme contam_case_01",),
|
||||
),
|
||||
("analysis", "sensor-placement", "kmeans"): CommandDoc(
|
||||
path=("analysis", "sensor-placement", "kmeans"),
|
||||
summary="执行 KMeans 传感器选址",
|
||||
description="使用 POST /pressure_sensor_placement_kmeans/,补齐 username 和 min_diameter。",
|
||||
options=(
|
||||
CommandOptionDoc("count", "传感器数量", required=True),
|
||||
CommandOptionDoc("min-diameter", "最小管径,默认 0"),
|
||||
CommandOptionDoc("scheme", "方案名称"),
|
||||
),
|
||||
examples=("tjwater-cli analysis sensor-placement kmeans --count 5 --min-diameter 100 --scheme placement_case_01",),
|
||||
),
|
||||
("analysis", "leakage", "identify"): CommandDoc(
|
||||
path=("analysis", "leakage", "identify"),
|
||||
summary="执行漏损识别",
|
||||
description="把 CLI 时间映射到 scada_start / scada_end。",
|
||||
options=(
|
||||
CommandOptionDoc("start-time", "显式带时区的 SCADA 开始时间", required=True),
|
||||
CommandOptionDoc("end-time", "显式带时区的 SCADA 结束时间", required=True),
|
||||
CommandOptionDoc("scheme", "方案名称"),
|
||||
),
|
||||
examples=("tjwater-cli analysis leakage identify --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme leak_case_01",),
|
||||
),
|
||||
("analysis", "leakage", "schemes", "list"): CommandDoc(
|
||||
path=("analysis", "leakage", "schemes", "list"),
|
||||
summary="列出漏损方案",
|
||||
description="调用 GET /api/v1/schemes,并传入 scheme_type=dma_leak_identification。",
|
||||
examples=("tjwater-cli analysis leakage schemes list",),
|
||||
),
|
||||
("analysis", "leakage", "schemes", "get"): CommandDoc(
|
||||
path=("analysis", "leakage", "schemes", "get"),
|
||||
summary="读取漏损方案详情",
|
||||
description="调用 GET /api/v1/schemes/{scheme_name},并传入 scheme_type=dma_leak_identification。",
|
||||
examples=("tjwater-cli analysis leakage schemes get my_scheme",),
|
||||
),
|
||||
("analysis", "burst-detection", "detect"): CommandDoc(
|
||||
path=("analysis", "burst-detection", "detect"),
|
||||
summary="执行爆管检测",
|
||||
description="调用 POST /api/v1/burst-detections。",
|
||||
options=(
|
||||
CommandOptionDoc("start-time", "显式带时区的 SCADA 开始时间", required=True),
|
||||
CommandOptionDoc("end-time", "显式带时区的 SCADA 结束时间", required=True),
|
||||
CommandOptionDoc("scheme", "方案名称"),
|
||||
),
|
||||
examples=("tjwater-cli analysis burst-detection detect --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme detect_case_01",),
|
||||
),
|
||||
("analysis", "burst-detection", "schemes", "list"): CommandDoc(
|
||||
path=("analysis", "burst-detection", "schemes", "list"),
|
||||
summary="列出爆管检测方案",
|
||||
description="调用 GET /api/v1/schemes,并传入 scheme_type=burst_detection。",
|
||||
examples=("tjwater-cli analysis burst-detection schemes list",),
|
||||
),
|
||||
("analysis", "burst-detection", "schemes", "get"): CommandDoc(
|
||||
path=("analysis", "burst-detection", "schemes", "get"),
|
||||
summary="读取爆管检测方案详情",
|
||||
description="调用 GET /api/v1/schemes/{scheme_name},并传入 scheme_type=burst_detection。",
|
||||
examples=("tjwater-cli analysis burst-detection schemes get my_scheme",),
|
||||
),
|
||||
("analysis", "burst-location", "locate"): CommandDoc(
|
||||
path=("analysis", "burst-location", "locate"),
|
||||
summary="执行爆管定位",
|
||||
description="调用 POST /api/v1/burst-locations;需要 burst-leakage。支持 monitoring 和 simulation 两种数据源。",
|
||||
options=(
|
||||
CommandOptionDoc("start-time", "显式带时区的 SCADA 开始时间", required=True),
|
||||
CommandOptionDoc("end-time", "显式带时区的 SCADA 结束时间", required=True),
|
||||
CommandOptionDoc("burst-leakage", "爆管漏水量", required=True),
|
||||
CommandOptionDoc("scheme", "方案名称"),
|
||||
CommandOptionDoc("data-source", "数据源:monitoring(默认)或 simulation"),
|
||||
CommandOptionDoc("pressure-scada-id", "压力 SCADA ID(可多次指定)", repeated=True),
|
||||
CommandOptionDoc("flow-scada-id", "流量 SCADA ID(可多次指定)", repeated=True),
|
||||
CommandOptionDoc("pressure-file", "包含 burst_pressure/normal_pressure 的 JSON 文件"),
|
||||
CommandOptionDoc("flow-file", "包含 burst_flow/normal_flow 的 JSON 文件"),
|
||||
CommandOptionDoc("use-scada-flow", "启用 SCADA 流量"),
|
||||
),
|
||||
examples=(
|
||||
"tjwater-cli analysis burst-location locate --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --burst-leakage 100.0 --scheme locate_case_01",
|
||||
"tjwater-cli analysis burst-location locate --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --burst-leakage 50.0 --scheme locate_case_01 --data-source simulation --pressure-file ./pressure.json --flow-file ./flow.json",
|
||||
),
|
||||
),
|
||||
("analysis", "burst-location", "schemes", "list"): CommandDoc(
|
||||
path=("analysis", "burst-location", "schemes", "list"),
|
||||
summary="列出爆管定位方案",
|
||||
description="调用 GET /api/v1/schemes,并传入 scheme_type=burst_location。",
|
||||
examples=("tjwater-cli analysis burst-location schemes list",),
|
||||
),
|
||||
("analysis", "burst-location", "schemes", "get"): CommandDoc(
|
||||
path=("analysis", "burst-location", "schemes", "get"),
|
||||
summary="读取爆管定位方案详情",
|
||||
description="调用 GET /api/v1/schemes/{scheme_name},并传入 scheme_type=burst_location。",
|
||||
examples=("tjwater-cli analysis burst-location schemes get my_scheme",),
|
||||
),
|
||||
("analysis", "risk", "pipe-now"): CommandDoc(
|
||||
path=("analysis", "risk", "pipe-now"),
|
||||
summary="读取单条管道当前风险",
|
||||
description="调用 GET /api/v1/pipes/risk-probability-now。",
|
||||
options=(CommandOptionDoc("pipe", "管道 ID", required=True),),
|
||||
examples=("tjwater-cli analysis risk pipe-now --pipe P1",),
|
||||
),
|
||||
("analysis", "risk", "pipe-history"): CommandDoc(
|
||||
path=("analysis", "risk", "pipe-history"),
|
||||
summary="读取单条管道历史风险",
|
||||
description="调用 GET /api/v1/pipes/risk-probability。",
|
||||
options=(CommandOptionDoc("pipe", "管道 ID", required=True),),
|
||||
examples=("tjwater-cli analysis risk pipe-history --pipe P1",),
|
||||
),
|
||||
("analysis", "risk", "network"): CommandDoc(
|
||||
path=("analysis", "risk", "network"),
|
||||
summary="读取全网风险",
|
||||
description="组合 /getnetworkpiperiskprobabilitynow/ 与 /getpiperiskprobabilitygeometries/。",
|
||||
examples=("tjwater-cli analysis risk network",),
|
||||
),
|
||||
("data", "timeseries", "realtime", "links"): CommandDoc(
|
||||
path=("data", "timeseries", "realtime", "links"),
|
||||
summary="查询实时管道时序",
|
||||
description="调用 GET /api/v1/timeseries/realtime/links。",
|
||||
options=(
|
||||
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
|
||||
),
|
||||
examples=("tjwater-cli data timeseries realtime links --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00",),
|
||||
),
|
||||
("data", "timeseries", "realtime", "nodes"): CommandDoc(
|
||||
path=("data", "timeseries", "realtime", "nodes"),
|
||||
summary="查询实时节点时序",
|
||||
description="调用 GET /api/v1/timeseries/realtime/nodes。",
|
||||
options=(
|
||||
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
|
||||
),
|
||||
examples=("tjwater-cli data timeseries realtime nodes --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00",),
|
||||
),
|
||||
("data", "timeseries", "realtime", "simulation-by-id-time"): CommandDoc(
|
||||
path=("data", "timeseries", "realtime", "simulation-by-id-time"),
|
||||
summary="按元素和时间查询实时模拟结果",
|
||||
description="调用 GET /api/v1/timeseries/realtime/by-element。",
|
||||
options=(
|
||||
CommandOptionDoc("id", "元素 ID", required=True),
|
||||
CommandOptionDoc("type", "元素类型:pipe 或 junction;links/nodes 是独立子命令,不是 type 取值", required=True),
|
||||
CommandOptionDoc("time", "显式带时区的查询时间", required=True),
|
||||
),
|
||||
examples=(
|
||||
"tjwater-cli data timeseries realtime simulation-by-id-time --id J1 --type junction --time 2025-01-02T03:30:00+08:00",
|
||||
"tjwater-cli data timeseries realtime simulation-by-id-time --id P1 --type pipe --time 2025-01-02T03:30:00+08:00",
|
||||
),
|
||||
),
|
||||
("data", "timeseries", "realtime", "simulation-by-time-property"): CommandDoc(
|
||||
path=("data", "timeseries", "realtime", "simulation-by-time-property"),
|
||||
summary="按时间和属性查询实时模拟结果",
|
||||
description="调用 GET /api/v1/timeseries/realtime/by-property。pipe 属性:flow、friction、headloss、quality、reaction、setting、status、velocity;junction 属性:actual_demand、total_head、pressure、quality。",
|
||||
options=(
|
||||
CommandOptionDoc("type", "元素类型:pipe 或 junction;links/nodes 是独立子命令,不是 type 取值", required=True),
|
||||
CommandOptionDoc("time", "显式带时区的查询时间", required=True),
|
||||
CommandOptionDoc("property", "属性名;会按 type 校验可选值", required=True),
|
||||
),
|
||||
examples=("tjwater-cli data timeseries realtime simulation-by-time-property --type pipe --time 2025-01-02T03:30:00+08:00 --property flow",),
|
||||
),
|
||||
("data", "timeseries", "scheme", "links"): CommandDoc(
|
||||
path=("data", "timeseries", "scheme", "links"),
|
||||
summary="查询方案管道时序",
|
||||
description="调用 GET /api/v1/timeseries/schemes/links。",
|
||||
options=(
|
||||
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
|
||||
CommandOptionDoc("scheme", "方案名称"),
|
||||
CommandOptionDoc("scheme-type", "方案类型"),
|
||||
),
|
||||
examples=("tjwater-cli data timeseries scheme links --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme",),
|
||||
),
|
||||
("data", "timeseries", "scheme", "node-field"): CommandDoc(
|
||||
path=("data", "timeseries", "scheme", "node-field"),
|
||||
summary="查询方案节点字段时序",
|
||||
description="调用 GET /api/v1/timeseries/schemes/nodes/{node_id}/{field}。field 仅支持 actual_demand、total_head、pressure、quality。",
|
||||
options=(
|
||||
CommandOptionDoc("node", "节点 ID", required=True),
|
||||
CommandOptionDoc("field", "字段名:actual_demand、total_head、pressure、quality", required=True),
|
||||
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
|
||||
CommandOptionDoc("scheme", "方案名称"),
|
||||
CommandOptionDoc("scheme-type", "方案类型"),
|
||||
),
|
||||
examples=("tjwater-cli data timeseries scheme node-field --node J1 --field pressure --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme",),
|
||||
),
|
||||
("data", "timeseries", "scheme", "simulation"): CommandDoc(
|
||||
path=("data", "timeseries", "scheme", "simulation"),
|
||||
summary="查询方案模拟数据",
|
||||
description="支持 by-id-time 与 by-scheme-time-property 两种查询。pipe 属性:flow、friction、headloss、quality、reaction、setting、status、velocity;junction 属性:actual_demand、total_head、pressure、quality。",
|
||||
options=(
|
||||
CommandOptionDoc("query", "查询模式:by-id-time 或 by-scheme-time-property", required=True),
|
||||
CommandOptionDoc("scheme", "方案名称"),
|
||||
CommandOptionDoc("scheme-type", "方案类型"),
|
||||
CommandOptionDoc("id", "元素 ID(by-id-time 时必需)"),
|
||||
CommandOptionDoc("time", "显式带时区的查询时间", required=True),
|
||||
CommandOptionDoc("type", "元素类型:pipe 或 junction;links/nodes 是独立子命令,不是 type 取值"),
|
||||
CommandOptionDoc("property", "属性名(by-scheme-time-property 时必需;会按 type 校验可选值)"),
|
||||
),
|
||||
examples=(
|
||||
"tjwater-cli data timeseries scheme simulation --query by-id-time --id J1 --time 2025-01-02T03:30:00+08:00 --type junction --scheme my_scheme",
|
||||
"tjwater-cli data timeseries scheme simulation --query by-scheme-time-property --time 2025-01-02T03:30:00+08:00 --type pipe --property flow --scheme my_scheme",
|
||||
),
|
||||
),
|
||||
("data", "timeseries", "scada", "query"): CommandDoc(
|
||||
path=("data", "timeseries", "scada", "query"),
|
||||
summary="查询 SCADA 时序",
|
||||
description="device-id 会被转换成后端逗号分隔参数。field 仅支持 monitored_value、cleaned_value。",
|
||||
options=(
|
||||
CommandOptionDoc("device-id", "设备 ID(可多次指定)", required=True, repeated=True),
|
||||
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
|
||||
CommandOptionDoc("field", "字段名:monitored_value、cleaned_value"),
|
||||
),
|
||||
examples=(
|
||||
"tjwater-cli data timeseries scada query --device-id D1 --device-id D2 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00",
|
||||
"tjwater-cli data timeseries scada query --device-id D1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --field monitored_value",
|
||||
),
|
||||
),
|
||||
("data", "timeseries", "composite"): CommandDoc(
|
||||
path=("data", "timeseries", "composite"),
|
||||
summary="执行复合时序查询",
|
||||
description="kind 支持 scada-simulation、element-simulation、element-scada。",
|
||||
options=(
|
||||
CommandOptionDoc("kind", "复合查询类型", required=True),
|
||||
CommandOptionDoc("feature", "特征值(可多次指定,scada-simulation 为 device_id,element-simulation 为 element_id:property,element-scada 为 element_id)", repeated=True),
|
||||
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
|
||||
CommandOptionDoc("scheme", "方案名称"),
|
||||
CommandOptionDoc("scheme-type", "方案类型"),
|
||||
CommandOptionDoc("use-cleaned", "element-scada 使用清洗值"),
|
||||
),
|
||||
examples=(
|
||||
"tjwater-cli data timeseries composite --kind scada-simulation --feature D1 --feature D2 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme",
|
||||
"tjwater-cli data timeseries composite --kind element-simulation --feature J1:pressure --feature P1:flow --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme",
|
||||
"tjwater-cli data timeseries composite --kind element-scada --feature J1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --use-cleaned",
|
||||
),
|
||||
),
|
||||
("data", "timeseries", "composite", "pipeline-health"): CommandDoc(
|
||||
path=("data", "timeseries", "composite", "pipeline-health"),
|
||||
summary="查询管道健康预测",
|
||||
description="调用 GET /api/v1/pipeline-health-predictions。",
|
||||
options=(
|
||||
CommandOptionDoc("pipe", "管道 ID", required=True),
|
||||
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
|
||||
),
|
||||
examples=("tjwater-cli data timeseries composite pipeline-health --pipe P1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00",),
|
||||
),
|
||||
("data", "scada", "get"): CommandDoc(
|
||||
path=("data", "scada", "get"),
|
||||
summary="读取单条 SCADA 元数据",
|
||||
description="kind 仅支持 info。",
|
||||
options=(
|
||||
CommandOptionDoc("kind", "SCADA 数据类型", required=True),
|
||||
CommandOptionDoc("id", "记录 ID", required=True),
|
||||
),
|
||||
examples=("tjwater-cli data scada get --kind info --id SCADA-001",),
|
||||
),
|
||||
("data", "scada", "list"): CommandDoc(
|
||||
path=("data", "scada", "list"),
|
||||
summary="列出 SCADA 元数据",
|
||||
description="kind 仅支持 info。",
|
||||
options=(CommandOptionDoc("kind", "SCADA 数据类型", required=True),),
|
||||
examples=("tjwater-cli data scada list --kind info",),
|
||||
),
|
||||
("data", "scheme", "schema"): CommandDoc(
|
||||
path=("data", "scheme", "schema"),
|
||||
summary="读取方案 schema",
|
||||
description="调用 GET /api/v1/network-schemas/scheme。",
|
||||
examples=("tjwater-cli data scheme schema",),
|
||||
),
|
||||
("data", "scheme", "get"): CommandDoc(
|
||||
path=("data", "scheme", "get"),
|
||||
summary="读取单条方案",
|
||||
description="调用 GET /api/v1/schemes/detail。",
|
||||
options=(CommandOptionDoc("name", "方案名称", required=True),),
|
||||
examples=("tjwater-cli data scheme get --name my_scheme",),
|
||||
),
|
||||
("data", "scheme", "list"): CommandDoc(
|
||||
path=("data", "scheme", "list"),
|
||||
summary="列出方案",
|
||||
description="调用 GET /api/v1/schemes。",
|
||||
examples=("tjwater-cli data scheme list",),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _build_examples(doc: CommandDoc) -> list[str]:
|
||||
return list(doc.examples) if doc.examples else [_build_usage(doc)]
|
||||
|
||||
|
||||
def _is_hidden_path(path: tuple[str, ...]) -> bool:
|
||||
return any(path[: len(prefix)] == prefix for prefix in HIDDEN_PATH_PREFIXES)
|
||||
|
||||
|
||||
def is_hidden_path(path: tuple[str, ...]) -> bool:
|
||||
return _is_hidden_path(path)
|
||||
|
||||
|
||||
def has_subcommands(path_prefix: tuple[str, ...]) -> bool:
|
||||
return any(
|
||||
not _is_hidden_path(doc.path)
|
||||
and doc.path[: len(path_prefix)] == path_prefix
|
||||
and len(doc.path) > len(path_prefix)
|
||||
for doc in COMMAND_DOCS.values()
|
||||
)
|
||||
|
||||
|
||||
def get_group_summary(path_prefix: tuple[str, ...]) -> str:
|
||||
return GROUP_SUMMARIES.get(path_prefix, f"{' '.join(path_prefix)} 可用子命令")
|
||||
|
||||
|
||||
def list_capabilities() -> dict[str, object]:
|
||||
seen: set[tuple[str, ...]] = set()
|
||||
commands: list[dict[str, str]] = []
|
||||
for doc in sorted(COMMAND_DOCS.values(), key=lambda item: item.path):
|
||||
if _is_hidden_path(doc.path):
|
||||
continue
|
||||
prefix = doc.path[:1]
|
||||
if prefix in seen:
|
||||
continue
|
||||
seen.add(prefix)
|
||||
commands.append(
|
||||
{
|
||||
"command": " ".join(prefix),
|
||||
"summary": get_group_summary(prefix),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"summary": "可用一级菜单",
|
||||
"menu_level": 1,
|
||||
"commands": commands,
|
||||
}
|
||||
|
||||
|
||||
def get_command_doc(path: tuple[str, ...]) -> dict[str, object] | None:
|
||||
if _is_hidden_path(path):
|
||||
return None
|
||||
doc = COMMAND_DOCS.get(path)
|
||||
if doc is None:
|
||||
return None
|
||||
return {
|
||||
"ok": True,
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"summary": doc.summary,
|
||||
"command": " ".join(doc.path),
|
||||
"description": doc.description,
|
||||
"usage": _build_usage(doc),
|
||||
"options": [
|
||||
{
|
||||
"name": option.name,
|
||||
"description": option.description,
|
||||
"required": option.required,
|
||||
"repeated": option.repeated,
|
||||
"default": option.default,
|
||||
}
|
||||
for option in doc.options
|
||||
],
|
||||
"examples": _build_examples(doc),
|
||||
"next_commands": list(doc.next_commands),
|
||||
"output": doc.output,
|
||||
}
|
||||
|
||||
|
||||
def list_subcommands(path_prefix: tuple[str, ...], summary: str | None = None) -> dict[str, object]:
|
||||
seen: set[str] = set()
|
||||
commands: list[dict[str, str]] = []
|
||||
for doc in sorted(COMMAND_DOCS.values(), key=lambda item: item.path):
|
||||
if _is_hidden_path(doc.path):
|
||||
continue
|
||||
if doc.path[: len(path_prefix)] != path_prefix or len(doc.path) <= len(path_prefix):
|
||||
continue
|
||||
subcommand = doc.path[len(path_prefix)]
|
||||
if subcommand in seen:
|
||||
continue
|
||||
seen.add(subcommand)
|
||||
current_path = (*path_prefix, subcommand)
|
||||
is_group = has_subcommands(current_path)
|
||||
usage = f"tjwater-cli {' '.join(current_path)} help" if is_group else (doc.examples[0] if doc.examples else _build_usage(doc))
|
||||
commands.append(
|
||||
{
|
||||
"command": " ".join(current_path),
|
||||
"summary": get_group_summary(current_path) if is_group else doc.summary,
|
||||
"usage": usage,
|
||||
"example": f"tjwater-cli {' '.join(current_path)} help" if is_group else _build_examples(doc)[0],
|
||||
}
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"summary": summary or get_group_summary(path_prefix),
|
||||
"commands": commands,
|
||||
}
|
||||
|
||||
|
||||
def _build_usage(doc: CommandDoc) -> str:
|
||||
parts = ["tjwater-cli", *doc.path]
|
||||
for option in doc.options:
|
||||
placeholder = option.name.upper().replace("-", "_")
|
||||
if option.required:
|
||||
parts.extend([f"--{option.name}", f"<{placeholder}>"])
|
||||
else:
|
||||
parts.append(f"[--{option.name} <{placeholder}>]")
|
||||
return " ".join(parts)
|
||||
@@ -1,427 +0,0 @@
|
||||
# Agent CLI 接口范围确认
|
||||
|
||||
本文档确认 `app/api/v1/endpoints/` 面向 Agent CLI 的首批封装范围。
|
||||
|
||||
## 结论
|
||||
|
||||
首批 CLI 采用 **少量顶层入口 + 业务域二级分组 + 只读/分析优先** 的设计。
|
||||
|
||||
```text
|
||||
tjwater-cli network
|
||||
tjwater-cli component
|
||||
tjwater-cli simulation
|
||||
tjwater-cli analysis
|
||||
tjwater-cli data
|
||||
tjwater-cli help
|
||||
```
|
||||
|
||||
首批默认不暴露:
|
||||
|
||||
- 会修改 network 的接口:`add*`、`set*`、`delete*`、`generate*`
|
||||
- 项目生命周期接口:创建、删除、导入、打开、关闭、锁定、解锁、复制
|
||||
- 数据写入/清理接口:insert、update、delete、clean、clear、batch store
|
||||
- 用户管理接口:创建、更新、删除、激活、停用
|
||||
- 快照回滚和批量命令执行接口:undo、redo、pick、batch
|
||||
|
||||
## 设计原则
|
||||
|
||||
- CLI 不按 HTTP endpoint 一比一映射,而按 Agent 任务组织。
|
||||
- 首批只暴露 `schema`、`list`、`get`、`exists`、只读计算和分析类能力。
|
||||
- CLI 输入优先使用显式选项、可重复选项、枚举值和文件路径,尽量不要求用户直接输入 JSON。
|
||||
- CLI 输出统一使用 JSON;首批默认直接在 stdout 返回结构化结果,不再额外设计 `result_ref` / `--out-ref` 输出层。
|
||||
- 首批 CLI 只保留 **Non-interactive / Agent** 认证模式:必须显式注入认证上下文,不隐式复用本机默认登录态,也不设计本地 `login`。
|
||||
- stdout/stderr、退出码、输出 schema version 视为 CLI 契约的一部分,需要独立于 HTTP body 明确定义。
|
||||
- 现有 HTTP 路径的拼写错误、双斜杠、错误方法不继承到 CLI。
|
||||
- 高频命令可以提供 alias,但文档和 skill 只写规范命令。
|
||||
|
||||
## 分级约束
|
||||
|
||||
| 顶层命令 | 二级范围 | 说明 |
|
||||
|---|---|---|
|
||||
| `network` | `get-node-properties`、`get-link-properties` | 管网节点/管线属性查询,只读 |
|
||||
| `component` | `option` | EPANET 选项设置,只读 |
|
||||
| `simulation` | `run` | 模拟运行 |
|
||||
| `analysis` | `burst`、`valve`、`flushing`、`age`、`contaminant`、`sensor-placement`、`leakage`、`burst-detection`、`burst-location`、`risk` | 任务级分析 |
|
||||
| `data` | `timeseries`、`scada`、`scheme`、`extension`、`misc` | 数据查询 |
|
||||
| `help` | `COMMAND` | Agent 能力发现和命令说明 |
|
||||
|
||||
命令深度建议:
|
||||
|
||||
- 常规命令不超过 3 层:`tjwater-cli component option get`
|
||||
- 时序数据允许 4 层:`tjwater-cli data timeseries realtime links`
|
||||
- `risk` 归入 `analysis risk`
|
||||
- `scada`、`scheme`、`extension` 归入 `data`
|
||||
|
||||
## 全局上下文与通用参数
|
||||
|
||||
首批 CLI 建议统一支持以下全局参数:
|
||||
|
||||
```text
|
||||
--server URL
|
||||
--auth-context PATH
|
||||
--scheme SCHEME
|
||||
--timeout SEC
|
||||
--request-id ID
|
||||
```
|
||||
|
||||
参数含义:
|
||||
|
||||
| 参数 | 含义 | 作用域 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `--server URL` | 指定 CLI 要连接的服务端地址 | 连接上下文 | 例如 `https://api.example.com`。用于覆盖环境变量或 `auth-context` 中的默认 base URL,便于在 dev / test / prod 间切换。 |
|
||||
| `--auth-context PATH` | 指定一份显式的隔离认证上下文文件 | 认证上下文 | 面向 agent / 自动化调用。该文件可包含 access token、server、project、user 等字段;不得隐式回退到本机默认状态。 |
|
||||
| `--scheme SCHEME` | 指定当前命令使用的方案 / 工况 / 配置集标识 | 业务资源上下文 | 适用于时序方案、检测方案、定位方案等场景。用于区分当前 project 下的不同分析配置。 |
|
||||
| `--timeout SEC` | 指定本次命令等待响应的超时时间 | 执行控制 | 对同步请求表示请求超时上限,超过后 CLI 直接返回超时错误。 |
|
||||
| `--request-id ID` | 为本次调用显式指定链路追踪 ID | 追踪与观测 | 便于跨前端、CLI、服务端串联日志与审计记录。若未提供,CLI 可自动生成,并应在输出 metadata 中回显。 |
|
||||
|
||||
约束:
|
||||
|
||||
- project 属于认证上下文的一部分,默认从 `auth-context` 或前端传入的 `X-Project-Id` 解析,不作为常规全局参数要求重复传入。
|
||||
- 首批 CLI 不提供 Interactive / Human 登录态;所有命令都按 Agent 模式处理,不得依赖隐式默认认证状态。
|
||||
- `--server`、`--auth-context` 属于连接与认证上下文;`--scheme` 属于业务资源上下文,两者需要分开建模。
|
||||
- `--request-id` 用于链路追踪;若未显式传入,CLI 可以自动生成,但必须在输出 metadata 中回显。
|
||||
|
||||
参数表达建议:
|
||||
|
||||
- 用户输入的业务时间默认按 **UTC+8** 理解;若命令直接接收完整时间戳,应使用 ISO 8601 / RFC 3339 并显式包含时区。CLI 可直接传 `+08:00`,也可传其他时区的绝对时间,由服务端统一归一化。
|
||||
- 范围参数优先拆成 `--start-time` / `--end-time`,不再引入模糊的 `--time-range ...` 写法。
|
||||
- 复合输入优先使用可重复显式选项或 `--input FILE`,避免把多个语义字段压进 `ID:SIZE`、`NODE:VALUE`、`VALVE:OPENING` 这类 shell 内联 DSL。
|
||||
- 若必须传大批量复合参数,优先支持 `--input FILE`,文件格式由 `help` 给出 schema。
|
||||
|
||||
## 首批 CLI 范围
|
||||
|
||||
### Network
|
||||
|
||||
来源:
|
||||
|
||||
```text
|
||||
app/api/v1/endpoints/network/*.py
|
||||
```
|
||||
|
||||
| 命令 | 覆盖接口 | 说明 |
|
||||
|---|---|---|
|
||||
| `tjwater-cli network get-node-properties --node NODE` | `GET /getnodeproperties/` | 读取当前 project 中指定节点的属性 |
|
||||
| `tjwater-cli network get-link-properties --link LINK` | `GET /getlinkproperties/` | 读取当前 project 中指定管线的属性 |
|
||||
| `tjwater-cli network get-all-junction-properties` | `GET /getalljunctionproperties/` | 读取当前 project 中所有节点属性 |
|
||||
| `tjwater-cli network get-all-pipe-properties` | `GET /getallpipeproperties/` | 读取当前 project 中所有管道属性 |
|
||||
|
||||
暂不暴露:
|
||||
|
||||
```text
|
||||
add*
|
||||
set*
|
||||
delete*
|
||||
generate*
|
||||
POST /generatedistrictmeteringarea/
|
||||
POST /generatesubdistrictmeteringarea/
|
||||
POST /generateservicearea/
|
||||
POST /generatevirtualdistrict/
|
||||
```
|
||||
|
||||
备注:`GET /settitle/` 语义是修改标题,首批不暴露。
|
||||
|
||||
### Component
|
||||
|
||||
来源:
|
||||
|
||||
```text
|
||||
app/api/v1/endpoints/components/*.py
|
||||
```
|
||||
|
||||
| 命令 | 覆盖接口 | 说明 |
|
||||
|---|---|---|
|
||||
| `tjwater-cli component option schema --kind time` | `GET /gettimeschema` | 时间选项 schema |
|
||||
| `tjwater-cli component option get --kind time` | `GET /gettimeproperties/` | 时间选项属性 |
|
||||
| `tjwater-cli component option schema --kind energy` | `GET /getenergyschema/` | 全局能耗选项 schema |
|
||||
| `tjwater-cli component option get --kind energy` | `GET /getenergyproperties/` | 全局能耗选项属性 |
|
||||
| `tjwater-cli component option schema --kind pump-energy` | `GET /getpumpenergyschema/` | 泵能耗选项 schema |
|
||||
| `tjwater-cli component option get --kind pump-energy --pump PUMP` | `GET /getpumpenergyproperties//` | 指定泵的能耗选项属性 |
|
||||
| `tjwater-cli component option schema --kind network` | `GET /getoptionschema/` | 管网选项 schema |
|
||||
| `tjwater-cli component option get --kind network` | `GET /getoptionproperties/` | 管网选项属性 |
|
||||
|
||||
暂不暴露:
|
||||
|
||||
```text
|
||||
POST /addcurve/
|
||||
POST /setcurveproperties/
|
||||
POST /deletecurve/
|
||||
POST /addpattern/
|
||||
POST /setpatternproperties/
|
||||
POST /deletepattern/
|
||||
POST /settimeproperties/
|
||||
POST /setenergyproperties/
|
||||
GET /setpumpenergyproperties//
|
||||
POST /setoptionproperties/
|
||||
POST /setcontrolproperties/
|
||||
POST /setruleproperties/
|
||||
POST /setqualityproperties/
|
||||
POST /setemitterproperties/
|
||||
POST /setsource/
|
||||
POST /addsource/
|
||||
POST /deletesource/
|
||||
POST /setreaction/
|
||||
POST /setpipereaction/
|
||||
POST /settankreaction/
|
||||
POST /setmixing/
|
||||
POST /addmixing/
|
||||
POST /deletemixing/
|
||||
POST /setvertexproperties/
|
||||
POST /addvertex/
|
||||
POST /deletevertex/
|
||||
POST /setlabelproperties/
|
||||
POST /addlabel/
|
||||
POST /deletelabel/
|
||||
POST /setbackdropproperties/
|
||||
```
|
||||
|
||||
备注:
|
||||
|
||||
- `options` 当前实际只读接口分为 4 组:`time`、`energy`、`pump-energy`、`network`。
|
||||
- `pump-energy` 是唯一需要额外资源标识的读取接口,必须带 `--pump PUMP`。
|
||||
- 后端现有路径 `GET /getpumpenergyproperties//` 和 `GET /setpumpenergyproperties//` 存在双斜杠 / 方法异常,CLI 不继承这些路径细节,只保留语义化命令。
|
||||
|
||||
### Simulation / Analysis / Risk
|
||||
|
||||
来源:
|
||||
|
||||
```text
|
||||
app/api/v1/endpoints/simulation.py
|
||||
app/api/v1/endpoints/leakage.py
|
||||
app/api/v1/endpoints/burst_detection.py
|
||||
app/api/v1/endpoints/burst_location.py
|
||||
app/api/v1/endpoints/risk.py
|
||||
```
|
||||
|
||||
| 命令 | 覆盖接口 | 说明 |
|
||||
|---|---|---|
|
||||
| `tjwater-cli simulation run --start-time RFC3339 --duration MINUTES` | `POST /simulations/run-by-date` | 按指定绝对开始时间触发当前 project 的实时模拟;`start-time` 必须显式带时区,结果写入服务端时序库,后续通过 `tjwater-cli data timeseries realtime *` 查询 |
|
||||
| `tjwater-cli analysis burst --start-time TIME --duration SEC --scheme SCHEME --burst-file FILE` | `GET /burst-analysis` | 爆管分析;`FILE` 提供爆管点与流量列表,CLI 负责转换为 `burst_ID[]` / `burst_size[]` |
|
||||
| `tjwater-cli analysis valve --mode close\|isolation --start-time TIME --valve VALVE [--scheme SCHEME]` | `GET /valve_close_analysis/`、`GET /valve-isolation-analysis` | 阀门分析;close 模式需要 `--scheme`,`--valve` 可重复 |
|
||||
| `tjwater-cli analysis flushing --start-time TIME --valve-setting-file FILE --drainage-node NODE --flow FLOW --scheme SCHEME [--duration SEC]` | `GET /flushing-analysis` | 冲洗分析;`FILE` 提供阀门与开度列表,CLI 负责转换为 `valves[]` / `valves_k[]` |
|
||||
| `tjwater-cli analysis age --start-time TIME --duration SEC` | `GET /age_analysis/` | 水龄分析 |
|
||||
| `tjwater-cli analysis contaminant --start-time TIME --duration SEC --source-node NODE --concentration VALUE --scheme SCHEME [--pattern PATTERN]` | `GET /contaminant-simulation` | 污染物模拟 |
|
||||
| `tjwater-cli analysis sensor-placement kmeans --count N` | `GET /pressuresensorplacementkmeans/` | 基于 kmeans 的传感器放置分析;不包含创建方案 |
|
||||
| `tjwater-cli analysis leakage identify --scheme SCHEME --start-time TIME --end-time TIME` | `POST /leakage/identify/` | 漏损识别 |
|
||||
| `tjwater-cli analysis leakage schemes list\|get` | `GET /schemes?scheme_type=dma_leak_identification`、`GET /schemes/{scheme_name}?scheme_type=dma_leak_identification` | 漏损方案查询 |
|
||||
| `tjwater-cli analysis burst-detection detect --scheme SCHEME --start-time TIME --end-time TIME` | `POST /burst-detection/detect/` | 爆管检测 |
|
||||
| `tjwater-cli analysis burst-detection schemes list\|get` | `GET /schemes?scheme_type=burst_detection`、`GET /schemes/{scheme_name}?scheme_type=burst_detection` | 爆管检测方案查询 |
|
||||
| `tjwater-cli analysis burst-location locate --scheme SCHEME --start-time TIME --end-time TIME` | `POST /burst-location/locate/` | 爆管定位 |
|
||||
| `tjwater-cli analysis burst-location schemes list\|get` | `GET /schemes?scheme_type=burst_location`、`GET /schemes/{scheme_name}?scheme_type=burst_location` | 爆管定位方案查询 |
|
||||
| `tjwater-cli analysis risk pipe-now --pipe PIPE` | `GET /getpiperiskprobabilitynow/` | 单条管道当前风险 |
|
||||
| `tjwater-cli analysis risk pipe-history --pipe PIPE` | `GET /getpiperiskprobability/` | 单条管道历史风险 |
|
||||
| `tjwater-cli analysis risk network` | `GET /getnetworkpiperiskprobabilitynow/`、`GET /getpiperiskprobabilitygeometries/` | 当前 project 全网风险 |
|
||||
|
||||
暂缓或暂不暴露:
|
||||
|
||||
```text
|
||||
POST /network_project/
|
||||
GET /runproject/
|
||||
POST /network_update/
|
||||
POST /project_management/
|
||||
POST /sensorplacementscheme/create
|
||||
POST /pump_failure/
|
||||
POST /pressure_regulation/
|
||||
POST /scheduling_analysis/
|
||||
POST /daily_scheduling_analysis/
|
||||
```
|
||||
|
||||
执行模型:
|
||||
|
||||
- 首批 CLI 统一按同步命令设计,避免引入额外的异步轮询协议。
|
||||
- `simulation run` 不直接回传全量模拟结果;它负责触发服务端模拟,并返回执行摘要、时间窗口和后续查询提示。
|
||||
- 当前 `simulations/run-by-date` 接口会从 `start_time` 指定的绝对时间开始,按 15 分钟步长运行直到达到 `duration`,结果持久化到服务端时序存储。
|
||||
- `start_time` 必须显式带时区;CLI 推荐直接传 **UTC+8** 时间,服务端统一转换后执行和落库。CLI 文档与帮助信息需要把这条规则写成显式契约,不能把数据库存储时间直接暴露成用户输入语义。
|
||||
- 模拟结果读取统一走 `tjwater-cli data timeseries realtime *`,而不是再单独设计 `simulation output`。
|
||||
- `analysis` 相关命令首批也按同步请求处理;若后续服务端真的引入任务队列,再单独设计 `job` 类基础设施能力。
|
||||
|
||||
### Data
|
||||
|
||||
来源:
|
||||
|
||||
```text
|
||||
app/api/v1/endpoints/timeseries/*.py
|
||||
app/api/v1/endpoints/scada.py
|
||||
app/api/v1/endpoints/schemes.py
|
||||
app/api/v1/endpoints/extension.py
|
||||
app/api/v1/endpoints/misc.py
|
||||
app/api/v1/endpoints/project_data.py
|
||||
```
|
||||
|
||||
| 命令 | 覆盖接口 | 说明 |
|
||||
|---|---|---|
|
||||
| `tjwater-cli data timeseries realtime links --start-time TIME --end-time TIME` | `GET /realtime/links` | 查询指定时间范围内的实时/模拟管道数据 |
|
||||
| `tjwater-cli data timeseries realtime nodes --start-time TIME --end-time TIME` | `GET /realtime/nodes` | 查询指定时间范围内的实时/模拟节点数据 |
|
||||
| `tjwater-cli data timeseries realtime simulation-by-id-time --id ID --type pipe\|junction --time TIME` | `GET /realtime/query/by-id-time` | 查询指定元素在指定时间点的模拟结果 |
|
||||
| `tjwater-cli data timeseries realtime simulation-by-time-property --type pipe\|junction --time TIME --property PROPERTY` | `GET /realtime/query/by-time-property` | 查询指定时间点某类元素某属性的聚合模拟结果 |
|
||||
| `tjwater-cli data timeseries scheme links --scheme SCHEME --start-time TIME --end-time TIME` | `GET /scheme/links`、`GET /scheme/links/{link_id}/field` | 方案管道数据 |
|
||||
| `tjwater-cli data timeseries scheme node-field --node NODE --field FIELD` | `GET /scheme/nodes/{node_id}/field` | 方案节点字段 |
|
||||
| `tjwater-cli data timeseries scheme simulation --query by-id-time\|by-scheme-time-property --scheme SCHEME --id ID --time TIME --property PROPERTY` | `GET /scheme/query/*` | 方案模拟查询 |
|
||||
| `tjwater-cli data timeseries scada query --device-id ID --start-time TIME --end-time TIME [--device-id ID ...] [--field FIELD]` | `GET /scada/by-ids-time-range`、`GET /scada/by-ids-field-time-range` | SCADA 时序;CLI 把重复 `--device-id` 转换为后端逗号分隔参数 |
|
||||
| `tjwater-cli data timeseries composite --kind scada-simulation\|element-simulation\|element-scada --feature FEATURE --start-time TIME --end-time TIME` | `GET /composite/*` | 复合查询,`--feature` 可重复 |
|
||||
| `tjwater-cli data timeseries composite pipeline-health --pipe PIPE --start-time TIME --end-time TIME` | `GET /composite/pipeline-health-prediction` | 管道健康预测 |
|
||||
| `tjwater-cli data scada get\|list --kind info` | `GET /getscadainfo/`、`GET /getallscadainfo/` | `SCADA info` 元数据 |
|
||||
| `tjwater-cli data scheme schema\|get\|list` | `schemes.py` 下 `GET` 接口 | 当前 project 方案查询 |
|
||||
|
||||
- `realtime` 是首批 simulation 结果的主读取域;CLI 可以按任务语义组合 `links`、`nodes`、`simulation-by-id-time`、`simulation-by-time-property`,但底层数据源仍以 `realtime.py` 为准。
|
||||
- `realtime`、`scheme`、`composite` 等时间查询命令面向用户时仍按 **UTC+8** 输入;CLI/服务端负责转换为后端使用的 **UTC0** 条件进行检索。若返回结果直接包含时间戳,必须显式带时区,避免把存储时间和展示时间混淆。
|
||||
|
||||
暂不暴露:
|
||||
|
||||
```text
|
||||
POST /realtime/*/batch
|
||||
DELETE /realtime/*
|
||||
PATCH /realtime/*
|
||||
POST /realtime/simulation/store
|
||||
POST /scheme/*/batch
|
||||
PATCH /scheme/*
|
||||
DELETE /scheme/*
|
||||
POST /scheme/simulation/store
|
||||
POST /scada/batch
|
||||
PATCH /scada/{device_id}/field
|
||||
DELETE /scada/by-id-time-range
|
||||
POST /composite/clean-scada
|
||||
POST /setscadadevice/
|
||||
POST /addscadadevice/
|
||||
POST /deletescadadevice/
|
||||
POST /cleanscadadevice/
|
||||
POST /setscadadevicedata/
|
||||
POST /addscadadevicedata/
|
||||
POST /deletescadadevicedata/
|
||||
POST /cleanscadadevicedata/
|
||||
POST /setscadaelement/
|
||||
POST /addscadaelement/
|
||||
POST /deletescadaelement/
|
||||
POST /cleanscadaelement/
|
||||
POST /setextensiondata/
|
||||
POST /test_dict/
|
||||
GET /getjson/
|
||||
```
|
||||
|
||||
### 不纳入首批 CLI 的运维接口
|
||||
|
||||
来源:
|
||||
|
||||
```text
|
||||
app/api/v1/endpoints/snapshots.py
|
||||
app/api/v1/endpoints/cache.py
|
||||
app/api/v1/endpoints/audit.py
|
||||
app/api/v1/endpoints/users.py
|
||||
```
|
||||
|
||||
这些接口不纳入首批 Agent CLI。原因是它们更偏运维、审计或状态回滚,不属于 Agent 面向水务业务分析的核心调用范围。
|
||||
|
||||
暂不暴露:
|
||||
|
||||
```text
|
||||
GET /getcurrentoperationid/
|
||||
GET /getsnapshots/
|
||||
GET /havesnapshot/
|
||||
GET /havesnapshotforoperation/
|
||||
GET /havesnapshotforcurrentoperation/
|
||||
GET /getrestoreoperation/
|
||||
POST /undo/
|
||||
POST /redo/
|
||||
POST /takesnapshot*/
|
||||
POST /picksnapshot/
|
||||
POST /pickoperation/
|
||||
GET /syncwithserver/
|
||||
POST /batch/
|
||||
POST /compressedbatch/
|
||||
POST /setrestoreoperation/
|
||||
GET /queryredis/
|
||||
POST /clearrediskey/
|
||||
POST /clearrediskeys/
|
||||
POST /clearallredis/
|
||||
GET /audit/logs
|
||||
GET /audit/logs/my
|
||||
GET /audit/logs/count
|
||||
GET /getuserschema/
|
||||
GET /getuser/
|
||||
GET /getallusers/
|
||||
```
|
||||
|
||||
## Help
|
||||
|
||||
`help` 不直接对应现有 endpoint,但建议作为 Agent CLI 的基础设施。能力发现更适合复用 CLI 的 `help` 语义,而不是新增一个偏内部化的 `capability` 顶层命令。
|
||||
|
||||
| 命令 | 说明 |
|
||||
|---|---|
|
||||
| `tjwater-cli help` | 返回当前 CLI 能力清单,供 Agent 发现可用命令 |
|
||||
| `tjwater-cli help COMMAND` | 返回某个命令的参数、输出、示例和推荐后续命令 |
|
||||
|
||||
输出补充约束:
|
||||
|
||||
- 首批 CLI 不再设计通用 `result_ref` / `--out-ref` 机制。
|
||||
- 若某业务命令确实需要落本地文件,应由所属命令显式提供 `--output PATH`。
|
||||
- 若后续出现超大结果集、必须脱离 stdout 传输时,再单独设计结果引用机制,而不是在首批 CLI 中预埋未闭环能力。
|
||||
|
||||
## 输出规范
|
||||
|
||||
进程级契约:
|
||||
|
||||
- `stdout`:默认只输出一个 JSON 对象,供 agent / 脚本稳定解析。
|
||||
- `stderr`:输出进度、警告和诊断信息;不得混入结构化结果 JSON。
|
||||
- 退出码必须稳定,不能简单透传底层 HTTP status。
|
||||
|
||||
建议退出码:
|
||||
|
||||
| 退出码 | 含义 |
|
||||
|---|---|
|
||||
| `0` | 成功 |
|
||||
| `2` | CLI 参数错误 / 用法错误 |
|
||||
| `3` | 认证失败 |
|
||||
| `4` | 权限不足 |
|
||||
| `5` | 资源不存在 |
|
||||
| `6` | 冲突、前置条件不满足或非法状态 |
|
||||
| `7` | 服务端错误 |
|
||||
|
||||
成功:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"schema_version": "tjwater-cli/v1",
|
||||
"summary": "读取成功",
|
||||
"data": {},
|
||||
"metadata": {},
|
||||
"next_commands": []
|
||||
}
|
||||
```
|
||||
|
||||
失败:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"schema_version": "tjwater-cli/v1",
|
||||
"summary": "认证失败",
|
||||
"error": {
|
||||
"code": "UNAUTHENTICATED",
|
||||
"message": "missing access token for agent context",
|
||||
"retryable": false
|
||||
},
|
||||
"data": null,
|
||||
"metadata": {},
|
||||
"next_commands": [
|
||||
"tjwater-cli <command> --auth-context /path/to/auth-context.json"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
补充约束:
|
||||
|
||||
- `metadata` 至少建议包含:`request_id`、`server`、`duration_ms`、`generated_at`。
|
||||
- `next_commands` 是面向 agent 的推荐后续动作,不影响退出码和主结果语义。
|
||||
- 所有 `help` 输出也应带 `schema_version`,便于 agent 做能力协商。
|
||||
|
||||
## 后续开放条件
|
||||
|
||||
如后续要开放写操作,需要单独设计:
|
||||
|
||||
- 权限校验
|
||||
- dry-run / preview
|
||||
- 显式确认机制
|
||||
- 审计日志
|
||||
- 变更快照
|
||||
- 回滚策略
|
||||
- Agent 可读的错误恢复建议
|
||||
@@ -3,7 +3,7 @@
|
||||
"contracts": {
|
||||
"server": {
|
||||
"file": "server-v1.openapi.json",
|
||||
"sha256": "d80a968d281fdb2953364a5979c2d61fda5151a1e1759c01cc96780b11a6d56c"
|
||||
"sha256": "b003a57c9b8c1a041b644363ef58afb8bfcede1fe076ce48a190d2a1ac915e3f"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+122
-334
@@ -2598,6 +2598,18 @@
|
||||
"title": "Map Y",
|
||||
"type": "number"
|
||||
},
|
||||
"max_pipe_diameter": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "节点关联管道的最大管径,单位:毫米",
|
||||
"title": "Max Pipe Diameter"
|
||||
},
|
||||
"node_id": {
|
||||
"title": "Node Id",
|
||||
"type": "string"
|
||||
@@ -2613,6 +2625,7 @@
|
||||
},
|
||||
"required": [
|
||||
"node_id",
|
||||
"max_pipe_diameter",
|
||||
"project_x",
|
||||
"project_y",
|
||||
"map_x",
|
||||
@@ -19425,108 +19438,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/network-schemas/user": {
|
||||
"get": {
|
||||
"description": "获取指定网络的用户模式定义",
|
||||
"operationId": "get_network_schemas_user",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "header",
|
||||
"name": "X-Project-Id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "X-Project-Id",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"additionalProperties": {
|
||||
"type": "object"
|
||||
},
|
||||
"title": "Response Get Network Schemas User",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Authentication required"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Insufficient permission"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Resource not found"
|
||||
},
|
||||
"409": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Resource conflict"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation error"
|
||||
},
|
||||
"503": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Dependency unavailable"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"OAuth2PasswordBearer": []
|
||||
}
|
||||
],
|
||||
"summary": "获取用户模式",
|
||||
"tags": [
|
||||
"Users"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/network-schemas/valve": {
|
||||
"get": {
|
||||
"description": "获取指定水网中所有阀门的架构和字段定义",
|
||||
@@ -27430,7 +27341,7 @@
|
||||
},
|
||||
"/api/v1/projects/current/lock/ownership": {
|
||||
"get": {
|
||||
"description": "检查指定项目是否被当前客户端 (IP) 锁定。",
|
||||
"description": "检查指定项目是否被当前访问地址 (IP) 锁定。",
|
||||
"operationId": "get_projects_current_lock_ownership",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -35648,6 +35559,114 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/sensor-placement-candidates/{node_id}": {
|
||||
"get": {
|
||||
"operationId": "get_sensor_placement_candidates_node_id",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "node_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"maxLength": 32,
|
||||
"minLength": 1,
|
||||
"title": "Node Id",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "header",
|
||||
"name": "X-Project-Id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "X-Project-Id",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SensorPointResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Authentication required"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Insufficient permission"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Resource not found"
|
||||
},
|
||||
"409": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Resource conflict"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation error"
|
||||
},
|
||||
"503": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Dependency unavailable"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"OAuth2PasswordBearer": []
|
||||
}
|
||||
],
|
||||
"summary": "获取监测点候选节点详情",
|
||||
"tags": [
|
||||
"Sensor Placement"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/sensor-placement-optimization-runs": {
|
||||
"post": {
|
||||
"operationId": "post_sensor_placement_optimization_runs",
|
||||
@@ -47552,237 +47571,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/users": {
|
||||
"get": {
|
||||
"description": "获取指定网络的所有用户列表",
|
||||
"operationId": "get_users",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "limit",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"default": 100,
|
||||
"maximum": 1000,
|
||||
"minimum": 1,
|
||||
"title": "Limit",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "offset",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"default": 0,
|
||||
"minimum": 0,
|
||||
"title": "Offset",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "header",
|
||||
"name": "X-Project-Id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "X-Project-Id",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Page_dict_Any__Any__"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Authentication required"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Insufficient permission"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Resource not found"
|
||||
},
|
||||
"409": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Resource conflict"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation error"
|
||||
},
|
||||
"503": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Dependency unavailable"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"OAuth2PasswordBearer": []
|
||||
}
|
||||
],
|
||||
"summary": "获取所有用户",
|
||||
"tags": [
|
||||
"Users"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/users/detail": {
|
||||
"get": {
|
||||
"description": "获取指定网络中的单个用户信息",
|
||||
"operationId": "get_users_detail",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "用户名",
|
||||
"in": "query",
|
||||
"name": "user_name",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"description": "用户名",
|
||||
"title": "User Name",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "header",
|
||||
"name": "X-Project-Id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "X-Project-Id",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"title": "Response Get Users Detail",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Authentication required"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Insufficient permission"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Resource not found"
|
||||
},
|
||||
"409": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Resource conflict"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation error"
|
||||
},
|
||||
"503": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Dependency unavailable"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"OAuth2PasswordBearer": []
|
||||
}
|
||||
],
|
||||
"summary": "获取单个用户",
|
||||
"tags": [
|
||||
"Users"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/valve-closure-analyses": {
|
||||
"post": {
|
||||
"description": "高级版本的阀门关闭分析,支持同时关闭多个阀门,并在指定持续时间内进行模拟。返回纯文本格式的分析结果。",
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
# 监测点灵敏度算法优化说明
|
||||
|
||||
本文记录压力监测点布置算法的改造方案、数学含义、复杂度变化和本地验证结果。实现位于 `app/algorithms/sensor/sensitivity.py`,对外兼容入口仍为 `get_ID(name, sensor_num, min_diameter)`。
|
||||
|
||||
## 改造目标
|
||||
|
||||
原算法根据节点压力对管道粗糙度变化的灵敏度,以及节点到全网的水力距离,给每个候选节点评分。空间聚类用于限制监测点过度集中。业务评分可以写成:
|
||||
|
||||
$$
|
||||
score_i = sensitivity_i \times \sum_j distance(i, j)
|
||||
$$
|
||||
|
||||
本次改造保留这套评分语义和空间覆盖原则,主要处理以下问题:
|
||||
|
||||
- 稠密的节点与节点、节点与管道、管道与管道矩阵占用大量内存。
|
||||
- 显式计算行列式、逆矩阵和伪逆,计算量随节点数快速增长。
|
||||
- 从每个节点执行最短路径并保存全节点距离矩阵,时间和内存均为平方级。
|
||||
- 一次任务重复运行 EPANET,并保留算法没有使用的扩展时序结果。
|
||||
- 聚类没有固定随机状态,相同输入可能返回不同结果。
|
||||
- KMeans 按候选节点密度最小化平方距离,密集管网会系统性获得更多监测点,不能保证地图或管网路径覆盖均匀。
|
||||
- WNTR 管径使用米,接口参数使用毫米,旧候选过滤没有统一单位。
|
||||
|
||||
## 稀疏表示替代稠密矩阵
|
||||
|
||||
管网的节点通常只连接少量管道。新实现使用 SciPy CSR 或 CSC 矩阵保存节点与管道关联矩阵、水力有向图和水力雅可比矩阵,只记录真实存在的连接。
|
||||
|
||||
准备阶段生成一个只读的 `_PreparedNetwork`,其中包括:
|
||||
|
||||
- 参与分析的节点及其在完整模型中的索引。
|
||||
- 符合安装条件的候选节点索引。
|
||||
- 归一化前的二维坐标。
|
||||
- 稀疏节点与管道关联矩阵。
|
||||
- 管道导通系数和粗糙度响应系数。
|
||||
- 按实际流向构建的稀疏水力距离图。
|
||||
- 按初始运行状态构建的无向物理覆盖图。
|
||||
|
||||
水库、水箱、泵和阀门的边界节点继续沿用原算法的排除规则。与水源直接相连的管道不参加扰动计算。平行有向边只保留权重最小的边,以符合最短路径语义。
|
||||
|
||||
主体存储由平方级降到接近 `O(n + m)`。稀疏 LU 分解可能产生填充,其内存仍取决于管网拓扑,但实现不会再主动分配完整的 `n × n` 或 `n × m` 数组。
|
||||
|
||||
## 用 Cauchy 投影估计压力灵敏度
|
||||
|
||||
旧算法先显式计算压力对管道粗糙度的响应矩阵:
|
||||
|
||||
$$
|
||||
J = X^{-1} A S
|
||||
$$
|
||||
|
||||
其中,`X` 是节点水力雅可比矩阵,`A` 是节点与管道关联矩阵,`S` 是管道粗糙度响应矩阵。节点灵敏度是 `J` 对应行的 L1 范数。完整的 `J` 为 `节点数 × 管道数`,大模型无法保存。
|
||||
|
||||
新算法使用 Cauchy 分布的 1-stable 特性估计每一行的 L1 范数。设 `R` 为 Cauchy 随机投影矩阵,只需求解:
|
||||
|
||||
$$
|
||||
Y = X^{-1} A S R
|
||||
$$
|
||||
|
||||
对节点 `i` 而言,`Y` 中每个投影值服从以 `||J_i||_1` 为尺度的 Cauchy 分布。实现使用投影绝对值的对数几何均值估计 `log(||J_i||_1)`,不保存完整灵敏度矩阵。
|
||||
|
||||
当前固定参数如下:
|
||||
|
||||
| 参数 | 数值 | 用途 |
|
||||
|---|---:|---|
|
||||
| 随机种子 | 42 | 保证结果可复现 |
|
||||
| Cauchy 投影数 | 256 | 控制灵敏度估计精度 |
|
||||
| 投影批大小 | 16 | 限制投影中间矩阵内存 |
|
||||
| 雅可比正则化 | `max(abs(diag(X))) × sqrt(eps)` | 改善接近奇异矩阵的稳定性 |
|
||||
| 稀疏排序 | `MMD_AT_PLUS_A` | 减少 LU 分解填充 |
|
||||
|
||||
水力雅可比矩阵只进行一次稀疏 LU 分解,256 次投影复用该分解结果。批处理期间最多保留 16 列投影数据。
|
||||
|
||||
## 用空间代表点估计水力距离总和
|
||||
|
||||
旧算法从每个节点执行一次最短路径,并保存 `n × n` 的水力距离矩阵。新算法根据节点坐标构建最多 256 个空间代表点:
|
||||
|
||||
1. 使用固定随机状态的 `MiniBatchKMeans` 对节点坐标分组。
|
||||
2. 每组选择最靠近聚类中心的节点作为代表点。
|
||||
3. 代表点权重等于该组包含的节点数。
|
||||
4. 在反向水力图上从代表点执行有向 Dijkstra,得到原图中各节点到代表点的距离。
|
||||
|
||||
节点 `i` 的全网水力距离总和估计为:
|
||||
|
||||
$$
|
||||
distance\_sum_i \approx \sum_{l \in landmarks} weight_l \times distance(i, l)
|
||||
$$
|
||||
|
||||
Dijkstra 每批处理 16 个代表点,内存中只保留当前距离块。不可达距离按原算法约定计为 0,避免断开分支得到无穷评分。
|
||||
|
||||
## 评分约束下的混合覆盖选点
|
||||
|
||||
最终排序使用对数形式:
|
||||
|
||||
$$
|
||||
log\_score_i = log\_sensitivity_i + \log(distance\_sum_i)
|
||||
$$
|
||||
|
||||
对数是单调函数,因此该排序等价于比较 `sensitivity_i × distance_sum_i`,同时可以避免大数乘法溢出。
|
||||
|
||||
最终选点不再对全部候选节点执行 KMeans。KMeans 的目标函数按候选节点数计权,节点密集区域即使地理范围较小,也会获得更多聚类中心。本次改为评分约束下的最远空白区优先策略。
|
||||
|
||||
候选坐标使用同一个尺度因子归一化,保留管网原始长宽比。初始状态下开启的管道按物理长度构建无向覆盖图,开启的泵和阀门作为点连接;关闭连接会形成独立区域。监测点名额首先按各连通区域的有效管道长度分配:名额足够时每个区域至少一个,其余名额按最大余数法分配,并受该区域候选数量限制。
|
||||
|
||||
选点按区域名额从多到少处理,使主干管网先形成覆盖骨架。第一个区域的首点取综合评分最高的候选;后续区域的首点也必须考虑此前所有区域的已选点,先进入全局地图空白度前 70% 的候选集,再比较综合评分。这一约束避免两个拓扑断开但地图上重叠或相邻的区域各自选择一个近邻高分点。
|
||||
|
||||
设全局已选集合为 `S`,其余候选的最近地图距离和本连通区域内的最近管网路径距离分别为:
|
||||
|
||||
$$
|
||||
g_i = \min_{s \in S} ||x_i-x_s||, \qquad
|
||||
t_i = \min_{s \in S} shortest\_path(i, s)
|
||||
$$
|
||||
|
||||
两类距离分别除以当前未选候选中的最大值,混合空白度取两者较大值:
|
||||
|
||||
$$
|
||||
coverage_i = \max\left(\frac{g_i}{\max g}, \frac{t_i}{\max t}\right)
|
||||
$$
|
||||
|
||||
每轮只保留空白度达到当前最大值 70% 的候选,再从中选择综合评分最高的节点。地图距离始终相对全部已选区域更新,管网路径距离在当前连通区域内更新。这样地图或管网路径中任一维度仍有明显空白时,该区域都不会被高密度节点误判为已经覆盖。综合评分相同时按节点 ID 排序,结果数量、唯一性和可复现性保持不变。
|
||||
|
||||
实现只维护候选节点的最近距离数组。每新增一个监测点,执行一次单源 Dijkstra 并增量更新,不构造候选节点两两距离矩阵。
|
||||
|
||||
所有模型使用同一套算法和相同参数,不根据节点数量切换实现。
|
||||
|
||||
## EPANET 只计算初始状态
|
||||
|
||||
后续计算只读取水力结果的第一个时刻。新实现会临时将 `wn.options.time.duration` 设置为 0,只运行一次 EPANET 初始状态模拟,并在结束或异常后恢复原始时长。
|
||||
|
||||
EPANET 中间文件写入独立的 `TemporaryDirectory`,任务结束后自动清理。并发请求不再共用工作目录下的 `temp.*` 文件。
|
||||
|
||||
旧调用链最多重复运行约四次 EPANET,新调用链只运行一次,也不会分配没有使用的完整时序结果。
|
||||
|
||||
## 最小管径规则
|
||||
|
||||
`min_diameter` 的接口单位是毫米,WNTR 中的管径单位是米。准备阶段使用以下换算:
|
||||
|
||||
$$
|
||||
diameter\_mm = diameter\_m \times 1000
|
||||
$$
|
||||
|
||||
节点连接的管道中,只要至少一根达到最小管径,该节点就可以作为安装候选。小管径管道仍参加全网水力和灵敏度计算,管径条件只限制监测点安装位置。
|
||||
|
||||
当候选节点少于请求的监测点数量时,算法会返回包含候选数量和请求数量的明确错误。
|
||||
|
||||
## 复杂度变化
|
||||
|
||||
下表中的 `n` 为参与分析的节点数,`m` 为参与分析的管道数,`k=256` 为灵敏度投影数,`l≤256` 为水力距离代表点数,`b=16` 为批大小。
|
||||
|
||||
| 环节 | 原实现 | 新实现 |
|
||||
|---|---|---|
|
||||
| 节点与管道关系 | 稠密 `n × m` | CSR 稀疏矩阵,约 `O(n + m)` |
|
||||
| 节点关系与距离 | 多个稠密 `n × n` 矩阵 | 稀疏有向图和流式距离块 |
|
||||
| 灵敏度 | 稠密行列式、逆矩阵或伪逆,时间接近 `O(n³)` | 一次稀疏 LU 分解和 `k` 次稀疏求解 |
|
||||
| 灵敏度结果 | 保存完整 `n × m` 响应矩阵 | 保存 `n` 个对数灵敏度和当前投影批 |
|
||||
| 水力距离 | 从全部节点执行最短路径并保存 `n × n` 结果 | 从至多 `l` 个代表点执行 Dijkstra,每批 `b` 个 |
|
||||
| 最终选点 | 按节点密度分配的完整 KMeans | 连通区域配额和 70% 混合覆盖,线性距离数组 |
|
||||
| 水力模拟 | 调用链中重复执行,并可能保存完整时序 | 一次初始状态模拟 |
|
||||
|
||||
稀疏 LU 的时间和内存不能简单视为线性,其填充程度受网络拓扑影响。当前压力测试覆盖到 20.3 万原始节点,不能据此保证任意更大或连接更稠密的模型都保持相同比例。
|
||||
|
||||
## 精度取舍
|
||||
|
||||
新实现不逐元素生成旧算法的完整精确矩阵,而是估计最终排序需要的两个统计量:
|
||||
|
||||
- Cauchy 投影估计压力响应矩阵每一行的 L1 范数。
|
||||
- 加权空间代表点估计节点到全网的水力距离总和。
|
||||
|
||||
单元测试使用小型模型构造完整稠密参考结果,要求近似方案选点的精确参考目标值不低于精确方案的 95%。本地模型对比结果如下:
|
||||
|
||||
| 模型 | 近似选点的精确参考目标比 |
|
||||
|---|---:|
|
||||
| `fengxian.inp` | 98.80% |
|
||||
| MD 模型 | 99.71% |
|
||||
|
||||
固定随机种子和固定采样数保证同一模型、监测点数量和最小管径得到相同结果。近似排序仍可能与完整稠密算法不同,特别是多个候选节点得分接近时。
|
||||
|
||||
混合覆盖会主动放弃部分集中在同一区域的高分节点。单点综合评分之和因此不是唯一质量指标,还需要同时检查未覆盖半径、最小点间距和入选节点的评分百分位。
|
||||
|
||||
## 资源压力测试记录
|
||||
|
||||
以下结果来自 2026-08-03 的本地验证。环境为 Linux、Python 3.12、Conda `server` 环境,主机物理内存约 30 GiB。测试参数统一为 20 个监测点、最小管径 0。
|
||||
|
||||
外部看门狗使用以下停止条件:
|
||||
|
||||
- 算法进程树 RSS 达到 7.5 GiB。
|
||||
- 系统可用内存低于 6 GiB。
|
||||
- 单模型运行超过 600 秒。
|
||||
- 子进程虚拟地址空间硬限制为 8 GiB。
|
||||
|
||||
任一条件满足时,看门狗会终止整个进程组。三次压力测试均未触发停止条件。
|
||||
|
||||
| 模型 | 原始节点 | 实际分析节点 | 实际分析管道 | 算法流水线耗时 | 峰值 RSS |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
| `temp/leakage/temp_3698123.inp` | 31,143 | 28,723 | 29,974 | 2.77 秒 | 467 MiB |
|
||||
| `inp/jbh.inp` | 94,049 | 69,949 | 82,369 | 8.92 秒 | 980 MiB |
|
||||
| `inp/Todo/v-16常熟模型.inp` | 203,569 | 145,948 | 176,512 | 20.00 秒 | 1.84 GiB |
|
||||
|
||||
实际分析节点少于原始节点,是因为水库、水箱、泵和阀门边界节点按算法规则排除。20.3 万节点模型原文件使用非标准的 `REPORTING TIMESTEP`,并且缺少 `[END]`。压力测试只在隔离临时副本中将其规范化,没有修改原文件。
|
||||
|
||||
20.3 万节点模型各阶段耗时如下:
|
||||
|
||||
| 阶段 | 耗时 |
|
||||
|---|---:|
|
||||
| 模型加载 | 6.63 秒 |
|
||||
| EPANET 初始状态模拟 | 8.30 秒 |
|
||||
| 稀疏数据准备 | 3.00 秒 |
|
||||
| 压力灵敏度估计 | 0.76 秒 |
|
||||
| 水力距离估计 | 0.94 秒 |
|
||||
| 监测点选择 | 0.37 秒 |
|
||||
|
||||
### `tjwater` 分布优化前后对比
|
||||
|
||||
2026-08-03 使用 `db_inp/tjwater.db.inp`、20 个监测点、最小管径 0 进行同机对比。进程通过 systemd scope 限制在 2.5 GiB 内存,并设置 90 秒超时;两次运行均未触发保护。覆盖距离使用保持长宽比后的模型坐标,按管网最大轴跨度归一化。
|
||||
|
||||
| 指标 | KMeans 选点 | 70% 混合覆盖 | 变化 |
|
||||
|---|---:|---:|---:|
|
||||
| 实际分析/候选节点 | 87,877 | 87,877 | 不变 |
|
||||
| 最大地图覆盖半径 | 0.176786 | 0.173941 | -1.61% |
|
||||
| P95 地图覆盖半径 | 0.122093 | 0.090686 | -25.72% |
|
||||
| 最小监测点间距 | 0.000559 | 0.040887 | 约 73.2 倍 |
|
||||
| 综合评分中位百分位 | 98.76% | 95.41% | -3.35 个百分点 |
|
||||
| 端到端耗时 | 12.34 秒 | 11.71 秒 | -5.11% |
|
||||
| 峰值 RSS | 913 MiB | 932 MiB | +19 MiB |
|
||||
|
||||
结果达到预定验收条件:P95 覆盖半径下降超过 15%,评分中位百分位下降少于 10 个百分点,运行时间没有增加,峰值内存增加远低于 256 MiB。
|
||||
|
||||
### 2026-08-03:跨连通区域共享全局间距
|
||||
|
||||
首次混合覆盖结果中,节点 `121302` 与 `110874` 分属两个不可达连通区域,但地图距离只有约 550.47 个模型单位。旧的分区独立首点规则分别选中了两个区域的最高分节点,形成视觉近邻。加入跨区域全局地图间距后,`121302` 被替换,最小归一化点间距由 0.013685 进一步提高到 0.040887。
|
||||
|
||||
本次调整保留连通区域名额,改变区域之间互不感知的选点方式。区域按名额从多到少处理,主干管网先形成覆盖骨架。第一个区域仍从综合评分最高的候选开始;后续区域选择首点时,先计算该区域所有候选到全局已选点的最近地图距离,只保留达到本区域最大空白距离 70% 的候选,再比较灵敏度综合评分。区域内部后续选点继续使用地图距离与管网路径距离的混合空白度。
|
||||
|
||||
这项约束只影响跨区域首点选择,不改变灵敏度估计、水力距离估计、区域名额、最小管径规则和公开 API。合成回归模型会构造两个地图上重叠但拓扑断开的区域,确保算法不会再次分别选择两个相邻高分点。
|
||||
|
||||
## 测试与回归验证
|
||||
|
||||
新增测试位于 `tests/unit/test_sensor_sensitivity.py`,覆盖以下行为:
|
||||
|
||||
- 相同输入返回相同节点,并且每次调用只运行一次 EPANET。
|
||||
- EPANET 只保留初始状态,模型原始模拟时长能够恢复。
|
||||
- 关联矩阵和距离图保持 CSR 稀疏格式。
|
||||
- 最小管径按毫米过滤安装候选。
|
||||
- 近似方案在稠密参考目标上的结果不低于 95%。
|
||||
- 高密西部、低密东部的合成模型不再按候选节点密度分配名额。
|
||||
- 地理位置相邻但拓扑断开的区域在名额允许时分别获得监测点。
|
||||
- 地图上重叠的独立区域共享全局地理间距,不能各自选择相邻的首个高分点。
|
||||
- 名额不足时优先覆盖有效管道长度更大的连通区域。
|
||||
- 重复坐标依靠管网路径距离继续选点,并保持数量和确定性。
|
||||
- 非法监测点数量和最小管径返回明确错误。
|
||||
- 模拟结束后不残留共享 `temp.*` 文件。
|
||||
|
||||
回归命令:
|
||||
|
||||
```bash
|
||||
conda run -n server python -m pytest \
|
||||
tests/unit \
|
||||
tests/auth \
|
||||
tests/api/test_sensor_placement_endpoints.py \
|
||||
tests/api/test_simulation_endpoints.py \
|
||||
-q
|
||||
```
|
||||
|
||||
2026-08-03 的执行结果为 `142 passed, 2 skipped, 7 warnings`。`git diff --check` 同时通过。
|
||||
|
||||
## 实现边界
|
||||
|
||||
- 256 次投影和 256 个代表点是当前质量与性能验证后的固定参数。调整参数需要重新运行稠密参考质量测试和大模型压力测试。
|
||||
- 稀疏 LU 对高连接度或拓扑特殊的模型可能产生更多填充,应继续用进程级资源保护运行未知大模型。
|
||||
- 算法使用单个初始水力状态。如果业务目标改为覆盖全天多个工况,需要先定义多工况评分和结果合并规则,不能直接恢复长时段模拟后沿用当前评分。
|
||||
- 节点坐标用于代表点构建和地图覆盖,假定其能表达一致的平面相对距离;缺少有效二维坐标的模型会返回错误。
|
||||
- 物理覆盖图使用初始水力状态。全天工况中频繁开闭的阀门或泵需要在多工况方案中重新定义连通区域合并规则。
|
||||
- 当前压力测试验证到 203,569 个原始节点,没有验证 30 万节点模型。
|
||||
@@ -0,0 +1,44 @@
|
||||
<#import "template.ftl" as layout>
|
||||
<#assign isLogout = message.summary == msg("successLogout")>
|
||||
<@layout.registrationLayout displayMessage=false; section>
|
||||
<#if section = "header">
|
||||
<#if isLogout>
|
||||
${kcSanitize(msg("tjwaterLogoutTitle"))?no_esc}
|
||||
<#elseif messageHeader??>
|
||||
${kcSanitize(msg("${messageHeader}"))?no_esc}
|
||||
<#else>
|
||||
${message.summary}
|
||||
</#if>
|
||||
<#elseif section = "form">
|
||||
<#if isLogout>
|
||||
<div id="kc-info-message" class="tjwater-logout-message" role="status">
|
||||
<div class="tjwater-logout-mark" aria-hidden="true">
|
||||
<span></span>
|
||||
</div>
|
||||
<div class="tjwater-logout-copy">
|
||||
<p class="tjwater-logout-eyebrow">${kcSanitize(msg("tjwaterLogoutEyebrow"))?no_esc}</p>
|
||||
<p class="tjwater-logout-description">${kcSanitize(msg("tjwaterLogoutDescription"))?no_esc}</p>
|
||||
</div>
|
||||
<#if pageRedirectUri?has_content>
|
||||
<a class="pf-v5-c-button pf-m-primary tjwater-logout-action" href="${pageRedirectUri}">${kcSanitize(msg("tjwaterReturnToApplication"))?no_esc}</a>
|
||||
<#elseif (client.baseUrl)?has_content>
|
||||
<a class="pf-v5-c-button pf-m-primary tjwater-logout-action" href="${client.baseUrl}">${kcSanitize(msg("tjwaterReturnToApplication"))?no_esc}</a>
|
||||
</#if>
|
||||
</div>
|
||||
<#else>
|
||||
<div id="kc-info-message">
|
||||
<p class="instruction">${message.summary}<#if requiredActions??><#list requiredActions>: <b><#items as reqActionItem>${kcSanitize(msg("requiredAction.${reqActionItem}"))?no_esc}<#sep>, </#items></b></#list><#else></#if></p>
|
||||
<#if skipLink??>
|
||||
<#else>
|
||||
<#if pageRedirectUri?has_content>
|
||||
<p><a href="${pageRedirectUri}">${kcSanitize(msg("backToApplication"))?no_esc}</a></p>
|
||||
<#elseif actionUri?has_content>
|
||||
<p><a href="${actionUri}">${kcSanitize(msg("proceedWithAction"))?no_esc}</a></p>
|
||||
<#elseif (client.baseUrl)?has_content>
|
||||
<p><a href="${client.baseUrl}">${kcSanitize(msg("backToApplication"))?no_esc}</a></p>
|
||||
</#if>
|
||||
</#if>
|
||||
</div>
|
||||
</#if>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
||||
@@ -1,3 +1,7 @@
|
||||
loginAccountTitle=Account sign in
|
||||
doLogIn=Sign in
|
||||
doForgotPassword=Forgot password
|
||||
tjwaterLogoutTitle=Signed out securely
|
||||
tjwaterLogoutEyebrow=Your session has ended
|
||||
tjwaterLogoutDescription=Your platform and identity-provider sessions have been ended securely.
|
||||
tjwaterReturnToApplication=Return to sign in
|
||||
|
||||
@@ -7,3 +7,7 @@ invalidUserMessage=用户名或密码错误
|
||||
invalidUsernameOrPasswordMessage=用户名或密码错误
|
||||
expiredCodeMessage=登录已超时,请重新登录
|
||||
loginTimeout=登录已超时,请重新开始登录
|
||||
tjwaterLogoutTitle=已安全退出
|
||||
tjwaterLogoutEyebrow=会话已结束
|
||||
tjwaterLogoutDescription=您的平台与身份认证会话均已安全结束。
|
||||
tjwaterReturnToApplication=返回登录页
|
||||
|
||||
@@ -367,6 +367,60 @@ a:focus-visible {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tjwater-logout-message {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.tjwater-logout-mark {
|
||||
display: grid;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border: 1px solid oklch(0.76 0.09 184 / 52%);
|
||||
border-radius: 50%;
|
||||
background: oklch(0.92 0.04 184 / 58%);
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.tjwater-logout-mark span {
|
||||
width: 18px;
|
||||
height: 10px;
|
||||
border-bottom: 3px solid var(--tjwater-teal);
|
||||
border-left: 3px solid var(--tjwater-teal);
|
||||
transform: translateY(-2px) rotate(-45deg);
|
||||
}
|
||||
|
||||
.tjwater-logout-copy {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.tjwater-logout-eyebrow {
|
||||
margin: 0;
|
||||
color: var(--tjwater-ink);
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.tjwater-logout-description {
|
||||
margin: 0;
|
||||
color: var(--tjwater-muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.tjwater-logout-action {
|
||||
display: inline-flex;
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@keyframes tjwater-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
-- ============================================
|
||||
-- TJWater Server 用户系统数据库迁移脚本
|
||||
-- ============================================
|
||||
|
||||
-- 创建用户表
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
email VARCHAR(100) UNIQUE NOT NULL,
|
||||
hashed_password VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(20) DEFAULT 'USER' NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE NOT NULL,
|
||||
is_superuser BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
|
||||
CONSTRAINT users_role_check CHECK (role IN ('ADMIN', 'OPERATOR', 'USER', 'VIEWER'))
|
||||
);
|
||||
|
||||
-- 创建索引
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_is_active ON users(is_active);
|
||||
|
||||
-- 创建触发器自动更新 updated_at
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS update_users_updated_at ON users;
|
||||
CREATE TRIGGER update_users_updated_at
|
||||
BEFORE UPDATE ON users
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- 创建默认管理员账号 (密码: admin123)
|
||||
INSERT INTO users (username, email, hashed_password, role, is_superuser)
|
||||
VALUES (
|
||||
'admin',
|
||||
'admin@tjwater.com',
|
||||
'$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5aeAJK.1tYKAW',
|
||||
'ADMIN',
|
||||
TRUE
|
||||
) ON CONFLICT (username) DO NOTHING;
|
||||
|
||||
-- 迁移现有硬编码用户 (tjwater/tjwater@123)
|
||||
INSERT INTO users (username, email, hashed_password, role, is_superuser)
|
||||
VALUES (
|
||||
'tjwater',
|
||||
'tjwater@tjwater.com',
|
||||
'$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW',
|
||||
'ADMIN',
|
||||
TRUE
|
||||
) ON CONFLICT (username) DO NOTHING;
|
||||
|
||||
-- 添加注释
|
||||
COMMENT ON TABLE users IS '用户表 - 存储系统用户信息';
|
||||
COMMENT ON COLUMN users.id IS '用户ID(主键)';
|
||||
COMMENT ON COLUMN users.username IS '用户名(唯一)';
|
||||
COMMENT ON COLUMN users.email IS '邮箱地址(唯一)';
|
||||
COMMENT ON COLUMN users.hashed_password IS 'bcrypt 密码哈希';
|
||||
COMMENT ON COLUMN users.role IS '用户角色: ADMIN, OPERATOR, USER, VIEWER';
|
||||
@@ -1,45 +0,0 @@
|
||||
-- ============================================
|
||||
-- TJWater Server 审计日志表迁移脚本
|
||||
-- ============================================
|
||||
|
||||
-- 创建审计日志表
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
username VARCHAR(50),
|
||||
action VARCHAR(50) NOT NULL,
|
||||
resource_type VARCHAR(50),
|
||||
resource_id VARCHAR(100),
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT,
|
||||
request_method VARCHAR(10),
|
||||
request_path TEXT,
|
||||
request_data JSONB,
|
||||
response_status INTEGER,
|
||||
error_message TEXT,
|
||||
timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
-- 创建索引以提高查询性能
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_user_id ON audit_logs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_username ON audit_logs(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_timestamp ON audit_logs(timestamp DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs(action);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_resource ON audit_logs(resource_type, resource_id);
|
||||
|
||||
-- 添加注释
|
||||
COMMENT ON TABLE audit_logs IS '审计日志表 - 记录所有关键操作';
|
||||
COMMENT ON COLUMN audit_logs.id IS '日志ID(主键)';
|
||||
COMMENT ON COLUMN audit_logs.user_id IS '用户ID(外键)';
|
||||
COMMENT ON COLUMN audit_logs.username IS '用户名(冗余字段,用于用户删除后仍可查询)';
|
||||
COMMENT ON COLUMN audit_logs.action IS '操作类型(如:LOGIN, LOGOUT, CREATE, UPDATE, DELETE)';
|
||||
COMMENT ON COLUMN audit_logs.resource_type IS '资源类型(如:user, project, network)';
|
||||
COMMENT ON COLUMN audit_logs.resource_id IS '资源ID';
|
||||
COMMENT ON COLUMN audit_logs.ip_address IS '客户端IP地址';
|
||||
COMMENT ON COLUMN audit_logs.user_agent IS '客户端User-Agent';
|
||||
COMMENT ON COLUMN audit_logs.request_method IS 'HTTP请求方法';
|
||||
COMMENT ON COLUMN audit_logs.request_path IS '请求路径';
|
||||
COMMENT ON COLUMN audit_logs.request_data IS '请求数据(JSON格式,敏感信息已脱敏)';
|
||||
COMMENT ON COLUMN audit_logs.response_status IS 'HTTP响应状态码';
|
||||
COMMENT ON COLUMN audit_logs.error_message IS '错误消息(如果有)';
|
||||
COMMENT ON COLUMN audit_logs.timestamp IS '操作时间';
|
||||
@@ -44,20 +44,4 @@ BEGIN
|
||||
ALTER COLUMN timestamp TYPE TIMESTAMP WITH TIME ZONE
|
||||
USING "timestamp" AT TIME ZONE ''UTC''';
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'scheme_list'
|
||||
AND column_name = 'scheme_start_time'
|
||||
AND data_type IN ('character varying', 'text')
|
||||
) THEN
|
||||
EXECUTE 'ALTER TABLE public.scheme_list
|
||||
ALTER COLUMN scheme_start_time TYPE TIMESTAMP WITH TIME ZONE
|
||||
USING CASE
|
||||
WHEN scheme_start_time ~ ''(Z|[+-][0-9]{2}:[0-9]{2})$'' THEN scheme_start_time::timestamptz
|
||||
ELSE scheme_start_time::timestamp AT TIME ZONE ''UTC''
|
||||
END';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
@@ -51,20 +51,3 @@ ALTER TABLE users
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_keycloak_id ON users(keycloak_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_is_active ON users(is_active);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_project_membership (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL,
|
||||
project_id UUID NOT NULL,
|
||||
project_role VARCHAR(20) DEFAULT 'viewer' NOT NULL,
|
||||
CONSTRAINT user_project_membership_role_check
|
||||
CHECK (
|
||||
project_role IN ('member', 'viewer')
|
||||
),
|
||||
CONSTRAINT user_project_membership_unique UNIQUE (user_id, project_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_project_membership_user_id
|
||||
ON user_project_membership(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_project_membership_project_id
|
||||
ON user_project_membership(project_id);
|
||||
|
||||
@@ -25,7 +25,6 @@ CREATE TABLE IF NOT EXISTS projects (
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_code ON projects(code);
|
||||
|
||||
DROP TRIGGER IF EXISTS update_projects_updated_at ON projects;
|
||||
CREATE TRIGGER update_projects_updated_at
|
||||
@@ -33,6 +32,20 @@ CREATE TRIGGER update_projects_updated_at
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_project_membership (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
project_role VARCHAR(20) DEFAULT 'viewer' NOT NULL,
|
||||
CONSTRAINT user_project_membership_role_check
|
||||
CHECK (project_role IN ('member', 'viewer')),
|
||||
CONSTRAINT user_project_membership_unique UNIQUE (user_id, project_id)
|
||||
);
|
||||
|
||||
-- The unique (user_id, project_id) index already supports user-side lookups.
|
||||
CREATE INDEX IF NOT EXISTS idx_user_project_membership_project_id
|
||||
ON user_project_membership(project_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_databases (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
-- [USERS]
|
||||
-- 王名豪
|
||||
-- 2025/03/23
|
||||
-- 存储系统的用户信息,如用户名,密码
|
||||
|
||||
create table users (
|
||||
user_id SERIAL PRIMARY KEY,
|
||||
username varchar(32) not null unique,
|
||||
password varchar(32) not null
|
||||
)
|
||||
@@ -7,8 +7,8 @@ create table scheme_list (
|
||||
scheme_id SERIAL PRIMARY KEY,
|
||||
scheme_name varchar(32) not null,
|
||||
scheme_type varchar(32) not null,
|
||||
username varchar(32) not null REFERENCES "users"(username) ON UPDATE CASCADE ON DELETE RESTRICT,
|
||||
username varchar(32) not null,
|
||||
create_time TIMESTAMP WITH TIME ZONE not null DEFAULT date_trunc('minute', CURRENT_TIMESTAMP),
|
||||
scheme_start_time TIMESTAMP WITH TIME ZONE not null,
|
||||
scheme_start_time varchar(50) not null,
|
||||
scheme_detail JSON
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ CREATE TABLE sensor_placement (
|
||||
scheme_name varchar(32) not null,
|
||||
sensor_number int,
|
||||
min_diameter int,
|
||||
username varchar(32) not null REFERENCES "users"(username) ON UPDATE CASCADE ON DELETE RESTRICT,
|
||||
username varchar(32) not null,
|
||||
create_time TIMESTAMP WITH TIME ZONE not null DEFAULT date_trunc('minute', CURRENT_TIMESTAMP),
|
||||
sensor_location TEXT[]
|
||||
);
|
||||
);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
-- [LEAKAGE_IDENTIFY_RESULT]
|
||||
-- 存储漏损识别任务的结果数据。
|
||||
|
||||
CREATE TABLE leakage_identify_result (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
scheme_name varchar NOT NULL,
|
||||
network varchar NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
run_status varchar NOT NULL DEFAULT 'completed',
|
||||
error_message text,
|
||||
sensor_nodes jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
result_rows jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
node_area_map jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
areas jsonb DEFAULT '[]'::jsonb,
|
||||
drawing_payload jsonb DEFAULT '{"type": "FeatureCollection", "features": []}'::jsonb,
|
||||
CONSTRAINT uq_leakage_identify_result_scheme UNIQUE (scheme_name)
|
||||
);
|
||||
@@ -1,5 +0,0 @@
|
||||
-- 王名豪
|
||||
-- 2025/03/23
|
||||
-- 删除user这张表
|
||||
|
||||
drop table if exists users;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- [LEAKAGE_IDENTIFY_RESULT]
|
||||
|
||||
DROP TABLE IF EXISTS leakage_identify_result;
|
||||
@@ -40,11 +40,11 @@ sql_create = [
|
||||
"script/sql/create/36.wda.sql",
|
||||
"script/sql/create/37.history_patterns_flows.sql",
|
||||
"script/sql/create/38.scada_info.sql",
|
||||
"script/sql/create/39.users.sql",
|
||||
"script/sql/create/40.scheme_list.sql",
|
||||
"script/sql/create/41.pipe_risk_probability.sql",
|
||||
"script/sql/create/42.sensor_placement.sql",
|
||||
"script/sql/create/43.burst_locate_result.sql",
|
||||
"script/sql/create/44.leakage_identify_result.sql",
|
||||
"script/sql/create/extension_data.sql",
|
||||
"script/sql/create/operation.sql"
|
||||
]
|
||||
@@ -54,9 +54,9 @@ sql_drop = [
|
||||
"script/sql/drop/extension_data.sql",
|
||||
"script/sql/drop/43.burst_locate_result.sql",
|
||||
"script/sql/drop/42.sensor_placement.sql",
|
||||
"script/sql/drop/44.leakage_identify_result.sql",
|
||||
"script/sql/drop/41.pipe_risk_probability.sql",
|
||||
"script/sql/drop/40.scheme_list.sql",
|
||||
"script/sql/drop/39.users.sql",
|
||||
"script/sql/drop/38.scada_info.sql",
|
||||
"script/sql/drop/37.history_patterns_flows.sql",
|
||||
"script/sql/drop/36.wda.sql",
|
||||
|
||||
@@ -138,7 +138,6 @@ from app.services.tjnetwork import (
|
||||
get_all_sensor_placements,
|
||||
get_all_service_areas,
|
||||
get_all_tanks,
|
||||
get_all_users,
|
||||
get_all_valves,
|
||||
get_all_vertex_links,
|
||||
get_all_vertices,
|
||||
@@ -238,8 +237,6 @@ from app.services.tjnetwork import (
|
||||
get_time_schema,
|
||||
get_title,
|
||||
get_title_schema,
|
||||
get_user,
|
||||
get_user_schema,
|
||||
get_valve,
|
||||
get_valve_schema,
|
||||
get_vertex,
|
||||
@@ -2910,24 +2907,6 @@ async def fastapi_get_all_scada_info(network: str) -> list[dict[str, float]]:
|
||||
return get_all_scada_info(network)
|
||||
|
||||
|
||||
###########################################################
|
||||
# user 39
|
||||
###########################################################
|
||||
@app.get("/getuserschema/")
|
||||
async def fastapi_get_user_schema(network: str) -> dict[str, dict[Any, Any]]:
|
||||
return get_user_schema(network)
|
||||
|
||||
|
||||
@app.get("/getuser/")
|
||||
async def fastapi_get_user(network: str, user_name: str) -> dict[Any, Any]:
|
||||
return get_user(network, user_name)
|
||||
|
||||
|
||||
@app.get("/getallusers/")
|
||||
async def fastapi_get_all_users(network: str) -> list[dict[Any, Any]]:
|
||||
return get_all_users(network)
|
||||
|
||||
|
||||
############################################################
|
||||
# scheme 40
|
||||
############################################################
|
||||
|
||||
@@ -327,9 +327,6 @@ Non-commented FastAPI routes defined in `scripts/main.py`.
|
||||
- `GET /getscadainfoschema/`
|
||||
- `GET /getscadainfo/`
|
||||
- `GET /getallscadainfo/`
|
||||
- `GET /getuserschema/`
|
||||
- `GET /getuser/`
|
||||
- `GET /getallusers/`
|
||||
- `GET /getschemeschema/`
|
||||
- `GET /getscheme/`
|
||||
- `GET /getallschemes/`
|
||||
|
||||
@@ -1145,53 +1145,6 @@ def submit_scada_info(name: str, coord_id: str) -> None:
|
||||
print(f"scada_info文件不存在。")
|
||||
|
||||
|
||||
# 2025/03/23
|
||||
def create_user(name: str, username: str, password: str):
|
||||
"""
|
||||
创建用户
|
||||
:param name: 数据库名称
|
||||
:param username: 用户名
|
||||
:param password: 密码
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
# 动态替换数据库名称
|
||||
conn_string = get_pgconn_string(db_name=name)
|
||||
# 连接到 PostgreSQL 数据库(这里是数据库 "bb")
|
||||
with psycopg.connect(conn_string) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO users (username, password) VALUES (%s, %s)",
|
||||
(username, password),
|
||||
)
|
||||
# 提交事务
|
||||
conn.commit()
|
||||
print("新用户创建成功!")
|
||||
except Exception as e:
|
||||
print(f"创建用户出错:{e}")
|
||||
|
||||
|
||||
# 2025/03/23
|
||||
def delete_user(name: str, username: str):
|
||||
"""
|
||||
删除用户
|
||||
:param name: 数据库名称
|
||||
:param username: 用户名
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
# 动态替换数据库名称
|
||||
conn_string = get_pgconn_string(db_name=name)
|
||||
# 连接到 PostgreSQL 数据库(这里是数据库 "bb")
|
||||
with psycopg.connect(conn_string) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("DELETE FROM users WHERE username = %s", (username,))
|
||||
conn.commit()
|
||||
print(f"用户 {username} 删除成功!")
|
||||
except Exception as e:
|
||||
print(f"删除用户出错:{e}")
|
||||
|
||||
|
||||
# 2025/03/23
|
||||
def scheme_name_exists(name: str, scheme_name: str) -> bool:
|
||||
"""
|
||||
@@ -1572,12 +1525,6 @@ if __name__ == "__main__":
|
||||
# burst_analysis(name='bb', modify_pattern_start_time='2025-04-17T00:00:00+08:00',
|
||||
# burst_ID='GSD230112144241FA18292A84CB', burst_size=400, modify_total_duration=1800, scheme_name='GSD230112144241FA18292A84CB_400')
|
||||
|
||||
# 示例:create_user
|
||||
# create_user(name=project_info.name, username='tjwater dev', password='123456')
|
||||
|
||||
# # 示例:delete_user
|
||||
# delete_user(name=project_info.name, username='admin_test')
|
||||
|
||||
# # 示例:query_scheme_list
|
||||
# result = query_scheme_list(name=project_info.name)
|
||||
# print(result)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -76,6 +77,20 @@ def test_handler_router_defines_only_the_public_rest_operations() -> None:
|
||||
assert ("GET", "/getpipeproperties/") not in source_operations
|
||||
|
||||
|
||||
def test_legacy_project_user_operations_are_not_exposed() -> None:
|
||||
source_paths = {
|
||||
route.path
|
||||
for route in source_api_router.routes
|
||||
if isinstance(route, APIRoute)
|
||||
}
|
||||
|
||||
assert {
|
||||
"/network-schemas/user",
|
||||
"/users",
|
||||
"/users/detail",
|
||||
}.isdisjoint(source_paths)
|
||||
|
||||
|
||||
def test_rest_openapi_satisfies_contract_invariants() -> None:
|
||||
from fastapi import FastAPI
|
||||
|
||||
@@ -282,3 +297,35 @@ def test_rest_runtime_wraps_handler_paginated_list() -> None:
|
||||
"limit": 2,
|
||||
"offset": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_rest_runtime_json_encodes_untyped_datetime_response() -> None:
|
||||
source_router = APIRouter()
|
||||
|
||||
@source_router.get("/simulation-result")
|
||||
async def simulation_result():
|
||||
return {
|
||||
"result": [
|
||||
{
|
||||
"time": datetime(2026, 7, 30, 4, tzinfo=timezone.utc),
|
||||
"id": "4277",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
app = FastAPI(redirect_slashes=False)
|
||||
app.include_router(build_rest_router(source_router.routes), prefix="/api/v1")
|
||||
|
||||
response = TestClient(app, raise_server_exceptions=False).get(
|
||||
"/api/v1/simulation-result"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"result": [
|
||||
{
|
||||
"time": "2026-07-30T04:00:00+00:00",
|
||||
"id": "4277",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ def _scheme(**overrides):
|
||||
"sensor_points": [
|
||||
{
|
||||
"node_id": "J1",
|
||||
"max_pipe_diameter": 400.0,
|
||||
"project_x": 13500000.0,
|
||||
"project_y": 3600000.0,
|
||||
"map_x": 13500000.0,
|
||||
@@ -42,6 +43,7 @@ def _scheme(**overrides):
|
||||
},
|
||||
{
|
||||
"node_id": "J2",
|
||||
"max_pipe_diameter": 300.0,
|
||||
"project_x": 13500100.0,
|
||||
"project_y": 3600100.0,
|
||||
"map_x": 13500100.0,
|
||||
@@ -114,6 +116,9 @@ def _load_module(monkeypatch):
|
||||
"get_sensor_placement_scheme": lambda network, scheme_id: _scheme(
|
||||
id=scheme_id
|
||||
),
|
||||
"get_sensor_placement_candidate": (
|
||||
lambda network, node_id: _scheme()["sensor_points"][0]
|
||||
),
|
||||
"update_sensor_placement_scheme": (
|
||||
lambda network, scheme_id, **kwargs: _scheme(
|
||||
id=scheme_id,
|
||||
@@ -170,6 +175,18 @@ def test_optimize_returns_created_scheme(monkeypatch):
|
||||
assert captured["username"] == "alice"
|
||||
|
||||
|
||||
def test_get_candidate_returns_maximum_incident_pipe_diameter(monkeypatch):
|
||||
module = _load_module(monkeypatch)
|
||||
|
||||
response = _client(module).get(
|
||||
"/api/v1/sensor-placement-candidates/J1",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["node_id"] == "J1"
|
||||
assert response.json()["max_pipe_diameter"] == 400.0
|
||||
|
||||
|
||||
def test_optimize_rejects_unsupported_sensor_type(monkeypatch):
|
||||
module = _load_module(monkeypatch)
|
||||
response = _client(module).post(
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.auth.keycloak_dependencies import get_current_keycloak_username
|
||||
from app.auth import keycloak_dependencies
|
||||
from app.auth.keycloak_dependencies import (
|
||||
_decode_keycloak_token,
|
||||
get_current_keycloak_username,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -28,3 +32,42 @@ async def test_current_username_rejects_username_fallback():
|
||||
|
||||
assert exc.value.status_code == 401
|
||||
assert exc.value.detail == "Missing preferred_username claim"
|
||||
|
||||
|
||||
def test_decode_keycloak_token_rejects_a_token_older_than_the_configured_limit(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
monkeypatch.setattr(keycloak_dependencies.settings, "KEYCLOAK_PUBLIC_KEY", "public-key")
|
||||
monkeypatch.setattr(
|
||||
keycloak_dependencies.settings,
|
||||
"KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS",
|
||||
900,
|
||||
)
|
||||
monkeypatch.setattr(keycloak_dependencies.time, "time", lambda: 2_000)
|
||||
monkeypatch.setattr(
|
||||
keycloak_dependencies.jwt,
|
||||
"decode",
|
||||
lambda *args, **kwargs: {"iat": 1_000},
|
||||
)
|
||||
|
||||
with pytest.raises(keycloak_dependencies.JWTError):
|
||||
_decode_keycloak_token("expired-by-policy")
|
||||
|
||||
|
||||
def test_decode_keycloak_token_accepts_a_recent_token(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
monkeypatch.setattr(keycloak_dependencies.settings, "KEYCLOAK_PUBLIC_KEY", "public-key")
|
||||
monkeypatch.setattr(
|
||||
keycloak_dependencies.settings,
|
||||
"KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS",
|
||||
900,
|
||||
)
|
||||
monkeypatch.setattr(keycloak_dependencies.time, "time", lambda: 1_500)
|
||||
monkeypatch.setattr(
|
||||
keycloak_dependencies.jwt,
|
||||
"decode",
|
||||
lambda *args, **kwargs: {"iat": 1_000, "sub": "subject"},
|
||||
)
|
||||
|
||||
assert _decode_keycloak_token("recent") == {"iat": 1_000, "sub": "subject"}
|
||||
|
||||
@@ -1,6 +1,35 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_metadata_schema_creates_membership_after_referenced_tables():
|
||||
auth_sql = Path("resources/sql/004_metadata_auth_management.sql").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
project_sql = Path(
|
||||
"resources/sql/005_metadata_project_configuration.sql"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert "CREATE TABLE IF NOT EXISTS user_project_membership" not in auth_sql
|
||||
assert project_sql.index("CREATE TABLE IF NOT EXISTS projects") < project_sql.index(
|
||||
"CREATE TABLE IF NOT EXISTS user_project_membership"
|
||||
)
|
||||
assert "user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE" in project_sql
|
||||
assert (
|
||||
"project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE"
|
||||
in project_sql
|
||||
)
|
||||
|
||||
|
||||
def test_metadata_schema_avoids_indexes_duplicated_by_unique_constraints():
|
||||
project_sql = Path(
|
||||
"resources/sql/005_metadata_project_configuration.sql"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert "idx_projects_code" not in project_sql
|
||||
assert "idx_user_project_membership_user_id" not in project_sql
|
||||
assert "idx_user_project_membership_project_id" in project_sql
|
||||
|
||||
|
||||
def test_rbac_migration_normalizes_legacy_roles():
|
||||
sql = Path("resources/sql/006_metadata_rbac_roles.sql").read_text(
|
||||
encoding="utf-8"
|
||||
|
||||
@@ -29,6 +29,7 @@ def test_build_workbook_contains_engineering_columns(monkeypatch):
|
||||
lambda network, locations: [
|
||||
{
|
||||
"node_id": "J1",
|
||||
"max_pipe_diameter": 400.0,
|
||||
"project_x": 13500000.0,
|
||||
"project_y": 3600000.0,
|
||||
"map_x": 13500000.0,
|
||||
@@ -75,7 +76,7 @@ def test_build_workbook_contains_engineering_columns(monkeypatch):
|
||||
assert workbook["方案信息"]["B8"].value == "未保存草稿"
|
||||
|
||||
|
||||
def test_sensor_points_keep_engineering_coordinates_and_transform_map_coordinates(
|
||||
def test_candidate_keeps_engineering_coordinates_and_transforms_map_coordinates(
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
@@ -84,6 +85,7 @@ def test_sensor_points_keep_engineering_coordinates_and_transform_map_coordinate
|
||||
lambda network, node_ids: [
|
||||
{
|
||||
"node_id": "J1",
|
||||
"max_pipe_diameter": 400.0,
|
||||
"project_x": 3038.94,
|
||||
"project_y": -34446.59,
|
||||
"map_x": 13525191.530279,
|
||||
@@ -93,10 +95,11 @@ def test_sensor_points_keep_engineering_coordinates_and_transform_map_coordinate
|
||||
],
|
||||
)
|
||||
|
||||
point = sensor_placement._sensor_points("tjwater", ["J1"])[0]
|
||||
point = sensor_placement.get_sensor_placement_candidate("tjwater", "J1")
|
||||
|
||||
assert point["project_x"] == 3038.94
|
||||
assert point["project_y"] == -34446.59
|
||||
assert point["max_pipe_diameter"] == 400.0
|
||||
assert point["longitude"] == pytest.approx(121.498863, abs=1e-6)
|
||||
assert point["latitude"] == pytest.approx(30.924784, abs=1e-6)
|
||||
|
||||
@@ -133,6 +136,8 @@ def test_sensor_nodes_use_materialized_web_mercator_geometry(monkeypatch):
|
||||
assert "ST_Y(c.coord)" in query
|
||||
assert "ST_X(gj.geom)" in query
|
||||
assert "ST_Y(gj.geom)" in query
|
||||
assert "MAX(diameter) AS max_pipe_diameter" in query
|
||||
assert cursor.execute.call_args.args[1] == (["J1"], ["J1"], ["J1"])
|
||||
|
||||
|
||||
def test_workbook_escapes_formula_in_scheme_metadata(monkeypatch):
|
||||
@@ -142,6 +147,7 @@ def test_workbook_escapes_formula_in_scheme_metadata(monkeypatch):
|
||||
lambda network, locations: [
|
||||
{
|
||||
"node_id": "J1",
|
||||
"max_pipe_diameter": 400.0,
|
||||
"project_x": 3038.94,
|
||||
"project_y": -34446.59,
|
||||
"map_x": 13525191.53,
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import wntr
|
||||
from scipy.sparse import csr_matrix, isspmatrix_csr
|
||||
from scipy.sparse.csgraph import dijkstra
|
||||
|
||||
from app.algorithms.sensor import sensitivity
|
||||
|
||||
|
||||
def _build_test_network() -> wntr.network.WaterNetworkModel:
|
||||
wn = wntr.network.WaterNetworkModel()
|
||||
wn.options.time.duration = 0
|
||||
wn.add_reservoir("R1", base_head=100.0, coordinates=(-1.0, 0.0))
|
||||
wn.add_junction("J0", elevation=5.0, coordinates=(0.0, 0.0))
|
||||
|
||||
for index in range(1, 13):
|
||||
wn.add_junction(
|
||||
f"J{index}",
|
||||
base_demand=0.001 + index * 0.00001,
|
||||
elevation=5.0 + index * 0.05,
|
||||
coordinates=(float(index % 4), float(index // 4)),
|
||||
)
|
||||
|
||||
wn.add_pipe("P0", "R1", "J0", length=100.0, diameter=0.4, roughness=110)
|
||||
wn.add_pipe("P1", "J0", "J1", length=100.0, diameter=0.4, roughness=110)
|
||||
for index in range(1, 11):
|
||||
wn.add_pipe(
|
||||
f"P{index + 1}",
|
||||
f"J{index}",
|
||||
f"J{index + 1}",
|
||||
length=80.0 + index,
|
||||
diameter=0.3,
|
||||
roughness=105,
|
||||
)
|
||||
# J12 is connected only through a small pipe, while J11 also touches P11.
|
||||
wn.add_pipe("P12", "J11", "J12", length=90.0, diameter=0.1, roughness=105)
|
||||
wn.add_pipe("PX1", "J2", "J6", length=120.0, diameter=0.3, roughness=105)
|
||||
wn.add_pipe("PX2", "J5", "J9", length=120.0, diameter=0.3, roughness=105)
|
||||
return wn
|
||||
|
||||
|
||||
def _prepared_selection_network(
|
||||
coordinates: np.ndarray,
|
||||
edges: list[tuple[int, int, float]],
|
||||
) -> sensitivity._PreparedNetwork:
|
||||
node_count = len(coordinates)
|
||||
rows: list[int] = []
|
||||
columns: list[int] = []
|
||||
weights: list[float] = []
|
||||
for start, end, weight in edges:
|
||||
rows.extend((start, end))
|
||||
columns.extend((end, start))
|
||||
weights.extend((weight, weight))
|
||||
coverage_graph = csr_matrix(
|
||||
(weights, (rows, columns)),
|
||||
shape=(node_count, node_count),
|
||||
)
|
||||
return sensitivity._PreparedNetwork(
|
||||
node_names=tuple(f"N{index:04d}" for index in range(node_count)),
|
||||
full_node_indices=np.arange(node_count, dtype=np.int64),
|
||||
candidate_indices=np.arange(node_count, dtype=np.int64),
|
||||
coordinates=np.asarray(coordinates, dtype=np.float64),
|
||||
incidence=csr_matrix((node_count, 1), dtype=np.float64),
|
||||
conductance=np.ones(1, dtype=np.float64),
|
||||
roughness_response=np.ones(1, dtype=np.float64),
|
||||
distance_graph=csr_matrix((node_count, node_count), dtype=np.float64),
|
||||
coverage_graph=coverage_graph,
|
||||
)
|
||||
|
||||
|
||||
def test_algorithm_is_deterministic_and_runs_epanet_once(monkeypatch, tmp_path):
|
||||
wn = _build_test_network()
|
||||
original_run_sim = wntr.sim.EpanetSimulator.run_sim
|
||||
prefixes: list[str] = []
|
||||
|
||||
def counted_run_sim(simulator, *args, **kwargs):
|
||||
prefixes.append(str(kwargs["file_prefix"]))
|
||||
return original_run_sim(simulator, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(wntr.sim.EpanetSimulator, "run_sim", counted_run_sim)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
first = sensitivity.optimize_sensor_placement(wn, sensor_num=4, min_diameter=0)
|
||||
second = sensitivity.optimize_sensor_placement(wn, sensor_num=4, min_diameter=0)
|
||||
|
||||
assert first == second
|
||||
assert len(first) == len(set(first)) == 4
|
||||
assert len(prefixes) == 2
|
||||
assert all(not Path(prefix).parent.exists() for prefix in prefixes)
|
||||
assert not list(tmp_path.glob("temp.*"))
|
||||
|
||||
|
||||
def test_hydraulic_simulation_keeps_only_initial_state_and_restores_duration():
|
||||
wn = _build_test_network()
|
||||
wn.options.time.duration = 24 * 60 * 60
|
||||
|
||||
results = sensitivity._run_hydraulic_simulation(wn)
|
||||
|
||||
assert len(results.node["head"].index) == 1
|
||||
assert wn.options.time.duration == 24 * 60 * 60
|
||||
|
||||
|
||||
def test_preparation_keeps_network_matrices_sparse():
|
||||
wn = _build_test_network()
|
||||
results = sensitivity._run_hydraulic_simulation(wn)
|
||||
|
||||
prepared = sensitivity._prepare_network(wn, results, min_diameter=0)
|
||||
|
||||
assert isspmatrix_csr(prepared.incidence)
|
||||
assert isspmatrix_csr(prepared.distance_graph)
|
||||
assert isspmatrix_csr(prepared.coverage_graph)
|
||||
assert prepared.incidence.nnz <= 2 * prepared.incidence.shape[1]
|
||||
assert prepared.distance_graph.nnz <= wn.num_pipes
|
||||
assert prepared.coverage_graph.nnz <= 2 * wn.num_links
|
||||
assert (prepared.coverage_graph != prepared.coverage_graph.T).nnz == 0
|
||||
dense_incidence_bytes = int(np.prod(prepared.incidence.shape)) * 8
|
||||
sparse_payload_bytes = (
|
||||
prepared.incidence.data.nbytes
|
||||
+ prepared.incidence.indices.nbytes
|
||||
+ prepared.incidence.indptr.nbytes
|
||||
)
|
||||
assert sparse_payload_bytes < dense_incidence_bytes
|
||||
|
||||
|
||||
def test_minimum_diameter_filters_installation_candidates_in_millimetres():
|
||||
wn = _build_test_network()
|
||||
results = sensitivity._run_hydraulic_simulation(wn)
|
||||
prepared = sensitivity._prepare_network(wn, results, min_diameter=300)
|
||||
candidate_names = {
|
||||
prepared.node_names[index] for index in prepared.candidate_indices
|
||||
}
|
||||
|
||||
assert "J12" not in candidate_names
|
||||
assert "J11" in candidate_names
|
||||
|
||||
selected = sensitivity.optimize_sensor_placement(
|
||||
wn,
|
||||
sensor_num=4,
|
||||
min_diameter=300,
|
||||
)
|
||||
assert set(selected) <= candidate_names
|
||||
|
||||
with pytest.raises(ValueError, match="候选节点少于"):
|
||||
sensitivity.optimize_sensor_placement(
|
||||
wn,
|
||||
sensor_num=len(candidate_names) + 1,
|
||||
min_diameter=300,
|
||||
)
|
||||
|
||||
|
||||
def test_sparse_estimate_preserves_dense_reference_placement_quality():
|
||||
wn = _build_test_network()
|
||||
results = sensitivity._run_hydraulic_simulation(wn)
|
||||
prepared = sensitivity._prepare_network(wn, results, min_diameter=0)
|
||||
|
||||
approximate_log_sensitivity = sensitivity._estimate_log_pressure_sensitivity(
|
||||
prepared
|
||||
)
|
||||
approximate_distance = sensitivity._estimate_hydraulic_distance_sums(prepared)
|
||||
approximate_selected = sensitivity._select_sensor_nodes(
|
||||
prepared,
|
||||
approximate_log_sensitivity,
|
||||
approximate_distance,
|
||||
sensor_num=4,
|
||||
)
|
||||
|
||||
incidence = prepared.incidence.toarray()
|
||||
laplacian = (
|
||||
prepared.incidence.multiply(prepared.conductance)
|
||||
@ prepared.incidence.T
|
||||
).toarray()
|
||||
diagonal_scale = float(np.max(np.abs(np.diag(laplacian))))
|
||||
laplacian += np.eye(laplacian.shape[0]) * (
|
||||
diagonal_scale * np.sqrt(np.finfo(np.float64).eps)
|
||||
)
|
||||
response = np.linalg.solve(
|
||||
laplacian,
|
||||
incidence * prepared.roughness_response,
|
||||
)
|
||||
exact_sensitivity = np.abs(response).sum(axis=1)
|
||||
|
||||
exact_distances = dijkstra(
|
||||
prepared.distance_graph.transpose().tocsr(),
|
||||
directed=True,
|
||||
indices=prepared.full_node_indices,
|
||||
return_predecessors=False,
|
||||
)[:, prepared.full_node_indices]
|
||||
exact_distances[~np.isfinite(exact_distances)] = 0.0
|
||||
exact_distance = exact_distances.sum(axis=0)
|
||||
exact_selected = sensitivity._select_sensor_nodes(
|
||||
prepared,
|
||||
np.log(np.maximum(exact_sensitivity, np.finfo(np.float64).tiny)),
|
||||
exact_distance,
|
||||
sensor_num=4,
|
||||
)
|
||||
|
||||
exact_score = exact_sensitivity * exact_distance
|
||||
node_index = {
|
||||
node_name: index for index, node_name in enumerate(prepared.node_names)
|
||||
}
|
||||
approximate_objective = sum(
|
||||
exact_score[node_index[node_name]] for node_name in approximate_selected
|
||||
)
|
||||
exact_objective = sum(
|
||||
exact_score[node_index[node_name]] for node_name in exact_selected
|
||||
)
|
||||
|
||||
assert approximate_objective / exact_objective >= 0.95
|
||||
|
||||
|
||||
def test_mixed_coverage_avoids_candidate_density_bias():
|
||||
dense_west = np.linspace(0.0, 2.0, 200)
|
||||
sparse_east = np.linspace(3.0, 10.0, 20)
|
||||
x_coordinates = np.concatenate((dense_west, sparse_east))
|
||||
coordinates = np.column_stack(
|
||||
(x_coordinates, np.zeros(len(x_coordinates), dtype=np.float64))
|
||||
)
|
||||
ordered = np.argsort(x_coordinates)
|
||||
edges = [
|
||||
(
|
||||
int(start),
|
||||
int(end),
|
||||
float(x_coordinates[end] - x_coordinates[start]),
|
||||
)
|
||||
for start, end in zip(ordered[:-1], ordered[1:])
|
||||
]
|
||||
prepared = _prepared_selection_network(coordinates, edges)
|
||||
log_scores = np.linspace(4.0, 0.0, len(coordinates))
|
||||
distance_sums = np.ones(len(coordinates), dtype=np.float64)
|
||||
|
||||
selected = sensitivity._select_sensor_nodes(
|
||||
prepared,
|
||||
log_scores,
|
||||
distance_sums,
|
||||
sensor_num=6,
|
||||
)
|
||||
name_to_position = {
|
||||
name: position for position, name in enumerate(prepared.node_names)
|
||||
}
|
||||
selected_positions = [name_to_position[name] for name in selected]
|
||||
new_metrics = sensitivity._geographic_coverage_metrics(
|
||||
coordinates,
|
||||
selected_positions,
|
||||
)
|
||||
|
||||
legacy_labels, _centers = sensitivity._cluster_labels(
|
||||
coordinates,
|
||||
6,
|
||||
random_seed=sensitivity._RANDOM_SEED + 2,
|
||||
)
|
||||
legacy_positions: list[int] = []
|
||||
represented: set[int] = set()
|
||||
for position in np.argsort(-log_scores):
|
||||
label = int(legacy_labels[position])
|
||||
if label in represented:
|
||||
continue
|
||||
represented.add(label)
|
||||
legacy_positions.append(int(position))
|
||||
legacy_metrics = sensitivity._geographic_coverage_metrics(
|
||||
coordinates,
|
||||
legacy_positions,
|
||||
)
|
||||
|
||||
assert new_metrics[0] <= legacy_metrics[0] * 0.6
|
||||
assert new_metrics[2] >= legacy_metrics[2] * 1.5
|
||||
assert max(x_coordinates[selected_positions]) >= 9.0
|
||||
|
||||
|
||||
def test_disconnected_components_each_receive_a_sensor_when_slots_allow():
|
||||
coordinates = np.asarray(
|
||||
[
|
||||
(0.0, 0.0),
|
||||
(1.0, 0.0),
|
||||
(0.0, 0.01),
|
||||
(1.0, 0.01),
|
||||
]
|
||||
)
|
||||
prepared = _prepared_selection_network(
|
||||
coordinates,
|
||||
[(0, 1, 1.0), (2, 3, 1.0)],
|
||||
)
|
||||
|
||||
selected = sensitivity._select_sensor_nodes(
|
||||
prepared,
|
||||
np.asarray([10.0, 9.0, 8.0, 7.0]),
|
||||
np.ones(4),
|
||||
sensor_num=2,
|
||||
)
|
||||
|
||||
assert len(set(selected) & {"N0000", "N0001"}) == 1
|
||||
assert len(set(selected) & {"N0002", "N0003"}) == 1
|
||||
|
||||
|
||||
def test_overlapping_components_respect_global_geographic_spacing():
|
||||
coordinates = np.asarray(
|
||||
[
|
||||
(0.0, 0.0),
|
||||
(10.0, 0.0),
|
||||
(0.1, 0.0),
|
||||
(10.1, 0.0),
|
||||
]
|
||||
)
|
||||
prepared = _prepared_selection_network(
|
||||
coordinates,
|
||||
[(0, 1, 10.0), (2, 3, 10.0)],
|
||||
)
|
||||
|
||||
selected = sensitivity._select_sensor_nodes(
|
||||
prepared,
|
||||
np.asarray([10.0, 1.0, 9.0, 0.0]),
|
||||
np.ones(4),
|
||||
sensor_num=2,
|
||||
)
|
||||
selected_positions = [prepared.node_names.index(name) for name in selected]
|
||||
minimum_gap = sensitivity._geographic_coverage_metrics(
|
||||
coordinates,
|
||||
selected_positions,
|
||||
)[2]
|
||||
|
||||
assert minimum_gap >= 0.9
|
||||
|
||||
|
||||
def test_component_quota_prefers_longer_networks_when_slots_are_limited():
|
||||
coordinates = np.asarray(
|
||||
[
|
||||
(0.0, 0.0),
|
||||
(10.0, 0.0),
|
||||
(20.0, 0.0),
|
||||
(25.0, 0.0),
|
||||
(30.0, 0.0),
|
||||
(31.0, 0.0),
|
||||
]
|
||||
)
|
||||
prepared = _prepared_selection_network(
|
||||
coordinates,
|
||||
[(0, 1, 10.0), (2, 3, 5.0), (4, 5, 1.0)],
|
||||
)
|
||||
|
||||
selected = sensitivity._select_sensor_nodes(
|
||||
prepared,
|
||||
np.asarray([1.0, 1.0, 2.0, 2.0, 100.0, 100.0]),
|
||||
np.ones(6),
|
||||
sensor_num=2,
|
||||
)
|
||||
|
||||
assert set(selected) <= {"N0000", "N0001", "N0002", "N0003"}
|
||||
assert len(set(selected) & {"N0000", "N0001"}) == 1
|
||||
assert len(set(selected) & {"N0002", "N0003"}) == 1
|
||||
|
||||
|
||||
def test_duplicate_coordinates_use_topology_and_return_exact_count():
|
||||
coordinates = np.zeros((6, 2), dtype=np.float64)
|
||||
prepared = _prepared_selection_network(
|
||||
coordinates,
|
||||
[(index, index + 1, 1.0) for index in range(5)],
|
||||
)
|
||||
log_scores = np.linspace(6.0, 1.0, 6)
|
||||
|
||||
first = sensitivity._select_sensor_nodes(
|
||||
prepared,
|
||||
log_scores,
|
||||
np.ones(6),
|
||||
sensor_num=4,
|
||||
)
|
||||
second = sensitivity._select_sensor_nodes(
|
||||
prepared,
|
||||
log_scores,
|
||||
np.ones(6),
|
||||
sensor_num=4,
|
||||
)
|
||||
|
||||
assert first == second
|
||||
assert len(first) == len(set(first)) == 4
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("sensor_num", "min_diameter", "message"),
|
||||
[
|
||||
(0, 0, "监测点数量必须大于 0"),
|
||||
(1, -1, "最小管径不能小于 0"),
|
||||
],
|
||||
)
|
||||
def test_algorithm_rejects_invalid_parameters(sensor_num, min_diameter, message):
|
||||
with pytest.raises(ValueError, match=message):
|
||||
sensitivity.optimize_sensor_placement(
|
||||
_build_test_network(),
|
||||
sensor_num=sensor_num,
|
||||
min_diameter=min_diameter,
|
||||
)
|
||||
Reference in New Issue
Block a user