31 Commits
Author SHA1 Message Date
jiang 1d061f6099 feat: add project-scoped operations
Generic Container CI/CD / test-build-publish (push) Failing after 6s
Server CI/CD v2 / build-test-publish-and-deploy (push) Failing after 6s
2026-10-22 18:40:20 +08:00
jiang d320972d57 fix(docker): pin GeoServer platform manifest 2026-10-22 13:03:26 +08:00
jiang b4f3204489 chore(docker): advance PostGIS 14 patch line 2026-10-22 12:30:30 +08:00
jiang 1e71f8953b test(server): align service access delegation expectation 2026-10-22 12:18:12 +08:00
jiang f3818b3fed fix(docker): include runtime samples in image 2026-10-22 12:14:40 +08:00
jiang 024abf07a8 chore(docker): pin secure runtime and resolve MCP dependencies 2026-10-22 12:08:36 +08:00
jiang be6317eb48 feat(server): integrate MCP queries and investigation messaging
Add project-bound OAuth MCP queries and controlled analysis with scoped evidence,
limits, timeouts and a nearby-model spatial query using EPSG:4326 inputs.
Default nearby searches to 200 meters and five results.

Integrate investigation messaging/presentation, health/readiness and typed history;
remove duplicate web search and fix online simulation and compressed baseline writes.
Include project migrations 023-025; keep local credentials and demo seeding excluded.

Validation: 319 focused pytest checks; real PostGIS temporary-table regression;
read-only Jinqian bridge radius comparison; cross-language MCP discovery/calls.
2026-10-19 18:46:58 +08:00
jiang 2b6fc5aa52 feat(tasks): define investigation report contract and history 2026-10-10 16:02:46 +08:00
jiang 4a63fcd7ff fix(workbench): serialize events and target work order sync 2026-10-08 15:41:49 +08:00
jiang b70283c2ff feat(work-orders): synchronize peripheral changes through events 2026-10-08 11:50:15 +08:00
jiang afcbd52a5a refactor(tasks): centralize access request lookup 2026-09-24 17:11:21 +08:00
jiang 540d73add8 docs(workbench): clarify active notification counting 2026-09-24 17:00:41 +08:00
jiang ad813a7c16 feat(workbench): add dynamic task approval lifecycle 2026-09-24 16:36:12 +08:00
jiang db092b0f5e feat(workbench): keep only actionable notifications 2026-09-24 15:18:18 +08:00
jiang e374b5b3cb fix(workbench): remove synthetic check projections 2026-09-24 12:01:01 +08:00
jiang 1a6ff64bbe feat(db): add workbench schema comments 2026-09-24 10:33:12 +08:00
jiang 91afe76c6d docs(workbench): gate database rollout by project 2026-09-24 10:13:52 +08:00
jiang ed06bc2bfc fix(workbench): configure psycopg snapshot transaction
Open the psycopg transaction without unsupported SQLAlchemy-style options, then set the PostgreSQL isolation and read-only characteristics before the first query. Cover the exact snapshot endpoint path with a regression test.
2026-09-24 09:49:01 +08:00
jiang 9335bdc1ea feat(workbench): persist operational workflows and events 2026-09-24 04:19:02 +08:00
jiang baff7dfe9e chore(api): refresh workbench contracts 2026-09-22 10:44:43 +08:00
jiang 7ab9b36a5e feat(workbench): add runtime GIS and sample integrations 2026-09-22 10:44:31 +08:00
jiang a3b4e75317 fix(api): return real total for user pagination 2026-09-15 10:55:29 +08:00
jiang 682c26fddd feat(timeseries): unify element history queries 2026-09-14 12:30:41 +08:00
jiang 0685f6dd17 fix(sensor): use published coordinates for custom SRIDs
Result validation bypassed the project-specific GIS transform and called ST_Transform on custom engineering SRIDs. Read project and map coordinates from gis.junctions so every project uses its configured publication transform.
2026-09-11 11:29:51 +08:00
jiang 90b02057bc feat(projects): automate project infrastructure provisioning 2026-09-11 10:57:51 +08:00
jiang 10a7a66a41 fix(metadata): hide inactive projects from user list
Project selection previously relied only on membership, so inactive projects remained visible. Filter at the repository boundary and add regression coverage.
2026-09-10 14:49:53 +08:00
jiang bd857ea5e1 fix(ci): test backend in container environment 2026-09-08 18:26:20 +08:00
jiang 8942541759 feat(scada): serve project devices through pooled API 2026-09-08 18:18:30 +08:00
jiang 5966d039de refactor(backend)!: separate algorithm and data layers
Reorganize algorithm packages by business responsibility, move orchestration into services, and keep database access behind pooled repositories.

Harden analysis API validation, remove unsafe legacy simulation endpoints, and add regression and architecture boundary coverage.

BREAKING CHANGE: legacy algorithm module paths and obsolete simulation endpoints are removed.
2026-09-04 17:30:55 +08:00
jiang 9b095c7439 refactor(db)!: clean up business SQL access
- make realtime replacement and analysis result writes transactional\n- consolidate SCADA repositories and remove process-global project state\n- validate SCADA batches and use indexed GIS-backed business queries\n\nBREAKING CHANGE: remove the public analysis result writer and the pipeline-health network_name query parameter.
2026-08-28 11:37:36 +08:00
jiang b74799a39d refactor(db)!: finalize pooled WNDB v2 migration 2026-08-27 17:26:22 +08:00
421 changed files with 60826 additions and 11286 deletions
+1 -1
View File
@@ -19,4 +19,4 @@ logs/
coverage/
*.pyc
*.dump
app/algorithms/health/model/my_survival_forest_model_quxi.joblib
app/algorithms/pipe_health_prediction/model/my_survival_forest_model_quxi.joblib
+107 -44
View File
@@ -1,72 +1,135 @@
# TJWater Server 环境变量配置模板
# 复制此文件为 .env 并填写实际值
# CI/CD: 生产环境变量由 Dev 主机的受控 backend.env 注入,不要将完整 .env 保存为 Gitea 仓库密钥。
ENVIRONMENT="production"
NETWORK_NAME="tjwater"
# ============================================
# 敏感配置加密 (必填)
# ============================================
# TJWater Server 环境变量模板
# 复制为 .env 后按启用的功能填写;不要提交 .env 或任何真实凭据。
# 生产配置由部署主机的受控 backend.env 注入,不要把完整配置保存为仓库密钥。
# Fernet 格式,生产环境必须替换为独立密钥
# 生成方式: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# 用于项目数据库 DSN、GeoServer 管理密码等敏感配置
# 基础运行
PROJECT_NAME="TJWater Server"
ENVIRONMENT="production"
API_V1_STR="/api/v1"
NETWORK_NAME="tjwater"
# Fernet 格式;用于加密项目数据库 DSN、GeoServer 管理密码等敏感配置。
# 生成方式:python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
DATABASE_ENCRYPTION_KEY="replace-with-generated-fernet-key"
# ============================================
# 数据库配置 (PostgreSQL)
# ============================================
# 业务数据库(PostgreSQL)
DB_NAME="tjwater"
DB_HOST="localhost"
DB_PORT="5432"
DB_USER="tjwater"
DB_PASSWORD="password"
# ============================================
# 数据库配置 (TimescaleDB)
# ============================================
# 时序数据库(TimescaleDB)
TIMESCALEDB_DB_NAME="tjwater"
TIMESCALEDB_DB_HOST="localhost"
TIMESCALEDB_DB_PORT="5433"
TIMESCALEDB_DB_USER="tjwater"
TIMESCALEDB_DB_PASSWORD="password"
# 实时快照替换事务的解压预算;仅事务内生效,禁止设为 0(无限制)。
TIMESCALEDB_REALTIME_MAX_DECOMPRESSED_TUPLES=1000000
# ============================================
# 元数据数据库配置 (Metadata DB)
# ============================================
# 元数据数据库
METADATA_DB_NAME="system_hub"
METADATA_DB_HOST="localhost"
METADATA_DB_PORT="5432"
METADATA_DB_USER="tjwater"
METADATA_DB_PASSWORD="password"
METADATA_DB_POOL_SIZE=5
METADATA_DB_MAX_OVERFLOW=10
# Per-project synchronous connection pools
PROJECT_PG_CACHE_SIZE="50"
PROJECT_TS_CACHE_SIZE="50"
PROJECT_PG_POOL_MIN_SIZE="0"
PROJECT_PG_POOL_SIZE="5"
PROJECT_PG_MAX_OVERFLOW="10"
PROJECT_TS_POOL_MIN_SIZE="0"
PROJECT_TS_POOL_MAX_SIZE="10"
# 项目数据库连接池、缓存与模板
PROJECT_PG_CACHE_SIZE=16
PROJECT_TS_CACHE_SIZE=16
PROJECT_PG_POOL_MIN_SIZE=0
PROJECT_PG_POOL_SIZE=4
PROJECT_PG_MAX_OVERFLOW=2
PROJECT_TS_POOL_MIN_SIZE=0
PROJECT_TS_POOL_MAX_SIZE=4
WNDB_SCHEMA_TEMPLATE_DB_NAME="tjwater_v2_schema_template"
TIMESCALEDB_SCHEMA_TEMPLATE_DB_NAME="tjwater_v2_timescale_template"
WNDB_TEMP_DB_MAX_COUNT=8
# ============================================
# Keycloak JWT (可选)
# ============================================
KEYCLOAK_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
KEYCLOAK_ALGORITHM=RS256
# GeoServer 项目供应
GEOSERVER_URL="http://localhost:8080/geoserver"
GEOSERVER_USERNAME="admin"
GEOSERVER_PASSWORD="password"
# 留空时分别复用 DB_HOST、DB_PORT、DB_USER、DB_PASSWORD。
# GeoServer 在容器内运行时,GEOSERVER_DB_HOST 通常应填写数据库服务名。
GEOSERVER_DB_HOST=""
GEOSERVER_DB_PORT=""
GEOSERVER_DB_USER=""
GEOSERVER_DB_PASSWORD=""
# 客户端缓存时间,单位为秒;默认 30 天。
GEOSERVER_CLIENT_CACHE_SECONDS=2592000
# Keycloak JWT 校验
# 未启用认证时可留空;生产环境应注入完整公钥和预期 audience。
KEYCLOAK_PUBLIC_KEY=""
KEYCLOAK_ALGORITHM="RS256"
KEYCLOAK_AUDIENCE="account"
KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS=900
# 工作台事件与跨域
# 认证接口必须使用明确来源,逗号分隔;不要配置为 *。
CORS_ALLOWED_ORIGINS="http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:15173,http://localhost:15173"
WORKBENCH_EVENTS_ENABLED=true
WORKBENCH_EVENT_RETENTION_DAYS=7
# ============================================
# 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_TOKEN=""
TIANDITU_GEOCODER_URL="https://api.tianditu.gov.cn/geocoder"
TIANDITU_GEOCODER_TIMEOUT_SECONDS=30
TIANDITU_SEARCH_URL="https://api.tianditu.gov.cn/v2/search"
TIANDITU_SEARCH_TIMEOUT_SECONDS=15
# 调查任务 worker(独立进程)
# 单项目填写 PROJECT_ID;多项目填写逗号分隔的 PROJECT_IDS,后者优先。
# worker 与外围工单同步器共用项目范围,服务身份必须拥有每个项目的权限。
OPERATIONAL_WORKER_PROJECT_ID=""
OPERATIONAL_WORKER_PROJECT_IDS=""
OPERATIONAL_WORKER_SERVER_URL="http://127.0.0.1:8000"
OPERATIONAL_WORKER_AGENT_URL="http://127.0.0.1:8787"
OPERATIONAL_WORKER_RECONCILE_SECONDS=30
OPERATIONAL_WORKER_MAX_CONCURRENCY=4
# 可选 ISO 8601 时间下界,必须包含时区,例如 2026-10-01T00:00:00+08:00。
OPERATIONAL_WORKER_TASK_CREATED_FROM=""
# 长期运行使用 OAuth 客户端凭据;固定 Bearer 仅用于受控开发或短期运行。
OPERATIONAL_WORKER_TOKEN_URL=""
# 仅 ENVIRONMENT=development 时覆盖 TOKEN_URL,其他环境忽略。
OPERATIONAL_WORKER_DEV_TOKEN_URL=""
OPERATIONAL_WORKER_CLIENT_ID=""
OPERATIONAL_WORKER_CLIENT_SECRET=""
OPERATIONAL_WORKER_TOKEN_REFRESH_SECONDS=300
OPERATIONAL_WORKER_BEARER=""
# 调查数据适配器
OPERATIONAL_DATA_SERVER_URL="http://127.0.0.1:8000"
OPERATIONAL_DATA_PERIPHERAL_URL=""
OPERATIONAL_DATA_TIMEOUT_SECONDS=15
# 仅供 Server 在已批准的兼容委托路径中使用,不传给 Agent。
OPERATIONAL_DATA_DELEGATION_BEARER=""
# 调查动作适配器
OPERATIONAL_ACTION_PERIPHERAL_URL=""
OPERATIONAL_ACTION_COMMAND_GATEWAY_URL=""
OPERATIONAL_ACTION_TIMEOUT_SECONDS=10
# MCP 查询服务;OAuth 和所有项目业务库就绪前保持关闭。
TJWATER_MCP_ENABLED=false
TJWATER_MCP_ISSUER_URI=""
# 仅 ENVIRONMENT=development:可与 ISSUER_URI 同值并指向内网 HTTP IPv4 issuer。
# 正式环境必须留空;该例外不允许远程 HTTP MCP resource。
TJWATER_MCP_DEV_HTTP_ISSUER_URI=""
TJWATER_MCP_RESOURCE_URI=""
TJWATER_MCP_AUDIENCE="tjwater-mcp"
TJWATER_MCP_HOST_CLIENT_ID="tjwater-agent-mcp"
# 列表使用 JSON 格式。
TJWATER_MCP_ALLOWED_HOSTS='["127.0.0.1:*","localhost:*"]'
TJWATER_MCP_ALLOWED_ORIGINS='[]'
TJWATER_MCP_CONTEXT_TTL_SECONDS=600
# 普通查询预算;计算工具由 Agent 通过 timeout_seconds 按次指定。
TJWATER_MCP_QUERY_TIMEOUT_SECONDS=30
# 计算等待上限为 120–86400 秒,实际上限通过 tools/list 发布。
TJWATER_MCP_MAX_CALCULATION_TIMEOUT_SECONDS=3600
+3 -7
View File
@@ -10,15 +10,11 @@ jobs:
build-test-publish-and-deploy:
uses: OrgTJWater/ci-templates/.gitea/workflows/container-cd.yml@68c8a9855391baa31d31523674f5812cd24ec604
with:
image_name: gitea.waternetwork.cn/orgtjwater/tjwater-backend
image_name: gitea.waternetwork.cn/orgtjwater/next-tjwater-backend
dockerfile: Dockerfile
build_context: .
test_command: |
python -m compileall -q app
python -c "import app.main"
python scripts/check_openapi.py
pytest -q tests
deploy_service: backend
test_target: test
deploy_service: next-backend
deploy_host: 192.168.1.114
secrets:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
+1 -1
View File
@@ -6,5 +6,5 @@ build/
.env
*.dump
.vscode/
app/algorithms/health/model/my_survival_forest_model_quxi.joblib
app/algorithms/pipe_health_prediction/model/my_survival_forest_model_quxi.joblib
/inp/
+20 -7
View File
@@ -1,13 +1,16 @@
FROM condaforge/miniforge3:latest
FROM condaforge/miniforge3:26.7.2-0@sha256:569afc65b5338d35e276adf9a860025cd37a677659d19dcb71fc1d4c2063a94e AS base
WORKDIR /app
ENV CONDA_ENV_PATH=/opt/server \
PATH="/opt/server/bin:${PATH}"
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 避免编译问题)
RUN mamba install -y python=3.12 pymetis && \
# 在独立环境中安装 Python 3.12 和 pymetis,避免修改 Miniforge 基础环境。
RUN mamba create -y -p "$CONDA_ENV_PATH" python=3.12 pymetis pip && \
mamba clean -afy
COPY requirements.txt .
@@ -20,15 +23,25 @@ COPY app ./app
COPY contracts ./contracts
COPY infra ./infra
COPY resources ./resources
COPY scripts/check_openapi.py ./scripts/check_openapi.py
COPY tests ./tests
RUN python -c "from pathlib import Path; from zipfile import ZipFile; model_dir = Path('app/algorithms/health/model'); zip_path = model_dir / 'my_survival_forest_model_quxi.zip'; joblib_name = 'my_survival_forest_model_quxi.joblib'; joblib_path = model_dir / joblib_name; assert zip_path.exists(), f'Model archive not found: {zip_path}'; archive = ZipFile(zip_path); archive.extract(joblib_name, model_dir); archive.close(); assert joblib_path.exists(), f'Model file not extracted: {joblib_path}'" && \
rm -f app/algorithms/health/model/my_survival_forest_model_quxi.zip
COPY scripts/run_operational_worker.py scripts/sync_peripheral_work_orders.py ./scripts/
COPY samples/peripheral ./samples/peripheral
RUN python -c "from pathlib import Path; from zipfile import ZipFile; model_dir = Path('app/algorithms/pipe_health_prediction/model'); zip_path = model_dir / 'my_survival_forest_model_quxi.zip'; joblib_name = 'my_survival_forest_model_quxi.joblib'; joblib_path = model_dir / joblib_name; assert zip_path.exists(), f'Model archive not found: {zip_path}'; archive = ZipFile(zip_path); archive.extract(joblib_name, model_dir); archive.close(); assert joblib_path.exists(), f'Model file not extracted: {joblib_path}'" && \
rm -f app/algorithms/pipe_health_prediction/model/my_survival_forest_model_quxi.zip
RUN mkdir -p db_inp temp data inp
# 设置 PYTHONPATH 以便 uvicorn 找到 app 模块
ENV PYTHONPATH=/app
FROM base AS test
COPY scripts ./scripts
COPY tests ./tests
RUN python -m compileall -q app && \
python -c "import app.main" && \
python scripts/check_openapi.py && \
pytest -q --import-mode=importlib tests
FROM base AS runner
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
+48 -2
View File
@@ -43,8 +43,18 @@ conda run -n server uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
conda activate server
```
`GET /health` 是无需鉴权的 HTTP 服务存活检查,返回 `200` 和 `{"status":"ok"}`,不查询数据库。
`GET /ready` 检查应用启动状态、元数据库 `SELECT 1` 及已启用的 MCP 传输生命周期;
全部就绪返回 `200`,否则返回 `503`,元数据库探测预算为2秒。MCP 关闭时标记为
`disabled`,不阻止就绪。返回结果不包含数据库地址、凭据或原始异常,禁止缓存。
就绪状态不等同于 OAuth、用户权限或真实工具调用成功;指定项目的业务库和时序库仍通过
带身份与 `X-Project-Id` 的 `GET /api/v1/projects/current/database-health` 检查。
## 常用命令
外围接入样板是独立进程,复用 PostgreSQL,不自动建表;启动、授权边界和契约见 [外围样板说明](docs/peripheral-sample.md)。
外围样板提供 synthetic 测试工单和测试数据的版本化 CRUD;工单经同步器投影为 `workbench.operational_tasks` 中的外围来源任务。固定演示时窗和造数方式见 [合成样本说明](docs/peripheral-generated-data.md),运行态读取边界见 [工作台运行态说明](docs/workbench-runtime.md)。这些数据不代表现场事实,也不会触发设备控制。
```bash
conda run -n server python -m pytest tests -q
conda run -n server python scripts/run_server.py
@@ -69,11 +79,43 @@ docker compose -f infra/docker/docker-compose.yml config
项目级 REST 请求通过 `X-Project-Id` 解析元数据中的数据库配置:
- `biz_data` DSN 用于管网业务数据;`{project_code}_template` 和模拟临时库沿用该 DSN 的主机、端口与凭据,仅替换数据库名。
- `biz_data` DSN 用于管网业务数据。每个物理业务库使用同名 `_template` 数据库,通过逻辑订阅只同步 `network` schema;模拟临时库从该项目模板克隆。`WNDB_SCHEMA_TEMPLATE_DB_NAME`(当前为 `tjwater_v2_schema_template`)仅用于创建空业务库和 INP 导入暂存库。
- `iot_data` DSN 用于 TimescaleDB,始终使用元数据配置的完整 DSN,不再从项目代码推导数据库名。
- 元数据、业务库和 TimescaleDB 可以部署在同一主机,也可以分别部署。
使用模板复制或临时方案库的模拟功能时,`biz_data` 账号必须具备现有数据库创建、删除和连接终止操作所需的 PostgreSQL 权限。
在线重算会原子替换一个时刻的节点、管段基线。历史分块已经压缩时,写入事务使用
`TIMESCALEDB_REALTIME_MAX_DECOMPRESSED_TUPLES` 设置解压预算,默认100万条;配置必须
为正整数,不允许0。该设置仅在实时快照写入事务内生效,不改变数据库全局设置或普通读取。
预算按需根据受影响压缩批次的规模调整,超过预算仍会失败并回滚当前快照;此前完成的其他
时刻不会一起回滚。同步、异步及单表批量快照入口使用同一预算。
压缩写入回归位于 `tests/integration/test_realtime_compression.py`;仅在设置
`REALTIME_COMPRESSION_TEST_DSN` 时执行,并且只接受库名以 `tjw_test_realtime_` 开头的
独立可丢弃数据库。测试会创建和删除该库内的 `realtime` schema,禁止指向项目数据库。
完整新建供水项目使用 `POST /api/v1/admin/project-provisions`,以
`multipart/form-data` 同时提交 `name`、小写 `code`、可选的
`description`、`gs_workspace`、`map_zoom` 和 INP `file`。工作流会按顺序完成:
1. EPANET 校验 INP,并从空结构模板创建业务库、导入模型;
2. 创建同名 `_template`,复制 32 张 `network` 表并建立逻辑订阅;
3. 从 `TIMESCALEDB_SCHEMA_TEMPLATE_DB_NAME` 创建空时序库;
4. 创建 GeoServer 工作空间、PostGIS 数据存储和 7 个 GIS 图层,将客户端缓存设为 `GEOSERVER_CLIENT_CACHE_SECONDS`;
5. 最后在一个元数据事务中写入项目、两条加密数据库路由和创建者成员关系,并将项目设为 `active`。
基础设施任一步失败时按 GeoServer、时序库、管网模板、业务库的逆序清理;元数据提交失败也执行同样清理。旧 `POST /admin/projects` 仅保留给已经由外部流程创建好的资源登记使用,并已标记为 deprecated。
使用模板复制或临时方案库的模拟功能时,`biz_data` 账号必须具备数据库创建和删除权限;只有显式删除项目时才会终止该项目的现有数据库会话,普通复制不会主动中断复制源会话。
奉贤项目的原始 GIS 与水质监测点使用独立脚本维护。导入脚本读取两份交付目录并写入项目 BizDB;水质脚本调用现有灵敏度分析服务生成 50 个监测点;发布脚本创建 10 个项目图层并刷新 GeoServer 缓存:
```bash
conda run -n server python scripts/import_fengxian_gis.py --database tjwater_v2
conda run -n server python scripts/generate_fengxian_water_quality_points.py --project tjwater_v2
conda run -n server python scripts/publish_fengxian_map_layers.py --database tjwater_v2 --workspace tjwater_next
```
发布命令需要配置 `GEOSERVER_URL`、`GEOSERVER_USERNAME` 和 `GEOSERVER_PASSWORD`。其中 7 个基础管网图层仍由项目供应流程创建,奉贤发布脚本追加 9 个原始 GIS 图层和 1 个水质监测点图层。
## 测试与发布
@@ -94,3 +136,7 @@ Gitea 包工作流位于 `.gitea/workflows/package.yml`,通常由 tag 触发
## 安全规则
不要提交 `.env`、客户数据、数据库 dump、日志、生成缓存、`db_inp/`、`temp/`、`data/` 或本地密钥。CI/CD 凭据应放在 Gitea secrets 和仓库变量中。
## 产品 MCP 查询
Server 可选启用 MCP 2026-07-28 的25项项目查询及10项受控分析,默认关闭。它使用专用 OAuth resource Bearer、宿主绑定上下文及现有项目/任务权限,调查查询返回前持久化证据。启用需更新依赖、应用项目 SQL 迁移和配置 Keycloak token exchange;这些生产操作尚未执行。详见 [实施与启用](docs/mcp-query-implementation.md) 和 [协议/旧 CLI 能力对照](docs/mcp-query-design.md)。
+4 -44
View File
@@ -1,45 +1,5 @@
"""Algorithm package with side-effect-free, lazy compatibility exports."""
"""Pure water-network calculation packages.
from importlib import import_module
from typing import Any
_EXPORT_MODULES = {
"flow_data_clean": "app.algorithms.cleaning",
"pressure_data_clean": "app.algorithms.cleaning",
"pressure_sensor_placement_sensitivity": "app.algorithms.sensor",
"pressure_sensor_placement_kmeans": "app.algorithms.sensor",
"valve_isolation_analysis": "app.algorithms.isolation.valve",
"LeakageIdentifier": "app.algorithms.leakage",
"PipelineHealthAnalyzer": "app.algorithms.health",
"run_burst_location": "app.algorithms.burst_location",
**{
name: "app.algorithms.simulation.scenarios"
for name in (
"convert_to_local_unit",
"burst_analysis",
"valve_close_analysis",
"flushing_analysis",
"contaminant_simulation",
"age_analysis",
"pressure_regulation",
)
},
}
__all__ = list(_EXPORT_MODULES)
def __getattr__(name: str) -> Any:
try:
module_name = _EXPORT_MODULES[name]
except KeyError as exc:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc
value = getattr(import_module(module_name), name)
globals()[name] = value
return value
def __dir__() -> list[str]:
return sorted({*globals(), *__all__})
Application workflows belong in :mod:`app.services`; database and external
system access belongs in :mod:`app.infra` or :mod:`app.native`.
"""
+2 -2
View File
@@ -1,3 +1,3 @@
from app.algorithms.burst_detection.burst_detector import BurstDetector
from app.algorithms.burst_detection.pressure_anomaly import PressureAnomalyDetector
__all__ = ["BurstDetector"]
__all__ = ["PressureAnomalyDetector"]
@@ -17,7 +17,7 @@ PressureDataInput = (
IGNORED_OBSERVATION_COLUMNS = {"time", "timestamp", "datetime", "date"}
class BurstDetector:
class PressureAnomalyDetector:
"""FFT + IsolationForest based burst detection for daily aligned pressure data."""
def __init__(
@@ -0,0 +1,3 @@
from .pipeline import run_burst_location
__all__ = ["run_burst_location"]
@@ -11,13 +11,13 @@ import networkx as nx
import numpy as np
import pandas as pd
from .leak_simulator import cal_signature_pipe_multi_pf
from .network_partitioner import (
from .leak_signature import cal_signature_pipe_multi_pf
from .topology_partitioning import (
cal_group_num,
metis_grouping_pipe_weight,
visualize_metis_partition,
)
from .similarity_calculator import (
from .similarity_metrics import (
adjust_ratio,
cal_similarity_all_multi_new_sq_improve_double_lzr,
decode_mode,
@@ -769,4 +769,3 @@ def DN_search_multi_simple_add_flow_count_new(
final_candidates_csv,
)
@@ -1,5 +1,3 @@
import argparse
import json
import logging
from multiprocessing import cpu_count
from pathlib import Path
@@ -7,12 +5,12 @@ from typing import Any, Iterable
import pandas as pd
from app.algorithms.burst_location import leak_simulator
from app.algorithms.burst_localization import leak_signature
from .burst_locator import (
from .candidate_ranking import (
DN_search_multi_simple_add_flow_count_new,
)
from .network_model import (
from .topology_model import (
_build_node_pipe_maps,
cal_node_coordinate,
construct_graph,
@@ -26,35 +24,6 @@ DEFAULT_N_WORKERS = max(1, min(cpu_count() - 1, 4))
logger = logging.getLogger(__name__)
def _read_id_list_json(path):
if path is None:
return None
data = json.loads(Path(path).read_text(encoding="utf-8"))
if isinstance(data, list):
return [str(item) for item in data]
if isinstance(data, dict):
if "ids" in data and isinstance(data["ids"], list):
return [str(item) for item in data["ids"]]
raise ValueError(f"ID JSON must be list or dict with key 'ids': {path}")
raise ValueError(f"Unsupported ID JSON format: {path}")
def _read_series_csv(path):
if path is None:
return None
df = pd.read_csv(path)
if df.shape[1] < 2:
raise ValueError(f"CSV must contain at least two columns (id,value): {path}")
if {"id", "value"}.issubset(df.columns):
id_col, value_col = "id", "value"
else:
id_col, value_col = df.columns[0], df.columns[1]
series = pd.Series(
df[value_col].values, index=df[id_col].astype(str).values, dtype=float
)
return series
def _align_scada_series(
series: pd.Series, ids: Iterable[str], series_name: str
) -> pd.Series:
@@ -159,7 +128,7 @@ def run_burst_location(
pipe_diameter,
) = read_inf_inp(wn)
candidate_pipe, _ = leak_simulator.cal_possible_pipe(
candidate_pipe, _ = leak_signature.cal_possible_pipe(
burst_leakage, all_pipe, pipe_diameter
)
@@ -267,76 +236,3 @@ def run_burst_location(
"final_candidates_csv": final_candidates_csv,
"stage_timing_seconds": stage_timing,
}
def _parse_args():
parser = argparse.ArgumentParser(description="爆管定位主函数入口")
parser.add_argument("--wn-inp", required=True, help="EPANET inp 文件路径")
parser.add_argument(
"--pressure-ids-json", required=True, help="压力SCADA ID列表 JSON 文件"
)
parser.add_argument(
"--flow-ids-json", default=None, help="(可选)流量SCADA ID列表 JSON 文件"
)
parser.add_argument(
"--burst-pressure-csv", required=True, help="爆管时压力 CSV(id,value)"
)
parser.add_argument(
"--normal-pressure-csv", required=True, help="正常时压力 CSV(id,value)"
)
parser.add_argument(
"--burst-flow-csv", default=None, help="(可选)爆管时流量 CSV(id,value)"
)
parser.add_argument(
"--normal-flow-csv", default=None, help="(可选)正常时流量 CSV(id,value)"
)
parser.add_argument(
"--burst-leakage", type=float, required=True, help="爆管漏损流量"
)
parser.add_argument(
"--min-dpressure",
type=float,
default=2.0,
help="(可选)最小压降阈值,默认 2.0",
)
parser.add_argument(
"--basic-pressure",
type=float,
default=10.0,
help="(可选)基础服务压力,默认 10.0",
)
parser.add_argument(
"--n-workers",
type=int,
default=DEFAULT_N_WORKERS,
help="(可选)特征中心模拟进程数,默认 max(1, min(cpu_count()-1, 4))",
)
parser.add_argument(
"--final-candidates-csv-path",
default="temp/burst_location/final_round_candidates.csv",
help="(可选)最后一轮候选管道明细 CSV 输出路径",
)
return parser.parse_args()
def main():
args = _parse_args()
result = run_burst_location(
wn_inp_path=args.wn_inp,
pressure_scada_ids=_read_id_list_json(args.pressure_ids_json),
burst_pressure=_read_series_csv(args.burst_pressure_csv),
normal_pressure=_read_series_csv(args.normal_pressure_csv),
burst_leakage=args.burst_leakage,
flow_scada_ids=_read_id_list_json(args.flow_ids_json),
burst_flow=_read_series_csv(args.burst_flow_csv),
normal_flow=_read_series_csv(args.normal_flow_csv),
min_dpressure=args.min_dpressure,
basic_pressure=args.basic_pressure,
n_workers=args.n_workers,
final_candidates_csv_path=args.final_candidates_csv_path,
)
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
@@ -6,7 +6,7 @@ import random
import numpy as np
import pandas as pd
from .leak_simulator import simple_add_leak, simple_recover_wn, simple_simulation_pf
from .leak_signature import simple_add_leak, simple_recover_wn, simple_simulation_pf
def add_noise_pd(data, noise_type, noise_para):
@@ -195,4 +195,3 @@ def change_para_of_wn(wn, pipe_roughness_change):
pipe.roughness = pipe_roughness_change[pipe_name]
return wn
@@ -1,3 +0,0 @@
from .burst_location import run_burst_location
__all__ = ["run_burst_location"]
-59
View File
@@ -1,59 +0,0 @@
import os
from app.algorithms.cleaning import flow as _flow_module
from app.algorithms.cleaning import pressure as _pressure_module
############################################################
# 流量监测数据清洗 ***卡尔曼滤波法***
############################################################
# 2025/08/21 hxyan
def flow_data_clean(input_csv_file: str) -> str:
"""
读取 input_csv_path 中的每列时间序列,使用一维 Kalman 滤波平滑并用预测值替换基于 3σ 检测出的异常点。
保存输出为:<input_filename>_cleaned.xlsx(与输入同目录),并返回输出文件的绝对路径。如有同名文件存在,则覆盖。
:param: input_csv_file: 输入的 CSV 文件明或路径
:return: 输出文件的绝对路径
"""
# 提供的 input_csv_path 绝对路径,以下为 默认脚本目录下同名 CSV 文件,构建绝对路径,可根据情况修改
# 使用 algorithms 根目录保持与原 data_cleaning.py 一致的行为
script_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
input_csv_path = os.path.join(script_dir, input_csv_file)
# 检查文件是否存在
if not os.path.exists(input_csv_path):
raise FileNotFoundError(f"指定的文件不存在: {input_csv_path}")
# 调用 clean_flow_data_kf 函数进行数据清洗
out_xlsx_path = _flow_module.clean_flow_data_kf(input_csv_path)
print("清洗后的数据已保存到:", out_xlsx_path)
############################################################
# 压力监测数据清洗 ***kmean++法***
############################################################
# 2025/08/21 hxyan
def pressure_data_clean(input_csv_file: str) -> str:
"""
读取 input_csv_path 中的每列时间序列,使用Kmean++清洗数据。
保存输出为:<input_filename>_cleaned.xlsx(与输入同目录),并返回输出文件的绝对路径。如有同名文件存在,则覆盖。
原始数据在 sheet 'raw_pressure_data',处理后数据在 sheet 'cleaned_pressusre_data'。
:param input_csv_path: 输入的 CSV 文件路径
:return: 输出文件的绝对路径
"""
# 提供的 input_csv_path 绝对路径,以下为 默认脚本目录下同名 CSV 文件,构建绝对路径,可根据情况修改
# 使用 algorithms 根目录保持与原 data_cleaning.py 一致的行为
script_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
input_csv_path = os.path.join(script_dir, input_csv_file)
# 检查文件是否存在
if not os.path.exists(input_csv_path):
raise FileNotFoundError(f"指定的文件不存在: {input_csv_path}")
# 调用 clean_pressure_data_km 函数进行数据清洗
out_xlsx_path = _pressure_module.clean_pressure_data_km(input_csv_path)
print("清洗后的数据已保存到:", out_xlsx_path)
@@ -0,0 +1,5 @@
"""Demand allocation calculations."""
from .pipe_length_weighted import allocate_demand_by_pipe_length
__all__ = ["allocate_demand_by_pipe_length"]
@@ -0,0 +1,36 @@
"""Pipe-length-weighted demand allocation.
This module deliberately accepts plain topology data and performs no database
or file access. Application services are responsible for loading topology.
"""
from typing import Any, Mapping
def allocate_demand_by_pipe_length(
demand: float,
topology_nodes: Mapping[str, Mapping[str, Any]],
topology_links: Mapping[str, Mapping[str, Any]],
) -> dict[str, float]:
"""Allocate total demand to junctions by half of each incident link length."""
if not topology_nodes or not topology_links or demand == 0.0:
return {}
total_link_length = sum(
abs(float(link["length"])) for link in topology_links.values()
)
if total_link_length <= 0.0:
return {}
demand_per_length = demand / total_link_length
result: dict[str, float] = {}
for node_id, node in topology_nodes.items():
if node["type"] != "junction":
continue
incident_length = sum(
abs(float(topology_links[link_id]["length"]))
for link_id in node["links"]
)
result[node_id] = incident_length * demand_per_length * 0.5
return result
@@ -0,0 +1,3 @@
from app.algorithms.dma_leakage_estimation.genetic_optimizer import DmaLeakageOptimizer
__all__ = ["DmaLeakageOptimizer"]
@@ -3,7 +3,6 @@ import numpy as np
import pandas as pd
import os
import time
import argparse
from multiprocessing import Pool, cpu_count
from typing import Any, List, Dict, Union
@@ -70,7 +69,7 @@ def _worker_init(
def _worker_evaluate(raw_ratios: np.ndarray) -> float:
d = _worker_data
effective_ratio_map = LeakageIdentifier._effective_area_ratios(
effective_ratio_map = DmaLeakageOptimizer._effective_area_ratios(
raw_ratios,
d["area_ids"],
d["nodes_by_area"],
@@ -121,7 +120,7 @@ def _worker_evaluate(raw_ratios: np.ndarray) -> float:
_cleanup_temp_files(prefix)
class LeakageIdentifier:
class DmaLeakageOptimizer:
FLOW_UNIT_TO_M3S = {
"m3/s": 1.0,
"m³/s": 1.0,
@@ -543,7 +542,7 @@ class LeakageProblem(Problem):
leak_ratios = x
# 将漏损分布归一化
effective_ratio_map = LeakageIdentifier._effective_area_ratios(
effective_ratio_map = DmaLeakageOptimizer._effective_area_ratios(
leak_ratios,
self.area_ids,
self.nodes_by_area,
@@ -605,51 +604,6 @@ class LeakageProblem(Problem):
def close(self) -> None:
if self._pool is not None:
self._pool.close()
self._pool.join()
self._pool = None
def main() -> int:
parser = argparse.ArgumentParser(description="漏损区域识别")
parser.add_argument("--inp", required=True, help=".inp 文件路径")
parser.add_argument("--map", help="节点-区域映射 CSV 路径")
parser.add_argument("--scada", help="SCADA 压力 CSV 路径 (观测数据)")
parser.add_argument("--sensors", help="传感器节点 ID 列表 (逗号分隔)")
parser.add_argument("--output", default="Results", help="输出目录")
parser.add_argument("--pop_size", type=int, default=50, help="种群大小")
parser.add_argument("--max_gen", type=int, default=100, help="最大代数")
parser.add_argument("--duration", type=float, default=24, help="模拟时长(小时)")
parser.add_argument("--q_sum", type=float, default=0.241, help="总漏损流量")
parser.add_argument(
"--q_sum_unit",
default="m3/s",
choices=list(LeakageIdentifier.FLOW_UNIT_TO_M3S.keys()),
help="q_sum 输入单位(建议与现场习惯一致,内部统一换算为 m3/s)",
)
args = parser.parse_args()
if not args.map or not args.scada or not args.sensors:
parser.error("--map、--scada、--sensors 为必填")
q_sum_m3s = LeakageIdentifier._flow_to_m3s(args.q_sum, args.q_sum_unit)
sensors = [sensor.strip() for sensor in args.sensors.split(",") if sensor.strip()]
identifier = LeakageIdentifier(
args.inp, sensors, args.map, duration=args.duration, q_sum=q_sum_m3s
)
identifier.run_identification(
args.scada,
args.output,
pop_size=args.pop_size,
max_gen=args.max_gen,
output_flow_unit=args.q_sum_unit,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
self._pool.close()
self._pool.join()
self._pool = None
@@ -0,0 +1,206 @@
"""Pure topology partitioning used by DMA leakage estimation."""
import math
from collections import deque
from typing import Any, Iterable, Mapping
import numpy as np
def build_dma_partitions(
sensor_nodes: list[str],
node_coords: Mapping[str, Mapping[str, Any]],
link_entries: Iterable[str],
dma_count: int | None,
) -> tuple[dict[str, str], list[dict[str, Any]]]:
"""Assign every topology node to a sensor-seeded virtual DMA."""
all_nodes = list(node_coords)
if not all_nodes:
raise ValueError("管网中未获取到可分区节点。")
available_sensors = [node for node in sensor_nodes if node in node_coords]
if not available_sensors:
raise ValueError("无可用压力传感器,无法生成虚拟分区。")
area_count = _resolve_dma_count(dma_count, available_sensors, all_nodes)
sensor_area_map = _cluster_sensors_to_areas(
available_sensors, node_coords, area_count
)
adjacency = _build_adjacency(link_entries, all_nodes)
distance_by_sensor = {
sensor: _bfs_distances(adjacency, sensor) for sensor in available_sensors
}
assignment_count = {sensor: 0 for sensor in available_sensors}
area_map: dict[str, str] = {}
for node_id in sorted(all_nodes):
sensor = _choose_sensor_for_node(
node_id,
available_sensors,
node_coords,
distance_by_sensor,
assignment_count,
)
assignment_count[sensor] += 1
area_map[node_id] = sensor_area_map[sensor]
return area_map, _build_area_meta(area_map, sensor_area_map)
def _resolve_dma_count(
dma_count: int | None, sensor_nodes: list[str], all_nodes: list[str]
) -> int:
if dma_count is None:
return min(len(sensor_nodes), len(all_nodes))
if dma_count <= 0:
raise ValueError("dma_count 必须大于 0。")
if dma_count > len(all_nodes):
raise ValueError("dma_count 不能大于可分区节点数量。")
if dma_count > len(sensor_nodes):
raise ValueError("dma_count 不能大于可用传感器数量。")
return dma_count
def _cluster_sensors_to_areas(
sensor_nodes: list[str],
node_coords: Mapping[str, Mapping[str, Any]],
area_count: int,
) -> dict[str, str]:
if area_count >= len(sensor_nodes):
return {sensor: str(index + 1) for index, sensor in enumerate(sensor_nodes)}
points = np.array(
[
[float(node_coords[sensor]["x"]), float(node_coords[sensor]["y"])]
for sensor in sensor_nodes
],
dtype=float,
)
centers = points[:area_count].copy()
labels = np.full(points.shape[0], -1, dtype=int)
for _ in range(20):
distances_squared = (
(points[:, None, :] - centers[None, :, :]) ** 2
).sum(axis=2)
next_labels = distances_squared.argmin(axis=1)
if np.array_equal(labels, next_labels):
break
labels = next_labels
for index in range(area_count):
cluster_points = points[labels == index]
if cluster_points.size > 0:
centers[index] = cluster_points.mean(axis=0)
labels = _restore_empty_area_labels(labels, points, centers, area_count)
return {
sensor: str(int(labels[index]) + 1)
for index, sensor in enumerate(sensor_nodes)
}
def _restore_empty_area_labels(
labels: np.ndarray,
points: np.ndarray,
centers: np.ndarray,
area_count: int,
) -> np.ndarray:
"""Keep every requested area represented when coordinates are degenerate."""
labels = labels.copy()
for missing_area in sorted(set(range(area_count)) - set(labels.tolist())):
area_sizes = {
area: int(np.count_nonzero(labels == area)) for area in range(area_count)
}
donor_area = max(
(area for area, size in area_sizes.items() if size > 1),
key=lambda area: (area_sizes[area], -area),
)
donor_indices = np.flatnonzero(labels == donor_area)
replacement_index = max(
(int(index) for index in donor_indices),
key=lambda index: (
float(np.sum((points[index] - centers[donor_area]) ** 2)),
index,
),
)
labels[replacement_index] = missing_area
centers[missing_area] = points[replacement_index]
return labels
def _build_adjacency(
link_entries: Iterable[str], all_nodes: list[str]
) -> dict[str, set[str]]:
adjacency: dict[str, set[str]] = {node: set() for node in all_nodes}
for link in link_entries:
parts = str(link).split(":")
if len(parts) < 4:
continue
node1, node2 = parts[-2], parts[-1]
if node1 in adjacency and node2 in adjacency:
adjacency[node1].add(node2)
adjacency[node2].add(node1)
return adjacency
def _bfs_distances(adjacency: Mapping[str, set[str]], start: str) -> dict[str, int]:
distances = {start: 0}
queue: deque[str] = deque([start])
while queue:
node = queue.popleft()
for neighbor in adjacency.get(node, set()):
if neighbor in distances:
continue
distances[neighbor] = distances[node] + 1
queue.append(neighbor)
return distances
def _choose_sensor_for_node(
node_id: str,
sensors: list[str],
node_coords: Mapping[str, Mapping[str, Any]],
distance_by_sensor: Mapping[str, Mapping[str, int]],
assignment_count: Mapping[str, int],
) -> str:
min_distance: int | None = None
candidates: list[str] = []
for sensor in sensors:
distance = distance_by_sensor.get(sensor, {}).get(node_id)
if distance is None:
continue
if min_distance is None or distance < min_distance:
min_distance = distance
candidates = [sensor]
elif distance == min_distance:
candidates.append(sensor)
if not candidates:
node_coord = node_coords[node_id]
return min(
sensors,
key=lambda sensor: math.hypot(
float(node_coord["x"]) - float(node_coords[sensor]["x"]),
float(node_coord["y"]) - float(node_coords[sensor]["y"]),
),
)
return min(candidates, key=lambda sensor: (assignment_count[sensor], sensor))
def _build_area_meta(
area_map: Mapping[str, str], sensor_area_map: Mapping[str, str]
) -> list[dict[str, Any]]:
nodes_by_area: dict[str, list[str]] = {}
for node_id, area_id in area_map.items():
nodes_by_area.setdefault(area_id, []).append(node_id)
sensors_by_area: dict[str, list[str]] = {}
for sensor, area_id in sensor_area_map.items():
sensors_by_area.setdefault(area_id, []).append(sensor)
return [
{
"area_id": area_id,
"sensor_nodes": sorted(sensors_by_area.get(area_id, [])),
"node_ids": sorted(nodes_by_area[area_id]),
"node_count": len(nodes_by_area[area_id]),
}
for area_id in sorted(nodes_by_area, key=int)
]
-3
View File
@@ -1,3 +0,0 @@
from app.algorithms.health.analyzer import PipelineHealthAnalyzer
__all__ = ["PipelineHealthAnalyzer"]
-3
View File
@@ -1,3 +0,0 @@
from app.algorithms.isolation.valve import valve_isolation_analysis
__all__ = ["valve_isolation_analysis"]
-167
View File
@@ -1,167 +0,0 @@
from collections import defaultdict, deque
from functools import lru_cache
from typing import Any
from app.services.tjnetwork import (
get_network_link_nodes,
is_node,
get_link_properties,
)
VALVE_LINK_TYPE = "valve"
def _parse_link_entry(link_entry: str) -> tuple[str, str, str, str]:
parts = link_entry.split(":", 3)
if len(parts) != 4:
raise ValueError(f"Invalid link entry format: {link_entry}")
return parts[0], parts[1], parts[2], parts[3]
@lru_cache(maxsize=16)
def _get_network_topology(network: str):
"""
解析并缓存网络拓扑,大幅减少重复的 API 调用和字符串解析开销。
返回:
- pipe_adj: 永久连通的管道/泵邻接表 (dict[str, set])
- all_valves: 所有阀门字典 {id: (n1, n2)}
- link_lookup: 链路快速查表 {id: (n1, n2, type)} 用于快速定位事故点
- node_set: 所有已知节点集合
"""
pipe_adj = defaultdict(set)
all_valves = {}
link_lookup = {}
node_set = set()
# 此处假设 get_network_link_nodes 获取全网数据
for link_entry in get_network_link_nodes(network):
link_id, link_type, node1, node2 = _parse_link_entry(link_entry)
link_type_name = str(link_type).lower()
link_lookup[link_id] = (node1, node2, link_type_name)
node_set.add(node1)
node_set.add(node2)
if link_type_name == VALVE_LINK_TYPE:
all_valves[link_id] = (node1, node2)
else:
# 只有非阀门(管道/泵)才进入永久连通图
pipe_adj[node1].add(node2)
pipe_adj[node2].add(node1)
return pipe_adj, all_valves, link_lookup, node_set
def valve_isolation_analysis(
network: str, accident_elements: str | list[str], disabled_valves: list[str] = None
) -> dict[str, Any]:
"""
关阀搜索/分析:基于拓扑结构确定事故隔离所需关阀。
:param network: 模型名称
:param accident_elements: 事故点(节点或管道/泵/阀门ID),可以是单个ID字符串或ID列表
:param disabled_valves: 故障/无法关闭的阀门ID列表
:return: dict,包含受影响节点、必须关闭阀门、可选阀门等信息
"""
if disabled_valves is None:
disabled_valves_set = set()
else:
disabled_valves_set = set(disabled_valves)
if isinstance(accident_elements, str):
target_elements = [accident_elements]
else:
target_elements = accident_elements
# 1. 获取缓存拓扑 (极快,无 IO)
pipe_adj, all_valves, link_lookup, node_set = _get_network_topology(network)
# 2. 确定起点,优先查表避免 API 调用
start_nodes = set()
for element in target_elements:
if element in node_set:
start_nodes.add(element)
elif element in link_lookup:
n1, n2, _ = link_lookup[element]
start_nodes.add(n1)
start_nodes.add(n2)
else:
# 仅当缓存中没找到时(极少见),才回退到慢速 API
if is_node(network, element):
start_nodes.add(element)
else:
props = get_link_properties(network, element)
n1, n2 = props.get("node1"), props.get("node2")
if n1 and n2:
start_nodes.add(n1)
start_nodes.add(n2)
else:
raise ValueError(
f"Accident element {element} invalid or missing endpoints"
)
# 3. 处理故障阀门 (构建临时增量图)
# 我们不修改 cached pipe_adj,而是建立一个 extra_adj
extra_adj = defaultdict(list)
boundary_valves = {} # 当前有效的边界阀门
for vid, (n1, n2) in all_valves.items():
if vid in disabled_valves_set:
# 故障阀门:视为连通管道
extra_adj[n1].append(n2)
extra_adj[n2].append(n1)
else:
# 正常阀门:视为潜在边界
boundary_valves[vid] = (n1, n2)
# 4. BFS 搜索 (叠加 pipe_adj 和 extra_adj)
affected_nodes: set[str] = set()
queue = deque(start_nodes)
while queue:
node = queue.popleft()
if node in affected_nodes:
continue
affected_nodes.add(node)
# 遍历永久管道邻居
if node in pipe_adj:
for neighbor in pipe_adj[node]:
if neighbor not in affected_nodes:
queue.append(neighbor)
# 遍历故障阀门带来的额外邻居
if node in extra_adj:
for neighbor in extra_adj[node]:
if neighbor not in affected_nodes:
queue.append(neighbor)
# 5. 结果聚合
must_close_valves: list[str] = []
optional_valves: list[str] = []
for valve_id, (n1, n2) in boundary_valves.items():
in_n1 = n1 in affected_nodes
in_n2 = n2 in affected_nodes
if in_n1 and in_n2:
optional_valves.append(valve_id)
elif in_n1 or in_n2:
must_close_valves.append(valve_id)
must_close_valves.sort()
optional_valves.sort()
isolatable = bool(must_close_valves)
result = {
"accident_elements": target_elements,
"disabled_valves": disabled_valves,
"affected_nodes": sorted(affected_nodes) if isolatable else [],
"affected_node_count": len(affected_nodes),
"must_close_valves": must_close_valves,
"optional_valves": optional_valves,
"isolatable": isolatable,
}
if len(target_elements) == 1:
result["accident_element"] = target_elements[0]
return result
-3
View File
@@ -1,3 +0,0 @@
from app.algorithms.leakage.identifier import LeakageIdentifier
__all__ = ["LeakageIdentifier"]
@@ -0,0 +1,5 @@
from app.algorithms.pipe_health_prediction.survival_predictor import (
PipeHealthSurvivalPredictor,
)
__all__ = ["PipeHealthSurvivalPredictor"]
@@ -4,7 +4,7 @@ import pandas as pd
import matplotlib.pyplot as plt
class PipelineHealthAnalyzer:
class PipeHealthSurvivalPredictor:
"""
管道健康分析器类,使用随机生存森林模型预测管道的生存概率。
@@ -28,11 +28,6 @@ class PipelineHealthAnalyzer:
"model",
"my_survival_forest_model_quxi.joblib",
)
# 确保 model 目录存在
model_dir = os.path.dirname(model_path)
if model_dir and not os.path.exists(model_dir):
os.makedirs(model_dir, exist_ok=True)
if not os.path.exists(model_path):
raise FileNotFoundError(f"模型文件未找到: {model_path}")
@@ -102,7 +97,7 @@ class PipelineHealthAnalyzer:
# 调用说明示例
"""
在其他项目中使用PipelineHealthAnalyzer类的步骤:
在其他项目中使用 PipeHealthSurvivalPredictor 类的步骤:
1. 安装依赖(在requirements.txt中添加):
joblib==1.5.0
@@ -112,34 +107,29 @@ class PipelineHealthAnalyzer:
matplotlib==3.9.4
2. 导入类:
from pipeline_health_analyzer import PipelineHealthAnalyzer
from survival_predictor import PipeHealthSurvivalPredictor
3. 初始化分析器(替换为实际模型路径):
analyzer = PipelineHealthAnalyzer(model_path='path/to/my_survival_forest_model3-10.joblib')
predictor = PipeHealthSurvivalPredictor(model_path='path/to/model.joblib')
4. 准备数据(pandas DataFrame,包含9个特征列):
4. 准备数据(pandas DataFrame,包含4个特征列):
import pandas as pd
data = pd.DataFrame({
'Material': [1, 2], # 示例数据
'Diameter': [100, 150],
'Flow Velocity': [1.5, 2.0],
'Pressure': [50, 60],
'Temperature': [20, 25],
'Precipitation': [0.1, 0.2],
'Location': [1, 2],
'Structural Defects': [0, 1],
'Functional Defects': [0, 0]
'Pressure': [50, 60]
})
5. 进行预测:
survival_funcs = analyzer.predict_survival(data)
survival_funcs = predictor.predict_survival(data)
6. 查看结果(每个样本的生存概率随时间变化):
for i, sf in enumerate(survival_funcs):
print(f"样本 {i+1}: 时间点: {sf.x[:5]}..., 生存概率: {sf.y[:5]}...")
7. 可视化(可选):
analyzer.plot_survival(survival_funcs, save_path='survival_plot.png')
predictor.plot_survival(survival_funcs, save_path='survival_plot.png')
注意:
- 数据格式必须匹配特征列表,特征值为数值型。
@@ -0,0 +1 @@
"""Pressure sensor placement calculation implementations."""
@@ -0,0 +1,96 @@
import matplotlib.pyplot as plt
import numpy as np
import sklearn.cluster
import wntr
class KMeansPlacement:
def __init__(self, wn, num_monitors: int, min_diameter_mm: float):
self.cluster_num = num_monitors
self.wn = wn
self.monitor_nodes: list[str] = []
self.coords: list[tuple[float, float]] = []
self.candidate_nodes: list[str] = []
self.min_diameter_mm = min_diameter_mm
def get_junctions_coordinates(self) -> None:
eligible_nodes: set[str] = set()
junction_names = set(self.wn.junction_name_list)
for pipe_name in self.wn.pipe_name_list:
pipe = self.wn.get_link(pipe_name)
if float(pipe.diameter) * 1000 < self.min_diameter_mm:
continue
eligible_nodes.update(
node_id
for node_id in (pipe.start_node_name, pipe.end_node_name)
if node_id in junction_names
)
for junction_name in self.wn.junction_name_list:
if junction_name not in eligible_nodes:
continue
junction = self.wn.get_node(junction_name)
self.candidate_nodes.append(junction_name)
self.coords.append(junction.coordinates)
def select_monitoring_points(self) -> list[str]:
if not self.coords:
self.get_junctions_coordinates()
if self.cluster_num <= 0:
raise ValueError("sensor_count must be greater than zero")
if self.cluster_num > len(self.candidate_nodes):
raise ValueError("符合最小管径条件的候选节点数量少于请求的监测点数量")
coords = np.array(self.coords)
coordinate_span = coords.max(axis=0) - coords.min(axis=0)
coordinate_span[coordinate_span == 0] = 1.0
coords_normalized = (coords - coords.min(axis=0)) / coordinate_span
kmeans = sklearn.cluster.KMeans(n_clusters=self.cluster_num, random_state=42)
kmeans.fit(coords_normalized)
selected_indices: set[int] = set()
for cluster_index, center in enumerate(kmeans.cluster_centers_):
cluster_indices = np.flatnonzero(kmeans.labels_ == cluster_index)
available_indices = [
int(index)
for index in cluster_indices
if int(index) not in selected_indices
]
if not available_indices:
available_indices = [
index
for index in range(len(self.candidate_nodes))
if index not in selected_indices
]
nearest_index = min(
available_indices,
key=lambda index: (
float(np.sum((coords_normalized[index] - center) ** 2)),
index,
),
)
selected_indices.add(nearest_index)
nearest_node = self.candidate_nodes[nearest_index]
self.monitor_nodes.append(nearest_node)
return self.monitor_nodes
def visualize_network(self) -> None:
"""Visualize network with monitoring points."""
wntr.graphics.plot_network(
self.wn,
node_attribute=self.monitor_nodes,
node_size=30,
title="Optimal sensor",
)
plt.show()
def optimize_sensor_placement(
network_model: wntr.network.WaterNetworkModel,
sensor_count: int,
min_diameter_mm: float,
) -> list[str]:
"""Select sensor nodes from an already loaded network model."""
placement = KMeansPlacement(network_model, sensor_count, min_diameter_mm)
return placement.select_monitoring_points()
@@ -903,14 +903,3 @@ def optimize_sensor_placement_from_inp(
sensor_num=sensor_num,
min_diameter=min_diameter,
)
def get_ID(name: str, sensor_num: int, min_diameter: int) -> list[str]:
"""Compatibility entry point used by the sensor placement service."""
inp_path = Path("db_inp") / f"{name}.db.inp"
return optimize_sensor_placement_from_inp(
inp_path,
sensor_num=sensor_num,
min_diameter=min_diameter,
)
@@ -0,0 +1,6 @@
"""SCADA time-series cleaning algorithms."""
from .flow_series import clean_flow_data_df_kf
from .pressure_series import clean_pressure_data_df_km
__all__ = ["clean_flow_data_df_kf", "clean_pressure_data_df_km"]
@@ -142,11 +142,13 @@ def clean_flow_data_kf(
return os.path.abspath(output_path)
def clean_flow_data_df_kf(data: pd.DataFrame, show_plot: bool = False) -> dict:
def clean_flow_data_df_kf(
data: pd.DataFrame, show_plot: bool = False
) -> pd.DataFrame:
"""
接收一个 DataFrame 数据结构,使用一维 Kalman 滤波平滑并用预测值替换基于 IQR 检测出的异常点。
区分合理的0值(流量转换)和异常的0值(连续多个0或孤立0)。
返回完整的清洗后的字典数据结构。
返回完整的清洗后 DataFrame。
Args:
data: 输入 DataFrame(可包含 time 列)
@@ -305,42 +307,3 @@ def clean_flow_data_df_kf(data: pd.DataFrame, show_plot: bool = False) -> dict:
# 返回完整的修复后字典
return cleaned_data
# # 测试
# if __name__ == "__main__":
# # 默认:脚本目录下同名 CSV 文件
# script_dir = os.path.dirname(os.path.abspath(__file__))
# default_csv = os.path.join(script_dir, "pipe_flow_data_to_clean2.0.csv")
# out = clean_flow_data_kf(default_csv)
# print("清洗后的数据已保存到:", out)
# 测试 clean_flow_data_dict 函数
if __name__ == "__main__":
import random
# 读取 szh_flow_scada.csv 文件
script_dir = os.path.dirname(os.path.abspath(__file__))
csv_path = os.path.join(script_dir, "szh_flow_scada.csv")
data = pd.read_csv(csv_path, header=0, index_col=None, encoding="utf-8")
# 排除 Time 列,随机选择 5 列
columns_to_exclude = ["Time"]
available_columns = [col for col in data.columns if col not in columns_to_exclude]
selected_columns = random.sample(available_columns, 1)
# 将选中的列转换为字典
data_dict = {col: data[col].tolist() for col in selected_columns}
print("选中的列:", selected_columns)
print("原始数据长度:", len(data_dict[selected_columns[0]]))
# 调用函数进行清洗
cleaned_dict = clean_flow_data_df_kf(data_dict, show_plot=True)
# 将清洗后的字典写回 CSV
out_csv = os.path.join(script_dir, f"{selected_columns[0]}_clean.csv")
pd.DataFrame(cleaned_dict).to_csv(out_csv, index=False, encoding="utf-8-sig")
print("已保存清洗结果到:", out_csv)
print("清洗后的字典键:", list(cleaned_dict.keys()))
print("清洗后的数据长度:", len(cleaned_dict[selected_columns[0]]))
print("测试完成:函数运行正常")
@@ -541,39 +541,3 @@ def clean_pressure_data_df_km(data: pd.DataFrame, show_plot: bool = False) -> pd
plt.show()
return data_repaired
# 测试
# if __name__ == "__main__":
# # 默认使用脚本目录下的 pressure_raw_data.csv
# script_dir = os.path.dirname(os.path.abspath(__file__))
# default_csv = os.path.join(script_dir, "pressure_raw_data.csv")
# out_path = clean_pressure_data_km(default_csv, show_plot=False)
# print("保存路径:", out_path)
# 测试 clean_pressure_data_dict_km 函数
if __name__ == "__main__":
import random
# 读取 szh_pressure_scada.csv 文件
script_dir = os.path.dirname(os.path.abspath(__file__))
csv_path = os.path.join(script_dir, "szh_pressure_scada.csv")
data = pd.read_csv(csv_path, header=0, index_col=None, encoding="utf-8")
# 排除 Time 列,随机选择 5 列
columns_to_exclude = ["Time"]
available_columns = [col for col in data.columns if col not in columns_to_exclude]
selected_columns = random.sample(available_columns, 5)
# 将选中的列转换为字典
data_dict = {col: data[col].tolist() for col in selected_columns}
print("选中的列:", selected_columns)
print("原始数据长度:", len(data_dict[selected_columns[0]]))
# 调用函数进行清洗
cleaned_dict = clean_pressure_data_df_km(data_dict, show_plot=True)
print("清洗后的字典键:", list(cleaned_dict.keys()))
print("清洗后的数据长度:", len(cleaned_dict[selected_columns[0]]))
print("测试完成:函数运行正常")
-131
View File
@@ -1,131 +0,0 @@
from contextlib import contextmanager
import fcntl
from pathlib import Path
from typing import Any
from app.algorithms.sensor import kmeans as kmeans_sensor
from app.algorithms.sensor import sensitivity
from app.infra.db.postgresql.sensor_placement import create_sensor_placement
from app.services.sensor_placement import (
SensorPlacementConflictError,
SensorPlacementValidationError,
validate_sensor_placement_nodes,
)
from app.services.tjnetwork import dump_inp
def _sensor_inp_path(name: str) -> Path:
if (
not name
or name in {".", ".."}
or "/" in name
or "\\" in name
or "\x00" in name
):
raise SensorPlacementValidationError("管网名称不是有效的项目标识")
return Path("db_inp") / f"{name}.db.inp"
@contextmanager
def _sensor_inp_lock(name: str):
inp_path = _sensor_inp_path(name)
inp_path.parent.mkdir(parents=True, exist_ok=True)
lock_path = inp_path.with_suffix(".sensor.lock")
with lock_path.open("w", encoding="utf-8") as lock_file:
try:
fcntl.flock(
lock_file.fileno(),
fcntl.LOCK_EX | fcntl.LOCK_NB,
)
except BlockingIOError as exc:
raise SensorPlacementConflictError(
"当前项目已有监测点优化任务正在运行,请稍后重试"
) from exc
try:
yield inp_path
finally:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
def _create_validated_placement(
name: str,
*,
run_name: str,
min_diameter: int,
created_by: str,
sensor_locations: list[str],
) -> dict[str, Any]:
validate_sensor_placement_nodes(name, sensor_locations)
return create_sensor_placement(
name,
run_name=run_name,
min_diameter=min_diameter,
created_by=created_by,
sensor_locations=sensor_locations,
)
def pressure_sensor_placement_sensitivity(
name: str,
scheme_name: str,
sensor_number: int,
min_diameter: int,
username: str,
) -> dict[str, Any]:
"""
基于改进灵敏度法进行压力监测点优化布置
:param name: 数据库名称
:param scheme_name: 监测优化布置方案名称
:param sensor_number: 传感器数目
:param min_diameter: 最小管径
:param username: 用户名
:return: 新建的监测点方案
"""
with _sensor_inp_lock(name):
sensor_location = sensitivity.get_ID(
name=name,
sensor_num=sensor_number,
min_diameter=min_diameter,
)
return _create_validated_placement(
name,
run_name=scheme_name,
min_diameter=min_diameter,
created_by=username,
sensor_locations=sensor_location,
)
# 2025/08/21
# 基于kmeans聚类法进行压力监测点优化布置
def pressure_sensor_placement_kmeans(
name: str,
scheme_name: str,
sensor_number: int,
min_diameter: int,
username: str,
) -> dict[str, Any]:
"""
基于聚类法进行压力监测点优化布置
:param name: 数据库名称(注意,此处数据库名称也是inp文件名称,inp文件与pg库名要一样)
:param scheme_name: 监测优化布置方案名称
:param sensor_number: 传感器数目
:param min_diameter: 最小管径
:param username: 用户名
:return: 新建的监测点方案
"""
# dump_inp
with _sensor_inp_lock(name) as inp_path:
dump_inp(name, str(inp_path), "2")
sensor_location = kmeans_sensor.kmeans_sensor_placement(
name=name,
sensor_num=sensor_number,
min_diameter=min_diameter,
)
return _create_validated_placement(
name,
run_name=scheme_name,
min_diameter=min_diameter,
created_by=username,
sensor_locations=sensor_location,
)
-71
View File
@@ -1,71 +0,0 @@
import wntr
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import sklearn.cluster
import os
class QD_KMeans(object):
def __init__(self, wn, num_monitors):
# self.inp = inp
self.cluster_num = num_monitors # 聚类中心个数,也即测压点个数
self.wn = wn
self.monitor_nodes = []
self.coords = []
self.junction_nodes = {} # Added missing initialization
def get_junctions_coordinates(self):
for junction_name in self.wn.junction_name_list:
junction = self.wn.get_node(junction_name)
self.junction_nodes[junction_name] = junction.coordinates
self.coords.append(junction.coordinates)
# print(f"Total junctions: {self.junction_coordinates}")
def select_monitoring_points(self):
if not self.coords: # Add check if coordinates are collected
self.get_junctions_coordinates()
coords = np.array(self.coords)
coords_normalized = (coords - coords.min(axis=0)) / (
coords.max(axis=0) - coords.min(axis=0)
)
kmeans = sklearn.cluster.KMeans(n_clusters=self.cluster_num, random_state=42)
kmeans.fit(coords_normalized)
for center in kmeans.cluster_centers_:
distances = np.sum((coords_normalized - center) ** 2, axis=1)
nearest_node = self.wn.junction_name_list[np.argmin(distances)]
self.monitor_nodes.append(nearest_node)
return self.monitor_nodes
def visualize_network(self):
"""Visualize network with monitoring points"""
ax = wntr.graphics.plot_network(
self.wn,
node_attribute=self.monitor_nodes,
node_size=30,
title="Optimal sensor",
)
plt.show()
def kmeans_sensor_placement(name: str, sensor_num: int, min_diameter: int) -> list:
inp_name = f"./db_inp/{name}.db.inp"
wn = wntr.network.WaterNetworkModel(inp_name)
wn_cluster = QD_KMeans(wn, sensor_num)
# Select monitoring pointse
sensor_ids = wn_cluster.select_monitoring_points()
# wn_cluster.visualize_network()
return sensor_ids
if __name__ == "__main__":
# sensorindex = get_ID(name='suzhouhe_2024_cloud_0817', sensor_num=30, min_diameter=500)
sensorindex = kmeans_sensor_placement(name="szh", sensor_num=50, min_diameter=300)
print(sensorindex)
-19
View File
@@ -1,19 +0,0 @@
from app.algorithms.simulation.scenarios import (
convert_to_local_unit,
burst_analysis,
valve_close_analysis,
flushing_analysis,
contaminant_simulation,
age_analysis,
pressure_regulation,
)
__all__ = [
"convert_to_local_unit",
"burst_analysis",
"valve_close_analysis",
"flushing_analysis",
"contaminant_simulation",
"age_analysis",
"pressure_regulation",
]
-871
View File
@@ -1,871 +0,0 @@
import numpy as np
from app.services.tjnetwork import (
ChangeSet,
close_project,
copy_project,
delete_project,
get_pattern,
get_patterns,
get_pump,
get_reservoir,
get_status,
get_tank,
get_time,
have_project,
is_project_open,
open_project,
read_all,
run_project,
set_pattern,
set_status,
set_tank,
set_time,
)
# from get_real_status import *
from datetime import datetime,timedelta
from math import modf
import json
import pytz
import requests
import time
import app.services.project_info as project_info
from app.services.time_api import parse_clock_duration_seconds
url_path = 'http://10.101.15.16:9000/loong' # 内网
# url_path = 'http://183.64.62.100:9057/loong' # 外网
url_real = url_path + '/api/mpoints/realValue'
url_hist = url_path + '/api/curves/data'
PATTERN_TIME_STEP=15.0
DN_900_ID='2498'
DN_500_ID='3854'
DN_1000_ID='3853'
H_RESSURE='2510'
L_PRESURE='2514'
H_TANK='4780'
L_TANK='4854'
H_REGION_1='SA_ZBBDJSCP000002'
H_REGION_2='' #to do
L_REGION_1='SA_ZBBDTJSC000001'
L_REGION_2='SA_R00003'
# reservoir basic height
RESERVOIR_BASIC_HEIGHT = float(250.35)
# regions
regions = ['hp', 'lp']
regions_demand_patterns = {'hp': ['DN900', 'DN500'], 'lp': ['DN1000']} # 出厂水量近似表示用水量
regions_patterns = {'hp': ['ChuanYiJiXiao', 'BeiQuanHuaYuan', 'ZhuangYuanFuDi', 'JingNingJiaYuan',
'308', 'JiaYinYuan', 'XinChengGuoJi', 'YiJingBeiChen', 'ZhongYangXinDu',
'XinHaiJiaYuan', 'DongFengJie', 'DingYaXinYu', 'ZiYunTai', 'XieMaGuangChang',
'YongJinFu', 'BianDianZhan', 'BeiNanDaDao', 'TianShengLiJie', 'XueYuanXiaoQu',
'YunHuaLu', 'GaoJiaQiao', 'LuZuoFuLuXiaDuan', 'TianRunCheng', 'CaoJiaBa',
'PuLingChang', 'QiLongXiaoQu', 'TuanXiao',
'TuanShanBaoZhongShiHua', 'XieMa', 'BeiWenQuanJiuHaoErQi', 'LaiYinHuSiQi',
'DN500', 'DN900'],
'lp': ['PanXiMingDu', 'WanKeJinYuHuaFuGaoCeng', 'KeJiXiao',
'LuGouQiao', 'LongJiangHuaYuan', 'LaoQiZhongDui', 'ShiYanCun', 'TianQiDaSha',
'TianShengPaiChuSuo', 'TianShengShangPin', 'JiaoTang', 'RenMinHuaYuan',
'TaiJiBinJiangYiQi', 'TianQiHuaYuan', 'TaiJiBinJiangErQi', '122Zhong',
'WanKeJinYuHuaFuYangFang', 'ChengBeiCaiShiKou', 'WenXingShe', 'YueLiangTianBBGJCZ',
'YueLiangTian', 'YueLiangTian200', 'ChengTaoChang', 'HuoCheZhan', 'LiangKu', 'QunXingLu',
'JiuYuanErTongYiYuan', 'TangDouHua', 'TaiJiBinJiangErQi(SanJi)',
'ZhangDouHua', 'JinYunXiaoQuDN400',
'DN1000']}
# nodes
monitor_single_patterns = ['ChuanYiJiXiao', 'BeiQuanHuaYuan', 'ZhuangYuanFuDi', 'JingNingJiaYuan',
'308', 'JiaYinYuan', 'XinChengGuoJi', 'YiJingBeiChen', 'ZhongYangXinDu',
'XinHaiJiaYuan', 'DongFengJie', 'DingYaXinYu', 'ZiYunTai', 'XieMaGuangChang',
'YongJinFu', 'PanXiMingDu', 'WanKeJinYuHuaFuGaoCeng', 'KeJiXiao',
'LuGouQiao', 'LongJiangHuaYuan', 'LaoQiZhongDui', 'ShiYanCun', 'TianQiDaSha',
'TianShengPaiChuSuo', 'TianShengShangPin', 'JiaoTang', 'RenMinHuaYuan',
'TaiJiBinJiangYiQi', 'TianQiHuaYuan', 'TaiJiBinJiangErQi', '122Zhong',
'WanKeJinYuHuaFuYangFang']
monitor_single_patterns_id = {'ChuanYiJiXiao': '7338', 'BeiQuanHuaYuan': '7315', 'ZhuangYuanFuDi': '7316',
'JingNingJiaYuan': '7528', '308': '8272', 'JiaYinYuan': '7304',
'XinChengGuoJi': '7325', 'YiJingBeiChen': '7328', 'ZhongYangXinDu': '7329',
'XinHaiJiaYuan': '9138', 'DongFengJie': '7302', 'DingYaXinYu': '7331',
'ZiYunTai': '7420,9059', 'XieMaGuangChang': '7326', 'YongJinFu': '9059',
'PanXiMingDu': '7320', 'WanKeJinYuHuaFuGaoCeng': '7419',
'KeJiXiao': '7305', 'LuGouQiao': '7306', 'LongJiangHuaYuan': '7318',
'LaoQiZhongDui': '9075', 'ShiYanCun': '7309', 'TianQiDaSha': '7323',
'TianShengPaiChuSuo': '7335', 'TianShengShangPin': '7324', 'JiaoTang': '7332',
'RenMinHuaYuan': '7322', 'TaiJiBinJiangYiQi': '7333', 'TianQiHuaYuan': '8235',
'TaiJiBinJiangErQi': '7334', '122Zhong': '7314', 'WanKeJinYuHuaFuYangFang': '7418'}
monitor_unity_patterns = ['BianDianZhan', 'BeiNanDaDao', 'TianShengLiJie', 'XueYuanXiaoQu',
'YunHuaLu', 'GaoJiaQiao', 'LuZuoFuLuXiaDuan', 'TianRunCheng',
'CaoJiaBa', 'PuLingChang', 'QiLongXiaoQu', 'TuanXiao',
'ChengBeiCaiShiKou', 'WenXingShe', 'YueLiangTianBBGJCZ',
'YueLiangTian', 'YueLiangTian200',
'ChengTaoChang', 'HuoCheZhan', 'LiangKu', 'QunXingLu',
'TuanShanBaoZhongShiHua', 'XieMa', 'BeiWenQuanJiuHaoErQi', 'LaiYinHuSiQi',
'JiuYuanErTongYiYuan', 'TangDouHua', 'TaiJiBinJiangErQi(SanJi)',
'ZhangDouHua', 'JinYunXiaoQuDN400',
'DN500', 'DN900', 'DN1000']
monitor_unity_patterns_id = {'BianDianZhan': '7339', 'BeiNanDaDao': '7319', 'TianShengLiJie': '8242',
'XueYuanXiaoQu': '7327', 'YunHuaLu': '7312', 'GaoJiaQiao': '7340',
'LuZuoFuLuXiaDuan': '7343', 'TianRunCheng': '7310', 'CaoJiaBa': '7300',
'PuLingChang': '7307', 'QiLongXiaoQu': '7321', 'TuanXiao': '8963',
'ChengBeiCaiShiKou': '7330', 'WenXingShe': '7311',
'YueLiangTianBBGJCZ': '7313', 'YueLiangTian': '7313', 'YueLiangTian200': '7313',
'ChengTaoChang': '7301', 'HuoCheZhan': '7303',
'LiangKu': '7296', 'QunXingLu': '7308',
'DN500': '3854', 'DN900': '2498', 'DN1000': '3853'}
monitor_patterns = monitor_single_patterns + monitor_unity_patterns
monitor_patterns_id = {**monitor_single_patterns_id, **monitor_unity_patterns_id}
# pumps
pumps_name = ['1#', '2#', '3#', '4#', '5#', '6#', '7#']
pumps = ['PU00000', 'PU00001', 'PU00002', 'PU00003', 'PU00004', 'PU00005', 'PU00006']
variable_frequency_pumps = ['PU00004', 'PU00005', 'PU00006']
pumps_id = {'PU00000': '2747', 'PU00001': '2776', 'PU00002': '2730', 'PU00003': '2787',
'PU00004': '2500', 'PU00005': '2502', 'PU00006': '2504'}
# reservoirs
reservoirs = ['ZBBDJSCP000002', 'R00003']
reservoirs_id = {'ZBBDJSCP000002': '2497', 'R00003': '2571'}
# tanks
tanks = ['ZBBDTJSC000002', 'ZBBDTJSC000001']
tanks_id = {'ZBBDTJSC000002': '4780', 'ZBBDTJSC000001': '9774'}
class DataLoader:
"""数据加载器"""
def __init__(self, project_name, start_time: datetime, end_time: datetime,
pumps_control: dict = None, tank_initial_level_control: dict = None,
region_demand_control: dict = None, downloading_prohibition: bool = False):
self.project_name = project_name # 数据库名
self.current_time = self.round_time(datetime.now(pytz.timezone('Asia/Shanghai')), 1) # 圆整至整分钟
self.current_round_time = self.round_time(self.current_time, int(PATTERN_TIME_STEP))
self.updating_data_flag = True \
if self.current_round_time == self.round_time(start_time, int(PATTERN_TIME_STEP)) \
else False # 判断是否从当前时刻开始模拟(是否更新最新监测数据)
self.downloading_prohibition = downloading_prohibition # 是否禁止下载数据(默认False: 允许下载)
self.updating_data_flag = False if self.downloading_prohibition else self.updating_data_flag
self.pattern_start_index = get_pattern_index(
self.round_time(start_time, int(PATTERN_TIME_STEP)).strftime("%Y-%m-%d %H:%M:%S")) # pattern起始索引
self.pattern_end_index = get_pattern_index(
self.round_time(end_time, int(PATTERN_TIME_STEP)).strftime("%Y-%m-%d %H:%M:%S")) # pattern结束索引
self.pattern_index_list = list(range(self.pattern_start_index, self.pattern_end_index + 1)) # pattern索引列表
self.download_id = self.get_download_id() # 数据下载接口id '7338,7315,7316,...'
self.current_time_download_data = dict(
zip(self.download_id.split(','),
[np.nan]*len(list(self.download_id.split(','))))
) # {id(str): value(float)}
self.current_time_download_data_flag = dict(
zip(self.download_id.split(','),
[False]*len(list(self.download_id.split(','))))
) # 下载数据是否具备实时性, {id(str): flag(bool)}
self.old_flow_data = self.init_dict_of_list(dict(
zip(monitor_patterns,
[[np.nan]] * (len(monitor_patterns)))
)) # {pattern_name(str): flow(float)}
self.old_pattern_factor = self.init_dict_of_list(dict(
zip(monitor_patterns,
[[np.nan]] * (len(monitor_patterns)))
)) # {pattern_name(str): [pattern_factor(float)]}
self.new_flow_data = self.init_dict_of_list(dict(
zip(monitor_patterns,
[[np.nan]] * (len(monitor_patterns)))
)) # {pattern_name(str): flow(float)}
self.new_pattern_factor = self.init_dict_of_list(dict(
zip(monitor_patterns,
[[np.nan]] * (len(monitor_patterns)))
)) # {pattern_name(str): [pattern_factor(float)]}
self.reservoir_data = dict(zip(reservoirs, [np.nan]*len(reservoirs))) # {reservoir_name(str): level(float)}
self.tank_data = dict(zip(tanks, [np.nan] * len(tanks))) # {tank_name(str): level(float)}
self.pump_data = self.init_dict_of_list(
dict(zip(pumps, [[np.nan]]*len(pumps)))) # {pump_name(str): [frequency(float)]}
self.pump_control = pumps_control # {pump_name(str): [frequency(float)]}
self.tank_initial_level_control = tank_initial_level_control # {tank_name(str): level(float)}
self.region_demand_current = dict(zip(regions, [0]*len(regions))) # {region_name(str): total_demand(float)}
self.region_demand_control = region_demand_control # {region_name(str): total_demand(float)}
self.region_demand_control_factor = dict(
zip(regions, [1]*len(regions))) # 区域流量控制系数(用于调整用水量), {region_name(str): factor(float)}
def load_data(self):
"""生成数据集"""
self.download_data() # 下载实时数据
self.get_old_pattern_and_flow() # 读取历史记录pattern信息
self.cal_demand_convert_factor() # 计算用水量转换系数(设定用水量时)
self.set_new_flow() # 设置'更新'流量
self.set_new_pattern_factor() # 设置'更新'pattern factors
self.set_reservoirs() # 设置清水池
self.set_tanks() # 设置调节池
self.set_pumps() # 设置水泵
return self.pattern_start_index
def download_data(self):
"""下载数据"""
if self.updating_data_flag is True:
print('{} -- Start downloading data.'.format(
datetime.now().strftime('%Y-%m-%d %H:%M:%S')))
data_wait_flag = True
while data_wait_flag:
try:
newest_data_time = self.download_real_data(self.download_id) # 获取实时数据
except Exception as e:
print('{}\nWaiting for real data.'.format(e))
time.sleep(1)
else:
print('{} -- Downloading data ok. Newest timestamp: {}.'.format(
datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
newest_data_time.strftime('%Y-%m-%d %H:%M:%S')))
data_wait_flag = False
def cal_current_region_demand(self):
"""计算区域当前用水量"""
if self.updating_data_flag is True:
for region in self.region_demand_current.keys():
total_demand = 0
for pipe in regions_demand_patterns[region]:
total_demand += self.current_time_download_data[monitor_patterns_id[pipe]] # 出厂流量
self.region_demand_current[region] = total_demand
def cal_history_region_demand(self, pattern_index_list):
"""计算区域历史用水量(对应记录的pattern)"""
old_demand = {}
for region in regions:
total_demand = 0
for pipe_pattern_name in regions_demand_patterns[region]:
old_flows, old_patterns = self.get_history_pattern_info(self.project_name, pipe_pattern_name)
for idx in pattern_index_list:
total_demand += old_flows[idx] / 4 # 15分钟水量
old_demand[region] = total_demand
return old_demand
def cal_demand_convert_factor(self):
"""计算用水量转换系数(设定用水量时)"""
self.cal_current_region_demand() # 计算区域当前时刻用水量
old_demand_moment = self.cal_history_region_demand([self.pattern_start_index]) # 计算区域目标时刻总用水量
old_demand_period = self.cal_history_region_demand(self.pattern_index_list) # 计算区域目标时段总用水量
for region in regions:
self.region_demand_control_factor[region] \
= (self.region_demand_current[region] / 4) / old_demand_moment[region] \
if self.updating_data_flag is True else 1
self.region_demand_control_factor[region] = self.region_demand_control[region] / old_demand_period[region] \
if (self.region_demand_control is not None) and (region in self.region_demand_control.keys()) \
else self.region_demand_control_factor[region]
def get_old_pattern_and_flow(self):
"""获取所有pattern的选定时段的历史记录的pattern和flow"""
for idx in monitor_patterns: # 遍历patterns
old_flows, old_patterns = self.get_history_pattern_info(self.project_name, idx)
for pattern_idx in self.pattern_index_list:
old_flow_data = old_flows[pattern_idx]
old_pattern_factor = old_patterns[pattern_idx]
if pattern_idx == self.pattern_start_index: # 起始时刻
self.old_flow_data[idx][0] = old_flow_data
self.old_pattern_factor[idx][0] = old_pattern_factor
else:
self.old_flow_data[idx].append(old_flow_data)
self.old_pattern_factor[idx].append(old_pattern_factor)
def set_new_flow(self):
"""计算模拟时段新流量(相较于历史记录)"""
for idx in self.new_flow_data.keys(): # 遍历patterns
region_name = None
for region in regions_patterns.keys():
if idx in regions_patterns[region]:
region_name = region # pattern所属分区
break
# 实时流量
if self.updating_data_flag is True:
if idx in monitor_unity_patterns[-3:]: # 出水管流量
self.new_flow_data[idx][0] = self.current_time_download_data[monitor_patterns_id[idx]]
else: # 其余流量
self.new_flow_data[idx][0] \
= self.region_demand_control_factor[region_name] * self.old_flow_data[idx][0]
# if idx == 'ZiYunTai':
# idx_a, idx_b = monitor_patterns_id[idx].split(',')
# self.new_flow_data[idx][0] \
# = self.current_time_download_data[idx_a] - self.current_time_download_data[idx_b]
# else:
# self.new_flow_data[idx][0] = self.current_time_download_data[monitor_patterns_id[idx]]
# for data_id in monitor_patterns_id[idx].split(','):
# if (self.current_time_download_data_flag[data_id] is False) \
# and (idx not in [pipe for pipe_list in regions_demand_patterns.values()
# for pipe in pipe_list]): # 无法获取实时数据
# self.new_flow_data[idx][0] \
# = self.region_demand_control_factor[region_name] * self.old_flow_data[idx][0]
# break
# 根据设定用水量修改新流量
if (self.region_demand_control is not None) \
and (region_name in self.region_demand_control.keys()):
for pattern_idx in self.pattern_index_list:
if pattern_idx == self.pattern_start_index: # 起始时刻
self.new_flow_data[idx][0] \
= self.region_demand_control_factor[region_name] * self.old_flow_data[idx][0]
else:
self.new_flow_data[idx].append(
self.region_demand_control_factor[region_name]
* self.old_flow_data[idx][self.pattern_index_list.index(pattern_idx)]
)
def set_new_pattern_factor(self):
"""更新计算选定时段(设定用水量)/时刻的pattern factor"""
pattern_index_list = self.pattern_index_list \
if self.region_demand_control is not None \
else [self.pattern_start_index]
for idx in monitor_patterns: # 遍历patterns
for pattern_idx in pattern_index_list: # 遍历需要修改的pattern(index)
pattern_idx_cls = pattern_index_list.index(pattern_idx) # 转换index(类表存储结构)
old_flow_data = self.old_flow_data[idx][pattern_idx_cls]
old_pattern_factor = self.old_pattern_factor[idx][pattern_idx_cls]
if pattern_idx_cls == 0: # 起始时刻
if idx in monitor_single_patterns:
if not np.isnan(self.new_flow_data[idx][0]):
self.new_pattern_factor[idx][0] = (self.new_flow_data[idx][0] * 1000 / 3600) # m3/h to L/s
if idx in monitor_unity_patterns:
if not np.isnan(self.new_flow_data[idx][0]):
self.new_pattern_factor[idx][0] \
= old_pattern_factor * self.new_flow_data[idx][0] / old_flow_data
else:
if idx in monitor_single_patterns:
if len(self.new_flow_data[idx]) > pattern_idx_cls:
self.new_pattern_factor[idx].append(
(self.new_flow_data[idx][pattern_idx_cls] * 1000 / 3600)) # m3/h to L/s
if idx in monitor_unity_patterns:
if len(self.new_flow_data[idx]) > pattern_idx_cls:
self.new_pattern_factor[idx].append(
old_pattern_factor
* self.new_flow_data[idx][pattern_idx_cls]
/ old_flow_data)
def set_reservoirs(self):
"""设置清水池"""
if self.updating_data_flag is True:
for idx in self.reservoir_data.keys():
if self.current_time_download_data_flag[reservoirs_id[idx]] is False: # 无法获取实时数据
print('There is no current data of reservoir: {}.'.format(idx))
else:
self.reservoir_data[idx] \
= self.current_time_download_data[reservoirs_id[idx]] + RESERVOIR_BASIC_HEIGHT
def set_tanks(self):
"""设置调节池"""
for idx in self.tank_data.keys():
if self.updating_data_flag is True:
if self.current_time_download_data_flag[tanks_id[idx]] is False: # 无法获取实时数据
print('There is no current data of tank: {}.'.format(idx))
else:
self.tank_data[idx] = self.current_time_download_data[tanks_id[idx]]
self.tank_data[idx] = self.tank_initial_level_control[idx] \
if (self.tank_initial_level_control is not None) and (idx in self.tank_initial_level_control) \
else self.tank_data[idx]
def set_pumps(self):
"""设置水泵"""
for idx in self.pump_data.keys():
if self.updating_data_flag is True:
if self.current_time_download_data_flag[pumps_id[idx]] is False: # 无法获取实时数据
print('There is no current data of pump: {}.'.format(idx))
if (self.pump_control is not None) and (idx in self.pump_control.keys()):
self.pump_data[idx] = self.pump_control[idx]
else:
self.pump_data[idx] = [self.current_time_download_data[pumps_id[idx]]]
if (self.pump_control is not None) and (idx in self.pump_control.keys()):
self.pump_data[idx] = self.pump_data[idx] + self.pump_control[idx] \
if len(self.pump_control[idx]) < len(self.pattern_index_list) \
else self.pump_control[idx] # 水泵设定
else:
if (self.pump_control is not None) and (idx in self.pump_control.keys()):
self.pump_data[idx] = self.pump_control[idx]
self.pump_data[idx] \
= list(np.array(self.pump_data[idx]) / 50) \
if idx in variable_frequency_pumps else self.pump_data[idx]
def set_valves(self):
"""设置阀门"""
pass
def download_real_data(self, ids: str):
"""加载实时数据"""
# 数据接口的地址
global url_real
# 设置GET请求的参数
params = {'ids': ids}
# 发送GET请求获取数据
response = requests.get(url_real, params=params)
# 检查响应状态码,200表示请求成功
if response.status_code == 200:
newest_data_time = None # 下载记录数据的最新时间
# 解析响应的JSON数据
data = response.json()
for realValue in data: # 取出逐个id的数据
data_time = convert_utc_to_bj(realValue['datadt']) # datetime
self.current_time_download_data[str(realValue['id'])] \
= float(realValue['realValue']) # {id(str): value(float)}
if data_time > self.current_round_time.replace(tzinfo=None) - timedelta(minutes=5): # 下载数据为实时数据
self.current_time_download_data_flag[str(realValue['id'])] = True
if newest_data_time is None:
newest_data_time = data_time
else:
newest_data_time = data_time if data_time > newest_data_time else newest_data_time # 更新最新时间
if newest_data_time <= self.current_round_time.replace(tzinfo=None) - timedelta(minutes=5): # 最新记录时间早于当前时间
warning_text = 'There is no current data with newest timestamp: {}.'.format(
newest_data_time.strftime('%Y-%m-%d %H:%M:%S'))
delta_time = self.current_round_time.replace(tzinfo=None) - newest_data_time
if delta_time < timedelta(minutes=PATTERN_TIME_STEP): # 时间接近(可等待再次下载)
raise Exception(warning_text)
else:
print(warning_text)
self.updating_data_flag = False
else:
for idx in monitor_unity_patterns[-3:]: # 出水管流量
if self.current_time_download_data_flag[monitor_patterns_id[idx]] is False: # 无法获取出水管流量的实时数据
print('There is no current data of outflow: {}.'.format(idx))
self.updating_data_flag = False
if self.updating_data_flag is False:
print('Abandon updating data with downloaded data.')
return newest_data_time
else:
# 如果请求不成功,打印错误信息
print("请求失败,状态码:", response.status_code)
raise ConnectionError('Cannot download data.')
@ staticmethod
def init_dict_of_list(dict_of_list):
"""初始化值为列表的字典(重新生成列表地址, 防止指向同一列表)"""
for idx in dict_of_list.keys():
dict_of_list[idx] = dict_of_list[idx].copy()
return dict_of_list
@ staticmethod
def get_download_id():
"""生成下载数据项的id"""
# id_list = (list(monitor_single_patterns_id.values())
# + list(monitor_unity_patterns_id.values())
# + list(tanks_id.values())
# + list(reservoirs_id.values())
# + list(pumps_id.values()))
id_list = (list(monitor_unity_patterns_id.values())[-3:]
+ list(tanks_id.values())
+ list(reservoirs_id.values())
+ list(pumps_id.values()))
id_list = sorted(set(id_list), key=id_list.index)
if None in id_list:
id_list.remove(None)
return ','.join(id_list)
@ staticmethod
def get_history_pattern_info(project_name, pattern_name):
"""读取选定pattern的保存的历史pattern信息(flow, factor)"""
factors_list = []
flow_list = []
patterns_info = read_all(
project_name,
"select flow, factor from network.pattern_flow_samples "
"where pattern_id = %s order by sequence_no",
(pattern_name,),
)
for item in patterns_info:
flow_list.append(float(item['flow']))
factors_list.append(float(item['factor']))
return flow_list, factors_list
@ staticmethod
def judge_time(current_time, time_index_list):
"""时间判断"""
current_index \
= time_index_list.index(current_time) if (current_time in time_index_list) else None
return current_index
@staticmethod
def get_time_index_list(start_time: datetime, end_time: datetime, step: int):
"""生成时间索引"""
time_index_list = [] # 时间索引[str]
time_index = start_time
while time_index <= end_time:
time_index_list.append(time_index)
time_index += timedelta(minutes=step)
return time_index_list
@ staticmethod
def round_time(time_: datetime, interval=5):
"""时间向下取整到整n分钟(北京时间): 四舍六入五留双/向下取整"""
# return datetime.fromtimestamp(round(time_.timestamp() / (60 * interval)) * (60 * interval))
return datetime.fromtimestamp(int((time_.timestamp()) // (60 * interval)) * (60 * interval))
def convert_utc_to_bj(utc_time_str):
"""将utc时间(str)转换成北京时间(datetime)"""
# 解析UTC时间字符串为datetime对象
utc_time = datetime.strptime(utc_time_str, '%Y-%m-%dT%H:%M:%SZ')
# 设定UTC时区
utc_timezone = pytz.timezone('UTC')
# 转换为北京时间
beijing_timezone = pytz.timezone('Asia/Shanghai')
beijing_time = utc_time.replace(tzinfo=utc_timezone).astimezone(beijing_timezone).replace(tzinfo=None)
return beijing_time
def get_datetime(cur_datetime:str):
str_format = "%Y-%m-%d %H:%M:%S"
return datetime.strptime(cur_datetime, str_format)
def get_strftime(cur_datetime: datetime):
str_format = "%Y-%m-%d %H:%M:%S"
return cur_datetime.strftime(str_format)
def step_time(cur_datetime:str, step=5):
str_format="%Y-%m-%d %H:%M:%S"
dt=datetime.strptime(cur_datetime,str_format)
dt=dt+timedelta(minutes=step)
return datetime.strftime(dt,str_format)
def get_pattern_index(cur_datetime:str)->int:
str_format="%Y-%m-%d %H:%M:%S"
dt=datetime.strptime(cur_datetime,str_format)
hr=dt.hour
mnt=dt.minute
i=int((hr*60+mnt)/PATTERN_TIME_STEP)
return i
def get_pattern_index_str(cur_datetime:str)->str:
i=get_pattern_index(cur_datetime)
[minN,hrN]=modf(i*PATTERN_TIME_STEP/60)
minN_str=str(int(minN*60))
minN_str=minN_str.zfill(2)
hrN_str=str(int(hrN))
hrN_str=hrN_str.zfill(2)
str_i='{}:{}:00'.format(hrN_str,minN_str)
return str_i
def from_seconds_to_clock (secs: int)->str:
hrs=int(secs/3600)
minutes=int((secs-hrs*3600)/60)
seconds=(secs-hrs*3600-minutes*60)
hrs_str=str(hrs).zfill(2)
minutes_str=str(minutes).zfill(2)
seconds_str=str(seconds).zfill(2)
str_clock='{}:{}:{}'.format(hrs_str,minutes_str,seconds_str)
return str_clock
def from_clock_to_seconds (clock: str)->int:
str_format="%Y-%m-%d %H:%M:%S"
dt=datetime.strptime(clock,str_format)
hr=dt.hour
mnt=dt.minute
seconds=dt.second
return hr*3600+mnt*60+seconds
def from_clock_to_seconds_2 (clock: str)->int:
return parse_clock_duration_seconds(clock)
def from_clock_to_seconds_3 (clock: str)->int:
return parse_clock_duration_seconds(clock)
###convert datetimestring
##"XXXX-XX-XXT00:00:00Z" ->"XXXX-XX-XX 00:00:00"
def trim_time_flag(url_date_time:str)->str:
str_datetime=str.replace(url_date_time,'T',' ')
str_datetime=str.replace(str_datetime,'Z','')
return str_datetime
# 单时间步长模拟
def run_simulation(name:str,start_datetime:str,end_datetime:str=None, duration:int=900)->str:
if(is_project_open(name)):
close_project(name)
open_project(name)
#get_current_data(cur_datetime)
#extract the patternindex from datetime
#e.g. 0: the first time step for 00:00-00:14; 1: the second step for 00:15-00:30
start_datetime=trim_time_flag(start_datetime)
if(end_datetime!=None):
end_datetime=trim_time_flag(end_datetime)
## redistribute the basedemand according to the currentTotalQ and the base_totalQ
# step 1. get_real _data
if end_datetime==None or start_datetime==end_datetime:
end_datetime=step_time(start_datetime)
# # ids=['2498','3854','3853','2510','2514','4780','4854']
# # real_data=get_real_data(ids,start_datetime,end_datetime)
# print(datetime.now(pytz.timezone('Asia/Shanghai')).strftime("%Y-%m-%d %H:%M:%S")+"--获取实时数据完毕\n")
# #step 2. re-distribute the real q to base demand of the node region_sa by region_sa
# regions=get_all_service_area_ids(name)
# total_demands={}
# for region in regions:
# total_demands[region]=get_total_base_demand(name,region)
# region_demand_factor={}
# #Region_ID:SA_ZBBDJSCP000002 高区;SA_R00003+SA_ZBBDTJSC000001 低区
# H_region_real_demands=real_data[DN_900_ID][start_datetime]+real_data[DN_500_ID][start_datetime]
# L_region_real_demands=real_data[DN_1000_ID][start_datetime]
# factor_H_zone=H_region_real_demands/total_demands[H_REGION_1]/3.6 #3.6: m3/h->L/s
# factor_L_zone=L_region_real_demands/(total_demands[L_REGION_1]+total_demands[L_REGION_2])/3.6
# print(datetime.now(pytz.timezone('Asia/Shanghai')).strftime("%Y-%m-%d %H:%M:%S")+"--流量因子计算完毕完毕\n")
# for region in regions:
# region_nodes=get_nodes_in_region(name,region)
# factor=1
# if region==H_REGION_1 or H_REGION_2:
# factor=factor_H_zone
# else:
# factor=factor_L_zone
#
# for node in region_nodes:
# d=get_demand(name,node)
# for r in d['demands']:
# r['demand']=factor*r['demand']
# cs=ChangeSet()
# cs.append(d)
# set_demand(name,cs)
#
# #
# #
# print(datetime.now(pytz.timezone('Asia/Shanghai')).strftime("%Y-%m-%d %H:%M:%S")+"--节点流量重分配完毕\n")
#step 3. set pattern index to the current time,and set duration to 300 secs
#
str_pattern_start=get_pattern_index_str(start_datetime)
dic_time=get_time(name)
dic_time['PATTERN START']=str_pattern_start
if duration !=None:
dic_time['DURATION']=from_seconds_to_clock(duration)
else:
dic_time['DURATION']=dic_time['HYDRAULIC TIMESTEP']
cs=ChangeSet()
cs.operations.append(dic_time)
set_time(name,cs)
# step4. run simulation and save the result to name-time.out for download
#inp_file = 'inp\\'+name+'.inp'
#db_name=name
#dump_inp(db_name,inp_file,'2')
# result=run_inp(db_name)
result=run_project(name)
#json string format
# simulation_result, output, report
result_data=json.loads(result)
#print(result_data['simulation_result'])
print(datetime.now(pytz.timezone('Asia/Shanghai')).strftime("%Y-%m-%d %H:%M:%S")+'run finished successfully\n')
#print(result_data['report'])
return result
# 在线模拟
def run_simulation_ex(name: str, simulation_type: str, start_datetime: str,
end_datetime: str = None, duration: int = 0,
pump_control: dict[str, list] = None, tank_initial_level_control: dict[str, float] = None,
region_demand_control: dict[str, float] = None, valve_control: dict[str, dict] = None,
downloading_prohibition: bool = False) -> str:
time_cost_start = time.perf_counter()
print('{} -- Hydraulic simulation started.'.format(
datetime.now(pytz.timezone('Asia/Shanghai')).strftime('%Y-%m-%d %H:%M:%S')))
if is_project_open(name):
close_project(name)
if simulation_type.upper() == 'REALTIME': # 实时模拟(修改原数据库)
name_c = name
elif simulation_type.upper() == 'EXTENDED': # 扩展模拟(复制数据库)
name_c = '_'.join([name, 'c'])
if have_project(name_c):
if is_project_open(name_c):
close_project(name_c)
delete_project(name_c)
copy_project(name, name_c) # 备份项目
else:
raise Exception('Incorrect simulation type, choose in (realtime, extended)')
open_project(name_c)
# 时间处理
# extract the pattern index from datetime
# e.g. 0: the first time step for 00:00-00:14; 1: the second step for 00:15-00:30
# start_datetime = get_strftime(convert_utc_to_bj(start_datetime))
start_datetime = trim_time_flag(start_datetime)
if end_datetime is not None:
# end_datetime = get_strftime(convert_utc_to_bj(end_datetime))
end_datetime = trim_time_flag(end_datetime)
# pump name转化/输入值规范化
if pump_control is not None:
for key in list(pump_control.keys()):
pump_control[key] = [pump_control[key]] if type(pump_control[key]) is not list else pump_control[key]
pump_control[pumps[pumps_name.index(key)]] = pump_control.pop(key)
# 重新分配节点(nodes)水量
# 1) (single)base_demand_new=1, pattern_new=real_data
# 2) (unity)base_demand_new=base_demand_old, pattern_new=factor*pattern_old(factor=flow_new/flow_old)
# 获取需水量数据
# a) 历史pattern对应水量(读取保存数据库)
# b) 实时水量(数据接口下载)
# 修改node demand = 1, pattern factor *= demand(monitor single patterns对应node)
# nodes = get_nodes(name_c) # nodes
# for node_name in nodes: # 遍历nodes
# demands_dict = get_demand(name_c, node_name) # {'demands':[{'demand':, 'pattern':}]}
# for demands in demands_dict['demands']:
# if (demands['pattern'] in monitor_single_patterns) and (demands['demand'] != 1): # 1)
# pattern = get_pattern(name_c, demands['pattern'])
# pattern['factors'] = list(demands['demand'] * np.array(pattern['factors'])) # 修改pattern
# cs = ChangeSet()
# cs.append(pattern)
# set_pattern(name_c, cs)
# demands_dict['demands'][
# demands_dict['demands'].index(demands)
# ]['demand'] = 1 # 修改demand
# cs = ChangeSet()
# cs.append(demands_dict)
# set_demand(name_c, cs)
start_time = get_datetime(start_datetime) # datetime
end_time = get_datetime(end_datetime) \
if end_datetime is not None \
else get_datetime(start_datetime) + timedelta(seconds=duration) # datetime
# modify_pattern_start_index = get_pattern_index(start_datetime) # 待修改pattern的起始索引(int)
dataset_loader = DataLoader(project_name=name_c,
start_time=start_time, end_time=end_time,
pumps_control=pump_control, tank_initial_level_control=tank_initial_level_control,
region_demand_control=region_demand_control,
downloading_prohibition=downloading_prohibition) # 实例化数据加载器
modify_index \
= dataset_loader.load_data() # 加载数据(index: 需要修改pattern的factor index, None: 无需修改除水泵和调节池外pattern)
new_patterns \
= dataset_loader.new_pattern_factor # {name: float,} pattern factor(实时: 更新, 其他: 保持/更新(设定用水量时))
tank_init_level = dataset_loader.tank_data # {name: float,} 调节池初始液位(实时: 更新, 其他: 保持/更新(设定液位时))
reservoir_level = dataset_loader.reservoir_data # {name: float,} 水库液位(实时: 更新, 其他: 保持)
pump_freq = dataset_loader.pump_data # {name: [float,]} 水泵频率(实时: 更新, 其他: 保持/更新(设定状态时))
print(datetime.now(pytz.timezone('Asia/Shanghai')).strftime("%Y-%m-%d %H:%M:%S") + " -- Loading data ok.\n")
pattern_name_list = get_patterns(name_c) # 所有pattern
# 修改node pattern/demand
# nodes = get_nodes(name_c) # nodes
# for node_name in nodes: # 遍历nodes
# demands_dict = get_demand(name_c, node_name) # {'demands':[{'demand':, 'pattern':}]}
# for demands in demands_dict['demands']:
# if demands['pattern'] in monitor_single_patterns: # 1)
# demands_dict['demands'][
# demands_dict['demands'].index(demands)
# ]['demand'] = 1 # 修改demand
# pattern = get_pattern(name_c, demands['pattern'])
# pattern['factors'][modify_index] = flow_new[demands['pattern']] # 修改pattern
# cs = ChangeSet()
# cs.append(pattern)
# set_pattern(name_c, cs)
# if demands['pattern'] in pattern_name_list:
# pattern_name_list.remove(demands['pattern']) # 移出待修改pattern列表
# else: # 2)
# continue
# cs = ChangeSet()
# cs.append(demands_dict)
# set_demand(name_c, cs)
for pattern_name in monitor_patterns: # 遍历patterns
if not np.isnan(new_patterns[pattern_name][0]):
pattern = get_pattern(name_c, pattern_name)
pattern['factors'][modify_index:
modify_index + len(new_patterns[pattern_name])] \
= new_patterns[pattern_name]
cs = ChangeSet()
cs.append(pattern)
set_pattern(name_c, cs)
if pattern_name in pattern_name_list:
pattern_name_list.remove(pattern_name) # 移出待修改pattern列表
# 修改清水池(reservoir)液位pattern
for reservoir_name in reservoirs: # 遍历reservoirs
if (not np.isnan(reservoir_level[reservoir_name])) and (reservoir_level[reservoir_name] != 0):
reservoir_pattern = get_pattern(name_c, get_reservoir(name_c, reservoir_name)['pattern'])
reservoir_pattern['factors'][modify_index] = reservoir_level[reservoir_name]
cs = ChangeSet()
cs.append(reservoir_pattern)
set_pattern(name_c, cs)
if reservoir_pattern['id'] in pattern_name_list:
pattern_name_list.remove(reservoir_pattern['id']) # 移出待修改pattern列表
# 修改调节池(tank)初始液位
for tank_name in tanks: # 遍历tanks
if (not np.isnan(tank_init_level[tank_name])) and (tank_init_level[tank_name] != 0):
tank = get_tank(name_c, tank_name)
tank['init_level'] = tank_init_level[tank_name]
cs = ChangeSet()
cs.append(tank)
set_tank(name_c, cs)
# 修改水泵(pump)pattern
for pump_name in pumps: # 遍历pumps
if not np.isnan(pump_freq[pump_name][0]):
pump_pattern = get_pattern(name_c, get_pump(name_c, pump_name)['pattern'])
pump_pattern['factors'][modify_index
:modify_index + len(pump_freq[pump_name])] \
= pump_freq[pump_name]
cs = ChangeSet()
cs.append(pump_pattern)
set_pattern(name_c, cs)
if pump_pattern['id'] in pattern_name_list:
pattern_name_list.remove(pump_pattern['id']) # 移出待修改pattern列表
# 修改阀门(valve)status和setting
if valve_control is not None:
for valve in valve_control.keys():
status = get_status(name_c, valve)
if 'status' in valve_control[valve].keys():
status['status'] = valve_control[valve]['status']
if 'setting' in valve_control[valve].keys():
status['setting'] = valve_control[valve]['setting']
if 'k' in valve_control[valve].keys():
valve_k = valve_control[valve]['k']
if valve_k == 0:
status['status'] = 'CLOSED'
else:
status['setting'] = 0.1036 * pow(valve_k, -3.105)
cs = ChangeSet()
cs.append(status)
set_status(name_c, cs)
print('Finish demands amending, unmodified patterns: {}.'.format(pattern_name_list))
# 修改时间信息
str_pattern_start = get_pattern_index_str(
DataLoader.round_time(start_time, int(PATTERN_TIME_STEP)).strftime("%Y-%m-%d %H:%M:%S"))
dic_time = get_time(name_c)
dic_time['PATTERN START'] = str_pattern_start
if duration is not None:
dic_time['DURATION'] = from_seconds_to_clock(duration)
else:
dic_time['DURATION'] = dic_time['HYDRAULIC TIMESTEP']
cs = ChangeSet()
cs.operations.append(dic_time)
set_time(name_c, cs)
# 运行并返回结果
result = run_project(name_c)
time_cost_end = time.perf_counter()
print('{} -- Hydraulic simulation finished, cost time: {:.2f} s.'.format(
datetime.now(pytz.timezone('Asia/Shanghai')).strftime('%Y-%m-%d %H:%M:%S'),
time_cost_end - time_cost_start))
close_project(name_c)
return result
if __name__ == '__main__':
# if get_current_data()==True:
# tQ=get_current_total_Q()
# print(f"the current tQ is {tQ}\n")
# data=get_hist_data(ids,conver_beingtime_to_ucttime('2024-04-10 15:05:00'),conver_beingtime_to_ucttime('2024-04-10 15:10:00'))
# open_project("beibeizone")
# read_inp("beibeizone","beibeizone-export_nochinese.inp")
# run_simulation("beibeizone","2024-04-01T08:00:00Z")
# read_inp('bb_server', 'model20_en.inp')
run_simulation_ex(
name=project_info.name, simulation_type='extended', start_datetime='2024-11-09T02:30:00Z',
# end_datetime='2024-05-30T16:00:00Z',
# duration=0,
# pump_control={'PU00006': [45, 40]}
# region_demand_control={'hp': 6000, 'lp': 2000}
)
@@ -0,0 +1,3 @@
from app.algorithms.valve_isolation.topology_search import valve_isolation_analysis
__all__ = ["valve_isolation_analysis"]
@@ -0,0 +1,103 @@
"""Topology-only valve isolation search."""
from collections import defaultdict, deque
from typing import Any, Iterable
VALVE_LINK_TYPE = "valve"
def _parse_link_entry(link_entry: str) -> tuple[str, str, str, str]:
parts = link_entry.split(":", 3)
if len(parts) != 4:
raise ValueError(f"Invalid link entry format: {link_entry}")
return parts[0], parts[1], parts[2], parts[3]
def valve_isolation_analysis(
link_entries: Iterable[str],
accident_elements: str | list[str],
disabled_valves: list[str] | None = None,
) -> dict[str, Any]:
"""Determine boundary valves and affected nodes from a topology snapshot."""
disabled_valves_set = set(disabled_valves or [])
target_elements = (
[accident_elements]
if isinstance(accident_elements, str)
else accident_elements
)
pipe_adj: dict[str, set[str]] = defaultdict(set)
all_valves: dict[str, tuple[str, str]] = {}
link_lookup: dict[str, tuple[str, str, str]] = {}
node_set: set[str] = set()
for link_entry in link_entries:
link_id, link_type, node1, node2 = _parse_link_entry(link_entry)
link_type_name = str(link_type).lower()
link_lookup[link_id] = (node1, node2, link_type_name)
node_set.update((node1, node2))
if link_type_name == VALVE_LINK_TYPE:
all_valves[link_id] = (node1, node2)
else:
pipe_adj[node1].add(node2)
pipe_adj[node2].add(node1)
start_nodes: set[str] = set()
for element in target_elements:
if element in node_set:
start_nodes.add(element)
elif element in link_lookup:
node1, node2, _ = link_lookup[element]
start_nodes.update((node1, node2))
else:
raise ValueError(f"Accident element {element} was not found in topology")
extra_adj: dict[str, list[str]] = defaultdict(list)
boundary_valves: dict[str, tuple[str, str]] = {}
for valve_id, (node1, node2) in all_valves.items():
if valve_id in disabled_valves_set:
extra_adj[node1].append(node2)
extra_adj[node2].append(node1)
else:
boundary_valves[valve_id] = (node1, node2)
affected_nodes: set[str] = set()
queue = deque(start_nodes)
while queue:
node = queue.popleft()
if node in affected_nodes:
continue
affected_nodes.add(node)
queue.extend(pipe_adj.get(node, set()) - affected_nodes)
queue.extend(
neighbor
for neighbor in extra_adj.get(node, ())
if neighbor not in affected_nodes
)
must_close_valves: list[str] = []
optional_valves: list[str] = []
for valve_id, (node1, node2) in boundary_valves.items():
node1_affected = node1 in affected_nodes
node2_affected = node2 in affected_nodes
if node1_affected and node2_affected:
optional_valves.append(valve_id)
elif node1_affected or node2_affected:
must_close_valves.append(valve_id)
must_close_valves.sort()
optional_valves.sort()
isolatable = bool(must_close_valves)
result: dict[str, Any] = {
"accident_elements": target_elements,
"disabled_valves": disabled_valves,
"affected_nodes": sorted(affected_nodes) if isolatable else [],
"affected_node_count": len(affected_nodes),
"must_close_valves": must_close_valves,
"optional_valves": optional_valves,
"isolatable": isolatable,
}
if len(target_elements) == 1:
result["accident_element"] = target_elements[0]
return result
-19
View File
@@ -1,19 +0,0 @@
"""Water-demand distribution algorithms."""
from .service import (
calculate_demand_to_network,
calculate_demand_to_nodes,
calculate_demand_to_region,
distribute_demand_to_nodes,
distribute_demand_to_region,
get_total_base_demand,
)
__all__ = [
"calculate_demand_to_network",
"calculate_demand_to_nodes",
"calculate_demand_to_region",
"distribute_demand_to_nodes",
"distribute_demand_to_region",
"get_total_base_demand",
]
-104
View File
@@ -1,104 +0,0 @@
from app.native.wndb.commands.executor import execute_batch_command
from app.native.wndb.core.database import ChangeSet
from app.native.wndb.gis.region_geometry import Topology, get_nodes_in_region
from app.native.wndb.model.demands import get_demand
from app.native.wndb.model.elements import get_nodes, is_junction
DISTRIBUTION_TYPE_ADD = 'ADD'
DISTRIBUTION_TYPE_OVERRIDE = 'OVERRIDE'
def calculate_demand_to_nodes(name: str, demand: float, nodes: list[str]) -> dict[str, float]:
if len(nodes) == 0 or demand == 0.0:
return {}
topology = Topology(name, nodes)
t_nodes = topology.nodes()
t_links = topology.links()
length_sum = 0.0
for value in t_links.values():
length_sum += abs(value['length'])
if length_sum <= 0.0:
return {}
demand_per_length = demand / length_sum
result: dict[str, float] = {}
for node, value in t_nodes.items():
if not is_junction(name, node):
continue
demand_per_node = 0.0
for link in value['links']:
demand_per_node += abs(t_links[link]['length']) * demand_per_length * 0.5
result[node] = demand_per_node
return result
def calculate_demand_to_region(name: str, demand: float, region: str) -> dict[str, float]:
nodes = get_nodes_in_region(name, region)
return calculate_demand_to_nodes(name, demand, nodes)
def calculate_demand_to_network(name: str, demand: float) -> dict[str, float]:
nodes = get_nodes(name)
return calculate_demand_to_nodes(name, demand, nodes)
def distribute_demand_to_nodes(name: str, demand: float, nodes: list[str], type: str = DISTRIBUTION_TYPE_ADD) -> ChangeSet:
if len(nodes) == 0 or demand == 0.0:
return ChangeSet()
if type != DISTRIBUTION_TYPE_ADD and type != DISTRIBUTION_TYPE_OVERRIDE:
return ChangeSet()
topology = Topology(name, nodes)
t_nodes = topology.nodes()
t_links = topology.links()
length_sum = 0.0
for value in t_links.values():
length_sum += abs(value['length'])
if length_sum <= 0.0:
return ChangeSet()
demand_per_length = demand / length_sum
cs = ChangeSet()
for node, value in t_nodes.items():
if not is_junction(name, node):
continue
demand_per_node = 0.0
for link in value['links']:
demand_per_node += abs(t_links[link]['length']) * demand_per_length * 0.5
ds = get_demand(name, node)['demands']
if len(ds) == 0:
ds = [{'demand': demand_per_node, 'pattern': None, 'category': None}]
elif type == DISTRIBUTION_TYPE_ADD:
ds[0]['demand'] += demand_per_node
else:
ds[0]['demand'] = demand_per_node
cs.update({'type': 'demand', 'junction': node, 'demands': ds})
return execute_batch_command(name, cs)
def distribute_demand_to_region(name: str, demand: float, region: str, type: str = DISTRIBUTION_TYPE_ADD) -> ChangeSet:
nodes = get_nodes_in_region(name, region)
return distribute_demand_to_nodes(name, demand, nodes, type)
def get_total_base_demand(name:str,region:str)->float:
nodes = get_nodes_in_region(name, region)
t_demands=0.0
for node in nodes:
if not is_junction(name, node):
continue
ds = get_demand(name, node)['demands']
t_demands= t_demands+ds[0]['demand']
return t_demands
+58
View File
@@ -0,0 +1,58 @@
"""Public process probes, separate from authenticated project diagnostics."""
import asyncio
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from sqlalchemy import text
from app.infra.db.metadb.database import engine
router = APIRouter(include_in_schema=False)
METADATA_TIMEOUT_SECONDS = 2
async def metadata_database_ready() -> bool:
try:
async with asyncio.timeout(METADATA_TIMEOUT_SECONDS):
async with engine.connect() as connection:
result = await connection.execute(text("SELECT 1"))
return result.scalar_one() == 1
except Exception:
# Public probes must not expose connection strings or driver errors.
return False
@router.get("/health")
async def health() -> dict[str, str]:
"""Check HTTP liveness without project or database dependencies."""
return {"status": "ok"}
@router.get("/ready")
async def readiness(request: Request) -> JSONResponse:
"""Check startup, metadata connectivity and the enabled MCP lifecycle."""
started = getattr(request.app.state, "startup_complete", False)
runtime = getattr(request.app.state, "mcp_runtime", None)
mcp_ready = runtime is None or runtime.ready
metadata_ready = await metadata_database_ready() if started else False
ready = started and metadata_ready and mcp_ready
return JSONResponse(
status_code=200 if ready else 503,
content={
"status": "ready" if ready else "not_ready",
"checks": {
"startup": "ready" if started else "not_ready",
"metadata_database": (
"not_checked" if not started
else "ready" if metadata_ready else "unavailable"
),
"mcp_transport": (
"disabled" if runtime is None
else "ready" if mcp_ready else "not_ready"
),
},
},
headers={"Cache-Control": "no-store"},
)
+67 -5
View File
@@ -8,6 +8,8 @@ from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from app.native.wndb.core.database import MaterializedViewRefreshAfterCommitError
class ProblemDetails(BaseModel):
"""RFC 9457 compatible error response used by the REST contract."""
@@ -20,6 +22,10 @@ class ProblemDetails(BaseModel):
code: str
trace_id: str
errors: list[dict[str, Any]] = Field(default_factory=list)
retryable: bool = False
related_refs: list[Any] = Field(default_factory=list)
recovery_action: str = "none"
retry_after_seconds: int | None = None
def _trace_id(request: Request) -> str:
@@ -34,6 +40,10 @@ def _problem_response(
detail: str,
code: str,
errors: list[dict[str, Any]] | None = None,
retryable: bool = False,
related_refs: list[Any] | None = None,
recovery_action: str = "none",
retry_after_seconds: int | None = None,
) -> JSONResponse:
problem = ProblemDetails(
type=f"https://tjwater.example/problems/{code.replace('_', '-')}",
@@ -44,6 +54,10 @@ def _problem_response(
code=code,
trace_id=_trace_id(request),
errors=errors or [],
retryable=retryable,
related_refs=related_refs or [],
recovery_action=recovery_action,
retry_after_seconds=retry_after_seconds,
)
return JSONResponse(
status_code=status_code,
@@ -53,6 +67,24 @@ def _problem_response(
def install_problem_details_handlers(app: FastAPI) -> None:
@app.exception_handler(MaterializedViewRefreshAfterCommitError)
async def materialized_view_refresh_error_handler(
request: Request,
exc: MaterializedViewRefreshAfterCommitError,
) -> JSONResponse:
response = _problem_response(
request,
status_code=503,
title="Materialized view refresh failed",
detail=(
f"Project {exc.project!r} changes were committed, but GIS query "
"views could not be refreshed. Do not repeat the write blindly."
),
code="materialized_view_refresh_failed_after_commit",
)
response.headers["X-TJWater-Changes-Committed"] = "true"
return response
@app.exception_handler(RequestValidationError)
async def validation_error_handler(
request: Request,
@@ -69,7 +101,6 @@ def install_problem_details_handlers(app: FastAPI) -> None:
@app.exception_handler(HTTPException)
async def http_error_handler(request: Request, exc: HTTPException) -> JSONResponse:
detail = exc.detail if isinstance(exc.detail, str) else str(exc.detail)
code_by_status = {
401: "unauthenticated",
403: "forbidden",
@@ -78,12 +109,43 @@ def install_problem_details_handlers(app: FastAPI) -> None:
422: "validation_error",
503: "dependency_unavailable",
}
fallback_code = code_by_status.get(exc.status_code, "request_error")
detail_payload = exc.detail if isinstance(exc.detail, dict) else None
code = (
detail_payload.get("code")
if detail_payload and isinstance(detail_payload.get("code"), str)
else fallback_code
)
detail = exc.detail if isinstance(exc.detail, str) else None
if detail_payload:
candidate = detail_payload.get("message") or detail_payload.get("detail")
if isinstance(candidate, str):
detail = candidate
if detail is None:
detail = code.replace("_", " ").title()
related_refs = detail_payload.get("related_refs", []) if detail_payload else []
if not isinstance(related_refs, list):
related_refs = []
recovery_action = (
detail_payload.get("recovery_action", "none") if detail_payload else "none"
)
if not isinstance(recovery_action, str):
recovery_action = "none"
retry_after_seconds = (
detail_payload.get("retry_after_seconds") if detail_payload else None
)
if not isinstance(retry_after_seconds, int):
retry_after_seconds = None
return _problem_response(
request,
status_code=exc.status_code,
title=code_by_status.get(exc.status_code, "request_error")
.replace("_", " ")
.title(),
title=fallback_code.replace("_", " ").title(),
detail=detail,
code=code_by_status.get(exc.status_code, "request_error"),
code=code,
retryable=(
bool(detail_payload.get("retryable", False)) if detail_payload else False
),
related_refs=related_refs,
recovery_action=recovery_action,
retry_after_seconds=retry_after_seconds,
)
+12 -1
View File
@@ -7,6 +7,7 @@ from sqlalchemy.engine.url import make_url
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from sqlalchemy.ext.asyncio import create_async_engine
from app.api.pagination import PaginatedList
from app.auth.metadata_dependencies import (
get_current_metadata_admin,
get_metadata_repository,
@@ -47,6 +48,7 @@ def _project_response(project: models.Project) -> AdminProjectResponse:
description=project.description,
gs_workspace=project.gs_workspace,
map_extent=project.map_extent,
map_config=getattr(project, "map_config", {}) or {},
status=project.status,
created_at=project.created_at,
updated_at=project.updated_at,
@@ -236,7 +238,9 @@ async def list_metadata_users(
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> List[MetadataUserResponse]:
users = await metadata_repo.list_users(skip=skip, limit=limit)
return [MetadataUserResponse.model_validate(user) for user in users]
total = await metadata_repo.count_users()
items = [MetadataUserResponse.model_validate(user) for user in users]
return PaginatedList(items, total=total)
@router.get("/admin/projects", response_model=List[AdminProjectResponse])
@@ -252,6 +256,12 @@ async def list_admin_projects(
"/admin/projects",
response_model=AdminProjectResponse,
status_code=status.HTTP_201_CREATED,
deprecated=True,
summary="仅登记已有项目元数据",
description=(
"仅用于登记已经由外部流程完整创建的资源。新项目应调用 "
"POST /admin/project-provisions。"
),
)
async def create_admin_project(
payload: AdminProjectCreateRequest,
@@ -265,6 +275,7 @@ async def create_admin_project(
description=payload.description,
gs_workspace=payload.gs_workspace,
map_extent=payload.map_extent,
map_config=payload.map_config.model_dump(exclude_unset=True),
status=payload.status,
creator_user_id=current_user.id,
)
+101
View File
@@ -0,0 +1,101 @@
"""Actor-private messaging APIs; the Agent verifies its session ownership."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from psycopg import AsyncConnection
from app.auth.permissions import OPERATIONAL_TASK_VIEW, OPERATIONAL_TASK_WRITE, require_permission
from app.auth.project_dependencies import ProjectContext, get_project_pg_connection
from app.domain.domain_errors import DomainError
from app.domain.schemas.agent_messages import (
AgentMessage, AgentMessageCreate, AgentMessageDelivery, AgentMessagePage, AgentMessageReply,
)
from app.infra.db.postgresql.agent_messages import AgentMessageRepository
from app.services.agent_messages import AgentMessageService
router = APIRouter(prefix="/agent-messages")
def _service(ctx: ProjectContext, conn: AsyncConnection) -> AgentMessageService:
return AgentMessageService(AgentMessageRepository(conn, ctx.project_id), ctx)
def _raise_domain(exc: DomainError):
raise HTTPException(status_code=exc.status, detail=exc.payload()) from exc
@router.post("", response_model=AgentMessage, status_code=201)
async def create_agent_message(
command: AgentMessageCreate,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).create(command)
except DomainError as exc:
_raise_domain(exc)
@router.get("", response_model=AgentMessagePage)
async def list_agent_messages(
session_id: str = Query(min_length=1, max_length=500),
limit: int = Query(default=50, ge=1, le=100),
cursor: str | None = None,
pending_only: bool = False,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).list_session(session_id, limit, cursor, pending_only)
except DomainError as exc:
_raise_domain(exc)
@router.get("/pending", response_model=AgentMessagePage)
async def list_pending_agent_messages(
limit: int = Query(default=50, ge=1, le=100),
cursor: str | None = None,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).pending(limit, cursor)
except DomainError as exc:
_raise_domain(exc)
@router.get("/{message_id}", response_model=AgentMessage)
async def get_agent_message(
message_id: UUID,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).get(message_id)
except DomainError as exc:
_raise_domain(exc)
@router.post("/{message_id}/reply", response_model=AgentMessage, status_code=201)
async def reply_agent_message(
message_id: UUID, command: AgentMessageReply,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).reply(message_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.post("/{message_id}/delivery", response_model=AgentMessage)
async def record_agent_message_delivery(
message_id: UUID, command: AgentMessageDelivery,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).delivery(message_id, command)
except DomainError as exc:
_raise_domain(exc)
+6 -1
View File
@@ -4,6 +4,7 @@ from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Body
from pydantic import BaseModel, Field
from starlette.concurrency import run_in_threadpool
from app.auth.keycloak_dependencies import get_current_keycloak_username
from app.services.burst_detection import (
@@ -73,6 +74,10 @@ async def detect_burst(
HTTPException: 当处理过程中发生错误时
"""
try:
return run_burst_detection(**data.model_dump(), username=username)
return await run_in_threadpool(
run_burst_detection,
**data.model_dump(),
username=username,
)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc))
+6 -1
View File
@@ -6,6 +6,7 @@ from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Body
from pydantic import BaseModel, Field
from starlette.concurrency import run_in_threadpool
from app.auth.keycloak_dependencies import get_current_keycloak_username
from app.services.burst_location import (
@@ -63,6 +64,10 @@ async def locate_burst(
HTTPException: 当数据类型或值不正确时
"""
try:
return run_burst_location_by_network(**data.model_dump(), username=username)
return await run_in_threadpool(
run_burst_location_by_network,
**data.model_dump(),
username=username,
)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc))
+13 -12
View File
@@ -1,5 +1,6 @@
from fastapi import APIRouter, Request, Query, Path, Body
from typing import Any, List, Dict, Union
from typing import Any
from fastapi import APIRouter, Body, Query
from app.services.tjnetwork import (
ChangeSet,
get_control,
@@ -13,7 +14,7 @@ from app.services.tjnetwork import (
router = APIRouter()
@router.get("/network-schemas/control", summary="获取控制架构", description="获取网络中控制对象的架构定义")
async def fastapi_get_control_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_control_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取控制架构。
返回指定网络中控制对象的属性架构定义。
@@ -21,7 +22,7 @@ async def fastapi_get_control_schema(network: str = Query(..., description="管
return get_control_schema(network)
@router.get("/controls/properties", summary="获取控制属性", description="获取指定网络中的控制属性信息")
async def fastapi_get_control_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
def fastapi_get_control_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
"""获取控制属性。
返回指定网络中的控制对象属性信息。
@@ -29,19 +30,19 @@ async def fastapi_get_control_properties(network: str = Query(..., description="
return get_control(network)
@router.patch("/controls/properties", response_model=None, summary="设置控制属性", description="更新指定网络中的控制属性")
async def fastapi_set_control_properties(
def fastapi_set_control_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置控制属性。
更新指定网络中的控制属性值。
"""
props = await req.json()
props = payload
return set_control(network, ChangeSet(props))
@router.get("/rule-schemas", summary="获取规则架构", description="获取网络中规则对象的架构定义")
async def fastapi_get_rule_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_rule_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取规则架构。
返回指定网络中规则对象的属性架构定义。
@@ -49,7 +50,7 @@ async def fastapi_get_rule_schema(network: str = Query(..., description="管网
return get_rule_schema(network)
@router.get("/rule-properties", summary="获取规则属性", description="获取指定网络中的规则属性信息")
async def fastapi_get_rule_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
def fastapi_get_rule_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
"""获取规则属性。
返回指定网络中的规则对象属性信息。
@@ -57,13 +58,13 @@ async def fastapi_get_rule_properties(network: str = Query(..., description="管
return get_rule(network)
@router.patch("/rule-properties", response_model=None, summary="设置规则属性", description="更新指定网络中的规则属性")
async def fastapi_set_rule_properties(
def fastapi_set_rule_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置规则属性。
更新指定网络中的规则属性值。
"""
props = await req.json()
props = payload
return set_rule(network, ChangeSet(props))
+14 -13
View File
@@ -1,5 +1,6 @@
from fastapi import APIRouter, Request, Query, Path, Body
from typing import Any, List, Dict, Union
from typing import Any
from fastapi import APIRouter, Body, Query
from app.services.tjnetwork import (
ChangeSet,
add_curve,
@@ -14,7 +15,7 @@ from app.services.tjnetwork import (
router = APIRouter()
@router.get("/network-schemas/curve", summary="获取曲线架构", description="获取网络中曲线对象的架构定义")
async def fastapi_get_curve_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_curve_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取曲线架构。
返回指定网络中曲线对象的属性架构定义。
@@ -22,23 +23,23 @@ async def fastapi_get_curve_schema(network: str = Query(..., description="管网
return get_curve_schema(network)
@router.post("/curves", response_model=None, summary="添加曲线", description="在网络中添加一条新的曲线")
async def fastapi_add_curve(
def fastapi_add_curve(
network: str = Query(..., description="管网名称(或数据库名称)"),
curve: str = Query(..., description="曲线ID"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""添加曲线。
在指定网络中创建一条新的曲线,并设置其初始属性。
"""
props = await req.json()
props = payload
ps = {
"id": curve,
} | props
return add_curve(network, ChangeSet(ps))
@router.delete("/curves", response_model=None, summary="删除曲线", description="从网络中删除指定的曲线")
async def fastapi_delete_curve(
def fastapi_delete_curve(
network: str = Query(..., description="管网名称(或数据库名称)"),
curve: str = Query(..., description="曲线ID")
) -> ChangeSet:
@@ -50,7 +51,7 @@ async def fastapi_delete_curve(
return delete_curve(network, ChangeSet(ps))
@router.get("/curves/properties", summary="获取曲线属性", description="获取指定曲线的属性信息")
async def fastapi_get_curve_properties(
def fastapi_get_curve_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
curve: str = Query(..., description="曲线ID")
) -> dict[str, Any]:
@@ -61,21 +62,21 @@ async def fastapi_get_curve_properties(
return get_curve(network, curve)
@router.patch("/curves/properties", response_model=None, summary="设置曲线属性", description="更新指定曲线的属性")
async def fastapi_set_curve_properties(
def fastapi_set_curve_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
curve: str = Query(..., description="曲线ID"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置曲线属性。
更新指定曲线的属性值。
"""
props = await req.json()
props = payload
ps = {"id": curve} | props
return set_curve(network, ChangeSet(ps))
@router.get("/curves", summary="获取所有曲线", description="获取网络中的所有曲线列表")
async def fastapi_get_curves(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[str]:
def fastapi_get_curves(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[str]:
"""获取所有曲线。
返回指定网络中的所有曲线ID列表。
@@ -83,7 +84,7 @@ async def fastapi_get_curves(network: str = Query(..., description="管网名称
return get_curves(network)
@router.get("/curves/existence", summary="检查曲线存在性", description="检查指定的曲线是否存在")
async def fastapi_is_curve(
def fastapi_is_curve(
network: str = Query(..., description="管网名称(或数据库名称)"),
curve: str = Query(..., description="曲线ID")
) -> bool:
+23 -22
View File
@@ -1,5 +1,6 @@
from fastapi import APIRouter, Request, Query, Path, Body
from typing import Any, List, Dict, Union
from typing import Any
from fastapi import APIRouter, Body, Query
from app.services.tjnetwork import (
ChangeSet,
get_energy,
@@ -19,7 +20,7 @@ from app.services.tjnetwork import (
router = APIRouter()
@router.get("/network-schemas/time", summary="获取时间选项架构", description="获取网络中时间选项的架构定义")
async def fastapi_get_time_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_time_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取时间选项架构。
返回指定网络中时间相关选项的属性架构定义。
@@ -27,7 +28,7 @@ async def fastapi_get_time_schema(network: str = Query(..., description="管网
return get_time_schema(network)
@router.get("/network-options/time", summary="获取时间选项属性", description="获取指定网络中的时间选项属性信息")
async def fastapi_get_time_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
def fastapi_get_time_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
"""获取时间选项属性。
返回指定网络中的时间相关选项属性。
@@ -35,19 +36,19 @@ async def fastapi_get_time_properties(network: str = Query(..., description="管
return get_time(network)
@router.patch("/time-properties", response_model=None, summary="设置时间选项属性", description="更新指定网络中的时间选项属性")
async def fastapi_set_time_properties(
def fastapi_set_time_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置时间选项属性。
更新指定网络中的时间相关选项属性值。
"""
props = await req.json()
props = payload
return set_time(network, ChangeSet(props))
@router.get("/network-schemas/energy", summary="获取能耗选项架构", description="获取网络中能耗选项的架构定义")
async def fastapi_get_energy_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_energy_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取能耗选项架构。
返回指定网络中能耗相关选项的属性架构定义。
@@ -55,7 +56,7 @@ async def fastapi_get_energy_schema(network: str = Query(..., description="管
return get_energy_schema(network)
@router.get("/network-options/energy", summary="获取能耗选项属性", description="获取指定网络中的能耗选项属性信息")
async def fastapi_get_energy_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
def fastapi_get_energy_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
"""获取能耗选项属性。
返回指定网络中的能耗相关选项属性。
@@ -63,19 +64,19 @@ async def fastapi_get_energy_properties(network: str = Query(..., description="
return get_energy(network)
@router.patch("/energy-properties", response_model=None, summary="设置能耗选项属性", description="更新指定网络中的能耗选项属性")
async def fastapi_set_energy_properties(
def fastapi_set_energy_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置能耗选项属性。
更新指定网络中的能耗相关选项属性值。
"""
props = await req.json()
props = payload
return set_energy(network, ChangeSet(props))
@router.get("/network-schemas/pump-energy", summary="获取泵能耗选项架构", description="获取网络中泵能耗选项的架构定义")
async def fastapi_get_pump_energy_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_pump_energy_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取泵能耗选项架构。
返回指定网络中泵能耗相关选项的属性架构定义。
@@ -83,7 +84,7 @@ async def fastapi_get_pump_energy_schema(network: str = Query(..., description="
return get_pump_energy_schema(network)
@router.get("/network-options/pump-energy", summary="获取泵能耗属性", description="获取指定泵的能耗属性信息")
async def fastapi_get_pump_energy_proeprties(
def fastapi_get_pump_energy_proeprties(
network: str = Query(..., description="管网名称(或数据库名称)"),
pump: str = Query(..., description="泵ID")
) -> dict[str, Any]:
@@ -94,21 +95,21 @@ async def fastapi_get_pump_energy_proeprties(
return get_pump_energy(network, pump)
@router.patch("/network-options/pump-energy", response_model=None, summary="设置泵能耗属性", description="更新指定泵的能耗属性")
async def fastapi_set_pump_energy_properties(
def fastapi_set_pump_energy_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
pump: str = Query(..., description="泵ID"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置泵能耗属性。
更新指定泵的能耗相关属性值。
"""
props = await req.json()
props = payload
ps = {"id": pump} | props
return set_pump_energy(network, ChangeSet(ps))
@router.get("/network-schemas/option", summary="获取选项架构", description="获取网络中选项对象的架构定义")
async def fastapi_get_option_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_option_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取选项架构。
返回指定网络中选项对象的属性架构定义。
@@ -116,7 +117,7 @@ async def fastapi_get_option_schema(network: str = Query(..., description="管
return get_option_v3_schema(network)
@router.get("/network-options", summary="获取选项属性", description="获取指定网络中的选项属性信息")
async def fastapi_get_option_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
def fastapi_get_option_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
"""获取选项属性。
返回指定网络中的选项对象属性信息。
@@ -124,13 +125,13 @@ async def fastapi_get_option_properties(network: str = Query(..., description="
return get_option_v3(network)
@router.patch("/network-options", response_model=None, summary="设置选项属性", description="更新指定网络中的选项属性")
async def fastapi_set_option_properties(
def fastapi_set_option_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置选项属性。
更新指定网络中的选项属性值。
"""
props = await req.json()
props = payload
return set_option_v3(network, ChangeSet(props))
+14 -13
View File
@@ -1,5 +1,6 @@
from fastapi import APIRouter, Request, Query, Path, Body
from typing import Any, List, Dict, Union
from typing import Any
from fastapi import APIRouter, Body, Query
from app.services.tjnetwork import (
ChangeSet,
add_pattern,
@@ -14,7 +15,7 @@ from app.services.tjnetwork import (
router = APIRouter()
@router.get("/network-schemas/pattern", summary="获取模式架构", description="获取网络中模式对象的架构定义")
async def fastapi_get_pattern_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_pattern_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取模式架构。
返回指定网络中模式对象的属性架构定义。
@@ -22,23 +23,23 @@ async def fastapi_get_pattern_schema(network: str = Query(..., description="管
return get_pattern_schema(network)
@router.post("/patterns", response_model=None, summary="添加模式", description="在网络中添加一个新的模式")
async def fastapi_add_pattern(
def fastapi_add_pattern(
network: str = Query(..., description="管网名称(或数据库名称)"),
pattern: str = Query(..., description="模式ID"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""添加模式。
在指定网络中创建一个新的模式,并设置其初始属性。
"""
props = await req.json()
props = payload
ps = {
"id": pattern,
} | props
return add_pattern(network, ChangeSet(ps))
@router.delete("/patterns", response_model=None, summary="删除模式", description="从网络中删除指定的模式")
async def fastapi_delete_pattern(
def fastapi_delete_pattern(
network: str = Query(..., description="管网名称(或数据库名称)"),
pattern: str = Query(..., description="模式ID")
) -> ChangeSet:
@@ -50,7 +51,7 @@ async def fastapi_delete_pattern(
return delete_pattern(network, ChangeSet(ps))
@router.get("/patterns/properties", summary="获取模式属性", description="获取指定模式的属性信息")
async def fastapi_get_pattern_properties(
def fastapi_get_pattern_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
pattern: str = Query(..., description="模式ID")
) -> dict[str, Any]:
@@ -61,21 +62,21 @@ async def fastapi_get_pattern_properties(
return get_pattern(network, pattern)
@router.patch("/patterns/properties", response_model=None, summary="设置模式属性", description="更新指定模式的属性")
async def fastapi_set_pattern_properties(
def fastapi_set_pattern_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
pattern: str = Query(..., description="模式ID"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置模式属性。
更新指定模式的属性值。
"""
props = await req.json()
props = payload
ps = {"id": pattern} | props
return set_pattern(network, ChangeSet(ps))
@router.get("/patterns/existence", summary="检查模式存在性", description="检查指定的模式是否存在")
async def fastapi_is_pattern(
def fastapi_is_pattern(
network: str = Query(..., description="管网名称(或数据库名称)"),
pattern: str = Query(..., description="模式ID")
) -> bool:
@@ -86,7 +87,7 @@ async def fastapi_is_pattern(
return is_pattern(network, pattern)
@router.get("/patterns", summary="获取所有模式", description="获取网络中的所有模式列表")
async def fastapi_get_patterns(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[str]:
def fastapi_get_patterns(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[str]:
"""获取所有模式。
返回指定网络中的所有模式ID列表。
+48 -47
View File
@@ -1,5 +1,6 @@
from fastapi import APIRouter, Request, Query, Path, Body
from typing import Any, List, Dict, Union
from typing import Any
from fastapi import APIRouter, Body, Query
from app.services.tjnetwork import (
ChangeSet,
add_mixing,
@@ -32,7 +33,7 @@ from app.services.tjnetwork import (
router = APIRouter()
@router.get("/network-schemas/quality", summary="获取水质架构", description="获取网络中水质对象的架构定义")
async def fastapi_get_quality_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_quality_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取水质架构。
返回指定网络中水质对象的属性架构定义。
@@ -40,7 +41,7 @@ async def fastapi_get_quality_schema(network: str = Query(..., description="管
return get_quality_schema(network)
@router.get("/quality-configurations/properties", summary="获取水质属性", description="获取指定节点的水质属性信息")
async def fastapi_get_quality_properties(
def fastapi_get_quality_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
node: str = Query(..., description="节点ID")
) -> dict[str, Any]:
@@ -51,19 +52,19 @@ async def fastapi_get_quality_properties(
return get_quality(network, node)
@router.patch("/quality-configurations/properties", response_model=None, summary="设置水质属性", description="更新指定节点的水质属性")
async def fastapi_set_quality_properties(
def fastapi_set_quality_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置水质属性。
更新指定节点的水质属性值。
"""
props = await req.json()
props = payload
return set_quality(network, ChangeSet(props))
@router.get("/network-schemas/emitter", summary="获取发射器架构", description="获取网络中发射器对象的架构定义")
async def fastapi_get_emitter_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_emitter_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取发射器架构。
返回指定网络中发射器对象的属性架构定义。
@@ -71,7 +72,7 @@ async def fastapi_get_emitter_schema(network: str = Query(..., description="管
return get_emitter_schema(network)
@router.get("/emitters/properties", summary="获取发射器属性", description="获取指定连接点的发射器属性信息")
async def fastapi_get_emitter_properties(
def fastapi_get_emitter_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="连接点ID")
) -> dict[str, Any]:
@@ -82,21 +83,21 @@ async def fastapi_get_emitter_properties(
return get_emitter(network, junction)
@router.patch("/emitters/properties", response_model=None, summary="设置发射器属性", description="更新指定连接点的发射器属性")
async def fastapi_set_emitter_properties(
def fastapi_set_emitter_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="连接点ID"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置发射器属性。
更新指定连接点的发射器属性值。
"""
props = await req.json()
props = payload
ps = {"junction": junction} | props
return set_emitter(network, ChangeSet(ps))
@router.get("/network-schemas/source", summary="获取水源架构", description="获取网络中水源对象的架构定义")
async def fastapi_get_source_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_source_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取水源架构。
返回指定网络中水源对象的属性架构定义。
@@ -104,7 +105,7 @@ async def fastapi_get_source_schema(network: str = Query(..., description="管
return get_source_schema(network)
@router.get("/sources/detail", summary="获取水源属性", description="获取指定节点的水源属性信息")
async def fastapi_get_source(
def fastapi_get_source(
network: str = Query(..., description="管网名称(或数据库名称)"),
node: str = Query(..., description="节点ID")
) -> dict[str, Any]:
@@ -115,31 +116,31 @@ async def fastapi_get_source(
return get_source(network, node)
@router.patch("/sources", response_model=None, summary="设置水源属性", description="更新指定节点的水源属性")
async def fastapi_set_source(
def fastapi_set_source(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置水源属性。
更新指定节点的水源属性值。
"""
props = await req.json()
props = payload
return set_source(network, ChangeSet(props))
@router.post("/sources", response_model=None, summary="添加水源", description="在网络中添加一个新的水源")
async def fastapi_add_source(
def fastapi_add_source(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""添加水源。
在指定网络中创建一个新的水源,并设置其初始属性。
"""
props = await req.json()
props = payload
return add_source(network, ChangeSet(props))
@router.delete("/sources", response_model=None, summary="删除水源", description="从网络中删除指定节点的水源")
async def fastapi_delete_source(
def fastapi_delete_source(
network: str = Query(..., description="管网名称(或数据库名称)"),
node: str = Query(..., description="节点ID")
) -> ChangeSet:
@@ -151,7 +152,7 @@ async def fastapi_delete_source(
return delete_source(network, ChangeSet(props))
@router.get("/network-schemas/reaction", summary="获取反应架构", description="获取网络中反应对象的架构定义")
async def fastapi_get_reaction_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_reaction_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取反应架构。
返回指定网络中反应对象的属性架构定义。
@@ -159,7 +160,7 @@ async def fastapi_get_reaction_schema(network: str = Query(..., description="管
return get_reaction_schema(network)
@router.get("/reactions/detail", summary="获取反应属性", description="获取指定网络中的反应属性信息")
async def fastapi_get_reaction(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
def fastapi_get_reaction(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
"""获取反应属性。
返回指定网络中的反应属性信息。
@@ -167,19 +168,19 @@ async def fastapi_get_reaction(network: str = Query(..., description="管网名
return get_reaction(network)
@router.patch("/reactions", response_model=None, summary="设置反应属性", description="更新指定网络中的反应属性")
async def fastapi_set_reaction(
def fastapi_set_reaction(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置反应属性。
更新指定网络中的反应属性值。
"""
props = await req.json()
props = payload
return set_reaction(network, ChangeSet(props))
@router.get("/network-schemas/pipe-reaction", summary="获取管道反应架构", description="获取网络中管道反应对象的架构定义")
async def fastapi_get_pipe_reaction_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_pipe_reaction_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取管道反应架构。
返回指定网络中管道反应对象的属性架构定义。
@@ -187,7 +188,7 @@ async def fastapi_get_pipe_reaction_schema(network: str = Query(..., description
return get_pipe_reaction_schema(network)
@router.get("/pipe-reactions/detail", summary="获取管道反应属性", description="获取指定管道的反应属性信息")
async def fastapi_get_pipe_reaction(
def fastapi_get_pipe_reaction(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID")
) -> dict[str, Any]:
@@ -198,19 +199,19 @@ async def fastapi_get_pipe_reaction(
return get_pipe_reaction(network, pipe)
@router.patch("/pipe-reactions", response_model=None, summary="设置管道反应属性", description="更新指定管道的反应属性")
async def fastapi_set_pipe_reaction(
def fastapi_set_pipe_reaction(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置管道反应属性。
更新指定管道的反应属性值。
"""
props = await req.json()
props = payload
return set_pipe_reaction(network, ChangeSet(props))
@router.get("/network-schemas/tank-reaction", summary="获取水池反应架构", description="获取网络中水池反应对象的架构定义")
async def fastapi_get_tank_reaction_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_tank_reaction_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取水池反应架构。
返回指定网络中水池反应对象的属性架构定义。
@@ -218,7 +219,7 @@ async def fastapi_get_tank_reaction_schema(network: str = Query(..., description
return get_tank_reaction_schema(network)
@router.get("/tank-reactions/detail", summary="获取水池反应属性", description="获取指定水池的反应属性信息")
async def fastapi_get_tank_reaction(
def fastapi_get_tank_reaction(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水池ID")
) -> dict[str, Any]:
@@ -229,19 +230,19 @@ async def fastapi_get_tank_reaction(
return get_tank_reaction(network, tank)
@router.patch("/tank-reactions", response_model=None, summary="设置水池反应属性", description="更新指定水池的反应属性")
async def fastapi_set_tank_reaction(
def fastapi_set_tank_reaction(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置水池反应属性。
更新指定水池的反应属性值。
"""
props = await req.json()
props = payload
return set_tank_reaction(network, ChangeSet(props))
@router.get("/network-schemas/mixing", summary="获取混合架构", description="获取网络中混合对象的架构定义")
async def fastapi_get_mixing_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_mixing_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取混合架构。
返回指定网络中混合对象的属性架构定义。
@@ -249,7 +250,7 @@ async def fastapi_get_mixing_schema(network: str = Query(..., description="管
return get_mixing_schema(network)
@router.get("/mixing-configurations/detail", summary="获取混合属性", description="获取指定水池的混合属性信息")
async def fastapi_get_mixing(
def fastapi_get_mixing(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水池ID")
) -> dict[str, Any]:
@@ -260,37 +261,37 @@ async def fastapi_get_mixing(
return get_mixing(network, tank)
@router.patch("/mixing-configurations", response_model=None, summary="设置混合属性", description="更新指定水池的混合属性")
async def fastapi_set_mixing(
def fastapi_set_mixing(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置混合属性。
更新指定水池的混合属性值。
"""
props = await req.json()
props = payload
return set_mixing(network, ChangeSet(props))
@router.post("/mixing-configurations", response_model=None, summary="添加混合", description="在网络中添加一个新的混合")
async def fastapi_add_mixing(
def fastapi_add_mixing(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""添加混合。
在指定网络中创建一个新的混合,并设置其初始属性。
"""
props = await req.json()
props = payload
return add_mixing(network, ChangeSet(props))
@router.delete("/mixing-configurations", response_model=None, summary="删除混合", description="从网络中删除指定的混合")
async def fastapi_delete_mixing(
def fastapi_delete_mixing(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""删除混合。
从指定网络中删除指定的混合及其相关数据。
"""
props = await req.json()
props = payload
return delete_mixing(network, ChangeSet(props))
+32 -31
View File
@@ -1,5 +1,6 @@
from fastapi import APIRouter, Request, Query, Path, Body, Response
from typing import Any, List, Dict, Union
from typing import Any
from fastapi import APIRouter, Body, Query
from app.services.tjnetwork import (
ChangeSet,
add_label,
@@ -24,7 +25,7 @@ import json
router = APIRouter()
@router.get("/network-schemas/vertex", summary="获取图形元素架构", description="获取网络中图形元素对象的架构定义")
async def fastapi_get_vertex_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_vertex_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取图形元素架构。
返回指定网络中图形元素对象的属性架构定义。
@@ -32,7 +33,7 @@ async def fastapi_get_vertex_schema(network: str = Query(..., description="管
return get_vertex_schema(network)
@router.get("/visual-elements/properties", summary="获取图形元素属性", description="获取指定图形元素的属性信息")
async def fastapi_get_vertex_properties(
def fastapi_get_vertex_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
link: str = Query(..., description="图形元素链接")
) -> dict[str, Any]:
@@ -43,43 +44,43 @@ async def fastapi_get_vertex_properties(
return get_vertex(network, link)
@router.patch("/visual-elements/properties", response_model=None, summary="设置图形元素属性", description="更新指定图形元素的属性")
async def fastapi_set_vertex_properties(
def fastapi_set_vertex_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置图形元素属性。
更新指定图形元素的属性值。
"""
props = await req.json()
props = payload
return set_vertex(network, ChangeSet(props))
@router.post("/visual-elements", response_model=None, summary="添加图形元素", description="在网络中添加一个新的图形元素")
async def fastapi_add_vertex(
def fastapi_add_vertex(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""添加图形元素。
在指定网络中创建一个新的图形元素,并设置其初始属性。
"""
props = await req.json()
props = payload
return add_vertex(network, ChangeSet(props))
@router.delete("/visual-elements", response_model=None, summary="删除图形元素", description="从网络中删除指定的图形元素")
async def fastapi_delete_vertex(
def fastapi_delete_vertex(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""删除图形元素。
从指定网络中删除指定的图形元素及其相关数据。
"""
props = await req.json()
props = payload
return delete_vertex(network, ChangeSet(props))
@router.get("/visual-elements/links", response_class=PlainTextResponse, summary="获取所有图形元素链接", description="获取网络中的所有图形元素链接列表")
async def fastapi_get_all_vertex_links(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[str]:
def fastapi_get_all_vertex_links(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[str]:
"""获取所有图形元素链接。
返回指定网络中的所有图形元素链接列表。
@@ -87,7 +88,7 @@ async def fastapi_get_all_vertex_links(network: str = Query(..., description="
return json.dumps(get_all_vertex_links(network))
@router.get("/all-vertices", response_class=PlainTextResponse, summary="获取所有图形元素", description="获取网络中的所有图形元素详细信息")
async def fastapi_get_all_vertices(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[dict[str, Any]]:
def fastapi_get_all_vertices(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[dict[str, Any]]:
"""获取所有图形元素。
返回指定网络中的所有图形元素详细信息。
@@ -95,7 +96,7 @@ async def fastapi_get_all_vertices(network: str = Query(..., description="管网
return json.dumps(get_all_vertices(network))
@router.get("/network-schemas/label", summary="获取标签架构", description="获取网络中标签对象的架构定义")
async def fastapi_get_label_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_label_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取标签架构。
返回指定网络中标签对象的属性架构定义。
@@ -103,7 +104,7 @@ async def fastapi_get_label_schema(network: str = Query(..., description="管网
return get_label_schema(network)
@router.get("/labels/properties", summary="获取标签属性", description="获取指定坐标处的标签属性信息")
async def fastapi_get_label_properties(
def fastapi_get_label_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
x: float = Query(..., description="X坐标"),
y: float = Query(..., description="Y坐标")
@@ -115,43 +116,43 @@ async def fastapi_get_label_properties(
return get_label(network, x, y)
@router.patch("/labels/properties", response_model=None, summary="设置标签属性", description="更新指定标签的属性")
async def fastapi_set_label_properties(
def fastapi_set_label_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置标签属性。
更新指定标签的属性值。
"""
props = await req.json()
props = payload
return set_label(network, ChangeSet(props))
@router.post("/labels", response_model=None, summary="添加标签", description="在网络中添加一个新的标签")
async def fastapi_add_label(
def fastapi_add_label(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""添加标签。
在指定网络中创建一个新的标签,并设置其初始属性。
"""
props = await req.json()
props = payload
return add_label(network, ChangeSet(props))
@router.delete("/labels", response_model=None, summary="删除标签", description="从网络中删除指定的标签")
async def fastapi_delete_label(
def fastapi_delete_label(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""删除标签。
从指定网络中删除指定的标签及其相关数据。
"""
props = await req.json()
props = payload
return delete_label(network, ChangeSet(props))
@router.get("/network-schemas/backdrop", summary="获取背景架构", description="获取网络中背景对象的架构定义")
async def fastapi_get_backdrop_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_backdrop_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取背景架构。
返回指定网络中背景对象的属性架构定义。
@@ -159,7 +160,7 @@ async def fastapi_get_backdrop_schema(network: str = Query(..., description="管
return get_backdrop_schema(network)
@router.get("/backdrops/properties", summary="获取背景属性", description="获取指定网络的背景属性信息")
async def fastapi_get_backdrop_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
def fastapi_get_backdrop_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
"""获取背景属性。
返回指定网络的背景属性信息。
@@ -167,13 +168,13 @@ async def fastapi_get_backdrop_properties(network: str = Query(..., description=
return get_backdrop(network)
@router.patch("/backdrops/properties", response_model=None, summary="设置背景属性", description="更新指定网络的背景属性")
async def fastapi_set_backdrop_properties(
def fastapi_set_backdrop_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置背景属性。
更新指定网络的背景属性值。
"""
props = await req.json()
props = payload
return set_backdrop(network, ChangeSet(props))
+33 -14
View File
@@ -3,34 +3,49 @@ from typing import Any
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Body
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from starlette.concurrency import run_in_threadpool
from app.auth.keycloak_dependencies import get_current_keycloak_username
from app.services.leakage_identifier import (
from app.services.dma_leakage_estimation import (
run_leakage_identification,
)
router = APIRouter()
DEFAULT_N_WORKERS = max(1, min((os.cpu_count() or 1) - 1, 4))
MAX_POPULATION_SIZE = 1_000
MAX_GENERATIONS = 1_000
MAX_DURATION_HOURS = 168
class LeakageIdentifyRequest(BaseModel):
"""漏损识别请求模型"""
model_config = ConfigDict(extra="forbid")
network: str = Field(..., description="管网名称(或数据库名称)")
observed_pressure_data: str | dict[str, list[Any]] | list[dict[str, Any]] | None = Field(
None, description="观测的压力数据"
observed_pressure_data: dict[str, list[Any]] | list[dict[str, Any]] | None = (
Field(None, description="观测的压力数据;文件路径不属于公共 API 输入")
)
start_time: float = Field(0, description="起始时间(小时)")
duration: float = Field(24, description="持续时间(小时)")
timestep: float = Field(5, description="时间步长(分钟)")
q_sum: float = Field(0.2, description="总流量(m3/s)")
start_time: float = Field(0, ge=0, description="起始时间(小时)")
duration: float = Field(
24, gt=0, le=MAX_DURATION_HOURS, description="持续时间(小时)"
)
timestep: float = Field(5, gt=0, le=1440, description="时间步长(分钟)")
q_sum: float = Field(0.2, ge=0, description="总流量(m3/s)")
q_sum_unit: str = Field("m3/s", description="流量单位")
output_dir: str = Field("db_inp", description="输出目录")
pop_size: int = Field(50, description="种群大小")
max_gen: int = Field(100, description="最大代数")
n_workers: int = Field(DEFAULT_N_WORKERS, description="工作线程数")
pop_size: int = Field(
50, ge=2, le=MAX_POPULATION_SIZE, description="种群大小"
)
max_gen: int = Field(100, ge=1, le=MAX_GENERATIONS, description="最大代数")
n_workers: int = Field(
DEFAULT_N_WORKERS,
ge=1,
le=DEFAULT_N_WORKERS,
description="工作进程数",
)
output_flow_unit: str = Field("m3/s", description="输出流量单位")
dma_count: int | None = Field(None, description="DMA区域数量")
dma_count: int | None = Field(None, ge=1, description="DMA区域数量")
scada_start: datetime | None = Field(None, description="SCADA数据起始时间")
scada_end: datetime | None = Field(None, description="SCADA数据结束时间")
sensor_nodes: list[str] | None = Field(None, description="传感器节点列表")
@@ -63,6 +78,10 @@ async def identify_leakage(
HTTPException: 当处理过程中发生错误时
"""
try:
return run_leakage_identification(**data.model_dump(), username=username)
return await run_in_threadpool(
run_leakage_identification,
**data.model_dump(),
username=username,
)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc))
+60
View File
@@ -0,0 +1,60 @@
"""Host-only MCP bindings: declared in REST contracts even when MCP is disabled."""
from fastapi import APIRouter, Header, HTTPException, Request, Response
from psycopg.errors import UndefinedTable, InvalidSchemaName
from app.mcp.models import BindInput, BindResponse
from app.infra.db.postgresql.mcp_queries import McpQueryRepository
router = APIRouter(tags=["MCP Query Context"])
def runtime_for(request: Request):
runtime = getattr(request.app.state, "mcp_runtime", None)
if runtime is None:
raise HTTPException(503, "Product MCP is disabled")
return runtime
@router.post("/mcp-query-contexts", response_model=BindResponse)
async def create_context(
command: BindInput,
request: Request,
x_project_id: str = Header(alias="X-Project-Id"),
) -> BindResponse:
runtime = runtime_for(request)
principal = await runtime.identity(request)
from app.mcp.contexts import bind_context
try:
async with runtime.scope_factory(principal, x_project_id) as scope:
binding = await bind_context(scope, principal, command, runtime.settings)
except (UndefinedTable, InvalidSchemaName):
raise HTTPException(
503, "MCP project storage has not been provisioned"
) from None
return BindResponse(
context_handle=binding.context_handle,
expires_at=binding.expires_at,
task_id=binding.task_id,
run_id=binding.run_id,
)
@router.delete("/mcp-query-contexts/{context_handle}", status_code=204)
async def delete_context(
context_handle: str,
request: Request,
x_project_id: str = Header(alias="X-Project-Id"),
) -> Response:
runtime = runtime_for(request)
principal = await runtime.identity(request)
try:
async with runtime.scope_factory(principal, x_project_id) as scope:
await McpQueryRepository(scope.connection, scope.project.project_id).revoke(
context_handle, principal.subject, principal.client_id
)
except (UndefinedTable, InvalidSchemaName):
raise HTTPException(
503, "MCP project storage has not been provisioned"
) from None
return Response(status_code=204)
+7 -7
View File
@@ -2,14 +2,11 @@ import logging
from fastapi import APIRouter, Depends, HTTPException, status, Query, Path
import psycopg
from psycopg import AsyncConnection
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.project_dependencies import (
ProjectContext,
get_project_context,
get_project_pg_session,
get_project_pg_connection,
get_project_timescale_connection,
get_metadata_repository,
)
@@ -47,6 +44,7 @@ async def get_project_metadata(
description=project.description,
gs_workspace=project.gs_workspace,
map_extent=project.map_extent,
map_config=getattr(project, "map_config", {}) or {},
status=project.status,
project_role=ctx.project_role,
)
@@ -90,7 +88,7 @@ async def list_user_projects(
@router.get("/projects/current/database-health", summary="检查数据库健康状态", description="检查项目数据库连接的健康状况")
async def project_db_health(
pg_session: AsyncSession = Depends(get_project_pg_session),
pg_conn: AsyncConnection = Depends(get_project_pg_connection),
ts_conn: AsyncConnection = Depends(get_project_timescale_connection),
):
"""
@@ -99,8 +97,10 @@ async def project_db_health(
检查PostgreSQL和TimescaleDB数据库的连接状态
"""
try:
await pg_session.execute(text("SELECT 1"))
except SQLAlchemyError as exc:
async with pg_conn.cursor() as cur:
await cur.execute("SELECT 1")
await cur.fetchone()
except psycopg.Error as exc:
logger.error("Project PostgreSQL health check failed", exc_info=True)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+191 -4
View File
@@ -1,3 +1,4 @@
import json
from pathlib import Path
from tempfile import NamedTemporaryFile
from uuid import UUID, uuid4
@@ -6,12 +7,15 @@ from fastapi import (
APIRouter,
Depends,
File,
Form,
HTTPException,
Path as ApiPath,
Request,
UploadFile,
status,
)
from sqlalchemy.exc import IntegrityError
from starlette.concurrency import run_in_threadpool
from app.auth.metadata_dependencies import (
get_current_metadata_admin,
@@ -22,9 +26,21 @@ from app.auth.project_dependencies import (
resolve_project_business_routing,
)
from app.core.audit import AuditAction, log_audit_event
from app.core.encryption import is_database_encryption_configured
from app.domain.schemas.admin_metadata import (
AdminProjectResponse,
ProjectProvisionResponse,
)
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
from app.infra.db.project_routing import activate_project_routing
from app.native.wndb.core.database import MaterializedViewRefreshAfterCommitError
from app.services.network_import import network_update
from app.services.project_provisioning import (
ProjectProvisioningError,
ProvisionedProjectInfrastructure,
provision_project_infrastructure,
validate_project_code,
)
from app.services.tjnetwork import run_inp
router = APIRouter()
@@ -87,8 +103,8 @@ def _validate_inp_bytes(content: bytes, filename: str) -> str:
async def _read_upload(file: UploadFile) -> tuple[bytes, str]:
filename = Path(file.filename or "").name
content = await file.read(MAX_INP_FILE_BYTES + 1)
_validate_inp_bytes(content, filename)
return content, filename
normalized = _validate_inp_bytes(content, filename).encode("utf-8")
return normalized, filename
async def _audit_model_change(
@@ -114,7 +130,7 @@ async def _audit_model_change(
)
async def _run_uploaded_inp(content: bytes) -> str:
def _run_uploaded_inp_sync(content: bytes) -> str:
target_dir = Path("inp")
target_dir.mkdir(parents=True, exist_ok=True)
model_name = f"admin_model_{uuid4().hex}"
@@ -123,7 +139,11 @@ async def _run_uploaded_inp(content: bytes) -> str:
return run_inp(model_name)
async def _update_from_inp(content: bytes, project_code: str) -> None:
async def _run_uploaded_inp(content: bytes) -> str:
return await run_in_threadpool(_run_uploaded_inp_sync, content)
def _update_from_inp_sync(content: bytes, project_code: str) -> None:
temp_path: Path | None = None
try:
with NamedTemporaryFile(suffix=".inp", delete=False) as temp_file:
@@ -135,9 +155,15 @@ async def _update_from_inp(content: bytes, project_code: str) -> None:
temp_path.unlink(missing_ok=True)
async def _update_from_inp(content: bytes, project_code: str) -> None:
await run_in_threadpool(_update_from_inp_sync, content, project_code)
async def _apply_model_update(content: bytes, project_code: str) -> None:
try:
await _update_from_inp(content, project_code)
except MaterializedViewRefreshAfterCommitError:
raise
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -145,6 +171,167 @@ async def _apply_model_update(content: bytes, project_code: str) -> None:
) from exc
def _provision_from_inp_sync(
content: bytes,
*,
code: str,
workspace: str,
) -> ProvisionedProjectInfrastructure:
temp_path: Path | None = None
try:
with NamedTemporaryFile(suffix=".inp", delete=False) as temp_file:
temp_file.write(content)
temp_path = Path(temp_file.name)
return provision_project_infrastructure(
code=code,
workspace=workspace,
inp_path=temp_path,
)
finally:
if temp_path is not None:
temp_path.unlink(missing_ok=True)
@router.post(
"/admin/project-provisions",
response_model=ProjectProvisionResponse,
status_code=status.HTTP_201_CREATED,
summary="创建完整供水项目",
)
async def provision_project(
request: Request,
name: str = Form(..., min_length=1, max_length=100),
code: str = Form(..., min_length=1, max_length=50),
description: str | None = Form(default=None),
gs_workspace: str | None = Form(default=None, max_length=100),
map_zoom: int = Form(default=14, ge=1, le=22),
file: UploadFile = File(..., description="EPANET INP 模型文件"),
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> ProjectProvisionResponse:
try:
normalized_code = validate_project_code(code)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
) from exc
workspace = gs_workspace or normalized_code
if await metadata_repo.get_project_by_code(normalized_code) is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Project code already exists",
)
if not is_database_encryption_configured():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="DATABASE_ENCRYPTION_KEY is not configured",
)
content, filename = await _read_upload(file)
validation_result = await _run_uploaded_inp(content)
try:
validation_payload = json.loads(validation_result)
except (TypeError, json.JSONDecodeError) as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="EPANET validation returned an invalid response",
) from exc
if validation_payload.get("simulation_result") != "successful":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="EPANET model validation failed",
)
try:
infrastructure = await run_in_threadpool(
_provision_from_inp_sync,
content,
code=normalized_code,
workspace=workspace,
)
except ProjectProvisioningError as exc:
if isinstance(exc.cause, ValueError):
response_status = status.HTTP_409_CONFLICT
elif exc.stage == "preflight":
response_status = status.HTTP_503_SERVICE_UNAVAILABLE
else:
response_status = status.HTTP_500_INTERNAL_SERVER_ERROR
raise HTTPException(
status_code=response_status,
detail={
"stage": exc.stage,
"message": str(exc.cause),
"cleanup_errors": exc.cleanup_errors,
},
) from exc
map_extent = {"bbox": list(infrastructure.map_bbox), "zoom": map_zoom}
try:
project = await metadata_repo.create_provisioned_project(
name=name,
code=normalized_code,
description=description,
gs_workspace=workspace,
map_extent=map_extent,
creator_user_id=current_user.id,
business_dsn=infrastructure.business_dsn,
timescale_dsn=infrastructure.timescale_dsn,
pool_min_size=1,
pool_max_size=4,
)
except Exception as exc:
await metadata_repo.session.rollback()
cleanup_errors = await run_in_threadpool(infrastructure.cleanup)
if isinstance(exc, IntegrityError):
response_status = status.HTTP_409_CONFLICT
detail = "Project code or workspace conflicts with an existing project"
else:
response_status = status.HTTP_503_SERVICE_UNAVAILABLE
detail = f"Metadata database error: {exc}"
if cleanup_errors:
detail = f"{detail}; cleanup failures: {', '.join(cleanup_errors)}"
raise HTTPException(status_code=response_status, detail=detail) from exc
await log_audit_event(
action=AuditAction.CREATE,
user_id=current_user.id,
project_id=project.id,
resource_type="project_provision",
resource_id=str(project.id),
request_data={
"name": name,
"code": normalized_code,
"filename": filename,
"gs_workspace": workspace,
"layers": list(infrastructure.layers),
},
ip_address=request.client.host if request.client else None,
request_method=request.method,
request_path=request.url.path,
response_status=status.HTTP_201_CREATED,
session=metadata_repo.session,
)
return ProjectProvisionResponse(
project=AdminProjectResponse(
project_id=project.id,
name=project.name,
code=project.code,
description=project.description,
gs_workspace=project.gs_workspace,
map_extent=project.map_extent,
map_config=getattr(project, "map_config", {}) or {},
status=project.status,
created_at=project.created_at,
updated_at=project.updated_at,
),
business_database=normalized_code,
model_template_database=infrastructure.model_template,
timescale_database=normalized_code,
geoserver_workspace=workspace,
geoserver_layers=list(infrastructure.layers),
)
@router.post(
"/admin/projects/{project_id}/model-imports",
summary="导入桌面端水力模型",
+15 -14
View File
@@ -1,5 +1,6 @@
from fastapi import APIRouter, Request, Query, Path, Body
from typing import Any, List, Dict, Union
from typing import Any
from fastapi import APIRouter, Body, Query
from app.services.tjnetwork import (
ChangeSet,
calculate_demand_to_network,
@@ -21,7 +22,7 @@ router = APIRouter()
summary="获取需水量属性架构",
description="获取指定水网中需水量(Demand)的属性架构定义"
)
async def fastapi_get_demand_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_demand_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""
获取需水量属性架构。
@@ -35,7 +36,7 @@ async def fastapi_get_demand_schema(network: str = Query(..., description="管
summary="获取需水量属性",
description="获取指定水网中节点的需水量属性信息"
)
async def fastapi_get_demand_properties(
def fastapi_get_demand_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点ID")
) -> dict[str, Any]:
@@ -54,17 +55,17 @@ async def fastapi_get_demand_properties(
summary="设置需水量属性",
description="设置指定水网中节点的需水量属性信息"
)
async def fastapi_set_demand_properties(
def fastapi_set_demand_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点ID"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""
设置节点的需水量属性。
修改指定节点的需水量信息。请求体应包含需水量值、水压等级等属性。
"""
props = await req.json()
props = payload
ps = {"junction": junction} | props
return set_demand(network, ChangeSet(ps))
@@ -76,9 +77,9 @@ async def fastapi_set_demand_properties(
summary="计算需水量到节点分配",
description="将总需水量按指定方式分配到多个节点"
)
async def fastapi_calculate_demand_to_nodes(
def fastapi_calculate_demand_to_nodes(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> dict[str, float]:
"""
计算需水量到节点分配。
@@ -91,7 +92,7 @@ async def fastapi_calculate_demand_to_nodes(
"nodes": 节点ID列表(list[str])
}
"""
props = await req.json()
props = payload
demand = props["demand"]
nodes = props["nodes"]
return calculate_demand_to_nodes(network, demand, nodes)
@@ -101,9 +102,9 @@ async def fastapi_calculate_demand_to_nodes(
summary="计算需水量到区域分配",
description="将总需水量按区域特征分配到该区域内的节点"
)
async def fastapi_calculate_demand_to_region(
def fastapi_calculate_demand_to_region(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> dict[str, float]:
"""
计算需水量到区域分配。
@@ -116,7 +117,7 @@ async def fastapi_calculate_demand_to_region(
"region": 区域ID(str)
}
"""
props = await req.json()
props = payload
demand = props["demand"]
region = props["region"]
return calculate_demand_to_region(network, demand, region)
@@ -126,7 +127,7 @@ async def fastapi_calculate_demand_to_region(
summary="计算需水量到整网分配",
description="将需水量均匀分配到整个水网的所有需水节点"
)
async def fastapi_calculate_demand_to_network(
def fastapi_calculate_demand_to_network(
network: str = Query(..., description="管网名称(或数据库名称)"),
demand: float = Query(..., description="总需水量(m³/h)", gt=0)
) -> dict[str, float]:
+35 -34
View File
@@ -1,5 +1,6 @@
from fastapi import APIRouter, Request, Query, Path, Body
from typing import Any, List, Dict, Union
from typing import Any
from fastapi import APIRouter, Body, Query
from app.services.tjnetwork import (
ChangeSet,
delete_junction,
@@ -48,7 +49,7 @@ router = APIRouter()
summary="检查节点有效性",
description="检查指定ID是否为水网中的有效节点"
)
async def fastapi_is_node(
def fastapi_is_node(
network: str = Query(..., description="管网名称(或数据库名称)"),
node: str = Query(..., description="节点ID")
) -> bool:
@@ -60,7 +61,7 @@ async def fastapi_is_node(
summary="检查是否为接点",
description="检查指定ID是否为水网中的接点(需求点)"
)
async def fastapi_is_junction(
def fastapi_is_junction(
network: str = Query(..., description="管网名称(或数据库名称)"),
node: str = Query(..., description="节点ID")
) -> bool:
@@ -72,7 +73,7 @@ async def fastapi_is_junction(
summary="检查是否为水源",
description="检查指定ID是否为水网中的水源(水库/河流)"
)
async def fastapi_is_reservoir(
def fastapi_is_reservoir(
network: str = Query(..., description="管网名称(或数据库名称)"),
node: str = Query(..., description="节点ID")
) -> bool:
@@ -84,7 +85,7 @@ async def fastapi_is_reservoir(
summary="检查是否为蓄水池",
description="检查指定ID是否为水网中的蓄水池"
)
async def fastapi_is_tank(
def fastapi_is_tank(
network: str = Query(..., description="管网名称(或数据库名称)"),
node: str = Query(..., description="节点ID")
) -> bool:
@@ -96,7 +97,7 @@ async def fastapi_is_tank(
summary="检查管线有效性",
description="检查指定ID是否为水网中的有效管线"
)
async def fastapi_is_link(
def fastapi_is_link(
network: str = Query(..., description="管网名称(或数据库名称)"),
link: str = Query(..., description="管线ID")
) -> bool:
@@ -108,7 +109,7 @@ async def fastapi_is_link(
summary="检查是否为管道",
description="检查指定ID是否为水网中的管道"
)
async def fastapi_is_pipe(
def fastapi_is_pipe(
network: str = Query(..., description="管网名称(或数据库名称)"),
link: str = Query(..., description="管线ID")
) -> bool:
@@ -120,7 +121,7 @@ async def fastapi_is_pipe(
summary="检查是否为泵",
description="检查指定ID是否为水网中的泵"
)
async def fastapi_is_pump(
def fastapi_is_pump(
network: str = Query(..., description="管网名称(或数据库名称)"),
link: str = Query(..., description="管线ID")
) -> bool:
@@ -132,7 +133,7 @@ async def fastapi_is_pump(
summary="检查是否为阀门",
description="检查指定ID是否为水网中的阀门"
)
async def fastapi_is_valve(
def fastapi_is_valve(
network: str = Query(..., description="管网名称(或数据库名称)"),
link: str = Query(..., description="管线ID")
) -> bool:
@@ -144,7 +145,7 @@ async def fastapi_is_valve(
summary="获取节点类型",
description="获取指定节点的类型(接点/水源/蓄水池)"
)
async def fastapi_get_node_type(
def fastapi_get_node_type(
network: str = Query(..., description="管网名称(或数据库名称)"),
node: str = Query(..., description="节点ID")
) -> str:
@@ -156,7 +157,7 @@ async def fastapi_get_node_type(
summary="获取管线类型",
description="获取指定管线的类型(管道/泵/阀门)"
)
async def fastapi_get_link_type(
def fastapi_get_link_type(
network: str = Query(..., description="管网名称(或数据库名称)"),
link: str = Query(..., description="管线ID")
) -> str:
@@ -168,7 +169,7 @@ async def fastapi_get_link_type(
summary="获取元素类型",
description="获取指定元素的类型(节点或管线)"
)
async def fastapi_get_element_type(
def fastapi_get_element_type(
network: str = Query(..., description="管网名称(或数据库名称)"),
element: str = Query(..., description="元素ID")
) -> str:
@@ -180,7 +181,7 @@ async def fastapi_get_element_type(
summary="获取元素类型值",
description="获取指定元素的类型数值标识"
)
async def fastapi_get_element_type_value(
def fastapi_get_element_type_value(
network: str = Query(..., description="管网名称(或数据库名称)"),
element: str = Query(..., description="元素ID")
) -> int:
@@ -192,7 +193,7 @@ async def fastapi_get_element_type_value(
summary="获取所有节点",
description="获取指定水网中的所有节点ID列表"
)
async def fastapi_get_nodes(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[str]:
def fastapi_get_nodes(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[str]:
"""获取水网中所有节点的ID列表。"""
return get_nodes(network)
@@ -201,7 +202,7 @@ async def fastapi_get_nodes(network: str = Query(..., description="管网名称
summary="获取所有管线",
description="获取指定水网中的所有管线ID列表"
)
async def fastapi_get_links(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[str]:
def fastapi_get_links(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[str]:
"""获取水网中所有管线的ID列表。"""
return get_links(network)
@@ -226,7 +227,7 @@ def get_node_links_endpoint(
summary="获取节点属性",
description="获取指定节点的所有属性信息"
)
async def fast_get_node_properties(
def fast_get_node_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
node: str = Query(..., description="节点ID")
) -> dict[str, Any]:
@@ -238,7 +239,7 @@ async def fast_get_node_properties(
summary="获取管线属性",
description="获取指定管线的所有属性信息"
)
async def fast_get_link_properties(
def fast_get_link_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
link: str = Query(..., description="管线ID")
) -> dict[str, Any]:
@@ -250,7 +251,7 @@ async def fast_get_link_properties(
summary="获取SCADA点属性",
description="获取指定SCADA点的属性信息"
)
async def fast_get_scada_properties(
def fast_get_scada_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
scada: str = Query(..., description="SCADA点ID")
) -> dict[str, Any]:
@@ -262,7 +263,7 @@ async def fast_get_scada_properties(
summary="获取所有SCADA点属性",
description="获取指定水网中所有SCADA点的属性信息"
)
async def fast_get_all_scada_properties(
def fast_get_all_scada_properties(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> list[dict[str, Any]]:
"""获取水网中所有SCADA点的属性列表。"""
@@ -273,7 +274,7 @@ async def fast_get_all_scada_properties(
summary="获取指定类型元素属性",
description="获取指定类型的元素属性信息"
)
async def fast_get_element_properties_with_type(
def fast_get_element_properties_with_type(
network: str = Query(..., description="管网名称(或数据库名称)"),
elementtype: str = Query(..., description="元素类型"),
element: str = Query(..., description="元素ID")
@@ -286,7 +287,7 @@ async def fast_get_element_properties_with_type(
summary="获取元素属性",
description="获取指定元素的属性信息"
)
async def fast_get_element_properties(
def fast_get_element_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
element: str = Query(..., description="元素ID")
) -> dict[str, Any]:
@@ -302,7 +303,7 @@ async def fast_get_element_properties(
summary="获取标题属性架构",
description="获取指定水网的标题(标题)属性架构定义"
)
async def fast_get_title_schema(
def fast_get_title_schema(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> dict[str, dict[str, Any]]:
"""获取水网标题的属性架构。"""
@@ -313,7 +314,7 @@ async def fast_get_title_schema(
summary="获取水网标题属性",
description="获取指定水网的标题(Title)信息"
)
async def fast_get_title(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
def fast_get_title(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
"""获取水网的标题属性。"""
return get_title(network)
@@ -323,12 +324,12 @@ async def fast_get_title(network: str = Query(..., description="管网名称(
summary="设置水网标题属性",
description="设置指定水网的标题(Title)信息"
)
async def fastapi_set_title(
def fastapi_set_title(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置水网的标题属性。"""
props = await req.json()
props = payload
return set_title(network, ChangeSet(props))
############################################################
@@ -340,7 +341,7 @@ async def fastapi_set_title(
summary="获取状态属性架构",
description="获取指定水网的状态(Status)属性架构定义"
)
async def fastapi_get_status_schema(
def fastapi_get_status_schema(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> dict[str, dict[str, Any]]:
"""获取水网状态的属性架构。"""
@@ -351,7 +352,7 @@ async def fastapi_get_status_schema(
summary="获取管线状态",
description="获取指定管线的状态信息"
)
async def fastapi_get_status(
def fastapi_get_status(
network: str = Query(..., description="管网名称(或数据库名称)"),
link: str = Query(..., description="管线ID")
) -> dict[str, Any]:
@@ -364,13 +365,13 @@ async def fastapi_get_status(
summary="设置管线状态",
description="设置指定管线的状态信息"
)
async def fastapi_set_status_properties(
def fastapi_set_status_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
link: str = Query(..., description="管线ID"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置管线的状态属性。"""
props = await req.json()
props = payload
ps = {"link": link} | props
return set_status(network, ChangeSet(ps))
@@ -384,7 +385,7 @@ async def fastapi_set_status_properties(
summary="删除节点",
description="删除指定的节点(接点/水源/蓄水池)"
)
async def fastapi_delete_node(
def fastapi_delete_node(
network: str = Query(..., description="管网名称(或数据库名称)"),
node: str = Query(..., description="节点ID")
) -> ChangeSet:
@@ -404,7 +405,7 @@ async def fastapi_delete_node(
summary="删除管线",
description="删除指定的管线(管道/泵/阀门)"
)
async def fastapi_delete_link(
def fastapi_delete_link(
network: str = Query(..., description="管网名称(或数据库名称)"),
link: str = Query(..., description="管线ID")
) -> ChangeSet:
+6 -6
View File
@@ -27,7 +27,7 @@ router = APIRouter()
# # example: set_coord(p, ChangeSet({'node': 'j1', 'x': 1.0, 'y': 2.0}))
# @router.post("/setcoord/", response_model=None)
# async def fastapi_set_coord(network: str, req: Request) -> ChangeSet:
# props = await req.json()
# props = payload
# return set_coord(network, ChangeSet(props))
@router.get(
@@ -35,7 +35,7 @@ router = APIRouter()
summary="获取节点坐标",
description="获取指定节点的地理坐标(X, Y)"
)
async def fastapi_get_node_coord(
def fastapi_get_node_coord(
network: str = Query(..., description="管网名称(或数据库名称)"),
node: str = Query(..., description="节点ID")
) -> dict[str, float] | None:
@@ -48,7 +48,7 @@ async def fastapi_get_node_coord(
summary="获取范围内的网络元素",
description="获取指定地理范围内的网络节点和管线"
)
async def fastapi_get_network_in_extent(
def fastapi_get_network_in_extent(
network: str = Query(..., description="管网名称(或数据库名称)"),
x1: float = Query(..., description="范围左下角X坐标", alias="x1"),
y1: float = Query(..., description="范围左下角Y坐标", alias="y1"),
@@ -63,7 +63,7 @@ async def fastapi_get_network_in_extent(
summary="获取主要节点坐标",
description="获取直径大于等于指定值的节点坐标"
)
async def fastapi_get_majornode_coords(
def fastapi_get_majornode_coords(
network: str = Query(..., description="管网名称(或数据库名称)"),
diameter: int = Query(..., description="最小直径(mm)", gt=0)
) -> dict[str, dict[str, float]]:
@@ -75,7 +75,7 @@ async def fastapi_get_majornode_coords(
summary="获取主要管道节点",
description="获取直径大于等于指定值的管道的节点ID"
)
async def fastapi_get_major_pipe_nodes(
def fastapi_get_major_pipe_nodes(
network: str = Query(..., description="管网名称(或数据库名称)"),
diameter: int = Query(..., description="最小直径(mm)", gt=0)
) -> list[str] | None:
@@ -87,7 +87,7 @@ async def fastapi_get_major_pipe_nodes(
summary="获取网络管线节点",
description="获取指定水网所有管线的起点和终点节点"
)
async def fastapi_get_network_link_nodes(
def fastapi_get_network_link_nodes(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> list[str] | None:
"""获取网络中所有管线的连接节点。"""
+23 -22
View File
@@ -1,5 +1,6 @@
from fastapi import APIRouter, Request, Query, Path, Body
from typing import Any, List, Dict, Union
from typing import Any
from fastapi import APIRouter, Body, Query
from app.services.tjnetwork import (
ChangeSet,
add_junction,
@@ -13,7 +14,7 @@ from app.services.tjnetwork import (
router = APIRouter()
@router.get("/network-schemas/junction", summary="获取节点架构", description="获取指定项目的节点属性架构和数据类型定义。")
async def fast_get_junction_schema(
def fast_get_junction_schema(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> dict[str, dict[str, Any]]:
"""
@@ -27,7 +28,7 @@ async def fast_get_junction_schema(
return get_junction_schema(network)
@router.post("/junctions", response_model=None, summary="添加节点", description="在供水网络中添加新的节点,指定节点ID和空间坐标。")
async def fastapi_add_junction(
def fastapi_add_junction(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID"),
x: float = Query(..., description="X 坐标"),
@@ -51,7 +52,7 @@ async def fastapi_add_junction(
return add_junction(network, ChangeSet(ps))
@router.delete("/junctions", response_model=None, summary="删除节点", description="从供水网络中删除指定的节点。")
async def fastapi_delete_junction(
def fastapi_delete_junction(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID")
) -> ChangeSet:
@@ -69,7 +70,7 @@ async def fastapi_delete_junction(
return delete_junction(network, ChangeSet(ps))
@router.get("/junctions/elevation", summary="获取节点标高", description="获取指定节点的标高(海拔高度)。")
async def fastapi_get_junction_elevation(
def fastapi_get_junction_elevation(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID")
) -> float:
@@ -87,7 +88,7 @@ async def fastapi_get_junction_elevation(
return ps["elevation"]
@router.get("/junctions/x", summary="获取节点 X 坐标", description="获取指定节点的 X 坐标值。")
async def fastapi_get_junction_x(
def fastapi_get_junction_x(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID")
) -> float:
@@ -105,7 +106,7 @@ async def fastapi_get_junction_x(
return ps["x"]
@router.get("/junctions/y", summary="获取节点 Y 坐标", description="获取指定节点的 Y 坐标值。")
async def fastapi_get_junction_y(
def fastapi_get_junction_y(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID")
) -> float:
@@ -123,7 +124,7 @@ async def fastapi_get_junction_y(
return ps["y"]
@router.get("/junctions/coord", summary="获取节点坐标", description="获取指定节点的 X 和 Y 坐标。")
async def fastapi_get_junction_coord(
def fastapi_get_junction_coord(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID")
) -> dict[str, float]:
@@ -142,7 +143,7 @@ async def fastapi_get_junction_coord(
return coord
@router.get("/junctions/demand", summary="获取节点需水量", description="获取指定节点的需水量。")
async def fastapi_get_junction_demand(
def fastapi_get_junction_demand(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID")
) -> float:
@@ -160,7 +161,7 @@ async def fastapi_get_junction_demand(
return ps["demand"]
@router.get("/junctions/pattern", summary="获取节点需水模式", description="获取指定节点的需水模式标识。")
async def fastapi_get_junction_pattern(
def fastapi_get_junction_pattern(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID")
) -> str:
@@ -178,7 +179,7 @@ async def fastapi_get_junction_pattern(
return ps["pattern"]
@router.patch("/junctions/elevation", response_model=None, summary="设置节点标高", description="设置指定节点的标高值。")
async def fastapi_set_junction_elevation(
def fastapi_set_junction_elevation(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID"),
elevation: float = Query(..., description="标高(海拔高度)")
@@ -198,7 +199,7 @@ async def fastapi_set_junction_elevation(
return set_junction(network, ChangeSet(ps))
@router.patch("/junctions/x", response_model=None, summary="设置节点 X 坐标", description="设置指定节点的 X 坐标值。")
async def fastapi_set_junction_x(
def fastapi_set_junction_x(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID"),
x: float = Query(..., description="X 坐标值")
@@ -218,7 +219,7 @@ async def fastapi_set_junction_x(
return set_junction(network, ChangeSet(ps))
@router.patch("/junctions/y", response_model=None, summary="设置节点 Y 坐标", description="设置指定节点的 Y 坐标值。")
async def fastapi_set_junction_y(
def fastapi_set_junction_y(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID"),
y: float = Query(..., description="Y 坐标值")
@@ -238,7 +239,7 @@ async def fastapi_set_junction_y(
return set_junction(network, ChangeSet(ps))
@router.patch("/junctions/coord", response_model=None, summary="设置节点坐标", description="设置指定节点的 X 和 Y 坐标。")
async def fastapi_set_junction_coord(
def fastapi_set_junction_coord(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID"),
x: float = Query(..., description="X 坐标值"),
@@ -260,7 +261,7 @@ async def fastapi_set_junction_coord(
return set_junction(network, ChangeSet(ps))
@router.patch("/junctions/demand", response_model=None, summary="设置节点需水量", description="设置指定节点的需水量。")
async def fastapi_set_junction_demand(
def fastapi_set_junction_demand(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID"),
demand: float = Query(..., description="需水量值")
@@ -280,7 +281,7 @@ async def fastapi_set_junction_demand(
return set_junction(network, ChangeSet(ps))
@router.patch("/junctions/pattern", response_model=None, summary="设置节点需水模式", description="设置指定节点的需水模式标识。")
async def fastapi_set_junction_pattern(
def fastapi_set_junction_pattern(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID"),
pattern: str = Query(..., description="需水模式标识")
@@ -300,7 +301,7 @@ async def fastapi_set_junction_pattern(
return set_junction(network, ChangeSet(ps))
@router.get("/junctions/properties", summary="获取节点属性", description="获取指定节点的所有属性信息。")
async def fastapi_get_junction_properties(
def fastapi_get_junction_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID")
) -> dict[str, Any]:
@@ -317,7 +318,7 @@ async def fastapi_get_junction_properties(
return get_junction(network, junction)
@router.get("/junctions", summary="获取所有节点属性", description="获取指定项目中所有节点的属性信息。")
async def fastapi_get_all_junction_properties(
def fastapi_get_all_junction_properties(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> list[dict[str, Any]]:
"""
@@ -336,10 +337,10 @@ async def fastapi_get_all_junction_properties(
return results
@router.patch("/junctions/properties", response_model=None, summary="批量设置节点属性", description="批量设置指定节点的多个属性。")
async def fastapi_set_junction_properties(
def fastapi_set_junction_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""
批量设置节点属性。
@@ -354,6 +355,6 @@ async def fastapi_set_junction_properties(
Returns:
ChangeSet: 包含变更信息的结果
"""
props = await req.json()
props = payload
ps = {"id": junction} | props
return set_junction(network, ChangeSet(ps))
+25 -24
View File
@@ -1,5 +1,6 @@
from fastapi import APIRouter, Request, Query, Path, Body
from typing import Any, List, Dict, Union
from typing import Any
from fastapi import APIRouter, Body, Query
from app.services.tjnetwork import (
ChangeSet,
PIPE_STATUS_OPEN,
@@ -14,7 +15,7 @@ from app.services.tjnetwork import (
router = APIRouter()
@router.get("/network-schemas/pipe", summary="获取管道模式", description="获取管道对象的模式定义,包含所有可用字段及其类型")
async def fastapi_get_pipe_schema(
def fastapi_get_pipe_schema(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> dict[str, dict[str, Any]]:
"""
@@ -29,7 +30,7 @@ async def fastapi_get_pipe_schema(
return get_pipe_schema(network)
@router.post("/pipes", response_model=None, summary="添加管道", description="向网络中添加新的管道,需要提供管道的基本参数如长度、管径、粗糙度等")
async def fastapi_add_pipe(
def fastapi_add_pipe(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道标识符"),
node1: str = Query(..., description="管道起始节点ID"),
@@ -70,7 +71,7 @@ async def fastapi_add_pipe(
return add_pipe(network, ChangeSet(ps))
@router.delete("/pipes", response_model=None, summary="删除管道", description="从网络中删除指定的管道")
async def fastapi_delete_pipe(
def fastapi_delete_pipe(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="要删除的管道ID")
) -> ChangeSet:
@@ -88,7 +89,7 @@ async def fastapi_delete_pipe(
return delete_pipe(network, ChangeSet(ps))
@router.get("/pipes/node1", summary="获取管道起始节点", description="获取指定管道的起始节点ID")
async def fastapi_get_pipe_node1(
def fastapi_get_pipe_node1(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID")
) -> str | None:
@@ -106,7 +107,7 @@ async def fastapi_get_pipe_node1(
return ps["node1"]
@router.get("/pipes/node2", summary="获取管道终止节点", description="获取指定管道的终止节点ID")
async def fastapi_get_pipe_node2(
def fastapi_get_pipe_node2(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID")
) -> str | None:
@@ -124,7 +125,7 @@ async def fastapi_get_pipe_node2(
return ps["node2"]
@router.get("/pipes/length", summary="获取管道长度", description="获取指定管道的长度")
async def fastapi_get_pipe_length(
def fastapi_get_pipe_length(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID")
) -> float | None:
@@ -142,7 +143,7 @@ async def fastapi_get_pipe_length(
return ps["length"]
@router.get("/pipes/diameter", summary="获取管道管径", description="获取指定管道的管径")
async def fastapi_get_pipe_diameter(
def fastapi_get_pipe_diameter(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID")
) -> float | None:
@@ -160,7 +161,7 @@ async def fastapi_get_pipe_diameter(
return ps["diameter"]
@router.get("/pipes/roughness", summary="获取管道粗糙度", description="获取指定管道的粗糙度")
async def fastapi_get_pipe_roughness(
def fastapi_get_pipe_roughness(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID")
) -> float | None:
@@ -178,7 +179,7 @@ async def fastapi_get_pipe_roughness(
return ps["roughness"]
@router.get("/pipes/minor-loss", summary="获取管道局部阻力系数", description="获取指定管道的局部阻力系数")
async def fastapi_get_pipe_minor_loss(
def fastapi_get_pipe_minor_loss(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID")
) -> float | None:
@@ -196,7 +197,7 @@ async def fastapi_get_pipe_minor_loss(
return ps["minor_loss"]
@router.get("/pipes/status", summary="获取管道状态", description="获取指定管道的状态(开启或关闭)")
async def fastapi_get_pipe_status(
def fastapi_get_pipe_status(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID")
) -> str | None:
@@ -214,7 +215,7 @@ async def fastapi_get_pipe_status(
return ps["status"]
@router.patch("/pipes/node1", response_model=None, summary="设置管道起始节点", description="设置指定管道的起始节点")
async def fastapi_set_pipe_node1(
def fastapi_set_pipe_node1(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID"),
node1: str = Query(..., description="新的起始节点ID")
@@ -234,7 +235,7 @@ async def fastapi_set_pipe_node1(
return set_pipe(network, ChangeSet(ps))
@router.patch("/pipes/node2", response_model=None, summary="设置管道终止节点", description="设置指定管道的终止节点")
async def fastapi_set_pipe_node2(
def fastapi_set_pipe_node2(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID"),
node2: str = Query(..., description="新的终止节点ID")
@@ -254,7 +255,7 @@ async def fastapi_set_pipe_node2(
return set_pipe(network, ChangeSet(ps))
@router.patch("/pipes/length", response_model=None, summary="设置管道长度", description="设置指定管道的长度")
async def fastapi_set_pipe_length(
def fastapi_set_pipe_length(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID"),
length: float = Query(..., description="新的管道长度(单位:米)")
@@ -274,7 +275,7 @@ async def fastapi_set_pipe_length(
return set_pipe(network, ChangeSet(ps))
@router.patch("/pipes/diameter", response_model=None, summary="设置管道管径", description="设置指定管道的管径")
async def fastapi_set_pipe_diameter(
def fastapi_set_pipe_diameter(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID"),
diameter: float = Query(..., description="新的管道管径(单位:毫米)")
@@ -294,7 +295,7 @@ async def fastapi_set_pipe_diameter(
return set_pipe(network, ChangeSet(ps))
@router.patch("/pipes/roughness", response_model=None, summary="设置管道粗糙度", description="设置指定管道的粗糙度")
async def fastapi_set_pipe_roughness(
def fastapi_set_pipe_roughness(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID"),
roughness: float = Query(..., description="新的管道粗糙度值")
@@ -314,7 +315,7 @@ async def fastapi_set_pipe_roughness(
return set_pipe(network, ChangeSet(ps))
@router.patch("/pipes/minor-loss", response_model=None, summary="设置管道局部阻力系数", description="设置指定管道的局部阻力系数")
async def fastapi_set_pipe_minor_loss(
def fastapi_set_pipe_minor_loss(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID"),
minor_loss: float = Query(..., description="新的局部阻力系数值")
@@ -334,7 +335,7 @@ async def fastapi_set_pipe_minor_loss(
return set_pipe(network, ChangeSet(ps))
@router.patch("/pipes/status", response_model=None, summary="设置管道状态", description="设置指定管道的状态(开启或关闭)")
async def fastapi_set_pipe_status(
def fastapi_set_pipe_status(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID"),
status: str = Query(..., description="新的管道状态(开启/关闭)")
@@ -354,7 +355,7 @@ async def fastapi_set_pipe_status(
return set_pipe(network, ChangeSet(ps))
@router.get("/pipes/properties", summary="获取管道属性", description="获取指定管道的所有属性信息")
async def fastapi_get_pipe_properties(
def fastapi_get_pipe_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID")
) -> dict[str, Any]:
@@ -371,7 +372,7 @@ async def fastapi_get_pipe_properties(
return get_pipe(network, pipe)
@router.get("/pipes", summary="获取所有管道属性", description="获取网络中所有管道的属性信息列表")
async def fastapi_get_all_pipe_properties(
def fastapi_get_all_pipe_properties(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> list[dict[str, Any]]:
"""
@@ -388,10 +389,10 @@ async def fastapi_get_all_pipe_properties(
return results
@router.patch("/pipes/properties", response_model=None, summary="设置管道属性", description="批量设置指定管道的多个属性")
async def fastapi_set_pipe_properties(
def fastapi_set_pipe_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""
批量设置管道属性。
@@ -404,6 +405,6 @@ async def fastapi_set_pipe_properties(
Returns:
ChangeSet对象,包含本次修改的变更信息
"""
props = await req.json()
props = payload
ps = {"id": pipe} | props
return set_pipe(network, ChangeSet(ps))
+15 -14
View File
@@ -1,5 +1,6 @@
from fastapi import APIRouter, Request, Query, Path, Body
from typing import Any, List, Dict, Union
from typing import Any
from fastapi import APIRouter, Body, Query
from app.services.tjnetwork import (
ChangeSet,
add_pump,
@@ -13,7 +14,7 @@ from app.services.tjnetwork import (
router = APIRouter()
@router.get("/network-schemas/pump", summary="获取水泵模式", description="获取水泵对象的模式定义,包含所有可用字段及其类型")
async def fastapi_get_pump_schema(
def fastapi_get_pump_schema(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> dict[str, dict[str, Any]]:
"""
@@ -28,7 +29,7 @@ async def fastapi_get_pump_schema(
return get_pump_schema(network)
@router.post("/pumps", response_model=None, summary="添加水泵", description="向网络中添加新的水泵,需要提供水泵的基本参数如功率等")
async def fastapi_add_pump(
def fastapi_add_pump(
network: str = Query(..., description="管网名称(或数据库名称)"),
pump: str = Query(..., description="水泵标识符"),
node1: str = Query(..., description="水泵起始节点ID"),
@@ -52,7 +53,7 @@ async def fastapi_add_pump(
return add_pump(network, ChangeSet(ps))
@router.delete("/pumps", response_model=None, summary="删除水泵", description="从网络中删除指定的水泵")
async def fastapi_delete_pump(
def fastapi_delete_pump(
network: str = Query(..., description="管网名称(或数据库名称)"),
pump: str = Query(..., description="要删除的水泵ID")
) -> ChangeSet:
@@ -70,7 +71,7 @@ async def fastapi_delete_pump(
return delete_pump(network, ChangeSet(ps))
@router.get("/pumps/node1", summary="获取水泵起始节点", description="获取指定水泵的起始节点ID")
async def fastapi_get_pump_node1(
def fastapi_get_pump_node1(
network: str = Query(..., description="管网名称(或数据库名称)"),
pump: str = Query(..., description="水泵ID")
) -> str | None:
@@ -88,7 +89,7 @@ async def fastapi_get_pump_node1(
return ps["node1"]
@router.get("/pumps/node2", summary="获取水泵终止节点", description="获取指定水泵的终止节点ID")
async def fastapi_get_pump_node2(
def fastapi_get_pump_node2(
network: str = Query(..., description="管网名称(或数据库名称)"),
pump: str = Query(..., description="水泵ID")
) -> str | None:
@@ -106,7 +107,7 @@ async def fastapi_get_pump_node2(
return ps["node2"]
@router.patch("/pumps/node1", response_model=None, summary="设置水泵起始节点", description="设置指定水泵的起始节点")
async def fastapi_set_pump_node1(
def fastapi_set_pump_node1(
network: str = Query(..., description="管网名称(或数据库名称)"),
pump: str = Query(..., description="水泵ID"),
node1: str = Query(..., description="新的起始节点ID")
@@ -126,7 +127,7 @@ async def fastapi_set_pump_node1(
return set_pump(network, ChangeSet(ps))
@router.patch("/pumps/node2", response_model=None, summary="设置水泵终止节点", description="设置指定水泵的终止节点")
async def fastapi_set_pump_node2(
def fastapi_set_pump_node2(
network: str = Query(..., description="管网名称(或数据库名称)"),
pump: str = Query(..., description="水泵ID"),
node2: str = Query(..., description="新的终止节点ID")
@@ -146,7 +147,7 @@ async def fastapi_set_pump_node2(
return set_pump(network, ChangeSet(ps))
@router.get("/pumps/properties", summary="获取水泵属性", description="获取指定水泵的所有属性信息")
async def fastapi_get_pump_properties(
def fastapi_get_pump_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
pump: str = Query(..., description="水泵ID")
) -> dict[str, Any]:
@@ -163,7 +164,7 @@ async def fastapi_get_pump_properties(
return get_pump(network, pump)
@router.get("/pumps", summary="获取所有水泵属性", description="获取网络中所有水泵的属性信息列表")
async def fastapi_get_all_pump_properties(
def fastapi_get_all_pump_properties(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> list[dict[str, Any]]:
"""
@@ -180,10 +181,10 @@ async def fastapi_get_all_pump_properties(
return results
@router.patch("/pumps/properties", response_model=None, summary="设置水泵属性", description="批量设置指定水泵的多个属性")
async def fastapi_set_pump_properties(
def fastapi_set_pump_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
pump: str = Query(..., description="水泵ID"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""
批量设置水泵属性。
@@ -196,6 +197,6 @@ async def fastapi_set_pump_properties(
Returns:
ChangeSet对象,包含本次修改的变更信息
"""
props = await req.json()
props = payload
ps = {"id": pump} | props
return set_pump(network, ChangeSet(ps))
+13 -14
View File
@@ -1,6 +1,6 @@
from typing import Any
from fastapi import APIRouter, Query, Request
from fastapi import APIRouter, Body, Query
from app.services.tjnetwork import (
ChangeSet,
@@ -18,21 +18,21 @@ router = APIRouter()
@router.get("/network-schemas/region", summary="获取区域属性架构")
async def get_region_schema_endpoint(
def get_region_schema_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
) -> dict[str, dict[str, Any]]:
return get_region_schema(network)
@router.get("/regions", summary="获取区域列表")
async def get_regions_endpoint(
def get_regions_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
) -> list[dict[str, Any]]:
return [get_region(network, region_id) for region_id in get_regions(network)]
@router.get("/regions/detail", summary="获取区域信息")
async def get_region_endpoint(
def get_region_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
id: str = Query(..., description="区域 ID"),
) -> dict[str, Any]:
@@ -40,7 +40,7 @@ async def get_region_endpoint(
@router.get("/regions/nodes", summary="获取区域节点")
async def get_region_nodes_endpoint(
def get_region_nodes_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
id: str = Query(..., description="区域 ID"),
) -> list[str]:
@@ -48,26 +48,25 @@ async def get_region_nodes_endpoint(
@router.patch("/regions", summary="修改区域", response_model=None)
async def set_region_endpoint(
def set_region_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
request: Request = None,
payload: dict[str, Any] = Body(...),
) -> ChangeSet:
return set_region(network, ChangeSet(await request.json()))
return set_region(network, ChangeSet(payload))
@router.post("/regions", summary="添加区域", response_model=None)
async def add_region_endpoint(
def add_region_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
request: Request = None,
payload: dict[str, Any] = Body(...),
) -> ChangeSet:
payload = await request.json()
payload["boundary"] = [tuple(point[:2]) for point in payload.get("boundary", [])]
return add_region(network, ChangeSet(payload))
@router.delete("/regions", summary="删除区域", response_model=None)
async def delete_region_endpoint(
def delete_region_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
request: Request = None,
payload: dict[str, Any] = Body(...),
) -> ChangeSet:
return delete_region(network, ChangeSet(await request.json()))
return delete_region(network, ChangeSet(payload))
+21 -20
View File
@@ -1,5 +1,6 @@
from fastapi import APIRouter, Request, Query, Path, Body
from typing import Any, List, Dict, Union
from typing import Any
from fastapi import APIRouter, Body, Query
from app.services.tjnetwork import (
ChangeSet,
add_reservoir,
@@ -17,7 +18,7 @@ router = APIRouter()
summary="获取水库模式",
description="获取指定供水网络中所有水库的模式/属性字段定义"
)
async def fast_get_reservoir_schema(
def fast_get_reservoir_schema(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> dict[str, dict[str, Any]]:
"""
@@ -39,7 +40,7 @@ async def fast_get_reservoir_schema(
summary="添加水库",
description="在指定供水网络中添加新的水库/水源节点"
)
async def fastapi_add_reservoir(
def fastapi_add_reservoir(
network: str = Query(..., description="管网名称(或数据库名称)"),
reservoir: str = Query(..., description="水库的唯一标识符"),
x: float = Query(..., description="水库的X坐标"),
@@ -70,7 +71,7 @@ async def fastapi_add_reservoir(
summary="删除水库",
description="从指定供水网络中删除指定的水库/水源节点"
)
async def fastapi_delete_reservoir(
def fastapi_delete_reservoir(
network: str = Query(..., description="管网名称(或数据库名称)"),
reservoir: str = Query(..., description="要删除的水库的唯一标识符")
) -> ChangeSet:
@@ -94,7 +95,7 @@ async def fastapi_delete_reservoir(
summary="获取水库水头",
description="获取指定水库的供水水头/总水头值"
)
async def fastapi_get_reservoir_head(
def fastapi_get_reservoir_head(
network: str = Query(..., description="管网名称(或数据库名称)"),
reservoir: str = Query(..., description="水库的唯一标识符")
) -> float | None:
@@ -118,7 +119,7 @@ async def fastapi_get_reservoir_head(
summary="获取水库模式",
description="获取指定水库的运行模式/供水模式"
)
async def fastapi_get_reservoir_pattern(
def fastapi_get_reservoir_pattern(
network: str = Query(..., description="管网名称(或数据库名称)"),
reservoir: str = Query(..., description="水库的唯一标识符")
) -> str | None:
@@ -142,7 +143,7 @@ async def fastapi_get_reservoir_pattern(
summary="获取水库X坐标",
description="获取指定水库的X坐标位置"
)
async def fastapi_get_reservoir_x(
def fastapi_get_reservoir_x(
network: str = Query(..., description="管网名称(或数据库名称)"),
reservoir: str = Query(..., description="水库的唯一标识符")
) -> dict[str, float] | None:
@@ -166,7 +167,7 @@ async def fastapi_get_reservoir_x(
summary="获取水库Y坐标",
description="获取指定水库的Y坐标位置"
)
async def fastapi_get_reservoir_y(
def fastapi_get_reservoir_y(
network: str = Query(..., description="管网名称(或数据库名称)"),
reservoir: str = Query(..., description="水库的唯一标识符")
) -> dict[str, float] | None:
@@ -190,7 +191,7 @@ async def fastapi_get_reservoir_y(
summary="获取水库坐标",
description="获取指定水库的平面坐标(X和Y坐标)"
)
async def fastapi_get_reservoir_coord(
def fastapi_get_reservoir_coord(
network: str = Query(..., description="管网名称(或数据库名称)"),
reservoir: str = Query(..., description="水库的唯一标识符")
) -> dict[str, float] | None:
@@ -216,7 +217,7 @@ async def fastapi_get_reservoir_coord(
summary="设置水库水头",
description="更新指定水库的供水水头/总水头值"
)
async def fastapi_set_reservoir_head(
def fastapi_set_reservoir_head(
network: str = Query(..., description="管网名称(或数据库名称)"),
reservoir: str = Query(..., description="水库的唯一标识符"),
head: float = Query(..., description="新的水头值(米)")
@@ -243,7 +244,7 @@ async def fastapi_set_reservoir_head(
summary="设置水库模式",
description="更新指定水库的运行模式/供水模式"
)
async def fastapi_set_reservoir_pattern(
def fastapi_set_reservoir_pattern(
network: str = Query(..., description="管网名称(或数据库名称)"),
reservoir: str = Query(..., description="水库的唯一标识符"),
pattern: str = Query(..., description="新的运行模式")
@@ -270,7 +271,7 @@ async def fastapi_set_reservoir_pattern(
summary="设置水库X坐标",
description="更新指定水库的X坐标位置"
)
async def fastapi_set_reservoir_x(
def fastapi_set_reservoir_x(
network: str = Query(..., description="管网名称(或数据库名称)"),
reservoir: str = Query(..., description="水库的唯一标识符"),
x: float = Query(..., description="新的X坐标值")
@@ -297,7 +298,7 @@ async def fastapi_set_reservoir_x(
summary="设置水库Y坐标",
description="更新指定水库的Y坐标位置"
)
async def fastapi_set_reservoir_y(
def fastapi_set_reservoir_y(
network: str = Query(..., description="管网名称(或数据库名称)"),
reservoir: str = Query(..., description="水库的唯一标识符"),
y: float = Query(..., description="新的Y坐标值")
@@ -324,7 +325,7 @@ async def fastapi_set_reservoir_y(
summary="设置水库坐标",
description="更新指定水库的平面坐标(X和Y坐标)"
)
async def fastapi_set_reservoir_coord(
def fastapi_set_reservoir_coord(
network: str = Query(..., description="管网名称(或数据库名称)"),
reservoir: str = Query(..., description="水库的唯一标识符"),
x: float = Query(..., description="新的X坐标值"),
@@ -352,7 +353,7 @@ async def fastapi_set_reservoir_coord(
summary="获取水库属性",
description="获取指定水库的所有属性"
)
async def fastapi_get_reservoir_properties(
def fastapi_get_reservoir_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
reservoir: str = Query(..., description="水库的唯一标识符")
) -> dict[str, Any]:
@@ -375,7 +376,7 @@ async def fastapi_get_reservoir_properties(
summary="获取所有水库属性",
description="获取指定供水网络中所有水库的属性"
)
async def fastapi_get_all_reservoir_properties(
def fastapi_get_all_reservoir_properties(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> list[dict[str, Any]]:
"""
@@ -398,10 +399,10 @@ async def fastapi_get_all_reservoir_properties(
summary="设置水库属性",
description="批量更新指定水库的多个属性"
)
async def fastapi_set_reservoir_properties(
def fastapi_set_reservoir_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
reservoir: str = Query(..., description="水库的唯一标识符"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""
设置水库的多个属性。
@@ -416,6 +417,6 @@ async def fastapi_set_reservoir_properties(
Returns:
包含操作变更集的ChangeSet对象
"""
props = await req.json()
props = payload
ps = {"id": reservoir} | props
return set_reservoir(network, ChangeSet(ps))
+9 -8
View File
@@ -1,5 +1,6 @@
from fastapi import APIRouter, Request, Query, Path, Body
from typing import Any, List, Dict, Union
from typing import Any
from fastapi import APIRouter, Body, Query
from app.services.tjnetwork import (
ChangeSet,
get_tag,
@@ -19,7 +20,7 @@ router = APIRouter()
summary="获取标签属性架构",
description="获取指定水网的标签(Tag)属性架构定义"
)
async def fastapi_get_tag_schema(
def fastapi_get_tag_schema(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> dict[str, dict[str, Any]]:
"""获取标签的属性架构。"""
@@ -30,7 +31,7 @@ async def fastapi_get_tag_schema(
summary="获取标签信息",
description="获取指定类型和ID的标签信息"
)
async def fastapi_get_tag(
def fastapi_get_tag(
network: str = Query(..., description="管网名称(或数据库名称)"),
t_type: str = Query(..., description="标签类型"),
id: str = Query(..., description="元素ID")
@@ -43,7 +44,7 @@ async def fastapi_get_tag(
summary="获取所有标签",
description="获取指定水网中的所有标签信息"
)
async def fastapi_get_tags(
def fastapi_get_tags(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> list[dict[str, Any]]:
"""获取水网中所有标签的列表。"""
@@ -56,10 +57,10 @@ async def fastapi_get_tags(
summary="设置标签",
description="为指定元素设置或修改标签信息"
)
async def fastapi_set_tag(
def fastapi_set_tag(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置标签信息。"""
props = await req.json()
props = payload
return set_tag(network, ChangeSet(props))
+33 -32
View File
@@ -1,5 +1,6 @@
from fastapi import APIRouter, Request, Query, Path, Body
from typing import Any, List, Dict, Union
from typing import Any
from fastapi import APIRouter, Body, Query
from app.services.tjnetwork import (
ChangeSet,
add_tank,
@@ -13,7 +14,7 @@ from app.services.tjnetwork import (
router = APIRouter()
@router.get("/network-schemas/tank", summary="获取水箱模式", description="获取指定网络的水箱数据结构模式定义")
async def fast_get_tank_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fast_get_tank_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""
获取水箱的数据结构模式。
@@ -26,7 +27,7 @@ async def fast_get_tank_schema(network: str = Query(..., description="管网名
return get_tank_schema(network)
@router.post("/tanks", summary="新增水箱", description="向指定网络中新增一个水箱", response_model=None)
async def fastapi_add_tank(
def fastapi_add_tank(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
x: float = Query(..., description="X坐标"),
@@ -70,7 +71,7 @@ async def fastapi_add_tank(
return add_tank(network, ChangeSet(ps))
@router.delete("/tanks", summary="删除水箱", description="删除指定网络中的水箱", response_model=None)
async def fastapi_delete_tank(
def fastapi_delete_tank(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
) -> ChangeSet:
@@ -88,7 +89,7 @@ async def fastapi_delete_tank(
return delete_tank(network, ChangeSet(ps))
@router.get("/tanks/elevation", summary="获取水箱标高", description="获取指定水箱的标高值")
async def fastapi_get_tank_elevation(
def fastapi_get_tank_elevation(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
) -> float | None:
@@ -106,7 +107,7 @@ async def fastapi_get_tank_elevation(
return ps["elevation"]
@router.get("/tanks/init-level", summary="获取水箱初始水位", description="获取指定水箱的初始水位值")
async def fastapi_get_tank_init_level(
def fastapi_get_tank_init_level(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
) -> float | None:
@@ -124,7 +125,7 @@ async def fastapi_get_tank_init_level(
return ps["init_level"]
@router.get("/tanks/min-level", summary="获取水箱最小水位", description="获取指定水箱的最小水位值")
async def fastapi_get_tank_min_level(
def fastapi_get_tank_min_level(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
) -> float | None:
@@ -142,7 +143,7 @@ async def fastapi_get_tank_min_level(
return ps["min_level"]
@router.get("/tanks/max-level", summary="获取水箱最大水位", description="获取指定水箱的最大水位值")
async def fastapi_get_tank_max_level(
def fastapi_get_tank_max_level(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
) -> float | None:
@@ -160,7 +161,7 @@ async def fastapi_get_tank_max_level(
return ps["max_level"]
@router.get("/tanks/diameter", summary="获取水箱直径", description="获取指定水箱的直径值")
async def fastapi_get_tank_diameter(
def fastapi_get_tank_diameter(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
) -> float | None:
@@ -178,7 +179,7 @@ async def fastapi_get_tank_diameter(
return ps["diameter"]
@router.get("/tanks/min-vol", summary="获取水箱最小体积", description="获取指定水箱的最小体积值")
async def fastapi_get_tank_min_vol(
def fastapi_get_tank_min_vol(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
) -> float | None:
@@ -196,7 +197,7 @@ async def fastapi_get_tank_min_vol(
return ps["min_vol"]
@router.get("/tanks/vol-curve", summary="获取水箱容积曲线", description="获取指定水箱的容积曲线标识")
async def fastapi_get_tank_vol_curve(
def fastapi_get_tank_vol_curve(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
) -> str | None:
@@ -214,7 +215,7 @@ async def fastapi_get_tank_vol_curve(
return ps["vol_curve"]
@router.get("/tanks/overflow", summary="获取水箱溢流口", description="获取指定水箱的溢流口配置")
async def fastapi_get_tank_overflow(
def fastapi_get_tank_overflow(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
) -> str | None:
@@ -232,7 +233,7 @@ async def fastapi_get_tank_overflow(
return ps["overflow"]
@router.get("/tanks/x", summary="获取水箱X坐标", description="获取指定水箱的X坐标值")
async def fastapi_get_tank_x(
def fastapi_get_tank_x(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
) -> float:
@@ -250,7 +251,7 @@ async def fastapi_get_tank_x(
return ps["x"]
@router.get("/tanks/y", summary="获取水箱Y坐标", description="获取指定水箱的Y坐标值")
async def fastapi_get_tank_y(
def fastapi_get_tank_y(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
) -> float:
@@ -268,7 +269,7 @@ async def fastapi_get_tank_y(
return ps["y"]
@router.get("/tanks/coord", summary="获取水箱坐标", description="获取指定水箱的X和Y坐标")
async def fastapi_get_tank_coord(
def fastapi_get_tank_coord(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
) -> dict[str, float]:
@@ -287,7 +288,7 @@ async def fastapi_get_tank_coord(
return coord
@router.patch("/tanks/elevation", summary="设置水箱标高", description="设置指定水箱的标高值", response_model=None)
async def fastapi_set_tank_elevation(
def fastapi_set_tank_elevation(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
elevation: float = Query(..., description="新的标高值")
@@ -307,7 +308,7 @@ async def fastapi_set_tank_elevation(
return set_tank(network, ChangeSet(ps))
@router.patch("/tanks/init-level", summary="设置水箱初始水位", description="设置指定水箱的初始水位值", response_model=None)
async def fastapi_set_tank_init_level(
def fastapi_set_tank_init_level(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
init_level: float = Query(..., description="新的初始水位值")
@@ -327,7 +328,7 @@ async def fastapi_set_tank_init_level(
return set_tank(network, ChangeSet(ps))
@router.patch("/tanks/min-level", summary="设置水箱最小水位", description="设置指定水箱的最小水位值", response_model=None)
async def fastapi_set_tank_min_level(
def fastapi_set_tank_min_level(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
min_level: float = Query(..., description="新的最小水位值")
@@ -347,7 +348,7 @@ async def fastapi_set_tank_min_level(
return set_tank(network, ChangeSet(ps))
@router.patch("/tanks/max-level", summary="设置水箱最大水位", description="设置指定水箱的最大水位值", response_model=None)
async def fastapi_set_tank_max_level(
def fastapi_set_tank_max_level(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
max_level: float = Query(..., description="新的最大水位值")
@@ -367,7 +368,7 @@ async def fastapi_set_tank_max_level(
return set_tank(network, ChangeSet(ps))
@router.patch("/tanks/diameter", summary="设置水箱直径", description="设置指定水箱的直径值", response_model=None)
async def fastapi_set_tank_diameter(
def fastapi_set_tank_diameter(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
diameter: float = Query(..., description="新的直径值")
@@ -387,7 +388,7 @@ async def fastapi_set_tank_diameter(
return set_tank(network, ChangeSet(ps))
@router.patch("/tanks/min-vol", summary="设置水箱最小体积", description="设置指定水箱的最小体积值", response_model=None)
async def fastapi_set_tank_min_vol(
def fastapi_set_tank_min_vol(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
min_vol: float = Query(..., description="新的最小体积值")
@@ -407,7 +408,7 @@ async def fastapi_set_tank_min_vol(
return set_tank(network, ChangeSet(ps))
@router.patch("/tanks/vol-curve", summary="设置水箱容积曲线", description="设置指定水箱的容积曲线标识", response_model=None)
async def fastapi_set_tank_vol_curve(
def fastapi_set_tank_vol_curve(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
vol_curve: str = Query(..., description="新的容积曲线标识")
@@ -427,7 +428,7 @@ async def fastapi_set_tank_vol_curve(
return set_tank(network, ChangeSet(ps))
@router.patch("/tanks/overflow", summary="设置水箱溢流口", description="设置指定水箱的溢流口配置", response_model=None)
async def fastapi_set_tank_overflow(
def fastapi_set_tank_overflow(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
overflow: str = Query(..., description="新的溢流口配置")
@@ -447,7 +448,7 @@ async def fastapi_set_tank_overflow(
return set_tank(network, ChangeSet(ps))
@router.patch("/tanks/x", summary="设置水箱X坐标", description="设置指定水箱的X坐标值", response_model=None)
async def fastapi_set_tank_x(
def fastapi_set_tank_x(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
x: float = Query(..., description="新的X坐标值")
@@ -467,7 +468,7 @@ async def fastapi_set_tank_x(
return set_tank(network, ChangeSet(ps))
@router.patch("/tanks/y", summary="设置水箱Y坐标", description="设置指定水箱的Y坐标值", response_model=None)
async def fastapi_set_tank_y(
def fastapi_set_tank_y(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
y: float = Query(..., description="新的Y坐标值")
@@ -487,7 +488,7 @@ async def fastapi_set_tank_y(
return set_tank(network, ChangeSet(ps))
@router.patch("/tanks/coord", summary="设置水箱坐标", description="设置指定水箱的X和Y坐标", response_model=None)
async def fastapi_set_tank_coord(
def fastapi_set_tank_coord(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
x: float = Query(..., description="新的X坐标值"),
@@ -509,7 +510,7 @@ async def fastapi_set_tank_coord(
return set_tank(network, ChangeSet(ps))
@router.get("/tanks/properties", summary="获取水箱属性", description="获取指定水箱的所有属性")
async def fastapi_get_tank_properties(
def fastapi_get_tank_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
) -> dict[str, Any]:
@@ -526,7 +527,7 @@ async def fastapi_get_tank_properties(
return get_tank(network, tank)
@router.get("/tanks", summary="获取所有水箱属性", description="获取指定网络中所有水箱的属性")
async def fastapi_get_all_tank_properties(
def fastapi_get_all_tank_properties(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> list[dict[str, Any]]:
"""
@@ -543,10 +544,10 @@ async def fastapi_get_all_tank_properties(
return results
@router.patch("/tanks/properties", summary="设置水箱属性", description="批量设置指定水箱的多个属性", response_model=None)
async def fastapi_set_tank_properties(
def fastapi_set_tank_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""
批量设置水箱的属性。
@@ -559,6 +560,6 @@ async def fastapi_set_tank_properties(
Returns:
包含变更信息的ChangeSet对象
"""
props = await req.json()
props = payload
ps = {"id": tank} | props
return set_tank(network, ChangeSet(ps))
+22 -21
View File
@@ -1,5 +1,6 @@
from fastapi import APIRouter, Request, Query, Path, Body
from typing import Any, List, Dict, Union
from typing import Any
from fastapi import APIRouter, Body, Query
from app.services.tjnetwork import (
ChangeSet,
VALVES_TYPE_PRV,
@@ -18,7 +19,7 @@ router = APIRouter()
summary="获取阀门架构",
description="获取指定水网中所有阀门的架构和字段定义",
)
async def fastapi_get_valve_schema(
def fastapi_get_valve_schema(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> dict[str, dict[str, Any]]:
"""
@@ -34,7 +35,7 @@ async def fastapi_get_valve_schema(
summary="添加阀门",
description="在指定的水网中添加新的阀门",
)
async def fastapi_add_valve(
def fastapi_add_valve(
network: str = Query(..., description="管网名称(或数据库名称)"),
valve: str = Query(..., description="阀门ID"),
node1: str = Query(..., description="起点节点ID"),
@@ -67,7 +68,7 @@ async def fastapi_add_valve(
summary="删除阀门",
description="从指定的水网中删除指定的阀门",
)
async def fastapi_delete_valve(
def fastapi_delete_valve(
network: str = Query(..., description="管网名称(或数据库名称)"),
valve: str = Query(..., description="阀门ID"),
) -> ChangeSet:
@@ -84,7 +85,7 @@ async def fastapi_delete_valve(
summary="获取阀门起点节点",
description="获取指定阀门连接的起点节点ID",
)
async def fastapi_get_valve_node1(
def fastapi_get_valve_node1(
network: str = Query(..., description="管网名称(或数据库名称)"),
valve: str = Query(..., description="阀门ID"),
) -> str | None:
@@ -101,7 +102,7 @@ async def fastapi_get_valve_node1(
summary="获取阀门终点节点",
description="获取指定阀门连接的终点节点ID",
)
async def fastapi_get_valve_node2(
def fastapi_get_valve_node2(
network: str = Query(..., description="管网名称(或数据库名称)"),
valve: str = Query(..., description="阀门ID"),
) -> str | None:
@@ -118,7 +119,7 @@ async def fastapi_get_valve_node2(
summary="获取阀门直径",
description="获取指定阀门的直径",
)
async def fastapi_get_valve_diameter(
def fastapi_get_valve_diameter(
network: str = Query(..., description="管网名称(或数据库名称)"),
valve: str = Query(..., description="阀门ID"),
) -> float | None:
@@ -135,7 +136,7 @@ async def fastapi_get_valve_diameter(
summary="获取阀门类型",
description="获取指定阀门的类型",
)
async def fastapi_get_valve_type(
def fastapi_get_valve_type(
network: str = Query(..., description="管网名称(或数据库名称)"),
valve: str = Query(..., description="阀门ID"),
) -> str | None:
@@ -152,7 +153,7 @@ async def fastapi_get_valve_type(
summary="获取阀门开度",
description="获取指定阀门的开度/设置值",
)
async def fastapi_get_valve_setting(
def fastapi_get_valve_setting(
network: str = Query(..., description="管网名称(或数据库名称)"),
valve: str = Query(..., description="阀门ID"),
) -> float | None:
@@ -169,7 +170,7 @@ async def fastapi_get_valve_setting(
summary="获取阀门损失系数",
description="获取指定阀门的损失系数",
)
async def fastapi_get_valve_minor_loss(
def fastapi_get_valve_minor_loss(
network: str = Query(..., description="管网名称(或数据库名称)"),
valve: str = Query(..., description="阀门ID"),
) -> float | None:
@@ -187,7 +188,7 @@ async def fastapi_get_valve_minor_loss(
summary="设置阀门起点节点",
description="设置指定阀门的起点节点",
)
async def fastapi_set_valve_node1(
def fastapi_set_valve_node1(
network: str = Query(..., description="管网名称(或数据库名称)"),
valve: str = Query(..., description="阀门ID"),
node1: str = Query(..., description="新的起点节点ID"),
@@ -206,7 +207,7 @@ async def fastapi_set_valve_node1(
summary="设置阀门终点节点",
description="设置指定阀门的终点节点",
)
async def fastapi_set_valve_node2(
def fastapi_set_valve_node2(
network: str = Query(..., description="管网名称(或数据库名称)"),
valve: str = Query(..., description="阀门ID"),
node2: str = Query(..., description="新的终点节点ID"),
@@ -225,7 +226,7 @@ async def fastapi_set_valve_node2(
summary="设置阀门直径",
description="设置指定阀门的直径",
)
async def fastapi_set_valve_diameter(
def fastapi_set_valve_diameter(
network: str = Query(..., description="管网名称(或数据库名称)"),
valve: str = Query(..., description="阀门ID"),
diameter: float = Query(..., description="新的直径值(mm)"),
@@ -244,7 +245,7 @@ async def fastapi_set_valve_diameter(
summary="设置阀门类型",
description="设置指定阀门的类型",
)
async def fastapi_set_valve_type(
def fastapi_set_valve_type(
network: str = Query(..., description="管网名称(或数据库名称)"),
valve: str = Query(..., description="阀门ID"),
type: str = Query(..., description="新的阀门类型"),
@@ -263,7 +264,7 @@ async def fastapi_set_valve_type(
summary="设置阀门开度",
description="设置指定阀门的开度/设置值",
)
async def fastapi_set_valve_setting(
def fastapi_set_valve_setting(
network: str = Query(..., description="管网名称(或数据库名称)"),
valve: str = Query(..., description="阀门ID"),
setting: float = Query(..., description="新的开度值"),
@@ -281,7 +282,7 @@ async def fastapi_set_valve_setting(
summary="获取阀门所有属性",
description="获取指定阀门的所有属性",
)
async def fastapi_get_valve_properties(
def fastapi_get_valve_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
valve: str = Query(..., description="阀门ID"),
) -> dict[str, Any]:
@@ -297,7 +298,7 @@ async def fastapi_get_valve_properties(
summary="获取所有阀门属性",
description="获取指定水网中所有阀门的属性",
)
async def fastapi_get_all_valve_properties(
def fastapi_get_all_valve_properties(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> list[dict[str, Any]]:
"""
@@ -315,16 +316,16 @@ async def fastapi_get_all_valve_properties(
summary="批量设置阀门属性",
description="批量设置指定阀门的多个属性",
)
async def fastapi_set_valve_properties(
def fastapi_set_valve_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
valve: str = Query(..., description="阀门ID"),
req: Request = None,
payload: dict[str, Any] = Body(...),
) -> ChangeSet:
"""
批量设置阀门的属性。
更新指定阀门的一个或多个属性,通过JSON请求体传递要更新的属性。
"""
props = await req.json()
props = payload
ps = {"id": valve} | props
return set_valve(network, ChangeSet(ps))
+773
View File
@@ -0,0 +1,773 @@
"""Project-scoped APIs for operational tasks, evidence and approved actions."""
from __future__ import annotations
from datetime import datetime
from typing import Literal
from uuid import UUID
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
from psycopg import AsyncConnection
from app.auth.permissions import (
OPERATIONAL_TASK_EXECUTE,
OPERATIONAL_TASK_EXTERNAL_SYNC,
OPERATIONAL_TASK_VIEW,
OPERATIONAL_TASK_WRITE,
SCADA_VIEW,
SIMULATION_VIEW,
permissions_for_context,
require_permission,
)
from app.auth.project_dependencies import (
ProjectContext,
get_project_context,
get_project_pg_connection,
)
from app.domain.domain_errors import DomainError
from app.domain.schemas.workbench_runtime import WorkbenchDateTime
from app.domain.schemas.operational_data import TaskDataQuery, TaskDataSnapshot
from app.domain.schemas.operational_task import (
ActionDecisionCommand,
ActionExecuteCommand,
ActionProposalCommand,
ActionReconcileCommand,
WorkOrderDraftUpdateCommand,
WorkOrderConfirmDispatchCommand,
TaskAccessRequest,
TaskAccessRequestCreate,
TaskAccessRequestDecision,
AgentArtifactsCommand,
AgentArtifactsResult,
AgentRunCancelCommand,
AgentRunCommand,
AgentRunDispatchFailureCommand,
AgentRunFailureCommand,
AgentTaskContext,
DispatchDueSchedulesCommand,
DispatchDueSchedulesResult,
EvidenceRecord,
ExternalFeedbackCommand,
ExternalFeedbackResult,
ExternalSourceWithdrawalCommand,
HumanApprovalAction,
HumanEvidenceCommand,
InvestigationReportPage,
InvestigationReportRevision,
NotificationAcknowledgeCommand,
OperationalTaskCreate,
OperationalTaskDetail,
OperationalTaskNotification,
OperationalTaskNotificationPage,
OperationalTaskPage,
OperationalTaskRetryCommand,
OperationalTaskRun,
OperationalTaskRunPage,
OperationalTaskSchedule,
OperationalTaskScheduleCreate,
OperationalTaskSchedulePage,
OperationalTaskScheduleUpdate,
RecoverExpiredLeasesCommand,
RecoverExpiredLeasesResult,
TaskStatus,
TaskDataQueryResult,
TaskType,
TaskPresentationEventCommand,
TaskPresentationEventResult,
TaskPresentationCursor,
)
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
from app.infra.integrations.operational_actions import (
OperationalActionDispatcher,
get_operational_action_dispatcher,
)
from app.infra.integrations.operational_data import TaskDataError, read_task_data
from app.services.operational_tasks import OperationalTaskService
router = APIRouter()
def _service(ctx: ProjectContext, conn: AsyncConnection) -> OperationalTaskService:
return OperationalTaskService(
OperationalTaskRepository(conn, ctx.project_id),
ctx,
_task_permissions(ctx),
)
def _task_permissions(ctx: ProjectContext) -> set[str]:
# Investigations use preconfigured project access, including service accounts.
# Do not force an already-authorized reader through legacy per-task delegation.
return set(permissions_for_context(ctx))
def _raise_domain(exc: DomainError):
raise HTTPException(status_code=exc.status, detail=exc.payload()) from exc
@router.get("/operational-tasks/{task_id}/reports", response_model=InvestigationReportPage)
async def list_investigation_reports(
task_id: UUID,
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
items, next_offset = await _service(ctx, conn).list_reports(task_id, limit, offset)
return InvestigationReportPage(items=items, next_offset=next_offset)
except DomainError as exc:
_raise_domain(exc)
@router.get("/operational-tasks/{task_id}/reports/{revision}", response_model=InvestigationReportRevision)
async def get_investigation_report(
task_id: UUID,
revision: int,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).report(task_id, revision)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks",
response_model=OperationalTaskDetail,
status_code=status.HTTP_201_CREATED,
)
async def create_operational_task(
command: OperationalTaskCreate,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).create(command)
except DomainError as exc:
_raise_domain(exc)
@router.get("/operational-tasks", response_model=OperationalTaskPage)
async def list_operational_tasks(
task_type: TaskType | None = None,
task_status: TaskStatus | None = Query(default=None, alias="status"),
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
peripheral_work_order_id: UUID | None = Query(
default=None, description="Only tasks sourced from or linked to this peripheral work order",
),
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
items, next_offset = await _service(ctx, conn).list(
task_type=task_type, status=task_status, limit=limit, offset=offset,
**({"peripheral_work_order_id": peripheral_work_order_id}
if peripheral_work_order_id is not None else {}),
)
return OperationalTaskPage(
items=items, limit=limit, offset=offset, next_offset=next_offset
)
except DomainError as exc:
_raise_domain(exc)
@router.get("/operational-task-runs", response_model=OperationalTaskRunPage)
async def list_operational_task_runs(
run_status: Literal[
"queued", "claimed", "running", "succeeded", "failed", "timed_out", "cancelled"
] | None = Query(default=None, alias="status"),
task_type: TaskType | None = None,
task_created_from: WorkbenchDateTime | None = None,
limit: int = Query(default=50, ge=1, le=100),
cursor: str | None = None,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
cursor_created_at = None
cursor_run_id = None
if cursor:
try:
timestamp, run_id = cursor.rsplit("|", 1)
cursor_created_at = datetime.fromisoformat(timestamp)
cursor_run_id = UUID(run_id)
except (ValueError, TypeError) as exc:
raise HTTPException(status_code=400, detail="Invalid run cursor") from exc
try:
items, next_cursor = await _service(ctx, conn).list_runs(
status=run_status,
task_type=task_type,
task_created_from=task_created_from,
limit=limit,
cursor_created_at=cursor_created_at,
cursor_run_id=cursor_run_id,
)
return OperationalTaskRunPage(items=items, next_cursor=next_cursor)
except DomainError as exc:
_raise_domain(exc)
@router.get("/operational-tasks/{task_id}/runs", response_model=OperationalTaskRunPage)
async def list_task_run_history(
task_id: UUID,
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return OperationalTaskRunPage(items=await _service(ctx, conn).task_runs(task_id, limit, offset))
except DomainError as exc:
_raise_domain(exc)
@router.get("/operational-tasks/{task_id}/runs/{run_id}", response_model=OperationalTaskRun)
async def get_task_run(
task_id: UUID, run_id: UUID,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).task_run(task_id, run_id)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/schedules",
response_model=OperationalTaskSchedule,
status_code=status.HTTP_201_CREATED,
)
async def create_operational_schedule(
command: OperationalTaskScheduleCreate,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).create_schedule(command)
except DomainError as exc:
_raise_domain(exc)
@router.get(
"/operational-tasks/schedules", response_model=OperationalTaskSchedulePage
)
async def list_operational_schedules(
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return OperationalTaskSchedulePage(items=await _service(ctx, conn).list_schedules())
except DomainError as exc:
_raise_domain(exc)
@router.patch(
"/operational-tasks/schedules/{schedule_id}",
response_model=OperationalTaskSchedule,
)
async def update_operational_schedule(
schedule_id: UUID,
command: OperationalTaskScheduleUpdate,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).update_schedule(schedule_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/schedules/dispatch-due",
response_model=DispatchDueSchedulesResult,
)
async def dispatch_due_operational_schedules(
command: DispatchDueSchedulesCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).dispatch_due_schedules(command.limit)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/leases/recover",
response_model=RecoverExpiredLeasesResult,
)
async def recover_operational_task_leases(
command: RecoverExpiredLeasesCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).recover_expired_leases(
command.limit, task_type=command.task_type,
task_created_from=command.task_created_from,
)
except DomainError as exc:
_raise_domain(exc)
@router.get(
"/operational-task-notifications",
response_model=OperationalTaskNotificationPage,
)
async def list_operational_task_notifications(
unread_only: bool = False,
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
items, next_offset = await _service(ctx, conn).list_notifications(
unread_only=unread_only, limit=limit, offset=offset
)
return OperationalTaskNotificationPage(
items=items, limit=limit, offset=offset, next_offset=next_offset
)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-task-notifications/{notification_id}/acknowledge",
response_model=OperationalTaskNotification,
)
async def acknowledge_operational_task_notification(
notification_id: UUID,
command: NotificationAcknowledgeCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).acknowledge_notification(notification_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.get("/operational-tasks/{task_id}", response_model=OperationalTaskDetail)
async def get_operational_task(
task_id: UUID,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).detail(task_id)
except DomainError as exc:
_raise_domain(exc)
@router.post("/operational-tasks/{task_id}/retry", response_model=OperationalTaskDetail)
async def retry_operational_task(
task_id: UUID,
command: OperationalTaskRetryCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).retry_task(task_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/runs/{run_id}/claim",
response_model=OperationalTaskRun,
)
async def claim_operational_task_run(
task_id: UUID,
run_id: UUID,
command: AgentRunCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).claim_run(task_id, run_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/runs/{run_id}/heartbeat",
response_model=OperationalTaskRun,
)
async def heartbeat_operational_task_run(
task_id: UUID,
run_id: UUID,
command: AgentRunCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).heartbeat_run(task_id, run_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/runs/{run_id}/complete",
response_model=OperationalTaskDetail,
)
async def complete_operational_task_run(
task_id: UUID,
run_id: UUID,
command: AgentRunCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).complete_run(task_id, run_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/runs/{run_id}/fail",
response_model=OperationalTaskDetail,
)
async def fail_operational_task_run(
task_id: UUID,
run_id: UUID,
command: AgentRunFailureCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).fail_run(task_id, run_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/runs/{run_id}/cancel",
response_model=OperationalTaskDetail,
)
async def cancel_operational_task_run(
task_id: UUID,
run_id: UUID,
command: AgentRunCancelCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).cancel_run(task_id, run_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/runs/{run_id}/dispatch-failure",
response_model=OperationalTaskRun,
)
async def record_operational_task_run_dispatch_failure(
task_id: UUID,
run_id: UUID,
command: AgentRunDispatchFailureCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).record_dispatch_failure(task_id, run_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.get(
"/operational-tasks/{task_id}/agent-context", response_model=AgentTaskContext
)
async def get_operational_task_agent_context(
task_id: UUID,
report_revision: int | None = Query(default=None, ge=1),
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).agent_context(task_id, report_revision)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/agent-artifacts",
response_model=AgentArtifactsResult,
)
async def publish_operational_task_artifacts(
task_id: UUID,
command: AgentArtifactsCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).publish_artifacts(task_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.get("/operational-tasks/{task_id}/presentation-cursor", response_model=TaskPresentationCursor)
async def get_operational_task_presentation_cursor(
task_id: UUID,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).presentation_cursor(task_id)
except DomainError as exc:
_raise_domain(exc)
@router.post("/operational-tasks/{task_id}/presentation-events", response_model=TaskPresentationEventResult)
async def publish_operational_task_presentation_event(
task_id: UUID,
command: TaskPresentationEventCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).publish_presentation_event(task_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/data-query", response_model=TaskDataQueryResult
)
async def query_operational_task_data(
task_id: UUID,
query: TaskDataQuery,
authorization: str | None = Header(default=None, alias="Authorization"),
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
service = _service(ctx, conn)
try:
if not await service.can_read_source(task_id, query.source):
raise DomainError("PERMISSION_DENIED", "Task has no approved access to this data source", status=403)
source_permission = SCADA_VIEW if query.source.startswith("scada_") else SIMULATION_VIEW
snapshot = await read_task_data(
context=ctx, query=query, credential=authorization,
delegated=source_permission not in _task_permissions(ctx),
)
evidence, stored_snapshot = await service.record_data_snapshot(task_id, snapshot)
return TaskDataQueryResult(evidence=evidence, snapshot=stored_snapshot)
except TaskDataError as exc:
raise HTTPException(status_code=exc.status, detail=exc.detail) from exc
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/access-requests",
response_model=TaskAccessRequest,
status_code=status.HTTP_201_CREATED,
)
async def request_operational_task_access(
task_id: UUID,
command: TaskAccessRequestCreate,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).request_access(task_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/access-requests/{access_request_id}/decision",
response_model=TaskAccessRequest,
)
async def decide_operational_task_access(
task_id: UUID,
access_request_id: UUID,
command: TaskAccessRequestDecision,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).decide_access(task_id, access_request_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/human-evidence",
response_model=OperationalTaskDetail,
)
async def add_operational_task_human_evidence(
task_id: UUID,
command: HumanEvidenceCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).add_human_evidence(task_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.get(
"/operational-tasks/{task_id}/evidence/{evidence_id}/snapshot",
response_model=TaskDataSnapshot,
)
async def get_operational_task_data_snapshot(
task_id: UUID,
evidence_id: str,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).data_snapshot(task_id, evidence_id)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/actions",
response_model=HumanApprovalAction,
status_code=status.HTTP_201_CREATED,
)
async def propose_operational_task_action(
task_id: UUID,
command: ActionProposalCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).propose_action(task_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/actions/{action_id}/decision",
response_model=HumanApprovalAction,
)
async def decide_operational_task_action(
task_id: UUID,
action_id: UUID,
command: ActionDecisionCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).decide_action(task_id, action_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.patch(
"/operational-tasks/{task_id}/actions/{action_id}/draft",
response_model=HumanApprovalAction,
)
async def update_operational_work_order_draft(
task_id: UUID,
action_id: UUID,
command: WorkOrderDraftUpdateCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).update_work_order_draft(task_id, action_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/actions/{action_id}/confirm-dispatch",
response_model=HumanApprovalAction,
)
async def confirm_operational_work_order_dispatch(
task_id: UUID,
action_id: UUID,
command: WorkOrderConfirmDispatchCommand,
authorization: str | None = Header(default=None, alias="Authorization"),
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
dispatcher: OperationalActionDispatcher = Depends(get_operational_action_dispatcher),
):
try:
return await _service(ctx, conn).confirm_work_order_dispatch(
task_id, action_id, command, dispatcher, authorization
)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/actions/{action_id}/execute",
response_model=HumanApprovalAction,
)
async def execute_operational_task_action(
task_id: UUID,
action_id: UUID,
command: ActionExecuteCommand,
authorization: str | None = Header(default=None, alias="Authorization"),
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
dispatcher: OperationalActionDispatcher = Depends(get_operational_action_dispatcher),
):
try:
return await _service(ctx, conn).submit_action(
task_id, action_id, command, dispatcher, authorization
)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/actions/{action_id}/reconcile",
response_model=HumanApprovalAction,
)
async def reconcile_operational_task_action(
task_id: UUID,
action_id: UUID,
command: ActionReconcileCommand,
authorization: str | None = Header(default=None, alias="Authorization"),
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
dispatcher: OperationalActionDispatcher = Depends(get_operational_action_dispatcher),
):
try:
return await _service(ctx, conn).reconcile_action(
task_id, action_id, command, dispatcher, authorization
)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/external-feedback",
response_model=ExternalFeedbackResult,
)
async def synchronize_operational_task_feedback(
task_id: UUID,
command: ExternalFeedbackCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXTERNAL_SYNC)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).external_feedback(task_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/withdraw-external-source",
response_model=OperationalTaskDetail,
)
async def withdraw_operational_task_external_source(
task_id: UUID,
command: ExternalSourceWithdrawalCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXTERNAL_SYNC)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).withdraw_external_source(task_id, command)
except DomainError as exc:
_raise_domain(exc)
+60
View File
@@ -0,0 +1,60 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import ValidationError
from app.auth.project_dependencies import ProjectContext, get_metadata_repository, get_project_context
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
from app.domain.schemas.project_map_config import PlaceSearchConfig, ProjectMapConfig
from app.domain.schemas.place_search import PlaceSearchItem, PlaceSearchRequest, PlaceSearchResponse, ReverseGeocodingRequest
from app.services.place_search import PlaceSearchError, search_tianditu_places
from app.services.reverse_geocoding import reverse_geocode_tianditu
router = APIRouter()
async def get_place_search_config(
ctx: ProjectContext = Depends(get_project_context),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> PlaceSearchConfig:
project = await metadata_repo.get_project_by_id(ctx.project_id)
if project is None:
raise HTTPException(status_code=404, detail="Project not found")
try:
config = ProjectMapConfig.model_validate(project.map_config or {}).place_search
except ValidationError:
raise HTTPException(status_code=503, detail="当前项目地名搜索配置无效") from None
if config is None or not config.enabled:
raise HTTPException(status_code=503, detail="当前项目暂未配置地名搜索")
return config
@router.post(
"/place-search-requests",
response_model=PlaceSearchResponse,
summary="Search project places",
description="调用天地图地名搜索,按当前项目 map_config.place_search 的行政区范围,只返回前 10 个可定位候选,不提供分页。",
)
async def tianditu_place_search(
request: PlaceSearchRequest,
config: PlaceSearchConfig = Depends(get_place_search_config),
) -> PlaceSearchResponse:
try:
return await search_tianditu_places(request, config=config)
except PlaceSearchError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
@router.post(
"/reverse-geocoding-requests",
response_model=PlaceSearchItem,
summary="Reverse geocode a project map point",
description="查询 EPSG:4326 地图取点的地名和地址,复用当前项目的地名搜索配置并保留输入坐标。",
)
async def tianditu_reverse_geocode(
request: ReverseGeocodingRequest,
config: PlaceSearchConfig = Depends(get_place_search_config),
) -> PlaceSearchItem:
try:
return await reverse_geocode_tianditu(request, config=config)
except PlaceSearchError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
+5 -398
View File
@@ -1,43 +1,18 @@
import json
from fastapi import APIRouter, Request, HTTPException, Query, Path, Depends
from fastapi.responses import PlainTextResponse
from typing import Any, Dict, List
from fastapi import APIRouter, HTTPException, Query, Depends
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
from app.auth.project_dependencies import get_metadata_repository
from app.auth.permissions import (
ENVIRONMENT_MANAGE,
require_permission,
from app.auth.project_dependencies import (
get_metadata_repository,
)
from app.domain.schemas.metadata import ProjectMetaResponse
import app.services.project_info as project_info
from app.services.tjnetwork import (
ChangeSet,
list_project,
have_project,
create_project,
delete_project,
is_project_open,
open_project,
close_project,
copy_project,
export_inp,
read_inp,
dump_inp,
get_all_vertices,
get_all_scada_info,
convert_inp_v3_to_v2,
)
# For inp file upload/download
import os
from fastapi import Response, status
from fastapi.responses import FileResponse
inpDir = "data/" # Assuming data directory exists or is defined somewhere.
# In main.py it was likely global. For safety, let's use a relative path or get from config.
# But let's stick to what main.py probably used or a default.
router = APIRouter()
lockedPrjs: Dict[str, str] = {}
@router.get("/projects/current", summary="获取项目信息", description="从数据库获取项目的详细信息,包括地图范围等。", response_model=ProjectMetaResponse)
async def get_project_info_endpoint(
@@ -59,109 +34,13 @@ async def get_project_info_endpoint(
description=project_detail.description,
gs_workspace=project_detail.gs_workspace,
map_extent=project_detail.map_extent,
map_config=getattr(project_detail, "map_config", {}) or {},
status=project_detail.status,
project_role="viewer", # Default role for public access
)
@router.get("/project-codes", summary="获取项目列表", description="获取服务器上所有可用的供水管网项目名称列表。")
async def list_projects_endpoint() -> list[str]:
"""
获取项目列表
返回所有已创建项目的名称列表。
"""
return list_project()
@router.get("/projects/existence", summary="检查项目是否存在", description="检查指定名称的项目是否存在。")
async def have_project_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)")
):
"""
检查项目是否存在
- **network**: 管网名称(或数据库名称)
"""
return have_project(network)
@router.post("/projects", summary="创建新项目", description="创建一个新的供水管网项目。如果项目已存在,可能会覆盖或报错(取决于底层实现)。")
async def create_project_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
_=Depends(require_permission(ENVIRONMENT_MANAGE)),
):
"""
创建新项目
- **network**: 管网名称(或数据库名称)
"""
create_project(network)
return network
@router.delete("/projects", summary="删除项目", description="永久删除指定的供水管网项目。此操作不可恢复。")
async def delete_project_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
_=Depends(require_permission(ENVIRONMENT_MANAGE)),
):
"""
删除项目
- **network**: 管网名称(或数据库名称)
"""
delete_project(network)
return True
@router.get("/projects/current/status", summary="检查项目是否已打开", description="检查指定项目是否已被加载到内存中。")
async def is_project_open_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)")
):
"""
检查项目是否已打开
- **network**: 管网名称(或数据库名称)
"""
return is_project_open(network)
@router.post("/projects/current", summary="打开项目", description="将指定项目加载到内存中,并初始化数据库连接池。")
async def open_project_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)")
):
"""
打开项目
- **network**: 管网名称(或数据库名称)
"""
open_project(network)
return network
@router.delete("/projects/current", summary="关闭项目", description="将指定项目从内存中卸载,释放资源。")
async def close_project_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)")
):
"""
关闭项目
- **network**: 管网名称(或数据库名称)
"""
close_project(network)
return True
@router.post("/project-copies", summary="复制项目", description="将现有项目复制为新项目。")
async def copy_project_endpoint(
source: str = Query(..., description="管网名称(或数据库名称)"),
target: str = Query(..., description="管网名称(或数据库名称)"),
_=Depends(require_permission(ENVIRONMENT_MANAGE)),
):
"""
复制项目
- **source**: 管网名称(或数据库名称)
- **target**: 管网名称(或数据库名称)
"""
copy_project(source, target)
return True
@router.get("/projects/current/exports/change-set", response_model=None, summary="导出项目为 ChangeSet", description="导出项目的变更集 (ChangeSet),包含顶点、SCADA 元素、DMA、SA、VD 等信息。")
async def export_inp_endpoint(
def export_inp_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
version: str = Query(..., description="版本号 (通常用于增量更新)")
) -> ChangeSet:
@@ -173,279 +52,7 @@ async def export_inp_endpoint(
"""
cs = export_inp(network, version)
op = cs.operations[0]
open_project(network)
op["vertex"] = json.dumps(get_all_vertices(network))
op["scada"] = json.dumps(get_all_scada_info(network))
close_project(network)
return cs
@router.post("/projects/current/imports", summary="读取 INP 文件到项目", description="从服务器文件系统中读取指定的 INP 文件并加载到项目中。")
async def read_inp_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
inp: str = Query(..., description="INP 文件名 (不包含路径)")
) -> bool:
"""
读取 INP 文件到项目
- **network**: 管网名称(或数据库名称)
- **inp**: INP 文件名
"""
read_inp(network, inp)
return True
@router.post("/projects/current/exports/inp", summary="导出项目到 INP 文件", description="将项目当前状态保存为 INP 文件到服务器文件系统。")
async def dump_inp_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
inp: str = Query(..., description="目标文件名")
) -> bool:
"""
导出项目到 INP 文件
- **network**: 管网名称(或数据库名称)
- **inp**: 目标文件名
"""
dump_inp(network, inp)
return True
@router.get("/projects/current/lock", summary="检查项目是否被锁定", description="检查指定项目是否处于锁定状态。")
async def is_project_locked_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
):
"""
检查项目是否被锁定
- **network**: 管网名称(或数据库名称)
"""
return network in lockedPrjs.keys()
@router.get("/projects/current/lock/ownership", summary="检查项目是否被当前用户锁定", description="检查指定项目是否被当前访问地址 (IP) 锁定。")
async def is_project_locked_by_me_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
):
"""
检查项目是否被当前用户锁定
- **network**: 管网名称(或数据库名称)
"""
client_host = req.client.host
return lockedPrjs.get(network) == client_host
# 0 successfully locked
# 1 already locked by you
# 2 locked by others
@router.post("/projects/current/lock", summary="锁定项目", description="锁定指定项目以防止并发修改。")
async def lock_project_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
):
"""
锁定项目
返回值:
- **0**: 锁定成功
- **1**: 已被当前用户锁定
- **2**: 已被其他用户锁定
"""
client_host = req.client.host
if not network in lockedPrjs.keys():
lockedPrjs[network] = client_host
return 0
else:
if lockedPrjs.get(network) == client_host:
return 1
else:
return 2
@router.delete("/projects/current/lock", summary="解锁项目", description="释放对项目的锁定。")
def unlock_project_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
):
"""
解锁项目
只有锁定者才能解锁。
"""
client_host = req.client.host
if lockedPrjs.get(network) == client_host:
print("delete key")
del lockedPrjs[network]
return True
return False
@router.get("/projects/current/files/inp", status_code=status.HTTP_200_OK, summary="下载 INP 文件", description="从服务器数据目录下载指定的 INP 文件。")
async def fastapi_download_inp(
name: str = Query(..., description="文件名"),
response: Response = None
):
"""
下载 INP 文件
- **name**: 文件名
"""
filePath = inpDir + name
if os.path.exists(filePath):
return FileResponse(
filePath, media_type="application/octet-stream", filename="inp.inp"
)
else:
response.status_code = status.HTTP_400_BAD_REQUEST
return True
# DingZQ, 2024-12-28, convert v3 to v2
@router.post("/project-conversions", response_model=None, summary="转换 INP V3 为 V2", description="将 EPANET 3.0 格式的 INP 内容转换为 2.x 格式。")
async def fastapi_convert_v3_to_v2(
req: Request
) -> ChangeSet:
"""
转换 INP V3 为 V2
- **req**: 请求体,需包含 `{"inp": "..."}` 结构
"""
network = "v3Tov2"
jo_root = await req.json()
inp = jo_root["inp"]
cs = convert_inp_v3_to_v2(inp)
op = cs.operations[0]
open_project(network)
op["vertex"] = json.dumps(get_all_vertices(network))
op["scada"] = json.dumps(get_all_scada_info(network))
close_project(network)
return cs
async def read_inp_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
inp: str = Query(..., description="INP 文件名 (不包含路径)")
) -> bool:
"""
读取 INP 文件到项目
- **network**: 管网名称(或数据库名称)
- **inp**: INP 文件名
"""
read_inp(network, inp)
return True
async def dump_inp_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
inp: str = Query(..., description="目标文件名")
) -> bool:
"""
导出项目到 INP 文件
- **network**: 管网名称(或数据库名称)
- **inp**: 目标文件名
"""
dump_inp(network, inp)
return True
async def is_project_locked_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
):
"""
检查项目是否被锁定
- **network**: 管网名称(或数据库名称)
"""
return network in lockedPrjs.keys()
async def is_project_locked_by_me_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
):
"""
检查项目是否被当前用户锁定
- **network**: 管网名称(或数据库名称)
"""
client_host = req.client.host
return lockedPrjs.get(network) == client_host
# 0 successfully locked
# 1 already locked by you
# 2 locked by others
async def lock_project_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
):
"""
锁定项目
返回值:
- **0**: 锁定成功
- **1**: 已被当前用户锁定
- **2**: 已被其他用户锁定
"""
client_host = req.client.host
if not network in lockedPrjs.keys():
lockedPrjs[network] = client_host
return 0
else:
if lockedPrjs.get(network) == client_host:
return 1
else:
return 2
def unlock_project_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
):
"""
解锁项目
只有锁定者才能解锁。
"""
client_host = req.client.host
if lockedPrjs.get(network) == client_host:
print("delete key")
del lockedPrjs[network]
return True
return False
async def fastapi_download_inp(
name: str = Query(..., description="文件名"),
response: Response = None
):
"""
下载 INP 文件
- **name**: 文件名
"""
filePath = inpDir + name
if os.path.exists(filePath):
return FileResponse(
filePath, media_type="application/octet-stream", filename="inp.inp"
)
else:
response.status_code = status.HTTP_400_BAD_REQUEST
return True
# DingZQ, 2024-12-28, convert v3 to v2
async def fastapi_convert_v3_to_v2(
req: Request
) -> ChangeSet:
"""
转换 INP V3 为 V2
- **req**: 请求体,需包含 `{"inp": "..."}` 结构
"""
network = "v3Tov2"
jo_root = await req.json()
inp = jo_root["inp"]
cs = convert_inp_v3_to_v2(inp)
op = cs.operations[0]
open_project(network)
op["vertex"] = json.dumps(get_all_vertices(network))
op["scada"] = json.dumps(get_all_scada_info(network))
close_project(network)
return cs
+105 -52
View File
@@ -1,66 +1,116 @@
import logging
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from psycopg import AsyncConnection
from app.infra.db.postgresql.scada import ScadaInfoRepository
from app.infra.db.postgresql.analysis import AnalysisRepository
from app.auth.project_dependencies import get_project_pg_connection
from app.domain.schemas.gis_attributes import (
ElementGisProperties,
GisElementType,
SourceGisFeatureProperties,
)
from app.infra.db.postgresql.analysis import AnalysisRepository
from app.infra.db.postgresql.gis_attributes import GisAttributeRepository
router = APIRouter()
logger = logging.getLogger(__name__)
async def get_database_connection(
@router.get(
"/source-gis/layers/{layer_key}/features/{source_fid}/properties",
response_model=SourceGisFeatureProperties,
summary="获取源 GIS 要素台账属性",
)
async def get_source_gis_feature_properties(
layer_key: str,
source_fid: int,
conn: AsyncConnection = Depends(get_project_pg_connection),
):
"""获取数据库连接"""
yield conn
@router.get("/scada-info/database-view", summary="获取SCADA信息", description="使用连接池查询所有SCADA信息")
async def get_scada_info_with_connection(
conn: AsyncConnection = Depends(get_database_connection),
):
"""
获取所有SCADA信息
返回项目中所有的SCADA设备信息
"""
normalized_layer_key = layer_key.strip()
if (
not normalized_layer_key
or len(normalized_layer_key) > 80
or source_fid < 0
):
raise HTTPException(status_code=422, detail="无效的源 GIS 要素编号")
try:
scada_data = await ScadaInfoRepository.get_scadas(conn)
return {"success": True, "data": scada_data, "count": len(scada_data)}
except Exception as e:
raise HTTPException(
status_code=500, detail=f"查询SCADA信息时发生错误: {str(e)}"
group = await GisAttributeRepository.get_source_feature_group(
conn,
normalized_layer_key,
source_fid,
)
if group is None:
raise HTTPException(status_code=404, detail="源 GIS 要素不存在")
return SourceGisFeatureProperties(
layer_key=normalized_layer_key,
source_fid=source_fid,
group=group,
)
except HTTPException:
raise
except Exception as exc:
logger.warning("Source GIS feature properties are unavailable", exc_info=True)
raise HTTPException(status_code=500, detail="查询源 GIS 要素属性失败") from exc
@router.get(
"/elements/{element_type}/{element_id}/gis-properties",
response_model=ElementGisProperties,
summary="获取元素 GIS 台账属性",
)
async def get_element_gis_properties(
element_type: GisElementType,
element_id: str,
conn: AsyncConnection = Depends(get_project_pg_connection),
):
"""Return source GIS records linked to one current-project map element."""
normalized_id = element_id.strip()
if not normalized_id or len(normalized_id) > 160:
raise HTTPException(status_code=422, detail="无效的元素编号")
try:
groups = await GisAttributeRepository.list_element_groups(
conn,
element_type,
normalized_id,
)
return ElementGisProperties(
element_type=element_type,
element_id=normalized_id,
groups=groups,
)
except Exception as exc:
logger.warning("Element GIS properties are unavailable", exc_info=True)
raise HTTPException(status_code=500, detail="查询 GIS 台账属性失败") from exc
@router.get("/analysis/runs", summary="获取分析运行列表")
async def get_analysis_runs(
conn: AsyncConnection = Depends(get_database_connection),
limit: int = Query(default=100, ge=1, le=100),
offset: int = Query(default=0, ge=0),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
"""
获取所有方案信息
返回项目中所有方案的详细信息
"""
"""分页返回当前项目的持久分析运行。"""
try:
runs = await AnalysisRepository.list_runs(conn)
return {"success": True, "data": runs, "count": len(runs)}
except Exception as e:
raise HTTPException(status_code=500, detail=f"查询分析运行时发生错误: {str(e)}")
runs = await AnalysisRepository.list_runs(conn, limit=limit, offset=offset)
return {
"success": True,
"data": runs,
"count": len(runs),
"limit": limit,
"offset": offset,
}
except Exception as exc:
logger.warning("Analysis run page is unavailable", exc_info=True)
raise HTTPException(status_code=500, detail="查询分析运行失败") from exc
@router.get("/analysis/runs/{run_id}", summary="获取分析运行")
async def get_analysis_run(
run_id: UUID,
conn: AsyncConnection = Depends(get_database_connection),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
"""
获取所有爆管定位结果
返回项目中所有的爆管定位分析结果
"""
"""返回当前项目的一条持久分析运行。"""
try:
run = await AnalysisRepository.get_run(conn, run_id)
if run is None:
@@ -68,28 +118,31 @@ async def get_analysis_run(
return run
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500, detail=f"查询分析运行时发生错误: {str(e)}"
)
except Exception as exc:
logger.warning("Analysis run is unavailable", exc_info=True)
raise HTTPException(status_code=500, detail="查询分析运行失败") from exc
@router.get("/analysis/runs/{run_id}/results", summary="获取分析结果")
async def get_analysis_results(
run_id: UUID,
result_type: str | None = Query(default=None, description="结果类型"),
conn: AsyncConnection = Depends(get_database_connection),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
"""
根据爆管事件ID查询爆管定位结果
参数:
burst_incident: 爆管事件的唯一标识符
"""
"""分页返回一条分析运行的持久结果。"""
try:
return await AnalysisRepository.list_results(conn, run_id, result_type)
except Exception as e:
return await AnalysisRepository.list_results(
conn,
run_id,
result_type,
limit=limit,
offset=offset,
)
except Exception as exc:
logger.warning("Analysis result page is unavailable", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"查询分析结果时发生错误: {str(e)}",
)
detail="查询分析结果失败",
) from exc
+25 -17
View File
@@ -1,34 +1,42 @@
from typing import Any
from fastapi import APIRouter, Query
from fastapi import APIRouter, Depends, HTTPException
from psycopg import AsyncConnection
from app.services.tjnetwork import (
get_all_scada_info,
get_scada_info,
get_scada_info_schema,
)
from app.auth.project_dependencies import get_project_pg_connection
from app.domain.schemas.scada import ScadaDeviceResponse
from app.infra.db.postgresql.scada import ScadaInfoRepository, get_scada_info_schema
router = APIRouter()
@router.get("/network-schemas/scada-device", summary="获取 SCADA 设备结构")
async def get_scada_device_schema(
network: str = Query(..., description="管网名称(或数据库名称)"),
) -> dict[str, dict[str, Any]]:
return get_scada_info_schema(network)
def get_scada_device_schema() -> dict[str, dict[str, Any]]:
return get_scada_info_schema("")
@router.get("/scada-devices", summary="获取 SCADA 设备列表")
@router.get(
"/scada-devices",
summary="获取 SCADA 设备列表",
response_model=list[ScadaDeviceResponse],
)
async def get_scada_devices(
network: str = Query(..., description="管网名称(或数据库名称)"),
conn: AsyncConnection = Depends(get_project_pg_connection),
) -> list[dict[str, Any]]:
return get_all_scada_info(network)
return await ScadaInfoRepository.get_scadas(conn)
@router.get("/scada-devices/detail", summary="获取 SCADA 设备")
@router.get(
"/scada-devices/{device_id}",
summary="获取 SCADA 设备",
response_model=ScadaDeviceResponse,
)
async def get_scada_device(
network: str = Query(..., description="管网名称(或数据库名称)"),
device_id: str = Query(..., description="SCADA 设备 ID"),
device_id: str,
conn: AsyncConnection = Depends(get_project_pg_connection),
) -> dict[str, Any]:
return get_scada_info(network, device_id)
device = await ScadaInfoRepository.get_scada(conn, device_id)
if device is None:
raise HTTPException(status_code=404, detail="SCADA 设备不存在")
return device
+19 -13
View File
@@ -7,12 +7,13 @@ from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
from fastapi.responses import StreamingResponse
from starlette.concurrency import run_in_threadpool
from app.algorithms.sensor import (
pressure_sensor_placement_kmeans,
pressure_sensor_placement_sensitivity,
)
from app.auth.metadata_dependencies import get_current_metadata_user
from app.auth.project_dependencies import ProjectContext, get_project_context
from app.auth.project_dependencies import (
ProjectContext,
get_project_context,
use_project_business_routing,
)
from app.infra.db.project_routing import ActiveProjectRouting
from app.domain.schemas.sensor_placement import (
SensorPointResponse,
SensorPlacementExportRequest,
@@ -29,6 +30,8 @@ from app.services.sensor_placement import (
get_sensor_placement_candidate,
get_sensor_placement_run,
list_sensor_placement_runs,
optimize_sensor_placement_by_kmeans,
optimize_sensor_placement_by_sensitivity,
update_sensor_placement_run,
)
@@ -106,6 +109,7 @@ def _get_run_response(
async def get_sensor_placement_candidate_detail(
node_id: str = Path(..., min_length=1, max_length=32),
project_context: ProjectContext = Depends(get_project_context),
_routing: ActiveProjectRouting = Depends(use_project_business_routing),
) -> dict[str, Any]:
try:
return await run_in_threadpool(
@@ -130,18 +134,19 @@ async def optimize_sensor_placement_scheme(
network = _project_network(payload.network, project_context)
_require_project_write(project_context)
optimizer = (
pressure_sensor_placement_sensitivity
optimize_sensor_placement_by_sensitivity
if payload.method == "sensitivity"
else pressure_sensor_placement_kmeans
else optimize_sensor_placement_by_kmeans
)
try:
created = await run_in_threadpool(
optimizer,
name=network,
scheme_name=payload.run_name,
sensor_number=payload.sensor_count,
project_code=network,
run_name=payload.run_name,
sensor_count=payload.sensor_count,
min_diameter=payload.min_diameter,
username=current_user.username,
created_by=current_user.username,
sensor_type=payload.sensor_type,
)
run = get_sensor_placement_run(network, created["run_id"])
return {**run, "can_edit": True}
@@ -166,6 +171,7 @@ async def optimize_sensor_placement_scheme(
)
async def get_sensor_placement_runs(
project_context: ProjectContext = Depends(get_project_context),
_routing: ActiveProjectRouting = Depends(use_project_business_routing),
) -> list[dict[str, Any]]:
return await run_in_threadpool(
list_sensor_placement_runs,
@@ -178,7 +184,7 @@ async def get_sensor_placement_runs(
response_model=SensorPlacementSchemeResponse,
summary="获取监测点方案详情",
)
async def get_sensor_placement_run_detail(
def get_sensor_placement_run_detail(
run_id: UUID,
network: str = Query(..., min_length=1),
project_context: ProjectContext = Depends(get_project_context),
@@ -197,7 +203,7 @@ async def get_sensor_placement_run_detail(
response_model=SensorPlacementSchemeResponse,
summary="覆盖保存监测点方案",
)
async def overwrite_sensor_placement_run(
def overwrite_sensor_placement_run(
run_id: UUID,
payload: SensorPlacementUpdateRequest,
network: str = Query(..., min_length=1),
+33 -296
View File
@@ -1,36 +1,23 @@
from typing import Any, List, Literal, Optional
from datetime import datetime, timedelta
import json
import threading
from fastapi import APIRouter, Depends, HTTPException, Query, Path, Body
from datetime import datetime
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from fastapi.responses import PlainTextResponse
from app.auth.keycloak_dependencies import get_current_keycloak_username
import app.services.simulation as simulation
import app.services.globals as globals
from app.services.tjnetwork import (
run_project,
run_project_return_dict,
run_inp,
dump_output,
)
from app.algorithms.simulation.scenarios import (
from app.services.simulation_scenarios import (
burst_analysis,
valve_close_analysis,
flushing_analysis,
contaminant_simulation,
age_analysis,
# scheduling_analysis,
pressure_regulation,
)
from app.services.simulation_ops import (
project_management,
scheduling_simulation,
daily_scheduling_simulation,
)
from app.services.valve_isolation import analyze_valve_isolation
from app.services.time_api import (
from app.domain.time import (
parse_aware_time,
parse_clock_duration_seconds,
parse_utc_time,
)
from pydantic import BaseModel, Field, field_validator
@@ -50,84 +37,26 @@ class RunSimulationManuallyByDate(BaseModel):
return value
class BurstAnalysis(BaseModel):
name: str = Field(..., description="管网名称(或数据库名称)")
modify_pattern_start_time: str = Field(..., description="模式修改开始时间 (ISO 8601)")
burst_ID: List[str] | str | None = Field(None, description="爆管节点/管段ID列表")
burst_size: List[float] | float | int | None = Field(None, description="爆管流量大小")
modify_total_duration: int = Field(900, description="模拟总时长 (秒)")
modify_fixed_pump_pattern: Optional[dict[str, list]] = Field(None, description="定速泵模式修改")
modify_variable_pump_pattern: Optional[dict[str, list]] = Field(None, description="变速泵模式修改")
modify_valve_opening: Optional[dict[str, float]] = Field(None, description="阀门开度修改")
scheme_name: Optional[str] = Field(None, description="方案名称")
class SchedulingAnalysis(BaseModel):
network: str = Field(..., description="管网名称(或数据库名称)")
start_time: str = Field(..., description="开始时间")
pump_control: dict = Field(..., description="泵控制策略")
tank_id: str = Field(..., description="水箱ID")
water_plant_output_id: str = Field(..., description="水厂出水ID")
time_delta: Optional[int] = Field(300, description="时间步长 (秒)")
class PressureRegulation(BaseModel):
network: str = Field(..., description="管网名称(或数据库名称)")
start_time: str = Field(..., description="开始时间")
pump_control: dict = Field(..., description="泵控制策略")
tank_init_level: Optional[dict] = Field(None, description="水箱初始水位")
duration: Optional[int] = Field(900, description="持续时间 (秒)")
scheme_name: Optional[str] = Field(None, description="方案名称")
class ProjectManagement(BaseModel):
network: str = Field(..., description="管网名称(或数据库名称)")
start_time: str = Field(..., description="开始时间")
pump_control: dict = Field(..., description="泵控制策略")
tank_init_level: Optional[dict] = Field(None, description="水箱初始水位")
region_demand: Optional[dict] = Field(None, description="区域需水量控制")
class DailySchedulingAnalysis(BaseModel):
network: str = Field(..., description="管网名称(或数据库名称)")
start_time: str = Field(..., description="开始时间")
pump_control: dict = Field(..., description="泵控制策略")
reservoir_id: str = Field(..., description="水库ID")
tank_id: str = Field(..., description="水箱ID")
water_plant_output_id: str = Field(..., description="水厂出水ID")
time_delta: Optional[int] = Field(300, description="时间步长 (秒)")
class PumpFailureState(BaseModel):
time: str = Field(..., description="故障发生时间")
pump_status: dict = Field(..., description="泵状态字典")
scheme_name: str = Field(..., min_length=1, description="方案名称")
def run_simulation_manually_by_date(
network_name: str, start_time: datetime, duration: int
) -> None:
end_datetime = start_time + timedelta(minutes=duration)
time_properties = simulation.get_time(network_name)
hydraulic_step_seconds = parse_clock_duration_seconds(
time_properties["HYDRAULIC TIMESTEP"],
field_name="HYDRAULIC TIMESTEP",
simulation.run_online_simulation(
name=network_name, start_time=start_time, duration_seconds=duration * 60,
)
if hydraulic_step_seconds <= 0:
raise ValueError("HYDRAULIC TIMESTEP must be greater than 0.")
hydraulic_step = timedelta(seconds=hydraulic_step_seconds)
current_time = start_time
while current_time < end_datetime:
simulation.run_simulation(
name=network_name,
simulation_type="realtime",
modify_pattern_start_time=current_time.isoformat(timespec="seconds"),
)
current_time += hydraulic_step
# 必须用这个PlainTextResponse,不然每个key都有引号
@router.post("/project-runs", response_class=PlainTextResponse, summary="运行项目模拟", description="基于指定的管网项目运行标准水力模拟,返回纯文本格式的模拟报告。")
async def run_project_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")) -> str:
def run_project_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")) -> str:
"""
运行项目模拟
@@ -143,7 +72,7 @@ async def run_project_endpoint(network: str = Query(..., description="管网名
# output 是 json
# report 是 text
@router.post("/project-return-dict-runs", summary="运行项目模拟(返回字典)", description="基于指定的管网项目运行标准水力模拟,返回JSON格式的字典,包含输出数据和报告文本。")
async def run_project_return_dict_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
def run_project_return_dict_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
"""
运行项目模拟(返回字典)
@@ -158,41 +87,15 @@ async def run_project_return_dict_endpoint(network: str = Query(..., description
return run_project_return_dict(network)
# put in inp folder, name without extension
@router.post("/inp-runs", summary="运行INP文件", description="运行指定INP文件格式的管网模型进行水力模拟。INP文件应该放在inp文件夹中,参数为文件名不含扩展名。")
async def run_inp_endpoint(network: str = Query(..., description="inp文件名(不含扩展名)")) -> str:
"""
运行INP文件
- **network**: inp文件名(不含扩展名)
从inp文件夹中读取指定的INP文件并运行模拟。
"""
return run_inp(network)
# path is absolute path
@router.get("/outputs", summary="导出模拟输出", description="导出指定路径的模拟输出文件内容。参数应为绝对路径。")
async def dump_output_endpoint(output: str = Query(..., description="模拟输出文件的绝对路径")) -> str:
"""
导出模拟输出
- **output**: 模拟输出文件的绝对路径
读取并返回指定路径的模拟输出内容。
"""
return dump_output(output)
# Analysis Endpoints
@router.post("/burst-analyses", summary="爆管分析(高级)", description="高级版本的爆管分析,支持在指定时间点修改泵控制模式和阀门开度,以分析这些改变对爆管影响的作用。支持固定泵和变速泵的独立控制。")
async def fastapi_burst_analysis(
def fastapi_burst_analysis(
network: str = Query(..., description="管网名称(或数据库名称)"),
modify_pattern_start_time: str = Query(..., description="模式修改开始时间(ISO 8601格式)"),
burst_ID: list[str] = Query(..., description="爆管节点/管段ID列表"),
burst_size: list[float] = Query(..., description="对应各爆管点的爆管流量大小列表(L/s)"),
modify_total_duration: int = Query(..., description="模拟总时长(秒)"),
scheme_name: str = Query(..., description="分析方案名称"),
burst_ID: list[str] = Query(..., min_length=1, description="爆管节点/管段ID列表"),
burst_size: list[float] = Query(..., min_length=1, description="对应各爆管点的爆管流量大小列表(L/s)"),
modify_total_duration: int = Query(..., gt=0, description="模拟总时长(秒)"),
scheme_name: str = Query(..., min_length=1, description="分析方案名称"),
username: str = Depends(get_current_keycloak_username),
) -> str:
"""
@@ -207,6 +110,11 @@ async def fastapi_burst_analysis(
支持在指定时间修改泵控制模式和阀门开度。
"""
if len(burst_ID) != len(burst_size):
raise HTTPException(
status_code=422,
detail="burst_id 与 burst_size 的数量必须一致",
)
burst_analysis(
name=network,
modify_pattern_start_time=modify_pattern_start_time,
@@ -220,7 +128,7 @@ async def fastapi_burst_analysis(
@router.post("/valve-closure-analyses", response_class=PlainTextResponse, summary="阀门关闭分析(高级)", description="高级版本的阀门关闭分析,支持同时关闭多个阀门,并在指定持续时间内进行模拟。返回纯文本格式的分析结果。")
async def fastapi_valve_close_analysis(
def fastapi_valve_close_analysis(
network: str = Query(..., description="管网名称(或数据库名称)"),
start_time: str = Query(..., description="阀门关闭开始时间(ISO 8601格式)"),
valves: List[str] = Query(..., description="要关闭的阀门ID列表"),
@@ -249,7 +157,7 @@ async def fastapi_valve_close_analysis(
@router.post("/valve-isolation-analyses", summary="阀门隔离分析", description="分析当发生突发事件时,通过关闭指定阀门进行隔离,确定哪些阀门必须关闭、哪些可选关闭,以及隔离的可行性。")
async def valve_isolation_endpoint(
def valve_isolation_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
accident_element: List[str] = Query(..., description="发生事故的管段/节点ID列表"),
disabled_valves: List[str] = Query(None, description="已故障的阀门ID列表(可选)"),
@@ -290,7 +198,7 @@ async def valve_isolation_endpoint(
@router.post("/flushing-analyses", response_class=PlainTextResponse, summary="冲洗分析(高级)", description="高级版本的冲洗分析,支持按状态和设置值控制多个可选阀门,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。")
async def fastapi_flushing_analysis(
def fastapi_flushing_analysis(
network: str = Query(..., description="管网名称(或数据库名称)"),
start_time: str = Query(..., description="冲洗开始时间(ISO 8601格式)"),
valves: List[str] | None = Query(None, description="参与控制的阀门ID列表(可选)"),
@@ -403,7 +311,7 @@ async def fastapi_flushing_analysis(
@router.post("/contaminant-simulations", response_class=PlainTextResponse, summary="污染物模拟", description="对管网中的污染物扩散进行模拟,评估污染源对管网的影响范围和浓度分布。支持指定污染源位置、污染浓度和扩散模式。")
async def fastapi_contaminant_simulation(
def fastapi_contaminant_simulation(
network: str = Query(..., description="管网名称(或数据库名称)"),
start_time: str = Query(..., description="污染开始时间(ISO 8601格式)"),
source: str = Query(..., description="污染源节点ID"),
@@ -439,50 +347,8 @@ async def fastapi_contaminant_simulation(
return result or "success"
@router.post("/water-age-analyses", response_class=PlainTextResponse, summary="水龄分析(高级)", description="高级版本的水龄分析,在指定时间点进行分析,支持自定义模拟持续时间。返回纯文本格式的分析结果。")
async def fastapi_age_analysis(
network: str = Query(..., description="管网名称(或数据库名称)"),
start_time: str = Query(..., description="分析开始时间(ISO 8601格式)"),
duration: int = Query(..., description="模拟持续时间(秒)"),
) -> str:
"""
水龄分析(高级版本)
- **network**: 管网名称(或数据库名称)
- **start_time**: 分析开始时间
- **duration**: 模拟持续时间(秒)
分析指定时间段内管网中各节点的水体停留时间。
"""
result = age_analysis(network, start_time, duration)
return result or "success"
# @router.get("/schedulinganalysis/")
# async def scheduling_analysis_endpoint(network: str):
# return scheduling_analysis(network)
@router.post("/pressure-regulation-calculations", summary="压力调节(基础)", description="对管网的压力进行调节分析,通过控制泵的运行来维持目标节点的目标压力。此为基础版本。")
async def pressure_regulation_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
target_node: str = Query(..., description="目标节点ID"),
target_pressure: float = Query(..., description="目标压力值(kPa)"),
):
"""
压力调节(基础版本)
- **network**: 管网名称(或数据库名称)
- **target_node**: 目标节点ID
- **target_pressure**: 目标压力值(kPa)
通过泵控制维持目标节点的压力。
"""
return pressure_regulation(network, target_node, target_pressure)
@router.post("/pressure-regulation-analyses", summary="压力调节(高级)", description="高级版本的压力调节分析,通过JSON请求体提供详细的控制参数,包括固定泵和变速泵的独立控制、水箱初始水位等。")
async def fastapi_pressure_regulation(data: PressureRegulation = Body(..., description="压力调节控制参数")) -> str:
def fastapi_pressure_regulation(data: PressureRegulation = Body(..., description="压力调节控制参数")) -> str:
"""
压力调节(高级版本)
@@ -492,14 +358,16 @@ async def fastapi_pressure_regulation(data: PressureRegulation = Body(..., descr
- **pump_control**: 泵控制策略字典
- **tank_init_level**: 水箱初始水位字典(可选)
- **duration**: 模拟持续时间(秒,可选,默认900)
- **scheme_name**: 控制方案名称(可选)
- **scheme_name**: 控制方案名称
支持固定泵和变速泵的独立控制。
"""
item = data.dict()
simulation.query_corresponding_element_id_and_query_id(item["network"])
fixed_pumps = set(globals.fixed_pumps_id.keys())
variable_pumps = set(globals.variable_pumps_id.keys())
item = data.model_dump()
scada_mappings = simulation.query_corresponding_element_id_and_query_id(
item["network"]
)
fixed_pumps = set(scada_mappings.fixed_pumps)
variable_pumps = set(scada_mappings.variable_pumps)
fixed_pump_pattern: dict[str, list] = {}
variable_pump_pattern: dict[str, list] = {}
for pump_id, values in item["pump_control"].items():
@@ -515,143 +383,13 @@ async def fastapi_pressure_regulation(data: PressureRegulation = Body(..., descr
modify_fixed_pump_pattern=fixed_pump_pattern or None,
modify_variable_pump_pattern=variable_pump_pattern or None,
scheme_name=item["scheme_name"],
scada_mappings=scada_mappings,
)
return "success"
@router.post("/project-managements", summary="项目管理(高级)", description="高级版本的项目管理,通过JSON请求体提供详细的控制参数,包括泵控制策略、水箱初始水位和区域需水量控制。")
async def fastapi_project_management(data: ProjectManagement = Body(..., description="项目管理控制参数")) -> str:
"""
项目管理(高级版本)
请求体参数:
- **network**: 管网名称(或数据库名称)
- **start_time**: 管理开始时间
- **pump_control**: 泵控制策略字典
- **tank_init_level**: 水箱初始水位字典(可选)
- **region_demand**: 区域需水量控制字典(可选)
支持多维度的项目管理。
"""
item = data.dict()
return project_management(
prj_name=item["network"],
start_datetime=item["start_time"],
pump_control=item["pump_control"],
tank_initial_level_control=item["tank_init_level"],
region_demand_control=item["region_demand"],
)
# @router.get("/dailyschedulinganalysis/")
# async def daily_scheduling_analysis_endpoint(network: str):
# return daily_scheduling_analysis(network)
@router.post("/scheduling-analyses", summary="排程分析", description="对管网的供水排程进行分析,优化泵的运行时间和出水流量,平衡水厂出水、水箱进出水,满足用户需求。")
async def fastapi_scheduling_analysis(data: SchedulingAnalysis = Body(..., description="排程分析参数")) -> str:
"""
排程分析
请求体参数:
- **network**: 管网名称(或数据库名称)
- **start_time**: 分析开始时间
- **pump_control**: 泵控制策略字典
- **tank_id**: 水箱ID
- **water_plant_output_id**: 水厂出水ID
- **time_delta**: 时间步长(秒,可选,默认300)
用于优化供水排程。
"""
item = data.dict()
return scheduling_simulation(
item["network"],
item["start_time"],
item["pump_control"],
item["tank_id"],
item["water_plant_output_id"],
item["time_delta"],
)
@router.post("/daily-scheduling-analyses", summary="日排程分析", description="对管网的每日供水排程进行分析,优化水库、水厂、水箱和用户需求的协调,制定合理的每日排程方案。")
async def fastapi_daily_scheduling_analysis(data: DailySchedulingAnalysis = Body(..., description="日排程分析参数")) -> str:
"""
日排程分析
请求体参数:
- **network**: 管网名称(或数据库名称)
- **start_time**: 分析开始时间
- **pump_control**: 泵控制策略字典
- **reservoir_id**: 水库ID
- **tank_id**: 水箱ID
- **water_plant_output_id**: 水厂出水ID
- **time_delta**: 时间步长(秒,可选,默认300)
用于制定每日供水排程方案。
"""
item = data.dict()
return daily_scheduling_simulation(
item["network"],
item["start_time"],
item["pump_control"],
item["reservoir_id"],
item["tank_id"],
item["water_plant_output_id"],
)
# @router.get("/pumpfailure/")
# async def pump_failure_endpoint(network: str, pump_id: str, time: str):
# return pump_failure(network, pump_id, time)
@router.post("/pump-failure-events", summary="泵故障管理", description="记录和管理泵的故障状态,包括故障发生时间和受影响的泵列表。系统将记录故障日志并更新泵状态。")
async def fastapi_pump_failure(data: PumpFailureState = Body(..., description="泵故障状态信息")) -> str:
"""
泵故障管理
请求体参数:
- **time**: 故障发生时间
- **pump_status**: 泵状态字典,包含第一阶段和第二阶段泵的故障状态
系统将验证泵信息的有效性并更新故障状态文件。
"""
item = data.dict()
with open("./pump_failure_message.txt", "a", encoding="utf-8-sig") as f1:
f1.write("[{}] {}\n".format(datetime.now().strftime("%Y-%m-%d %H:%M:%S"), item))
with open("./pump_failure_status.txt", "r", encoding="utf-8-sig") as f2:
lines = f2.readlines()
first_stage_pump_status_dict = json.loads(json.dumps(eval(lines[0])))
second_stage_pump_status_dict = json.loads(json.dumps(eval(lines[-1])))
pump_status_dict = {
"first": first_stage_pump_status_dict,
"second": second_stage_pump_status_dict,
}
status_info = item.copy()
for pump_type in status_info["pump_status"].keys():
if pump_type in pump_status_dict.keys():
if all(
pump_id in pump_status_dict[pump_type].keys()
for pump_id in status_info["pump_status"][pump_type].keys()
):
for pump_id in status_info["pump_status"][pump_type].keys():
pump_status_dict[pump_type][pump_id] = int(
status_info["pump_status"][pump_type][pump_id]
)
else:
return json.dumps("ERROR: Wrong Pump ID")
else:
return json.dumps("ERROR: Wrong Pump Type")
with open("./pump_failure_status.txt", "w", encoding="utf-8-sig") as f2_:
f2_.write(
"{}\n{}".format(pump_status_dict["first"], pump_status_dict["second"])
)
return json.dumps("SUCCESS")
@router.post("/simulation-runs", summary="手动运行日期指定模拟", description="根据指定的开始时间和持续时间,手动运行水力模拟。开始时间必须是显式带时区的 ISO 8601 / RFC3339 时间。")
async def fastapi_run_simulation_manually_by_date(
def fastapi_run_simulation_manually_by_date(
data: RunSimulationManuallyByDate = Body(..., description="模拟运行参数"),
) -> dict[str, str]:
"""
@@ -667,7 +405,6 @@ async def fastapi_run_simulation_manually_by_date(
"""
item = data.model_dump()
try:
simulation.query_corresponding_element_id_and_query_id(item["name"])
start_time = parse_utc_time(item["start_time"], field_name="start_time")
run_simulation_manually_by_date(
item["name"], start_time, item["duration"]
+1 -22
View File
@@ -1,7 +1,7 @@
from datetime import datetime
from uuid import UUID
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
from fastapi import APIRouter, Depends, HTTPException, Query
from psycopg import AsyncConnection
from app.infra.db.timescaledb.repositories.analysis import AnalysisResultsRepository
@@ -11,27 +11,6 @@ from .dependencies import get_timescale_connection
router = APIRouter()
@router.post("/timeseries/analysis/runs/{run_id}/results", status_code=201)
async def store_analysis_results(
run_id: UUID = Path(..., description="分析运行 ID"),
payload: dict = Body(...),
conn: AsyncConnection = Depends(get_timescale_connection),
):
try:
node_rows = payload.get("node_results", [])
link_rows = payload.get("link_results", [])
await AnalysisResultsRepository.store_results(
conn, run_id, node_rows, link_rows
)
return {
"run_id": run_id,
"node_count": len(node_rows),
"link_count": len(link_rows),
}
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.get("/timeseries/analysis/runs/{run_id}/nodes/{node_id}")
async def get_analysis_node_series(
run_id: UUID,
+23 -172
View File
@@ -1,183 +1,35 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from datetime import datetime
from psycopg import AsyncConnection
from uuid import UUID
from app.infra.db.timescaledb.composite_queries import CompositeQueries
from app.services.timeseries_analysis import TimeseriesAnalysisService
from app.domain.schemas.timeseries_history import (
ElementHistoryQuery,
ElementHistoryResponse,
)
from app.services.timeseries_history import TimeseriesHistoryService
from .dependencies import get_timescale_connection, get_postgres_connection
router = APIRouter()
history_router = APIRouter()
@router.get("/timeseries/views/scada-simulations", summary="获取SCADA关联的模拟数据")
async def get_scada_associated_simulation_data(
start_time: datetime = Query(..., description="查询开始时间"),
end_time: datetime = Query(..., description="查询结束时间"),
device_ids: str = Query(..., description="SCADA设备ID列表,逗号分隔"),
run_id: UUID | None = Query(None, description="分析运行 ID;为空时查询实时数据"),
@history_router.post(
"/timeseries/views/element-history/query",
summary="批量查询管网元素历史数据",
response_model=ElementHistoryResponse,
)
async def query_element_history(
payload: ElementHistoryQuery,
timescale_conn: AsyncConnection = Depends(get_timescale_connection),
postgres_conn: AsyncConnection = Depends(get_postgres_connection),
):
"""
获取SCADA关联的link/node模拟值
根据传入的SCADA device_ids,找到关联的link/node,
并根据对应的type,查询对应的模拟数据。支持查询实时或分析运行数据。
Args:
start_time: 查询开始时间
end_time: 查询结束时间
device_ids: SCADA设备ID列表,用逗号分隔
run_id: 分析运行 ID,若为空则查询实时数据
timescale_conn: TimescaleDB连接
postgres_conn: PostgreSQL连接
Returns:
SCADA关联的模拟数据
Raises:
HTTPException: 当查询参数无效时返回400错误,未找到数据时返回404错误
"""
) -> ElementHistoryResponse:
try:
device_ids_list = (
[id.strip() for id in device_ids.split(",") if id.strip()]
if device_ids
else []
return await TimeseriesHistoryService.query(
timescale_conn, postgres_conn, payload
)
if run_id is not None:
result = await CompositeQueries.get_scada_associated_analysis_simulation_data(
timescale_conn,
postgres_conn,
device_ids_list,
start_time,
end_time,
run_id,
)
else:
result = (
await CompositeQueries.get_scada_associated_realtime_simulation_data(
timescale_conn,
postgres_conn,
device_ids_list,
start_time,
end_time,
)
)
if result is None:
raise HTTPException(status_code=404, detail="No simulation data found")
return result
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/timeseries/views/element-simulations", summary="获取管网元素的模拟数据")
async def get_feature_simulation_data(
start_time: datetime = Query(..., description="查询开始时间"),
end_time: datetime = Query(..., description="查询结束时间"),
feature_infos: str = Query(
..., description="特征信息,格式: id1:type1,id2:type2,type为pipe(管道)或junction(节点)"
),
run_id: UUID | None = Query(None, description="分析运行 ID;为空时查询实时数据"),
timescale_conn: AsyncConnection = Depends(get_timescale_connection),
):
"""
获取link/node模拟值
根据传入的featureInfos,找到关联的link/node,
并根据对应的type,查询对应的模拟数据。支持查询实时或分析运行数据。
Args:
start_time: 查询开始时间
end_time: 查询结束时间
feature_infos: 格式为 "element_id1:type1,element_id2:type2"
例如: "P1:pipe,J1:junction"
run_id: 分析运行 ID,若为空则查询实时数据
timescale_conn: TimescaleDB连接
Returns:
管网元素的模拟数据
Raises:
HTTPException: 当feature_infos为空返回400错误,未找到数据返回404错误,其他错误返回400错误
"""
try:
feature_infos_list = []
if feature_infos:
for item in feature_infos.split(","):
item = item.strip()
if ":" in item:
element_id, element_type = item.split(":", 1)
feature_infos_list.append(
(element_id.strip(), element_type.strip())
)
if not feature_infos_list:
raise HTTPException(status_code=400, detail="feature_infos cannot be empty")
if run_id is not None:
result = await CompositeQueries.get_analysis_simulation_data(
timescale_conn,
feature_infos_list,
start_time,
end_time,
run_id,
)
else:
result = await CompositeQueries.get_realtime_simulation_data(
timescale_conn,
feature_infos_list,
start_time,
end_time,
)
if result is None:
raise HTTPException(status_code=404, detail="No simulation data found")
return result
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/timeseries/views/element-scada-readings", summary="获取管网元素关联的SCADA监测数据")
async def get_element_associated_scada_data(
element_id: str = Query(..., description="管网元素ID(管道或节点)"),
start_time: datetime = Query(..., description="查询开始时间"),
end_time: datetime = Query(..., description="查询结束时间"),
use_cleaned: bool = Query(False, description="是否使用清洗后的数据"),
timescale_conn: AsyncConnection = Depends(get_timescale_connection),
postgres_conn: AsyncConnection = Depends(get_postgres_connection),
):
"""
获取link/node关联的SCADA监测值
根据传入的link/node id,匹配SCADA信息,
如果存在关联的SCADA device_id,获取实际的监测数据。
Args:
element_id: 管网元素ID
start_time: 查询开始时间
end_time: 查询结束时间
use_cleaned: 是否使用清洗后的数据,默认为False使用原始数据
timescale_conn: TimescaleDB连接
postgres_conn: PostgreSQL连接
Returns:
管网元素关联的SCADA监测数据
Raises:
HTTPException: 当查询参数无效时返回400错误,未找到关联数据返回404错误
"""
try:
result = await CompositeQueries.get_element_associated_scada_data(
timescale_conn, postgres_conn, element_id, start_time, end_time, use_cleaned
)
if result is None:
raise HTTPException(
status_code=404, detail="No associated SCADA data found"
)
return result
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@router.post("/timeseries/scada-cleaning-runs", summary="清洗SCADA监测数据")
@@ -216,7 +68,7 @@ async def clean_scada_data(
if device_ids
else []
)
return await CompositeQueries.clean_scada_data(
return await TimeseriesAnalysisService.clean_scada_data(
timescale_conn, postgres_conn, device_ids_list, start_time, end_time
)
except ValueError as e:
@@ -226,8 +78,8 @@ async def clean_scada_data(
@router.get("/pipeline-health-predictions", summary="预测管道健康状况")
async def predict_pipeline_health(
query_time: datetime = Query(..., description="查询时间"),
network_name: str = Query(..., description="管网名称(或数据库名称)"),
timescale_conn: AsyncConnection = Depends(get_timescale_connection),
postgres_conn: AsyncConnection = Depends(get_postgres_connection),
):
"""
预测管道健康状况
@@ -237,7 +89,6 @@ async def predict_pipeline_health(
Args:
query_time: 查询时间
network_name: 管网名称(或数据库名称)
timescale_conn: TimescaleDB连接
Returns:
@@ -247,8 +98,8 @@ async def predict_pipeline_health(
HTTPException: 当模型文件不存在返回404错误,其他错误返回400或500错误
"""
try:
return await CompositeQueries.predict_pipeline_health(
timescale_conn, network_name, query_time
return await TimeseriesAnalysisService.predict_pipeline_health(
timescale_conn, postgres_conn, query_time
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
+31 -4
View File
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Path, Body
from typing import List
from datetime import datetime
from psycopg import AsyncConnection
from pydantic import BaseModel
from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository
from .dependencies import get_timescale_connection
@@ -13,9 +14,31 @@ TIME_RANGE_START_DESC = f"时间范围开始时间。{TIME_WITH_TZ_DESC}"
TIME_RANGE_END_DESC = f"时间范围结束时间。{TIME_WITH_TZ_DESC}"
class RealtimeLinkBatchItem(BaseModel):
time: datetime
id: str
flow: float | None = None
friction: float | None = None
headloss: float | None = None
quality: float | None = None
reaction: float | None = None
setting: float | None = None
status: float | None = None
velocity: float | None = None
class RealtimeNodeBatchItem(BaseModel):
time: datetime
id: str
actual_demand: float | None = None
total_head: float | None = None
pressure: float | None = None
quality: float | None = None
@router.post("/timeseries/realtime/links/batches", status_code=201, summary="批量插入实时管道数据")
async def insert_realtime_links(
data: List[dict] = Body(..., description="管道数据列表,每项包含管道ID、时间戳等信息"),
data: List[RealtimeLinkBatchItem] = Body(..., description="同一时间点的管道快照数据"),
conn: AsyncConnection = Depends(get_timescale_connection)
):
"""
@@ -29,7 +52,9 @@ async def insert_realtime_links(
Returns:
插入成功的记录数
"""
await RealtimeRepository.insert_links_batch(conn, data)
await RealtimeRepository.insert_links_batch(
conn, [item.model_dump() for item in data]
)
return {"message": f"Inserted {len(data)} records"}
@@ -119,7 +144,7 @@ async def update_realtime_link_field(
@router.post("/timeseries/realtime/nodes/batches", status_code=201, summary="批量插入实时节点数据")
async def insert_realtime_nodes(
data: List[dict] = Body(..., description="节点数据列表,每项包含节点ID、时间戳等信息"),
data: List[RealtimeNodeBatchItem] = Body(..., description="同一时间点的节点快照数据"),
conn: AsyncConnection = Depends(get_timescale_connection)
):
"""
@@ -133,7 +158,9 @@ async def insert_realtime_nodes(
Returns:
插入成功的记录数
"""
await RealtimeRepository.insert_nodes_batch(conn, data)
await RealtimeRepository.insert_nodes_batch(
conn, [item.model_dump() for item in data]
)
return {"message": f"Inserted {len(data)} records"}
+89 -3
View File
@@ -2,17 +2,42 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Path, Body
from typing import List
from datetime import datetime
from psycopg import AsyncConnection
from pydantic import BaseModel, Field, field_validator
from app.domain.schemas.scada import ScadaLatestReadingsResponse
from app.infra.db.postgresql.scada import ScadaInfoRepository
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
from .dependencies import get_timescale_connection
from .dependencies import get_postgres_connection, get_timescale_connection
router = APIRouter()
SCADA_BATCH_MAX_ITEMS = 10_000
class ScadaReadingBatchItem(BaseModel):
time: datetime
device_id: str = Field(min_length=1)
monitored_value: float | None = None
cleaned_value: float | None = None
@field_validator("device_id")
@classmethod
def normalize_device_id(cls, value: str) -> str:
normalized = value.strip()
if not normalized:
raise ValueError("device_id must not be blank")
return normalized
@router.post("/timeseries/scada-readings/batches", status_code=201, summary="批量插入SCADA监测数据")
async def insert_scada_data(
data: List[dict] = Body(..., description="SCADA设备监测数据列表"),
data: List[ScadaReadingBatchItem] = Body(
...,
min_length=1,
max_length=SCADA_BATCH_MAX_ITEMS,
description="SCADA设备监测数据列表",
),
conn: AsyncConnection = Depends(get_timescale_connection),
postgres_conn: AsyncConnection = Depends(get_postgres_connection),
):
"""
批量插入SCADA监测数据
@@ -25,7 +50,20 @@ async def insert_scada_data(
Returns:
插入成功的记录数
"""
await ScadaRepository.insert_scada_batch(conn, data)
rows = [item.model_dump() for item in data]
requested_ids = list(dict.fromkeys(item["device_id"] for item in rows))
existing_ids = await ScadaInfoRepository.get_existing_device_ids(
postgres_conn, requested_ids
)
missing_ids = [
device_id for device_id in requested_ids if device_id not in existing_ids
]
if missing_ids:
raise HTTPException(
status_code=422,
detail=f"SCADA devices do not exist in BizDB: {', '.join(missing_ids)}",
)
await ScadaRepository.insert_scada_batch(conn, rows)
return {"message": f"Inserted {len(data)} records"}
@@ -59,6 +97,54 @@ async def get_scada_by_ids_time_range(
)
@router.get(
"/timeseries/scada-readings/latest",
response_model=ScadaLatestReadingsResponse,
summary="获取SCADA设备最新数据",
)
async def get_latest_scada_readings(
limit: int = Query(100, ge=1, le=500, description="返回设备数上限"),
offset: int = Query(0, ge=0, description="设备目录偏移量"),
conn: AsyncConnection = Depends(get_timescale_connection),
postgres_conn: AsyncConnection = Depends(get_postgres_connection),
) -> ScadaLatestReadingsResponse:
"""Read one latest persisted row per project device without applying a time window."""
devices = await ScadaInfoRepository.get_scadas(postgres_conn)
selected_devices = devices[offset : offset + limit]
latest_by_device = await ScadaRepository.get_latest_scada_readings(
conn,
[device["device_id"] for device in selected_devices],
)
items = []
for device in selected_devices:
reading = latest_by_device.get(device["device_id"])
items.append(
{
"device_id": device["device_id"],
"device_type": device["device_type"],
"measurement_unit": device["measurement_unit"],
"transmission_mode": device["transmission_mode"],
"transmission_frequency": device["transmission_frequency"],
"observed_at": reading["time"] if reading else None,
"monitored_value": reading["monitored_value"] if reading else None,
"cleaned_value": reading["cleaned_value"] if reading else None,
}
)
observed_times = [item["observed_at"] for item in items if item["observed_at"] is not None]
return ScadaLatestReadingsResponse.model_validate(
{
"items": items,
"total": len(devices),
"limit": limit,
"offset": offset,
"observed_count": len(observed_times),
"latest_observed_at": max(observed_times) if observed_times else None,
}
)
@router.get(
"/timeseries/scada-readings/fields", summary="按设备ID、字段和时间范围查询SCADA数据"
)
-29
View File
@@ -1,29 +0,0 @@
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-searches",
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
+156
View File
@@ -0,0 +1,156 @@
"""Authenticated workbench snapshot and resumable Server-Sent Events."""
from __future__ import annotations
import asyncio
import json
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Header, HTTPException, Query
from fastapi.responses import StreamingResponse
from psycopg import AsyncConnection
from psycopg.conninfo import conninfo_to_dict
from app.api.v1.endpoints.workbench_runtime import workbench_runtime
from app.auth.permissions import OPERATIONAL_TASK_VIEW, permissions_for_context, require_permission
from app.auth.project_dependencies import (
ProjectContext,
get_metadata_repository,
get_project_business_routing,
get_project_pg_connection,
)
from app.core.config import settings
from app.domain.schemas.workbench_events import WorkbenchEvent, WorkbenchSyncSnapshot
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
from app.infra.db.postgresql.workbench_events import WorkbenchEventRepository
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
from app.infra.db.project_routing import ActiveProjectRouting
from app.services.operational_tasks import OperationalTaskService
from app.infra.db.postgresql.workbench_event_hub import workbench_event_hubs
router = APIRouter()
def _service(ctx: ProjectContext, conn: AsyncConnection) -> OperationalTaskService:
return OperationalTaskService(
OperationalTaskRepository(conn, ctx.project_id),
ctx,
permissions_for_context(ctx),
)
@router.get("/workbench/sync-snapshot", response_model=WorkbenchSyncSnapshot)
async def workbench_sync_snapshot(
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
async with conn.transaction():
await conn.execute(
"SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY"
)
cursor = await WorkbenchEventRepository(conn, ctx.project_id).current_cursor()
runtime = await workbench_runtime(ctx=ctx, conn=conn)
tasks, _ = await _service(ctx, conn).list(
task_type=None, status=None, limit=50, offset=0
)
notifications, _ = await _service(ctx, conn).list_notifications(
unread_only=False, limit=100, offset=0
)
return WorkbenchSyncSnapshot(
schema="workbench-sync-snapshot@1",
event_cursor=cursor,
runtime=runtime,
tasks=tasks,
notifications=notifications,
generated_at=datetime.now(timezone.utc),
)
@router.get("/workbench/events")
async def workbench_events(
after: int | None = Query(default=None, ge=0),
last_event_id: str | None = Header(default=None, alias="Last-Event-ID"),
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
routing: ActiveProjectRouting = Depends(get_project_business_routing),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
):
if not settings.WORKBENCH_EVENTS_ENABLED:
raise HTTPException(status_code=503, detail="Workbench events are disabled")
cursor = after
if cursor is None and last_event_id:
try:
cursor = int(last_event_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail="Invalid Last-Event-ID") from exc
cursor = cursor or 0
current_identity = _database_identity(routing.business_dsn)
try:
active_routings = await metadata_repo.list_active_project_db_routings("biz_data")
finally:
# FastAPI keeps yield dependencies alive for a StreamingResponse. The
# stream only needs immutable routing values, so release the shared
# metadata checkout before the five-minute SSE body begins.
await metadata_repo.session.close()
if any(
item.project_id != ctx.project_id
and _database_identity(item.dsn) == current_identity
for item in active_routings
):
raise HTTPException(
status_code=503,
detail="Workbench events require one dedicated business database per project",
)
async def stream():
started = asyncio.get_running_loop().time()
last_keepalive = started
async with workbench_event_hubs.subscribe(ctx.project_id, routing.business_dsn) as hub:
oldest = await hub.oldest_cursor()
latest = await hub.current_cursor()
if cursor > 0 and (
oldest is None or cursor < oldest - 1 or cursor > latest
):
payload = {"schema": "workbench-event@1", "cursor": cursor}
yield f"event: reset_required\ndata: {json.dumps(payload, separators=(',', ':'))}\n\n"
return
current = cursor
wake_generation = hub.generation
while asyncio.get_running_loop().time() - started < 300:
rows = await hub.read_after(current)
for row in rows:
event = WorkbenchEvent(
schema="workbench-event@1",
**row,
)
current = event.event_seq
data = event.model_dump_json(by_alias=True)
yield f"id: {current}\nevent: workbench_event\ndata: {data}\n\n"
if rows:
continue
now = asyncio.get_running_loop().time()
if now - last_keepalive >= 15:
yield f": keepalive {int(now)}\n\n"
last_keepalive = now
# The Hub invalidates shared reads on NOTIFY and every 30s.
# Per-client timeout only keeps the transport alive, not SQL.
wake_generation = await hub.wait_for_wake(wake_generation, timeout=15)
yield "event: reconnect\ndata: {}\n\n"
return StreamingResponse(
stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
"Content-Encoding": "identity",
},
)
def _database_identity(dsn: str) -> tuple[str, str, str]:
values = conninfo_to_dict(dsn)
return (
values.get("host") or values.get("hostaddr") or "localhost",
values.get("port") or "5432",
values.get("dbname") or values.get("user") or "",
)
+155
View File
@@ -0,0 +1,155 @@
"""Read-only workbench runtime aggregation."""
import logging
from contextlib import asynccontextmanager
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from psycopg import AsyncConnection
from pydantic import ValidationError
from app.auth.project_dependencies import (
ProjectContext,
get_project_context,
get_project_pg_connection,
)
from app.domain.schemas.workbench_runtime import (
WorkbenchAnalysisHistoryPage,
WorkbenchPersistedAlert,
WorkbenchRuntimeLatestAnalysis,
WorkbenchRuntimeSnapshot,
)
from app.domain.schemas.task_alert import TaskAlertCommand, TaskAlertEvent, TaskAlertState
from app.infra.db.postgresql.analysis import AnalysisRepository
from app.infra.db.postgresql.workbench_operations import (
WorkbenchOperationsRepository,
)
from app.infra.db.postgresql.task_alerts import TaskAlertRepository
from app.services.task_alerts import TaskAlertService
from app.domain.domain_errors import DomainError
from app.services.workbench_runtime import (
build_workbench_runtime_snapshot,
project_analysis_history_item,
project_latest_analysis,
project_sensor_location_map_payload,
sensor_location_ids_for_map_projection,
)
router = APIRouter()
logger = logging.getLogger(__name__)
@asynccontextmanager
async def _projection_transaction(conn: AsyncConnection):
"""Isolate an optional projection so one SQL failure cannot poison others."""
transaction = getattr(conn, "transaction", None)
if transaction is None: # Lightweight endpoint test doubles.
yield
return
async with transaction():
yield
@router.get(
"/workbench/analysis-runs",
response_model=WorkbenchAnalysisHistoryPage,
)
async def workbench_analysis_runs(
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
"""Return a bounded, sanitized analysis history page for the workbench."""
rows = await AnalysisRepository.list_run_summaries(
conn,
limit=limit + 1,
offset=offset,
)
has_more = len(rows) > limit
items = rows[:limit]
return {
"items": [project_analysis_history_item(run) for run in items],
"limit": limit,
"offset": offset,
"next_offset": offset + limit if has_more else None,
}
@router.get(
"/workbench/analysis-runs/{run_id}",
response_model=WorkbenchRuntimeLatestAnalysis,
)
async def workbench_analysis_run(
run_id: UUID,
conn: AsyncConnection = Depends(get_project_pg_connection),
):
"""Return one authorized project's analysis run as a typed workbench artifact."""
run = await AnalysisRepository.get_run(conn, run_id)
if run is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Analysis run not found",
)
results = await AnalysisRepository.list_recent_results(conn, run_id, limit=100)
try:
referenced_map_payload = await _sensor_location_map_payload(conn, results)
except Exception:
referenced_map_payload = None
logger.warning(
"Workbench analysis spatial references are unavailable",
exc_info=True,
)
return project_latest_analysis(
run,
results,
referenced_map_payload=referenced_map_payload,
)
@router.post("/workbench/task-alert-events", response_model=TaskAlertState)
async def push_task_alert_event(
command: TaskAlertCommand,
ctx: ProjectContext = Depends(get_project_context),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
event = TaskAlertEvent(
**command.model_dump(),
producer_id=f"principal:{ctx.user_id}",
)
return await TaskAlertService(
TaskAlertRepository(conn, ctx.project_id), ctx
).push(event)
except DomainError as exc:
raise HTTPException(status_code=exc.status, detail=exc.message) from exc
@router.get("/workbench/runtime", response_model=WorkbenchRuntimeSnapshot)
async def workbench_runtime(
ctx: ProjectContext = Depends(get_project_context),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
from app.services.workbench_runtime import read_workbench_runtime
return await read_workbench_runtime(ctx, conn)
async def _sensor_location_map_payload(
conn: AsyncConnection, results: list[dict]
) -> dict | None:
node_ids = sensor_location_ids_for_map_projection(results)
if not node_ids:
return None
map_points = await AnalysisRepository.list_junction_map_points(conn, node_ids)
return project_sensor_location_map_payload(node_ids, map_points)
def _validate_documents(documents, model, label: str) -> tuple[list[dict], int]:
valid: list[dict] = []
rejected = 0
for document in documents:
try:
valid.append(model.model_validate(document).model_dump())
except ValidationError:
rejected += 1
logger.warning("Workbench %s document was rejected", label)
return valid, rejected
+12 -12
View File
@@ -11,6 +11,7 @@ from fastapi import APIRouter, Depends, Query
from fastapi.encoders import jsonable_encoder
from fastapi.routing import APIRoute
from pydantic import BaseModel, JsonValue, create_model
from starlette.concurrency import run_in_threadpool
from starlette.responses import Response
from app.api.problem_details import ProblemDetails
@@ -38,6 +39,14 @@ class Page(BaseModel, Generic[T]):
offset: int
async def _call_endpoint(endpoint, *args, **kwargs):
"""Call async handlers directly and offload synchronous handlers."""
if inspect.iscoroutinefunction(endpoint):
return await endpoint(*args, **kwargs)
result = await run_in_threadpool(endpoint, *args, **kwargs)
return await result if inspect.isawaitable(result) else result
_NAME_IS_NETWORK = {
"pressure_sensor_placement_sensitivity_endpoint",
"pressure_sensor_placement_kmeans_endpoint",
@@ -59,7 +68,6 @@ _TIMESCALE_ROUTED_ENDPOINT_MODULES = {
"app.api.v1.endpoints.leakage",
"app.api.v1.endpoints.simulation",
}
_TIMESCALE_ROUTED_ENDPOINT_NAMES = {"open_project_endpoint"}
def _clean_name(name: str) -> str:
@@ -183,10 +191,7 @@ def _with_header_project_context(endpoint, route_name: str):
if model_has_username:
kwargs.pop(injected_user_name, None)
with activate_project_routing(project_routing):
result = endpoint(*args, **kwargs)
if inspect.isawaitable(result):
return await result
return result
return await _call_endpoint(endpoint, *args, **kwargs)
parameters = []
for name, parameter in signature.parameters.items():
@@ -206,7 +211,6 @@ def _with_header_project_context(endpoint, route_name: str):
get_project_simulation_routing
if (
endpoint.__module__ in _TIMESCALE_ROUTED_ENDPOINT_MODULES
or endpoint.__name__ in _TIMESCALE_ROUTED_ENDPOINT_NAMES
)
else get_project_business_routing
)
@@ -262,9 +266,7 @@ def _with_pagination(endpoint):
else:
limit = kwargs.pop("_rest_limit")
offset = kwargs.pop("_rest_offset")
result = endpoint(*args, **kwargs)
if inspect.isawaitable(result):
result = await result
result = await _call_endpoint(endpoint, *args, **kwargs)
if not isinstance(result, list):
return result
if handler_handles_pagination:
@@ -313,9 +315,7 @@ def _with_jsonable_response(endpoint):
@wraps(endpoint)
async def wrapper(*args, **kwargs):
result = endpoint(*args, **kwargs)
if inspect.isawaitable(result):
result = await result
result = await _call_endpoint(endpoint, *args, **kwargs)
if isinstance(result, Response):
return result
return jsonable_encoder(result)
+29 -5
View File
@@ -4,6 +4,7 @@ from app.api.v1.endpoints import (
access,
admin_metadata,
agent_auth,
agent_messages,
audit,
burst_detection,
burst_location,
@@ -11,12 +12,15 @@ from app.api.v1.endpoints import (
leakage,
meta,
model_import,
operational_tasks,
place_search,
project,
project_data,
scada,
sensor_placement,
simulation,
web_search,
workbench_runtime,
workbench_events,
)
from app.api.v1.endpoints.components import (
controls,
@@ -85,8 +89,22 @@ burst_run_access = Depends(require_permission(BURST_RUN))
optimization_run_access = Depends(require_permission(OPTIMIZATION_RUN))
# Core services
api_router.include_router(
workbench_runtime.router,
tags=["Workbench Runtime"],
dependencies=[simulation_access],
)
api_router.include_router(
workbench_events.router,
tags=["Workbench Events"],
)
api_router.include_router(
operational_tasks.router,
tags=["Operational Tasks"],
)
api_router.include_router(access.router, tags=["Access Control"])
api_router.include_router(agent_auth.router, tags=["Agent Auth"])
api_router.include_router(agent_messages.router, tags=["Agent Messages"])
api_router.include_router(
admin_metadata.router,
tags=["Metadata Admin"],
@@ -143,13 +161,13 @@ api_router.include_router(
dependencies=[optimization_run_access],
)
api_router.include_router(
web_search.router,
tags=["Web Search"],
geocoding.router,
tags=["Geocoding"],
dependencies=[webgis_view_access],
)
api_router.include_router(
geocoding.router,
tags=["Geocoding"],
place_search.router,
tags=["Place Search"],
dependencies=[webgis_view_access],
)
api_router.include_router(
@@ -179,6 +197,12 @@ for endpoint_router, tag in (
dependencies=[simulation_access],
)
api_router.include_router(
ts_composite.history_router,
tags=["TimescaleDB - Composite"],
dependencies=[Depends(require_permission(SCADA_VIEW))],
)
for endpoint_router, tag in (
(ts_scada.router, "TimescaleDB - SCADA"),
(ts_composite.router, "TimescaleDB - Composite"),
+10
View File
@@ -6,6 +6,9 @@ from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from app.core.config import settings
from app.mcp.config import McpSettings
mcp_resource_settings = McpSettings()
oauth2_optional = OAuth2PasswordBearer(
tokenUrl="keycloak", auto_error=False
@@ -29,6 +32,13 @@ def _decode_keycloak_token(token: str) -> dict:
algorithms=[settings.KEYCLOAK_ALGORITHM],
audience=settings.KEYCLOAK_AUDIENCE or None,
)
# A resource-bound MCP token must never authorize product REST APIs,
# including when another audience overlaps the configured API audience.
audience = payload.get("aud", [])
audiences = [audience] if isinstance(audience, str) else audience
if (isinstance(audiences, list) and mcp_resource_settings.audience in audiences
or payload.get("azp") == mcp_resource_settings.host_client_id):
raise JWTError("MCP resource tokens cannot authorize product REST APIs")
if settings.KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS <= 0:
return payload
+33
View File
@@ -21,6 +21,15 @@ MODEL_IMPORT = "model.import"
AUDIT_VIEW = "audit.view"
ENVIRONMENT_MANAGE = "environment.manage"
MEMBERSHIP_MANAGE = "membership.manage"
OPERATIONAL_TASK_VIEW = "operational_task.view"
OPERATIONAL_TASK_WRITE = "operational_task.write"
OPERATIONAL_TASK_EXECUTE = "operational_task.execute"
OPERATIONAL_TASK_EXTERNAL_SYNC = "operational_task.external_sync"
OPERATIONAL_TASK_ACCESS_APPROVE = "operational_task.access.approve"
OPERATIONAL_ACTION_WORK_ORDER_APPROVE = "operational_action.work_order.approve"
OPERATIONAL_ACTION_WORK_ORDER_EXECUTE = "operational_action.work_order.execute"
OPERATIONAL_ACTION_COMMAND_APPROVE = "operational_action.command.approve"
OPERATIONAL_ACTION_COMMAND_EXECUTE = "operational_action.command.execute"
PROJECT_MEMBER_PERMISSIONS = frozenset(
{
@@ -36,6 +45,11 @@ PROJECT_MEMBER_PERMISSIONS = frozenset(
RISK_RUN,
OPTIMIZATION_VIEW,
OPTIMIZATION_RUN,
OPERATIONAL_TASK_VIEW,
OPERATIONAL_TASK_WRITE,
OPERATIONAL_TASK_ACCESS_APPROVE,
OPERATIONAL_ACTION_WORK_ORDER_APPROVE,
OPERATIONAL_ACTION_WORK_ORDER_EXECUTE,
}
)
@@ -44,6 +58,7 @@ PROJECT_VIEWER_PERMISSIONS = frozenset(
WEBGIS_VIEW,
SCADA_VIEW,
SIMULATION_VIEW,
OPERATIONAL_TASK_VIEW,
}
)
@@ -53,6 +68,15 @@ SYSTEM_ADMIN_PERMISSIONS = frozenset(
AUDIT_VIEW,
ENVIRONMENT_MANAGE,
MEMBERSHIP_MANAGE,
OPERATIONAL_TASK_EXECUTE,
OPERATIONAL_TASK_EXTERNAL_SYNC,
}
)
SYSTEM_SERVICE_PERMISSIONS = frozenset(
{
OPERATIONAL_TASK_EXECUTE,
OPERATIONAL_TASK_EXTERNAL_SYNC,
}
)
@@ -71,6 +95,15 @@ def resolve_permissions(
permissions = set(PROJECT_ROLE_PERMISSIONS.get(project_role or "", frozenset()))
if is_superuser or system_role == "admin":
permissions.update(SYSTEM_ADMIN_PERMISSIONS)
if project_role == "member":
permissions.update(
{
OPERATIONAL_ACTION_COMMAND_APPROVE,
OPERATIONAL_ACTION_COMMAND_EXECUTE,
}
)
elif system_role == "service" and project_role == "member":
permissions.update(SYSTEM_SERVICE_PERMISSIONS)
return frozenset(permissions)
+35 -36
View File
@@ -16,7 +16,7 @@ from app.infra.db.metadb.repositories.metadata_repository import (
MetadataRepository,
ProjectDbRouting,
)
from app.infra.db.project_routing import ActiveProjectRouting
from app.infra.db.project_routing import ActiveProjectRouting, activate_project_routing
DB_ROLE_BIZ_DATA = "biz_data"
DB_ROLE_IOT_DATA = "iot_data"
@@ -107,6 +107,14 @@ async def get_project_business_routing(
return await resolve_project_business_routing(ctx, metadata_repo)
async def use_project_business_routing(
routing: ActiveProjectRouting = Depends(get_project_business_routing),
) -> AsyncGenerator[ActiveProjectRouting, None]:
"""Keep the routed BizDB active for an entire endpoint invocation."""
with activate_project_routing(routing):
yield routing
async def resolve_project_business_routing(
ctx: ProjectContext,
metadata_repo: MetadataRepository,
@@ -121,6 +129,7 @@ async def resolve_project_business_routing(
return ActiveProjectRouting(
project_code=ctx.project_code,
business_dsn=business.dsn,
project_id=ctx.project_id,
)
@@ -153,6 +162,7 @@ async def resolve_project_simulation_routing(
project_code=ctx.project_code,
business_dsn=business.dsn,
timescale_dsn=timescale.dsn,
project_id=ctx.project_id,
)
@@ -189,31 +199,6 @@ async def _get_project_routing(
return routing
async def get_project_pg_session(
ctx: ProjectContext = Depends(get_project_context),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> AsyncGenerator[AsyncSession, None]:
routing = await _get_project_routing(
metadata_repo,
ctx.project_id,
DB_ROLE_BIZ_DATA,
DB_TYPE_POSTGRES,
"PostgreSQL",
)
pool_min_size = routing.pool_min_size or settings.PROJECT_PG_POOL_SIZE
pool_max_size = routing.pool_max_size or settings.PROJECT_PG_POOL_SIZE
sessionmaker = await project_connection_manager.get_pg_sessionmaker(
ctx.project_id,
DB_ROLE_BIZ_DATA,
routing.dsn,
pool_min_size,
pool_max_size,
)
async with sessionmaker() as session:
yield session
async def get_project_pg_connection(
ctx: ProjectContext = Depends(get_project_context),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
@@ -226,16 +211,23 @@ async def get_project_pg_connection(
"PostgreSQL",
)
pool_min_size = routing.pool_min_size or settings.PROJECT_PG_POOL_SIZE
pool_max_size = routing.pool_max_size or settings.PROJECT_PG_POOL_SIZE
pool = await project_connection_manager.get_pg_pool(
pool_min_size = (
routing.pool_min_size
if routing.pool_min_size is not None
else settings.PROJECT_PG_POOL_MIN_SIZE
)
pool_max_size = (
routing.pool_max_size
if routing.pool_max_size is not None
else settings.PROJECT_PG_POOL_SIZE
)
async with project_connection_manager.pg_connection(
ctx.project_id,
DB_ROLE_BIZ_DATA,
routing.dsn,
pool_min_size,
pool_max_size,
)
async with pool.connection() as conn:
) as conn:
yield conn
@@ -251,14 +243,21 @@ async def get_project_timescale_connection(
"TimescaleDB",
)
pool_min_size = routing.pool_min_size or settings.PROJECT_TS_POOL_MIN_SIZE
pool_max_size = routing.pool_max_size or settings.PROJECT_TS_POOL_MAX_SIZE
pool = await project_connection_manager.get_timescale_pool(
pool_min_size = (
routing.pool_min_size
if routing.pool_min_size is not None
else settings.PROJECT_TS_POOL_MIN_SIZE
)
pool_max_size = (
routing.pool_max_size
if routing.pool_max_size is not None
else settings.PROJECT_TS_POOL_MAX_SIZE
)
async with project_connection_manager.timescale_connection(
ctx.project_id,
DB_ROLE_IOT_DATA,
routing.dsn,
pool_min_size,
pool_max_size,
)
async with pool.connection() as conn:
) as conn:
yield conn
+46 -12
View File
@@ -1,6 +1,7 @@
from pathlib import Path
from typing import Optional
from urllib.parse import quote_plus
from pydantic import AliasChoices, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -9,7 +10,6 @@ class Settings(BaseSettings):
ENVIRONMENT: str = "production"
API_V1_STR: str = "/api/v1"
NETWORK_NAME: str = "default_network"
# 敏感配置加密密钥 (Fernet)
DATABASE_ENCRYPTION_KEY: str = ""
@@ -26,6 +26,9 @@ class Settings(BaseSettings):
TIMESCALEDB_DB_PORT: str = "5433"
TIMESCALEDB_DB_USER: str = "postgres"
TIMESCALEDB_DB_PASSWORD: str = "password"
TIMESCALEDB_REALTIME_MAX_DECOMPRESSED_TUPLES: int = Field(
default=1_000_000, ge=1, le=2_147_483_647,
)
# Metadata Database Config (PostgreSQL)
METADATA_DB_NAME: str = "system_hub"
METADATA_DB_HOST: str = "localhost"
@@ -36,13 +39,26 @@ class Settings(BaseSettings):
METADATA_DB_POOL_SIZE: int = 5
METADATA_DB_MAX_OVERFLOW: int = 10
PROJECT_PG_CACHE_SIZE: int = 50
PROJECT_TS_CACHE_SIZE: int = 50
PROJECT_PG_CACHE_SIZE: int = 16
PROJECT_TS_CACHE_SIZE: int = 16
PROJECT_PG_POOL_MIN_SIZE: int = 0
PROJECT_PG_POOL_SIZE: int = 5
PROJECT_PG_MAX_OVERFLOW: int = 10
PROJECT_PG_POOL_SIZE: int = 4
PROJECT_PG_MAX_OVERFLOW: int = 2
PROJECT_TS_POOL_MIN_SIZE: int = 0
PROJECT_TS_POOL_MAX_SIZE: int = 10
PROJECT_TS_POOL_MAX_SIZE: int = 4
WNDB_SCHEMA_TEMPLATE_DB_NAME: str = "tjwater_v2_schema_template"
TIMESCALEDB_SCHEMA_TEMPLATE_DB_NAME: str = "tjwater_v2_timescale_template"
WNDB_TEMP_DB_MAX_COUNT: int = 8
# GeoServer project provisioning
GEOSERVER_URL: str = "http://localhost:8080/geoserver"
GEOSERVER_USERNAME: str = ""
GEOSERVER_PASSWORD: str = ""
GEOSERVER_DB_HOST: str = ""
GEOSERVER_DB_PORT: str = ""
GEOSERVER_DB_USER: str = ""
GEOSERVER_DB_PASSWORD: str = ""
GEOSERVER_CLIENT_CACHE_SECONDS: int = 30 * 24 * 60 * 60
# Keycloak access token verification
KEYCLOAK_PUBLIC_KEY: str = ""
@@ -50,16 +66,34 @@ class Settings(BaseSettings):
KEYCLOAK_AUDIENCE: str = ""
KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS: int = 900
# 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
# Authenticated workbench synchronization.
WORKBENCH_EVENTS_ENABLED: bool = True
WORKBENCH_EVENT_RETENTION_DAYS: int = 7
CORS_ALLOWED_ORIGINS: str = (
"http://127.0.0.1:5173,http://localhost:5173,"
"http://127.0.0.1:15173,http://localhost:15173"
)
# Tianditu Geocoding API
TIANDITU_GEOCODER_TOKEN: str = ""
@property
def cors_allowed_origins(self) -> list[str]:
origins = [value.strip() for value in self.CORS_ALLOWED_ORIGINS.split(",") if value.strip()]
if "*" in origins:
raise ValueError("CORS_ALLOWED_ORIGINS must list explicit origins")
return origins
# One shared Tianditu key for geocoding, place search and other services.
TIANDITU_TOKEN: str = Field(
"",
validation_alias=AliasChoices(
"TIANDITU_TOKEN", "TJWATER_TIANDITU_TOKEN", "TIANDITU_GEOCODER_TOKEN"
),
)
TIANDITU_GEOCODER_URL: str = "https://api.tianditu.gov.cn/geocoder"
TIANDITU_GEOCODER_TIMEOUT_SECONDS: float = 30.0
TIANDITU_SEARCH_URL: str = "https://api.tianditu.gov.cn/v2/search"
TIANDITU_SEARCH_TIMEOUT_SECONDS: float = 15.0
@property
def SQLALCHEMY_DATABASE_URI(self) -> str:
db_password = quote_plus(self.DB_PASSWORD)
+17
View File
@@ -0,0 +1,17 @@
import logging
import re
class TiandituTokenFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
message = record.getMessage()
if "tianditu.gov.cn" in message:
record.msg = re.sub(r"([?&]tk=)[^&\s\"']+", r"\1[redacted]", message)
record.args = ()
return True
def install_tianditu_token_filter() -> None:
logger = logging.getLogger("httpx")
if not any(isinstance(item, TiandituTokenFilter) for item in logger.filters):
logger.addFilter(TiandituTokenFilter())
+22
View File
@@ -0,0 +1,22 @@
from __future__ import annotations
class DomainError(Exception):
"""A domain rejection with a stable API error payload."""
def __init__(self, code: str, message: str, *, refs: list | None = None,
recovery: str = "none", status: int = 422):
super().__init__(message)
self.code, self.message, self.status = code, message, status
self.refs, self.recovery = refs or [], recovery
def payload(self) -> dict:
return {"code": self.code, "message": self.message,
"retryable": self.recovery == "retry_same_request",
"related_refs": self.refs, "recovery_action": self.recovery,
"retry_after_seconds": None}
def require(condition: bool, code: str, message: str, **kwargs) -> None:
if not condition:
raise DomainError(code, message, **kwargs)
+13
View File
@@ -0,0 +1,13 @@
from typing import Literal
Metric = Literal["flow", "pressure", "velocity"]
DISPLAY_UNITS: dict[Metric, str] = {
"flow": "m³/h",
"pressure": "m",
"velocity": "m/s",
}
def display_unit_for_metric(metric: Metric) -> str:
return DISPLAY_UNITS[metric]
+15 -2
View File
@@ -4,8 +4,10 @@ from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.domain.schemas.project_map_config import ProjectMapConfig, ProjectMapConfigUpdate
BusinessRole = Literal["admin", "user"]
BusinessRole = Literal["admin", "service", "user"]
ProjectRole = Literal["member", "viewer"]
ProjectStatus = Literal["active", "inactive", "archived"]
ProjectDbRole = Literal["biz_data", "iot_data"]
@@ -75,10 +77,11 @@ class AdminProjectCreateRequest(BaseModel):
description: str | None = None
gs_workspace: str = Field(..., min_length=1, max_length=100)
map_extent: dict | None = None
map_config: ProjectMapConfig = Field(default_factory=ProjectMapConfig)
status: ProjectStatus = "active"
class AdminProjectUpdateRequest(BaseModel):
class AdminProjectUpdateRequest(ProjectMapConfigUpdate):
name: str | None = Field(default=None, min_length=1, max_length=100)
code: str | None = Field(default=None, min_length=1, max_length=50)
description: str | None = None
@@ -94,11 +97,21 @@ class AdminProjectResponse(BaseModel):
description: str | None = None
gs_workspace: str
map_extent: dict | None = None
map_config: ProjectMapConfig = Field(default_factory=ProjectMapConfig)
status: str
created_at: datetime
updated_at: datetime
class ProjectProvisionResponse(BaseModel):
project: AdminProjectResponse
business_database: str
model_template_database: str
timescale_database: str
geoserver_workspace: str
geoserver_layers: list[str]
class ProjectDatabaseUpsertRequest(BaseModel):
db_role: ProjectDbRole
dsn: str | None = Field(default=None, min_length=1)
+59
View File
@@ -0,0 +1,59 @@
"""Private, durable messages between an ordinary chat and one investigation run."""
from typing import Annotated, Literal
from uuid import UUID
from pydantic import Field
from app.domain.schemas.shared import NonEmptyStr, StrictModel
from app.domain.schemas.workbench_runtime import WorkbenchDateTime
MessageText = Annotated[str, Field(min_length=1, max_length=12000, pattern=r"\S")]
MessageStatus = Literal["pending", "enqueued", "delivered", "failed"]
class AgentMessageCreate(StrictModel):
request_id: UUID
task_id: UUID
run_id: UUID
source_session_id: NonEmptyStr
text: MessageText
class AgentMessageReply(StrictModel):
request_id: UUID
source_session_id: NonEmptyStr
text: MessageText
class AgentMessageDelivery(StrictModel):
status: Literal["enqueued", "delivered", "failed"]
target_session_id: NonEmptyStr | None = None
native_message_id: NonEmptyStr | None = None
failure_detail: Annotated[str, Field(max_length=500)] | None = None
class AgentMessage(StrictModel):
project_id: UUID
message_id: UUID
request_id: UUID
task_id: UUID
run_id: UUID
source_session_id: NonEmptyStr
source_owner_id: UUID
conversation_owner_id: UUID
target_session_id: NonEmptyStr | None = None
direction: Literal["to_task", "to_chat"]
in_reply_to: UUID | None = None
text: MessageText
status: MessageStatus
native_message_id: NonEmptyStr
failure_detail: str | None = None
created_at: WorkbenchDateTime
updated_at: WorkbenchDateTime
delivered_at: WorkbenchDateTime | None = None
class AgentMessagePage(StrictModel):
items: list[AgentMessage]
next_cursor: str | None = None
+99
View File
@@ -0,0 +1,99 @@
"""Validated, local presentation blocks. Component props come from the frontend catalog."""
from __future__ import annotations
import json
import math
from pathlib import Path
from typing import Annotated, Any, Literal
from jsonschema import Draft7Validator
from pydantic import BaseModel, ConfigDict, Field, model_validator
_catalog = json.loads(
(Path(__file__).resolve().parents[3] / "contracts/answer-ui-catalog.json").read_text()
)
_validators = {name: Draft7Validator(schema) for name, schema in _catalog["props"].items()}
Key = Annotated[str, Field(pattern=r"^[a-zA-Z0-9_-]{1,80}$")]
class UiModel(BaseModel):
model_config = ConfigDict(extra="forbid")
class AnswerUiElement(UiModel):
type: str
props: dict[str, Any]
children: list[Key] = Field(max_length=48)
class AnswerUiSpec(UiModel):
root: Key
elements: dict[Key, AnswerUiElement]
class AnswerUiBlock(UiModel):
version: Literal["answer-ui@1"]
block_id: Key
fallback_text: Annotated[str, Field(min_length=1, max_length=4000)]
spec: AnswerUiSpec
@model_validator(mode="after")
def validate_components(self):
spec = self.spec
if not 1 <= len(spec.elements) <= 48:
raise ValueError("UI requires 1 to 48 elements")
# Match the existing JavaScript serialized character limit, including UTF-16.
serialized = json.dumps(self.model_dump(), ensure_ascii=False, separators=(",", ":"))
if len(serialized.encode("utf-16-le")) // 2 > 100000:
raise ValueError("UI block is too large")
for element in spec.elements.values():
validator = _validators.get(element.type)
if validator is None or not validator.is_valid(element.props):
raise ValueError("Unknown UI component or invalid properties")
if element.children and element.type not in {"Stack", "Section"}:
raise ValueError("Leaf components cannot contain children")
props = element.props
if element.type == "Chart":
if any(len(series["data"]) != len(props["x_data"]) for series in props["series"]):
raise ValueError("Chart series must match the axis")
if any(not math.isfinite(value) for series in props["series"] for value in series["data"]):
raise ValueError("Chart values must be finite")
if element.type == "Table" and any(len(row) != len(props["columns"]) for row in props["rows"]):
raise ValueError("Table row width must match its columns")
if element.type == "FollowUpForm":
names = [field["name"] for field in props["fields"]]
if len(names) != len(set(names)):
raise ValueError("Form field names must be unique")
visited: set[str] = set()
def visit(key: str, depth: int):
if depth > 12 or key in visited or key not in spec.elements:
raise ValueError("UI must be a bounded tree without duplicate or missing references")
visited.add(key)
for child in spec.elements[key].children:
visit(child, depth + 1)
visit(spec.root, 0)
if len(visited) != len(spec.elements):
raise ValueError("UI contains unreachable elements")
return self
class ReportUiBlock(UiModel):
block: AnswerUiBlock
evidence_refs: list[Annotated[str, Field(min_length=1, max_length=128)]] = Field(min_length=1, max_length=100)
def validate_report_ui_blocks(blocks: list[ReportUiBlock], evidence_refs: list[str]) -> None:
ids = [item.block.block_id for item in blocks]
if len(ids) != len(set(ids)):
raise ValueError("Report UI block ids must be unique")
serialized = json.dumps([item.model_dump() for item in blocks], ensure_ascii=False, separators=(",", ":"))
if len(serialized.encode("utf-8")) > 400000:
raise ValueError("Report UI exceeds 400 KB")
for item in blocks:
if not set(item.evidence_refs) <= set(evidence_refs):
raise ValueError("UI evidence must be included in report evidence")
for element in item.block.spec.elements.values():
if element.type == "Evidence" and element.props.get("evidence_id") not in item.evidence_refs:
raise ValueError("UI evidence component must reference its block evidence")
+47
View File
@@ -0,0 +1,47 @@
"""Schemas for project-scoped source GIS attributes."""
from datetime import date
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
GisElementType = Literal[
"pipes",
"junctions",
"valves",
"reservoirs",
"pumps",
"tanks",
"scada",
]
class GisAttributeGroup(BaseModel):
model_config = ConfigDict(extra="forbid")
dataset_key: str
dataset_title: str
source_date: date
layer_key: str
layer_name: str
source_fid: int
match_method: Literal["node_pair", "geometry", "source"]
match_distance_m: float | None = None
attributes: dict[str, Any] = Field(default_factory=dict)
class ElementGisProperties(BaseModel):
model_config = ConfigDict(extra="forbid")
element_type: GisElementType
element_id: str
groups: list[GisAttributeGroup] = Field(default_factory=list)
class SourceGisFeatureProperties(BaseModel):
model_config = ConfigDict(extra="forbid")
layer_key: str
source_fid: int
group: GisAttributeGroup
+4 -1
View File
@@ -1,7 +1,9 @@
from typing import Optional
from uuid import UUID
from pydantic import BaseModel
from pydantic import BaseModel, Field
from app.domain.schemas.project_map_config import ProjectMapConfig
class ProjectMetaResponse(BaseModel):
@@ -11,6 +13,7 @@ class ProjectMetaResponse(BaseModel):
description: Optional[str] = None
gs_workspace: str
map_extent: Optional[dict] = None
map_config: ProjectMapConfig = Field(default_factory=ProjectMapConfig)
status: str
project_role: str

Some files were not shown because too many files have changed in this diff Show More