9 Commits

Author SHA1 Message Date
jiang 7a9fcaae81 ci: add deployment trigger script
Server CI/CD / docker-image (push) Has been cancelled
Server CI/CD / deploy-fallback-log (push) Has been cancelled
2026-06-09 18:22:16 +08:00
jiang a1e9673d9a ci: add Gitea package workflow 2026-06-09 18:18:22 +08:00
jiang e588d1cf33 feat(api): add Tianditu geocoding 2026-06-09 17:09:42 +08:00
jiang 1712ecd4c7 feat(api): add web search endpoint 2026-06-09 16:13:24 +08:00
jiang 441979f581 修改默认超时时间 2026-06-05 19:11:53 +08:00
jiang e336ffcd46 移除存在无效数据的 cli 命令 2026-06-05 16:42:03 +08:00
jiang 52b8f07abd 更新 cli 命令,新增 network 其他元素的属性查询 2026-06-05 15:48:53 +08:00
jiang 7efaeb41e8 新增pyclipper依赖 2026-06-05 13:43:53 +08:00
jiang 9a7aad2d36 fix(cli): constrain timeseries option values 2026-06-05 13:43:32 +08:00
28 changed files with 1665 additions and 464 deletions
+18
View File
@@ -0,0 +1,18 @@
.git
.github
.gitea
__pycache__/
.pytest_cache/
.mypy_cache/
.venv/
venv/
build/
dist/
package/
temp/
data/
db_inp/
inp/
.env
*.pyc
*.dump
+15
View File
@@ -48,3 +48,18 @@ METADATA_DB_PASSWORD="password"
KEYCLOAK_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----" KEYCLOAK_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
KEYCLOAK_ALGORITHM=RS256 KEYCLOAK_ALGORITHM=RS256
KEYCLOAK_AUDIENCE="account" KEYCLOAK_AUDIENCE="account"
# ============================================
# Bocha Web Search API
# ============================================
BOCHA_API_KEY="sk-your-bocha-api-key"
BOCHA_WEB_SEARCH_URL="https://api.bochaai.com/v1/web-search"
BOCHA_WEB_SEARCH_TIMEOUT_SECONDS=30
# ============================================
# Tianditu Geocoding API
# ============================================
TIANDITU_GEOCODER_TOKEN="your-tianditu-geocoder-token"
TIANDITU_GEOCODER_URL="https://api.tianditu.gov.cn/geocoder"
TIANDITU_GEOCODER_TIMEOUT_SECONDS=30
+211
View File
@@ -0,0 +1,211 @@
name: Server CI/CD
on:
push:
tags:
- "v*"
- "latest"
workflow_dispatch: {}
jobs:
docker-image:
runs-on: ubuntu-22.04
if: startsWith(github.ref, 'refs/tags/')
permissions:
contents: read
defaults:
run:
shell: bash
steps:
- name: Checkout code
env:
SERVER_URL: ${{ github.server_url }}
REPOSITORY: ${{ github.repository }}
COMMIT_SHA: ${{ github.sha }}
GIT_USERNAME: ${{ github.actor }}
GIT_TOKEN: ${{ github.token }}
run: |
case "$SERVER_URL" in
http://*)
AUTH_SERVER_URL="http://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#http://}"
;;
https://*)
AUTH_SERVER_URL="https://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#https://}"
;;
*)
AUTH_SERVER_URL="$SERVER_URL"
;;
esac
if [ ! -d .git ]; then
git init .
fi
if git remote get-url origin >/dev/null 2>&1; then
git remote set-url origin "${AUTH_SERVER_URL}/${REPOSITORY}.git"
else
git remote add origin "${AUTH_SERVER_URL}/${REPOSITORY}.git"
fi
git fetch --depth=1 origin "$COMMIT_SHA"
git checkout --force --detach FETCH_HEAD
git clean -ffdx
- name: Normalize image metadata
env:
RAW_REGISTRY_HOST: ${{ vars.REGISTRY_HOST }}
RAW_REPOSITORY: ${{ github.repository }}
RAW_REF_NAME: ${{ github.ref_name }}
run: |
RAW_REGISTRY_HOST="$(printf '%s' "${RAW_REGISTRY_HOST}" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
if [ -z "${RAW_REGISTRY_HOST}" ]; then
echo "Missing required repository variable: REGISTRY_HOST"
exit 1
fi
REGISTRY_HOST="${RAW_REGISTRY_HOST#http://}"
REGISTRY_HOST="${REGISTRY_HOST#https://}"
REGISTRY_HOST="${REGISTRY_HOST%/}"
if [ -z "${REGISTRY_HOST}" ]; then
echo "Repository variable REGISTRY_HOST resolves to an empty host"
exit 1
fi
REPOSITORY_PATH="${RAW_REPOSITORY#/}"
IMAGE_REPOSITORY_PATH="$(printf '%s' "$REPOSITORY_PATH" | tr '[:upper:]' '[:lower:]')"
IMAGE_NAME="${REGISTRY_HOST}/${IMAGE_REPOSITORY_PATH}"
IMAGE_TAG="${RAW_REF_NAME}"
{
echo "REGISTRY_HOST=${REGISTRY_HOST}"
echo "REPOSITORY_PATH=${REPOSITORY_PATH}"
echo "IMAGE_REPOSITORY_PATH=${IMAGE_REPOSITORY_PATH}"
echo "IMAGE_NAME=${IMAGE_NAME}"
echo "IMAGE_TAG=${IMAGE_TAG}"
echo "IMAGE_REF=${IMAGE_NAME}:${IMAGE_TAG}"
} >> "$GITHUB_ENV"
- name: Login to Gitea Container Registry
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
if [ -z "${REGISTRY_HOST:-}" ]; then
echo "Missing resolved environment value: REGISTRY_HOST"
exit 1
fi
if [ -z "${REGISTRY_USERNAME}" ]; then
echo "Missing required repository secret: REGISTRY_USERNAME"
exit 1
fi
if [ -z "${REGISTRY_PASSWORD}" ]; then
echo "Missing required repository secret: REGISTRY_PASSWORD"
exit 1
fi
echo "Logging into registry host: ${REGISTRY_HOST}"
echo "${REGISTRY_PASSWORD}" | docker login "$REGISTRY_HOST" \
--username "${REGISTRY_USERNAME}" \
--password-stdin
- name: Build and Push Image
run: |
if [ -z "${IMAGE_NAME:-}" ] || [ -z "${IMAGE_TAG:-}" ]; then
echo "Missing resolved image metadata: IMAGE_NAME or IMAGE_TAG"
exit 1
fi
push_with_retry() {
image_ref="$1"
attempt=1
max_attempts=3
while [ "$attempt" -le "$max_attempts" ]; do
if docker push "$image_ref"; then
return 0
fi
if [ "$attempt" -eq "$max_attempts" ]; then
return 1
fi
echo "Push failed for $image_ref (attempt $attempt/$max_attempts); retrying in 10s..."
attempt=$((attempt + 1))
sleep 10
done
}
if [ "${IMAGE_TAG}" = "latest" ]; then
docker build \
-f ./Dockerfile \
-t "${IMAGE_NAME}:latest" \
.
push_with_retry "${IMAGE_NAME}:latest"
else
docker build \
-f ./Dockerfile \
-t "${IMAGE_NAME}:${IMAGE_TAG}" \
-t "${IMAGE_NAME}:latest" \
.
push_with_retry "${IMAGE_NAME}:${IMAGE_TAG}"
push_with_retry "${IMAGE_NAME}:latest"
fi
- name: Notify Deploy Server
run: |
post_deploy_webhook() {
label="$1"
payload="$2"
webhook_url="${{ vars.DEPLOY_WEBHOOK_URL }}"
token="${{ secrets.DEPLOY_WEBHOOK_TOKEN }}"
webhook_url=$(echo "$webhook_url" | xargs)
echo "[$label] Calling webhook: $webhook_url"
http_code=$(curl -sS -D /tmp/deploy_headers.txt -o /tmp/deploy_response.txt -w "%{http_code}" -X POST "$webhook_url" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $token" \
-d "$payload")
echo "[$label] webhook HTTP status: ${http_code}"
if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then
return 0
fi
echo "[$label] response headers:"
cat /tmp/deploy_headers.txt
echo "[$label] response body:"
cat /tmp/deploy_response.txt
return 1
}
PRIMARY_PAYLOAD="{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${REPOSITORY_PATH}\"}"
FALLBACK_PAYLOAD="{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${IMAGE_REPOSITORY_PATH}\"}"
echo "Deploy webhook target: ${{ vars.DEPLOY_WEBHOOK_URL }}"
echo "Deploy payload(primary): image=${IMAGE_REF}, tag=${IMAGE_TAG}, repo=${REPOSITORY_PATH}"
if post_deploy_webhook "primary" "$PRIMARY_PAYLOAD"; then
exit 0
fi
echo "Primary webhook request failed, retrying with lowercase repo path..."
echo "Deploy payload(fallback): image=${IMAGE_REF}, tag=${IMAGE_TAG}, repo=${IMAGE_REPOSITORY_PATH}"
if post_deploy_webhook "fallback" "$FALLBACK_PAYLOAD"; then
exit 0
fi
echo "Deploy webhook failed after primary and fallback attempts."
exit 1
deploy-fallback-log:
runs-on: ubuntu-22.04
needs: docker-image
if: failure()
steps:
- name: Deployment not triggered
run: echo "Image build/push failed, deployment webhook was not called."
-128
View File
@@ -1,128 +0,0 @@
name: Build And Package
on:
push:
tags:
- "v*"
jobs:
build-package:
runs-on: ${{ matrix.os }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
steps:
- name: Checkout source
uses: actions/checkout@v5
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install system build tools
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y build-essential
- name: Install compile dependencies
run: |
python -m pip install --upgrade pip
pip install cython setuptools wheel
- name: Run Cython compile
run: |
python scripts/compile.py
- name: Prepare package and archive
run: |
python - <<'PY'
import os
import shutil
import tarfile
import zipfile
import sys
from pathlib import Path
root = Path.cwd()
package_dir = root / "package"
dist_dir = root / "dist"
for d in [package_dir, dist_dir]:
if d.exists():
shutil.rmtree(d)
d.mkdir(parents=True, exist_ok=True)
# Define directories with compiled artifacts
compile_dirs = ["app/services", "app/native/wndb", "app/algorithms"]
# Global ignore list
ignore_names = {
".git",
".github",
"__pycache__",
".pytest_cache",
".mypy_cache",
".venv",
"venv",
"temp",
"tests",
"package",
"dist",
}
def ignore_func(directory, names):
rel_dir = os.path.relpath(directory, root).replace("\\", "/")
is_in_compile_path = any(rel_dir.startswith(d) for d in compile_dirs)
ignored = []
for name in names:
if name in ignore_names or name.endswith(".pyc"):
ignored.append(name)
# Exclude source .py files only in compiled directories
elif is_in_compile_path and name.endswith(".py"):
ignored.append(name)
return ignored
for item in root.iterdir():
if item.name in ignore_names:
continue
target = package_dir / item.name
if item.is_dir():
shutil.copytree(item, target, ignore=ignore_func)
else:
shutil.copy2(item, target)
# Safety guard: ensure no .github directory remains
github_paths = [p for p in package_dir.rglob(".github") if p.is_dir()]
for p in github_paths:
shutil.rmtree(p, ignore_errors=True)
sha = os.environ["GITHUB_SHA"]
run_os = os.environ["RUNNER_OS"].lower()
if run_os == "windows":
archive_path = dist_dir / f"tjwater-server-{run_os}-{sha}.zip"
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for f in package_dir.rglob("*"):
if f.is_file():
zf.write(f, f.relative_to(package_dir))
else:
archive_path = dist_dir / f"tjwater-server-{run_os}-{sha}.tar.gz"
with tarfile.open(archive_path, "w:gz") as tf:
tf.add(package_dir, arcname=".")
print(f"Archive created: {archive_path}")
PY
shell: bash
- name: Upload package artifact
uses: actions/upload-artifact@v5
with:
name: tjwater-server-package-${{ runner.os }}
path: dist/*
retention-days: 14
+38
View File
@@ -0,0 +1,38 @@
# Repository Guidelines
## Project Structure & Module Organization
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.
## Build, Test, and Development Commands
Use the existing conda environment when available:
```bash
conda run -n server python -m pytest tests/unit tests/auth -q
conda run -n server uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
docker build -t tjwater-server:local .
docker compose -f infra/docker/docker-compose.yml config
```
`pytest` runs backend tests. `uvicorn` starts the FastAPI app locally. `docker build` verifies the container image. `docker compose config` validates compose syntax and variable expansion.
## Coding Style & Naming Conventions
Use Python 3.12, four-space indentation, type hints for new public functions, and explicit imports. Keep API endpoint modules grouped by domain under `app/api/v1/endpoints`. Use `snake_case` for files, functions, and variables; `PascalCase` for classes and Pydantic models. Prefer existing repository/service patterns in `app/infra/db` and `app/services` over introducing new abstractions.
## Testing Guidelines
The project uses `pytest`. Name test files `test_*.py` and test functions `test_*`. Keep unit tests isolated with fakes or monkeypatching from `tests/conftest.py`. Some existing tests depend on local data outside the repository; avoid adding new tests that require untracked files. For API changes, add or update tests in `tests/api`.
## 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.
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.
## Security & Configuration Tips
Do not commit `.env`, database dumps, generated caches, or local project data. Use `.env.example` as the configuration template. Secrets for CI/CD belong in Gitea repository secrets such as `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`, and deploy webhook credentials.
+8 -5
View File
@@ -2,19 +2,22 @@ FROM condaforge/miniforge3:latest
WORKDIR /app WORKDIR /app
ENV PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple \
PIP_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn \
UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
# 安装 Python 3.12 和 pymetis (通过 conda-forge 避免编译问题) # 安装 Python 3.12 和 pymetis (通过 conda-forge 避免编译问题)
RUN mamba install -y python=3.12 pymetis && \ RUN mamba install -y python=3.12 pymetis && \
mamba clean -afy mamba clean -afy
COPY requirements.txt . COPY requirements.txt .
RUN pip install uv RUN pip install --no-cache-dir uv
RUN uv pip install --system --no-cache-dir -r requirements.txt RUN uv pip install --system --no-cache-dir -r requirements.txt
# 将代码放入子目录 'app'将数据放入子目录 'db_inp' # 将代码放入子目录 'app'临时数据目录运行时创建。
# 这样临时文件默认会生成在 /app 下,而代码在 /app/app 下,实现了分离 # db_inp 和 .env 都不应依赖 Git 跟踪或被烘焙进镜像。
COPY app ./app COPY app ./app
COPY db_inp ./db_inp RUN mkdir -p ./db_inp
COPY .env .
# 设置 PYTHONPATH 以便 uvicorn 找到 app 模块 # 设置 PYTHONPATH 以便 uvicorn 找到 app 模块
ENV PYTHONPATH=/app ENV PYTHONPATH=/app
+29
View File
@@ -0,0 +1,29 @@
from typing import Any
from fastapi import APIRouter, HTTPException, status
from app.services.geocoding import (
TiandituGeocodeRequest,
TiandituGeocodingAPIError,
TiandituGeocodingConfigError,
geocode_tianditu,
)
router = APIRouter()
@router.post(
"/tianditu/geocode",
summary="Tianditu Geocoding",
description="调用天地图地理编码服务,将结构化地址转换为经纬度",
)
async def tianditu_geocode(request: TiandituGeocodeRequest) -> dict[str, Any]:
try:
return await geocode_tianditu(request)
except TiandituGeocodingConfigError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=str(exc),
) from exc
except TiandituGeocodingAPIError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
+29
View File
@@ -0,0 +1,29 @@
from typing import Any
from fastapi import APIRouter, HTTPException, status
from app.services.web_search import (
BochaSearchAPIError,
BochaSearchConfigError,
WebSearchRequest,
search_bocha_web,
)
router = APIRouter()
@router.post(
"/web-search",
summary="Web Search",
description="调用 Bocha Web Search API 获取实时网页搜索结果",
)
async def web_search(request: WebSearchRequest) -> dict[str, Any]:
try:
return await search_bocha_web(request)
except BochaSearchConfigError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=str(exc),
) from exc
except BochaSearchAPIError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
+4
View File
@@ -18,6 +18,8 @@ from app.api.v1.endpoints import (
user_management, # 新增:用户管理 user_management, # 新增:用户管理
audit, # 新增:审计日志 audit, # 新增:审计日志
meta, meta,
web_search,
geocoding,
) )
from app.api.v1.endpoints.network import ( from app.api.v1.endpoints.network import (
general, general,
@@ -93,6 +95,8 @@ api_router.include_router(schemes.router, tags=["Schemes"])
api_router.include_router(misc.router, tags=["Misc"]) api_router.include_router(misc.router, tags=["Misc"])
api_router.include_router(risk.router, tags=["Risk"]) api_router.include_router(risk.router, tags=["Risk"])
api_router.include_router(cache.router, tags=["Cache"]) api_router.include_router(cache.router, tags=["Cache"])
api_router.include_router(web_search.router, tags=["Web Search"])
api_router.include_router(geocoding.router, tags=["Geocoding"])
api_router.include_router(leakage.router, prefix="/leakage", tags=["Leakage"]) api_router.include_router(leakage.router, prefix="/leakage", tags=["Leakage"])
api_router.include_router( api_router.include_router(
burst_detection.router, prefix="/burst-detection", tags=["Burst Detection"] burst_detection.router, prefix="/burst-detection", tags=["Burst Detection"]
+10
View File
@@ -64,6 +64,16 @@ class Settings(BaseSettings):
KEYCLOAK_ALGORITHM: str = "RS256" KEYCLOAK_ALGORITHM: str = "RS256"
KEYCLOAK_AUDIENCE: str = "" KEYCLOAK_AUDIENCE: str = ""
# Bocha Web Search API
BOCHA_API_KEY: str = ""
BOCHA_WEB_SEARCH_URL: str = "https://api.bochaai.com/v1/web-search"
BOCHA_WEB_SEARCH_TIMEOUT_SECONDS: float = 30.0
# Tianditu Geocoding API
TIANDITU_GEOCODER_TOKEN: str = ""
TIANDITU_GEOCODER_URL: str = "https://api.tianditu.gov.cn/geocoder"
TIANDITU_GEOCODER_TIMEOUT_SECONDS: float = 30.0
@property @property
def SQLALCHEMY_DATABASE_URI(self) -> str: def SQLALCHEMY_DATABASE_URI(self) -> str:
db_password = quote_plus(self.DB_PASSWORD) db_password = quote_plus(self.DB_PASSWORD)
+4 -35
View File
@@ -1,36 +1,5 @@
from app.services.network_import import network_update, submit_scada_info """Service package.
from app.services.scheme_management import (
create_user,
delete_user,
scheme_name_exists,
store_scheme_info,
delete_scheme_info,
query_scheme_list,
upload_shp_to_pg,
submit_risk_probability_result,
)
from app.services.valve_isolation import analyze_valve_isolation
from app.services.simulation_ops import (
project_management,
scheduling_simulation,
daily_scheduling_simulation,
)
from app.services.leakage_identifier import run_leakage_identification
__all__ = [ Keep package initialization lightweight. Import concrete service modules directly,
"network_update", for example: `from app.services.tjnetwork import open_project`.
"submit_scada_info", """
"create_user",
"delete_user",
"scheme_name_exists",
"store_scheme_info",
"delete_scheme_info",
"query_scheme_list",
"upload_shp_to_pg",
"submit_risk_probability_result",
"project_management",
"scheduling_simulation",
"daily_scheduling_simulation",
"analyze_valve_isolation",
"run_leakage_identification",
]
+76
View File
@@ -0,0 +1,76 @@
import json
from typing import Any
import httpx
from pydantic import AliasChoices, BaseModel, Field
from app.core.config import settings
class TiandituGeocodeRequest(BaseModel):
keyword: str = Field(
...,
min_length=1,
validation_alias=AliasChoices("keyword", "keyWord"),
description="地理编码地址关键字",
)
class TiandituGeocodingConfigError(RuntimeError):
pass
class TiandituGeocodingAPIError(RuntimeError):
def __init__(self, status_code: int, detail: Any):
super().__init__("Tianditu Geocoding API request failed")
self.status_code = status_code
self.detail = detail
async def geocode_tianditu(
request: TiandituGeocodeRequest,
*,
client: httpx.AsyncClient | None = None,
) -> dict[str, Any]:
if not settings.TIANDITU_GEOCODER_TOKEN:
raise TiandituGeocodingConfigError("TIANDITU_GEOCODER_TOKEN is not configured")
params = {
"ds": json.dumps({"keyWord": request.keyword}, ensure_ascii=False),
"tk": settings.TIANDITU_GEOCODER_TOKEN,
}
if client is not None:
response = await client.get(settings.TIANDITU_GEOCODER_URL, params=params)
return _parse_response(response)
async with httpx.AsyncClient(
timeout=settings.TIANDITU_GEOCODER_TIMEOUT_SECONDS
) as managed_client:
response = await managed_client.get(
settings.TIANDITU_GEOCODER_URL,
params=params,
)
return _parse_response(response)
def _parse_response(response: httpx.Response) -> dict[str, Any]:
try:
response.raise_for_status()
except httpx.HTTPStatusError as exc:
raise TiandituGeocodingAPIError(
exc.response.status_code,
_response_detail(exc.response),
) from exc
data = response.json()
if str(data.get("status")) != "0":
raise TiandituGeocodingAPIError(502, data)
return data
def _response_detail(response: httpx.Response) -> Any:
try:
return response.json()
except ValueError:
return response.text
+93
View File
@@ -0,0 +1,93 @@
from typing import Any, Literal
import httpx
from pydantic import BaseModel, Field
from app.core.config import settings
Freshness = Literal["noLimit", "oneDay", "oneWeek", "oneMonth", "oneYear"]
class WebSearchRequest(BaseModel):
query: str = Field(..., min_length=1, description="搜索关键词")
freshness: Freshness | str = Field(
default="noLimit",
description="时间范围:noLimit、oneDay、oneWeek、oneMonth、oneYear 或日期范围",
)
summary: bool = Field(default=True, description="是否返回网页摘要")
count: int = Field(default=10, ge=1, le=50, description="返回结果数量")
include: list[str] | None = Field(default=None, description="限定搜索域名")
exclude: list[str] | None = Field(default=None, description="排除搜索域名")
class BochaSearchConfigError(RuntimeError):
pass
class BochaSearchAPIError(RuntimeError):
def __init__(self, status_code: int, detail: Any):
super().__init__("Bocha Web Search API request failed")
self.status_code = status_code
self.detail = detail
def _build_payload(request: WebSearchRequest) -> dict[str, Any]:
payload = request.model_dump(exclude_none=True)
if request.include:
payload["include"] = ",".join(request.include)
if request.exclude:
payload["exclude"] = ",".join(request.exclude)
return payload
async def search_bocha_web(
request: WebSearchRequest,
*,
client: httpx.AsyncClient | None = None,
) -> dict[str, Any]:
if not settings.BOCHA_API_KEY:
raise BochaSearchConfigError("BOCHA_API_KEY is not configured")
headers = {
"Authorization": f"Bearer {settings.BOCHA_API_KEY}",
"Content-Type": "application/json",
}
payload = _build_payload(request)
if client is not None:
response = await client.post(
settings.BOCHA_WEB_SEARCH_URL,
headers=headers,
json=payload,
)
return _parse_response(response)
async with httpx.AsyncClient(
timeout=settings.BOCHA_WEB_SEARCH_TIMEOUT_SECONDS
) as managed_client:
response = await managed_client.post(
settings.BOCHA_WEB_SEARCH_URL,
headers=headers,
json=payload,
)
return _parse_response(response)
def _parse_response(response: httpx.Response) -> dict[str, Any]:
try:
response.raise_for_status()
except httpx.HTTPStatusError as exc:
raise BochaSearchAPIError(
exc.response.status_code,
_response_detail(exc.response),
) from exc
return response.json()
def _response_detail(response: httpx.Response) -> Any:
try:
return response.json()
except ValueError:
return response.text
+425 -12
View File
@@ -71,14 +71,14 @@ def test_auth_stdin_can_be_reused_with_runtime_context_cache(monkeypatch):
def fake_request_json(ctx, **kwargs): def fake_request_json(ctx, **kwargs):
observed_runtime_ids.append(id(ctx)) observed_runtime_ids.append(id(ctx))
assert ctx.auth.access_token == "token-1" assert ctx.auth.access_token == "token-1"
assert kwargs["params"] == {"network": "tjwater", "node": "11"} assert kwargs["params"] == {"network": "tjwater", "junction": "11"}
return {"node": "11"}, 5 return {"id": "11"}, 5
monkeypatch.setattr(common, "request_json", fake_request_json) monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke( result = runner.invoke(
app, app,
["--auth-stdin", "network", "get-node-properties", "--node", "11"], ["--auth-stdin", "network", "get-junction-properties", "--junction", "11"],
input=json.dumps( input=json.dumps(
{ {
"server": "http://server", "server": "http://server",
@@ -93,37 +93,70 @@ def test_auth_stdin_can_be_reused_with_runtime_context_cache(monkeypatch):
assert result.exit_code == 0 assert result.exit_code == 0
assert payload["ok"] is True assert payload["ok"] is True
assert payload["data"] == {"node": "11"} assert payload["data"] == {"id": "11"}
assert len(observed_runtime_ids) == 1 assert len(observed_runtime_ids) == 1
def test_network_get_all_junction_properties_uses_network_context(monkeypatch): def test_network_get_junction_properties_uses_network_context(monkeypatch):
captured = {} captured = {}
def fake_request_json(ctx, **kwargs): def fake_request_json(ctx, **kwargs):
captured["access_token"] = ctx.auth.access_token captured["access_token"] = ctx.auth.access_token
captured["path"] = kwargs["path"]
captured["params"] = kwargs["params"] captured["params"] = kwargs["params"]
return [{"id": "J1"}], 5 return {"id": "J1"}, 5
monkeypatch.setenv("TJWATER_SERVER", "http://server") monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "tjwater") monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
monkeypatch.setattr(common, "request_json", fake_request_json) monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(app, ["network", "get-all-junction-properties"]) result = runner.invoke(app, ["network", "get-junction-properties", "--junction", "J1"])
payload = json.loads(result.stdout) payload = json.loads(result.stdout)
assert result.exit_code == 0 assert result.exit_code == 0
assert payload["ok"] is True assert payload["ok"] is True
assert payload["data"] == [{"id": "J1"}] assert payload["data"] == {"id": "J1"}
assert captured == {"access_token": "abc", "params": {"network": "tjwater"}} assert captured == {
"access_token": "abc",
"path": "/getjunctionproperties/",
"params": {"network": "tjwater", "junction": "J1"},
}
def test_network_get_all_pipe_properties_uses_network_context(monkeypatch): def test_network_get_pipe_properties_uses_network_context(monkeypatch):
captured = {} captured = {}
def fake_request_json(ctx, **kwargs): def fake_request_json(ctx, **kwargs):
captured["access_token"] = ctx.auth.access_token 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.setenv("TJWATER_NETWORK", "tjwater")
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": "/getpipeproperties/",
"params": {"network": "tjwater", "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"] captured["params"] = kwargs["params"]
return [{"id": "P1"}], 5 return [{"id": "P1"}], 5
@@ -132,13 +165,233 @@ def test_network_get_all_pipe_properties_uses_network_context(monkeypatch):
monkeypatch.setenv("TJWATER_NETWORK", "tjwater") monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
monkeypatch.setattr(common, "request_json", fake_request_json) monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(app, ["network", "get-all-pipe-properties"]) result = runner.invoke(app, ["network", "get-all-pipes-properties"])
payload = json.loads(result.stdout) payload = json.loads(result.stdout)
assert result.exit_code == 0 assert result.exit_code == 0
assert payload["ok"] is True assert payload["ok"] is True
assert payload["data"] == [{"id": "P1"}] assert payload["data"] == [{"id": "P1"}]
assert captured == {"access_token": "abc", "params": {"network": "tjwater"}} assert captured == {
"access_token": "abc",
"path": "/getallpipeproperties/",
"params": {"network": "tjwater"},
}
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.setenv("TJWATER_NETWORK", "tjwater")
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": "/getreservoirproperties/",
"params": {"network": "tjwater", "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.setenv("TJWATER_NETWORK", "tjwater")
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": "/getallreservoirproperties/",
"params": {"network": "tjwater"},
}
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.setenv("TJWATER_NETWORK", "tjwater")
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": "/gettankproperties/",
"params": {"network": "tjwater", "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.setenv("TJWATER_NETWORK", "tjwater")
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": "/getalltankproperties/",
"params": {"network": "tjwater"},
}
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.setenv("TJWATER_NETWORK", "tjwater")
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": "/getpumpproperties/",
"params": {"network": "tjwater", "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.setenv("TJWATER_NETWORK", "tjwater")
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": "/getallpumpproperties/",
"params": {"network": "tjwater"},
}
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.setenv("TJWATER_NETWORK", "tjwater")
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": "/getvalveproperties/",
"params": {"network": "tjwater", "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.setenv("TJWATER_NETWORK", "tjwater")
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": "/getallvalveproperties/",
"params": {"network": "tjwater"},
}
def test_help_outputs_json_lists_commands(): def test_help_outputs_json_lists_commands():
@@ -245,6 +498,33 @@ def test_leaf_help_flag_includes_usage_and_example():
assert "DURATION" 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): def test_analysis_burst_returns_next_step_to_fetch_scheme(monkeypatch, tmp_path: Path):
monkeypatch.setenv("TJWATER_SERVER", "http://server") monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
@@ -498,6 +778,139 @@ def test_main_missing_option_error_includes_usage_and_next_step(capsys):
assert '"tjwater-cli help simulation run"' 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): def test_main_bare_analysis_returns_typer_help_without_json_error(capsys):
exit_code = main(["analysis"]) exit_code = main(["analysis"])
stdout = capsys.readouterr().out stdout = capsys.readouterr().out
-6
View File
@@ -26,8 +26,6 @@ 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_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_scada_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
data_scheme_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) data_scheme_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
data_extension_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
data_misc_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
app.add_typer(network_app, name="network") app.add_typer(network_app, name="network")
app.add_typer(component_app, name="component") app.add_typer(component_app, name="component")
@@ -50,8 +48,6 @@ data_timeseries_app.add_typer(data_timeseries_scada_app, name="scada")
data_timeseries_app.add_typer(data_timeseries_composite_app, name="composite") 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_scada_app, name="scada")
data_app.add_typer(data_scheme_app, name="scheme") data_app.add_typer(data_scheme_app, name="scheme")
data_app.add_typer(data_extension_app, name="extension")
data_app.add_typer(data_misc_app, name="misc")
GROUP_HELP_APPS: list[tuple[typer.Typer, tuple[str, ...]]] = [ GROUP_HELP_APPS: list[tuple[typer.Typer, tuple[str, ...]]] = [
(network_app, ("network",)), (network_app, ("network",)),
@@ -75,8 +71,6 @@ GROUP_HELP_APPS: list[tuple[typer.Typer, tuple[str, ...]]] = [
(data_timeseries_composite_app, ("data", "timeseries", "composite")), (data_timeseries_composite_app, ("data", "timeseries", "composite")),
(data_scada_app, ("data", "scada")), (data_scada_app, ("data", "scada")),
(data_scheme_app, ("data", "scheme")), (data_scheme_app, ("data", "scheme")),
(data_extension_app, ("data", "extension")),
(data_misc_app, ("data", "misc")),
] ]
TOP_LEVEL_COMMANDS = {"help", "network", "component", "simulation", "analysis", "data"} TOP_LEVEL_COMMANDS = {"help", "network", "component", "simulation", "analysis", "data"}
+7 -11
View File
@@ -31,6 +31,7 @@ from .core import (
require_username, require_username,
resolve_scheme, resolve_scheme,
) )
from .option_types import DataSource, ValveMode
@simulation_app.command("run") @simulation_app.command("run")
@@ -100,7 +101,7 @@ def analysis_burst(
@analysis_app.command("valve") @analysis_app.command("valve")
def analysis_valve( def analysis_valve(
ctx: typer.Context, ctx: typer.Context,
mode: Annotated[str, typer.Option("--mode", help="close|isolation")], mode: Annotated[ValveMode, typer.Option("--mode", help="分析模式,仅支持 close|isolation")],
start_time: Annotated[str | None, typer.Option("--start-time", help="close 模式需要")] = None, start_time: Annotated[str | None, typer.Option("--start-time", help="close 模式需要")] = None,
valve: Annotated[list[str] | None, typer.Option("--valve", help="阀门 ID,可重复")] = None, valve: Annotated[list[str] | None, typer.Option("--valve", help="阀门 ID,可重复")] = None,
element: Annotated[list[str] | None, typer.Option("--element", help="isolation 模式的事故元素,可重复")] = None, element: Annotated[list[str] | None, typer.Option("--element", help="isolation 模式的事故元素,可重复")] = None,
@@ -110,7 +111,7 @@ def analysis_valve(
) -> None: ) -> None:
runtime = runtime_context(ctx) runtime = runtime_context(ctx)
network = require_network(runtime) network = require_network(runtime)
if mode == "close": if mode == ValveMode.CLOSE:
if not start_time or not valve: if not start_time or not valve:
raise CLIError( raise CLIError(
"CLI 参数错误", "CLI 参数错误",
@@ -135,7 +136,7 @@ def analysis_valve(
require_network_ctx=True, require_network_ctx=True,
) )
return return
if mode == "isolation": if mode == ValveMode.ISOLATION:
if not element: if not element:
raise CLIError( raise CLIError(
"CLI 参数错误", "CLI 参数错误",
@@ -156,12 +157,7 @@ def analysis_valve(
require_network_ctx=True, require_network_ctx=True,
) )
return return
raise CLIError( raise AssertionError(f"unreachable valve mode: {mode}")
"CLI 参数错误",
code="INVALID_MODE",
message="--mode must be close or isolation",
exit_code=2,
)
@analysis_app.command("flushing") @analysis_app.command("flushing")
@@ -397,7 +393,7 @@ def analysis_burst_location_locate(
end_time: Annotated[str, typer.Option("--end-time", help="RFC3339 结束时间")], end_time: Annotated[str, typer.Option("--end-time", help="RFC3339 结束时间")],
burst_leakage: Annotated[float, typer.Option("--burst-leakage", help="爆管漏水量")], burst_leakage: Annotated[float, typer.Option("--burst-leakage", help="爆管漏水量")],
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
data_source: Annotated[str, typer.Option("--data-source", help="monitoring|simulation")] = "monitoring", 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, 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, 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, pressure_file: Annotated[Path | None, typer.Option("--pressure-file", help="包含 burst_pressure/normal_pressure 的 JSON 文件")] = None,
@@ -410,7 +406,7 @@ def analysis_burst_location_locate(
body = { body = {
"network": require_network(runtime), "network": require_network(runtime),
"scheme_name": resolve_scheme(runtime, scheme, required=True), "scheme_name": resolve_scheme(runtime, scheme, required=True),
"data_source": data_source, "data_source": data_source.value,
"scada_burst_start": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), "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(), "scada_burst_end": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
"burst_leakage": burst_leakage, "burst_leakage": burst_leakage,
+70 -135
View File
@@ -5,8 +5,6 @@ from typing import Annotated
import typer import typer
from .apps import ( from .apps import (
data_extension_app,
data_misc_app,
data_scada_app, data_scada_app,
data_scheme_app, data_scheme_app,
data_timeseries_composite_app, data_timeseries_composite_app,
@@ -16,12 +14,55 @@ from .apps import (
) )
from .common import emit_api, runtime_context from .common import emit_api, runtime_context
from .core import CLIError, parse_time_with_timezone, require_network, resolve_scheme from .core import CLIError, parse_time_with_timezone, require_network, 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: def _scheme_type_option(scheme_type: str | None) -> str:
return scheme_type or "simulation" 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") @data_timeseries_realtime_app.command("links")
def data_realtime_links( def data_realtime_links(
ctx: typer.Context, ctx: typer.Context,
@@ -66,7 +107,7 @@ def data_realtime_nodes(
def data_realtime_simulation_by_id_time( def data_realtime_simulation_by_id_time(
ctx: typer.Context, ctx: typer.Context,
id: Annotated[str, typer.Option("--id", help="元素 ID")], id: Annotated[str, typer.Option("--id", help="元素 ID")],
type: Annotated[str, typer.Option("--type", help="pipe|junction")], type: Annotated[ElementType, typer.Option("--type", help="元素类型,仅支持 pipe|junctionlinks/nodes 是子命令")],
time: Annotated[str, typer.Option("--time", help="查询时间")], time: Annotated[str, typer.Option("--time", help="查询时间")],
) -> None: ) -> None:
emit_api( emit_api(
@@ -76,7 +117,7 @@ def data_realtime_simulation_by_id_time(
path="/realtime/query/by-id-time", path="/realtime/query/by-id-time",
params={ params={
"id": id, "id": id,
"type": type, "type": type.value,
"query_time": parse_time_with_timezone(time, option_name="--time").isoformat(), "query_time": parse_time_with_timezone(time, option_name="--time").isoformat(),
}, },
require_auth=True, require_auth=True,
@@ -87,17 +128,18 @@ def data_realtime_simulation_by_id_time(
@data_timeseries_realtime_app.command("simulation-by-time-property") @data_timeseries_realtime_app.command("simulation-by-time-property")
def data_realtime_simulation_by_time_property( def data_realtime_simulation_by_time_property(
ctx: typer.Context, ctx: typer.Context,
type: Annotated[str, typer.Option("--type", help="pipe|junction")], type: Annotated[ElementType, typer.Option("--type", help="元素类型,仅支持 pipe|junctionlinks/nodes 是子命令")],
time: Annotated[str, typer.Option("--time", help="查询时间")], time: Annotated[str, typer.Option("--time", help="查询时间")],
property: Annotated[str, typer.Option("--property", help="属性名")], property: Annotated[str, typer.Option("--property", help="属性名pipe: flow|friction|headloss|quality|reaction|setting|status|velocityjunction: actual_demand|total_head|pressure|quality")],
) -> None: ) -> None:
property = _validate_element_property(type, property, option_name="--property")
emit_api( emit_api(
ctx, ctx,
summary="读取实时属性聚合数据成功", summary="读取实时属性聚合数据成功",
method="GET", method="GET",
path="/realtime/query/by-time-property", path="/realtime/query/by-time-property",
params={ params={
"type": type, "type": type.value,
"query_time": parse_time_with_timezone(time, option_name="--time").isoformat(), "query_time": parse_time_with_timezone(time, option_name="--time").isoformat(),
"property": property, "property": property,
}, },
@@ -135,13 +177,14 @@ def data_scheme_links(
def data_scheme_node_field( def data_scheme_node_field(
ctx: typer.Context, ctx: typer.Context,
node: Annotated[str, typer.Option("--node", help="节点 ID")], node: Annotated[str, typer.Option("--node", help="节点 ID")],
field: Annotated[str, typer.Option("--field", help="字段名")], field: Annotated[str, typer.Option("--field", help="字段名,仅支持 actual_demand|total_head|pressure|quality")],
start_time: Annotated[str, typer.Option("--start-time", help="开始时间")], start_time: Annotated[str, typer.Option("--start-time", help="开始时间")],
end_time: Annotated[str, typer.Option("--end-time", help="结束时间")], end_time: Annotated[str, typer.Option("--end-time", help="结束时间")],
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
scheme_type: Annotated[str | None, typer.Option("--scheme-type", help="方案类型")] = None, scheme_type: Annotated[str | None, typer.Option("--scheme-type", help="方案类型")] = None,
) -> None: ) -> None:
runtime = runtime_context(ctx) runtime = runtime_context(ctx)
field = _validate_node_field(field, option_name="--field")
emit_api( emit_api(
ctx, ctx,
summary="读取方案节点字段成功", summary="读取方案节点字段成功",
@@ -162,22 +205,22 @@ def data_scheme_node_field(
@data_timeseries_scheme_app.command("simulation") @data_timeseries_scheme_app.command("simulation")
def data_scheme_simulation( def data_scheme_simulation(
ctx: typer.Context, ctx: typer.Context,
query: Annotated[str, typer.Option("--query", help="by-id-time|by-scheme-time-property")], query: Annotated[SimulationQuery, typer.Option("--query", help="查询模式,仅支持 by-id-time|by-scheme-time-property")],
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
scheme_type: Annotated[str | None, typer.Option("--scheme-type", help="方案类型")] = None, scheme_type: Annotated[str | None, typer.Option("--scheme-type", help="方案类型")] = None,
id: Annotated[str | None, typer.Option("--id", help="元素 ID")] = None, id: Annotated[str | None, typer.Option("--id", help="元素 ID")] = None,
time: Annotated[str, typer.Option("--time", help="查询时间")] = "", time: Annotated[str, typer.Option("--time", help="查询时间")] = "",
type: Annotated[str, typer.Option("--type", help="pipe|junction")] = "pipe", type: Annotated[ElementType, typer.Option("--type", help="元素类型,仅支持 pipe|junctionlinks/nodes 是子命令")] = ElementType.PIPE,
property: Annotated[str | None, typer.Option("--property", help="属性名")] = None, property: Annotated[str | None, typer.Option("--property", help="属性名pipe: flow|friction|headloss|quality|reaction|setting|status|velocityjunction: actual_demand|total_head|pressure|quality")] = None,
) -> None: ) -> None:
runtime = runtime_context(ctx) runtime = runtime_context(ctx)
params = { params = {
"scheme_name": resolve_scheme(runtime, scheme, required=True), "scheme_name": resolve_scheme(runtime, scheme, required=True),
"scheme_type": _scheme_type_option(scheme_type), "scheme_type": _scheme_type_option(scheme_type),
"query_time": parse_time_with_timezone(time, option_name="--time").isoformat(), "query_time": parse_time_with_timezone(time, option_name="--time").isoformat(),
"type": type, "type": type.value,
} }
if query == "by-id-time": if query == SimulationQuery.BY_ID_TIME:
if not id: if not id:
raise CLIError( raise CLIError(
"CLI 参数错误", "CLI 参数错误",
@@ -196,7 +239,7 @@ def data_scheme_simulation(
require_project=True, require_project=True,
) )
return return
if query == "by-scheme-time-property": if query == SimulationQuery.BY_SCHEME_TIME_PROPERTY:
if not property: if not property:
raise CLIError( raise CLIError(
"CLI 参数错误", "CLI 参数错误",
@@ -204,6 +247,7 @@ def data_scheme_simulation(
message="--property is required for --query by-scheme-time-property", message="--property is required for --query by-scheme-time-property",
exit_code=2, exit_code=2,
) )
property = _validate_element_property(type, property, option_name="--property")
params["property"] = property params["property"] = property
emit_api( emit_api(
ctx, ctx,
@@ -215,12 +259,7 @@ def data_scheme_simulation(
require_project=True, require_project=True,
) )
return return
raise CLIError( raise AssertionError(f"unreachable query variant: {query}")
"CLI 参数错误",
code="INVALID_QUERY",
message="--query must be by-id-time or by-scheme-time-property",
exit_code=2,
)
@data_timeseries_scada_app.command("query") @data_timeseries_scada_app.command("query")
@@ -229,7 +268,7 @@ def data_scada_query(
device_id: Annotated[list[str], typer.Option("--device-id", help="设备 ID,可重复")], device_id: Annotated[list[str], typer.Option("--device-id", help="设备 ID,可重复")],
start_time: Annotated[str, typer.Option("--start-time", help="开始时间")], start_time: Annotated[str, typer.Option("--start-time", help="开始时间")],
end_time: Annotated[str, typer.Option("--end-time", help="结束时间")], end_time: Annotated[str, typer.Option("--end-time", help="结束时间")],
field: Annotated[str | None, typer.Option("--field", help="字段名")] = None, field: Annotated[str | None, typer.Option("--field", help="字段名,仅支持 monitored_value|cleaned_value")] = None,
) -> None: ) -> None:
path = "/scada/by-ids-field-time-range" if field else "/scada/by-ids-time-range" path = "/scada/by-ids-field-time-range" if field else "/scada/by-ids-time-range"
params = { params = {
@@ -238,6 +277,7 @@ def data_scada_query(
"end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), "end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
} }
if field: if field:
field = _validate_scada_field(field, option_name="--field")
params["field"] = field params["field"] = field
emit_api( emit_api(
ctx, ctx,
@@ -253,7 +293,7 @@ def data_scada_query(
@data_timeseries_composite_app.callback(invoke_without_command=True) @data_timeseries_composite_app.callback(invoke_without_command=True)
def data_timeseries_composite( def data_timeseries_composite(
ctx: typer.Context, ctx: typer.Context,
kind: Annotated[str | None, typer.Option("--kind", help="scada-simulation|element-simulation|element-scada")] = None, 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, feature: Annotated[list[str] | None, typer.Option("--feature", help="特征值,可重复")] = None,
start_time: Annotated[str | None, typer.Option("--start-time", help="开始时间")] = None, start_time: Annotated[str | None, typer.Option("--start-time", help="开始时间")] = None,
end_time: Annotated[str | None, typer.Option("--end-time", help="结束时间")] = None, end_time: Annotated[str | None, typer.Option("--end-time", help="结束时间")] = None,
@@ -277,7 +317,7 @@ def data_timeseries_composite(
"start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), "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(), "end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
} }
if kind == "scada-simulation": if kind == CompositeKind.SCADA_SIMULATION:
if not feature: if not feature:
raise CLIError( raise CLIError(
"CLI 参数错误", "CLI 参数错误",
@@ -300,7 +340,7 @@ def data_timeseries_composite(
require_project=True, require_project=True,
) )
return return
if kind == "element-simulation": if kind == CompositeKind.ELEMENT_SIMULATION:
if not feature: if not feature:
raise CLIError( raise CLIError(
"CLI 参数错误", "CLI 参数错误",
@@ -323,7 +363,7 @@ def data_timeseries_composite(
require_project=True, require_project=True,
) )
return return
if kind == "element-scada": if kind == CompositeKind.ELEMENT_SCADA:
if not feature or len(feature) != 1: if not feature or len(feature) != 1:
raise CLIError( raise CLIError(
"CLI 参数错误", "CLI 参数错误",
@@ -343,12 +383,7 @@ def data_timeseries_composite(
require_project=True, require_project=True,
) )
return return
raise CLIError( raise AssertionError(f"unreachable composite kind: {kind}")
"CLI 参数错误",
code="INVALID_KIND",
message="--kind must be scada-simulation, element-simulation, or element-scada",
exit_code=2,
)
@data_timeseries_composite_app.command("pipeline-health") @data_timeseries_composite_app.command("pipeline-health")
@@ -376,15 +411,6 @@ def data_composite_pipeline_health(
def _scada_mapping(kind: str, action: str) -> tuple[str, dict[str, str]]: def _scada_mapping(kind: str, action: str) -> tuple[str, dict[str, str]]:
mapping = { mapping = {
("device", "schema"): ("/getscadadeviceschema/", {}),
("device", "get"): ("/getscadadevice/", {"id_param": "id"}),
("device", "list"): ("/getallscadadevices/", {}),
("device-data", "schema"): ("/getscadadevicedataschema/", {}),
("device-data", "get"): ("/getscadadevicedata/", {"id_param": "device_id"}),
("element", "schema"): ("/getscadaelementschema/", {}),
("element", "get"): ("/getscadaelement/", {"id_param": "id"}),
("element", "list"): ("/getscadaelements/", {}),
("info", "schema"): ("/getscadainfoschema/", {}),
("info", "get"): ("/getscadainfo/", {"id_param": "id"}), ("info", "get"): ("/getscadainfo/", {"id_param": "id"}),
("info", "list"): ("/getallscadainfo/", {}), ("info", "list"): ("/getallscadainfo/", {}),
} }
@@ -399,32 +425,14 @@ def _scada_mapping(kind: str, action: str) -> tuple[str, dict[str, str]]:
return result return result
@data_scada_app.command("schema")
def data_scada_schema(
ctx: typer.Context,
kind: Annotated[str, typer.Option("--kind", help="device|device-data|element|info")],
) -> None:
runtime = runtime_context(ctx)
path, _ = _scada_mapping(kind, "schema")
emit_api(
ctx,
summary="读取 SCADA schema 成功",
method="GET",
path=path,
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@data_scada_app.command("get") @data_scada_app.command("get")
def data_scada_get( def data_scada_get(
ctx: typer.Context, ctx: typer.Context,
kind: Annotated[str, typer.Option("--kind", help="device|device-data|element|info")], kind: Annotated[ScadaListKind, typer.Option("--kind", help="SCADA 类型,仅支持 info")],
id: Annotated[str, typer.Option("--id", help="记录 ID")], id: Annotated[str, typer.Option("--id", help="记录 ID")],
) -> None: ) -> None:
runtime = runtime_context(ctx) runtime = runtime_context(ctx)
path, meta = _scada_mapping(kind, "get") path, meta = _scada_mapping(kind.value, "get")
params = {"network": require_network(runtime), meta["id_param"]: id} params = {"network": require_network(runtime), meta["id_param"]: id}
emit_api( emit_api(
ctx, ctx,
@@ -440,10 +448,10 @@ def data_scada_get(
@data_scada_app.command("list") @data_scada_app.command("list")
def data_scada_list( def data_scada_list(
ctx: typer.Context, ctx: typer.Context,
kind: Annotated[str, typer.Option("--kind", help="device|element|info")], kind: Annotated[ScadaListKind, typer.Option("--kind", help="SCADA 类型,仅支持 info")],
) -> None: ) -> None:
runtime = runtime_context(ctx) runtime = runtime_context(ctx)
path, _ = _scada_mapping(kind, "list") path, _ = _scada_mapping(kind.value, "list")
emit_api( emit_api(
ctx, ctx,
summary="读取 SCADA 列表成功", summary="读取 SCADA 列表成功",
@@ -498,76 +506,3 @@ def data_scheme_list(ctx: typer.Context) -> None:
require_auth=True, require_auth=True,
require_network_ctx=True, require_network_ctx=True,
) )
@data_extension_app.command("keys")
def data_extension_keys(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取扩展数据键成功",
method="GET",
path="/getallextensiondatakeys/",
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@data_extension_app.command("get")
def data_extension_get(
ctx: typer.Context,
key: Annotated[str, typer.Option("--key", help="扩展键")],
) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取扩展数据成功",
method="GET",
path="/getextensiondata/",
params={"network": require_network(runtime), "key": key},
require_auth=True,
require_network_ctx=True,
)
@data_extension_app.command("list")
def data_extension_list(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取扩展数据列表成功",
method="GET",
path="/getallextensiondata/",
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@data_misc_app.command("sensor-placements")
def data_misc_sensor_placements(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取传感器位置成功",
method="GET",
path="/getallsensorplacements/",
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@data_misc_app.command("burst-location-results")
def data_misc_burst_location_results(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取爆管定位结果成功",
method="GET",
path="/getallburstlocateresults/",
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
+144 -33
View File
@@ -7,58 +7,45 @@ import typer
from .apps import component_option_app, network_app from .apps import component_option_app, network_app
from .common import emit_api, runtime_context from .common import emit_api, runtime_context
from .core import CLIError, require_network from .core import CLIError, require_network
from .option_types import ComponentOptionKind
@network_app.command("get-node-properties") @network_app.command("get-junction-properties")
def network_get_node_properties( def network_get_junction_properties(
ctx: typer.Context, ctx: typer.Context,
node: Annotated[str, typer.Option("--node", help="节点 ID")], junction: Annotated[str, typer.Option("--junction", help="节点 ID")],
) -> None: ) -> None:
runtime = runtime_context(ctx) runtime = runtime_context(ctx)
emit_api( emit_api(
ctx, ctx,
summary="读取节点属性成功", summary="读取节点属性成功",
method="GET", method="GET",
path="/getnodeproperties/", path="/getjunctionproperties/",
params={"network": require_network(runtime), "node": node}, params={"network": require_network(runtime), "junction": junction},
require_auth=True, require_auth=True,
require_network_ctx=True, require_network_ctx=True,
) )
@network_app.command("get-link-properties") @network_app.command("get-pipe-properties")
def network_get_link_properties( def network_get_pipe_properties(
ctx: typer.Context, ctx: typer.Context,
link: Annotated[str, typer.Option("--link", help="线 ID")], pipe: Annotated[str, typer.Option("--pipe", help=" ID")],
) -> None: ) -> None:
runtime = runtime_context(ctx) runtime = runtime_context(ctx)
emit_api( emit_api(
ctx, ctx,
summary="读取管线属性成功", summary="读取管属性成功",
method="GET", method="GET",
path="/getlinkproperties/", path="/getpipeproperties/",
params={"network": require_network(runtime), "link": link}, params={"network": require_network(runtime), "pipe": pipe},
require_auth=True, require_auth=True,
require_network_ctx=True, require_network_ctx=True,
) )
@network_app.command("get-all-junction-properties") @network_app.command("get-all-pipes-properties")
def network_get_all_junction_properties(ctx: typer.Context) -> None: def network_get_all_pipes_properties(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取全部节点属性成功",
method="GET",
path="/getalljunctionproperties/",
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@network_app.command("get-all-pipe-properties")
def network_get_all_pipe_properties(ctx: typer.Context) -> None:
runtime = runtime_context(ctx) runtime = runtime_context(ctx)
emit_api( emit_api(
ctx, ctx,
@@ -71,16 +58,140 @@ def network_get_all_pipe_properties(ctx: typer.Context) -> None:
) )
@network_app.command("get-reservoir-properties")
def network_get_reservoir_properties(
ctx: typer.Context,
reservoir: Annotated[str, typer.Option("--reservoir", help="水库 ID")],
) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取水库属性成功",
method="GET",
path="/getreservoirproperties/",
params={"network": require_network(runtime), "reservoir": reservoir},
require_auth=True,
require_network_ctx=True,
)
@network_app.command("get-all-reservoirs-properties")
def network_get_all_reservoir_properties(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取全部水库属性成功",
method="GET",
path="/getallreservoirproperties/",
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@network_app.command("get-tank-properties")
def network_get_tank_properties(
ctx: typer.Context,
tank: Annotated[str, typer.Option("--tank", help="水箱 ID")],
) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取水箱属性成功",
method="GET",
path="/gettankproperties/",
params={"network": require_network(runtime), "tank": tank},
require_auth=True,
require_network_ctx=True,
)
@network_app.command("get-all-tanks-properties")
def network_get_all_tank_properties(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取全部水箱属性成功",
method="GET",
path="/getalltankproperties/",
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@network_app.command("get-pump-properties")
def network_get_pump_properties(
ctx: typer.Context,
pump: Annotated[str, typer.Option("--pump", help="水泵 ID")],
) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取水泵属性成功",
method="GET",
path="/getpumpproperties/",
params={"network": require_network(runtime), "pump": pump},
require_auth=True,
require_network_ctx=True,
)
@network_app.command("get-all-pumps-properties")
def network_get_all_pump_properties(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取全部水泵属性成功",
method="GET",
path="/getallpumpproperties/",
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@network_app.command("get-valve-properties")
def network_get_valve_properties(
ctx: typer.Context,
valve: Annotated[str, typer.Option("--valve", help="阀门 ID")],
) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取阀门属性成功",
method="GET",
path="/getvalveproperties/",
params={"network": require_network(runtime), "valve": valve},
require_auth=True,
require_network_ctx=True,
)
@network_app.command("get-all-valves-properties")
def network_get_all_valve_properties(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取全部阀门属性成功",
method="GET",
path="/getallvalveproperties/",
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@component_option_app.command("schema") @component_option_app.command("schema")
def component_option_schema( def component_option_schema(
ctx: typer.Context, ctx: typer.Context,
kind: Annotated[str, typer.Option("--kind", help="time|energy|pump-energy|network")], kind: Annotated[ComponentOptionKind, typer.Option("--kind", help="选项类型,仅支持 time|energy|pump-energy|network")],
pump: Annotated[str | None, typer.Option("--pump", help="pump-energy 时需要的泵 ID")] = None, pump: Annotated[str | None, typer.Option("--pump", help="pump-energy 时需要的泵 ID")] = None,
) -> None: ) -> None:
runtime = runtime_context(ctx) runtime = runtime_context(ctx)
path = _component_option_path(kind, schema=True) path = _component_option_path(kind.value, schema=True)
params = {"network": require_network(runtime)} params = {"network": require_network(runtime)}
if kind == "pump-energy" and pump: if kind == ComponentOptionKind.PUMP_ENERGY and pump:
params["pump"] = pump params["pump"] = pump
emit_api( emit_api(
ctx, ctx,
@@ -96,13 +207,13 @@ def component_option_schema(
@component_option_app.command("get") @component_option_app.command("get")
def component_option_get( def component_option_get(
ctx: typer.Context, ctx: typer.Context,
kind: Annotated[str, typer.Option("--kind", help="time|energy|pump-energy|network")], kind: Annotated[ComponentOptionKind, typer.Option("--kind", help="选项类型,仅支持 time|energy|pump-energy|network")],
pump: Annotated[str | None, typer.Option("--pump", help="pump-energy 时需要的泵 ID")] = None, pump: Annotated[str | None, typer.Option("--pump", help="pump-energy 时需要的泵 ID")] = None,
) -> None: ) -> None:
runtime = runtime_context(ctx) runtime = runtime_context(ctx)
path = _component_option_path(kind, schema=False) path = _component_option_path(kind.value, schema=False)
params = {"network": require_network(runtime)} params = {"network": require_network(runtime)}
if kind == "pump-energy": if kind == ComponentOptionKind.PUMP_ENERGY:
if not pump: if not pump:
raise CLIError( raise CLIError(
"CLI 参数错误", "CLI 参数错误",
+1 -1
View File
@@ -15,7 +15,7 @@ import typer
SCHEMA_VERSION = "tjwater-cli/v1" SCHEMA_VERSION = "tjwater-cli/v1"
CLI_NAME = "tjwater-cli" CLI_NAME = "tjwater-cli"
DEFAULT_TIMEOUT = 60 DEFAULT_TIMEOUT = 180
DEFAULT_SERVER = "http://192.168.1.114:8000" DEFAULT_SERVER = "http://192.168.1.114:8000"
+2 -3
View File
@@ -100,9 +100,8 @@ def _sample_option_value(path: tuple[str, ...], option_name: str) -> str:
(("component", "option", "schema"), "kind"): "time", (("component", "option", "schema"), "kind"): "time",
(("component", "option", "get"), "kind"): "time", (("component", "option", "get"), "kind"): "time",
(("data", "timeseries", "composite"), "kind"): "scada-simulation", (("data", "timeseries", "composite"), "kind"): "scada-simulation",
(("data", "scada", "schema"), "kind"): "device", (("data", "scada", "get"), "kind"): "info",
(("data", "scada", "get"), "kind"): "device", (("data", "scada", "list"), "kind"): "info",
(("data", "scada", "list"), "kind"): "device",
} }
if (path, option_name) in path_specific_samples: if (path, option_name) in path_specific_samples:
return path_specific_samples[(path, option_name)] return path_specific_samples[(path, option_name)]
+72
View File
@@ -0,0 +1,72 @@
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}")
+83 -89
View File
@@ -16,7 +16,7 @@ GROUP_SUMMARIES: dict[tuple[str, ...], str] = {
("analysis", "burst-location", "schemes"): "爆管定位方案查询命令。", ("analysis", "burst-location", "schemes"): "爆管定位方案查询命令。",
("analysis", "risk"): "风险分析相关命令。", ("analysis", "risk"): "风险分析相关命令。",
("analysis", "sensor-placement"): "传感器选址相关命令。", ("analysis", "sensor-placement"): "传感器选址相关命令。",
("data",): "时序、SCADA、方案和扩展数据查询命令。", ("data",): "时序、SCADA 和方案数据查询命令。",
("data", "timeseries"): "时序数据查询命令。", ("data", "timeseries"): "时序数据查询命令。",
("data", "timeseries", "realtime"): "实时模拟时序查询命令。", ("data", "timeseries", "realtime"): "实时模拟时序查询命令。",
("data", "timeseries", "scheme"): "方案时序查询命令。", ("data", "timeseries", "scheme"): "方案时序查询命令。",
@@ -24,8 +24,6 @@ GROUP_SUMMARIES: dict[tuple[str, ...], str] = {
("data", "timeseries", "composite"): "复合时序查询命令。", ("data", "timeseries", "composite"): "复合时序查询命令。",
("data", "scada"): "SCADA 元数据查询命令。", ("data", "scada"): "SCADA 元数据查询命令。",
("data", "scheme"): "方案数据查询命令。", ("data", "scheme"): "方案数据查询命令。",
("data", "extension"): "扩展数据查询命令。",
("data", "misc"): "其他结果数据查询命令。",
} }
HIDDEN_PATH_PREFIXES: tuple[tuple[str, ...], ...] = ( HIDDEN_PATH_PREFIXES: tuple[tuple[str, ...], ...] = (
@@ -34,31 +32,77 @@ HIDDEN_PATH_PREFIXES: tuple[tuple[str, ...], ...] = (
) )
COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
("network", "get-node-properties"): CommandDoc( ("network", "get-junction-properties"): CommandDoc(
path=("network", "get-node-properties"), path=("network", "get-junction-properties"),
summary="读取节点属性", summary="读取节点属性",
description="调用 /getnodeproperties/。", description="调用 /getjunctionproperties/。",
options=(CommandOptionDoc("node", "节点 ID", required=True),), options=(CommandOptionDoc("junction", "节点 ID", required=True),),
examples=("tjwater-cli network get-node-properties --node J1",), examples=("tjwater-cli network get-junction-properties --junction J1",),
), ),
("network", "get-link-properties"): CommandDoc( ("network", "get-pipe-properties"): CommandDoc(
path=("network", "get-link-properties"), path=("network", "get-pipe-properties"),
summary="读取管线属性", summary="读取管属性",
description="调用 /getlinkproperties/。", description="调用 /getpipeproperties/。",
options=(CommandOptionDoc("link", "线 ID", required=True),), options=(CommandOptionDoc("pipe", " ID", required=True),),
examples=("tjwater-cli network get-link-properties --link P1",), examples=("tjwater-cli network get-pipe-properties --pipe P1",),
), ),
("network", "get-all-junction-properties"): CommandDoc( ("network", "get-all-pipes-properties"): CommandDoc(
path=("network", "get-all-junction-properties"), path=("network", "get-all-pipes-properties"),
summary="读取全部节点属性",
description="调用 /getalljunctionproperties/。",
examples=("tjwater-cli network get-all-junction-properties",),
),
("network", "get-all-pipe-properties"): CommandDoc(
path=("network", "get-all-pipe-properties"),
summary="读取全部管道属性", summary="读取全部管道属性",
description="调用 /getallpipeproperties/。", description="调用 /getallpipeproperties/。",
examples=("tjwater-cli network get-all-pipe-properties",), examples=("tjwater-cli network get-all-pipes-properties",),
),
("network", "get-reservoir-properties"): CommandDoc(
path=("network", "get-reservoir-properties"),
summary="读取水库属性",
description="调用 /getreservoirproperties/。",
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="调用 /getallreservoirproperties/。",
examples=("tjwater-cli network get-all-reservoirs-properties",),
),
("network", "get-tank-properties"): CommandDoc(
path=("network", "get-tank-properties"),
summary="读取水箱属性",
description="调用 /gettankproperties/。",
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="调用 /getalltankproperties/。",
examples=("tjwater-cli network get-all-tanks-properties",),
),
("network", "get-pump-properties"): CommandDoc(
path=("network", "get-pump-properties"),
summary="读取水泵属性",
description="调用 /getpumpproperties/。",
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="调用 /getallpumpproperties/。",
examples=("tjwater-cli network get-all-pumps-properties",),
),
("network", "get-valve-properties"): CommandDoc(
path=("network", "get-valve-properties"),
summary="读取阀门属性",
description="调用 /getvalveproperties/。",
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="调用 /getallvalveproperties/。",
examples=("tjwater-cli network get-all-valves-properties",),
), ),
("component", "option", "schema"): CommandDoc( ("component", "option", "schema"): CommandDoc(
path=("component", "option", "schema"), path=("component", "option", "schema"),
@@ -314,7 +358,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
description="调用 /realtime/query/by-id-time。", description="调用 /realtime/query/by-id-time。",
options=( options=(
CommandOptionDoc("id", "元素 ID", required=True), CommandOptionDoc("id", "元素 ID", required=True),
CommandOptionDoc("type", "元素类型:pipe 或 junction", required=True), CommandOptionDoc("type", "元素类型:pipe 或 junctionlinks/nodes 是独立子命令,不是 type 取值", required=True),
CommandOptionDoc("time", "显式带时区的查询时间", required=True), CommandOptionDoc("time", "显式带时区的查询时间", required=True),
), ),
examples=( examples=(
@@ -325,11 +369,11 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
("data", "timeseries", "realtime", "simulation-by-time-property"): CommandDoc( ("data", "timeseries", "realtime", "simulation-by-time-property"): CommandDoc(
path=("data", "timeseries", "realtime", "simulation-by-time-property"), path=("data", "timeseries", "realtime", "simulation-by-time-property"),
summary="按时间和属性查询实时模拟结果", summary="按时间和属性查询实时模拟结果",
description="调用 /realtime/query/by-time-property。", description="调用 /realtime/query/by-time-property。pipe 属性:flow、friction、headloss、quality、reaction、setting、status、velocityjunction 属性:actual_demand、total_head、pressure、quality。",
options=( options=(
CommandOptionDoc("type", "元素类型:pipe 或 junction", required=True), CommandOptionDoc("type", "元素类型:pipe 或 junctionlinks/nodes 是独立子命令,不是 type 取值", required=True),
CommandOptionDoc("time", "显式带时区的查询时间", required=True), CommandOptionDoc("time", "显式带时区的查询时间", required=True),
CommandOptionDoc("property", "属性名", 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",), examples=("tjwater-cli data timeseries realtime simulation-by-time-property --type pipe --time 2025-01-02T03:30:00+08:00 --property flow",),
), ),
@@ -348,10 +392,10 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
("data", "timeseries", "scheme", "node-field"): CommandDoc( ("data", "timeseries", "scheme", "node-field"): CommandDoc(
path=("data", "timeseries", "scheme", "node-field"), path=("data", "timeseries", "scheme", "node-field"),
summary="查询方案节点字段时序", summary="查询方案节点字段时序",
description="调用 /scheme/nodes/{node_id}/field。", description="调用 /scheme/nodes/{node_id}/field。field 仅支持 actual_demand、total_head、pressure、quality。",
options=( options=(
CommandOptionDoc("node", "节点 ID", required=True), CommandOptionDoc("node", "节点 ID", required=True),
CommandOptionDoc("field", "字段名", required=True), CommandOptionDoc("field", "字段名actual_demand、total_head、pressure、quality", required=True),
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True), CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
CommandOptionDoc("scheme", "方案名称"), CommandOptionDoc("scheme", "方案名称"),
@@ -362,15 +406,15 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
("data", "timeseries", "scheme", "simulation"): CommandDoc( ("data", "timeseries", "scheme", "simulation"): CommandDoc(
path=("data", "timeseries", "scheme", "simulation"), path=("data", "timeseries", "scheme", "simulation"),
summary="查询方案模拟数据", summary="查询方案模拟数据",
description="支持 by-id-time 与 by-scheme-time-property 两种查询。", description="支持 by-id-time 与 by-scheme-time-property 两种查询。pipe 属性:flow、friction、headloss、quality、reaction、setting、status、velocityjunction 属性:actual_demand、total_head、pressure、quality。",
options=( options=(
CommandOptionDoc("query", "查询模式:by-id-time 或 by-scheme-time-property", required=True), CommandOptionDoc("query", "查询模式:by-id-time 或 by-scheme-time-property", required=True),
CommandOptionDoc("scheme", "方案名称"), CommandOptionDoc("scheme", "方案名称"),
CommandOptionDoc("scheme-type", "方案类型"), CommandOptionDoc("scheme-type", "方案类型"),
CommandOptionDoc("id", "元素 IDby-id-time 时必需)"), CommandOptionDoc("id", "元素 IDby-id-time 时必需)"),
CommandOptionDoc("time", "显式带时区的查询时间", required=True), CommandOptionDoc("time", "显式带时区的查询时间", required=True),
CommandOptionDoc("type", "元素类型:pipe 或 junction"), CommandOptionDoc("type", "元素类型:pipe 或 junctionlinks/nodes 是独立子命令,不是 type 取值"),
CommandOptionDoc("property", "属性名(by-scheme-time-property 时必需)"), CommandOptionDoc("property", "属性名(by-scheme-time-property 时必需;会按 type 校验可选值"),
), ),
examples=( 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-id-time --id J1 --time 2025-01-02T03:30:00+08:00 --type junction --scheme my_scheme",
@@ -380,16 +424,16 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
("data", "timeseries", "scada", "query"): CommandDoc( ("data", "timeseries", "scada", "query"): CommandDoc(
path=("data", "timeseries", "scada", "query"), path=("data", "timeseries", "scada", "query"),
summary="查询 SCADA 时序", summary="查询 SCADA 时序",
description="device-id 会被转换成后端逗号分隔参数。", description="device-id 会被转换成后端逗号分隔参数。field 仅支持 monitored_value、cleaned_value。",
options=( options=(
CommandOptionDoc("device-id", "设备 ID(可多次指定)", required=True, repeated=True), CommandOptionDoc("device-id", "设备 ID(可多次指定)", required=True, repeated=True),
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True), CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
CommandOptionDoc("field", "字段名"), CommandOptionDoc("field", "字段名monitored_value、cleaned_value"),
), ),
examples=( 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 --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 flow", "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( ("data", "timeseries", "composite"): CommandDoc(
@@ -422,41 +466,22 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
), ),
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",), 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", "schema"): CommandDoc(
path=("data", "scada", "schema"),
summary="读取 SCADA schema",
description="kind 支持 device、device-data、element、info。",
options=(CommandOptionDoc("kind", "SCADA 数据类型", required=True),),
examples=(
"tjwater-cli data scada schema --kind device",
"tjwater-cli data scada schema --kind device-data",
"tjwater-cli data scada schema --kind element",
"tjwater-cli data scada schema --kind info",
),
),
("data", "scada", "get"): CommandDoc( ("data", "scada", "get"): CommandDoc(
path=("data", "scada", "get"), path=("data", "scada", "get"),
summary="读取单条 SCADA 元数据", summary="读取单条 SCADA 元数据",
description="kind 支持 device、device-data、element、info。", description="kind 支持 info。",
options=( options=(
CommandOptionDoc("kind", "SCADA 数据类型", required=True), CommandOptionDoc("kind", "SCADA 数据类型", required=True),
CommandOptionDoc("id", "记录 ID", required=True), CommandOptionDoc("id", "记录 ID", required=True),
), ),
examples=( examples=("tjwater-cli data scada get --kind info --id SCADA-001",),
"tjwater-cli data scada get --kind device --id D1",
"tjwater-cli data scada get --kind element --id E1",
),
), ),
("data", "scada", "list"): CommandDoc( ("data", "scada", "list"): CommandDoc(
path=("data", "scada", "list"), path=("data", "scada", "list"),
summary="列出 SCADA 元数据", summary="列出 SCADA 元数据",
description="kind 支持 device、element、infodevice-data 当前后端无 list 接口", description="kind 支持 info",
options=(CommandOptionDoc("kind", "SCADA 数据类型", required=True),), options=(CommandOptionDoc("kind", "SCADA 数据类型", required=True),),
examples=( examples=("tjwater-cli data scada list --kind info",),
"tjwater-cli data scada list --kind device",
"tjwater-cli data scada list --kind element",
"tjwater-cli data scada list --kind info",
),
), ),
("data", "scheme", "schema"): CommandDoc( ("data", "scheme", "schema"): CommandDoc(
path=("data", "scheme", "schema"), path=("data", "scheme", "schema"),
@@ -477,37 +502,6 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
description="调用 /getallschemes/。", description="调用 /getallschemes/。",
examples=("tjwater-cli data scheme list",), examples=("tjwater-cli data scheme list",),
), ),
("data", "extension", "keys"): CommandDoc(
path=("data", "extension", "keys"),
summary="列出扩展数据键",
description="调用 /getallextensiondatakeys/。",
examples=("tjwater-cli data extension keys",),
),
("data", "extension", "get"): CommandDoc(
path=("data", "extension", "get"),
summary="读取扩展数据",
description="调用 /getextensiondata/。",
options=(CommandOptionDoc("key", "扩展键", required=True),),
examples=("tjwater-cli data extension get --key my_key",),
),
("data", "extension", "list"): CommandDoc(
path=("data", "extension", "list"),
summary="列出扩展数据",
description="调用 /getallextensiondata/。",
examples=("tjwater-cli data extension list",),
),
("data", "misc", "sensor-placements"): CommandDoc(
path=("data", "misc", "sensor-placements"),
summary="列出传感器布置结果",
description="调用 /getallsensorplacements/。",
examples=("tjwater-cli data misc sensor-placements",),
),
("data", "misc", "burst-location-results"): CommandDoc(
path=("data", "misc", "burst-location-results"),
summary="列出爆管定位结果",
description="调用 /getallburstlocateresults/。",
examples=("tjwater-cli data misc burst-location-results",),
),
} }
+1 -5
View File
@@ -259,12 +259,8 @@ app/api/v1/endpoints/project_data.py
| `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 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 --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 timeseries composite pipeline-health --pipe PIPE --start-time TIME --end-time TIME` | `GET /composite/pipeline-health-prediction` | 管道健康预测 |
| `tjwater-cli data scada schema --kind device\|device-data\|element\|info` | `GET /getscada*schema/` | `SCADA` 元数据 `schema` | | `tjwater-cli data scada get\|list --kind info` | `GET /getscadainfo/``GET /getallscadainfo/` | `SCADA info` 元数据 |
| `tjwater-cli data scada get\|list --kind device\|device-data\|element\|info` | `scada.py``GET` 查询接口 | `SCADA` 元数据 |
| `tjwater-cli data scheme schema\|get\|list` | `schemes.py``GET` 接口 | 当前 project 方案查询 | | `tjwater-cli data scheme schema\|get\|list` | `schemes.py``GET` 接口 | 当前 project 方案查询 |
| `tjwater-cli data extension keys\|get\|list` | `extension.py``GET` 查询接口 | 当前 project 扩展数据查询 |
| `tjwater-cli data misc sensor-placements` | `GET /getallsensorplacements/` | 当前 project 传感器位置 |
| `tjwater-cli data misc burst-location-results` | `GET /getallburstlocateresults/` | 当前 project 爆管定位结果 |
- `realtime` 是首批 simulation 结果的主读取域;CLI 可以按任务语义组合 `links``nodes``simulation-by-id-time``simulation-by-time-property`,但底层数据源仍以 `realtime.py` 为准。 - `realtime` 是首批 simulation 结果的主读取域;CLI 可以按任务语义组合 `links``nodes``simulation-by-id-time``simulation-by-time-property`,但底层数据源仍以 `realtime.py` 为准。
- `realtime``scheme``composite` 等时间查询命令面向用户时仍按 **UTC+8** 输入;CLI/服务端负责转换为后端使用的 **UTC0** 条件进行检索。若返回结果直接包含时间戳,必须显式带时区,避免把存储时间和展示时间混淆。 - `realtime``scheme``composite` 等时间查询命令面向用户时仍按 **UTC+8** 输入;CLI/服务端负责转换为后端使用的 **UTC0** 条件进行检索。若返回结果直接包含时间戳,必须显式带时区,避免把存储时间和展示时间混淆。
+2 -1
View File
@@ -3,9 +3,10 @@ services:
# Core API Service # Core API Service
# ========================================== # ==========================================
api: api:
image: ${TJWATER_SERVER_IMAGE:-tjwater-server:local}
build: build:
context: ../.. context: ../..
dockerfile: infra/docker/Dockerfile dockerfile: Dockerfile
container_name: tjwater_api container_name: tjwater_api
restart: always restart: always
ports: ports:
+1
View File
@@ -168,3 +168,4 @@ zmq==0.0.0
pymoo==0.6.1.6 pymoo==0.6.1.6
scikit-learn==1.6.1 scikit-learn==1.6.1
scipy==1.15.2 scipy==1.15.2
pyclipper==1.4.0
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
echo "Usage: bash scripts/trigger-gitea-pipeline.sh [remote] [tag]"
echo ""
echo "Examples:"
echo " bash scripts/trigger-gitea-pipeline.sh"
echo " bash scripts/trigger-gitea-pipeline.sh origin latest"
echo " bash scripts/trigger-gitea-pipeline.sh gitea latest"
echo " bash scripts/trigger-gitea-pipeline.sh origin v2026.06.09.1"
exit 0
fi
resolve_default_remote() {
if git remote get-url gitea >/dev/null 2>&1; then
echo "gitea"
return 0
fi
if git remote get-url origin >/dev/null 2>&1; then
echo "origin"
return 0
fi
return 1
}
REMOTE="${1:-}"
TAG="${2:-latest}"
if ! git rev-parse --git-dir >/dev/null 2>&1; then
echo "[ERROR] Current directory is not a git repository."
exit 1
fi
if [[ -z "$REMOTE" ]]; then
if ! REMOTE="$(resolve_default_remote)"; then
echo "[ERROR] No default remote found. Expected 'gitea' or 'origin'."
echo "Available remotes:"
git remote -v || true
exit 1
fi
fi
if ! git remote get-url "$REMOTE" >/dev/null 2>&1; then
echo "[ERROR] Remote '$REMOTE' does not exist."
echo "Available remotes:"
git remote -v
exit 1
fi
HEAD_SHA="$(git rev-parse --short HEAD)"
MESSAGE="manual trigger: ${TAG} $(date '+%F %T')"
echo "[INFO] HEAD: ${HEAD_SHA}"
echo "[INFO] Recreate annotated tag '${TAG}'"
git tag -fa "$TAG" -m "$MESSAGE"
echo "[INFO] Push '${TAG}' to remote '${REMOTE}' (force update)"
git push "$REMOTE" "refs/tags/${TAG}" --force
echo "[INFO] Verify remote tag reference"
git ls-remote --tags "$REMOTE" "refs/tags/${TAG}"
echo "[DONE] Pipeline trigger request sent by updating tag '${TAG}'."
+140
View File
@@ -0,0 +1,140 @@
import asyncio
import importlib.util
import json
from pathlib import Path
import httpx
import pytest
def _load_geocoding_module():
module_path = Path(__file__).resolve().parents[2] / "app" / "services" / "geocoding.py"
spec = importlib.util.spec_from_file_location("tests_geocoding_under_test", module_path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
geocoding = _load_geocoding_module()
class FakeClient:
def __init__(self, response):
self.response = response
self.calls = []
async def get(self, url, *, params):
self.calls.append({"url": url, "params": params})
return self.response
def test_geocode_tianditu_gets_expected_params(monkeypatch):
monkeypatch.setattr(geocoding.settings, "TIANDITU_GEOCODER_TOKEN", "tk-test")
monkeypatch.setattr(
geocoding.settings,
"TIANDITU_GEOCODER_URL",
"https://api.tianditu.gov.cn/geocoder",
)
response = httpx.Response(
200,
json={
"location": {"lon": "116.407526", "lat": "39.904030", "level": "地名地址"},
"status": "0",
"msg": "ok",
},
request=httpx.Request("GET", "https://api.tianditu.gov.cn/geocoder"),
)
client = FakeClient(response)
result = asyncio.run(
geocoding.geocode_tianditu(
geocoding.TiandituGeocodeRequest(keyword="北京市人民政府"),
client=client,
)
)
assert result["location"] == {
"lon": "116.407526",
"lat": "39.904030",
"level": "地名地址",
}
assert client.calls == [
{
"url": "https://api.tianditu.gov.cn/geocoder",
"params": {
"ds": json.dumps({"keyWord": "北京市人民政府"}, ensure_ascii=False),
"tk": "tk-test",
},
}
]
def test_geocode_tianditu_accepts_key_word_alias(monkeypatch):
monkeypatch.setattr(geocoding.settings, "TIANDITU_GEOCODER_TOKEN", "tk-test")
response = httpx.Response(
200,
json={"location": {"lon": "116", "lat": "39"}, "status": "0", "msg": "ok"},
request=httpx.Request("GET", "https://api.tianditu.gov.cn/geocoder"),
)
result = asyncio.run(
geocoding.geocode_tianditu(
geocoding.TiandituGeocodeRequest(keyWord="北京市人民政府"),
client=FakeClient(response),
)
)
assert result["status"] == "0"
def test_geocode_tianditu_requires_token(monkeypatch):
monkeypatch.setattr(geocoding.settings, "TIANDITU_GEOCODER_TOKEN", "")
with pytest.raises(geocoding.TiandituGeocodingConfigError):
asyncio.run(
geocoding.geocode_tianditu(
geocoding.TiandituGeocodeRequest(keyword="北京市人民政府"),
client=FakeClient(httpx.Response(200, json={})),
)
)
def test_geocode_tianditu_surfaces_http_error(monkeypatch):
monkeypatch.setattr(geocoding.settings, "TIANDITU_GEOCODER_TOKEN", "tk-test")
response = httpx.Response(
403,
json={"msg": "invalid tk"},
request=httpx.Request("GET", "https://api.tianditu.gov.cn/geocoder"),
)
with pytest.raises(geocoding.TiandituGeocodingAPIError) as exc_info:
asyncio.run(
geocoding.geocode_tianditu(
geocoding.TiandituGeocodeRequest(keyword="北京市人民政府"),
client=FakeClient(response),
)
)
assert exc_info.value.status_code == 403
assert exc_info.value.detail == {"msg": "invalid tk"}
def test_geocode_tianditu_surfaces_tianditu_error_status(monkeypatch):
monkeypatch.setattr(geocoding.settings, "TIANDITU_GEOCODER_TOKEN", "tk-test")
response = httpx.Response(
200,
json={"status": "100", "msg": "bad request"},
request=httpx.Request("GET", "https://api.tianditu.gov.cn/geocoder"),
)
with pytest.raises(geocoding.TiandituGeocodingAPIError) as exc_info:
asyncio.run(
geocoding.geocode_tianditu(
geocoding.TiandituGeocodeRequest(keyword="北京市人民政府"),
client=FakeClient(response),
)
)
assert exc_info.value.status_code == 502
assert exc_info.value.detail == {"status": "100", "msg": "bad request"}
+115
View File
@@ -0,0 +1,115 @@
import asyncio
import importlib.util
from pathlib import Path
import httpx
import pytest
def _load_web_search_module():
module_path = (
Path(__file__).resolve().parents[2] / "app" / "services" / "web_search.py"
)
spec = importlib.util.spec_from_file_location("tests_web_search_under_test", module_path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
web_search = _load_web_search_module()
class FakeClient:
def __init__(self, response):
self.response = response
self.calls = []
async def post(self, url, *, headers, json):
self.calls.append({"url": url, "headers": headers, "json": json})
return self.response
def test_search_bocha_web_posts_expected_payload(monkeypatch):
monkeypatch.setattr(web_search.settings, "BOCHA_API_KEY", "sk-test")
monkeypatch.setattr(
web_search.settings,
"BOCHA_WEB_SEARCH_URL",
"https://api.bochaai.com/v1/web-search",
)
response = httpx.Response(
200,
json={"data": {"webPages": {"value": []}}},
request=httpx.Request("POST", "https://api.bochaai.com/v1/web-search"),
)
client = FakeClient(response)
result = asyncio.run(
web_search.search_bocha_web(
web_search.WebSearchRequest(
query="天津水务",
freshness="oneWeek",
summary=True,
count=5,
include=["example.com", "news.example.com"],
exclude=["spam.example.com"],
),
client=client,
)
)
assert result == {"data": {"webPages": {"value": []}}}
assert client.calls == [
{
"url": "https://api.bochaai.com/v1/web-search",
"headers": {
"Authorization": "Bearer sk-test",
"Content-Type": "application/json",
},
"json": {
"query": "天津水务",
"freshness": "oneWeek",
"summary": True,
"count": 5,
"include": "example.com,news.example.com",
"exclude": "spam.example.com",
},
}
]
def test_search_bocha_web_requires_api_key(monkeypatch):
monkeypatch.setattr(web_search.settings, "BOCHA_API_KEY", "")
with pytest.raises(web_search.BochaSearchConfigError):
asyncio.run(
web_search.search_bocha_web(
web_search.WebSearchRequest(query="天津水务"),
client=FakeClient(httpx.Response(200, json={})),
)
)
def test_search_bocha_web_surfaces_upstream_error(monkeypatch):
monkeypatch.setattr(web_search.settings, "BOCHA_API_KEY", "sk-test")
response = httpx.Response(
401,
json={"error": "invalid api key"},
request=httpx.Request("POST", "https://api.bochaai.com/v1/web-search"),
)
with pytest.raises(web_search.BochaSearchAPIError) as exc_info:
asyncio.run(
web_search.search_bocha_web(
web_search.WebSearchRequest(query="天津水务"),
client=FakeClient(response),
)
)
assert exc_info.value.status_code == 401
assert exc_info.value.detail == {"error": "invalid api key"}
def test_web_search_request_validates_count_range():
with pytest.raises(ValueError):
web_search.WebSearchRequest(query="天津水务", count=51)