Compare commits

...
12 Commits
Author SHA1 Message Date
jiang a7e1ce6ef4 fix(db): enforce metadata membership foreign keys
Server CI/CD / docker-image (push) Failing after 34s
Server CI/CD / deploy-fallback-log (push) Successful in 1s
2026-08-06 20:32:57 +08:00
jiang 4350612807 refactor(cli): remove deprecated Python implementation 2026-08-06 15:58:47 +08:00
jiang b0a23a8012 fix(cli): map timeseries element types for backend 2026-08-05 18:39:00 +08:00
jiang 70c5b0e445 fix(keycloak): design logout completion page 2026-08-05 17:57:49 +08:00
jiang 29f691731c fix(auth): enforce Keycloak access token age 2026-08-05 17:10:59 +08:00
jiang 1432934f12 feat(sensor): 返回候选节点最大管径 2026-08-03 19:00:00 +08:00
jiang b0e9d480ef refactor(sensor): 优化灵敏度监测点布置算法 2026-08-03 18:34:13 +08:00
jiang 87d922ea61 refactor(db): retain scheme start time as text 2026-08-03 11:18:38 +08:00
jiang c126e99b60 docs(api): align project lock contract 2026-08-03 11:12:31 +08:00
jiang b6a6527bab refactor(auth): remove project-local user API 2026-08-03 10:39:41 +08:00
jiang f010f071eb refactor(db): align project template schema 2026-08-03 10:39:31 +08:00
jiang 0be31869b5 fix(api): encode untyped datetime responses 2026-07-31 18:39:36 +08:00
70 changed files with 2050 additions and 6154 deletions
+1
View File
@@ -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
# ============================================
+2 -2
View File
@@ -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 -12
View File
@@ -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
+1 -1
View File
@@ -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
+22 -1
View File
@@ -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,
-36
View File
@@ -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)
+20
View File
@@ -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]
-6
View File
@@ -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"],
+12 -2
View File
@@ -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(
+1
View File
@@ -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 = ""
+4
View File
@@ -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
-2
View File
@@ -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 (
-37
View File
@@ -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")
+16 -1
View File
@@ -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())
+2 -49
View File
@@ -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:
"""
+14
View File
@@ -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],
-13
View File
@@ -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
############################################################
-3
View File
@@ -1,3 +0,0 @@
dist/
build/
__pycache__/
-79
View File
@@ -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": "..."
}
```
-26
View File
@@ -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"
-5
View File
@@ -1,5 +0,0 @@
from tjwater_cli.main import console_entry
if __name__ == "__main__":
console_entry()
-14
View File
@@ -1,14 +0,0 @@
{
"include": [
"tjwater_cli",
"tests"
],
"executionEnvironments": [
{
"root": ".",
"extraPaths": [
"."
]
}
]
}
-1
View File
@@ -1 +0,0 @@
pyinstaller>=6.11,<7
-3
View File
@@ -1,3 +0,0 @@
click>=8.1,<9
requests>=2.31,<3
typer>=0.12,<1
-6
View File
@@ -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))
-930
View File
@@ -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
-45
View File
@@ -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",
)
-3
View File
@@ -1,3 +0,0 @@
from .main import app, main
__all__ = ["app", "main"]
-5
View File
@@ -1,5 +0,0 @@
from .main import console_entry
if __name__ == "__main__":
console_entry()
-76
View File
@@ -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"}
-496
View File
@@ -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,
)
-498
View File
@@ -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,
)
-229
View File
@@ -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
-59
View File
@@ -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,
)
-602
View File
@@ -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
-15
View File
@@ -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)
-414
View File
@@ -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)
-112
View File
@@ -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())
-72
View File
@@ -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}")
-626
View File
@@ -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)
-427
View File
@@ -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 可读的错误恢复建议
+1 -1
View File
@@ -3,7 +3,7 @@
"contracts": {
"server": {
"file": "server-v1.openapi.json",
"sha256": "d80a968d281fdb2953364a5979c2d61fda5151a1e1759c01cc96780b11a6d56c"
"sha256": "b003a57c9b8c1a041b644363ef58afb8bfcede1fe076ce48a190d2a1ac915e3f"
}
}
}
+122 -334
View File
@@ -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": "高级版本的阀门关闭分析,支持同时关闭多个阀门,并在指定持续时间内进行模拟。返回纯文本格式的分析结果。",
+270
View File
@@ -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;
-67
View File
@@ -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,
-10
View File
@@ -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
)
+2 -2
View File
@@ -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
)
+2 -2
View File
@@ -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)
);
-5
View File
@@ -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;
+2 -2
View File
@@ -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",
-21
View File
@@ -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
############################################################
-3
View File
@@ -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/`
-53
View File
@@ -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)
+47
View File
@@ -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(
+44 -1
View File
@@ -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"}
+29
View File
@@ -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"
+8 -2
View File
@@ -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,
+391
View File
@@ -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,
)