Compare commits
234
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0685f6dd17 | ||
|
|
90b02057bc | ||
|
|
10a7a66a41 | ||
|
|
bd857ea5e1 | ||
|
|
8942541759 | ||
|
|
5966d039de | ||
|
|
9b095c7439 | ||
|
|
b74799a39d | ||
|
|
fa188af0b1 | ||
|
|
fdbcc5c033 | ||
|
|
d63d7ef1b6 | ||
|
|
6b09662de6 | ||
|
|
b21eaffe40 | ||
|
|
8853877fcd | ||
|
|
2581631b51 | ||
|
|
c250e97b87 | ||
|
|
69a7d53aff | ||
|
|
e4975b7be3 | ||
|
|
a7e1ce6ef4 | ||
|
|
4350612807 | ||
|
|
b0a23a8012 | ||
|
|
70c5b0e445 | ||
|
|
29f691731c | ||
|
|
1432934f12 | ||
|
|
b0e9d480ef | ||
|
|
87d922ea61 | ||
|
|
c126e99b60 | ||
|
|
b6a6527bab | ||
|
|
f010f071eb | ||
|
|
0be31869b5 | ||
|
|
eac6b78598 | ||
|
|
1d88f8efbe | ||
|
|
ba947b616b | ||
|
|
ae1a657554 | ||
|
|
3fbb17bb30 | ||
|
|
ddbb50173c | ||
|
|
437eb5a19a | ||
|
|
31e2728db1 | ||
|
|
03bb2d75c2 | ||
|
|
b977bf6725 | ||
|
|
045d6c5b49 | ||
|
|
db6032bd84 | ||
|
|
b4ecfbb87a | ||
|
|
a204980944 | ||
|
|
2b5f9b8514 | ||
|
|
ca1579dcc2 | ||
|
|
775ecb8a58 | ||
|
|
f72b56845f | ||
|
|
baeaa8a2e1 | ||
|
|
ca97de2e51 | ||
|
|
71fa2ae18c | ||
|
|
76cf6c32bc | ||
|
|
5a91da0904 | ||
|
|
d62bcae85e | ||
|
|
4c0a4b29e9 | ||
|
|
80ca985c28 | ||
|
|
5a55d65002 | ||
|
|
d99f4cec6a | ||
|
|
a6e7a2e75c | ||
|
|
23c008f602 | ||
|
|
2a762e63a7 | ||
|
|
f6939f5516 | ||
|
|
bbf6a0f7ba | ||
|
|
5fd82b8e7c | ||
|
|
2a823b2616 | ||
|
|
2af89eea1c | ||
|
|
26643d68c7 | ||
|
|
f35287d3cf | ||
|
|
4fa8e55748 | ||
|
|
7a9fcaae81 | ||
|
|
a1e9673d9a | ||
|
|
e588d1cf33 | ||
|
|
1712ecd4c7 | ||
|
|
441979f581 | ||
|
|
e336ffcd46 | ||
|
|
52b8f07abd | ||
|
|
7efaeb41e8 | ||
|
|
9a7aad2d36 | ||
|
|
b7872f29a9 | ||
|
|
233960d8db | ||
|
|
b9410b0ff3 | ||
|
|
4982efba5e | ||
|
|
f87dd91b2b | ||
|
|
c16e6e3d0c | ||
|
|
40e699e173 | ||
|
|
9b8a517092 | ||
|
|
f274cf5122 | ||
|
|
60db2a7193 | ||
|
|
b72e42521c | ||
|
|
c2ccb7bc4e | ||
|
|
88be97ddeb | ||
|
|
2317f4d527 | ||
|
|
751950e5b5 | ||
|
|
a1dcbd4230 | ||
|
|
3b712ea467 | ||
|
|
bf2aaa5ff7 | ||
|
|
51b481d174 | ||
|
|
644babf77e | ||
|
|
6b09c6b20d | ||
|
|
93cbd7e7b3 | ||
|
|
0196206ed3 | ||
|
|
88eec2787b | ||
|
|
621cd9d2f9 | ||
|
|
600ddd329c | ||
|
|
c184610035 | ||
|
|
21dd393aee | ||
|
|
b0acfb21ec | ||
|
|
20ec7d9c8d | ||
|
|
7c44654195 | ||
|
|
c5d3075ae2 | ||
|
|
2ea5ce14ba | ||
|
|
adb5dc01fb | ||
|
|
fb9f3217e2 | ||
|
|
5e8600a0a7 | ||
|
|
1dcaf5ae9f | ||
|
|
a792838e80 | ||
|
|
3cd76b9b52 | ||
|
|
e6d00e9bc6 | ||
|
|
68c12cc4eb | ||
|
|
e0c247f3b2 | ||
|
|
c3bf48499b | ||
|
|
102cfffefe | ||
|
|
1a76c89054 | ||
|
|
1673396e1a | ||
|
|
c137adedad | ||
|
|
5041922c84 | ||
|
|
cfe69e581b | ||
|
|
b513d05611 | ||
|
|
9a8d851275 | ||
|
|
50a1e78073 | ||
|
|
83a6143146 | ||
|
|
9aa0646bc6 | ||
|
|
d34c61a051 | ||
|
|
baf899eaeb | ||
|
|
72d642fcf6 | ||
|
|
4ea0b8f05b | ||
|
|
aa68bc73ca | ||
|
|
bef1c74782 | ||
|
|
90216a762a | ||
|
|
559d5bb8e3 | ||
|
|
7345210bdd | ||
|
|
0d8a7f5cb7 | ||
|
|
efeca41cbd | ||
|
|
8c7d77e6ee | ||
|
|
c946e1b58b | ||
|
|
0b72ac959a | ||
|
|
48f836d667 | ||
|
|
6eec6c04de | ||
|
|
61d540356d | ||
|
|
eb1d9cce56 | ||
|
|
78978c6931 | ||
|
|
747b4cd229 | ||
|
|
ed1eb74cfb | ||
|
|
20ab08e206 | ||
|
|
6b85cfc666 | ||
|
|
a56e041cfc | ||
|
|
f9111ab9c1 | ||
|
|
d55e23bc44 | ||
|
|
b3d58379ef | ||
|
|
9a4a91c328 | ||
|
|
a7e3b6aff9 | ||
|
|
05ca940c9f | ||
|
|
0f8d33291d | ||
|
|
143b918b86 | ||
|
|
7ff28893a1 | ||
|
|
b9d9cef5ef | ||
|
|
0c6c27a0c1 | ||
|
|
f5a7e5b3c9 | ||
|
|
78a57f5c56 | ||
|
|
7f481ca261 | ||
|
|
bc74e94fbb | ||
|
|
b83b895e2b | ||
|
|
63d3458fb4 | ||
|
|
b8aee14c00 | ||
|
|
340808e85e | ||
|
|
2464c7f612 | ||
|
|
61f6975296 | ||
|
|
d0abad3c65 | ||
|
|
e7a3aec02f | ||
|
|
1d662f973a | ||
|
|
5566172e26 | ||
|
|
df76e40b0a | ||
|
|
2e479868f8 | ||
|
|
80b6970970 | ||
|
|
364a8c8ec2 | ||
|
|
52ccb8abf1 | ||
|
|
0bc4058f23 | ||
|
|
0d3e6ca4fa | ||
|
|
6fc3aa5209 | ||
|
|
1b1b0a3697 | ||
|
|
2826999ddc | ||
|
|
efc05f7278 | ||
|
|
29209f5c63 | ||
|
|
020432ad0e | ||
|
|
780a48d927 | ||
|
|
ff2011ae24 | ||
|
|
f5069a5606 | ||
|
|
eb45e4aaa5 | ||
|
|
a472639b8a | ||
|
|
a0987105dc | ||
|
|
a41be9c362 | ||
|
|
63b31b46b9 | ||
|
|
e4f864a28c | ||
|
|
dc38313cdc | ||
|
|
f19962510a | ||
|
|
6434cae21c | ||
|
|
a85ff8e215 | ||
|
|
2794114000 | ||
|
|
4c208abe55 | ||
|
|
e893c7db5f | ||
|
|
f2776ef0bf | ||
|
|
870c9433d6 | ||
|
|
6fe01aa248 | ||
|
|
0755b1a61c | ||
|
|
9be2028e4c | ||
|
|
3c7e2c5806 | ||
|
|
c3c26fb107 | ||
|
|
e4c8b03277 | ||
|
|
35abaa1ebb | ||
|
|
807e634318 | ||
|
|
b6b37a453b | ||
|
|
e3141ee250 | ||
|
|
9037bf317b | ||
|
|
9d7a9fb2fd | ||
|
|
7c9667822f | ||
|
|
f3665798b7 | ||
|
|
7640d96f86 | ||
|
|
d21966e985 | ||
|
|
0d139f96f8 | ||
|
|
2668faf8ad | ||
|
|
fd3a9f92c0 | ||
|
|
5986a20cc3 | ||
|
|
6c0f7d821c | ||
|
|
f1b05b7fa2 |
@@ -0,0 +1,22 @@
|
|||||||
|
.git
|
||||||
|
.github
|
||||||
|
.gitea
|
||||||
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
package/
|
||||||
|
temp/
|
||||||
|
data/
|
||||||
|
db_inp/
|
||||||
|
inp/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
logs/
|
||||||
|
coverage/
|
||||||
|
*.pyc
|
||||||
|
*.dump
|
||||||
|
app/algorithms/pipe_health_prediction/model/my_survival_forest_model_quxi.joblib
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# TJWater Server 环境变量配置模板
|
||||||
|
# 复制此文件为 .env 并填写实际值
|
||||||
|
# CI/CD: 生产环境变量由 Dev 主机的受控 backend.env 注入,不要将完整 .env 保存为 Gitea 仓库密钥。
|
||||||
|
ENVIRONMENT="production"
|
||||||
|
NETWORK_NAME="tjwater"
|
||||||
|
# ============================================
|
||||||
|
# 敏感配置加密 (必填)
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# Fernet 格式,生产环境必须替换为独立密钥
|
||||||
|
# 生成方式: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
# 用于项目数据库 DSN、GeoServer 管理密码等敏感配置
|
||||||
|
DATABASE_ENCRYPTION_KEY="replace-with-generated-fernet-key"
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 数据库配置 (PostgreSQL)
|
||||||
|
# ============================================
|
||||||
|
DB_NAME="tjwater"
|
||||||
|
DB_HOST="localhost"
|
||||||
|
DB_PORT="5432"
|
||||||
|
DB_USER="tjwater"
|
||||||
|
DB_PASSWORD="password"
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 数据库配置 (TimescaleDB)
|
||||||
|
# ============================================
|
||||||
|
TIMESCALEDB_DB_NAME="tjwater"
|
||||||
|
TIMESCALEDB_DB_HOST="localhost"
|
||||||
|
TIMESCALEDB_DB_PORT="5433"
|
||||||
|
TIMESCALEDB_DB_USER="tjwater"
|
||||||
|
TIMESCALEDB_DB_PASSWORD="password"
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 元数据数据库配置 (Metadata DB)
|
||||||
|
# ============================================
|
||||||
|
METADATA_DB_NAME="system_hub"
|
||||||
|
METADATA_DB_HOST="localhost"
|
||||||
|
METADATA_DB_PORT="5432"
|
||||||
|
METADATA_DB_USER="tjwater"
|
||||||
|
METADATA_DB_PASSWORD="password"
|
||||||
|
|
||||||
|
# Per-project synchronous connection pools
|
||||||
|
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"
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 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 (可选)
|
||||||
|
# ============================================
|
||||||
|
KEYCLOAK_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
|
||||||
|
KEYCLOAK_ALGORITHM=RS256
|
||||||
|
KEYCLOAK_AUDIENCE="account"
|
||||||
|
KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS=900
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Bocha Web Search API
|
||||||
|
# ============================================
|
||||||
|
BOCHA_API_KEY="sk-your-bocha-api-key"
|
||||||
|
BOCHA_WEB_SEARCH_URL="https://api.bochaai.com/v1/web-search"
|
||||||
|
BOCHA_WEB_SEARCH_TIMEOUT_SECONDS=30
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Tianditu Geocoding API
|
||||||
|
# ============================================
|
||||||
|
TIANDITU_GEOCODER_TOKEN="your-tianditu-geocoder-token"
|
||||||
|
TIANDITU_GEOCODER_URL="https://api.tianditu.gov.cn/geocoder"
|
||||||
|
TIANDITU_GEOCODER_TIMEOUT_SECONDS=30
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
name: Server CI/CD v2
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
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
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
build_context: .
|
||||||
|
test_target: test
|
||||||
|
deploy_service: backend
|
||||||
|
deploy_host: 192.168.1.114
|
||||||
|
secrets:
|
||||||
|
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
DEV_DEPLOY_SSH_KEY: ${{ secrets.DEV_DEPLOY_SSH_KEY }}
|
||||||
+6
-3
@@ -1,7 +1,10 @@
|
|||||||
*.pyc
|
|
||||||
.env
|
|
||||||
db_inp/
|
db_inp/
|
||||||
temp/
|
temp/
|
||||||
data/
|
data/
|
||||||
|
build/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
*.dump
|
*.dump
|
||||||
api_ex/model/my_survival_forest_model_quxi.joblib
|
.vscode/
|
||||||
|
app/algorithms/pipe_health_prediction/model/my_survival_forest_model_quxi.joblib
|
||||||
|
/inp/
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Structure & Module Organization
|
||||||
|
|
||||||
|
This repository contains the TJWater Python backend. Main application code lives in `app/`: API routes under `app/api`, authentication in `app/auth`, configuration in `app/core`, database and repository code in `app/infra`, domain models/schemas in `app/domain`, and business logic in `app/services` and `app/algorithms`.
|
||||||
|
|
||||||
|
Tests are under `tests/`, split into `tests/unit`, `tests/api`, and `tests/auth`. SQL and sample assets are stored in `resources/`; deployment files are in `Dockerfile`, `.gitea/workflows/package.yml`, and `infra/docker/docker-compose.yml`. Local data directories such as `db_inp/`, `temp/`, `data/`, and `.env` are ignored and should not be committed.
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
|
||||||
|
Use the existing conda environment when available:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda run -n server python -m pytest tests/unit tests/auth -q
|
||||||
|
conda run -n server uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
docker build -t tjwater-server:local .
|
||||||
|
docker compose -f infra/docker/docker-compose.yml config
|
||||||
|
```
|
||||||
|
|
||||||
|
`pytest` runs backend tests. `uvicorn` starts the FastAPI app locally. `docker build` verifies the container image. `docker compose config` validates compose syntax and variable expansion.
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
|
||||||
|
Use Python 3.12, four-space indentation, type hints for new public functions, and explicit imports. Keep API endpoint modules grouped by domain under `app/api/v1/endpoints`. Use `snake_case` for files, functions, and variables; `PascalCase` for classes and Pydantic models. Prefer existing repository/service patterns in `app/infra/db` and `app/services` over introducing new abstractions.
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
|
||||||
|
The project uses `pytest`. Name test files `test_*.py` and test functions `test_*`. Keep unit tests isolated with fakes or monkeypatching from `tests/conftest.py`. Some existing tests depend on local data outside the repository; avoid adding new tests that require untracked files. For API changes, add or update tests in `tests/api`.
|
||||||
|
|
||||||
|
## Commit & Pull Request Guidelines
|
||||||
|
|
||||||
|
History uses a mix of Conventional Commit prefixes and concise Chinese messages, for example `feat(api): add Tianditu geocoding` or `fix(auth): validate project context`. Prefer `feat(scope): ...`, `fix(scope): ...`, or a clear Chinese summary.
|
||||||
|
|
||||||
|
Pull requests should describe the behavior change, list verification commands, mention configuration or migration impacts, and link related issues. Include API examples or screenshots only when they clarify user-facing behavior.
|
||||||
|
|
||||||
|
## Security & Configuration Tips
|
||||||
|
|
||||||
|
Do not commit `.env`, database dumps, generated caches, or local project data. Use `.env.example` as the configuration template. CI/CD only uses Gitea repository secrets `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`, and `DEV_DEPLOY_SSH_KEY`; production application settings are injected on the Dev host.
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# TJWater Authentication and Metadata Management
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
Keycloak owns login identity, credentials, token issuance, and token expiry.
|
||||||
|
TJWater metadata stores only business snapshots and authorization data:
|
||||||
|
|
||||||
|
- `users.keycloak_id` is the stable identity binding.
|
||||||
|
- `users.username`, `users.email`, and `users.last_login_at` are Keycloak claim caches.
|
||||||
|
- `users.role`, `users.is_active`, and `users.is_superuser` control TJWater system access.
|
||||||
|
- `user_project_membership.project_role` controls project access.
|
||||||
|
|
||||||
|
The backend does not accept passwords, does not issue local JWTs, and does not
|
||||||
|
trust frontend-supplied user IDs.
|
||||||
|
|
||||||
|
## Fixed Project RBAC
|
||||||
|
|
||||||
|
Project roles are stored directly in
|
||||||
|
`user_project_membership.project_role`; there is no separate role table or
|
||||||
|
user-defined permission editor in this delivery.
|
||||||
|
|
||||||
|
| Role | Main access |
|
||||||
|
| --- | --- |
|
||||||
|
| `modeler` | Model upload/import, simulation, burst, risk, and optimization analysis |
|
||||||
|
| `dispatcher` | SCADA cleaning, simulation and burst analysis |
|
||||||
|
| `auditor` | Project read access and project-scoped audit logs |
|
||||||
|
| `viewer` | WebGIS and read-only risk results |
|
||||||
|
|
||||||
|
Legacy `owner`, `admin`, and `member` values remain supported for existing
|
||||||
|
records. The backend is the authorization boundary; the frontend uses
|
||||||
|
`GET /api/v1/access/context` only to hide unavailable menus and guard routes.
|
||||||
|
System admins receive environment, membership, and global-audit permissions,
|
||||||
|
but still need a project membership for project business APIs.
|
||||||
|
|
||||||
|
## Login Snapshot Refresh
|
||||||
|
|
||||||
|
Every authenticated metadata-user resolution validates the Keycloak access token
|
||||||
|
and reads `sub`, `preferred_username`, and `email` claims. The
|
||||||
|
backend finds `users` by `keycloak_id = sub`, rejects inactive or missing users,
|
||||||
|
then refreshes `username`, `email`, and `last_login_at`.
|
||||||
|
|
||||||
|
This keeps local display data current without changing the identity binding.
|
||||||
|
There is no Keycloak webhook requirement; second-level user or permission sync is
|
||||||
|
out of scope unless explicitly requested later.
|
||||||
|
|
||||||
|
## Admin APIs
|
||||||
|
|
||||||
|
All admin APIs require metadata admin access: `users.is_superuser = true` or
|
||||||
|
`users.role = 'admin'`.
|
||||||
|
|
||||||
|
User and membership management:
|
||||||
|
|
||||||
|
- `GET /api/v1/admin/me`
|
||||||
|
- `POST /api/v1/admin/users/sync`
|
||||||
|
- `POST /api/v1/admin/users/sync/batch`
|
||||||
|
- `GET /api/v1/admin/users`
|
||||||
|
- `GET /api/v1/admin/users/{user_id}`
|
||||||
|
- `PATCH /api/v1/admin/users/{user_id}`
|
||||||
|
- `GET /api/v1/admin/projects/{project_id}/members`
|
||||||
|
- `POST /api/v1/admin/projects/{project_id}/members`
|
||||||
|
- `PATCH /api/v1/admin/projects/{project_id}/members/{user_id}`
|
||||||
|
- `DELETE /api/v1/admin/projects/{project_id}/members/{user_id}`
|
||||||
|
|
||||||
|
Project configuration:
|
||||||
|
|
||||||
|
- `GET /api/v1/admin/projects`
|
||||||
|
- `POST /api/v1/admin/projects`
|
||||||
|
- `PATCH /api/v1/admin/projects/{project_id}`
|
||||||
|
- `GET /api/v1/admin/projects/{project_id}/databases`
|
||||||
|
- `PUT /api/v1/admin/projects/{project_id}/databases`
|
||||||
|
- `DELETE /api/v1/admin/projects/{project_id}/databases/{db_role}`
|
||||||
|
- `POST /api/v1/admin/projects/{project_id}/databases/{db_role}/health`
|
||||||
|
|
||||||
|
## Secret Handling
|
||||||
|
|
||||||
|
Admins submit plaintext DSNs only through HTTPS admin APIs. Operators should not
|
||||||
|
write encrypted columns manually.
|
||||||
|
|
||||||
|
- `project_databases.dsn_encrypted` is encrypted with `DATABASE_ENCRYPTION_KEY`.
|
||||||
|
- Admin responses return only `has_dsn`.
|
||||||
|
- Audit logs record whether a secret was updated, but never store plaintext DSNs
|
||||||
|
or other secrets.
|
||||||
|
|
||||||
|
Generate the database encryption key with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep keys stable for the lifetime of encrypted metadata. Rotating a key requires
|
||||||
|
decrypting with the old key and re-encrypting with the new key.
|
||||||
|
|
||||||
|
## Metadata Schema Patches
|
||||||
|
|
||||||
|
Apply metadata patches in order:
|
||||||
|
|
||||||
|
1. `resources/sql/004_metadata_auth_management.sql`
|
||||||
|
2. `resources/sql/005_metadata_project_configuration.sql`
|
||||||
|
3. `resources/sql/006_metadata_rbac_roles.sql`
|
||||||
|
|
||||||
|
`004` creates Keycloak-backed metadata users and project memberships. `005`
|
||||||
|
creates project and project database routing tables with uniqueness, role/type,
|
||||||
|
and pool-size constraints. `006` extends existing membership constraints with
|
||||||
|
the fixed delivery roles.
|
||||||
|
|
||||||
|
## Frontend System Management
|
||||||
|
|
||||||
|
`/system-admin` is shown only when `GET /api/v1/access/context` returns
|
||||||
|
`environment.manage`. The page lets admins maintain metadata users, project
|
||||||
|
members, projects, project database routing for `biz_data` and `iot_data`, and
|
||||||
|
connection health checks. This replaces direct SQL editing for normal project
|
||||||
|
onboarding.
|
||||||
|
|
||||||
|
Hydraulic model authoring is outside the Web application. Models are prepared
|
||||||
|
in the desktop modeling client and uploaded/imported by an authorized modeler;
|
||||||
|
the system administrator configures the project environment and database
|
||||||
|
routing.
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Backend Naming Audit
|
||||||
|
|
||||||
|
DOC-003 audit for the internal `TJWaterServerBinary` backend.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Reviewed FastAPI route decorators under `app/api/v1/endpoints`, router prefixes in `app/api/v1/router.py`, and public request/response schema fields in `app/api` and `app/domain`.
|
||||||
|
|
||||||
|
The backend is mounted only under `/api/v1` from `app/main.py`; the old no-prefix router include remains commented out.
|
||||||
|
|
||||||
|
## Current Good Surface
|
||||||
|
|
||||||
|
These newer routes already follow the naming rule for public HTTP paths:
|
||||||
|
|
||||||
|
- Metadata/admin: `/api/v1/admin/projects`, `/api/v1/admin/users/sync`, `/api/v1/admin/projects/{project_id}/members`
|
||||||
|
- Audit: `/api/v1/audit/logs`, `/api/v1/audit/logs/count`
|
||||||
|
- Agent auth: `/api/v1/agent/auth/context`
|
||||||
|
- Business APIs: `/api/v1/burst-detection/detect`, `/api/v1/burst-location/locate`, `/api/v1/leakage/identify`
|
||||||
|
- Time-series APIs: `/api/v1/scada/by-ids-time-range`, `/api/v1/scada/by-ids-field-time-range`, `/api/v1/composite/clean-scada`
|
||||||
|
- Project data APIs: `/api/v1/scada-info`, `/api/v1/scheme-list`, `/api/v1/burst-locate-result`
|
||||||
|
- Web integrations: `/api/v1/web-search`, `/api/v1/geocode`
|
||||||
|
|
||||||
|
Path template parameters such as `{project_id}`, `{user_id}`, `{device_id}`, `{scheme_name}`, and `{link_id}` intentionally remain `snake_case`.
|
||||||
|
|
||||||
|
## Legacy URL Categories
|
||||||
|
|
||||||
|
### Keep With Compatibility
|
||||||
|
|
||||||
|
These now have `kebab-case` aliases. The frontend has been migrated to the replacement paths; keep the old paths as deprecated compatibility aliases for Agent planning, tests, customer scripts, or external callers:
|
||||||
|
|
||||||
|
| Current URL | Suggested replacement |
|
||||||
|
| --- | --- |
|
||||||
|
| `/api/v1/openproject/` | `/api/v1/projects/open` |
|
||||||
|
| `/api/v1/project_info/` | `/api/v1/project-info` |
|
||||||
|
| `/api/v1/getallschemes/` | `/api/v1/schemes` |
|
||||||
|
| `/api/v1/getallsensorplacements/` | `/api/v1/sensor-placement-schemes` |
|
||||||
|
| `/api/v1/sensorplacementscheme/create` | `/api/v1/sensor-placement-schemes` |
|
||||||
|
| `/api/v1/burst_analysis/` | `/api/v1/burst-analysis` |
|
||||||
|
| `/api/v1/valve_isolation_analysis/` | `/api/v1/valve-isolation-analysis` |
|
||||||
|
| `/api/v1/flushing_analysis/` | `/api/v1/flushing-analysis` |
|
||||||
|
| `/api/v1/contaminant_simulation/` | `/api/v1/contaminant-simulation` |
|
||||||
|
| `/api/v1/runsimulationmanuallybydate/` | `/api/v1/simulations/run-by-date` |
|
||||||
|
|
||||||
|
### Broad Legacy Surface
|
||||||
|
|
||||||
|
These route groups expose many command-style concatenated paths. They should not be copied into new work; replace only when a caller migration is planned:
|
||||||
|
|
||||||
|
- Project lifecycle: `listprojects`, `createproject`, `deleteproject`, `isprojectopen`, `closeproject`, `copyproject`, `importinp`, `exportinp`, `readinp`, `dumpinp`, `lockproject`, `unlockproject`
|
||||||
|
- Network object CRUD: `addjunction`, `getjunctionelevation`, `setpipediameter`, `getvalvesetting`, and similar junction/pipe/pump/tank/reservoir/valve routes
|
||||||
|
- Region/DMA/VD commands: `calculatedistrictmeteringareaforregion`, `getdistrictmeteringarea`, `generatevirtualdistrict`, and related routes
|
||||||
|
- SCADA native CRUD: `getscadadevice`, `setscadadevicedata`, `cleanscadaelement`, and related routes
|
||||||
|
- Snapshot/synchronization utilities: `takesnapshotforoperation`, `syncwithserver`
|
||||||
|
- Advanced simulation endpoints with underscore paths: `pressure_regulation`, `daily_scheduling_analysis`, `network_update`, `pressure_sensor_placement_kmeans`
|
||||||
|
|
||||||
|
### Direct Cleanup Candidates
|
||||||
|
|
||||||
|
These are likely safe only after confirming no caller uses them:
|
||||||
|
|
||||||
|
- `/api/v1/test_dict/`: development/test utility in `misc.py`.
|
||||||
|
- `/api/v1/takenapshotforcurrentoperation`: typo compatibility path; keep deprecated if any client may still call it.
|
||||||
|
- `/api/v1/getpumpenergyproperties//` and `/api/v1/setpumpenergyproperties//`: double-slash paths in options endpoints.
|
||||||
|
|
||||||
|
## Field Naming
|
||||||
|
|
||||||
|
Most public JSON, query, and SSE fields are already `snake_case`, including `project_id`, `user_id`, `scheme_name`, `scheme_type`, `start_time`, `end_time`, `device_ids`, `session_id`, and `request_id`.
|
||||||
|
|
||||||
|
Known legacy exception:
|
||||||
|
|
||||||
|
- `BurstAnalysis.burst_ID` in `app/api/v1/endpoints/simulation.py` should become `burst_id` on a new API contract. Preserve `burst_ID` only for the legacy body shape.
|
||||||
|
|
||||||
|
Headers keep standard HTTP casing:
|
||||||
|
|
||||||
|
- `X-Project-Id`
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
Do not rename existing legacy routes in place. For each active legacy route, keep the new `kebab-case` alias as the documented path, keep the old route marked deprecated, migrate remaining Agent/customer/script callers, then remove only after a documented compatibility window.
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
FROM condaforge/miniforge3:latest AS base
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple \
|
||||||
|
PIP_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn \
|
||||||
|
UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||||
|
|
||||||
|
# 安装 Python 3.12 和 pymetis (通过 conda-forge 避免编译问题)
|
||||||
|
RUN mamba install -y python=3.12 pymetis && \
|
||||||
|
mamba clean -afy
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir uv
|
||||||
|
RUN uv pip install --system --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# 本地数据目录和环境变量在运行时通过 Compose 挂载或注入,
|
||||||
|
# 不应进入镜像构建上下文。
|
||||||
|
COPY app ./app
|
||||||
|
COPY contracts ./contracts
|
||||||
|
COPY infra ./infra
|
||||||
|
COPY resources ./resources
|
||||||
|
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 tests
|
||||||
|
|
||||||
|
FROM base AS runner
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# TJWaterServerBinary 内部后端
|
||||||
|
|
||||||
|
`TJWaterServerBinary` 是 TJWater 内部版 Python 后端,基于 FastAPI 提供认证、项目、管网、模拟、爆管、漏损、SCADA 和地图服务集成能力。该仓库用于内部开发和完整功能维护。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- Python 3.12
|
||||||
|
- FastAPI / Uvicorn
|
||||||
|
- Pydantic / SQLAlchemy / psycopg
|
||||||
|
- PostgreSQL、PostGIS、TimescaleDB
|
||||||
|
- WNTR、EPANET、Cython、科学计算与空间分析依赖
|
||||||
|
- pytest
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/main.py FastAPI 入口
|
||||||
|
app/api/ HTTP API 路由
|
||||||
|
app/auth/ 认证和权限上下文
|
||||||
|
app/core/ 配置、日志和基础设施初始化
|
||||||
|
app/domain/ 领域模型和 Pydantic schema
|
||||||
|
app/infra/ 数据库、EPANET 和外部集成
|
||||||
|
app/services/ 业务服务编排
|
||||||
|
app/algorithms/ 管网算法、模拟、爆管、漏损、清洗和健康分析
|
||||||
|
app/native/ 本地管网数据读写与转换
|
||||||
|
tests/ 后端测试
|
||||||
|
resources/ SQL、模板和示例资源
|
||||||
|
infra/docker/ Docker Compose 编排
|
||||||
|
```
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
推荐使用已有 conda 环境:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda run -n server python -m pytest tests/unit tests/auth -q
|
||||||
|
conda run -n server uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
如需要进入环境:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda activate server
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常用命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda run -n server python -m pytest tests -q
|
||||||
|
conda run -n server python scripts/run_server.py
|
||||||
|
docker build -t tjwater-server:local .
|
||||||
|
docker compose -f infra/docker/docker-compose.yml config
|
||||||
|
```
|
||||||
|
|
||||||
|
- `pytest`:运行自动化测试。
|
||||||
|
- `scripts/run_server.py`:使用项目脚本启动服务。
|
||||||
|
- `docker build`:构建后端镜像。
|
||||||
|
- `docker compose config`:检查 compose 配置和变量展开。
|
||||||
|
|
||||||
|
## 开发规范
|
||||||
|
|
||||||
|
- Python 文件、函数、变量、Pydantic 字段、JSON body 字段和 query 参数使用 `snake_case`。
|
||||||
|
- Python 类和 Pydantic 模型使用 `PascalCase`。
|
||||||
|
- 新 HTTP 路径使用 `kebab-case`,例如 `/api/v1/pressure-status/analyze`。
|
||||||
|
- 优先复用现有 FastAPI/service/repository 边界。
|
||||||
|
- 不要把临时数据、数据库 dump、日志或本地运行产物纳入提交。
|
||||||
|
|
||||||
|
## 项目数据库路由
|
||||||
|
|
||||||
|
项目级 REST 请求通过 `X-Project-Id` 解析元数据中的数据库配置:
|
||||||
|
|
||||||
|
- `biz_data` DSN 用于管网业务数据。每个物理业务库使用同名 `_template` 数据库,通过逻辑订阅只同步 `network` schema;模拟临时库从该项目模板克隆。`WNDB_SCHEMA_TEMPLATE_DB_NAME`(当前为 `tjwater_v2_schema_template`)仅用于创建空业务库和 INP 导入暂存库。
|
||||||
|
- `iot_data` DSN 用于 TimescaleDB,始终使用元数据配置的完整 DSN,不再从项目代码推导数据库名。
|
||||||
|
- 元数据、业务库和 TimescaleDB 可以部署在同一主机,也可以分别部署。
|
||||||
|
|
||||||
|
完整新建供水项目使用 `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` 账号必须具备数据库创建和删除权限;只有显式删除项目时才会终止该项目的现有数据库会话,普通复制不会主动中断复制源会话。
|
||||||
|
|
||||||
|
## 测试与发布
|
||||||
|
|
||||||
|
提交前根据改动范围运行最小有效测试:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda run -n server python -m pytest tests/unit tests/auth -q
|
||||||
|
```
|
||||||
|
|
||||||
|
发布镜像前建议运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t tjwater-server:local .
|
||||||
|
```
|
||||||
|
|
||||||
|
Gitea 包工作流位于 `.gitea/workflows/package.yml`,通常由 tag 触发构建、推送镜像并通知部署 webhook。
|
||||||
|
|
||||||
|
## 安全规则
|
||||||
|
|
||||||
|
不要提交 `.env`、客户数据、数据库 dump、日志、生成缓存、`db_inp/`、`temp/`、`data/` 或本地密钥。CI/CD 凭据应放在 Gitea secrets 和仓库变量中。
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
当前 适配 szh 项目的分支 是 dingsu/szh
|
|
||||||
|
|
||||||
Binary 适配的是 代码 中dingsu/szh 的部分
|
|
||||||
当前只是把 API目录(也就是TJNetwork的部分)加密了
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Pure water-network calculation packages.
|
||||||
|
|
||||||
|
Application workflows belong in :mod:`app.services`; database and external
|
||||||
|
system access belongs in :mod:`app.infra` or :mod:`app.native`.
|
||||||
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
def fill_time_gaps(
|
||||||
|
data: pd.DataFrame,
|
||||||
|
time_col: str = "time",
|
||||||
|
freq: str = "1min",
|
||||||
|
short_gap_threshold: int = 10,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
补齐缺失时间戳并填补数据缺口。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: 包含时间列的 DataFrame
|
||||||
|
time_col: 时间列名(默认 'time')
|
||||||
|
freq: 重采样频率(默认 '1min')
|
||||||
|
short_gap_threshold: 短缺口阈值(分钟),<=此值用线性插值,>此值用前向填充
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
补齐时间后的 DataFrame(保留原时间列格式)
|
||||||
|
"""
|
||||||
|
if time_col not in data.columns:
|
||||||
|
raise ValueError(f"时间列 '{time_col}' 不存在于数据中")
|
||||||
|
|
||||||
|
# 解析时间列并设为索引
|
||||||
|
data = data.copy()
|
||||||
|
data[time_col] = pd.to_datetime(data[time_col], utc=True)
|
||||||
|
data_indexed = data.set_index(time_col)
|
||||||
|
|
||||||
|
# 生成完整时间范围
|
||||||
|
full_range = pd.date_range(
|
||||||
|
start=data_indexed.index.min(), end=data_indexed.index.max(), freq=freq
|
||||||
|
)
|
||||||
|
|
||||||
|
# 重索引以补齐缺失时间点,同时保留原始时间戳
|
||||||
|
combined_index = data_indexed.index.union(full_range).sort_values().unique()
|
||||||
|
data_reindexed = data_indexed.reindex(combined_index)
|
||||||
|
|
||||||
|
# 按列处理缺口
|
||||||
|
for col in data_reindexed.columns:
|
||||||
|
# 识别缺失值位置
|
||||||
|
is_missing = data_reindexed[col].isna()
|
||||||
|
|
||||||
|
# 计算连续缺失的长度
|
||||||
|
missing_groups = (is_missing != is_missing.shift()).cumsum()
|
||||||
|
gap_lengths = is_missing.groupby(missing_groups).transform("sum")
|
||||||
|
|
||||||
|
# 短缺口:时间插值
|
||||||
|
short_gap_mask = is_missing & (gap_lengths <= short_gap_threshold)
|
||||||
|
if short_gap_mask.any():
|
||||||
|
data_reindexed.loc[short_gap_mask, col] = (
|
||||||
|
data_reindexed[col]
|
||||||
|
.interpolate(method="time", limit_area="inside")
|
||||||
|
.loc[short_gap_mask]
|
||||||
|
)
|
||||||
|
|
||||||
|
# 长缺口:前向填充
|
||||||
|
long_gap_mask = is_missing & (gap_lengths > short_gap_threshold)
|
||||||
|
if long_gap_mask.any():
|
||||||
|
data_reindexed.loc[long_gap_mask, col] = (
|
||||||
|
data_reindexed[col].ffill().loc[long_gap_mask]
|
||||||
|
)
|
||||||
|
|
||||||
|
# 重置索引并恢复时间列(保留原格式)
|
||||||
|
data_result = data_reindexed.reset_index()
|
||||||
|
data_result.rename(columns={"index": time_col}, inplace=True)
|
||||||
|
|
||||||
|
# 保留时区信息
|
||||||
|
data_result[time_col] = data_result[time_col].dt.strftime("%Y-%m-%dT%H:%M:%S%z")
|
||||||
|
# 修正时区格式(Python的%z输出为+0000,需转为+00:00)
|
||||||
|
data_result[time_col] = data_result[time_col].str.replace(
|
||||||
|
r"(\+\d{2})(\d{2})$", r"\1:\2", regex=True
|
||||||
|
)
|
||||||
|
|
||||||
|
return data_result
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_temp_files(prefix: str) -> None:
|
||||||
|
"""清理 EPANET 仿真产生的临时文件。"""
|
||||||
|
for ext in [".inp", ".rpt", ".bin", ".out"]:
|
||||||
|
temp_file = prefix + ext
|
||||||
|
if os.path.exists(temp_file):
|
||||||
|
try:
|
||||||
|
os.remove(temp_file)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
import pandas as pd
|
|
||||||
import numpy as np
|
|
||||||
import matplotlib.pyplot as plt
|
|
||||||
from sklearn.cluster import KMeans
|
|
||||||
from sklearn.impute import SimpleImputer
|
|
||||||
import os
|
|
||||||
|
|
||||||
|
|
||||||
def clean_pressure_data_km(input_csv_path: str, show_plot: bool = False) -> str:
|
|
||||||
"""
|
|
||||||
读取输入 CSV,基于 KMeans 检测异常并用滚动平均修复。输出为 <input_basename>_cleaned.xlsx(同目录)。
|
|
||||||
原始数据在 sheet 'raw_pressure_data',处理后数据在 sheet 'cleaned_pressusre_data'。
|
|
||||||
返回输出文件的绝对路径。
|
|
||||||
"""
|
|
||||||
# 读取 CSV
|
|
||||||
input_csv_path = os.path.abspath(input_csv_path)
|
|
||||||
data = pd.read_csv(input_csv_path, header=0, index_col=None, encoding="utf-8")
|
|
||||||
# 标准化
|
|
||||||
data_norm = (data - data.mean()) / data.std()
|
|
||||||
|
|
||||||
# 聚类与异常检测
|
|
||||||
k = 3
|
|
||||||
kmeans = KMeans(n_clusters=k, init="k-means++", n_init=50, random_state=42)
|
|
||||||
clusters = kmeans.fit_predict(data_norm)
|
|
||||||
centers = kmeans.cluster_centers_
|
|
||||||
|
|
||||||
distances = np.linalg.norm(data_norm.values - centers[clusters], axis=1)
|
|
||||||
threshold = distances.mean() + 3 * distances.std()
|
|
||||||
|
|
||||||
anomaly_pos = np.where(distances > threshold)[0]
|
|
||||||
anomaly_indices = data.index[anomaly_pos]
|
|
||||||
|
|
||||||
anomaly_details = {}
|
|
||||||
for pos in anomaly_pos:
|
|
||||||
row_norm = data_norm.iloc[pos]
|
|
||||||
cluster_idx = clusters[pos]
|
|
||||||
center = centers[cluster_idx]
|
|
||||||
diff = abs(row_norm - center)
|
|
||||||
main_sensor = diff.idxmax()
|
|
||||||
anomaly_details[data.index[pos]] = main_sensor
|
|
||||||
|
|
||||||
# 修复:滚动平均(窗口可调)
|
|
||||||
data_rolled = data.rolling(window=13, center=True, min_periods=1).mean()
|
|
||||||
data_repaired = data.copy()
|
|
||||||
for pos in anomaly_pos:
|
|
||||||
label = data.index[pos]
|
|
||||||
sensor = anomaly_details[label]
|
|
||||||
data_repaired.loc[label, sensor] = data_rolled.loc[label, sensor]
|
|
||||||
|
|
||||||
# 可选可视化(使用位置作为 x 轴)
|
|
||||||
plt.rcParams["font.sans-serif"] = ["SimHei"]
|
|
||||||
plt.rcParams["axes.unicode_minus"] = False
|
|
||||||
|
|
||||||
if show_plot and len(data.columns) > 0:
|
|
||||||
n = len(data)
|
|
||||||
time = np.arange(n)
|
|
||||||
plt.figure(figsize=(12, 8))
|
|
||||||
for col in data.columns:
|
|
||||||
plt.plot(time, data[col].values, marker="o", markersize=3, label=col)
|
|
||||||
for pos in anomaly_pos:
|
|
||||||
sensor = anomaly_details[data.index[pos]]
|
|
||||||
plt.plot(pos, data.iloc[pos][sensor], "ro", markersize=8)
|
|
||||||
plt.xlabel("时间点(序号)")
|
|
||||||
plt.ylabel("压力监测值")
|
|
||||||
plt.title("各传感器折线图(红色标记主要异常点)")
|
|
||||||
plt.legend()
|
|
||||||
plt.show()
|
|
||||||
|
|
||||||
plt.figure(figsize=(12, 8))
|
|
||||||
for col in data_repaired.columns:
|
|
||||||
plt.plot(
|
|
||||||
time, data_repaired[col].values, marker="o", markersize=3, label=col
|
|
||||||
)
|
|
||||||
for pos in anomaly_pos:
|
|
||||||
sensor = anomaly_details[data.index[pos]]
|
|
||||||
plt.plot(pos, data_repaired.iloc[pos][sensor], "go", markersize=8)
|
|
||||||
plt.xlabel("时间点(序号)")
|
|
||||||
plt.ylabel("修复后压力监测值")
|
|
||||||
plt.title("修复后各传感器折线图(绿色标记修复值)")
|
|
||||||
plt.legend()
|
|
||||||
plt.show()
|
|
||||||
|
|
||||||
# 保存到 Excel:两个 sheet
|
|
||||||
input_dir = os.path.dirname(os.path.abspath(input_csv_path))
|
|
||||||
input_base = os.path.splitext(os.path.basename(input_csv_path))[0]
|
|
||||||
output_filename = f"{input_base}_cleaned.xlsx"
|
|
||||||
output_path = os.path.join(input_dir, output_filename)
|
|
||||||
|
|
||||||
if os.path.exists(output_path):
|
|
||||||
os.remove(output_path) # 覆盖同名文件
|
|
||||||
with pd.ExcelWriter(output_path, engine="openpyxl") as writer:
|
|
||||||
data.to_excel(writer, sheet_name="raw_pressure_data", index=False)
|
|
||||||
data_repaired.to_excel(writer, sheet_name="cleaned_pressusre_data", index=False)
|
|
||||||
|
|
||||||
# 返回输出文件的绝对路径
|
|
||||||
return os.path.abspath(output_path)
|
|
||||||
|
|
||||||
|
|
||||||
def clean_pressure_data_df_km(data: pd.DataFrame, show_plot: bool = False) -> dict:
|
|
||||||
"""
|
|
||||||
接收一个 DataFrame 数据结构,使用KMeans聚类检测异常并用滚动平均修复。
|
|
||||||
返回清洗后的字典数据结构。
|
|
||||||
"""
|
|
||||||
# 使用传入的 DataFrame
|
|
||||||
data = data.copy()
|
|
||||||
# 填充NaN值
|
|
||||||
data = data.ffill().bfill()
|
|
||||||
# 异常值预处理
|
|
||||||
# 将0值替换为NaN,然后用线性插值填充
|
|
||||||
data_filled = data.replace(0, np.nan)
|
|
||||||
data_filled = data_filled.interpolate(method="linear", limit_direction="both")
|
|
||||||
# 如果仍有NaN(全为0的列),用前后值填充
|
|
||||||
data_filled = data_filled.ffill().bfill()
|
|
||||||
|
|
||||||
# 标准化(使用填充后的数据)
|
|
||||||
data_norm = (data_filled - data_filled.mean()) / data_filled.std()
|
|
||||||
|
|
||||||
# 添加:处理标准化后的 NaN(例如,标准差为0的列),防止异常数据,时间段内所有数据都相同导致计算结果为 NaN
|
|
||||||
imputer = SimpleImputer(
|
|
||||||
strategy="constant", fill_value=0, keep_empty_features=True
|
|
||||||
) # 用 0 填充 NaN,包括全 NaN,并保留空特征
|
|
||||||
data_norm = pd.DataFrame(
|
|
||||||
imputer.fit_transform(data_norm),
|
|
||||||
columns=data_norm.columns,
|
|
||||||
index=data_norm.index,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 聚类与异常检测
|
|
||||||
k = 3
|
|
||||||
kmeans = KMeans(n_clusters=k, init="k-means++", n_init=50, random_state=42)
|
|
||||||
clusters = kmeans.fit_predict(data_norm)
|
|
||||||
centers = kmeans.cluster_centers_
|
|
||||||
|
|
||||||
distances = np.linalg.norm(data_norm.values - centers[clusters], axis=1)
|
|
||||||
threshold = distances.mean() + 3 * distances.std()
|
|
||||||
|
|
||||||
anomaly_pos = np.where(distances > threshold)[0]
|
|
||||||
anomaly_indices = data.index[anomaly_pos]
|
|
||||||
|
|
||||||
anomaly_details = {}
|
|
||||||
for pos in anomaly_pos:
|
|
||||||
row_norm = data_norm.iloc[pos]
|
|
||||||
cluster_idx = clusters[pos]
|
|
||||||
center = centers[cluster_idx]
|
|
||||||
diff = abs(row_norm - center)
|
|
||||||
main_sensor = diff.idxmax()
|
|
||||||
anomaly_details[data.index[pos]] = main_sensor
|
|
||||||
|
|
||||||
# 修复:滚动平均(窗口可调)
|
|
||||||
data_rolled = data_filled.rolling(window=13, center=True, min_periods=1).mean()
|
|
||||||
data_repaired = data_filled.copy()
|
|
||||||
for pos in anomaly_pos:
|
|
||||||
label = data.index[pos]
|
|
||||||
sensor = anomaly_details[label]
|
|
||||||
data_repaired.loc[label, sensor] = data_rolled.loc[label, sensor]
|
|
||||||
|
|
||||||
# 可选可视化(使用位置作为 x 轴)
|
|
||||||
plt.rcParams["font.sans-serif"] = ["SimHei"]
|
|
||||||
plt.rcParams["axes.unicode_minus"] = False
|
|
||||||
|
|
||||||
if show_plot and len(data.columns) > 0:
|
|
||||||
n = len(data)
|
|
||||||
time = np.arange(n)
|
|
||||||
plt.figure(figsize=(12, 8))
|
|
||||||
for col in data.columns:
|
|
||||||
plt.plot(
|
|
||||||
time, data[col].values, marker="o", markersize=3, label=col, alpha=0.5
|
|
||||||
)
|
|
||||||
for col in data_filled.columns:
|
|
||||||
plt.plot(
|
|
||||||
time,
|
|
||||||
data_filled[col].values,
|
|
||||||
marker="x",
|
|
||||||
markersize=3,
|
|
||||||
label=f"{col}_filled",
|
|
||||||
linestyle="--",
|
|
||||||
)
|
|
||||||
for pos in anomaly_pos:
|
|
||||||
sensor = anomaly_details[data.index[pos]]
|
|
||||||
plt.plot(pos, data_filled.iloc[pos][sensor], "ro", markersize=8)
|
|
||||||
plt.xlabel("时间点(序号)")
|
|
||||||
plt.ylabel("压力监测值")
|
|
||||||
plt.title("各传感器折线图(红色标记主要异常点,虚线为0值填充后)")
|
|
||||||
plt.legend()
|
|
||||||
plt.show()
|
|
||||||
|
|
||||||
plt.figure(figsize=(12, 8))
|
|
||||||
for col in data_repaired.columns:
|
|
||||||
plt.plot(
|
|
||||||
time, data_repaired[col].values, marker="o", markersize=3, label=col
|
|
||||||
)
|
|
||||||
for pos in anomaly_pos:
|
|
||||||
sensor = anomaly_details[data.index[pos]]
|
|
||||||
plt.plot(pos, data_repaired.iloc[pos][sensor], "go", markersize=8)
|
|
||||||
plt.xlabel("时间点(序号)")
|
|
||||||
plt.ylabel("修复后压力监测值")
|
|
||||||
plt.title("修复后各传感器折线图(绿色标记修复值)")
|
|
||||||
plt.legend()
|
|
||||||
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("测试完成:函数运行正常")
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from .Fdataclean import *
|
|
||||||
from .Pdataclean import *
|
|
||||||
from .pipeline_health_analyzer import *
|
|
||||||
@@ -1,109 +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)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from app.algorithms.burst_detection.pressure_anomaly import PressureAnomalyDetector
|
||||||
|
|
||||||
|
__all__ = ["PressureAnomalyDetector"]
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from scipy.fft import fft, ifft
|
||||||
|
from sklearn.ensemble import IsolationForest
|
||||||
|
|
||||||
|
PressureDataInput = (
|
||||||
|
pd.DataFrame
|
||||||
|
| dict[str, list[Any]]
|
||||||
|
| list[dict[str, Any]]
|
||||||
|
| list[list[Any]]
|
||||||
|
| np.ndarray
|
||||||
|
)
|
||||||
|
IGNORED_OBSERVATION_COLUMNS = {"time", "timestamp", "datetime", "date"}
|
||||||
|
|
||||||
|
|
||||||
|
class PressureAnomalyDetector:
|
||||||
|
"""FFT + IsolationForest based burst detection for daily aligned pressure data."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
mu: int = 100,
|
||||||
|
points_per_day: int = 1440,
|
||||||
|
iforest_params: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
if points_per_day <= 0:
|
||||||
|
raise ValueError("points_per_day 必须大于 0。")
|
||||||
|
if mu <= 0:
|
||||||
|
raise ValueError("mu 必须大于 0。")
|
||||||
|
|
||||||
|
self.mu = int(mu)
|
||||||
|
self.points_per_day = int(points_per_day)
|
||||||
|
self.iforest_params = {
|
||||||
|
"n_estimators": 50,
|
||||||
|
"random_state": 42,
|
||||||
|
"contamination": "auto",
|
||||||
|
}
|
||||||
|
if iforest_params:
|
||||||
|
self.iforest_params.update(iforest_params)
|
||||||
|
|
||||||
|
self.data: np.ndarray | None = None
|
||||||
|
self.sensor_names: list[str] = []
|
||||||
|
self.high_freq_features: np.ndarray | None = None
|
||||||
|
|
||||||
|
def load_data(
|
||||||
|
self,
|
||||||
|
data_source: PressureDataInput,
|
||||||
|
*,
|
||||||
|
sensor_nodes: list[str] | None = None,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
标准化输入观测数据为 DataFrame。
|
||||||
|
|
||||||
|
支持的 `data_source` 格式:
|
||||||
|
- `pd.DataFrame`
|
||||||
|
每一列代表一个传感器,每一行代表一个时间点。
|
||||||
|
- `dict[str, list[Any]]`
|
||||||
|
键为传感器 ID,值为该传感器按时间顺序排列的压力序列。
|
||||||
|
例如:`{"J1": [101.2, 101.0], "J2": [99.8, 99.7]}`。
|
||||||
|
- `list[dict[str, Any]]`
|
||||||
|
每个字典代表一个时间点,键为传感器 ID,值为该时刻压力。
|
||||||
|
例如:`[{"J1": 101.2, "J2": 99.8}, {"J1": 101.0, "J2": 99.7}]`。
|
||||||
|
- `list[list[Any]]`
|
||||||
|
二维列表,格式为 `(时间点数, 传感器数)`。
|
||||||
|
例如:`[[101.2, 99.8], [101.0, 99.7]]`。
|
||||||
|
- `np.ndarray`
|
||||||
|
二维数组,形状必须为 `(时间点数, 传感器数)`。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
- `sensor_nodes`:
|
||||||
|
可选的传感器列筛选列表。传入后,数据中必须包含这些列名。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
- 标准化后的 `pd.DataFrame`,列为传感器,行为时间点。
|
||||||
|
"""
|
||||||
|
if isinstance(data_source, np.ndarray):
|
||||||
|
observation_df = pd.DataFrame(data_source)
|
||||||
|
elif isinstance(data_source, pd.DataFrame):
|
||||||
|
observation_df = data_source.copy()
|
||||||
|
else:
|
||||||
|
observation_df = pd.DataFrame(data_source)
|
||||||
|
|
||||||
|
return self._normalize_observation_frame(
|
||||||
|
observation_df=observation_df, sensor_nodes=sensor_nodes
|
||||||
|
)
|
||||||
|
|
||||||
|
def process(
|
||||||
|
self,
|
||||||
|
observed_pressure_data: PressureDataInput,
|
||||||
|
*,
|
||||||
|
sensor_nodes: list[str] | None = None,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
对输入压力序列按天切片,并提取每天末时刻的高频特征。
|
||||||
|
|
||||||
|
`observed_pressure_data` 的格式与 `load_data()` 一致,统一要求:
|
||||||
|
- 数据必须表示为“行=时间点、列=传感器”。
|
||||||
|
- 总行数必须是 `points_per_day` 的整数倍。
|
||||||
|
- 至少需要 2 天数据,即总行数 `>= 2 * points_per_day`。
|
||||||
|
|
||||||
|
例如:
|
||||||
|
- 当 `points_per_day=1440` 时,15 天数据的形状通常为 `(21600, 传感器数)`。
|
||||||
|
- 若传入 `sensor_nodes=["J1", "J2"]`,则输入中必须存在 `J1/J2` 两列。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
- `np.ndarray`,形状为 `(天数, 传感器数)`,
|
||||||
|
每个值表示对应传感器在当天末时刻提取出的高频分量。
|
||||||
|
"""
|
||||||
|
observation_df = self.load_data(
|
||||||
|
observed_pressure_data,
|
||||||
|
sensor_nodes=sensor_nodes,
|
||||||
|
)
|
||||||
|
matrix = observation_df.to_numpy(dtype=float)
|
||||||
|
total_points, sensor_count = matrix.shape
|
||||||
|
if sensor_count == 0:
|
||||||
|
raise ValueError("压力观测数据中未找到可用传感器列。")
|
||||||
|
if total_points < self.points_per_day * 2:
|
||||||
|
raise ValueError("至少需要 2 天的观测数据才能执行爆管侦测。")
|
||||||
|
if total_points % self.points_per_day != 0:
|
||||||
|
raise ValueError("观测数据长度必须能被每日采样点数整除,以便按天切分。")
|
||||||
|
|
||||||
|
day_count = total_points // self.points_per_day
|
||||||
|
high_freq_features = np.zeros((day_count, sensor_count), dtype=float)
|
||||||
|
|
||||||
|
for sensor_idx in range(sensor_count):
|
||||||
|
sensor_series = matrix[:, sensor_idx]
|
||||||
|
for day_idx in range(day_count):
|
||||||
|
start = day_idx * self.points_per_day
|
||||||
|
end = (day_idx + 1) * self.points_per_day
|
||||||
|
day_data = sensor_series[start:end]
|
||||||
|
mirrored_data = np.concatenate([day_data, day_data[::-1]])
|
||||||
|
transformed = fft(mirrored_data)
|
||||||
|
transformed[self.mu : len(mirrored_data) - self.mu + 1] = 0
|
||||||
|
low_freq = ifft(transformed).real
|
||||||
|
high_freq = day_data - low_freq[: self.points_per_day]
|
||||||
|
high_freq_features[day_idx, sensor_idx] = float(high_freq[-1])
|
||||||
|
|
||||||
|
self.data = matrix
|
||||||
|
self.sensor_names = [str(column) for column in observation_df.columns]
|
||||||
|
self.high_freq_features = high_freq_features
|
||||||
|
return high_freq_features
|
||||||
|
|
||||||
|
def detect(self) -> pd.DataFrame:
|
||||||
|
if self.high_freq_features is None:
|
||||||
|
raise ValueError("特征未提取。请先调用 process()。")
|
||||||
|
|
||||||
|
day_count = self.high_freq_features.shape[0]
|
||||||
|
if day_count < 2:
|
||||||
|
raise ValueError("孤立森林至少需要 2 天特征数据。")
|
||||||
|
|
||||||
|
clf = IsolationForest(
|
||||||
|
n_estimators=self.iforest_params.get("n_estimators", 50),
|
||||||
|
max_samples=day_count,
|
||||||
|
random_state=self.iforest_params.get("random_state", 42),
|
||||||
|
contamination=self.iforest_params.get("contamination", "auto"),
|
||||||
|
**{
|
||||||
|
key: value
|
||||||
|
for key, value in self.iforest_params.items()
|
||||||
|
if key not in {"n_estimators", "random_state", "contamination"}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
clf.fit(self.high_freq_features)
|
||||||
|
|
||||||
|
scores = clf.decision_function(self.high_freq_features)
|
||||||
|
predictions = clf.predict(self.high_freq_features)
|
||||||
|
result_df = pd.DataFrame(
|
||||||
|
{
|
||||||
|
"Day": range(1, day_count + 1),
|
||||||
|
"Score": scores.astype(float),
|
||||||
|
"Prediction": predictions.astype(int),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result_df["IsBurst"] = result_df["Prediction"].eq(-1)
|
||||||
|
result_df.attrs["sensor_nodes"] = self.sensor_names.copy()
|
||||||
|
result_df.attrs["high_freq_features"] = self.high_freq_features.copy()
|
||||||
|
result_df.attrs["day_count"] = day_count
|
||||||
|
result_df.attrs["points_per_day"] = self.points_per_day
|
||||||
|
result_df.attrs["sample_count"] = (
|
||||||
|
int(self.data.shape[0]) if self.data is not None else 0
|
||||||
|
)
|
||||||
|
return result_df
|
||||||
|
|
||||||
|
def run_detection(
|
||||||
|
self,
|
||||||
|
observed_pressure_data: PressureDataInput,
|
||||||
|
*,
|
||||||
|
sensor_nodes: list[str] | None = None,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
执行完整爆管侦测流程。
|
||||||
|
|
||||||
|
输入格式与 `process()` 相同:
|
||||||
|
- `DataFrame` / `dict[str, list[Any]]` / `list[dict[str, Any]]` / `list[list[Any]]` / `np.ndarray`
|
||||||
|
- 行表示时间点,列表示传感器
|
||||||
|
- 总行数必须能被 `points_per_day` 整除
|
||||||
|
|
||||||
|
返回结果包含列:
|
||||||
|
- `Day`: 第几天(从 1 开始)
|
||||||
|
- `Score`: IsolationForest 异常分数,越小越异常
|
||||||
|
- `Prediction`: `-1` 表示异常,`1` 表示正常
|
||||||
|
- `IsBurst`: 是否判定为异常日
|
||||||
|
"""
|
||||||
|
self.process(observed_pressure_data, sensor_nodes=sensor_nodes)
|
||||||
|
return self.detect()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_observation_frame(
|
||||||
|
*,
|
||||||
|
observation_df: pd.DataFrame,
|
||||||
|
sensor_nodes: list[str] | None,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
if observation_df.empty:
|
||||||
|
raise ValueError("压力观测数据为空。")
|
||||||
|
|
||||||
|
normalized_df = observation_df.copy()
|
||||||
|
normalized_df.columns = [str(column) for column in normalized_df.columns]
|
||||||
|
normalized_df = normalized_df.drop(
|
||||||
|
columns=[
|
||||||
|
column
|
||||||
|
for column in normalized_df.columns
|
||||||
|
if column.lower() in IGNORED_OBSERVATION_COLUMNS
|
||||||
|
or column.lower().startswith("unnamed:")
|
||||||
|
],
|
||||||
|
errors="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
if sensor_nodes:
|
||||||
|
selected_columns = [str(node) for node in sensor_nodes]
|
||||||
|
missing_columns = [
|
||||||
|
column
|
||||||
|
for column in selected_columns
|
||||||
|
if column not in normalized_df.columns
|
||||||
|
]
|
||||||
|
if missing_columns:
|
||||||
|
preview = ", ".join(missing_columns[:10])
|
||||||
|
raise ValueError(f"观测数据缺少传感器列: {preview}")
|
||||||
|
normalized_df = normalized_df.loc[:, selected_columns]
|
||||||
|
else:
|
||||||
|
candidate_df = normalized_df.apply(pd.to_numeric, errors="coerce")
|
||||||
|
normalized_df = candidate_df.loc[:, candidate_df.notna().any(axis=0)]
|
||||||
|
|
||||||
|
if normalized_df.empty:
|
||||||
|
raise ValueError("未识别到可用的数值型压力观测列。")
|
||||||
|
|
||||||
|
normalized_df = normalized_df.apply(pd.to_numeric, errors="coerce")
|
||||||
|
invalid_columns = [
|
||||||
|
column
|
||||||
|
for column in normalized_df.columns
|
||||||
|
if normalized_df[column].isna().any()
|
||||||
|
]
|
||||||
|
if invalid_columns:
|
||||||
|
preview = ", ".join(invalid_columns[:10])
|
||||||
|
raise ValueError(f"压力观测数据包含非数值或缺失值: {preview}")
|
||||||
|
|
||||||
|
return normalized_df.reset_index(drop=True)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .pipeline import run_burst_location
|
||||||
|
|
||||||
|
__all__ = ["run_burst_location"]
|
||||||
@@ -0,0 +1,771 @@
|
|||||||
|
"""爆管定位主模块。"""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from time import perf_counter
|
||||||
|
|
||||||
|
import networkx as nx
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
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_metrics import (
|
||||||
|
adjust_ratio,
|
||||||
|
cal_similarity_all_multi_new_sq_improve_double_lzr,
|
||||||
|
decode_mode,
|
||||||
|
extra_judge,
|
||||||
|
update_similarity,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_signatures_for_centers(
|
||||||
|
wn,
|
||||||
|
wn_inp_path,
|
||||||
|
center_list, # 本轮要用到的中心(list[str])
|
||||||
|
pressure_leak_all,
|
||||||
|
flow_leak_all, # 全量缓存(可为空 DF)
|
||||||
|
timestep_list, # 你现有的时序列表
|
||||||
|
pressure_monitor,
|
||||||
|
flow_monitor, # 用来推断传感器列名
|
||||||
|
leak_mag,
|
||||||
|
n_workers=1,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
只为缺失的中心补算 SLF(调用你现有的 cal_signature_pipe_multi_pf),
|
||||||
|
并把补算结果并回缓存。返回:
|
||||||
|
pressure_leak_subset, flow_leak_subset, pressure_leak_all_new, flow_leak_all_new
|
||||||
|
其中 subset 只包含 center_list 的行(顺序与 center_list 保持一致)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
center_list = _dedupe_preserve_order(center_list)
|
||||||
|
|
||||||
|
# 1) 推断传感器列名(与现有数据保持一致)
|
||||||
|
sensor_name_all = list(pressure_monitor.columns)
|
||||||
|
sensor_f_name_all = (
|
||||||
|
list(flow_monitor.columns)
|
||||||
|
if (flow_monitor is not None and hasattr(flow_monitor, "columns"))
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2) 取出缓存里已经有的中心(考虑 MultiIndex 的第 0 层为 pipe)
|
||||||
|
def _existing_pipes(df):
|
||||||
|
if df is None or len(df) == 0:
|
||||||
|
return set()
|
||||||
|
idx = df.index
|
||||||
|
if isinstance(idx, pd.MultiIndex):
|
||||||
|
return set(idx.get_level_values(0))
|
||||||
|
else:
|
||||||
|
return set(idx)
|
||||||
|
|
||||||
|
exist_p = _existing_pipes(pressure_leak_all)
|
||||||
|
need = [p for p in center_list if p not in exist_p]
|
||||||
|
|
||||||
|
# 3) 若有缺失中心,仅为这些中心补算一次
|
||||||
|
if len(need) > 0:
|
||||||
|
p_new, _ = cal_signature_pipe_multi_pf(
|
||||||
|
wn,
|
||||||
|
leak_mag,
|
||||||
|
need,
|
||||||
|
timestep_list,
|
||||||
|
sensor_name_all,
|
||||||
|
n_workers=n_workers,
|
||||||
|
wn_inp_path=wn_inp_path,
|
||||||
|
)
|
||||||
|
# 初始化空缓存时,做一次“同构化”
|
||||||
|
if pressure_leak_all is None or len(pressure_leak_all) == 0:
|
||||||
|
pressure_leak_all = p_new
|
||||||
|
else:
|
||||||
|
pressure_leak_all = pd.concat([pressure_leak_all, p_new], axis=0)
|
||||||
|
# if (flow_leak_all is None or len(flow_leak_all) == 0) and f_new is not None:
|
||||||
|
# flow_leak_all = f_new
|
||||||
|
# elif f_new is not None:
|
||||||
|
# flow_leak_all = pd.concat([flow_leak_all, f_new], axis=0)
|
||||||
|
|
||||||
|
# 去重(如果既有缓存里不小心有重复中心)
|
||||||
|
if isinstance(pressure_leak_all.index, pd.MultiIndex):
|
||||||
|
pressure_leak_all = pressure_leak_all[
|
||||||
|
~pressure_leak_all.index.duplicated(keep="last")
|
||||||
|
]
|
||||||
|
if flow_leak_all is not None and len(flow_leak_all) > 0:
|
||||||
|
flow_leak_all = flow_leak_all[
|
||||||
|
~flow_leak_all.index.duplicated(keep="last")
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
pressure_leak_all = pressure_leak_all[
|
||||||
|
~pressure_leak_all.index.duplicated(keep="last")
|
||||||
|
]
|
||||||
|
if flow_leak_all is not None and len(flow_leak_all) > 0:
|
||||||
|
flow_leak_all = flow_leak_all[
|
||||||
|
~flow_leak_all.index.duplicated(keep="last")
|
||||||
|
]
|
||||||
|
|
||||||
|
# 4) 从更新后的缓存里,取出这轮需要的中心子集(顺序与 center_list 一致)
|
||||||
|
if isinstance(pressure_leak_all.index, pd.MultiIndex):
|
||||||
|
pressure_subset = pressure_leak_all.loc[center_list]
|
||||||
|
flow_subset = (
|
||||||
|
flow_leak_all.loc[center_list]
|
||||||
|
if (flow_leak_all is not None and len(flow_leak_all) > 0)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
pressure_subset = pressure_leak_all.loc[center_list, :]
|
||||||
|
flow_subset = (
|
||||||
|
flow_leak_all.loc[center_list, :]
|
||||||
|
if (flow_leak_all is not None and len(flow_leak_all) > 0)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return pressure_subset, flow_subset, pressure_leak_all, flow_leak_all
|
||||||
|
|
||||||
|
|
||||||
|
def area_output_num_ki_improve(
|
||||||
|
candidate_center,
|
||||||
|
candidate_group,
|
||||||
|
similarity,
|
||||||
|
new_all_node,
|
||||||
|
top_group_ratio,
|
||||||
|
top_pipe_num_max,
|
||||||
|
top_pipe_num_min,
|
||||||
|
cut_ratio,
|
||||||
|
):
|
||||||
|
final_area = []
|
||||||
|
final_center = []
|
||||||
|
all_node_iter = []
|
||||||
|
if similarity.index.is_unique == False:
|
||||||
|
total_center_num = len(set(similarity.index))
|
||||||
|
else:
|
||||||
|
total_center_num = len(similarity.index)
|
||||||
|
|
||||||
|
next_group_num = min(
|
||||||
|
total_center_num, math.ceil(total_center_num / cut_ratio * top_group_ratio)
|
||||||
|
)
|
||||||
|
|
||||||
|
for i in range(next_group_num):
|
||||||
|
top_center = similarity.index[i]
|
||||||
|
top_center_index = find_list_repeat(candidate_center, top_center)
|
||||||
|
for j in range(len(top_center_index)):
|
||||||
|
final_area = final_area + candidate_group[top_center_index[j]]
|
||||||
|
all_node_iter = all_node_iter + list(new_all_node[top_center_index[j]])
|
||||||
|
final_center.append(top_center)
|
||||||
|
final_area = sorted(set(final_area))
|
||||||
|
|
||||||
|
if len(final_area) > top_pipe_num_max:
|
||||||
|
if_end = 0
|
||||||
|
elif len(final_area) > top_pipe_num_min:
|
||||||
|
if_end = 1
|
||||||
|
elif total_center_num == next_group_num:
|
||||||
|
if_end = 1
|
||||||
|
else:
|
||||||
|
if_end = 1
|
||||||
|
for i in np.arange(next_group_num, total_center_num, 1):
|
||||||
|
|
||||||
|
before_list = copy.deepcopy(final_area)
|
||||||
|
top_center = similarity.index[i]
|
||||||
|
top_center_index = candidate_center.index(top_center)
|
||||||
|
temp_group = final_area + candidate_group[top_center_index]
|
||||||
|
temp_area = sorted(set(temp_group))
|
||||||
|
|
||||||
|
if len(temp_area) < top_pipe_num_min:
|
||||||
|
|
||||||
|
final_center.append(top_center)
|
||||||
|
all_node_iter = all_node_iter + list(new_all_node[top_center_index])
|
||||||
|
final_area = temp_area
|
||||||
|
|
||||||
|
elif len(temp_area) < top_pipe_num_max:
|
||||||
|
final_center.append(top_center)
|
||||||
|
all_node_iter = all_node_iter + list(new_all_node[top_center_index])
|
||||||
|
final_area = temp_area
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
a = len(temp_area) - top_pipe_num_max
|
||||||
|
b = top_pipe_num_min - len(before_list)
|
||||||
|
|
||||||
|
if a >= b:
|
||||||
|
final_area = before_list
|
||||||
|
else:
|
||||||
|
final_center.append(top_center)
|
||||||
|
all_node_iter = all_node_iter + list(new_all_node[top_center_index])
|
||||||
|
final_area = temp_area
|
||||||
|
break
|
||||||
|
|
||||||
|
final_center = sorted(set(final_center))
|
||||||
|
all_node_iter = sorted(set(all_node_iter))
|
||||||
|
return final_area, final_center, all_node_iter, if_end
|
||||||
|
|
||||||
|
|
||||||
|
def find_list_repeat(candidate_center, target):
|
||||||
|
repeated_list = []
|
||||||
|
for index, nums in enumerate(candidate_center):
|
||||||
|
if nums == target:
|
||||||
|
repeated_list.append(index)
|
||||||
|
return repeated_list
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_preserve_order(items):
|
||||||
|
seen = set()
|
||||||
|
output = []
|
||||||
|
for item in items:
|
||||||
|
if item in seen:
|
||||||
|
continue
|
||||||
|
seen.add(item)
|
||||||
|
output.append(item)
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def _accumulate_stage(stage_timing, stage_name, started_at):
|
||||||
|
stage_timing[stage_name] = stage_timing.get(stage_name, 0.0) + (
|
||||||
|
perf_counter() - started_at
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_last_round_candidates_csv(
|
||||||
|
csv_path,
|
||||||
|
exit_condition,
|
||||||
|
iteration_count,
|
||||||
|
similarity_mode,
|
||||||
|
candidate_details,
|
||||||
|
fallback_similarity,
|
||||||
|
):
|
||||||
|
if not csv_path:
|
||||||
|
return None
|
||||||
|
timestamp_suffix = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
base_path, ext = os.path.splitext(csv_path)
|
||||||
|
ext = ext or ".csv"
|
||||||
|
output_path = f"{base_path}_{timestamp_suffix}{ext}"
|
||||||
|
if candidate_details is not None and len(candidate_details) > 0:
|
||||||
|
export_df = candidate_details.copy()
|
||||||
|
if export_df.index.name == "pipe_id":
|
||||||
|
export_df = export_df.reset_index()
|
||||||
|
else:
|
||||||
|
export_df = pd.DataFrame(
|
||||||
|
{
|
||||||
|
"pipe_id": [str(pipe_id) for pipe_id in fallback_similarity.index],
|
||||||
|
"final_similarity": [float(value) for value in fallback_similarity.values],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
export_df["exit_condition"] = exit_condition
|
||||||
|
export_df["iterations"] = int(iteration_count)
|
||||||
|
export_df["similarity_mode"] = similarity_mode
|
||||||
|
parent_dir = os.path.dirname(output_path)
|
||||||
|
if parent_dir:
|
||||||
|
os.makedirs(parent_dir, exist_ok=True)
|
||||||
|
export_df.to_csv(output_path, index=False, encoding="utf-8-sig")
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def cal_DtoTop1(
|
||||||
|
G0, pipe_leak, located_pipe, pipe_start_node_all, pipe_end_node_all, pipe_length
|
||||||
|
):
|
||||||
|
if pipe_leak == located_pipe:
|
||||||
|
result_DtoTop1 = 0
|
||||||
|
result_DtoTop1_num = 0
|
||||||
|
else:
|
||||||
|
pipe_leak_start_node = pipe_start_node_all[pipe_leak]
|
||||||
|
pipe_leak_end_node = pipe_end_node_all[pipe_leak]
|
||||||
|
located_pipe_start_node = pipe_start_node_all[located_pipe]
|
||||||
|
located_pipe_end_node = pipe_end_node_all[located_pipe]
|
||||||
|
DtoTop1_series = pd.Series(dtype=object)
|
||||||
|
DtoTop1_num_series = pd.Series(dtype=object)
|
||||||
|
DtoTop1_series["ss"] = nx.shortest_path_length(
|
||||||
|
G0, pipe_leak_start_node, located_pipe_start_node, weight="weight"
|
||||||
|
)
|
||||||
|
DtoTop1_series["se"] = nx.shortest_path_length(
|
||||||
|
G0, pipe_leak_start_node, located_pipe_end_node, weight="weight"
|
||||||
|
)
|
||||||
|
DtoTop1_series["es"] = nx.shortest_path_length(
|
||||||
|
G0, pipe_leak_end_node, located_pipe_start_node, weight="weight"
|
||||||
|
)
|
||||||
|
DtoTop1_series["ee"] = nx.shortest_path_length(
|
||||||
|
G0, pipe_leak_end_node, located_pipe_end_node, weight="weight"
|
||||||
|
)
|
||||||
|
DtoTop1_num_series["ss"] = nx.shortest_path_length(
|
||||||
|
G0, pipe_leak_start_node, located_pipe_start_node
|
||||||
|
)
|
||||||
|
DtoTop1_num_series["se"] = nx.shortest_path_length(
|
||||||
|
G0, pipe_leak_start_node, located_pipe_end_node
|
||||||
|
)
|
||||||
|
DtoTop1_num_series["es"] = nx.shortest_path_length(
|
||||||
|
G0, pipe_leak_end_node, located_pipe_start_node
|
||||||
|
)
|
||||||
|
DtoTop1_num_series["ee"] = nx.shortest_path_length(
|
||||||
|
G0, pipe_leak_end_node, located_pipe_end_node
|
||||||
|
)
|
||||||
|
|
||||||
|
if DtoTop1_num_series.min() == 0:
|
||||||
|
result_DtoTop1_num = 1
|
||||||
|
result_DtoTop1 = DtoTop1_series.max() / 2
|
||||||
|
else:
|
||||||
|
result_DtoTop1_num = DtoTop1_num_series.min() + 1
|
||||||
|
DtoTop1_type = DtoTop1_series.argmin()
|
||||||
|
result_DtoTop1 = (
|
||||||
|
DtoTop1_series[DtoTop1_type]
|
||||||
|
+ (pipe_length[pipe_leak] + pipe_length[located_pipe]) / 2
|
||||||
|
)
|
||||||
|
return result_DtoTop1, result_DtoTop1_num
|
||||||
|
|
||||||
|
|
||||||
|
def cal_RR(located_pipe, similarity_sp):
|
||||||
|
if located_pipe in similarity_sp.index:
|
||||||
|
rank = similarity_sp.index.get_loc(located_pipe)
|
||||||
|
RR = rank / len(similarity_sp.index)
|
||||||
|
else:
|
||||||
|
RR = 1.1
|
||||||
|
return RR
|
||||||
|
|
||||||
|
|
||||||
|
def cal_cover(similarity, leak_pipe):
|
||||||
|
if leak_pipe in list(similarity.index):
|
||||||
|
cover = 1
|
||||||
|
else:
|
||||||
|
cover = 0
|
||||||
|
return cover
|
||||||
|
|
||||||
|
|
||||||
|
def cal_SD(located_pipe, real_pipe, pipe_x, pipe_y):
|
||||||
|
dx = pipe_x[located_pipe] - pipe_x[real_pipe]
|
||||||
|
dy = pipe_y[located_pipe] - pipe_y[real_pipe]
|
||||||
|
SD = math.sqrt(dx * dx + dy * dy)
|
||||||
|
return SD
|
||||||
|
|
||||||
|
|
||||||
|
def DN_search_multi_simple_add_flow_count_new(
|
||||||
|
wn,
|
||||||
|
wn_inp_path,
|
||||||
|
G0,
|
||||||
|
all_node,
|
||||||
|
node_x,
|
||||||
|
node_y,
|
||||||
|
pipe_start_node_all,
|
||||||
|
pipe_end_node_all,
|
||||||
|
pipe_diameter,
|
||||||
|
couple_node_length,
|
||||||
|
node_pipe_dic,
|
||||||
|
all_node_series,
|
||||||
|
top_group_ratio,
|
||||||
|
top_pipe_num_max,
|
||||||
|
top_pipe_num_min,
|
||||||
|
candidate_pipe_input_initial,
|
||||||
|
similarity_mode,
|
||||||
|
pressure_monitor,
|
||||||
|
pressure_predict,
|
||||||
|
pressure_normal,
|
||||||
|
pressure_leak_all,
|
||||||
|
flow_monitor,
|
||||||
|
flow_predict,
|
||||||
|
flow_normal,
|
||||||
|
flow_leak_all,
|
||||||
|
timestep_list,
|
||||||
|
max_flow,
|
||||||
|
group_basic_num,
|
||||||
|
Top_sensor_num,
|
||||||
|
if_gy,
|
||||||
|
pressure_threshold,
|
||||||
|
leak_mag,
|
||||||
|
n_workers=1,
|
||||||
|
stage_timing=None,
|
||||||
|
partition_on_full_graph=True,
|
||||||
|
visualize_partition=False,
|
||||||
|
visualize_pause_seconds=0.3,
|
||||||
|
final_candidates_csv_path=None,
|
||||||
|
):
|
||||||
|
if stage_timing is None:
|
||||||
|
stage_timing = {}
|
||||||
|
exit_condition = "unknown"
|
||||||
|
final_candidates_csv = None
|
||||||
|
iter_count = 0
|
||||||
|
all_node_iter = copy.deepcopy(all_node)
|
||||||
|
candidate_pipe_input = copy.deepcopy(candidate_pipe_input_initial) # 可能漏损管段
|
||||||
|
t1 = datetime.now()
|
||||||
|
if_flow, if_only_cos, if_only_flow = decode_mode(similarity_mode) # 定位方法
|
||||||
|
# threshold
|
||||||
|
if if_only_flow == 1:
|
||||||
|
dpressure = (flow_predict - flow_monitor).mean()
|
||||||
|
dpressure = dpressure.abs()
|
||||||
|
effective_sensor = list(dpressure.index)
|
||||||
|
|
||||||
|
else:
|
||||||
|
dpressure = (pressure_predict - pressure_monitor).mean()
|
||||||
|
dpressure = dpressure.abs()
|
||||||
|
dpressure = dpressure[dpressure > pressure_threshold]
|
||||||
|
effective_sensor = list(dpressure.index)
|
||||||
|
simulation_times = 0 # 模拟次数
|
||||||
|
if len(dpressure) > 0:
|
||||||
|
break_flag = 0
|
||||||
|
last_round_candidate_details = None
|
||||||
|
|
||||||
|
cos_h = 0
|
||||||
|
dis_h = 0
|
||||||
|
dis_f_h = 0
|
||||||
|
if_compalsive = 0
|
||||||
|
record_center_dataset = []
|
||||||
|
record_center_set = set()
|
||||||
|
# iter
|
||||||
|
while 1:
|
||||||
|
final_area = []
|
||||||
|
final_center = []
|
||||||
|
group_num = cal_group_num(candidate_pipe_input, group_basic_num)
|
||||||
|
partition_nodes = all_node if partition_on_full_graph else all_node_iter
|
||||||
|
|
||||||
|
# group 分组,得出候选漏损中心
|
||||||
|
stage_start = perf_counter()
|
||||||
|
(
|
||||||
|
candidate_center_list,
|
||||||
|
candidate_group_list,
|
||||||
|
new_all_node,
|
||||||
|
candidate_center_candidates,
|
||||||
|
) = (
|
||||||
|
metis_grouping_pipe_weight(
|
||||||
|
G0,
|
||||||
|
wn,
|
||||||
|
partition_nodes,
|
||||||
|
candidate_pipe_input,
|
||||||
|
group_num,
|
||||||
|
node_x,
|
||||||
|
node_y,
|
||||||
|
pipe_start_node_all,
|
||||||
|
pipe_end_node_all,
|
||||||
|
node_pipe_dic,
|
||||||
|
all_node_series,
|
||||||
|
couple_node_length,
|
||||||
|
pipe_diameter,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_accumulate_stage(stage_timing, "group_partitioning", stage_start)
|
||||||
|
if visualize_partition:
|
||||||
|
visualize_metis_partition(
|
||||||
|
G0,
|
||||||
|
candidate_center_list,
|
||||||
|
candidate_group_list,
|
||||||
|
node_x,
|
||||||
|
node_y,
|
||||||
|
pipe_start_node_all,
|
||||||
|
pipe_end_node_all,
|
||||||
|
title=(
|
||||||
|
f"METIS Partition Iteration {iter_count + 1} | "
|
||||||
|
f"candidate pipes={len(candidate_pipe_input)} "
|
||||||
|
f"groups={len(candidate_group_list)}"
|
||||||
|
),
|
||||||
|
block=False,
|
||||||
|
pause_seconds=visualize_pause_seconds,
|
||||||
|
)
|
||||||
|
simulation_times = simulation_times + len(candidate_center_list)
|
||||||
|
# pick_pressure_leak
|
||||||
|
# pressure_leak = pressure_leak_all.loc[candidate_center_list].loc[:, :]
|
||||||
|
# flow_leak = flow_leak_all.loc[candidate_center_list].loc[:, :]
|
||||||
|
# —— 新增泄漏量(保持你现在的一致,或从外部传入)——
|
||||||
|
# —— 只为缺失中心补算,然后取本轮需要的中心子集 ——
|
||||||
|
stage_start = perf_counter()
|
||||||
|
pressure_leak, flow_leak, pressure_leak_all, flow_leak_all = (
|
||||||
|
_ensure_signatures_for_centers(
|
||||||
|
wn=wn,
|
||||||
|
wn_inp_path=wn_inp_path,
|
||||||
|
center_list=candidate_center_list,
|
||||||
|
pressure_leak_all=pressure_leak_all,
|
||||||
|
flow_leak_all=flow_leak_all,
|
||||||
|
timestep_list=timestep_list,
|
||||||
|
pressure_monitor=pressure_monitor,
|
||||||
|
flow_monitor=flow_monitor,
|
||||||
|
leak_mag=leak_mag,
|
||||||
|
n_workers=n_workers,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_accumulate_stage(stage_timing, "signature_for_candidates", stage_start)
|
||||||
|
|
||||||
|
# pressure_leak_f= pressure_leak.swaplevel()
|
||||||
|
|
||||||
|
# --------------------------------------------------------
|
||||||
|
|
||||||
|
add_center = []
|
||||||
|
leak_center_dict = dict()
|
||||||
|
for i in range(len(candidate_center_list)):
|
||||||
|
primary_center = candidate_center_list[i]
|
||||||
|
houxuan_center = [
|
||||||
|
center
|
||||||
|
for center in candidate_center_candidates[i]
|
||||||
|
if center != primary_center
|
||||||
|
]
|
||||||
|
candidate_group_set = set(candidate_group_list[i])
|
||||||
|
for each_center in record_center_dataset:
|
||||||
|
if (
|
||||||
|
each_center in candidate_group_set
|
||||||
|
and each_center != primary_center
|
||||||
|
):
|
||||||
|
houxuan_center.append(each_center)
|
||||||
|
add_center = add_center + houxuan_center
|
||||||
|
leak_center_dict[primary_center] = _dedupe_preserve_order(
|
||||||
|
houxuan_center + [primary_center]
|
||||||
|
)
|
||||||
|
add_center = _dedupe_preserve_order(add_center)
|
||||||
|
for each_group_centers in candidate_center_candidates:
|
||||||
|
for each_center in each_group_centers:
|
||||||
|
if each_center not in record_center_set:
|
||||||
|
record_center_dataset.append(each_center)
|
||||||
|
record_center_set.add(each_center)
|
||||||
|
for each_center in add_center:
|
||||||
|
if each_center not in record_center_set:
|
||||||
|
record_center_dataset.append(each_center)
|
||||||
|
record_center_set.add(each_center)
|
||||||
|
|
||||||
|
# --------------------------------------------------------
|
||||||
|
# --------------------------------------------------------
|
||||||
|
# if len(add_center) > 0:
|
||||||
|
# s3 = pressure_leak_all.loc[add_center]
|
||||||
|
# pressure_leak = pd.concat([pressure_leak, s3])
|
||||||
|
# s4 = flow_leak_all.loc[add_center]
|
||||||
|
# flow_leak = pd.concat([flow_leak, s4])
|
||||||
|
# --------------------------------------------------------
|
||||||
|
# 只为 add_center 里还没算过的中心补算,并与本轮中心合并
|
||||||
|
if len(add_center) > 0:
|
||||||
|
stage_start = perf_counter()
|
||||||
|
pressure_add, flow_add, pressure_leak_all, flow_leak_all = (
|
||||||
|
_ensure_signatures_for_centers(
|
||||||
|
wn=wn,
|
||||||
|
wn_inp_path=wn_inp_path,
|
||||||
|
center_list=add_center,
|
||||||
|
pressure_leak_all=pressure_leak_all,
|
||||||
|
flow_leak_all=flow_leak_all,
|
||||||
|
timestep_list=timestep_list,
|
||||||
|
pressure_monitor=pressure_monitor,
|
||||||
|
flow_monitor=flow_monitor,
|
||||||
|
leak_mag=leak_mag, # 与上面一致
|
||||||
|
n_workers=n_workers,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_accumulate_stage(
|
||||||
|
stage_timing, "signature_for_extra_centers", stage_start
|
||||||
|
)
|
||||||
|
pressure_leak = pd.concat([pressure_leak, pressure_add], axis=0)
|
||||||
|
if (flow_leak is not None) and (flow_add is not None):
|
||||||
|
flow_leak = pd.concat([flow_leak, flow_add], axis=0)
|
||||||
|
# --------------------------------------------------------
|
||||||
|
#
|
||||||
|
if len(candidate_pipe_input) < 1.2 * top_pipe_num_max / top_group_ratio:
|
||||||
|
if_compalsive = 1
|
||||||
|
cos_h, dis_h, dis_f_h = adjust_ratio(similarity_mode, cos_h, dis_h, dis_f_h)
|
||||||
|
candidate_center_list_sup = _dedupe_preserve_order(
|
||||||
|
candidate_center_list + add_center
|
||||||
|
)
|
||||||
|
stage_start = perf_counter()
|
||||||
|
similarity, cos_h, dis_h, dis_f_h, break_flag, similarity_details = (
|
||||||
|
cal_similarity_all_multi_new_sq_improve_double_lzr(
|
||||||
|
candidate_center_list_sup,
|
||||||
|
similarity_mode,
|
||||||
|
pressure_leak,
|
||||||
|
pressure_monitor,
|
||||||
|
pressure_predict,
|
||||||
|
pressure_normal,
|
||||||
|
if_flow,
|
||||||
|
if_only_cos,
|
||||||
|
if_only_flow,
|
||||||
|
flow_leak,
|
||||||
|
flow_monitor,
|
||||||
|
flow_predict,
|
||||||
|
flow_normal,
|
||||||
|
timestep_list,
|
||||||
|
Top_sensor_num,
|
||||||
|
if_gy,
|
||||||
|
effective_sensor,
|
||||||
|
cos_h,
|
||||||
|
dis_h,
|
||||||
|
dis_f_h,
|
||||||
|
if_compalsive,
|
||||||
|
max_flow,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
last_round_candidate_details = similarity_details
|
||||||
|
_accumulate_stage(stage_timing, "similarity_ranking", stage_start)
|
||||||
|
if break_flag == 1:
|
||||||
|
exit_condition = "similarity_break_flag"
|
||||||
|
break
|
||||||
|
|
||||||
|
new_similarity = update_similarity(
|
||||||
|
candidate_center_list, similarity, leak_center_dict
|
||||||
|
)
|
||||||
|
if len(candidate_pipe_input) > top_pipe_num_max / top_group_ratio:
|
||||||
|
cut_ratio, new_similarity = extra_judge(new_similarity)
|
||||||
|
else:
|
||||||
|
cut_ratio = 1
|
||||||
|
stage_start = perf_counter()
|
||||||
|
final_area_t, final_center_t, all_node_new_1, if_end = (
|
||||||
|
area_output_num_ki_improve(
|
||||||
|
candidate_center_list,
|
||||||
|
candidate_group_list,
|
||||||
|
new_similarity,
|
||||||
|
new_all_node,
|
||||||
|
top_group_ratio,
|
||||||
|
top_pipe_num_max,
|
||||||
|
top_pipe_num_min,
|
||||||
|
cut_ratio,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_accumulate_stage(stage_timing, "candidate_area_selection", stage_start)
|
||||||
|
final_area = final_area + final_area_t
|
||||||
|
final_center = final_center + final_center_t
|
||||||
|
|
||||||
|
final_area = sorted(set(final_area))
|
||||||
|
final_center = sorted(set(final_center))
|
||||||
|
if if_end == 1:
|
||||||
|
exit_condition = "candidate_area_if_end"
|
||||||
|
break
|
||||||
|
elif len(candidate_pipe_input) == len(final_area):
|
||||||
|
exit_condition = "candidate_size_no_change"
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
candidate_pipe_input = final_area
|
||||||
|
if not partition_on_full_graph:
|
||||||
|
all_node_iter = all_node_new_1
|
||||||
|
iter_count += 1
|
||||||
|
sys.stdout.write(
|
||||||
|
"\r"
|
||||||
|
+ "已经完成"
|
||||||
|
+ str(iter_count)
|
||||||
|
+ "次迭代计算"
|
||||||
|
+ "候选节点"
|
||||||
|
+ str(len(final_area))
|
||||||
|
+ "个"
|
||||||
|
)
|
||||||
|
# if break_flag == 0:
|
||||||
|
# final_area_pipe = copy.deepcopy(final_area)
|
||||||
|
# simulation_times = simulation_times + len(final_area)
|
||||||
|
# pressure_leak_sp = pressure_leak_all.loc[final_area_pipe].loc[:, :]
|
||||||
|
# flow_leak_sp = flow_leak_all.loc[final_area_pipe].loc[:, :]
|
||||||
|
# similarity_sp, cos_h, dis_h, dis_f_h, break_flag = cal_similarity_all_multi_new_sq_improve_double_lzr(
|
||||||
|
# final_area_pipe, similarity_mode, pressure_leak_sp,
|
||||||
|
# pressure_monitor, pressure_predict, pressure_normal, if_flow,
|
||||||
|
# if_only_cos, if_only_flow,
|
||||||
|
# flow_leak_sp, flow_monitor, flow_predict, flow_normal,
|
||||||
|
# timestep_list, Top_sensor_num, if_gy, effective_sensor, cos_h, dis_h, dis_f_h, if_compalsive, max_flow)
|
||||||
|
if break_flag == 0:
|
||||||
|
final_area_pipe = list(final_area) # 确保是 list
|
||||||
|
|
||||||
|
# 只为还没算过的管段补齐 SLF(按需计算)
|
||||||
|
stage_start = perf_counter()
|
||||||
|
pressure_leak_sp, flow_leak_sp, pressure_leak_all, flow_leak_all = (
|
||||||
|
_ensure_signatures_for_centers(
|
||||||
|
wn=wn,
|
||||||
|
wn_inp_path=wn_inp_path,
|
||||||
|
center_list=final_area_pipe, # 这次要用的“最终区域里的所有管段”
|
||||||
|
pressure_leak_all=pressure_leak_all, # 累积缓存(会被更新)
|
||||||
|
flow_leak_all=flow_leak_all,
|
||||||
|
timestep_list=timestep_list,
|
||||||
|
pressure_monitor=pressure_monitor,
|
||||||
|
flow_monitor=flow_monitor,
|
||||||
|
leak_mag=leak_mag,
|
||||||
|
n_workers=n_workers,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_accumulate_stage(stage_timing, "signature_for_final_area", stage_start)
|
||||||
|
|
||||||
|
# 如果你要精确统计模拟次数,这里可以加上“本次新补的数量”,
|
||||||
|
# 做法:让 _ensure_signatures_for_centers 额外返回 need_cnt,再 simulation_times += need_cnt
|
||||||
|
|
||||||
|
stage_start = perf_counter()
|
||||||
|
(
|
||||||
|
similarity_sp,
|
||||||
|
cos_h,
|
||||||
|
dis_h,
|
||||||
|
dis_f_h,
|
||||||
|
break_flag,
|
||||||
|
similarity_details,
|
||||||
|
) = (
|
||||||
|
cal_similarity_all_multi_new_sq_improve_double_lzr(
|
||||||
|
final_area_pipe,
|
||||||
|
similarity_mode,
|
||||||
|
pressure_leak_sp,
|
||||||
|
pressure_monitor,
|
||||||
|
pressure_predict,
|
||||||
|
pressure_normal,
|
||||||
|
if_flow,
|
||||||
|
if_only_cos,
|
||||||
|
if_only_flow,
|
||||||
|
flow_leak_sp,
|
||||||
|
flow_monitor,
|
||||||
|
flow_predict,
|
||||||
|
flow_normal,
|
||||||
|
timestep_list,
|
||||||
|
Top_sensor_num,
|
||||||
|
if_gy,
|
||||||
|
effective_sensor,
|
||||||
|
cos_h,
|
||||||
|
dis_h,
|
||||||
|
dis_f_h,
|
||||||
|
if_compalsive,
|
||||||
|
max_flow,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
last_round_candidate_details = similarity_details
|
||||||
|
_accumulate_stage(stage_timing, "similarity_final", stage_start)
|
||||||
|
|
||||||
|
else:
|
||||||
|
dpressure = (pressure_predict - pressure_monitor).mean()
|
||||||
|
dpressure = dpressure.abs()
|
||||||
|
simulation_times = simulation_times + len(dpressure.index)
|
||||||
|
similarity_sp = pd.Series(dtype=float)
|
||||||
|
for each_node in dpressure.index:
|
||||||
|
pipe = node_pipe_dic[each_node][0]
|
||||||
|
similarity_sp.loc[pipe] = dpressure.loc[each_node]
|
||||||
|
similarity_sp = similarity_sp.sort_values(ascending=False, kind="mergesort")
|
||||||
|
t2 = datetime.now()
|
||||||
|
final_area_pipe = []
|
||||||
|
|
||||||
|
sys.stdout.write(
|
||||||
|
"\r"
|
||||||
|
+ "已经完成"
|
||||||
|
+ str(iter_count + 1)
|
||||||
|
+ "次迭代计算"
|
||||||
|
+ "候选节点"
|
||||||
|
+ str(len(final_area_pipe))
|
||||||
|
+ "个"
|
||||||
|
)
|
||||||
|
t2 = datetime.now()
|
||||||
|
dt = (t2 - t1).seconds
|
||||||
|
final_candidates_csv = _write_last_round_candidates_csv(
|
||||||
|
csv_path=final_candidates_csv_path,
|
||||||
|
exit_condition=exit_condition,
|
||||||
|
iteration_count=iter_count + 1,
|
||||||
|
similarity_mode=similarity_mode,
|
||||||
|
candidate_details=last_round_candidate_details,
|
||||||
|
fallback_similarity=similarity_sp,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
exit_condition = "no_effective_sensor_after_threshold"
|
||||||
|
dpressure = (pressure_predict - pressure_monitor).mean()
|
||||||
|
dpressure = dpressure.abs()
|
||||||
|
|
||||||
|
similarity_sp = pd.Series(dtype=float)
|
||||||
|
for each_node in dpressure.index:
|
||||||
|
pipe = node_pipe_dic[each_node][0]
|
||||||
|
similarity_sp.loc[pipe] = dpressure.loc[each_node]
|
||||||
|
similarity_sp = similarity_sp.sort_values(ascending=False, kind="mergesort")
|
||||||
|
t2 = datetime.now()
|
||||||
|
dt = (t2 - t1).seconds
|
||||||
|
final_candidates_csv = _write_last_round_candidates_csv(
|
||||||
|
csv_path=final_candidates_csv_path,
|
||||||
|
exit_condition=exit_condition,
|
||||||
|
iteration_count=0,
|
||||||
|
similarity_mode=similarity_mode,
|
||||||
|
candidate_details=None,
|
||||||
|
fallback_similarity=similarity_sp,
|
||||||
|
)
|
||||||
|
stage_timing["iterations"] = iter_count + 1 if len(dpressure) > 0 else 0
|
||||||
|
stage_timing["total_elapsed_seconds"] = float(dt)
|
||||||
|
stage_timing["exit_condition"] = exit_condition
|
||||||
|
stage_timing["final_candidates_csv"] = final_candidates_csv
|
||||||
|
return (
|
||||||
|
similarity_sp.index[0],
|
||||||
|
dt,
|
||||||
|
simulation_times,
|
||||||
|
wn,
|
||||||
|
similarity_sp,
|
||||||
|
exit_condition,
|
||||||
|
final_candidates_csv,
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,563 @@
|
|||||||
|
"""漏损模拟模块。"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import multiprocessing as mp
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import wntr
|
||||||
|
|
||||||
|
from app.algorithms._utils import _cleanup_temp_files
|
||||||
|
|
||||||
|
_PIPE2LEAKNODE = None
|
||||||
|
_SIGNATURE_WORKER_DATA = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_temp_prefix(tag):
|
||||||
|
temp_dir = os.path.abspath(os.path.join("temp", "burst_location"))
|
||||||
|
os.makedirs(temp_dir, exist_ok=True)
|
||||||
|
safe_tag = str(tag).replace(os.sep, "_").replace(" ", "_")
|
||||||
|
return os.path.join(temp_dir, f"{safe_tag}_{os.getpid()}")
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_hydraulic_options(wn):
|
||||||
|
options = wn.options
|
||||||
|
return {
|
||||||
|
"demand_model": options.hydraulic.demand_model,
|
||||||
|
"duration": float(options.time.duration),
|
||||||
|
"hydraulic_timestep": float(options.time.hydraulic_timestep),
|
||||||
|
"pattern_timestep": float(options.time.pattern_timestep),
|
||||||
|
"report_timestep": float(options.time.report_timestep),
|
||||||
|
"required_pressure": float(options.hydraulic.required_pressure),
|
||||||
|
"minimum_pressure": float(options.hydraulic.minimum_pressure),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_hydraulic_options(wn, option_values):
|
||||||
|
options = wn.options
|
||||||
|
options.hydraulic.demand_model = option_values["demand_model"]
|
||||||
|
options.time.duration = option_values["duration"]
|
||||||
|
options.time.hydraulic_timestep = option_values["hydraulic_timestep"]
|
||||||
|
options.time.pattern_timestep = option_values["pattern_timestep"]
|
||||||
|
options.time.report_timestep = option_values["report_timestep"]
|
||||||
|
options.hydraulic.required_pressure = option_values["required_pressure"]
|
||||||
|
options.hydraulic.minimum_pressure = option_values["minimum_pressure"]
|
||||||
|
|
||||||
|
|
||||||
|
def simple_add_leak(wn, leak_mag, leak_pipe):
|
||||||
|
whole_inf = dict()
|
||||||
|
leak_pipe_self = wn.get_link(leak_pipe)
|
||||||
|
pipe_diameter = leak_pipe_self.diameter
|
||||||
|
pipe_length = leak_pipe_self.length
|
||||||
|
pipe_roughness = leak_pipe_self.roughness
|
||||||
|
pipe_minor_loss = leak_pipe_self.minor_loss
|
||||||
|
# pipe_status = leak_pipe_self.status
|
||||||
|
# pipe_check_valve = leak_pipe_self.check_valve
|
||||||
|
pipe_start_node = leak_pipe_self.start_node_name
|
||||||
|
pipe_end_node = leak_pipe_self.end_node_name
|
||||||
|
|
||||||
|
# close the pipe
|
||||||
|
# leak_pipe_self.status = 'Closed'
|
||||||
|
wn.remove_link(leak_pipe)
|
||||||
|
# add the pipe
|
||||||
|
add_pipe1 = leak_pipe + "A"
|
||||||
|
add_pipe2 = leak_pipe + "B"
|
||||||
|
add_node = leak_pipe + "_"
|
||||||
|
|
||||||
|
start_n = wn.get_node(pipe_start_node)
|
||||||
|
end_n = wn.get_node(pipe_end_node)
|
||||||
|
if start_n.node_type == "Reservoir":
|
||||||
|
end_n_elevation = end_n.elevation
|
||||||
|
start_n_elevation = end_n_elevation
|
||||||
|
elif end_n.node_type == "Reservoir":
|
||||||
|
start_n_elevation = start_n.elevation
|
||||||
|
end_n_elevation = start_n_elevation
|
||||||
|
else:
|
||||||
|
end_n_elevation = end_n.elevation
|
||||||
|
start_n_elevation = start_n.elevation
|
||||||
|
elevation_self = (start_n_elevation + end_n_elevation) / 2
|
||||||
|
coordinates_self = (
|
||||||
|
(start_n.coordinates[0] + end_n.coordinates[0]) / 2,
|
||||||
|
(start_n.coordinates[1] + end_n.coordinates[1]),
|
||||||
|
)
|
||||||
|
|
||||||
|
wn.add_junction(
|
||||||
|
add_node, base_demand=0, elevation=elevation_self, coordinates=coordinates_self
|
||||||
|
)
|
||||||
|
leak_node = wn.get_node(add_node)
|
||||||
|
wn.add_pipe(
|
||||||
|
add_pipe1,
|
||||||
|
start_node_name=pipe_start_node,
|
||||||
|
end_node_name=add_node,
|
||||||
|
length=pipe_length / 2,
|
||||||
|
diameter=pipe_diameter,
|
||||||
|
roughness=pipe_roughness,
|
||||||
|
minor_loss=pipe_minor_loss,
|
||||||
|
)
|
||||||
|
wn.add_pipe(
|
||||||
|
add_pipe2,
|
||||||
|
start_node_name=pipe_end_node,
|
||||||
|
end_node_name=add_node,
|
||||||
|
length=pipe_length / 2,
|
||||||
|
diameter=pipe_diameter,
|
||||||
|
roughness=pipe_roughness,
|
||||||
|
minor_loss=pipe_minor_loss,
|
||||||
|
)
|
||||||
|
|
||||||
|
# simulation
|
||||||
|
leak_node.add_demand(base=leak_mag, pattern_name="add_leak")
|
||||||
|
|
||||||
|
whole_inf["leak_node_name"] = add_node
|
||||||
|
whole_inf["add_pipe1"] = add_pipe1
|
||||||
|
whole_inf["add_pipe2"] = add_pipe2
|
||||||
|
whole_inf["leak_pipe"] = leak_pipe
|
||||||
|
whole_inf["pipe_start_node"] = pipe_start_node
|
||||||
|
whole_inf["pipe_end_node"] = pipe_end_node
|
||||||
|
whole_inf["pipe_length"] = pipe_length
|
||||||
|
whole_inf["pipe_diameter"] = pipe_diameter
|
||||||
|
whole_inf["pipe_roughness"] = pipe_roughness
|
||||||
|
whole_inf["pipe_minor_loss"] = pipe_diameter
|
||||||
|
return wn, whole_inf, add_pipe1
|
||||||
|
|
||||||
|
|
||||||
|
def simple_recover_wn(wn, whole_inf):
|
||||||
|
leak_node = wn.get_node(whole_inf["leak_node_name"])
|
||||||
|
|
||||||
|
del leak_node.demand_timeseries_list[-1]
|
||||||
|
# update
|
||||||
|
wn.remove_link(whole_inf["add_pipe1"])
|
||||||
|
wn.remove_link(whole_inf["add_pipe2"])
|
||||||
|
wn.remove_node(whole_inf["leak_node_name"])
|
||||||
|
# open the pipe
|
||||||
|
# leak_pipe_self.status = 'Open'
|
||||||
|
wn.add_pipe(
|
||||||
|
whole_inf["leak_pipe"],
|
||||||
|
start_node_name=whole_inf["pipe_start_node"],
|
||||||
|
end_node_name=whole_inf["pipe_end_node"],
|
||||||
|
length=whole_inf["pipe_length"],
|
||||||
|
diameter=whole_inf["pipe_diameter"],
|
||||||
|
roughness=whole_inf["pipe_roughness"],
|
||||||
|
minor_loss=whole_inf["pipe_minor_loss"],
|
||||||
|
)
|
||||||
|
|
||||||
|
return wn
|
||||||
|
|
||||||
|
|
||||||
|
def disable_all_controls_temporarily(wn):
|
||||||
|
"""返回(控制名, 控制对象)的列表,之后可用 restore_controls 还原。"""
|
||||||
|
removed = []
|
||||||
|
# WNTR 的控制都在 wn.control_name_list / wn.get_control / wn.remove_control
|
||||||
|
for cname in list(wn.control_name_list):
|
||||||
|
ctrl = wn.get_control(cname)
|
||||||
|
removed.append((cname, ctrl))
|
||||||
|
wn.remove_control(cname)
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
def restore_controls(wn, removed):
|
||||||
|
"""把先前禁用的控制全部加回去。"""
|
||||||
|
for cname, ctrl in removed:
|
||||||
|
wn.add_control(cname, ctrl)
|
||||||
|
|
||||||
|
|
||||||
|
def set_pipe2leaknode_mapping(mapping):
|
||||||
|
global _PIPE2LEAKNODE
|
||||||
|
_PIPE2LEAKNODE = mapping
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create_leak_demand_ts(leak_node):
|
||||||
|
"""
|
||||||
|
返回:泄漏专用 demand 的下标 idx。
|
||||||
|
若不存在,以 category='leak' 新建一条 base=0.0 的 demand。
|
||||||
|
"""
|
||||||
|
# 先尝试找到已有的 'leak' 分类
|
||||||
|
for i, ts in enumerate(leak_node.demand_timeseries_list):
|
||||||
|
# WNTR 的 Demand object 存在 category 属性
|
||||||
|
if getattr(ts, "category", None) == "leak":
|
||||||
|
return i
|
||||||
|
# 没有则新建(base=0.0,后续临时改 base_value)
|
||||||
|
leak_node.add_demand(base=0.0, pattern_name=None, category="leak")
|
||||||
|
return len(leak_node.demand_timeseries_list) - 1
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_mid_node(wn, leak_pipe):
|
||||||
|
add_pipe1 = f"{leak_pipe}A"
|
||||||
|
add_pipe2 = f"{leak_pipe}B"
|
||||||
|
add_node = f"{leak_pipe}__mid"
|
||||||
|
|
||||||
|
if add_node in wn.node_name_list:
|
||||||
|
return add_node
|
||||||
|
|
||||||
|
if leak_pipe in wn.link_name_list:
|
||||||
|
leak_pipe_self = wn.get_link(leak_pipe)
|
||||||
|
pipe_diameter = leak_pipe_self.diameter
|
||||||
|
pipe_length = leak_pipe_self.length
|
||||||
|
pipe_roughness = leak_pipe_self.roughness
|
||||||
|
pipe_minor_loss = leak_pipe_self.minor_loss
|
||||||
|
pipe_start_node = leak_pipe_self.start_node_name
|
||||||
|
pipe_end_node = leak_pipe_self.end_node_name
|
||||||
|
|
||||||
|
start_n = wn.get_node(pipe_start_node)
|
||||||
|
end_n = wn.get_node(pipe_end_node)
|
||||||
|
if start_n.node_type == "Reservoir":
|
||||||
|
end_elev = end_n.elevation
|
||||||
|
start_elev = end_elev
|
||||||
|
elif end_n.node_type == "Reservoir":
|
||||||
|
start_elev = start_n.elevation
|
||||||
|
end_elev = start_elev
|
||||||
|
else:
|
||||||
|
end_elev = end_n.elevation
|
||||||
|
start_elev = start_n.elevation
|
||||||
|
elev_mid = (start_elev + end_elev) / 2.0
|
||||||
|
x_mid = (start_n.coordinates[0] + end_n.coordinates[0]) / 2.0
|
||||||
|
y_mid = (start_n.coordinates[1] + end_n.coordinates[1]) / 2.0
|
||||||
|
|
||||||
|
wn.remove_link(leak_pipe)
|
||||||
|
wn.add_junction(
|
||||||
|
add_node, base_demand=0.0, elevation=elev_mid, coordinates=(x_mid, y_mid)
|
||||||
|
)
|
||||||
|
wn.add_pipe(
|
||||||
|
add_pipe1,
|
||||||
|
start_node_name=pipe_start_node,
|
||||||
|
end_node_name=add_node,
|
||||||
|
length=pipe_length / 2.0,
|
||||||
|
diameter=pipe_diameter,
|
||||||
|
roughness=pipe_roughness,
|
||||||
|
minor_loss=pipe_minor_loss,
|
||||||
|
)
|
||||||
|
wn.add_pipe(
|
||||||
|
add_pipe2,
|
||||||
|
start_node_name=add_node,
|
||||||
|
end_node_name=pipe_end_node,
|
||||||
|
length=pipe_length / 2.0,
|
||||||
|
diameter=pipe_diameter,
|
||||||
|
roughness=pipe_roughness,
|
||||||
|
minor_loss=pipe_minor_loss,
|
||||||
|
)
|
||||||
|
return add_node
|
||||||
|
|
||||||
|
# 若 A/B 已存在但中点不在,建议确认网络一致性
|
||||||
|
raise KeyError(f"Cannot ensure mid node for pipe '{leak_pipe}'.")
|
||||||
|
|
||||||
|
|
||||||
|
def leak_simulation_pipe_dd_multi_pf(
|
||||||
|
wn, leak_mag, leak_pipe, sensor_name, file_prefix=None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
优化版:
|
||||||
|
- 不再 remove/add link/node
|
||||||
|
- 直接在预插入的中点泄漏节点上设置 base_demand = leak_mag;仿真后设回 0
|
||||||
|
"""
|
||||||
|
wn.options.hydraulic.demand_model = "DD"
|
||||||
|
|
||||||
|
# 确保中点节点存在
|
||||||
|
leak_node_name = ensure_mid_node(wn, leak_pipe)
|
||||||
|
leak_node = wn.get_node(leak_node_name)
|
||||||
|
|
||||||
|
# 拿到泄漏专用的 demand time-series 下标
|
||||||
|
leak_idx = _get_or_create_leak_demand_ts(leak_node)
|
||||||
|
ts_obj = leak_node.demand_timeseries_list[leak_idx]
|
||||||
|
|
||||||
|
# 记录原值(通常是 0.0)
|
||||||
|
orig_base = ts_obj.base_value
|
||||||
|
try:
|
||||||
|
# 打开泄漏:只改 base_value,不碰 base_demand(只读)
|
||||||
|
ts_obj.base_value = float(leak_mag)
|
||||||
|
|
||||||
|
# 仿真
|
||||||
|
sim = wntr.sim.EpanetSimulator(wn)
|
||||||
|
if file_prefix is None:
|
||||||
|
results = sim.run_sim()
|
||||||
|
else:
|
||||||
|
results = sim.run_sim(file_prefix=file_prefix)
|
||||||
|
|
||||||
|
# 输出(保持列顺序)
|
||||||
|
pressure_output = results.node["pressure"].loc[:, sensor_name]
|
||||||
|
# flow_output = results.link['flowrate'].loc[:, sensor_f_name]
|
||||||
|
return wn, pressure_output
|
||||||
|
finally:
|
||||||
|
# 关闭泄漏:还原 base_value
|
||||||
|
ts_obj.base_value = orig_base
|
||||||
|
if file_prefix is not None:
|
||||||
|
_cleanup_temp_files(file_prefix)
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_leak_infrastructure(wn, candidate_pipes):
|
||||||
|
"""
|
||||||
|
把 candidate_pipes 每条管段切成两段,并在中点插入一个泄漏节点(base_demand=0)。
|
||||||
|
返回一个映射:pipe_id -> leak_node_name
|
||||||
|
注意:只做一次;后续仿真通过在该节点设置 base_demand 实现“打开泄漏”,结束后恢复为 0。
|
||||||
|
"""
|
||||||
|
pipe2leaknode = {}
|
||||||
|
for leak_pipe in candidate_pipes:
|
||||||
|
if leak_pipe in pipe2leaknode:
|
||||||
|
continue
|
||||||
|
|
||||||
|
leak_pipe_self = wn.get_link(leak_pipe)
|
||||||
|
pipe_diameter = leak_pipe_self.diameter
|
||||||
|
pipe_length = leak_pipe_self.length
|
||||||
|
pipe_roughness = leak_pipe_self.roughness
|
||||||
|
pipe_minor_loss = leak_pipe_self.minor_loss
|
||||||
|
pipe_start_node = leak_pipe_self.start_node_name
|
||||||
|
pipe_end_node = leak_pipe_self.end_node_name
|
||||||
|
|
||||||
|
# 计算中点高程/坐标(与原逻辑一致)
|
||||||
|
start_n = wn.get_node(pipe_start_node)
|
||||||
|
end_n = wn.get_node(pipe_end_node)
|
||||||
|
if start_n.node_type == "Reservoir":
|
||||||
|
end_elev = end_n.elevation
|
||||||
|
start_elev = end_elev
|
||||||
|
elif end_n.node_type == "Reservoir":
|
||||||
|
start_elev = start_n.elevation
|
||||||
|
end_elev = start_elev
|
||||||
|
else:
|
||||||
|
end_elev = end_n.elevation
|
||||||
|
start_elev = start_n.elevation
|
||||||
|
elev_mid = (start_elev + end_elev) / 2.0
|
||||||
|
x_mid = (start_n.coordinates[0] + end_n.coordinates[0]) / 2.0
|
||||||
|
y_mid = (start_n.coordinates[1] + end_n.coordinates[1]) / 2.0
|
||||||
|
|
||||||
|
# 先删原管,再加中点与两段半长管(只做一次)
|
||||||
|
wn.remove_link(leak_pipe)
|
||||||
|
|
||||||
|
add_pipe1 = f"{leak_pipe}A"
|
||||||
|
add_pipe2 = f"{leak_pipe}B"
|
||||||
|
add_node = f"{leak_pipe}__mid" # 唯一命名,后面直接用它当泄漏节点
|
||||||
|
|
||||||
|
wn.add_junction(
|
||||||
|
add_node, base_demand=0.0, elevation=elev_mid, coordinates=(x_mid, y_mid)
|
||||||
|
)
|
||||||
|
wn.add_pipe(
|
||||||
|
add_pipe1,
|
||||||
|
start_node_name=pipe_start_node,
|
||||||
|
end_node_name=add_node,
|
||||||
|
length=pipe_length / 2.0,
|
||||||
|
diameter=pipe_diameter,
|
||||||
|
roughness=pipe_roughness,
|
||||||
|
minor_loss=pipe_minor_loss,
|
||||||
|
)
|
||||||
|
wn.add_pipe(
|
||||||
|
add_pipe2,
|
||||||
|
start_node_name=add_node,
|
||||||
|
end_node_name=pipe_end_node,
|
||||||
|
length=pipe_length / 2.0,
|
||||||
|
diameter=pipe_diameter,
|
||||||
|
roughness=pipe_roughness,
|
||||||
|
minor_loss=pipe_minor_loss,
|
||||||
|
)
|
||||||
|
|
||||||
|
pipe2leaknode[leak_pipe] = add_node
|
||||||
|
|
||||||
|
return pipe2leaknode
|
||||||
|
|
||||||
|
|
||||||
|
def normal_simulation_pf(
|
||||||
|
wn, drive_mode, sensor_name, sensor_f_name, inp_time, require_p, minimum_p
|
||||||
|
):
|
||||||
|
# inp_time = 0
|
||||||
|
if drive_mode == "PDD": # 需水量根据节点压力动态调整
|
||||||
|
wn.options.hydraulic.demand_model = "PDD"
|
||||||
|
wn.options.hydraulic.required_pressure = require_p
|
||||||
|
wn.options.hydraulic.minimum_pressure = minimum_p
|
||||||
|
elif drive_mode == "DD": # 需水量固定,与压力无关
|
||||||
|
wn.options.hydraulic.demand_model = "DD"
|
||||||
|
sim = wntr.sim.EpanetSimulator(wn)
|
||||||
|
results = sim.run_sim()
|
||||||
|
pressure_all = results.node["pressure"][sensor_name]
|
||||||
|
pressure = pressure_all.iloc[inp_time]
|
||||||
|
demand_all = results.node["demand"]
|
||||||
|
demand = demand_all.iloc[inp_time]
|
||||||
|
sum_demand = cal_sum_demand(demand)
|
||||||
|
flow_all = results.link["flowrate"][sensor_f_name]
|
||||||
|
flow = flow_all.iloc[inp_time]
|
||||||
|
|
||||||
|
top_sensor = pressure.idxmin()
|
||||||
|
basic_p = results.node["pressure"]
|
||||||
|
basic_p = basic_p.iloc[inp_time]
|
||||||
|
return pressure, flow, basic_p, top_sensor, sum_demand
|
||||||
|
|
||||||
|
|
||||||
|
def normal_simulation_multi_pf(
|
||||||
|
wn, drive_mode, sensor_name, sensor_f_name, require_p, minimum_p
|
||||||
|
):
|
||||||
|
# inp_time = 0
|
||||||
|
if drive_mode == "PDD":
|
||||||
|
wn.options.hydraulic.demand_model = "PDD"
|
||||||
|
wn.options.hydraulic.required_pressure = require_p
|
||||||
|
wn.options.hydraulic.minimum_pressure = minimum_p
|
||||||
|
elif drive_mode == "DD":
|
||||||
|
wn.options.hydraulic.demand_model = "DD"
|
||||||
|
sim = wntr.sim.EpanetSimulator(wn)
|
||||||
|
results = sim.run_sim()
|
||||||
|
pressure_all = results.node["pressure"][sensor_name]
|
||||||
|
pressure = pressure_all
|
||||||
|
demand_all = results.node["demand"]
|
||||||
|
demand = demand_all
|
||||||
|
flow = results.link["flowrate"][sensor_f_name]
|
||||||
|
sum_demand = pd.Series(dtype=object)
|
||||||
|
for i in range(len(demand.index)):
|
||||||
|
sum_demand[str(demand.index[i])] = cal_sum_demand(demand.iloc[i])
|
||||||
|
if type(pressure) == pd.core.series.Series:
|
||||||
|
top_sensor = pressure.idxmin()
|
||||||
|
else:
|
||||||
|
mean_pressure = pressure.mean()
|
||||||
|
top_sensor = mean_pressure.idxmin()
|
||||||
|
basic_p = results.node["pressure"]
|
||||||
|
return pressure, flow, basic_p, top_sensor, sum_demand
|
||||||
|
|
||||||
|
|
||||||
|
def simple_simulation_pf(wn, sensor_name, sensor_f_name, leak_pipe, add_pipe1):
|
||||||
|
sim = wntr.sim.EpanetSimulator(wn)
|
||||||
|
results = sim.run_sim()
|
||||||
|
pressure_all = results.node["pressure"][sensor_name]
|
||||||
|
if len(leak_pipe) > 0 and leak_pipe in sensor_f_name:
|
||||||
|
|
||||||
|
f_sensor_name = [add_pipe1 if i == leak_pipe else i for i in sensor_f_name]
|
||||||
|
flow_all = results.link["flowrate"][f_sensor_name]
|
||||||
|
flow_all.columns = sensor_f_name
|
||||||
|
else:
|
||||||
|
flow_all = results.link["flowrate"][sensor_f_name]
|
||||||
|
return pressure_all, flow_all
|
||||||
|
|
||||||
|
|
||||||
|
def cal_sum_demand(demand):
|
||||||
|
sum_demand = 0
|
||||||
|
for i in range(len(demand)):
|
||||||
|
if demand.iloc[i] > 0:
|
||||||
|
sum_demand += demand.iloc[i]
|
||||||
|
return sum_demand
|
||||||
|
|
||||||
|
|
||||||
|
def cal_signature_pipe_multi_pf(
|
||||||
|
wn,
|
||||||
|
leak_mag,
|
||||||
|
candidate_center,
|
||||||
|
timestep_list,
|
||||||
|
sensor_name,
|
||||||
|
n_workers=1,
|
||||||
|
wn_inp_path=None,
|
||||||
|
):
|
||||||
|
candidate_center_num = len(candidate_center)
|
||||||
|
pressure_leak = pd.DataFrame(
|
||||||
|
index=pd.MultiIndex.from_product([candidate_center, timestep_list]),
|
||||||
|
columns=sensor_name,
|
||||||
|
)
|
||||||
|
# flow_leak = pd.DataFrame(index=pd.MultiIndex.from_product([candidate_center, timestep_list]),
|
||||||
|
# columns=sensor_f_name)
|
||||||
|
pressure_leak = pressure_leak.sort_index()
|
||||||
|
# flow_leak = flow_leak.sort_index()
|
||||||
|
can_parallel = (
|
||||||
|
n_workers > 1
|
||||||
|
and candidate_center_num > 1
|
||||||
|
and wn_inp_path is not None
|
||||||
|
and len(str(wn_inp_path)) > 0
|
||||||
|
)
|
||||||
|
if can_parallel:
|
||||||
|
option_values = _snapshot_hydraulic_options(wn)
|
||||||
|
worker_count = min(n_workers, candidate_center_num)
|
||||||
|
start_methods = mp.get_all_start_methods()
|
||||||
|
context_name = "spawn" if "spawn" in start_methods else start_methods[0]
|
||||||
|
with mp.get_context(context_name).Pool(
|
||||||
|
processes=worker_count,
|
||||||
|
initializer=_signature_worker_init,
|
||||||
|
initargs=(
|
||||||
|
str(wn_inp_path),
|
||||||
|
float(leak_mag),
|
||||||
|
list(sensor_name),
|
||||||
|
option_values,
|
||||||
|
list(candidate_center),
|
||||||
|
),
|
||||||
|
) as pool:
|
||||||
|
for i, (center_name, pressure_array) in enumerate(
|
||||||
|
pool.imap(_signature_worker_run_center, candidate_center)
|
||||||
|
):
|
||||||
|
pressure_leak.loc[(center_name, slice(None)), :] = pressure_array
|
||||||
|
sys.stdout.write("\r" + "已经完成计算" + str(i + 1) + "个特征中心")
|
||||||
|
else:
|
||||||
|
# Pre-insert all mid-nodes so every simulation sees the same topology
|
||||||
|
for center in candidate_center:
|
||||||
|
ensure_mid_node(wn, center)
|
||||||
|
for i in range(candidate_center_num):
|
||||||
|
temp_prefix = _make_temp_prefix(f"sig_{i}")
|
||||||
|
wn, pressure_output = leak_simulation_pipe_dd_multi_pf(
|
||||||
|
wn,
|
||||||
|
leak_mag,
|
||||||
|
candidate_center[i],
|
||||||
|
sensor_name,
|
||||||
|
file_prefix=temp_prefix,
|
||||||
|
)
|
||||||
|
# leak_or_not_list.append(leak_or_not)
|
||||||
|
pressure_leak.loc[(candidate_center[i], slice(None)), :] = (
|
||||||
|
pressure_output.to_numpy()
|
||||||
|
)
|
||||||
|
# flow_leak.loc[candidate_center[i]].loc[:, :] = flow_output
|
||||||
|
sys.stdout.write("\r" + "已经完成计算" + str(i + 1) + "个特征中心")
|
||||||
|
return pressure_leak, candidate_center
|
||||||
|
|
||||||
|
|
||||||
|
def _signature_worker_init(
|
||||||
|
inp_path, leak_mag, sensor_name, option_values, candidate_centers=None
|
||||||
|
):
|
||||||
|
global _SIGNATURE_WORKER_DATA
|
||||||
|
wn = wntr.network.WaterNetworkModel(inp_path)
|
||||||
|
_apply_hydraulic_options(wn, option_values)
|
||||||
|
# Pre-insert ALL mid-nodes so every simulation runs on the same topology,
|
||||||
|
# regardless of which worker handles which task.
|
||||||
|
if candidate_centers is not None:
|
||||||
|
for center in candidate_centers:
|
||||||
|
ensure_mid_node(wn, center)
|
||||||
|
_SIGNATURE_WORKER_DATA = {
|
||||||
|
"wn": wn,
|
||||||
|
"leak_mag": leak_mag,
|
||||||
|
"sensor_name": sensor_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _signature_worker_run_center(center_name):
|
||||||
|
data = _SIGNATURE_WORKER_DATA
|
||||||
|
temp_prefix = _make_temp_prefix(f"sig_worker_{center_name}")
|
||||||
|
_, pressure_output = leak_simulation_pipe_dd_multi_pf(
|
||||||
|
data["wn"],
|
||||||
|
data["leak_mag"],
|
||||||
|
center_name,
|
||||||
|
data["sensor_name"],
|
||||||
|
file_prefix=temp_prefix,
|
||||||
|
)
|
||||||
|
return center_name, pressure_output.to_numpy()
|
||||||
|
|
||||||
|
|
||||||
|
def pick_pipe(all_pipes, pipe_diameter, limited_diameter):
|
||||||
|
candidate_pipe = []
|
||||||
|
for each_pipe in all_pipes:
|
||||||
|
if pipe_diameter[each_pipe] >= limited_diameter:
|
||||||
|
candidate_pipe.append(each_pipe)
|
||||||
|
return candidate_pipe
|
||||||
|
|
||||||
|
|
||||||
|
def cal_possible_pipe(leak_flow, all_pipe, pipe_diameter):
|
||||||
|
basic_pressure = 10 # 基础压力
|
||||||
|
discharge_coeff = 0.6 # 经验系数
|
||||||
|
break_area_ratio = 1 # 爆管面积比 0.5 1.25
|
||||||
|
break_area = leak_flow / (
|
||||||
|
discharge_coeff * math.sqrt(2 * basic_pressure * 9.81)
|
||||||
|
) # 爆管面积 m3/h
|
||||||
|
"""break_area_diameter = math.sqrt(4 * break_area / math.pi)
|
||||||
|
min_diameter = (math.ceil(1000 * break_area_diameter / break_area_ratio)) / 1000"""
|
||||||
|
break_area_diameter = math.sqrt(
|
||||||
|
4 * break_area / math.pi / break_area_ratio
|
||||||
|
) # 爆管直径
|
||||||
|
min_diameter = (math.ceil(1000 * break_area_diameter)) / 1000 # 向上取整
|
||||||
|
new_all_pipe = pick_pipe(all_pipe, pipe_diameter, min_diameter)
|
||||||
|
return new_all_pipe, min_diameter
|
||||||
|
|
||||||
|
|
||||||
|
def extract_links(data, link_types, direction):
|
||||||
|
return [
|
||||||
|
link
|
||||||
|
for res_data in data.values()
|
||||||
|
for link_type in link_types
|
||||||
|
for link in res_data[link_type][direction]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import logging
|
||||||
|
from multiprocessing import cpu_count
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterable
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from app.algorithms.burst_localization import leak_signature
|
||||||
|
|
||||||
|
from .candidate_ranking import (
|
||||||
|
DN_search_multi_simple_add_flow_count_new,
|
||||||
|
)
|
||||||
|
from .topology_model import (
|
||||||
|
_build_node_pipe_maps,
|
||||||
|
cal_node_coordinate,
|
||||||
|
construct_graph,
|
||||||
|
load_inp,
|
||||||
|
read_inf_inp,
|
||||||
|
read_inf_inp_other,
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFAULT_N_WORKERS = max(1, min(cpu_count() - 1, 4))
|
||||||
|
# DEFAULT_N_WORKERS = max(1, cpu_count() - 1)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _align_scada_series(
|
||||||
|
series: pd.Series, ids: Iterable[str], series_name: str
|
||||||
|
) -> pd.Series:
|
||||||
|
ids = [str(item) for item in ids]
|
||||||
|
aligned = series.copy()
|
||||||
|
aligned.index = aligned.index.map(str)
|
||||||
|
missing_ids = [item for item in ids if item not in aligned.index]
|
||||||
|
if missing_ids:
|
||||||
|
preview = ", ".join(missing_ids[:10])
|
||||||
|
raise ValueError(f"{series_name} missing IDs: {preview}")
|
||||||
|
aligned = pd.to_numeric(aligned.loc[ids], errors="coerce")
|
||||||
|
invalid_ids = aligned[aligned.isna()].index.tolist()
|
||||||
|
if invalid_ids:
|
||||||
|
preview = ", ".join(invalid_ids[:10])
|
||||||
|
raise ValueError(
|
||||||
|
f"{series_name} contains non-numeric values for IDs: {preview}"
|
||||||
|
)
|
||||||
|
return aligned
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_flow_inputs(
|
||||||
|
flow_scada_ids: list[str] | None,
|
||||||
|
burst_flow: pd.Series | None,
|
||||||
|
normal_flow: pd.Series | None,
|
||||||
|
) -> tuple[bool, list[str]]:
|
||||||
|
has_any_flow = any(
|
||||||
|
value is not None for value in [flow_scada_ids, burst_flow, normal_flow]
|
||||||
|
)
|
||||||
|
has_all_flow = all(
|
||||||
|
value is not None for value in [flow_scada_ids, burst_flow, normal_flow]
|
||||||
|
)
|
||||||
|
if has_any_flow and not has_all_flow:
|
||||||
|
raise ValueError(
|
||||||
|
"flow_scada_ids, burst_flow, and normal_flow must be provided together."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not has_all_flow:
|
||||||
|
return False, []
|
||||||
|
|
||||||
|
flow_ids = [str(item) for item in (flow_scada_ids or [])]
|
||||||
|
if len(flow_ids) == 0:
|
||||||
|
raise ValueError("flow_scada_ids cannot be empty when flow data is provided.")
|
||||||
|
return True, flow_ids
|
||||||
|
|
||||||
|
|
||||||
|
def _build_top_candidates(similarity_series: pd.Series) -> list[dict[str, Any]]:
|
||||||
|
top_series = similarity_series.iloc[:10]
|
||||||
|
return [
|
||||||
|
{"pipe_id": str(pipe_id), "similarity": float(score)}
|
||||||
|
for pipe_id, score in top_series.items()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def run_burst_location(
|
||||||
|
wn_inp_path: str,
|
||||||
|
pressure_scada_ids: list[str],
|
||||||
|
burst_pressure: pd.Series,
|
||||||
|
normal_pressure: pd.Series,
|
||||||
|
burst_leakage: float,
|
||||||
|
flow_scada_ids: list[str] | None = None,
|
||||||
|
burst_flow: pd.Series | None = None,
|
||||||
|
normal_flow: pd.Series | None = None,
|
||||||
|
min_dpressure: float = 2.0,
|
||||||
|
basic_pressure: float = 10.0,
|
||||||
|
n_workers: int = DEFAULT_N_WORKERS,
|
||||||
|
partition_on_full_graph: bool = True,
|
||||||
|
visualize_partition: bool = False,
|
||||||
|
visualize_pause_seconds: float = 0.3,
|
||||||
|
final_candidates_csv_path: (
|
||||||
|
str | None
|
||||||
|
) = "temp/burst_location/final_round_candidates.csv",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if pressure_scada_ids is None or len(pressure_scada_ids) == 0:
|
||||||
|
raise ValueError("pressure_scada_ids cannot be empty.")
|
||||||
|
if burst_pressure is None or normal_pressure is None:
|
||||||
|
raise ValueError("burst_pressure and normal_pressure are required.")
|
||||||
|
|
||||||
|
has_all_flow, flow_ids = _validate_flow_inputs(
|
||||||
|
flow_scada_ids=flow_scada_ids,
|
||||||
|
burst_flow=burst_flow,
|
||||||
|
normal_flow=normal_flow,
|
||||||
|
)
|
||||||
|
|
||||||
|
inp_path = Path(wn_inp_path)
|
||||||
|
wn = load_inp(
|
||||||
|
inp_name=inp_path.name,
|
||||||
|
inp_location=str(inp_path.parent) + "/",
|
||||||
|
inp_time=0,
|
||||||
|
driven_mode="PDD",
|
||||||
|
require_p=float(basic_pressure),
|
||||||
|
minimum_p=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
(
|
||||||
|
all_node,
|
||||||
|
_,
|
||||||
|
node_coordinates,
|
||||||
|
all_pipe,
|
||||||
|
_,
|
||||||
|
_,
|
||||||
|
pipe_length,
|
||||||
|
pipe_diameter,
|
||||||
|
) = read_inf_inp(wn)
|
||||||
|
|
||||||
|
candidate_pipe, _ = leak_signature.cal_possible_pipe(
|
||||||
|
burst_leakage, all_pipe, pipe_diameter
|
||||||
|
)
|
||||||
|
|
||||||
|
_, pipe_start_node_all, pipe_end_node_all = read_inf_inp_other(wn)
|
||||||
|
node_x, node_y = cal_node_coordinate(all_node, node_coordinates)
|
||||||
|
G0 = construct_graph(wn)
|
||||||
|
node_pipe_dic, couple_node_length = _build_node_pipe_maps(
|
||||||
|
all_node,
|
||||||
|
all_pipe,
|
||||||
|
pipe_start_node_all,
|
||||||
|
pipe_end_node_all,
|
||||||
|
pipe_length,
|
||||||
|
)
|
||||||
|
all_node_series = pd.Series(range(len(all_node)), index=all_node)
|
||||||
|
|
||||||
|
pressure_ids = [str(item) for item in pressure_scada_ids]
|
||||||
|
normal_pressure_aligned = _align_scada_series(
|
||||||
|
normal_pressure, pressure_ids, "normal_pressure"
|
||||||
|
)
|
||||||
|
burst_pressure_aligned = _align_scada_series(
|
||||||
|
burst_pressure, pressure_ids, "burst_pressure"
|
||||||
|
)
|
||||||
|
pressure_normal = normal_pressure_aligned.to_frame().T
|
||||||
|
pressure_monitor = burst_pressure_aligned.to_frame().T
|
||||||
|
pressure_predict = pressure_normal.copy()
|
||||||
|
timestep_list = list(pressure_normal.index)
|
||||||
|
|
||||||
|
if has_all_flow:
|
||||||
|
normal_flow_aligned = _align_scada_series(normal_flow, flow_ids, "normal_flow")
|
||||||
|
burst_flow_aligned = _align_scada_series(burst_flow, flow_ids, "burst_flow")
|
||||||
|
flow_normal = normal_flow_aligned.to_frame().T
|
||||||
|
flow_monitor = burst_flow_aligned.to_frame().T
|
||||||
|
flow_predict = flow_normal.copy()
|
||||||
|
similarity_mode = "CDF"
|
||||||
|
max_flow = flow_normal.iloc[0, :].abs()
|
||||||
|
else:
|
||||||
|
flow_normal = pd.DataFrame(index=timestep_list)
|
||||||
|
flow_monitor = pd.DataFrame(index=timestep_list)
|
||||||
|
flow_predict = pd.DataFrame(index=timestep_list)
|
||||||
|
similarity_mode = "CAD_new_gy"
|
||||||
|
max_flow = pd.Series(dtype=float)
|
||||||
|
|
||||||
|
stage_timing: dict[str, Any] = {}
|
||||||
|
try:
|
||||||
|
(
|
||||||
|
located_pipe,
|
||||||
|
elapsed_seconds,
|
||||||
|
simulation_times,
|
||||||
|
_,
|
||||||
|
similarity_series,
|
||||||
|
exit_condition,
|
||||||
|
final_candidates_csv,
|
||||||
|
) = DN_search_multi_simple_add_flow_count_new(
|
||||||
|
wn=wn,
|
||||||
|
wn_inp_path=str(inp_path),
|
||||||
|
G0=G0,
|
||||||
|
all_node=all_node,
|
||||||
|
node_x=node_x,
|
||||||
|
node_y=node_y,
|
||||||
|
pipe_start_node_all=pipe_start_node_all,
|
||||||
|
pipe_end_node_all=pipe_end_node_all,
|
||||||
|
pipe_diameter=pipe_diameter,
|
||||||
|
couple_node_length=couple_node_length,
|
||||||
|
node_pipe_dic=node_pipe_dic,
|
||||||
|
all_node_series=all_node_series,
|
||||||
|
top_group_ratio=0.3,
|
||||||
|
top_pipe_num_max=80,
|
||||||
|
top_pipe_num_min=10,
|
||||||
|
candidate_pipe_input_initial=candidate_pipe,
|
||||||
|
similarity_mode=similarity_mode,
|
||||||
|
pressure_monitor=pressure_monitor,
|
||||||
|
pressure_predict=pressure_predict,
|
||||||
|
pressure_normal=pressure_normal,
|
||||||
|
pressure_leak_all=None,
|
||||||
|
flow_monitor=flow_monitor,
|
||||||
|
flow_predict=flow_predict,
|
||||||
|
flow_normal=flow_normal,
|
||||||
|
flow_leak_all=None,
|
||||||
|
timestep_list=timestep_list,
|
||||||
|
max_flow=max_flow,
|
||||||
|
group_basic_num=30,
|
||||||
|
Top_sensor_num=min(5, len(pressure_ids)),
|
||||||
|
if_gy=0,
|
||||||
|
pressure_threshold=float(min_dpressure),
|
||||||
|
leak_mag=float(burst_leakage),
|
||||||
|
n_workers=max(1, int(n_workers)),
|
||||||
|
stage_timing=stage_timing,
|
||||||
|
partition_on_full_graph=partition_on_full_graph,
|
||||||
|
visualize_partition=visualize_partition,
|
||||||
|
visualize_pause_seconds=visualize_pause_seconds,
|
||||||
|
final_candidates_csv_path=final_candidates_csv_path,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Burst location algorithm execution failed.")
|
||||||
|
raise RuntimeError(f"Failed to run burst location algorithm: {exc}") from exc
|
||||||
|
|
||||||
|
return {
|
||||||
|
"located_pipe": located_pipe,
|
||||||
|
"burst_leakage": float(burst_leakage),
|
||||||
|
"elapsed_seconds": elapsed_seconds,
|
||||||
|
"simulation_times": int(simulation_times),
|
||||||
|
"top_candidates": _build_top_candidates(similarity_series),
|
||||||
|
"similarity_mode": similarity_mode,
|
||||||
|
"exit_condition": exit_condition,
|
||||||
|
"final_candidates_csv": final_candidates_csv,
|
||||||
|
"stage_timing_seconds": stage_timing,
|
||||||
|
}
|
||||||
@@ -0,0 +1,858 @@
|
|||||||
|
"""相似性计算模块。"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
def cal_similarity_simple_return_dd(
|
||||||
|
similarity_mode,
|
||||||
|
monitor_p,
|
||||||
|
predict_p,
|
||||||
|
normal_p,
|
||||||
|
leak_p,
|
||||||
|
monitor_p_all,
|
||||||
|
predict_p_all,
|
||||||
|
normal_p_all,
|
||||||
|
leak_p_all,
|
||||||
|
important_sensor,
|
||||||
|
mean_dpressure,
|
||||||
|
dpressure_std,
|
||||||
|
dpressure_std_all,
|
||||||
|
if_gy=0,
|
||||||
|
cos_or_flow=1,
|
||||||
|
):
|
||||||
|
# cos_or_flow 用于 CAF
|
||||||
|
dpressure_s = normal_p - leak_p
|
||||||
|
dpressure = predict_p - monitor_p
|
||||||
|
act_dpressure = pd.Series(dtype=object)
|
||||||
|
for i in range(len(leak_p.index)):
|
||||||
|
if dpressure_std.iloc[i] > -200: # 0.001:
|
||||||
|
if if_gy == 1:
|
||||||
|
act_dpressure[leak_p.index[i]] = (
|
||||||
|
leak_p.iloc[i] - monitor_p.iloc[i]
|
||||||
|
) / dpressure_std.iloc[i]
|
||||||
|
else:
|
||||||
|
act_dpressure[leak_p.index[i]] = leak_p.iloc[i] - monitor_p.iloc[i]
|
||||||
|
|
||||||
|
if similarity_mode == "COS" or (similarity_mode == "CAF" and cos_or_flow == 1):
|
||||||
|
|
||||||
|
"""if leak_p.min()<0:
|
||||||
|
none_flag = 1
|
||||||
|
similarity_cos = 0
|
||||||
|
similarity_dis = 0
|
||||||
|
else:"""
|
||||||
|
none_flag = 0
|
||||||
|
sensor_for_cos = sorted(
|
||||||
|
set(dpressure_s.index).intersection(set(act_dpressure.index))
|
||||||
|
)
|
||||||
|
"""if len(dpressure_s) ==0 or len(dpressure) ==0:
|
||||||
|
jj=9
|
||||||
|
else:"""
|
||||||
|
try:
|
||||||
|
s1 = np.dot(
|
||||||
|
np.transpose(dpressure_s.loc[sensor_for_cos]),
|
||||||
|
dpressure.loc[sensor_for_cos],
|
||||||
|
)
|
||||||
|
s2 = np.linalg.norm(dpressure_s.loc[sensor_for_cos]) * np.linalg.norm(
|
||||||
|
dpressure.loc[sensor_for_cos]
|
||||||
|
)
|
||||||
|
if s2 == 0:
|
||||||
|
s2 = s2 + 0.0001
|
||||||
|
similarity_cos = s1 / s2
|
||||||
|
similarity_dis = 0
|
||||||
|
except Exception as e:
|
||||||
|
print(dpressure_s)
|
||||||
|
print(sensor_for_cos)
|
||||||
|
print(act_dpressure)
|
||||||
|
print(dpressure_std)
|
||||||
|
print(dpressure)
|
||||||
|
|
||||||
|
elif similarity_mode == "DIS" or (similarity_mode == "CAF" and cos_or_flow == 2):
|
||||||
|
"""if leak_p.min()<0:
|
||||||
|
none_flag = 1
|
||||||
|
else:"""
|
||||||
|
none_flag = 0
|
||||||
|
important_sensor = sorted(
|
||||||
|
set(important_sensor).intersection(set(act_dpressure.index))
|
||||||
|
)
|
||||||
|
part_dpressure = dpressure_s[important_sensor] - dpressure[important_sensor]
|
||||||
|
similarity_pre_DIS = np.linalg.norm(part_dpressure)
|
||||||
|
# similarity_pre_DIS_later = 1 / (1 + similarity_pre_DIS)
|
||||||
|
similarity_dis = similarity_pre_DIS
|
||||||
|
similarity_cos = 0
|
||||||
|
|
||||||
|
elif similarity_mode == "CAD_new":
|
||||||
|
act_dpressure = leak_p - monitor_p
|
||||||
|
"""if leak_p.min() < 0:
|
||||||
|
none_flag = 1
|
||||||
|
similarity_cos = 0
|
||||||
|
similarity_dis =0
|
||||||
|
else:"""
|
||||||
|
none_flag = 0
|
||||||
|
# cos
|
||||||
|
s1 = np.dot(np.transpose(dpressure_s), dpressure)
|
||||||
|
s2 = np.linalg.norm(dpressure_s) * np.linalg.norm(dpressure)
|
||||||
|
|
||||||
|
if s2 == 0:
|
||||||
|
s2 = s2 + 0.0001
|
||||||
|
similarity_cos = s1 / s2
|
||||||
|
|
||||||
|
# DIS
|
||||||
|
part_dpressure = act_dpressure.loc[important_sensor]
|
||||||
|
similarity_pre_DIS = np.linalg.norm(part_dpressure)
|
||||||
|
similarity_pre_DIS_later = 1 / (1 + similarity_pre_DIS)
|
||||||
|
similarity_dis = similarity_pre_DIS
|
||||||
|
|
||||||
|
elif similarity_mode == "CAD_new_gy" or similarity_mode == "CDF":
|
||||||
|
# cos
|
||||||
|
sensor_for_cos = sorted(
|
||||||
|
set(dpressure_s.index).intersection(set(act_dpressure.index))
|
||||||
|
)
|
||||||
|
if len(sensor_for_cos) == 0 and len(dpressure_s) == 0:
|
||||||
|
similarity_cos = 0
|
||||||
|
elif len(sensor_for_cos) == 0 and len(dpressure_s) > 0:
|
||||||
|
sensor_for_cos = list(dpressure_s.index)
|
||||||
|
none_flag = 0
|
||||||
|
s1 = np.dot(
|
||||||
|
np.transpose(dpressure_s.loc[sensor_for_cos]),
|
||||||
|
dpressure.loc[sensor_for_cos],
|
||||||
|
)
|
||||||
|
s2 = np.linalg.norm(dpressure_s.loc[sensor_for_cos]) * np.linalg.norm(
|
||||||
|
dpressure.loc[sensor_for_cos]
|
||||||
|
)
|
||||||
|
|
||||||
|
if s2 == 0:
|
||||||
|
s2 = s2 + 0.0001
|
||||||
|
similarity_cos = s1 / s2
|
||||||
|
else:
|
||||||
|
none_flag = 0
|
||||||
|
s1 = np.dot(
|
||||||
|
np.transpose(dpressure_s.loc[sensor_for_cos]),
|
||||||
|
dpressure.loc[sensor_for_cos],
|
||||||
|
)
|
||||||
|
s2 = np.linalg.norm(dpressure_s.loc[sensor_for_cos]) * np.linalg.norm(
|
||||||
|
dpressure.loc[sensor_for_cos]
|
||||||
|
)
|
||||||
|
|
||||||
|
if s2 == 0:
|
||||||
|
s2 = s2 + 0.0001
|
||||||
|
similarity_cos = s1 / s2
|
||||||
|
|
||||||
|
# DIS
|
||||||
|
important_sensor_new = sorted(
|
||||||
|
set(important_sensor).intersection(set(act_dpressure.index))
|
||||||
|
)
|
||||||
|
if len(important_sensor_new) == 0:
|
||||||
|
important_sensor_new = important_sensor
|
||||||
|
act_dpressure = pd.Series(dtype=object)
|
||||||
|
for i in range(len(leak_p_all.index)):
|
||||||
|
# if dpressure_std.iloc [i] > -200: # 0.001:
|
||||||
|
if if_gy == 1:
|
||||||
|
act_dpressure[leak_p_all.index[i]] = (
|
||||||
|
leak_p_all.iloc[i] - monitor_p_all.iloc[i]
|
||||||
|
) / dpressure_std_all.iloc[i]
|
||||||
|
else:
|
||||||
|
act_dpressure[leak_p_all.index[i]] = (
|
||||||
|
leak_p_all.iloc[i] - monitor_p_all.iloc[i]
|
||||||
|
)
|
||||||
|
# part_dpressure = act_dpressure.loc[important_sensor_new]
|
||||||
|
|
||||||
|
part_dpressure = (
|
||||||
|
dpressure.loc[important_sensor_new] - dpressure_s.loc[important_sensor_new]
|
||||||
|
)
|
||||||
|
similarity_pre_DIS = np.linalg.norm(part_dpressure) ## chang test
|
||||||
|
# part_dpressure = dpressure_s.loc[important_sensor]-dpressure.loc[important_sensor]
|
||||||
|
# similarity_pre_DIS = np.linalg.norm(part_dpressure)
|
||||||
|
# similarity_pre_DIS_later = 1 / (1 + similarity_pre_DIS)
|
||||||
|
similarity_dis = similarity_pre_DIS
|
||||||
|
elif similarity_mode == "OF":
|
||||||
|
# cos
|
||||||
|
similarity_cos = 0
|
||||||
|
none_flag = 0
|
||||||
|
# DIS
|
||||||
|
important_sensor_new = sorted(
|
||||||
|
set(important_sensor).intersection(set(act_dpressure.index))
|
||||||
|
)
|
||||||
|
if len(important_sensor_new) == 0:
|
||||||
|
important_sensor_new = important_sensor
|
||||||
|
act_dpressure = pd.Series(dtype=object)
|
||||||
|
for i in range(len(leak_p_all.index)):
|
||||||
|
# if dpressure_std.iloc [i] > -200: # 0.001:
|
||||||
|
if if_gy == 1:
|
||||||
|
act_dpressure[leak_p_all.index[i]] = (
|
||||||
|
leak_p_all.iloc[i] - monitor_p_all.iloc[i]
|
||||||
|
) / dpressure_std_all.iloc[i]
|
||||||
|
else:
|
||||||
|
act_dpressure[leak_p_all.index[i]] = (
|
||||||
|
leak_p_all.iloc[i] - monitor_p_all.iloc[i]
|
||||||
|
)
|
||||||
|
# part_dpressure = act_dpressure.loc[important_sensor_new]
|
||||||
|
|
||||||
|
part_dpressure = (
|
||||||
|
dpressure.loc[important_sensor_new] - dpressure_s.loc[important_sensor_new]
|
||||||
|
)
|
||||||
|
similarity_pre_DIS = np.linalg.norm(part_dpressure) ## chang test
|
||||||
|
# part_dpressure = dpressure_s.loc[important_sensor]-dpressure.loc[important_sensor]
|
||||||
|
# similarity_pre_DIS = np.linalg.norm(part_dpressure)
|
||||||
|
# similarity_pre_DIS_later = 1 / (1 + similarity_pre_DIS)
|
||||||
|
similarity_dis = similarity_pre_DIS
|
||||||
|
|
||||||
|
return similarity_cos, similarity_dis, none_flag
|
||||||
|
|
||||||
|
|
||||||
|
def adjust(
|
||||||
|
similarity_cos,
|
||||||
|
similarity_dis,
|
||||||
|
record_success_candidate,
|
||||||
|
record_success_no_candidate,
|
||||||
|
):
|
||||||
|
if len(record_success_no_candidate) > 0:
|
||||||
|
for each in record_success_no_candidate:
|
||||||
|
similarity_cos[each] = similarity_cos[record_success_candidate].min() * 0.9
|
||||||
|
similarity_dis[each] = similarity_dis[record_success_candidate].max() * 1.1
|
||||||
|
return similarity_cos, similarity_dis
|
||||||
|
|
||||||
|
|
||||||
|
def cal_sq_all_multi(
|
||||||
|
similarity_cos,
|
||||||
|
similarity_dis,
|
||||||
|
similarity_f,
|
||||||
|
candidate_pipe,
|
||||||
|
timestep_list_spc,
|
||||||
|
if_flow,
|
||||||
|
if_only_cos,
|
||||||
|
if_only_flow,
|
||||||
|
cos_h_input,
|
||||||
|
dis_h_input,
|
||||||
|
dis_f_h_input,
|
||||||
|
if_compalsive,
|
||||||
|
cos_sensor_num,
|
||||||
|
flow_sensor_num,
|
||||||
|
):
|
||||||
|
"""融合多种相似性并输出按时刻与候选管段组织的综合相似度。
|
||||||
|
|
||||||
|
该函数会根据模式开关(是否仅流量、是否仅 COS、是否包含流量)对
|
||||||
|
`similarity_cos`、`similarity_dis`、`similarity_f` 做标准化,并计算
|
||||||
|
权重 `sq_cos/sq_dis/sq_f` 后进行加权融合。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
similarity_cos: 压力余弦相似性(DataFrame/Series,通常为时刻 x 候选管段)。
|
||||||
|
similarity_dis: 压力距离相似性(DataFrame/Series,通常为时刻 x 候选管段)。
|
||||||
|
similarity_f: 流量距离相似性(DataFrame/Series,通常为时刻 x 候选管段)。
|
||||||
|
candidate_pipe: 候选管段列表,用于输出列索引。
|
||||||
|
timestep_list_spc: 时刻列表,用于输出行索引。
|
||||||
|
if_flow: 是否启用流量相似性(1 启用,0 禁用)。
|
||||||
|
if_only_cos: 相似性模式标识(0: COS+DIS;1: COS;其他值按分支定义处理)。
|
||||||
|
if_only_flow: 是否仅使用流量相似性(1 是,0 否)。
|
||||||
|
cos_h_input: 外部给定的 COS 权重(强制权重模式下使用)。
|
||||||
|
dis_h_input: 外部给定的 DIS 权重(强制权重模式下使用)。
|
||||||
|
dis_f_h_input: 外部给定的流量权重(强制权重模式下使用)。
|
||||||
|
if_compalsive: 是否使用外部强制权重(1 使用输入权重,0 自动计算权重)。
|
||||||
|
cos_sensor_num: 压力传感器数量,用于权重调整。
|
||||||
|
flow_sensor_num: 流量传感器数量,用于权重调整。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple[pd.DataFrame | pd.Series, float, float, float]:
|
||||||
|
- output_similarity_pd: 综合相似性结果。
|
||||||
|
- sq_cos: 最终 COS 权重。
|
||||||
|
- sq_dis: 最终 DIS 权重。
|
||||||
|
- sq_f: 最终流量权重。
|
||||||
|
"""
|
||||||
|
if if_only_flow == 1:
|
||||||
|
similarity_f, h_f = cal_sq_single_array(
|
||||||
|
similarity_f.values.reshape((-1, 1)), if_direct=2
|
||||||
|
)
|
||||||
|
sq_cos = 0
|
||||||
|
sq_dis = 0
|
||||||
|
sq_f = 1
|
||||||
|
similarity_all = similarity_f * sq_f
|
||||||
|
output_similarity = similarity_all.reshape((-1, len(candidate_pipe)))
|
||||||
|
output_similarity_pd = pd.DataFrame(
|
||||||
|
output_similarity, index=timestep_list_spc, columns=candidate_pipe
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if if_only_cos == 0:
|
||||||
|
if if_flow == 1:
|
||||||
|
# standerdize
|
||||||
|
similarity_cos, h_cos = cal_sq_single_array(
|
||||||
|
similarity_cos.values.reshape((-1, 1)), if_direct=1
|
||||||
|
)
|
||||||
|
similarity_dis, h_dis = cal_sq_single_array(
|
||||||
|
similarity_dis.values.reshape((-1, 1)), if_direct=2
|
||||||
|
)
|
||||||
|
similarity_f, h_f = cal_sq_single_array(
|
||||||
|
similarity_f.values.reshape((-1, 1)), if_direct=2
|
||||||
|
)
|
||||||
|
if if_compalsive == 1:
|
||||||
|
sq_cos = cos_h_input
|
||||||
|
sq_dis = dis_h_input
|
||||||
|
sq_f = dis_f_h_input
|
||||||
|
else:
|
||||||
|
"""sq_cos = h_cos/(h_cos +h_dis +h_f )
|
||||||
|
sq_dis = h_dis/(h_cos +h_dis +h_f )
|
||||||
|
sq_f = h_f/(h_cos +h_dis +h_f )"""
|
||||||
|
sq_cos, sq_dis, sq_f = add_weight_for_SQ(
|
||||||
|
h_cos, h_dis, h_f, cos_sensor_num, flow_sensor_num
|
||||||
|
)
|
||||||
|
|
||||||
|
"""if cos_sensor_num == 2 and sq_cos>0.2:
|
||||||
|
sq_cos = 0.2
|
||||||
|
sq_dis = 0.8*h_dis / (h_dis + h_f)
|
||||||
|
sq_f = 0.8*h_f / (h_dis + h_f)
|
||||||
|
if cos_sensor_num == 1 and sq_dis > 0.3:
|
||||||
|
sq_cos = 0.1
|
||||||
|
sq_dis = 0.3
|
||||||
|
sq_f = 0.6"""
|
||||||
|
sq_cos, sq_dis, sq_f = adjust_ratio("CDF", sq_cos, sq_dis, sq_f)
|
||||||
|
if cos_sensor_num <= 1:
|
||||||
|
sq_cos = 0
|
||||||
|
# similarity
|
||||||
|
|
||||||
|
similarity_all = (
|
||||||
|
similarity_cos * sq_cos
|
||||||
|
+ similarity_dis * sq_dis
|
||||||
|
+ similarity_f * sq_f
|
||||||
|
)
|
||||||
|
output_similarity = similarity_all.reshape((-1, len(candidate_pipe)))
|
||||||
|
output_similarity_pd = pd.DataFrame(
|
||||||
|
output_similarity, index=timestep_list_spc, columns=candidate_pipe
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# standerdize
|
||||||
|
similarity_cos, h_cos = cal_sq_single_array(
|
||||||
|
similarity_cos.values.reshape((-1, 1)), if_direct=1
|
||||||
|
)
|
||||||
|
similarity_dis, h_dis = cal_sq_single_array(
|
||||||
|
similarity_dis.values.reshape((-1, 1)), if_direct=2
|
||||||
|
)
|
||||||
|
if if_compalsive == 1:
|
||||||
|
sq_cos = cos_h_input
|
||||||
|
sq_dis = dis_h_input
|
||||||
|
else:
|
||||||
|
sq_cos = h_cos / (h_cos + h_dis)
|
||||||
|
sq_dis = h_dis / (h_cos + h_dis)
|
||||||
|
if cos_sensor_num == 2 and sq_cos > 0.5:
|
||||||
|
sq_cos = 0.5
|
||||||
|
sq_dis = 0.5
|
||||||
|
sq_cos, sq_dis, sq_f = adjust_ratio("CAD_new_gy", sq_cos, sq_dis, 0)
|
||||||
|
sq_f = 0
|
||||||
|
# similarity
|
||||||
|
similarity_all = similarity_cos * sq_cos + similarity_dis * sq_dis
|
||||||
|
output_similarity = similarity_all.reshape((-1, len(candidate_pipe)))
|
||||||
|
output_similarity_pd = pd.DataFrame(
|
||||||
|
output_similarity, index=timestep_list_spc, columns=candidate_pipe
|
||||||
|
)
|
||||||
|
elif if_only_cos == 1:
|
||||||
|
if if_flow == 1:
|
||||||
|
# standerdize
|
||||||
|
similarity_cos, h_cos = cal_sq_single_array(
|
||||||
|
similarity_cos.values.reshape((-1, 1)), if_direct=1
|
||||||
|
)
|
||||||
|
similarity_f, h_f = cal_sq_single_array(
|
||||||
|
similarity_f.values.reshape((-1, 1)), if_direct=2
|
||||||
|
)
|
||||||
|
if if_compalsive == 1:
|
||||||
|
sq_cos = cos_h_input
|
||||||
|
sq_f = dis_f_h_input
|
||||||
|
else:
|
||||||
|
sq_cos = h_cos / (h_cos + h_f)
|
||||||
|
sq_f = h_f / (h_cos + h_f)
|
||||||
|
sq_cos, sq_dis, sq_f = adjust_ratio("CAF", sq_cos, 0, sq_f)
|
||||||
|
sq_dis = 0
|
||||||
|
# similarity
|
||||||
|
similarity_all = similarity_cos * sq_cos + similarity_f * sq_f
|
||||||
|
output_similarity = similarity_all.reshape((-1, len(candidate_pipe)))
|
||||||
|
output_similarity_pd = pd.DataFrame(
|
||||||
|
output_similarity, index=timestep_list_spc, columns=candidate_pipe
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
sq_cos = cos_h_input
|
||||||
|
sq_dis = dis_h_input
|
||||||
|
sq_f = dis_f_h_input
|
||||||
|
output_similarity_pd = similarity_cos
|
||||||
|
else:
|
||||||
|
sq_cos = cos_h_input
|
||||||
|
sq_dis = dis_h_input
|
||||||
|
sq_f = dis_f_h_input
|
||||||
|
output_similarity_pd = 1 / (similarity_dis + 1)
|
||||||
|
return output_similarity_pd, sq_cos, sq_dis, sq_f
|
||||||
|
|
||||||
|
|
||||||
|
def add_weight_for_SQ(h_cos, h_dis, h_f, sensor_cos_num, sensor_f_num):
|
||||||
|
h_f_new = h_f * sensor_f_num
|
||||||
|
if sensor_cos_num <= 1:
|
||||||
|
h_cos_new = 0
|
||||||
|
h_dis_new = h_dis * sensor_cos_num
|
||||||
|
else:
|
||||||
|
h_cos_new = h_cos * sensor_cos_num # / 2
|
||||||
|
h_dis_new = h_dis * sensor_cos_num # / 2
|
||||||
|
cos_sq = h_cos_new / (h_cos_new + h_dis_new + h_f_new)
|
||||||
|
dis_sq = h_dis_new / (h_cos_new + h_dis_new + h_f_new)
|
||||||
|
f_sq = h_f_new / (h_cos_new + h_dis_new + h_f_new)
|
||||||
|
|
||||||
|
if sensor_cos_num == 2 and cos_sq > 0.2:
|
||||||
|
cos_sq = 0.2
|
||||||
|
dis_sq = 0.8 * h_dis_new / (h_dis_new + h_f_new)
|
||||||
|
f_sq = 0.8 * h_f_new / (h_dis_new + h_f_new)
|
||||||
|
"""if sensor_cos_num == 1:
|
||||||
|
if dis_sq / f_sq > sensor_cos_num/sensor_f_num:
|
||||||
|
dis_sq = sensor_cos_num/sensor_f_num
|
||||||
|
f_sq=1-dis_sq"""
|
||||||
|
# if h_dis_new/h_f_new > sensor_cos_num/sensor_f_num
|
||||||
|
return cos_sq, dis_sq, f_sq
|
||||||
|
|
||||||
|
|
||||||
|
def cal_sq_single_array(similarity_pre, if_direct):
|
||||||
|
if similarity_pre.max() - similarity_pre.min() == 0:
|
||||||
|
similarity_pre = np.ones(similarity_pre.shape)
|
||||||
|
else:
|
||||||
|
if if_direct == 1:
|
||||||
|
similarity_pre = (
|
||||||
|
0.998
|
||||||
|
* (similarity_pre - similarity_pre.min())
|
||||||
|
/ (similarity_pre.max() - similarity_pre.min())
|
||||||
|
+ 0.002
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
similarity_pre = (
|
||||||
|
0.998
|
||||||
|
* (similarity_pre.max() - similarity_pre)
|
||||||
|
/ (similarity_pre.max() - similarity_pre.min())
|
||||||
|
+ 0.002
|
||||||
|
)
|
||||||
|
# calculate pij
|
||||||
|
similarity_p = similarity_pre / similarity_pre.sum()
|
||||||
|
# cal xinxishang
|
||||||
|
similarity_lnp = np.zeros((len(similarity_pre), 1))
|
||||||
|
for j in range(len(similarity_p)):
|
||||||
|
similarity_lnp[j] = -similarity_p[j] * math.log(similarity_p[j], math.e)
|
||||||
|
h = 1 - 1 / math.log(len(similarity_pre), math.e) * similarity_lnp.sum()
|
||||||
|
return similarity_pre, h
|
||||||
|
|
||||||
|
|
||||||
|
def cal_similarity_all_multi_new_sq_improve_double_lzr(
|
||||||
|
candidate_pipe,
|
||||||
|
similarity_mode,
|
||||||
|
pressure_leak,
|
||||||
|
monitor_p,
|
||||||
|
predict_p,
|
||||||
|
normal_p,
|
||||||
|
if_flow,
|
||||||
|
if_only_cos,
|
||||||
|
if_only_flow,
|
||||||
|
flow_leak,
|
||||||
|
monitor_f,
|
||||||
|
predict_f,
|
||||||
|
normal_f,
|
||||||
|
timestep_list,
|
||||||
|
Top_sensor_num,
|
||||||
|
if_gy,
|
||||||
|
effective_sensor,
|
||||||
|
cos_h,
|
||||||
|
dis_h,
|
||||||
|
dis_f_h,
|
||||||
|
if_compalsive,
|
||||||
|
max_flow,
|
||||||
|
):
|
||||||
|
similarity = pd.Series(dtype=float, index=candidate_pipe)
|
||||||
|
similarity_detail: pd.DataFrame | None = None
|
||||||
|
important_p_sensor = cal_top_sensors(monitor_p, predict_p, Top_sensor_num)
|
||||||
|
# important_f_sensor, basic_f = cal_top_f_sensor(normal_f)
|
||||||
|
important_f_sensor = monitor_f.columns
|
||||||
|
if (
|
||||||
|
len(important_p_sensor) > 0 or len(important_f_sensor) > 0
|
||||||
|
): # if len(important_p_sensor) > 0
|
||||||
|
break_flag = 0
|
||||||
|
pressure_leak_new = pressure_leak.swaplevel()
|
||||||
|
# flow_leak_new = flow_leak.swaplevel()
|
||||||
|
if isinstance(flow_leak, pd.DataFrame) and len(flow_leak) > 0:
|
||||||
|
flow_leak_new = flow_leak.swaplevel()
|
||||||
|
else:
|
||||||
|
flow_leak_new = None
|
||||||
|
total_similarity_cos = pd.DataFrame(index=timestep_list, columns=candidate_pipe)
|
||||||
|
total_similarity_dis = pd.DataFrame(index=timestep_list, columns=candidate_pipe)
|
||||||
|
total_similarity_dis_f = pd.DataFrame(
|
||||||
|
index=timestep_list, columns=candidate_pipe
|
||||||
|
)
|
||||||
|
|
||||||
|
for timestep in timestep_list:
|
||||||
|
# cal p_cos, p_dis, f_dis
|
||||||
|
if if_only_flow != 1:
|
||||||
|
pressure_leak_temp = pressure_leak_new.loc[timestep].loc[
|
||||||
|
:, effective_sensor
|
||||||
|
]
|
||||||
|
monitor_p_temp = monitor_p.loc[timestep, effective_sensor]
|
||||||
|
predict_p_temp = predict_p.loc[timestep, effective_sensor]
|
||||||
|
normal_p_temp = normal_p.loc[timestep, effective_sensor]
|
||||||
|
|
||||||
|
(
|
||||||
|
total_similarity_cos.loc[timestep, :],
|
||||||
|
total_similarity_dis.loc[timestep, :],
|
||||||
|
) = cal_similarity_all_cos_dis(
|
||||||
|
candidate_pipe,
|
||||||
|
pressure_leak_temp,
|
||||||
|
similarity_mode,
|
||||||
|
monitor_p_temp,
|
||||||
|
predict_p_temp,
|
||||||
|
normal_p_temp,
|
||||||
|
pressure_leak_new.loc[timestep].loc[:, monitor_p.columns],
|
||||||
|
monitor_p.loc[timestep, :],
|
||||||
|
predict_p.loc[timestep, :],
|
||||||
|
normal_p.loc[timestep, :],
|
||||||
|
important_p_sensor,
|
||||||
|
if_gy,
|
||||||
|
cos_or_flow=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
if if_flow == 1:
|
||||||
|
if len(timestep_list) == 1:
|
||||||
|
leak_f_temp = flow_leak_new.loc[timestep].loc[:, important_f_sensor]
|
||||||
|
monitor_f_temp = monitor_f.loc[timestep, important_f_sensor]
|
||||||
|
predict_f_temp = predict_f.loc[timestep, important_f_sensor]
|
||||||
|
normal_f_temp = normal_f.loc[timestep, important_f_sensor]
|
||||||
|
basic_normal_f_temp = abs(max_flow.loc[important_f_sensor])
|
||||||
|
|
||||||
|
leak_f_temp = leak_f_temp / basic_normal_f_temp
|
||||||
|
monitor_f_temp = monitor_f_temp / basic_normal_f_temp
|
||||||
|
predict_f_temp = predict_f_temp / basic_normal_f_temp
|
||||||
|
normal_f_temp = normal_f_temp / basic_normal_f_temp
|
||||||
|
|
||||||
|
else:
|
||||||
|
basic_f = abs(max_flow.loc[important_f_sensor])
|
||||||
|
leak_f_temp = (
|
||||||
|
flow_leak_new.loc[timestep].loc[:, important_f_sensor] / basic_f
|
||||||
|
)
|
||||||
|
monitor_f_temp = (
|
||||||
|
monitor_f.loc[timestep, important_f_sensor] / basic_f
|
||||||
|
)
|
||||||
|
predict_f_temp = (
|
||||||
|
predict_f.loc[timestep, important_f_sensor] / basic_f
|
||||||
|
)
|
||||||
|
normal_f_temp = normal_f.loc[timestep, important_f_sensor] / basic_f
|
||||||
|
_, total_similarity_dis_f.loc[timestep, :] = cal_similarity_all_cos_dis(
|
||||||
|
candidate_pipe,
|
||||||
|
leak_f_temp,
|
||||||
|
similarity_mode,
|
||||||
|
monitor_f_temp,
|
||||||
|
predict_f_temp,
|
||||||
|
normal_f_temp,
|
||||||
|
flow_leak_new.loc[timestep].loc[:, monitor_f.columns],
|
||||||
|
monitor_f.loc[timestep, :],
|
||||||
|
predict_f.loc[timestep, :],
|
||||||
|
normal_f.loc[timestep, :],
|
||||||
|
important_f_sensor,
|
||||||
|
if_gy,
|
||||||
|
cos_or_flow=2,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
total_similarity_dis_f = []
|
||||||
|
similarity_all, cos_h, dis_h, dis_f_h = cal_sq_all_multi(
|
||||||
|
total_similarity_cos,
|
||||||
|
total_similarity_dis,
|
||||||
|
total_similarity_dis_f,
|
||||||
|
candidate_pipe,
|
||||||
|
timestep_list,
|
||||||
|
if_flow,
|
||||||
|
if_only_cos,
|
||||||
|
if_only_flow,
|
||||||
|
cos_h,
|
||||||
|
dis_h,
|
||||||
|
dis_f_h,
|
||||||
|
if_compalsive,
|
||||||
|
len(important_p_sensor),
|
||||||
|
len(important_f_sensor),
|
||||||
|
)
|
||||||
|
if len(timestep_list) == 1:
|
||||||
|
similarity = similarity_all.iloc[0]
|
||||||
|
elif len(timestep_list) > 3:
|
||||||
|
for each_candidate in candidate_pipe:
|
||||||
|
similarity[each_candidate] = remove_3_sigma(
|
||||||
|
similarity_all.loc[:, each_candidate]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for each_candidate in candidate_pipe:
|
||||||
|
similarity[each_candidate] = similarity_all.loc[
|
||||||
|
:, each_candidate
|
||||||
|
].mean()
|
||||||
|
similarity = similarity.sort_values(ascending=False, kind="mergesort")
|
||||||
|
detail_index = [str(pipe) for pipe in candidate_pipe]
|
||||||
|
similarity_detail = pd.DataFrame(index=detail_index)
|
||||||
|
similarity_detail.index.name = "pipe_id"
|
||||||
|
if isinstance(total_similarity_cos, pd.DataFrame) and len(total_similarity_cos) > 0:
|
||||||
|
pressure_cos_mean = (
|
||||||
|
total_similarity_cos.mean(axis=0)
|
||||||
|
.reindex(candidate_pipe)
|
||||||
|
.to_numpy(dtype=float)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
pressure_cos_mean = np.full(len(candidate_pipe), np.nan)
|
||||||
|
if isinstance(total_similarity_dis, pd.DataFrame) and len(total_similarity_dis) > 0:
|
||||||
|
pressure_dis_mean = (
|
||||||
|
total_similarity_dis.mean(axis=0)
|
||||||
|
.reindex(candidate_pipe)
|
||||||
|
.to_numpy(dtype=float)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
pressure_dis_mean = np.full(len(candidate_pipe), np.nan)
|
||||||
|
if isinstance(total_similarity_dis_f, pd.DataFrame) and len(total_similarity_dis_f) > 0:
|
||||||
|
flow_dis_mean = (
|
||||||
|
total_similarity_dis_f.mean(axis=0)
|
||||||
|
.reindex(candidate_pipe)
|
||||||
|
.to_numpy(dtype=float)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
flow_dis_mean = np.full(len(candidate_pipe), np.nan)
|
||||||
|
similarity_detail["pressure_cos_mean"] = pressure_cos_mean
|
||||||
|
similarity_detail["pressure_dis_mean"] = pressure_dis_mean
|
||||||
|
similarity_detail["flow_dis_mean"] = flow_dis_mean
|
||||||
|
similarity_detail["weight_cos"] = float(cos_h)
|
||||||
|
similarity_detail["weight_dis"] = float(dis_h)
|
||||||
|
similarity_detail["weight_flow"] = float(dis_f_h)
|
||||||
|
similarity_detail["final_similarity"] = (
|
||||||
|
similarity.reindex(candidate_pipe).to_numpy(dtype=float)
|
||||||
|
)
|
||||||
|
similarity_detail["similarity_rank"] = (
|
||||||
|
similarity_detail["final_similarity"].rank(method="dense", ascending=False)
|
||||||
|
).astype(int)
|
||||||
|
similarity_detail["pressure_sensor_count"] = int(len(important_p_sensor))
|
||||||
|
similarity_detail["flow_sensor_count"] = int(len(important_f_sensor))
|
||||||
|
similarity_detail = similarity_detail.sort_values(
|
||||||
|
by="final_similarity", ascending=False, kind="mergesort"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
break_flag = 1
|
||||||
|
similarity = 0
|
||||||
|
cos_h = 0
|
||||||
|
dis_h = 0
|
||||||
|
dis_f_h = 0
|
||||||
|
return similarity, cos_h, dis_h, dis_f_h, break_flag, similarity_detail
|
||||||
|
|
||||||
|
|
||||||
|
def cal_similarity_all_cos_dis(
|
||||||
|
candidate_pipe,
|
||||||
|
pressure_leak,
|
||||||
|
similarity_mode,
|
||||||
|
monitor_p,
|
||||||
|
predict_p,
|
||||||
|
normal_p,
|
||||||
|
pressure_leak_all,
|
||||||
|
monitor_p_all,
|
||||||
|
predict_p_all,
|
||||||
|
normal_p_all,
|
||||||
|
important_sensor,
|
||||||
|
if_gy,
|
||||||
|
cos_or_flow,
|
||||||
|
):
|
||||||
|
similarity_cos = pd.Series(dtype=float, index=candidate_pipe)
|
||||||
|
similarity_dis = pd.Series(dtype=float, index=candidate_pipe)
|
||||||
|
dpressure = normal_p - pressure_leak
|
||||||
|
|
||||||
|
# 无用 ----------------------------------------------
|
||||||
|
mean_dpressure = dpressure.mean()
|
||||||
|
|
||||||
|
monitor_new = pd.DataFrame(index=["monitor"], columns=monitor_p.index)
|
||||||
|
monitor_new.iloc[0] = monitor_p
|
||||||
|
add_m_leak_pressure = [pressure_leak, monitor_p]
|
||||||
|
add_m_leak_pressure = pd.concat(add_m_leak_pressure)
|
||||||
|
pressure_leak_std = add_m_leak_pressure.std(axis=0, ddof=1)
|
||||||
|
pressure_leak_std = pd.Series(pressure_leak_std, index=pressure_leak.columns)
|
||||||
|
|
||||||
|
add_m_leak_pressure_all = [pressure_leak_all, monitor_p_all]
|
||||||
|
add_m_leak_pressure_all = pd.concat(add_m_leak_pressure_all)
|
||||||
|
pressure_leak_std_all = add_m_leak_pressure_all.std(axis=0, ddof=1)
|
||||||
|
pressure_leak_std_all = pd.Series(
|
||||||
|
pressure_leak_std_all, index=pressure_leak.columns
|
||||||
|
)
|
||||||
|
# 无用 ----------------------------------------------
|
||||||
|
|
||||||
|
monitor_p_temp = monitor_p
|
||||||
|
predict_p_temp = predict_p
|
||||||
|
normal_p_temp = normal_p
|
||||||
|
monitor_p_temp_all = monitor_p_all
|
||||||
|
predict_p_temp_all = predict_p_all
|
||||||
|
normal_p_temp_all = normal_p_all
|
||||||
|
record_success_candidate = []
|
||||||
|
record_success_no_candidate = []
|
||||||
|
for i in range(len(candidate_pipe)):
|
||||||
|
leak_p = pressure_leak.iloc[i, :]
|
||||||
|
leak_p_all = pressure_leak_all.iloc[i, :]
|
||||||
|
similarity_cos.iloc[i], similarity_dis.iloc[i], none_flag = (
|
||||||
|
cal_similarity_simple_return_dd(
|
||||||
|
similarity_mode,
|
||||||
|
monitor_p_temp,
|
||||||
|
predict_p_temp,
|
||||||
|
normal_p_temp,
|
||||||
|
leak_p,
|
||||||
|
monitor_p_temp_all,
|
||||||
|
predict_p_temp_all,
|
||||||
|
normal_p_temp_all,
|
||||||
|
leak_p_all,
|
||||||
|
important_sensor,
|
||||||
|
mean_dpressure,
|
||||||
|
pressure_leak_std,
|
||||||
|
pressure_leak_std_all,
|
||||||
|
if_gy,
|
||||||
|
cos_or_flow,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if none_flag == 0:
|
||||||
|
record_success_candidate.append(candidate_pipe[i])
|
||||||
|
else:
|
||||||
|
record_success_no_candidate.append(candidate_pipe[i])
|
||||||
|
similarity_cos, similarity_dis = adjust(
|
||||||
|
similarity_cos,
|
||||||
|
similarity_dis,
|
||||||
|
record_success_candidate,
|
||||||
|
record_success_no_candidate,
|
||||||
|
)
|
||||||
|
return similarity_cos, similarity_dis
|
||||||
|
|
||||||
|
|
||||||
|
def cal_top_f_sensor(normal_f):
|
||||||
|
if type(normal_f) == pd.core.frame.DataFrame:
|
||||||
|
mean_f = normal_f.mean()
|
||||||
|
else:
|
||||||
|
mean_f = normal_f
|
||||||
|
output_sensor = []
|
||||||
|
output_normal_f = pd.Series(dtype=object)
|
||||||
|
for i in range(len(mean_f.index)):
|
||||||
|
if abs(mean_f.iloc[i]) > 0.01 / 3600:
|
||||||
|
output_sensor.append(mean_f.index[i])
|
||||||
|
output_normal_f[mean_f.index[i]] = mean_f.iloc[i]
|
||||||
|
return output_sensor, output_normal_f
|
||||||
|
|
||||||
|
|
||||||
|
def cal_top_sensors(monitor_p, predict_p, Top_sensor_num):
|
||||||
|
dpressure = abs(predict_p - monitor_p)
|
||||||
|
if type(dpressure) == pd.core.frame.DataFrame:
|
||||||
|
dpressure = dpressure.mean()
|
||||||
|
dpressure_rank = dpressure.sort_values(ascending=False, kind="mergesort")
|
||||||
|
return list(dpressure_rank.index[:Top_sensor_num])
|
||||||
|
|
||||||
|
|
||||||
|
def remove_3_sigma(similarity_t):
|
||||||
|
all_sample = len(similarity_t.index)
|
||||||
|
apart_sample = math.ceil(all_sample * 0.6)
|
||||||
|
similarity = similarity_t.astype("float")
|
||||||
|
mean_t = similarity.mean()
|
||||||
|
std_t = similarity.std()
|
||||||
|
new_similarity = similarity[
|
||||||
|
(similarity <= mean_t + 3 * std_t) & (similarity >= mean_t - 3 * std_t)
|
||||||
|
]
|
||||||
|
mean_t_new = new_similarity.mean()
|
||||||
|
return mean_t_new
|
||||||
|
|
||||||
|
|
||||||
|
def update_similarity(leak_candidate_center, similarity, leak_center_dict):
|
||||||
|
similarity_new = pd.Series(dtype=float)
|
||||||
|
for each_center in leak_candidate_center:
|
||||||
|
houxuan_center = leak_center_dict[each_center]
|
||||||
|
if len(houxuan_center) > 1:
|
||||||
|
temp_similarity = similarity[houxuan_center]
|
||||||
|
similarity_new[each_center] = temp_similarity.max()
|
||||||
|
else:
|
||||||
|
if type(similarity[each_center]) == pd.core.series.Series:
|
||||||
|
similarity_new[each_center] = similarity[each_center].mean()
|
||||||
|
else:
|
||||||
|
similarity_new[each_center] = similarity[each_center]
|
||||||
|
similarity_new = similarity_new.sort_values(ascending=False, kind="mergesort")
|
||||||
|
return similarity_new
|
||||||
|
|
||||||
|
|
||||||
|
def extra_judge(
|
||||||
|
similarity, min_candidates_to_prune: int = 200, std_relax_factor: float = 0.5
|
||||||
|
):
|
||||||
|
if len(similarity.index) == 0:
|
||||||
|
return 1.0, similarity
|
||||||
|
if len(similarity.index) < int(min_candidates_to_prune):
|
||||||
|
return 1.0, similarity
|
||||||
|
|
||||||
|
mean_similarity = float(similarity.mean())
|
||||||
|
std_similarity = float(similarity.std())
|
||||||
|
if not math.isfinite(std_similarity):
|
||||||
|
std_similarity = 0.0
|
||||||
|
|
||||||
|
threshold = mean_similarity - float(std_relax_factor) * std_similarity
|
||||||
|
out_put_similarity = similarity[similarity >= threshold - 1e-10]
|
||||||
|
if len(out_put_similarity.index) == 0:
|
||||||
|
out_put_similarity = similarity.iloc[:1]
|
||||||
|
|
||||||
|
cut_ratio = len(out_put_similarity.index) / len(similarity.index)
|
||||||
|
return cut_ratio, out_put_similarity
|
||||||
|
|
||||||
|
|
||||||
|
def adjust_ratio(similarity_mode, cos_h, dis_h, dis_f_h, low_limit=0.1):
|
||||||
|
if similarity_mode == "CAF":
|
||||||
|
if cos_h < low_limit:
|
||||||
|
cos_h = low_limit
|
||||||
|
dis_f_h = 1 - cos_h
|
||||||
|
elif dis_f_h < low_limit:
|
||||||
|
dis_f_h = low_limit
|
||||||
|
cos_h = 1 - dis_f_h
|
||||||
|
elif similarity_mode == "CAD_new_gy":
|
||||||
|
if dis_h < low_limit:
|
||||||
|
dis_h = low_limit
|
||||||
|
cos_h = 1 - dis_h
|
||||||
|
elif cos_h < low_limit:
|
||||||
|
cos_h = low_limit
|
||||||
|
dis_h = 1 - cos_h
|
||||||
|
elif similarity_mode == "CDF":
|
||||||
|
normal_index = [0, 1, 2]
|
||||||
|
h_list = [cos_h, dis_h, dis_f_h]
|
||||||
|
if cos_h < low_limit:
|
||||||
|
h_list[0] = low_limit
|
||||||
|
normal_index.remove(0)
|
||||||
|
if dis_h < low_limit:
|
||||||
|
h_list[1] = low_limit
|
||||||
|
normal_index.remove(1)
|
||||||
|
if dis_f_h < low_limit:
|
||||||
|
h_list[2] = low_limit
|
||||||
|
normal_index.remove(2)
|
||||||
|
|
||||||
|
if len(normal_index) == 1:
|
||||||
|
h_list[normal_index[0]] = h_list[normal_index[0]] - (sum(h_list) - 1)
|
||||||
|
elif len(normal_index) == 2:
|
||||||
|
sum_list = sum(h_list)
|
||||||
|
multiper = 1 - (sum_list - 1) / (
|
||||||
|
h_list[normal_index[0]] + h_list[normal_index[1]]
|
||||||
|
)
|
||||||
|
h_list[normal_index[0]] = h_list[normal_index[0]] * multiper
|
||||||
|
h_list[normal_index[1]] = h_list[normal_index[1]] * multiper
|
||||||
|
|
||||||
|
cos_h, dis_h, dis_f_h = h_list[0], h_list[1], h_list[2]
|
||||||
|
|
||||||
|
return cos_h, dis_h, dis_f_h
|
||||||
|
|
||||||
|
|
||||||
|
# 返回相似性计算的模式(不同权重),是否计算流量相似性,是否只计算cos相似性,是否只计算流量相似性。
|
||||||
|
def decode_mode(similarity_mode):
|
||||||
|
if similarity_mode == "COS":
|
||||||
|
if_flow = 0
|
||||||
|
if_only_cos = 1
|
||||||
|
if_only_flow = 0
|
||||||
|
elif similarity_mode == "CAD_new_gy":
|
||||||
|
if_flow = 0
|
||||||
|
if_only_cos = 0
|
||||||
|
if_only_flow = 0
|
||||||
|
elif similarity_mode == "CDF":
|
||||||
|
if_flow = 1
|
||||||
|
if_only_cos = 0
|
||||||
|
if_only_flow = 0
|
||||||
|
elif similarity_mode == "CAF":
|
||||||
|
if_flow = 1
|
||||||
|
if_only_cos = 1
|
||||||
|
if_only_flow = 0
|
||||||
|
elif similarity_mode == "DIS":
|
||||||
|
if_flow = 1
|
||||||
|
if_only_cos = 2
|
||||||
|
if_only_flow = 0
|
||||||
|
elif similarity_mode == "OF":
|
||||||
|
if_flow = 1
|
||||||
|
if_only_cos = 0
|
||||||
|
if_only_flow = 1
|
||||||
|
return if_flow, if_only_cos, if_only_flow
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"""噪声生成模块。"""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import random
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from .leak_signature import simple_add_leak, simple_recover_wn, simple_simulation_pf
|
||||||
|
|
||||||
|
|
||||||
|
def add_noise_pd(data, noise_type, noise_para):
|
||||||
|
output_data = copy.deepcopy(data)
|
||||||
|
if type(output_data) == pd.core.frame.Series:
|
||||||
|
if noise_type == "uni":
|
||||||
|
for x in output_data.index:
|
||||||
|
noise = (np.random.random() - 0.5) * 2
|
||||||
|
output_data[x] = output_data[x] + noise * noise_para
|
||||||
|
|
||||||
|
elif noise_type == "gauss":
|
||||||
|
noise = np.random.normal(loc=0, scale=noise_para, size=output_data.shape)
|
||||||
|
output_data = output_data + noise
|
||||||
|
elif type(output_data) == pd.core.frame.DataFrame:
|
||||||
|
if noise_type == "uni":
|
||||||
|
noise = (np.random.random(size=output_data.shape) - 0.5) * 2
|
||||||
|
output_data = output_data + noise * noise_para
|
||||||
|
|
||||||
|
elif noise_type == "gauss":
|
||||||
|
noise = np.random.normal(loc=0, scale=noise_para, size=output_data.shape)
|
||||||
|
output_data = output_data + noise
|
||||||
|
return output_data
|
||||||
|
|
||||||
|
|
||||||
|
def add_noise_number(data, noise_type, noise_para):
|
||||||
|
output_data = copy.deepcopy(data)
|
||||||
|
if noise_type == "uni":
|
||||||
|
noise = (np.random.random() - 0.5) * 2
|
||||||
|
output_data = output_data + noise * noise_para
|
||||||
|
|
||||||
|
elif noise_type == "gauss":
|
||||||
|
noise = random.gauss(0, noise_para)
|
||||||
|
output_data = output_data + noise
|
||||||
|
return output_data
|
||||||
|
|
||||||
|
|
||||||
|
def add_noise_number_flow(data, noise_para_mean, noise_para_std1, noise_para_std2):
|
||||||
|
output_data = copy.deepcopy(data)
|
||||||
|
noise_flag1 = np.random.random() - 0.5
|
||||||
|
if noise_flag1 < 0:
|
||||||
|
noise = noise_para_mean - abs(np.random.normal(loc=0, scale=noise_para_std1))
|
||||||
|
else:
|
||||||
|
noise = noise_para_mean + abs(np.random.normal(loc=0, scale=noise_para_std2))
|
||||||
|
noise_flag2 = np.random.random() - 0.5
|
||||||
|
if noise_flag2 < 0:
|
||||||
|
noise_f = noise * (-1)
|
||||||
|
else:
|
||||||
|
noise_f = noise
|
||||||
|
output_data = output_data + noise_f
|
||||||
|
return output_data
|
||||||
|
|
||||||
|
|
||||||
|
def produce_noise_number(noise_type, noise_para):
|
||||||
|
if noise_type == "uni":
|
||||||
|
noise = (np.random.random() - 0.5) * 2
|
||||||
|
noise = noise * noise_para
|
||||||
|
elif noise_type == "gauss":
|
||||||
|
noise = random.gauss(0, noise_para)
|
||||||
|
else:
|
||||||
|
noise = 0
|
||||||
|
return noise
|
||||||
|
|
||||||
|
|
||||||
|
def add_noise_percentage_pd(data, noise_type, noise_para):
|
||||||
|
output_data = copy.deepcopy(data)
|
||||||
|
|
||||||
|
if type(output_data) == pd.core.frame.Series:
|
||||||
|
if noise_type == "uni":
|
||||||
|
for x in output_data.index:
|
||||||
|
noise = (np.random.random() - 0.5) * 2
|
||||||
|
output_data[x] = output_data[x] * (1 + noise * noise_para / 100)
|
||||||
|
|
||||||
|
elif noise_type == "gauss":
|
||||||
|
for x in output_data.index:
|
||||||
|
noise = np.random.gauss(0, noise_para)
|
||||||
|
output_data[x] = output_data[x] * (1 + noise / 100)
|
||||||
|
# std_noise = noise.std()
|
||||||
|
elif type(output_data) == pd.core.frame.DataFrame:
|
||||||
|
if noise_type == "uni":
|
||||||
|
noise = (np.random.random(size=output_data.shape) - 0.5) * 2
|
||||||
|
output_data = output_data * (1 + noise * noise_para / 100)
|
||||||
|
|
||||||
|
elif noise_type == "gauss":
|
||||||
|
noise = np.random.normal(loc=0, scale=noise_para, size=output_data.shape)
|
||||||
|
output_data = output_data * (1 + noise / 100)
|
||||||
|
# std_noise = noise.std().mean()
|
||||||
|
return output_data
|
||||||
|
|
||||||
|
|
||||||
|
def add_noise_in_wn_pf(
|
||||||
|
wn,
|
||||||
|
pipe_c_noise,
|
||||||
|
timestep_list,
|
||||||
|
pipe_coefficient,
|
||||||
|
sensor_name,
|
||||||
|
sensor_f_name,
|
||||||
|
all_node,
|
||||||
|
basic_demand_pd,
|
||||||
|
noise_type,
|
||||||
|
noise_para,
|
||||||
|
leak_pipe,
|
||||||
|
leak_flow,
|
||||||
|
):
|
||||||
|
|
||||||
|
wn.options.time.duration = 0
|
||||||
|
pipe_roughness_change = add_noise_pd(pipe_coefficient, noise_type, pipe_c_noise)
|
||||||
|
wn = change_para_of_wn(wn, pipe_roughness_change)
|
||||||
|
record_pressure = pd.DataFrame(index=timestep_list, columns=sensor_name)
|
||||||
|
record_flow = pd.DataFrame(index=timestep_list, columns=sensor_f_name)
|
||||||
|
record_noise_all = pd.DataFrame(
|
||||||
|
index=pd.MultiIndex.from_product([timestep_list, all_node]),
|
||||||
|
columns=basic_demand_pd.columns,
|
||||||
|
)
|
||||||
|
record_noise_all = record_noise_all.sort_index()
|
||||||
|
|
||||||
|
# normal 获取添加噪声后的监测点数据
|
||||||
|
for i in range(len(timestep_list)):
|
||||||
|
wn, record_noise = change_node_demand(
|
||||||
|
wn, basic_demand_pd, all_node, noise_type, noise_para
|
||||||
|
)
|
||||||
|
record_noise_all.loc[timestep_list[i]].loc[:, :] = record_noise
|
||||||
|
pressure_temp, flow_temp = simple_simulation_pf(
|
||||||
|
wn, sensor_name, sensor_f_name, [], []
|
||||||
|
)
|
||||||
|
record_pressure.iloc[i, :] = pressure_temp
|
||||||
|
record_flow.iloc[i, :] = flow_temp
|
||||||
|
|
||||||
|
# leak_simulation 获取添加漏损后的监测点数据
|
||||||
|
record_pressure_leak = pd.DataFrame(index=timestep_list, columns=sensor_name)
|
||||||
|
record_flow_leak = pd.DataFrame(index=timestep_list, columns=sensor_f_name)
|
||||||
|
# 改_wz_________________________________________
|
||||||
|
# add leak
|
||||||
|
wn, whole_inf, add_pipe1 = simple_add_leak(wn, leak_flow, leak_pipe)
|
||||||
|
# simulation
|
||||||
|
for i in range(len(timestep_list)):
|
||||||
|
record_noise = record_noise_all.loc[timestep_list[i]]
|
||||||
|
wn = change_node_demand_leak(wn, record_noise, all_node)
|
||||||
|
pressure_temp, flow_temp = simple_simulation_pf(
|
||||||
|
wn, sensor_name, sensor_f_name, leak_pipe, add_pipe1
|
||||||
|
)
|
||||||
|
record_pressure_leak.iloc[i, :] = pressure_temp
|
||||||
|
record_flow_leak.iloc[i, :] = flow_temp
|
||||||
|
# delete leak
|
||||||
|
wn = simple_recover_wn(wn, whole_inf)
|
||||||
|
return wn, record_pressure, record_flow, record_pressure_leak, record_flow_leak
|
||||||
|
|
||||||
|
|
||||||
|
def change_node_demand(wn, basic_demand_pd, all_node, noise_type, noise_para):
|
||||||
|
# 改_wz_____________________________________
|
||||||
|
record_noise = pd.DataFrame(index=all_node, columns=basic_demand_pd.columns)
|
||||||
|
for each_node in all_node:
|
||||||
|
node = wn.get_node(each_node)
|
||||||
|
num_columns = len(basic_demand_pd.columns)
|
||||||
|
# 处理前N-1列(如果有)
|
||||||
|
for i in range(num_columns - 1):
|
||||||
|
# 获取原始值并添加噪声
|
||||||
|
record_noise.loc[each_node].iloc[i] = (
|
||||||
|
1 + produce_noise_number(noise_type, noise_para)
|
||||||
|
) * basic_demand_pd.loc[each_node].iloc[i]
|
||||||
|
node.demand_timeseries_list[i].base_value = record_noise.loc[
|
||||||
|
each_node
|
||||||
|
].iloc[i]
|
||||||
|
# 处理最后一列(当列数>=1时)
|
||||||
|
if num_columns >= 1:
|
||||||
|
last_col = basic_demand_pd.columns[-1]
|
||||||
|
original_last = basic_demand_pd.loc[each_node, last_col]
|
||||||
|
record_noise.loc[each_node, last_col] = original_last
|
||||||
|
node.demand_timeseries_list[-1].base_value = original_last
|
||||||
|
|
||||||
|
return wn, record_noise
|
||||||
|
|
||||||
|
|
||||||
|
def change_node_demand_leak(wn, record_noise, all_node):
|
||||||
|
sample_node = wn.get_node(all_node[0])
|
||||||
|
# num_categories = len(sample_node.demand_timeseries_list)
|
||||||
|
num_categories = 1
|
||||||
|
for each in all_node:
|
||||||
|
node = wn.get_node(each)
|
||||||
|
for i in range(num_categories):
|
||||||
|
node.demand_timeseries_list[i].base_value = record_noise.loc[each].iloc[i]
|
||||||
|
return wn
|
||||||
|
|
||||||
|
|
||||||
|
def change_para_of_wn(wn, pipe_roughness_change):
|
||||||
|
for pipe_name, pipe in wn.pipes():
|
||||||
|
pipe.roughness = pipe_roughness_change[pipe_name]
|
||||||
|
return wn
|
||||||
|
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""管网模型读取与图构建模块。"""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import networkx as nx
|
||||||
|
import pandas as pd
|
||||||
|
import wntr
|
||||||
|
|
||||||
|
|
||||||
|
def load_inp(inp_name, inp_location, inp_time, driven_mode, require_p, minimum_p):
|
||||||
|
inp_file = inp_location + inp_name
|
||||||
|
wn = wntr.network.WaterNetworkModel(inp_file)
|
||||||
|
if driven_mode == "PDD":
|
||||||
|
wn.options.hydraulic.demand_model = "PDD"
|
||||||
|
wn.options.hydraulic.required_pressure = require_p
|
||||||
|
wn.options.hydraulic.minimum_pressure = minimum_p
|
||||||
|
else:
|
||||||
|
wn.options.hydraulic.demand_model = "DD"
|
||||||
|
return wn
|
||||||
|
|
||||||
|
|
||||||
|
def read_inf_inp(wn):
|
||||||
|
all_node = wn.node_name_list
|
||||||
|
node_elevation = wn.query_node_attribute("elevation")
|
||||||
|
node_coordinates = wn.query_node_attribute("coordinates")
|
||||||
|
|
||||||
|
all_pipe = wn.pipe_name_list
|
||||||
|
# 改_wz__________________________________
|
||||||
|
n_pipe = []
|
||||||
|
for p in all_pipe:
|
||||||
|
pipe = wn.get_link(p)
|
||||||
|
if pipe.initial_status == 0: # 状态为'Closed'
|
||||||
|
n_pipe.append(p)
|
||||||
|
candidate_pipe_init = sorted(set(all_pipe) - set(n_pipe))
|
||||||
|
pipe_start_node = wn.query_link_attribute(
|
||||||
|
"start_node_name", link_type=wntr.network.model.Pipe
|
||||||
|
)
|
||||||
|
pipe_end_node = wn.query_link_attribute(
|
||||||
|
"end_node_name", link_type=wntr.network.model.Pipe
|
||||||
|
)
|
||||||
|
pipe_length = wn.query_link_attribute("length")
|
||||||
|
pipe_diameter = wn.query_link_attribute("diameter")
|
||||||
|
|
||||||
|
return (
|
||||||
|
all_node,
|
||||||
|
node_elevation,
|
||||||
|
node_coordinates,
|
||||||
|
candidate_pipe_init,
|
||||||
|
pipe_start_node,
|
||||||
|
pipe_end_node,
|
||||||
|
pipe_length,
|
||||||
|
pipe_diameter,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def read_inf_inp_other(wn):
|
||||||
|
all_link = wn.link_name_list
|
||||||
|
pipe_start_node_all = wn.query_link_attribute("start_node_name")
|
||||||
|
pipe_end_node_all = wn.query_link_attribute("end_node_name")
|
||||||
|
|
||||||
|
return all_link, pipe_start_node_all, pipe_end_node_all
|
||||||
|
|
||||||
|
|
||||||
|
def construct_graph(wn):
|
||||||
|
length = wn.query_link_attribute("length")
|
||||||
|
G = wn.get_graph(wn, link_weight=length)
|
||||||
|
# 转为无向图
|
||||||
|
G0 = G.to_undirected()
|
||||||
|
# A0 = np.array(nx.adjacency_graph(G0).todense())
|
||||||
|
return G0 # , A0
|
||||||
|
|
||||||
|
|
||||||
|
def cal_pipe_coordinate(all_pipe, pipe_start_node, pipe_end_node, node_coordinates):
|
||||||
|
pipe_num = len(all_pipe)
|
||||||
|
pipe_coordinates = np.zeros([pipe_num, 2])
|
||||||
|
pipe_x = copy.deepcopy(pipe_start_node)
|
||||||
|
pipe_y = copy.deepcopy(pipe_start_node)
|
||||||
|
for i in range(pipe_num):
|
||||||
|
temp_pipe = all_pipe[i]
|
||||||
|
pipe_x[temp_pipe] = (
|
||||||
|
node_coordinates[pipe_start_node[temp_pipe]][0]
|
||||||
|
+ node_coordinates[pipe_end_node[temp_pipe]][0]
|
||||||
|
) / 2
|
||||||
|
pipe_y[temp_pipe] = (
|
||||||
|
node_coordinates[pipe_start_node[temp_pipe]][1]
|
||||||
|
+ node_coordinates[pipe_end_node[temp_pipe]][1]
|
||||||
|
) / 2
|
||||||
|
return pipe_x, pipe_y
|
||||||
|
|
||||||
|
|
||||||
|
def cal_node_coordinate(all_node, node_coordinates):
|
||||||
|
node_x = copy.deepcopy(node_coordinates)
|
||||||
|
node_y = copy.deepcopy(node_coordinates)
|
||||||
|
for i in range(len(node_x)):
|
||||||
|
temp_node = all_node[i]
|
||||||
|
node_x[temp_node] = node_coordinates[temp_node][0]
|
||||||
|
node_y[temp_node] = node_coordinates[temp_node][1]
|
||||||
|
return node_x, node_y
|
||||||
|
|
||||||
|
|
||||||
|
def produce_pattern_value(wn, all_node):
|
||||||
|
wn_o = copy.deepcopy(wn)
|
||||||
|
# 改_wz_____________________________
|
||||||
|
# sample_node = wn_o.get_node(all_node[0])
|
||||||
|
# num_categories = len(sample_node.demand_timeseries_list)
|
||||||
|
num_categories = 1
|
||||||
|
columns = [f"D{i}" for i in range(num_categories)]
|
||||||
|
basic_demand_pd = pd.DataFrame(index=all_node, columns=columns)
|
||||||
|
for each in all_node:
|
||||||
|
node = wn_o.get_node(each)
|
||||||
|
for i in range(num_categories):
|
||||||
|
basic_demand_pd.loc[each, columns[i]] = node.demand_timeseries_list[
|
||||||
|
i
|
||||||
|
].base_value
|
||||||
|
|
||||||
|
return basic_demand_pd
|
||||||
|
|
||||||
|
|
||||||
|
def _build_node_pipe_maps(
|
||||||
|
all_nodes, candidate_pipes, pipe_start_node, pipe_end_node, pipe_length
|
||||||
|
):
|
||||||
|
node_pipe_dic = {node: [] for node in all_nodes}
|
||||||
|
couple_node_length = {}
|
||||||
|
for pipe in candidate_pipes:
|
||||||
|
start_node = pipe_start_node[pipe]
|
||||||
|
end_node = pipe_end_node[pipe]
|
||||||
|
if start_node in node_pipe_dic:
|
||||||
|
node_pipe_dic[start_node].append(pipe)
|
||||||
|
if end_node in node_pipe_dic:
|
||||||
|
node_pipe_dic[end_node].append(pipe)
|
||||||
|
length = float(pipe_length[pipe])
|
||||||
|
couple_node_length[f"{start_node},{end_node}"] = length
|
||||||
|
couple_node_length[f"{end_node},{start_node}"] = length
|
||||||
|
return node_pipe_dic, couple_node_length
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
"""管网分区模块。"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import networkx as nx
|
||||||
|
import networkx as networkx
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import pymetis
|
||||||
|
from scipy.sparse import coo_matrix, csr_matrix
|
||||||
|
from scipy.sparse.csgraph import connected_components
|
||||||
|
|
||||||
|
|
||||||
|
def _to_metis_edge_weight(edge_weight):
|
||||||
|
weight = float(edge_weight)
|
||||||
|
if not math.isfinite(weight):
|
||||||
|
raise ValueError(f"Invalid non-finite METIS edge weight: {edge_weight}")
|
||||||
|
# pymetis expects integer edge weights.
|
||||||
|
return max(1, int(round(weight)))
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_preserve_order(items):
|
||||||
|
seen = set()
|
||||||
|
output = []
|
||||||
|
for item in items:
|
||||||
|
if item in seen:
|
||||||
|
continue
|
||||||
|
seen.add(item)
|
||||||
|
output.append(item)
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def pick_center_pipe(node_x, node_y, candidate_pipe, pipe_start_node, pipe_end_node):
|
||||||
|
candidate_pipe_list = list(candidate_pipe)
|
||||||
|
start_nodes = pipe_start_node[candidate_pipe_list]
|
||||||
|
end_nodes = pipe_end_node[candidate_pipe_list]
|
||||||
|
|
||||||
|
x_vals = (node_x[start_nodes].to_numpy() + node_x[end_nodes].to_numpy()) / 2.0
|
||||||
|
y_vals = (node_y[start_nodes].to_numpy() + node_y[end_nodes].to_numpy()) / 2.0
|
||||||
|
mean_x = float(np.mean(x_vals))
|
||||||
|
mean_y = float(np.mean(y_vals))
|
||||||
|
distance = np.abs(x_vals - mean_x) + np.abs(y_vals - mean_y)
|
||||||
|
center_idx = int(np.argmin(distance))
|
||||||
|
return candidate_pipe_list[center_idx]
|
||||||
|
|
||||||
|
|
||||||
|
def pick_max_diameter_pipe(candidate_pipe, pipe_diameter):
|
||||||
|
candidate_pipe_list = list(candidate_pipe)
|
||||||
|
diameters = pd.to_numeric(
|
||||||
|
pipe_diameter.reindex(candidate_pipe_list), errors="coerce"
|
||||||
|
).dropna()
|
||||||
|
if len(diameters) != len(candidate_pipe_list):
|
||||||
|
missing = sorted(set(candidate_pipe_list) - set(diameters.index))
|
||||||
|
preview = ", ".join(map(str, missing[:10]))
|
||||||
|
raise ValueError(f"Missing or invalid diameter for pipes: {preview}")
|
||||||
|
|
||||||
|
max_diameter = float(diameters.max())
|
||||||
|
max_diameter_pipes = sorted(
|
||||||
|
[pipe for pipe, diameter in diameters.items() if float(diameter) == max_diameter],
|
||||||
|
key=str,
|
||||||
|
)
|
||||||
|
return max_diameter_pipes[0]
|
||||||
|
|
||||||
|
|
||||||
|
def pick_dual_center_pipes(
|
||||||
|
node_x, node_y, candidate_pipe, pipe_start_node, pipe_end_node, pipe_diameter
|
||||||
|
):
|
||||||
|
geometric_center = pick_center_pipe(
|
||||||
|
node_x, node_y, candidate_pipe, pipe_start_node, pipe_end_node
|
||||||
|
)
|
||||||
|
diameter_center = pick_max_diameter_pipe(candidate_pipe, pipe_diameter)
|
||||||
|
return _dedupe_preserve_order([geometric_center, diameter_center])
|
||||||
|
|
||||||
|
|
||||||
|
def find_new_center_pipe(
|
||||||
|
node_x,
|
||||||
|
node_y,
|
||||||
|
candidate_pipe,
|
||||||
|
pipe_start_node,
|
||||||
|
pipe_end_node,
|
||||||
|
pipe_diameter,
|
||||||
|
record_center,
|
||||||
|
):
|
||||||
|
new_candidate_pipe = sorted(set(candidate_pipe) - set(record_center))
|
||||||
|
if new_candidate_pipe == []:
|
||||||
|
new_candidate_pipe = candidate_pipe
|
||||||
|
center_t = pick_center_pipe(
|
||||||
|
node_x,
|
||||||
|
node_y,
|
||||||
|
new_candidate_pipe,
|
||||||
|
pipe_start_node,
|
||||||
|
pipe_end_node,
|
||||||
|
)
|
||||||
|
return center_t
|
||||||
|
|
||||||
|
|
||||||
|
def cal_area_node_linked_pipe(nodeset, node_pipe_dic):
|
||||||
|
pipeset = []
|
||||||
|
for temp_node in nodeset:
|
||||||
|
pipeset.extend(node_pipe_dic[temp_node])
|
||||||
|
return pipeset
|
||||||
|
|
||||||
|
|
||||||
|
def metis_grouping_pipe_weight(
|
||||||
|
G0,
|
||||||
|
wn,
|
||||||
|
all_node_iter,
|
||||||
|
candidate_pipe_input,
|
||||||
|
group_num,
|
||||||
|
node_x,
|
||||||
|
node_y,
|
||||||
|
pipe_start_node_all,
|
||||||
|
pipe_end_node_all,
|
||||||
|
node_pipe_dic,
|
||||||
|
all_node_series,
|
||||||
|
couple_node_length,
|
||||||
|
pipe_diameter,
|
||||||
|
):
|
||||||
|
all_node_iter_series_new = all_node_series[all_node_iter]
|
||||||
|
all_node_iter_series_new = all_node_iter_series_new.sort_values(ascending=True)
|
||||||
|
all_node_iter_new = list(all_node_iter_series_new.index)
|
||||||
|
|
||||||
|
G1 = G0.subgraph(all_node_iter_new)
|
||||||
|
delimiter = " "
|
||||||
|
adjacency_list = []
|
||||||
|
node_dict = {}
|
||||||
|
c_new = 0
|
||||||
|
for each_node in all_node_iter_new:
|
||||||
|
node_dict[each_node] = c_new
|
||||||
|
c_new = c_new + 1
|
||||||
|
correspond_dic = {}
|
||||||
|
count_node = 0
|
||||||
|
w = []
|
||||||
|
for node_name in all_node_iter_new:
|
||||||
|
neighbors = G1[node_name]
|
||||||
|
w_temp = []
|
||||||
|
n_t = [node_dict[node_name]]
|
||||||
|
for neighbor_name in sorted(neighbors.keys()):
|
||||||
|
edge_data = neighbors[neighbor_name]
|
||||||
|
edge_key = f"{node_name},{neighbor_name}"
|
||||||
|
reverse_edge_key = f"{neighbor_name},{node_name}"
|
||||||
|
if edge_key in couple_node_length:
|
||||||
|
edge_weight = couple_node_length[edge_key]
|
||||||
|
elif reverse_edge_key in couple_node_length:
|
||||||
|
edge_weight = couple_node_length[reverse_edge_key]
|
||||||
|
elif edge_data.get("weight") is not None:
|
||||||
|
edge_weight = float(edge_data["weight"])
|
||||||
|
else:
|
||||||
|
# Ignore graph edges that are outside candidate pipes and have no usable
|
||||||
|
# partition weight (e.g. some non-pipe links in mixed network graphs).
|
||||||
|
continue
|
||||||
|
w_temp.append(_to_metis_edge_weight(edge_weight))
|
||||||
|
n_t.append(node_dict[neighbor_name])
|
||||||
|
w.append(w_temp)
|
||||||
|
correspond_dic[n_t[0]] = count_node
|
||||||
|
count_node = count_node + 1
|
||||||
|
# del n_t[0]
|
||||||
|
adjacency_list.append(n_t)
|
||||||
|
adjacency_list_new = [[] * 1 for i in range(len(adjacency_list))]
|
||||||
|
w_new = [[] * 1 for i in range(len(adjacency_list))]
|
||||||
|
for i in range(len(adjacency_list)):
|
||||||
|
adjacency_list_new[int(adjacency_list[i][0])] = adjacency_list[i]
|
||||||
|
w_new[int(adjacency_list[i][0])] = w[i]
|
||||||
|
|
||||||
|
for i in range(len(adjacency_list)):
|
||||||
|
del adjacency_list_new[i][0]
|
||||||
|
xadj = [0]
|
||||||
|
w_f = []
|
||||||
|
final_adjacency_list = []
|
||||||
|
for i in range(len(adjacency_list_new)):
|
||||||
|
final_adjacency_list = final_adjacency_list + adjacency_list_new[i]
|
||||||
|
xadj.append(len(final_adjacency_list))
|
||||||
|
w_f = w_f + w_new[i]
|
||||||
|
|
||||||
|
# (edgecuts, parts) = pymetis.part_graph(nparts=group_num, adjacency=adjacency_list_new)
|
||||||
|
metis_options = pymetis.Options()
|
||||||
|
metis_options.seed = 42
|
||||||
|
(edgecuts, parts) = pymetis.part_graph(
|
||||||
|
nparts=group_num,
|
||||||
|
adjncy=final_adjacency_list,
|
||||||
|
xadj=xadj,
|
||||||
|
eweights=w_f,
|
||||||
|
options=metis_options,
|
||||||
|
)
|
||||||
|
# (edgecuts, parts) = pymetis.part_graph(nparts=group_num, adjacency=adjacency_list_new)
|
||||||
|
candidate_group_list = [[] * 1 for i in range(group_num)]
|
||||||
|
for i in range(len(all_node_iter_new)):
|
||||||
|
candidate_group_list[parts[i]].append(all_node_iter_new[i])
|
||||||
|
|
||||||
|
"""parts_new = np.zeros(len(candidate_node_input), dtype=int)
|
||||||
|
for i in range(len(candidate_group_list)):
|
||||||
|
temp_group = candidate_group_list[i]
|
||||||
|
for each_node in temp_group:
|
||||||
|
parts_new[node_dict[each_node]] = i
|
||||||
|
parts_new = list(parts_new)"""
|
||||||
|
|
||||||
|
new_center = []
|
||||||
|
new_group = []
|
||||||
|
new_center_candidates = []
|
||||||
|
new_all_node = []
|
||||||
|
candidate_pipe_set = set(candidate_pipe_input)
|
||||||
|
all_grouped_pipe = []
|
||||||
|
for i in range(group_num):
|
||||||
|
# 构建子图
|
||||||
|
G_sub = G0.subgraph(candidate_group_list[i])
|
||||||
|
|
||||||
|
# 计算联通子图
|
||||||
|
sub_graphs = networkx.connected_components(G_sub)
|
||||||
|
if networkx.number_connected_components(G_sub) == 1:
|
||||||
|
# 求交集
|
||||||
|
nodeset = G_sub.nodes()
|
||||||
|
pipeset_set = set(cal_area_node_linked_pipe(nodeset, node_pipe_dic))
|
||||||
|
candidate_pipe = sorted(pipeset_set.intersection(candidate_pipe_set))
|
||||||
|
|
||||||
|
# 判断集合是否保留
|
||||||
|
if len(candidate_pipe) > 0:
|
||||||
|
# 保留 计算中心
|
||||||
|
center_t = pick_center_pipe(
|
||||||
|
node_x,
|
||||||
|
node_y,
|
||||||
|
candidate_pipe,
|
||||||
|
pipe_start_node_all,
|
||||||
|
pipe_end_node_all,
|
||||||
|
)
|
||||||
|
center_candidates_t = pick_dual_center_pipes(
|
||||||
|
node_x,
|
||||||
|
node_y,
|
||||||
|
candidate_pipe,
|
||||||
|
pipe_start_node_all,
|
||||||
|
pipe_end_node_all,
|
||||||
|
pipe_diameter,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 更新
|
||||||
|
new_center.append(center_t)
|
||||||
|
new_center_candidates.append(center_candidates_t)
|
||||||
|
new_group.append(candidate_pipe)
|
||||||
|
new_all_node.append(nodeset)
|
||||||
|
all_grouped_pipe = all_grouped_pipe + candidate_pipe
|
||||||
|
else:
|
||||||
|
for c in sorted(sub_graphs, key=lambda c: min(c)):
|
||||||
|
G_temp = G0.subgraph(c)
|
||||||
|
nodeset = G_temp.nodes()
|
||||||
|
pipeset = cal_area_node_linked_pipe(nodeset, node_pipe_dic)
|
||||||
|
pipeset_set = set(pipeset)
|
||||||
|
|
||||||
|
# 求交集
|
||||||
|
candidate_pipe = sorted(pipeset_set.intersection(candidate_pipe_set))
|
||||||
|
# print(len(candidate_node))
|
||||||
|
# 判断集合是否保留
|
||||||
|
if len(candidate_pipe) > 0:
|
||||||
|
# 保留 计算中心
|
||||||
|
center_t = pick_center_pipe(
|
||||||
|
node_x,
|
||||||
|
node_y,
|
||||||
|
candidate_pipe,
|
||||||
|
pipe_start_node_all,
|
||||||
|
pipe_end_node_all,
|
||||||
|
)
|
||||||
|
center_candidates_t = pick_dual_center_pipes(
|
||||||
|
node_x,
|
||||||
|
node_y,
|
||||||
|
candidate_pipe,
|
||||||
|
pipe_start_node_all,
|
||||||
|
pipe_end_node_all,
|
||||||
|
pipe_diameter,
|
||||||
|
)
|
||||||
|
# 更新
|
||||||
|
new_center.append(center_t)
|
||||||
|
new_center_candidates.append(center_candidates_t)
|
||||||
|
new_group.append(candidate_pipe)
|
||||||
|
new_all_node.append(nodeset)
|
||||||
|
all_grouped_pipe = all_grouped_pipe + candidate_pipe
|
||||||
|
record_center = []
|
||||||
|
c_g = 0
|
||||||
|
for each_group in new_group:
|
||||||
|
if len(each_group) < 3:
|
||||||
|
record_center.append(new_center[c_g])
|
||||||
|
c_g += 1
|
||||||
|
c_g = 0
|
||||||
|
for each_group in new_group:
|
||||||
|
if len(each_group) >= 3:
|
||||||
|
if new_center[c_g] in record_center:
|
||||||
|
new_center[c_g] = find_new_center_pipe(
|
||||||
|
node_x,
|
||||||
|
node_y,
|
||||||
|
each_group,
|
||||||
|
pipe_start_node_all,
|
||||||
|
pipe_end_node_all,
|
||||||
|
pipe_diameter,
|
||||||
|
record_center,
|
||||||
|
)
|
||||||
|
new_center_candidates[c_g] = _dedupe_preserve_order(
|
||||||
|
[new_center[c_g]] + list(new_center_candidates[c_g])
|
||||||
|
)
|
||||||
|
record_center.append(new_center[c_g])
|
||||||
|
c_g += 1
|
||||||
|
|
||||||
|
# visualize_metis_partition(
|
||||||
|
# G0, new_center, new_group,
|
||||||
|
# node_x, node_y,
|
||||||
|
# pipe_start_node_all, pipe_end_node_all
|
||||||
|
# )
|
||||||
|
return new_center, new_group, new_all_node, new_center_candidates
|
||||||
|
|
||||||
|
|
||||||
|
def visualize_metis_partition(
|
||||||
|
G,
|
||||||
|
center_pipes,
|
||||||
|
pipe_groups,
|
||||||
|
node_x,
|
||||||
|
node_y,
|
||||||
|
pipe_start_node_all,
|
||||||
|
pipe_end_node_all,
|
||||||
|
title: str | None = None,
|
||||||
|
block: bool = True,
|
||||||
|
pause_seconds: float | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
可视化METIS分区结果(单图模式)
|
||||||
|
参数:
|
||||||
|
G: 原始管网图(nx.Graph)
|
||||||
|
center_pipes: 中心管道列表(list)
|
||||||
|
pipe_groups: 分组管道列表(list of lists)
|
||||||
|
node_x: 节点X坐标字典(dict)
|
||||||
|
node_y: 节点Y坐标字典(dict)
|
||||||
|
pipe_start_node_all: 管道起点字典(dict)
|
||||||
|
pipe_end_node_all: 管道终点字典(dict)
|
||||||
|
"""
|
||||||
|
fig = plt.figure("metis_partition_convergence", figsize=(22.51, 12.48))
|
||||||
|
fig.clf()
|
||||||
|
ax = fig.add_subplot(111)
|
||||||
|
if not block:
|
||||||
|
plt.ion()
|
||||||
|
|
||||||
|
# 生成颜色映射(自动扩展颜色数量)
|
||||||
|
colors = plt.cm.tab20(np.linspace(0, 1, len(pipe_groups)))
|
||||||
|
|
||||||
|
# --- 绘制背景管网(灰色半透明) ---
|
||||||
|
for edge in G.edges():
|
||||||
|
start_node, end_node = edge
|
||||||
|
ax.plot(
|
||||||
|
[node_x[start_node], node_x[end_node]],
|
||||||
|
[node_y[start_node], node_y[end_node]],
|
||||||
|
color="lightgray",
|
||||||
|
linewidth=0.5,
|
||||||
|
alpha=0.3,
|
||||||
|
zorder=1, # 确保背景在底层
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- 绘制各分区管道(彩色)---
|
||||||
|
legend_handles = [] # 用于图例的句柄
|
||||||
|
for i, (group, center) in enumerate(zip(pipe_groups, center_pipes)):
|
||||||
|
color = colors[i % len(colors)] # 循环使用颜色
|
||||||
|
|
||||||
|
# 绘制分组管道
|
||||||
|
for pipe in group:
|
||||||
|
start = pipe_start_node_all[pipe]
|
||||||
|
end = pipe_end_node_all[pipe]
|
||||||
|
line = ax.plot(
|
||||||
|
[node_x[start], node_x[end]],
|
||||||
|
[node_y[start], node_y[end]],
|
||||||
|
color=color,
|
||||||
|
linewidth=2.5,
|
||||||
|
alpha=0.8,
|
||||||
|
zorder=2,
|
||||||
|
)
|
||||||
|
# 只为每个分组的第一个管道添加图例句柄
|
||||||
|
if pipe == group[0]:
|
||||||
|
legend_handles.append(line[0])
|
||||||
|
|
||||||
|
# 高亮中心管道(红色虚线)
|
||||||
|
if center in pipe_start_node_all and center in pipe_end_node_all:
|
||||||
|
start = pipe_start_node_all[center]
|
||||||
|
end = pipe_end_node_all[center]
|
||||||
|
ax.plot(
|
||||||
|
[node_x[start], node_x[end]],
|
||||||
|
[node_y[start], node_y[end]],
|
||||||
|
color="red",
|
||||||
|
linewidth=4,
|
||||||
|
linestyle="--",
|
||||||
|
dash_capstyle="round",
|
||||||
|
zorder=3, # 确保中心管道在最顶层
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- 添加图例和标注 ---
|
||||||
|
# 分组图例
|
||||||
|
if legend_handles:
|
||||||
|
group_labels = [f"Group {i + 1}" for i in range(len(pipe_groups))]
|
||||||
|
ax.legend(
|
||||||
|
legend_handles,
|
||||||
|
group_labels,
|
||||||
|
loc="upper right",
|
||||||
|
title="Partitions",
|
||||||
|
fontsize=8,
|
||||||
|
title_fontsize=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 中心管道标注(可选)
|
||||||
|
for i, center in enumerate(center_pipes):
|
||||||
|
if center in pipe_start_node_all:
|
||||||
|
x = (
|
||||||
|
node_x[pipe_start_node_all[center]] + node_x[pipe_end_node_all[center]]
|
||||||
|
) / 2
|
||||||
|
y = (
|
||||||
|
node_y[pipe_start_node_all[center]] + node_y[pipe_end_node_all[center]]
|
||||||
|
) / 2
|
||||||
|
ax.text(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
f"C{i + 1}",
|
||||||
|
color="red",
|
||||||
|
fontsize=10,
|
||||||
|
ha="center",
|
||||||
|
va="center",
|
||||||
|
bbox=dict(facecolor="white", alpha=0.8, edgecolor="none"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- 图形美化 ---
|
||||||
|
ax.set_title(title or "Water Network Partitioning Overview", fontsize=14, pad=20)
|
||||||
|
ax.set_xlabel("X Coordinate", fontsize=10)
|
||||||
|
ax.set_ylabel("Y Coordinate", fontsize=10)
|
||||||
|
ax.grid(True, alpha=0.2, linestyle=":")
|
||||||
|
fig.tight_layout()
|
||||||
|
|
||||||
|
# 显示图形并强制刷新,避免迭代显示滞后一轮。
|
||||||
|
plt.show(block=block)
|
||||||
|
if not block:
|
||||||
|
fig.canvas.draw_idle()
|
||||||
|
fig.canvas.flush_events()
|
||||||
|
pause_value = 0.001 if pause_seconds is None else max(0.0, float(pause_seconds))
|
||||||
|
plt.pause(max(0.001, pause_value))
|
||||||
|
elif pause_seconds is not None:
|
||||||
|
plt.pause(max(0.0, float(pause_seconds)))
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
def generate_adjlist_with_all_edges(G, delimiter):
|
||||||
|
for s, nbrs in G.adjacency():
|
||||||
|
line = str(s) + delimiter
|
||||||
|
for t, data in nbrs.items():
|
||||||
|
line += str(t) + delimiter
|
||||||
|
yield line[: -len(delimiter)]
|
||||||
|
|
||||||
|
|
||||||
|
def cal_group_num(candidate_node_input, cal_group_num):
|
||||||
|
candidate_node_num = len(candidate_node_input)
|
||||||
|
if candidate_node_num > 100:
|
||||||
|
group_num_input = cal_group_num # 30
|
||||||
|
else:
|
||||||
|
group_num_input = 10
|
||||||
|
|
||||||
|
return group_num_input
|
||||||
|
|
||||||
|
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1,609 @@
|
|||||||
|
import wntr
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from multiprocessing import Pool, cpu_count
|
||||||
|
from typing import Any, List, Dict, Union
|
||||||
|
|
||||||
|
from pymoo.core.problem import Problem
|
||||||
|
from pymoo.core.callback import Callback
|
||||||
|
from pymoo.algorithms.soo.nonconvex.ga import GA
|
||||||
|
from pymoo.operators.crossover.sbx import SBX
|
||||||
|
from pymoo.operators.mutation.pm import PM
|
||||||
|
from pymoo.optimize import minimize as pymoo_minimize
|
||||||
|
from pymoo.termination.default import DefaultSingleObjectiveTermination
|
||||||
|
|
||||||
|
|
||||||
|
from app.algorithms._utils import _cleanup_temp_files
|
||||||
|
|
||||||
|
_worker_data: dict[str, Any] = {}
|
||||||
|
DEFAULT_N_WORKERS = max(1, min(cpu_count() - 1, 4))
|
||||||
|
|
||||||
|
|
||||||
|
def _worker_init(
|
||||||
|
inp_path: str,
|
||||||
|
sensor_nodes: list[str],
|
||||||
|
area_ids: list[str],
|
||||||
|
nodes_by_area: dict[str, list[str]],
|
||||||
|
obs_matrix: np.ndarray,
|
||||||
|
q_sum: float,
|
||||||
|
duration_sec: float,
|
||||||
|
timestep_sec: float,
|
||||||
|
) -> None:
|
||||||
|
global _worker_data
|
||||||
|
wn = wntr.network.WaterNetworkModel(inp_path)
|
||||||
|
wn.options.hydraulic.demand_model = "DD"
|
||||||
|
wn.options.time.duration = duration_sec
|
||||||
|
wn.options.time.hydraulic_timestep = timestep_sec
|
||||||
|
wn.options.time.pattern_timestep = timestep_sec
|
||||||
|
wn.options.time.report_timestep = timestep_sec
|
||||||
|
|
||||||
|
demand_objs_by_area = {}
|
||||||
|
allocatable_counts = {}
|
||||||
|
for area_id in area_ids:
|
||||||
|
demand_objs = []
|
||||||
|
for node_name in nodes_by_area.get(area_id, []):
|
||||||
|
if node_name not in wn.node_name_list:
|
||||||
|
continue
|
||||||
|
node = wn.get_node(node_name)
|
||||||
|
if (
|
||||||
|
hasattr(node, "demand_timeseries_list")
|
||||||
|
and len(node.demand_timeseries_list) > 0
|
||||||
|
):
|
||||||
|
demand_objs.append(node.demand_timeseries_list[0])
|
||||||
|
demand_objs_by_area[area_id] = demand_objs
|
||||||
|
allocatable_counts[area_id] = len(demand_objs)
|
||||||
|
|
||||||
|
_worker_data = {
|
||||||
|
"wn": wn,
|
||||||
|
"sensor_nodes": sensor_nodes,
|
||||||
|
"area_ids": area_ids,
|
||||||
|
"nodes_by_area": nodes_by_area,
|
||||||
|
"demand_objs_by_area": demand_objs_by_area,
|
||||||
|
"allocatable_counts": allocatable_counts,
|
||||||
|
"obs_matrix": obs_matrix,
|
||||||
|
"q_sum": q_sum,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _worker_evaluate(raw_ratios: np.ndarray) -> float:
|
||||||
|
d = _worker_data
|
||||||
|
effective_ratio_map = DmaLeakageOptimizer._effective_area_ratios(
|
||||||
|
raw_ratios,
|
||||||
|
d["area_ids"],
|
||||||
|
d["nodes_by_area"],
|
||||||
|
allocatable_counts=d["allocatable_counts"],
|
||||||
|
)
|
||||||
|
|
||||||
|
modifications = []
|
||||||
|
for area_id in d["area_ids"]:
|
||||||
|
ratio = effective_ratio_map.get(area_id, 0.0)
|
||||||
|
if ratio <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
demand_objs = d["demand_objs_by_area"].get(area_id, [])
|
||||||
|
if not demand_objs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
per_node_leak = d["q_sum"] * ratio / len(demand_objs)
|
||||||
|
for demand_obj in demand_objs:
|
||||||
|
original_val = demand_obj.base_value
|
||||||
|
demand_obj.base_value = original_val + per_node_leak
|
||||||
|
modifications.append((demand_obj, original_val))
|
||||||
|
|
||||||
|
temp_dir = os.path.abspath(os.path.join("temp", "leakage"))
|
||||||
|
os.makedirs(temp_dir, exist_ok=True)
|
||||||
|
prefix = os.path.join(temp_dir, f"temp_{os.getpid()}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
sim = wntr.sim.EpanetSimulator(d["wn"])
|
||||||
|
results = sim.run_sim(file_prefix=prefix)
|
||||||
|
sim_pressure = results.node["pressure"].loc[:, d["sensor_nodes"]]
|
||||||
|
|
||||||
|
n_steps = min(sim_pressure.shape[0], d["obs_matrix"].shape[0])
|
||||||
|
sim_vals = sim_pressure.values[:n_steps, :]
|
||||||
|
obs_vals = d["obs_matrix"][:n_steps, :]
|
||||||
|
diff = sim_vals - obs_vals
|
||||||
|
|
||||||
|
row_max = np.max(np.abs(diff), axis=1, keepdims=True)
|
||||||
|
row_max[row_max == 0] = 1.0
|
||||||
|
normalized_diff = diff / row_max
|
||||||
|
return float(np.linalg.norm(normalized_diff))
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
return 1e9
|
||||||
|
|
||||||
|
finally:
|
||||||
|
for demand_obj, original_val in modifications:
|
||||||
|
demand_obj.base_value = original_val
|
||||||
|
_cleanup_temp_files(prefix)
|
||||||
|
|
||||||
|
|
||||||
|
class DmaLeakageOptimizer:
|
||||||
|
FLOW_UNIT_TO_M3S = {
|
||||||
|
"m3/s": 1.0,
|
||||||
|
"m³/s": 1.0,
|
||||||
|
"m3/h": 1.0 / 3600.0,
|
||||||
|
"m³/h": 1.0 / 3600.0,
|
||||||
|
"L/s": 1.0 / 1000.0,
|
||||||
|
"L/min": 1.0 / 60000.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _flow_to_m3s(cls, value: float, unit: str) -> float:
|
||||||
|
if unit not in cls.FLOW_UNIT_TO_M3S:
|
||||||
|
raise ValueError(f"不支持的流量单位: {unit}")
|
||||||
|
return float(value) * cls.FLOW_UNIT_TO_M3S[unit]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _flow_from_m3s(cls, value_m3s: float, unit: str) -> float:
|
||||||
|
if unit not in cls.FLOW_UNIT_TO_M3S:
|
||||||
|
raise ValueError(f"不支持的流量单位: {unit}")
|
||||||
|
return float(value_m3s) / cls.FLOW_UNIT_TO_M3S[unit]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _effective_area_ratios(
|
||||||
|
raw_ratios: Union["np.ndarray", Dict[str, float]],
|
||||||
|
area_ids: List[str],
|
||||||
|
nodes_by_area: Dict[str, List[str]],
|
||||||
|
allocatable_counts: Union[Dict[str, int], None] = None,
|
||||||
|
) -> Dict[str, float]:
|
||||||
|
"""将输入比例转换为有效区域比例,确保有效区域比例和为 1。"""
|
||||||
|
area_count = len(area_ids)
|
||||||
|
if area_count == 0:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
if isinstance(raw_ratios, dict):
|
||||||
|
ratios = np.array(
|
||||||
|
[float(raw_ratios.get(area_id, 0.0)) for area_id in area_ids],
|
||||||
|
dtype=float,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
arr = np.asarray(raw_ratios, dtype=float).reshape(-1)
|
||||||
|
ratios = np.zeros(area_count, dtype=float)
|
||||||
|
fill_len = min(area_count, arr.shape[0])
|
||||||
|
if fill_len > 0:
|
||||||
|
ratios[:fill_len] = arr[:fill_len]
|
||||||
|
|
||||||
|
# 仅保留非负比例,负值按 0 处理
|
||||||
|
ratios = np.clip(ratios, a_min=0.0, a_max=None)
|
||||||
|
|
||||||
|
# 仅在有效区域(存在可分配节点)内归一化
|
||||||
|
if allocatable_counts is not None:
|
||||||
|
valid_mask = np.array(
|
||||||
|
[int(allocatable_counts.get(area_id, 0)) > 0 for area_id in area_ids],
|
||||||
|
dtype=bool,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
valid_mask = np.array(
|
||||||
|
[len(nodes_by_area.get(area_id, [])) > 0 for area_id in area_ids],
|
||||||
|
dtype=bool,
|
||||||
|
)
|
||||||
|
if not np.any(valid_mask):
|
||||||
|
raise ValueError("没有可分配漏损的有效分区,无法满足漏损总量约束。")
|
||||||
|
|
||||||
|
effective = np.zeros(area_count, dtype=float)
|
||||||
|
valid_sum = float(np.sum(ratios[valid_mask]))
|
||||||
|
|
||||||
|
if valid_sum > 0:
|
||||||
|
effective[valid_mask] = ratios[valid_mask] / valid_sum
|
||||||
|
else:
|
||||||
|
# 若输入全为 0,则在有效区域内均分,保证总和仍为 1
|
||||||
|
valid_count = int(np.sum(valid_mask))
|
||||||
|
effective[valid_mask] = 1.0 / valid_count
|
||||||
|
|
||||||
|
return {area_id: float(effective[idx]) for idx, area_id in enumerate(area_ids)}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_area_map_df(df: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
"""标准化区域映射列名为 ID 和 Area。"""
|
||||||
|
if "ID" in df.columns and "Area" in df.columns:
|
||||||
|
return df
|
||||||
|
|
||||||
|
if "ID" in df.columns and "now" in df.columns:
|
||||||
|
df = df.rename(columns={"now": "Area"})
|
||||||
|
return df
|
||||||
|
|
||||||
|
df = df.copy()
|
||||||
|
df.columns = ["ID", "Area"] + list(df.columns[2:])
|
||||||
|
return df
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
inp_path: str,
|
||||||
|
sensor_nodes: List[str],
|
||||||
|
area_map: Union[str, Dict[str, str]],
|
||||||
|
start_time: float = 0,
|
||||||
|
duration: float = 24,
|
||||||
|
timestep: float = 5,
|
||||||
|
q_sum: float = 0.2,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
初始化漏损识别器。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
inp_path: EPANET .inp 文件路径。
|
||||||
|
sensor_nodes: 用作压力传感器的节点 ID 列表。
|
||||||
|
area_map: 节点到区域的映射。可以是 CSV 文件路径(列:ID, Area),也可以是字典 {NodeID: AreaID}。
|
||||||
|
start_time: 模拟开始时间(小时)。
|
||||||
|
duration: 模拟持续时间(小时)。
|
||||||
|
timestep: 模拟时间步长(分钟)。
|
||||||
|
q_sum: 假设的总漏损流量 (m3/s)。
|
||||||
|
"""
|
||||||
|
self.inp_path = inp_path
|
||||||
|
self.sensor_nodes = sensor_nodes
|
||||||
|
self.start_time = start_time
|
||||||
|
self.duration = duration
|
||||||
|
self.timestep = timestep
|
||||||
|
self.q_sum = q_sum
|
||||||
|
|
||||||
|
# 加载管网模型(仅一次)
|
||||||
|
self.wn = wntr.network.WaterNetworkModel(self.inp_path)
|
||||||
|
|
||||||
|
# 优化 WNTR 设置以提高速度
|
||||||
|
self.wn.options.hydraulic.demand_model = "DD"
|
||||||
|
self.wn.options.time.duration = float(self.duration) * 3600
|
||||||
|
self.wn.options.time.hydraulic_timestep = float(self.timestep) * 60
|
||||||
|
self.wn.options.time.pattern_timestep = float(self.timestep) * 60
|
||||||
|
self.wn.options.time.report_timestep = float(self.timestep) * 60
|
||||||
|
|
||||||
|
# 加载区域映射
|
||||||
|
if isinstance(area_map, str):
|
||||||
|
self.area_map_df = self._load_area_map(area_map)
|
||||||
|
elif isinstance(area_map, dict):
|
||||||
|
self.area_map_df = self._normalize_area_map_df(
|
||||||
|
pd.DataFrame(list(area_map.items()), columns=["ID", "Area"])
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError("area_map 必须是 CSV 文件路径或字典。")
|
||||||
|
|
||||||
|
self.area_ids = sorted(self.area_map_df["Area"].unique())
|
||||||
|
self.num_areas = len(self.area_ids)
|
||||||
|
|
||||||
|
# 按区域对节点进行预分类,以便更快查找
|
||||||
|
self.nodes_by_area = {
|
||||||
|
area: self.area_map_df[self.area_map_df["Area"] == area]["ID"].tolist()
|
||||||
|
for area in self.area_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
def _load_area_map(self, path: str) -> pd.DataFrame:
|
||||||
|
"""加载并验证节点-区域映射文件。"""
|
||||||
|
df = pd.read_csv(path, dtype={"ID": str, "Area": str})
|
||||||
|
return self._normalize_area_map_df(df)
|
||||||
|
|
||||||
|
def run_identification(
|
||||||
|
self,
|
||||||
|
observed_pressure_data: Union[
|
||||||
|
str, pd.DataFrame, Dict[str, List[Any]], List[Dict[str, Any]]
|
||||||
|
],
|
||||||
|
output_dir: str = "Results",
|
||||||
|
pop_size: int = 50,
|
||||||
|
max_gen: int = 100,
|
||||||
|
output_flow_unit: str = "m3/s",
|
||||||
|
save_result: bool = True,
|
||||||
|
ftol: float = 1e-3,
|
||||||
|
ftol_period: int = 15,
|
||||||
|
n_workers: int = DEFAULT_N_WORKERS,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
运行遗传算法以识别漏损分布。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
observed_pressure_data: 包含 SCADA 压力数据的 CSV 文件路径或 DataFrame/字典列表数据。
|
||||||
|
output_dir: 结果保存目录。
|
||||||
|
pop_size: GA 的种群大小。
|
||||||
|
max_gen: GA 的最大代数。
|
||||||
|
output_flow_unit: 输出漏损流量的单位。
|
||||||
|
save_result: 是否保存识别结果到本地 CSV。
|
||||||
|
ftol: 目标值收敛容差(连续 ftol_period 代改善 < ftol 则停止)。
|
||||||
|
ftol_period: 收敛检测的窗口代数。
|
||||||
|
n_workers: 并行工作进程数(1=串行,>1=并行评估)。
|
||||||
|
"""
|
||||||
|
if save_result:
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# 加载观测数据
|
||||||
|
if isinstance(observed_pressure_data, str):
|
||||||
|
obs_df = pd.read_csv(observed_pressure_data)
|
||||||
|
observed_name = os.path.basename(observed_pressure_data)
|
||||||
|
elif isinstance(observed_pressure_data, pd.DataFrame):
|
||||||
|
obs_df = observed_pressure_data.copy()
|
||||||
|
observed_name = "observed_pressure.csv"
|
||||||
|
else:
|
||||||
|
obs_df = pd.DataFrame(observed_pressure_data)
|
||||||
|
observed_name = "observed_pressure.csv"
|
||||||
|
|
||||||
|
# 准备 pymoo 问题实例
|
||||||
|
problem = LeakageProblem(
|
||||||
|
self.wn,
|
||||||
|
self.nodes_by_area,
|
||||||
|
self.area_ids,
|
||||||
|
self.sensor_nodes,
|
||||||
|
obs_df,
|
||||||
|
q_sum=self.q_sum,
|
||||||
|
n_workers=n_workers,
|
||||||
|
inp_path=os.path.abspath(self.inp_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 配置 pymoo GA 算法
|
||||||
|
n_var = self.num_areas
|
||||||
|
algorithm = GA(
|
||||||
|
pop_size=pop_size,
|
||||||
|
crossover=SBX(prob=0.9, eta=15),
|
||||||
|
mutation=PM(prob=1.0 / max(1, n_var), eta=20),
|
||||||
|
eliminate_duplicates=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 终止条件:收敛检测 + 最大代数
|
||||||
|
termination = DefaultSingleObjectiveTermination(
|
||||||
|
ftol=ftol,
|
||||||
|
period=ftol_period,
|
||||||
|
n_max_gen=max_gen,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 回调:记录每代信息
|
||||||
|
callback = _ProgressCallback()
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
res = pymoo_minimize(
|
||||||
|
problem,
|
||||||
|
algorithm,
|
||||||
|
termination,
|
||||||
|
seed=42,
|
||||||
|
verbose=True,
|
||||||
|
callback=callback,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
problem.close()
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
|
||||||
|
# 提取最优解
|
||||||
|
best_ind = res.X # 最优个体(漏损比例原始值)
|
||||||
|
best_obj = float(res.F[0])
|
||||||
|
|
||||||
|
# 输出终止信息
|
||||||
|
print(f"\n优化完成。耗时: {elapsed:.1f}s")
|
||||||
|
print(f"总代数: {res.algorithm.n_gen}, 总评估次数: {problem._eval_count}")
|
||||||
|
print(f"最佳目标值: {best_obj:.6f}")
|
||||||
|
|
||||||
|
# 保存到文件
|
||||||
|
effective_ratio_map = self._effective_area_ratios(
|
||||||
|
best_ind,
|
||||||
|
self.area_ids,
|
||||||
|
self.nodes_by_area,
|
||||||
|
allocatable_counts=problem.allocatable_counts,
|
||||||
|
)
|
||||||
|
normalized_ratios = [
|
||||||
|
effective_ratio_map.get(area_id, 0.0) for area_id in self.area_ids
|
||||||
|
]
|
||||||
|
leakage_flow_m3s = [ratio * self.q_sum for ratio in normalized_ratios]
|
||||||
|
leakage_flow_output = [
|
||||||
|
self._flow_from_m3s(value_m3s, output_flow_unit)
|
||||||
|
for value_m3s in leakage_flow_m3s
|
||||||
|
]
|
||||||
|
|
||||||
|
result_df = pd.DataFrame(
|
||||||
|
{
|
||||||
|
"Area": self.area_ids,
|
||||||
|
"LeakageRatioRaw": best_ind,
|
||||||
|
"LeakageRatio": normalized_ratios,
|
||||||
|
"LeakageFlow_m3_per_s": leakage_flow_m3s,
|
||||||
|
f"LeakageFlow_{output_flow_unit.replace('/', '_per_')}": leakage_flow_output,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result_path = None
|
||||||
|
if save_result:
|
||||||
|
result_path = os.path.join(
|
||||||
|
output_dir, f"identified_leakage_{observed_name}"
|
||||||
|
)
|
||||||
|
result_df.to_csv(result_path, index=False)
|
||||||
|
print(f"结果已保存至 {result_path}")
|
||||||
|
result_df.attrs["result_path"] = result_path
|
||||||
|
return result_df
|
||||||
|
|
||||||
|
|
||||||
|
class _ProgressCallback(Callback):
|
||||||
|
"""每代回调:记录进度。"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.gen_times = []
|
||||||
|
self._t_last = None
|
||||||
|
|
||||||
|
def notify(self, algorithm):
|
||||||
|
now = time.time()
|
||||||
|
if self._t_last is not None:
|
||||||
|
self.gen_times.append(now - self._t_last)
|
||||||
|
self._t_last = now
|
||||||
|
|
||||||
|
|
||||||
|
class LeakageProblem(Problem):
|
||||||
|
"""pymoo 批量评估问题定义。
|
||||||
|
|
||||||
|
搜索空间:n 维 [0, 1] 实数 -> 通过 _effective_area_ratios 归一化到单纯形。
|
||||||
|
目标:模拟压力与观测压力之间的归一化误差范数。
|
||||||
|
无显式约束(sum=1 由归一化自动保证)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
wn,
|
||||||
|
nodes_by_area,
|
||||||
|
area_ids,
|
||||||
|
sensor_nodes,
|
||||||
|
observed_data,
|
||||||
|
q_sum: float = 0.2,
|
||||||
|
n_workers: int = DEFAULT_N_WORKERS,
|
||||||
|
inp_path: str | None = None,
|
||||||
|
):
|
||||||
|
n_var = len(area_ids)
|
||||||
|
|
||||||
|
super().__init__(
|
||||||
|
n_var=n_var,
|
||||||
|
n_obj=1,
|
||||||
|
n_ieq_constr=0,
|
||||||
|
xl=np.zeros(n_var),
|
||||||
|
xu=np.ones(n_var),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.wn = wn
|
||||||
|
self.nodes_by_area = nodes_by_area
|
||||||
|
self.area_ids = area_ids
|
||||||
|
self.sensor_nodes = sensor_nodes
|
||||||
|
self.q_sum = q_sum
|
||||||
|
self.n_workers = max(1, int(n_workers))
|
||||||
|
self.inp_path = inp_path
|
||||||
|
|
||||||
|
# 预处理观测数据以匹配模拟格式
|
||||||
|
try:
|
||||||
|
missing_sensors = [
|
||||||
|
s for s in self.sensor_nodes if s not in observed_data.columns
|
||||||
|
]
|
||||||
|
if not missing_sensors:
|
||||||
|
self.obs_matrix = observed_data[self.sensor_nodes].values
|
||||||
|
else:
|
||||||
|
self.obs_matrix = observed_data.values[:, : len(self.sensor_nodes)]
|
||||||
|
except Exception:
|
||||||
|
self.obs_matrix = observed_data.values[:, : len(self.sensor_nodes)]
|
||||||
|
|
||||||
|
duration_sec = float(self.wn.options.time.duration)
|
||||||
|
step_sec = float(self.wn.options.time.hydraulic_timestep)
|
||||||
|
if step_sec > 0:
|
||||||
|
max_steps = int(duration_sec / step_sec) + 1
|
||||||
|
self.obs_matrix = self.obs_matrix[:max_steps, :]
|
||||||
|
|
||||||
|
# 预先缓存每个区域的需水对象,减少每次适应度计算的节点查找
|
||||||
|
self.demand_objs_by_area = {}
|
||||||
|
for area_id in self.area_ids:
|
||||||
|
demand_objs = []
|
||||||
|
for node_name in self.nodes_by_area.get(area_id, []):
|
||||||
|
if node_name not in self.wn.node_name_list:
|
||||||
|
continue
|
||||||
|
node = self.wn.get_node(node_name)
|
||||||
|
if (
|
||||||
|
hasattr(node, "demand_timeseries_list")
|
||||||
|
and len(node.demand_timeseries_list) > 0
|
||||||
|
):
|
||||||
|
demand_objs.append(node.demand_timeseries_list[0])
|
||||||
|
self.demand_objs_by_area[area_id] = demand_objs
|
||||||
|
|
||||||
|
self.allocatable_counts = {
|
||||||
|
area_id: len(self.demand_objs_by_area.get(area_id, []))
|
||||||
|
for area_id in self.area_ids
|
||||||
|
}
|
||||||
|
if not any(count > 0 for count in self.allocatable_counts.values()):
|
||||||
|
raise ValueError("没有可分配漏损的有效分区,无法满足漏损总量约束。")
|
||||||
|
|
||||||
|
# 评估计数器(诊断用)
|
||||||
|
self._eval_count = 0
|
||||||
|
self._pool = None
|
||||||
|
if self.n_workers > 1:
|
||||||
|
if not self.inp_path:
|
||||||
|
raise ValueError("并行评估需要提供 inp_path。")
|
||||||
|
duration_sec = float(self.wn.options.time.duration)
|
||||||
|
timestep_sec = float(self.wn.options.time.hydraulic_timestep)
|
||||||
|
self._pool = Pool(
|
||||||
|
processes=self.n_workers,
|
||||||
|
initializer=_worker_init,
|
||||||
|
initargs=(
|
||||||
|
self.inp_path,
|
||||||
|
list(self.sensor_nodes),
|
||||||
|
list(self.area_ids),
|
||||||
|
{k: list(v) for k, v in self.nodes_by_area.items()},
|
||||||
|
self.obs_matrix.copy(),
|
||||||
|
self.q_sum,
|
||||||
|
duration_sec,
|
||||||
|
timestep_sec,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _evaluate(self, X, out, *args, **kwargs):
|
||||||
|
"""批量评估种群。
|
||||||
|
|
||||||
|
X: 形状 (pop_size, n_var) 的决策变量矩阵。
|
||||||
|
"""
|
||||||
|
n_pop = X.shape[0]
|
||||||
|
self._eval_count += n_pop
|
||||||
|
|
||||||
|
if self._pool is not None:
|
||||||
|
results = self._pool.map(_worker_evaluate, [X[i] for i in range(n_pop)])
|
||||||
|
out["F"] = np.array(results, dtype=float).reshape(-1, 1)
|
||||||
|
return
|
||||||
|
|
||||||
|
F = np.zeros((n_pop, 1))
|
||||||
|
for i in range(n_pop):
|
||||||
|
F[i, 0] = self._evaluate_single(X[i])
|
||||||
|
out["F"] = F
|
||||||
|
|
||||||
|
def _evaluate_single(self, x):
|
||||||
|
"""评估单个个体,返回归一化误差范数。"""
|
||||||
|
leak_ratios = x
|
||||||
|
|
||||||
|
# 将漏损分布归一化
|
||||||
|
effective_ratio_map = DmaLeakageOptimizer._effective_area_ratios(
|
||||||
|
leak_ratios,
|
||||||
|
self.area_ids,
|
||||||
|
self.nodes_by_area,
|
||||||
|
allocatable_counts=self.allocatable_counts,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 跟踪修改以便稍后恢复
|
||||||
|
modifications = []
|
||||||
|
|
||||||
|
for j, area_id in enumerate(self.area_ids):
|
||||||
|
ratio = effective_ratio_map.get(area_id, 0.0)
|
||||||
|
if ratio <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
demand_objs = self.demand_objs_by_area.get(area_id, [])
|
||||||
|
if not demand_objs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
per_node_leak = self.q_sum * ratio / len(demand_objs)
|
||||||
|
|
||||||
|
for demand_obj in demand_objs:
|
||||||
|
original_val = demand_obj.base_value
|
||||||
|
demand_obj.base_value = original_val + per_node_leak
|
||||||
|
modifications.append((demand_obj, original_val))
|
||||||
|
|
||||||
|
# 结果保存在根目录的temp/leakage文件夹中
|
||||||
|
temp_dir = os.path.abspath(os.path.join("temp", "leakage"))
|
||||||
|
os.makedirs(temp_dir, exist_ok=True)
|
||||||
|
prefix = os.path.join(temp_dir, f"temp_{os.getpid()}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
sim = wntr.sim.EpanetSimulator(self.wn)
|
||||||
|
results = sim.run_sim(file_prefix=prefix)
|
||||||
|
sim_pressure = results.node["pressure"].loc[:, self.sensor_nodes]
|
||||||
|
|
||||||
|
n_steps = min(sim_pressure.shape[0], self.obs_matrix.shape[0])
|
||||||
|
sim_vals = sim_pressure.values[:n_steps, :]
|
||||||
|
obs_vals = self.obs_matrix[:n_steps, :]
|
||||||
|
|
||||||
|
diff = sim_vals - obs_vals
|
||||||
|
|
||||||
|
# 按行最大值归一化
|
||||||
|
row_max = np.max(np.abs(diff), axis=1, keepdims=True)
|
||||||
|
row_max[row_max == 0] = 1.0 # 防止除以零
|
||||||
|
|
||||||
|
normalized_diff = diff / row_max
|
||||||
|
|
||||||
|
# 目标:归一化差值矩阵的 2-范数
|
||||||
|
return float(np.linalg.norm(normalized_diff))
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
return 1e9
|
||||||
|
|
||||||
|
finally:
|
||||||
|
for demand_obj, original_val in modifications:
|
||||||
|
demand_obj.base_value = original_val
|
||||||
|
|
||||||
|
_cleanup_temp_files(prefix)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if self._pool is not None:
|
||||||
|
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)
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
|||||||
|
from app.algorithms.pipe_health_prediction.survival_predictor import (
|
||||||
|
PipeHealthSurvivalPredictor,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = ["PipeHealthSurvivalPredictor"]
|
||||||
+16
-20
@@ -4,7 +4,7 @@ import pandas as pd
|
|||||||
import matplotlib.pyplot as plt
|
import matplotlib.pyplot as plt
|
||||||
|
|
||||||
|
|
||||||
class PipelineHealthAnalyzer:
|
class PipeHealthSurvivalPredictor:
|
||||||
"""
|
"""
|
||||||
管道健康分析器类,使用随机生存森林模型预测管道的生存概率。
|
管道健康分析器类,使用随机生存森林模型预测管道的生存概率。
|
||||||
|
|
||||||
@@ -14,19 +14,20 @@ class PipelineHealthAnalyzer:
|
|||||||
使用前需确保安装依赖:joblib, pandas, numpy, scikit-survival, matplotlib。
|
使用前需确保安装依赖:joblib, pandas, numpy, scikit-survival, matplotlib。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, model_path: str = "model/my_survival_forest_model_quxi.joblib"):
|
def __init__(self, model_path: str = None):
|
||||||
"""
|
"""
|
||||||
初始化分析器,加载预训练的随机生存森林模型。
|
初始化分析器,加载预训练的随机生存森林模型。
|
||||||
|
|
||||||
:param model_path: 模型文件的路径(默认为相对路径 'model/my_survival_forest_model_quxi.joblib')。
|
:param model_path: 模型文件的路径(默认为相对路径 './model/my_survival_forest_model_quxi.joblib')。
|
||||||
:raises FileNotFoundError: 如果模型文件不存在。
|
:raises FileNotFoundError: 如果模型文件不存在。
|
||||||
:raises Exception: 如果模型加载失败。
|
:raises Exception: 如果模型加载失败。
|
||||||
"""
|
"""
|
||||||
# 确保 model 目录存在
|
if model_path is None:
|
||||||
model_dir = os.path.dirname(model_path)
|
model_path = os.path.join(
|
||||||
if model_dir and not os.path.exists(model_dir):
|
os.path.dirname(__file__),
|
||||||
os.makedirs(model_dir, exist_ok=True)
|
"model",
|
||||||
|
"my_survival_forest_model_quxi.joblib",
|
||||||
|
)
|
||||||
if not os.path.exists(model_path):
|
if not os.path.exists(model_path):
|
||||||
raise FileNotFoundError(f"模型文件未找到: {model_path}")
|
raise FileNotFoundError(f"模型文件未找到: {model_path}")
|
||||||
|
|
||||||
@@ -96,7 +97,7 @@ class PipelineHealthAnalyzer:
|
|||||||
|
|
||||||
# 调用说明示例
|
# 调用说明示例
|
||||||
"""
|
"""
|
||||||
在其他项目中使用PipelineHealthAnalyzer类的步骤:
|
在其他项目中使用 PipeHealthSurvivalPredictor 类的步骤:
|
||||||
|
|
||||||
1. 安装依赖(在requirements.txt中添加):
|
1. 安装依赖(在requirements.txt中添加):
|
||||||
joblib==1.5.0
|
joblib==1.5.0
|
||||||
@@ -106,34 +107,29 @@ class PipelineHealthAnalyzer:
|
|||||||
matplotlib==3.9.4
|
matplotlib==3.9.4
|
||||||
|
|
||||||
2. 导入类:
|
2. 导入类:
|
||||||
from pipeline_health_analyzer import PipelineHealthAnalyzer
|
from survival_predictor import PipeHealthSurvivalPredictor
|
||||||
|
|
||||||
3. 初始化分析器(替换为实际模型路径):
|
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
|
import pandas as pd
|
||||||
data = pd.DataFrame({
|
data = pd.DataFrame({
|
||||||
'Material': [1, 2], # 示例数据
|
'Material': [1, 2], # 示例数据
|
||||||
'Diameter': [100, 150],
|
'Diameter': [100, 150],
|
||||||
'Flow Velocity': [1.5, 2.0],
|
'Flow Velocity': [1.5, 2.0],
|
||||||
'Pressure': [50, 60],
|
'Pressure': [50, 60]
|
||||||
'Temperature': [20, 25],
|
|
||||||
'Precipitation': [0.1, 0.2],
|
|
||||||
'Location': [1, 2],
|
|
||||||
'Structural Defects': [0, 1],
|
|
||||||
'Functional Defects': [0, 0]
|
|
||||||
})
|
})
|
||||||
|
|
||||||
5. 进行预测:
|
5. 进行预测:
|
||||||
survival_funcs = analyzer.predict_survival(data)
|
survival_funcs = predictor.predict_survival(data)
|
||||||
|
|
||||||
6. 查看结果(每个样本的生存概率随时间变化):
|
6. 查看结果(每个样本的生存概率随时间变化):
|
||||||
for i, sf in enumerate(survival_funcs):
|
for i, sf in enumerate(survival_funcs):
|
||||||
print(f"样本 {i+1}: 时间点: {sf.x[:5]}..., 生存概率: {sf.y[:5]}...")
|
print(f"样本 {i+1}: 时间点: {sf.x[:5]}..., 生存概率: {sf.y[:5]}...")
|
||||||
|
|
||||||
7. 可视化(可选):
|
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()
|
||||||
@@ -0,0 +1,905 @@
|
|||||||
|
"""Pressure sensor placement based on scalable sensitivity analysis.
|
||||||
|
|
||||||
|
The original implementation expanded a sparse water network into several dense
|
||||||
|
``node x node``, ``node x pipe``, and ``pipe x pipe`` matrices. That made the
|
||||||
|
memory requirement quadratic and the explicit matrix inverse cubic in time.
|
||||||
|
|
||||||
|
This module keeps one algorithm for every network size:
|
||||||
|
|
||||||
|
* run EPANET once and reuse the first hydraulic state;
|
||||||
|
* keep incidence and hydraulic graphs sparse;
|
||||||
|
* estimate the row-wise L1 pressure sensitivity with deterministic Cauchy
|
||||||
|
projections and one sparse factorization;
|
||||||
|
* estimate total directed hydraulic distance from a deterministic spatial
|
||||||
|
coreset without materialising an all-pairs distance matrix;
|
||||||
|
* balance sensitivity score with geographic and pipe-network coverage without
|
||||||
|
materialising candidate-to-candidate distances.
|
||||||
|
|
||||||
|
The random seed and sample counts are fixed, so the same model and request
|
||||||
|
produce the same placement on every run.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from time import perf_counter
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import wntr
|
||||||
|
from scipy.sparse import csr_matrix, eye
|
||||||
|
from scipy.sparse.csgraph import connected_components, dijkstra
|
||||||
|
from scipy.sparse.linalg import splu
|
||||||
|
from sklearn.cluster import MiniBatchKMeans
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_RANDOM_SEED = 42
|
||||||
|
_SENSITIVITY_PROJECTIONS = 256
|
||||||
|
_HYDRAULIC_LANDMARKS = 256
|
||||||
|
_PROJECTION_BLOCK_SIZE = 16
|
||||||
|
_DIJKSTRA_BLOCK_SIZE = 16
|
||||||
|
_HEADLOSS_EPSILON = 1e-10
|
||||||
|
_DIAMETER_TOLERANCE_MM = 1e-9
|
||||||
|
_COVERAGE_ELIGIBILITY_RATIO = 0.70
|
||||||
|
_COVERAGE_EDGE_EPSILON = 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _PreparedNetwork:
|
||||||
|
"""Sparse data required by the placement pipeline."""
|
||||||
|
|
||||||
|
node_names: tuple[str, ...]
|
||||||
|
full_node_indices: np.ndarray
|
||||||
|
candidate_indices: np.ndarray
|
||||||
|
coordinates: np.ndarray
|
||||||
|
incidence: csr_matrix
|
||||||
|
conductance: np.ndarray
|
||||||
|
roughness_response: np.ndarray
|
||||||
|
distance_graph: csr_matrix
|
||||||
|
coverage_graph: csr_matrix
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _CandidatePool:
|
||||||
|
"""Aligned candidate arrays consumed by the placement stage."""
|
||||||
|
|
||||||
|
full_indices: np.ndarray
|
||||||
|
coordinates: np.ndarray
|
||||||
|
names: np.ndarray
|
||||||
|
scores: np.ndarray
|
||||||
|
|
||||||
|
|
||||||
|
def _run_hydraulic_simulation(
|
||||||
|
wn: wntr.network.WaterNetworkModel,
|
||||||
|
):
|
||||||
|
"""Run only the initial EPANET state without shared ``temp.*`` files."""
|
||||||
|
|
||||||
|
original_duration = wn.options.time.duration
|
||||||
|
try:
|
||||||
|
# Every downstream calculation reads ``iloc[0]``. Running an extended
|
||||||
|
# simulation only allocates unused time-series results, which is
|
||||||
|
# especially expensive for daily models with tens of thousands of
|
||||||
|
# nodes. Restore the caller's model even when EPANET fails.
|
||||||
|
wn.options.time.duration = 0
|
||||||
|
with TemporaryDirectory(prefix="tjwater-sensitivity-") as temp_dir:
|
||||||
|
file_prefix = str(Path(temp_dir) / "simulation")
|
||||||
|
return wntr.sim.EpanetSimulator(wn).run_sim(file_prefix=file_prefix)
|
||||||
|
finally:
|
||||||
|
wn.options.time.duration = original_duration
|
||||||
|
|
||||||
|
|
||||||
|
def _excluded_elements(
|
||||||
|
wn: wntr.network.WaterNetworkModel,
|
||||||
|
) -> tuple[set[str], set[str]]:
|
||||||
|
"""Return nodes that cannot host sensors and source-connected pipes.
|
||||||
|
|
||||||
|
Reservoirs, tanks, pump/valve endpoints, and the junction immediately next
|
||||||
|
to a reservoir or tank are treated as hydraulic boundary nodes. Pipes
|
||||||
|
connected directly to a source are removed from the perturbation set, as
|
||||||
|
in the legacy algorithm.
|
||||||
|
"""
|
||||||
|
|
||||||
|
source_nodes = set(wn.reservoir_name_list) | set(wn.tank_name_list)
|
||||||
|
excluded_nodes = set(source_nodes)
|
||||||
|
source_pipes: set[str] = set()
|
||||||
|
|
||||||
|
for pipe_name, pipe in wn.pipes():
|
||||||
|
endpoints = {pipe.start_node_name, pipe.end_node_name}
|
||||||
|
if endpoints & source_nodes:
|
||||||
|
source_pipes.add(pipe_name)
|
||||||
|
excluded_nodes.update(endpoints)
|
||||||
|
|
||||||
|
for _link_name, link in list(wn.pumps()) + list(wn.valves()):
|
||||||
|
excluded_nodes.add(link.start_node_name)
|
||||||
|
excluded_nodes.add(link.end_node_name)
|
||||||
|
|
||||||
|
return excluded_nodes, source_pipes
|
||||||
|
|
||||||
|
|
||||||
|
def _minimum_weight_csr(
|
||||||
|
rows: list[int],
|
||||||
|
columns: list[int],
|
||||||
|
weights: list[float],
|
||||||
|
*,
|
||||||
|
shape: tuple[int, int],
|
||||||
|
) -> csr_matrix:
|
||||||
|
"""Build a CSR graph while retaining the lightest parallel edge."""
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return csr_matrix(shape, dtype=np.float64)
|
||||||
|
|
||||||
|
row_array = np.asarray(rows, dtype=np.int64)
|
||||||
|
column_array = np.asarray(columns, dtype=np.int64)
|
||||||
|
weight_array = np.asarray(weights, dtype=np.float64)
|
||||||
|
order = np.lexsort((column_array, row_array))
|
||||||
|
row_array = row_array[order]
|
||||||
|
column_array = column_array[order]
|
||||||
|
weight_array = weight_array[order]
|
||||||
|
|
||||||
|
group_start = np.empty(len(row_array), dtype=bool)
|
||||||
|
group_start[0] = True
|
||||||
|
group_start[1:] = (row_array[1:] != row_array[:-1]) | (
|
||||||
|
column_array[1:] != column_array[:-1]
|
||||||
|
)
|
||||||
|
starts = np.flatnonzero(group_start)
|
||||||
|
minimum_weights = np.minimum.reduceat(weight_array, starts)
|
||||||
|
return csr_matrix(
|
||||||
|
(minimum_weights, (row_array[starts], column_array[starts])),
|
||||||
|
shape=shape,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _node_coordinates(
|
||||||
|
wn: wntr.network.WaterNetworkModel,
|
||||||
|
node_names: tuple[str, ...],
|
||||||
|
) -> np.ndarray:
|
||||||
|
coordinate_series = wn.query_node_attribute("coordinates")
|
||||||
|
coordinates = np.asarray(
|
||||||
|
[coordinate_series.loc[node_name] for node_name in node_names],
|
||||||
|
dtype=np.float64,
|
||||||
|
)
|
||||||
|
if coordinates.ndim != 2 or coordinates.shape[1] < 2:
|
||||||
|
raise ValueError("管网节点缺少二维坐标,无法进行监测点空间布置")
|
||||||
|
coordinates = coordinates[:, :2]
|
||||||
|
if not np.isfinite(coordinates).all():
|
||||||
|
raise ValueError("管网节点坐标包含非有限值,无法进行监测点空间布置")
|
||||||
|
return coordinates
|
||||||
|
|
||||||
|
|
||||||
|
def _build_coverage_graph(
|
||||||
|
wn: wntr.network.WaterNetworkModel,
|
||||||
|
results,
|
||||||
|
full_node_index: dict[str, int],
|
||||||
|
) -> csr_matrix:
|
||||||
|
"""Build the active undirected physical graph used to spread sensors."""
|
||||||
|
|
||||||
|
status_series = results.link["status"].iloc[0]
|
||||||
|
rows: list[int] = []
|
||||||
|
columns: list[int] = []
|
||||||
|
weights: list[float] = []
|
||||||
|
|
||||||
|
for link_name, link in wn.links():
|
||||||
|
if float(status_series.loc[link_name]) <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
start = full_node_index[link.start_node_name]
|
||||||
|
end = full_node_index[link.end_node_name]
|
||||||
|
# Pipes carry their physical length. Pumps and valves are point
|
||||||
|
# devices, so a tiny positive length preserves connectivity without
|
||||||
|
# dominating shortest-path distance.
|
||||||
|
weight = max(
|
||||||
|
float(getattr(link, "length", 0.0)),
|
||||||
|
_COVERAGE_EDGE_EPSILON,
|
||||||
|
)
|
||||||
|
rows.extend((start, end))
|
||||||
|
columns.extend((end, start))
|
||||||
|
weights.extend((weight, weight))
|
||||||
|
|
||||||
|
return _minimum_weight_csr(
|
||||||
|
rows,
|
||||||
|
columns,
|
||||||
|
weights,
|
||||||
|
shape=(len(full_node_index), len(full_node_index)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_network(
|
||||||
|
wn: wntr.network.WaterNetworkModel,
|
||||||
|
results,
|
||||||
|
*,
|
||||||
|
min_diameter: int,
|
||||||
|
) -> _PreparedNetwork:
|
||||||
|
excluded_nodes, source_pipes = _excluded_elements(wn)
|
||||||
|
full_node_names = tuple(wn.node_name_list)
|
||||||
|
full_node_index = {
|
||||||
|
node_name: index for index, node_name in enumerate(full_node_names)
|
||||||
|
}
|
||||||
|
node_names = tuple(
|
||||||
|
node_name for node_name in full_node_names if node_name not in excluded_nodes
|
||||||
|
)
|
||||||
|
if not node_names:
|
||||||
|
raise ValueError("管网中没有可参与灵敏度分析的节点")
|
||||||
|
|
||||||
|
node_index = {node_name: index for index, node_name in enumerate(node_names)}
|
||||||
|
full_node_indices = np.asarray(
|
||||||
|
[full_node_index[node_name] for node_name in node_names],
|
||||||
|
dtype=np.int64,
|
||||||
|
)
|
||||||
|
coordinates = _node_coordinates(wn, node_names)
|
||||||
|
|
||||||
|
flow_series = results.link["flowrate"].iloc[0]
|
||||||
|
headloss_series = results.link["headloss"].iloc[0]
|
||||||
|
head_series = results.node["head"].iloc[0]
|
||||||
|
|
||||||
|
candidate_nodes: set[str] = set()
|
||||||
|
for _pipe_name, pipe in wn.pipes():
|
||||||
|
diameter_mm = float(pipe.diameter) * 1000.0
|
||||||
|
if diameter_mm + _DIAMETER_TOLERANCE_MM < min_diameter:
|
||||||
|
continue
|
||||||
|
if pipe.start_node_name in node_index:
|
||||||
|
candidate_nodes.add(pipe.start_node_name)
|
||||||
|
if pipe.end_node_name in node_index:
|
||||||
|
candidate_nodes.add(pipe.end_node_name)
|
||||||
|
|
||||||
|
incidence_rows: list[int] = []
|
||||||
|
incidence_columns: list[int] = []
|
||||||
|
incidence_values: list[float] = []
|
||||||
|
conductance: list[float] = []
|
||||||
|
roughness_response: list[float] = []
|
||||||
|
distance_rows: list[int] = []
|
||||||
|
distance_columns: list[int] = []
|
||||||
|
distance_weights: list[float] = []
|
||||||
|
|
||||||
|
kept_pipe_count = 0
|
||||||
|
for pipe_name, pipe in wn.pipes():
|
||||||
|
if pipe_name in source_pipes:
|
||||||
|
continue
|
||||||
|
|
||||||
|
start_name = pipe.start_node_name
|
||||||
|
end_name = pipe.end_node_name
|
||||||
|
if start_name not in node_index and end_name not in node_index:
|
||||||
|
continue
|
||||||
|
|
||||||
|
flow = float(flow_series.loc[pipe_name])
|
||||||
|
absolute_flow = abs(flow)
|
||||||
|
headloss = abs(float(headloss_series.loc[pipe_name]))
|
||||||
|
roughness = float(pipe.roughness)
|
||||||
|
if roughness <= 0:
|
||||||
|
raise ValueError(f"管道 {pipe_name} 的粗糙度必须大于 0")
|
||||||
|
|
||||||
|
orientation = -1.0 if flow < 0 else 1.0
|
||||||
|
if start_name in node_index:
|
||||||
|
incidence_rows.append(node_index[start_name])
|
||||||
|
incidence_columns.append(kept_pipe_count)
|
||||||
|
incidence_values.append(-orientation)
|
||||||
|
if end_name in node_index:
|
||||||
|
incidence_rows.append(node_index[end_name])
|
||||||
|
incidence_columns.append(kept_pipe_count)
|
||||||
|
incidence_values.append(orientation)
|
||||||
|
|
||||||
|
conductance.append(
|
||||||
|
absolute_flow / (1.852 * headloss + _HEADLOSS_EPSILON)
|
||||||
|
)
|
||||||
|
roughness_response.append(absolute_flow / roughness)
|
||||||
|
|
||||||
|
if flow > 0:
|
||||||
|
upstream_name, downstream_name = start_name, end_name
|
||||||
|
else:
|
||||||
|
upstream_name, downstream_name = end_name, start_name
|
||||||
|
hydraulic_weight = (
|
||||||
|
abs(float(head_series.loc[start_name]) - float(head_series.loc[end_name]))
|
||||||
|
* float(pipe.length)
|
||||||
|
)
|
||||||
|
distance_rows.append(full_node_index[upstream_name])
|
||||||
|
distance_columns.append(full_node_index[downstream_name])
|
||||||
|
distance_weights.append(hydraulic_weight)
|
||||||
|
kept_pipe_count += 1
|
||||||
|
|
||||||
|
if kept_pipe_count == 0:
|
||||||
|
raise ValueError("管网中没有可用于灵敏度分析的管道")
|
||||||
|
|
||||||
|
incidence = csr_matrix(
|
||||||
|
(
|
||||||
|
np.asarray(incidence_values, dtype=np.float64),
|
||||||
|
(
|
||||||
|
np.asarray(incidence_rows, dtype=np.int64),
|
||||||
|
np.asarray(incidence_columns, dtype=np.int64),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
shape=(len(node_names), kept_pipe_count),
|
||||||
|
)
|
||||||
|
conductance_array = np.asarray(conductance, dtype=np.float64)
|
||||||
|
response_array = np.asarray(roughness_response, dtype=np.float64)
|
||||||
|
if not np.isfinite(conductance_array).all() or not np.isfinite(
|
||||||
|
response_array
|
||||||
|
).all():
|
||||||
|
raise ValueError("水力结果产生了非有限灵敏度系数")
|
||||||
|
|
||||||
|
distance_graph = _minimum_weight_csr(
|
||||||
|
distance_rows,
|
||||||
|
distance_columns,
|
||||||
|
distance_weights,
|
||||||
|
shape=(len(full_node_names), len(full_node_names)),
|
||||||
|
)
|
||||||
|
coverage_graph = _build_coverage_graph(wn, results, full_node_index)
|
||||||
|
candidate_indices = np.asarray(
|
||||||
|
[
|
||||||
|
index
|
||||||
|
for index, node_name in enumerate(node_names)
|
||||||
|
if node_name in candidate_nodes
|
||||||
|
],
|
||||||
|
dtype=np.int64,
|
||||||
|
)
|
||||||
|
|
||||||
|
return _PreparedNetwork(
|
||||||
|
node_names=node_names,
|
||||||
|
full_node_indices=full_node_indices,
|
||||||
|
candidate_indices=candidate_indices,
|
||||||
|
coordinates=coordinates,
|
||||||
|
incidence=incidence,
|
||||||
|
conductance=conductance_array,
|
||||||
|
roughness_response=response_array,
|
||||||
|
distance_graph=distance_graph,
|
||||||
|
coverage_graph=coverage_graph,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _axis_normalized_coordinates(coordinates: np.ndarray) -> np.ndarray:
|
||||||
|
"""Scale each axis independently for MiniBatchKMeans."""
|
||||||
|
|
||||||
|
minimum = coordinates.min(axis=0)
|
||||||
|
span = np.ptp(coordinates, axis=0)
|
||||||
|
span[span == 0] = 1.0
|
||||||
|
return (coordinates - minimum) / span
|
||||||
|
|
||||||
|
|
||||||
|
def _isotropic_coordinates(coordinates: np.ndarray) -> np.ndarray:
|
||||||
|
"""Normalize coordinates without distorting the network aspect ratio."""
|
||||||
|
|
||||||
|
minimum = coordinates.min(axis=0)
|
||||||
|
scale = float(np.max(np.ptp(coordinates, axis=0), initial=0.0))
|
||||||
|
if scale == 0:
|
||||||
|
scale = 1.0
|
||||||
|
return (coordinates - minimum) / scale
|
||||||
|
|
||||||
|
|
||||||
|
def _cluster_labels(
|
||||||
|
coordinates: np.ndarray,
|
||||||
|
cluster_count: int,
|
||||||
|
*,
|
||||||
|
random_seed: int,
|
||||||
|
) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""Cluster coordinates deterministically with one implementation at all sizes."""
|
||||||
|
|
||||||
|
normalized = _axis_normalized_coordinates(coordinates)
|
||||||
|
if cluster_count == 1:
|
||||||
|
return np.zeros(len(coordinates), dtype=np.int64), normalized[[0]]
|
||||||
|
if cluster_count >= len(coordinates):
|
||||||
|
return np.arange(len(coordinates), dtype=np.int64), normalized.copy()
|
||||||
|
|
||||||
|
model = MiniBatchKMeans(
|
||||||
|
n_clusters=cluster_count,
|
||||||
|
random_state=random_seed,
|
||||||
|
n_init=3,
|
||||||
|
batch_size=min(len(coordinates), max(1024, cluster_count * 3)),
|
||||||
|
max_iter=100,
|
||||||
|
max_no_improvement=20,
|
||||||
|
reassignment_ratio=0.0,
|
||||||
|
)
|
||||||
|
labels = model.fit_predict(normalized).astype(np.int64, copy=False)
|
||||||
|
return labels, np.asarray(model.cluster_centers_, dtype=np.float64)
|
||||||
|
|
||||||
|
|
||||||
|
def _estimate_log_pressure_sensitivity(prepared: _PreparedNetwork) -> np.ndarray:
|
||||||
|
"""Estimate each row's L1 sensitivity using streaming Cauchy projections."""
|
||||||
|
|
||||||
|
weighted_incidence = prepared.incidence.multiply(prepared.conductance)
|
||||||
|
laplacian = (weighted_incidence @ prepared.incidence.T).tocsc()
|
||||||
|
diagonal = np.asarray(laplacian.diagonal(), dtype=np.float64)
|
||||||
|
diagonal_scale = float(np.max(np.abs(diagonal), initial=0.0))
|
||||||
|
if diagonal_scale == 0:
|
||||||
|
raise ValueError("水力雅可比矩阵为空,无法计算压力灵敏度")
|
||||||
|
|
||||||
|
regularization = diagonal_scale * np.sqrt(np.finfo(np.float64).eps)
|
||||||
|
laplacian = laplacian + eye(
|
||||||
|
laplacian.shape[0], format="csc", dtype=np.float64
|
||||||
|
) * regularization
|
||||||
|
factor = splu(
|
||||||
|
laplacian,
|
||||||
|
permc_spec="MMD_AT_PLUS_A",
|
||||||
|
diag_pivot_thresh=0.0,
|
||||||
|
options={"SymmetricMode": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
random = np.random.default_rng(_RANDOM_SEED)
|
||||||
|
log_absolute_sum = np.zeros(len(prepared.node_names), dtype=np.float64)
|
||||||
|
projection_count = 0
|
||||||
|
float_epsilon = np.finfo(np.float64).eps
|
||||||
|
float_tiny = np.finfo(np.float64).tiny
|
||||||
|
|
||||||
|
while projection_count < _SENSITIVITY_PROJECTIONS:
|
||||||
|
block_size = min(
|
||||||
|
_PROJECTION_BLOCK_SIZE,
|
||||||
|
_SENSITIVITY_PROJECTIONS - projection_count,
|
||||||
|
)
|
||||||
|
uniform = random.random((prepared.incidence.shape[1], block_size))
|
||||||
|
np.clip(uniform, float_epsilon, 1.0 - float_epsilon, out=uniform)
|
||||||
|
cauchy_projection = np.tan(np.pi * (uniform - 0.5))
|
||||||
|
projected_response = prepared.incidence @ (
|
||||||
|
prepared.roughness_response[:, None] * cauchy_projection
|
||||||
|
)
|
||||||
|
solution = factor.solve(np.asarray(projected_response, dtype=np.float64))
|
||||||
|
log_absolute_sum += np.log(
|
||||||
|
np.maximum(np.abs(solution), float_tiny)
|
||||||
|
).sum(axis=1)
|
||||||
|
projection_count += block_size
|
||||||
|
|
||||||
|
# For a standard Cauchy variable E[log(abs(X))] is zero. Therefore this
|
||||||
|
# streaming geometric mean estimates log(||row||_1) without retaining the
|
||||||
|
# node-by-projection matrix. A finite-sample bias is common to all rows and
|
||||||
|
# does not affect ranking.
|
||||||
|
return log_absolute_sum / _SENSITIVITY_PROJECTIONS
|
||||||
|
|
||||||
|
|
||||||
|
def _landmark_coreset(prepared: _PreparedNetwork) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
landmark_count = min(_HYDRAULIC_LANDMARKS, len(prepared.node_names))
|
||||||
|
labels, centers = _cluster_labels(
|
||||||
|
prepared.coordinates,
|
||||||
|
landmark_count,
|
||||||
|
random_seed=_RANDOM_SEED + 1,
|
||||||
|
)
|
||||||
|
normalized = _axis_normalized_coordinates(prepared.coordinates)
|
||||||
|
landmarks: list[int] = []
|
||||||
|
weights: list[float] = []
|
||||||
|
|
||||||
|
for label in np.unique(labels):
|
||||||
|
members = np.flatnonzero(labels == label)
|
||||||
|
center = centers[int(label)]
|
||||||
|
squared_distance = np.square(normalized[members] - center).sum(axis=1)
|
||||||
|
landmarks.append(int(members[int(np.argmin(squared_distance))]))
|
||||||
|
weights.append(float(len(members)))
|
||||||
|
|
||||||
|
return (
|
||||||
|
np.asarray(landmarks, dtype=np.int64),
|
||||||
|
np.asarray(weights, dtype=np.float64),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _estimate_hydraulic_distance_sums(prepared: _PreparedNetwork) -> np.ndarray:
|
||||||
|
"""Estimate outbound distance sums without an all-pairs distance matrix."""
|
||||||
|
|
||||||
|
landmark_indices, landmark_weights = _landmark_coreset(prepared)
|
||||||
|
full_landmark_indices = prepared.full_node_indices[landmark_indices]
|
||||||
|
reversed_graph = prepared.distance_graph.transpose().tocsr()
|
||||||
|
distance_sums = np.zeros(len(prepared.node_names), dtype=np.float64)
|
||||||
|
|
||||||
|
for start in range(0, len(landmark_indices), _DIJKSTRA_BLOCK_SIZE):
|
||||||
|
stop = min(start + _DIJKSTRA_BLOCK_SIZE, len(landmark_indices))
|
||||||
|
distances = dijkstra(
|
||||||
|
reversed_graph,
|
||||||
|
directed=True,
|
||||||
|
indices=full_landmark_indices[start:stop],
|
||||||
|
return_predecessors=False,
|
||||||
|
)
|
||||||
|
distances = np.atleast_2d(distances)[:, prepared.full_node_indices]
|
||||||
|
# The legacy matrix represented unreachable pairs as zero. Retaining
|
||||||
|
# that convention prevents disconnected branches from receiving an
|
||||||
|
# artificial infinite score.
|
||||||
|
distances[~np.isfinite(distances)] = 0.0
|
||||||
|
distance_sums += landmark_weights[start:stop] @ distances
|
||||||
|
|
||||||
|
return distance_sums
|
||||||
|
|
||||||
|
|
||||||
|
def _build_candidate_pool(
|
||||||
|
prepared: _PreparedNetwork,
|
||||||
|
log_sensitivity: np.ndarray,
|
||||||
|
hydraulic_distance_sums: np.ndarray,
|
||||||
|
) -> _CandidatePool:
|
||||||
|
candidate_indices = prepared.candidate_indices
|
||||||
|
candidate_distance = hydraulic_distance_sums[candidate_indices]
|
||||||
|
with np.errstate(divide="ignore", invalid="ignore"):
|
||||||
|
scores = log_sensitivity[candidate_indices] + np.log(candidate_distance)
|
||||||
|
scores = np.nan_to_num(
|
||||||
|
scores,
|
||||||
|
nan=-np.inf,
|
||||||
|
neginf=-np.inf,
|
||||||
|
posinf=np.finfo(np.float64).max,
|
||||||
|
)
|
||||||
|
return _CandidatePool(
|
||||||
|
full_indices=prepared.full_node_indices[candidate_indices],
|
||||||
|
coordinates=_isotropic_coordinates(prepared.coordinates[candidate_indices]),
|
||||||
|
names=np.asarray(
|
||||||
|
[prepared.node_names[index] for index in candidate_indices],
|
||||||
|
dtype=str,
|
||||||
|
),
|
||||||
|
scores=scores,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _highest_scoring_position(
|
||||||
|
names: np.ndarray,
|
||||||
|
scores: np.ndarray,
|
||||||
|
positions: np.ndarray,
|
||||||
|
) -> int:
|
||||||
|
"""Return the best position, breaking score ties by node name."""
|
||||||
|
|
||||||
|
order = np.lexsort((names[positions], -scores[positions]))
|
||||||
|
return int(positions[order[0]])
|
||||||
|
|
||||||
|
|
||||||
|
def _relative_gap(
|
||||||
|
distances: np.ndarray,
|
||||||
|
available: np.ndarray,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Normalize available distances to their current finite maximum."""
|
||||||
|
|
||||||
|
maximum = float(np.max(distances[available], initial=0.0))
|
||||||
|
if not np.isfinite(maximum) or maximum <= np.finfo(np.float64).eps:
|
||||||
|
return np.zeros(len(distances), dtype=np.float64)
|
||||||
|
return distances / maximum
|
||||||
|
|
||||||
|
|
||||||
|
def _eligible_gap_positions(
|
||||||
|
relative_gap: np.ndarray,
|
||||||
|
available: np.ndarray,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Return positions within the configured fraction of the largest gap."""
|
||||||
|
|
||||||
|
maximum = float(np.max(relative_gap[available], initial=0.0))
|
||||||
|
if maximum <= np.finfo(np.float64).eps:
|
||||||
|
return np.flatnonzero(available)
|
||||||
|
threshold = _COVERAGE_ELIGIBILITY_RATIO * maximum
|
||||||
|
return np.flatnonzero(
|
||||||
|
available & (relative_gap >= threshold - np.finfo(np.float64).eps)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _allocate_component_quotas(
|
||||||
|
coverage_graph: csr_matrix,
|
||||||
|
candidate_full_indices: np.ndarray,
|
||||||
|
candidate_scores: np.ndarray,
|
||||||
|
*,
|
||||||
|
sensor_num: int,
|
||||||
|
) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""Allocate sensor counts by active pipe length with candidate caps."""
|
||||||
|
|
||||||
|
component_count, node_components = connected_components(
|
||||||
|
coverage_graph,
|
||||||
|
directed=False,
|
||||||
|
return_labels=True,
|
||||||
|
)
|
||||||
|
candidate_components = node_components[candidate_full_indices]
|
||||||
|
capacities = np.bincount(
|
||||||
|
candidate_components,
|
||||||
|
minlength=component_count,
|
||||||
|
).astype(np.int64, copy=False)
|
||||||
|
|
||||||
|
# The graph is symmetric. Summed row weights count every physical edge
|
||||||
|
# twice, hence the division by two after aggregation by component.
|
||||||
|
node_lengths = np.asarray(coverage_graph.sum(axis=1)).ravel()
|
||||||
|
component_lengths = np.bincount(
|
||||||
|
node_components,
|
||||||
|
weights=node_lengths,
|
||||||
|
minlength=component_count,
|
||||||
|
) / 2.0
|
||||||
|
component_best_scores = np.full(component_count, -np.inf, dtype=np.float64)
|
||||||
|
np.maximum.at(
|
||||||
|
component_best_scores,
|
||||||
|
candidate_components,
|
||||||
|
candidate_scores,
|
||||||
|
)
|
||||||
|
|
||||||
|
active_components = np.flatnonzero(capacities)
|
||||||
|
quotas = np.zeros(component_count, dtype=np.int64)
|
||||||
|
if len(active_components) > sensor_num:
|
||||||
|
order = np.lexsort(
|
||||||
|
(
|
||||||
|
active_components,
|
||||||
|
-component_best_scores[active_components],
|
||||||
|
-component_lengths[active_components],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
quotas[active_components[order[:sensor_num]]] = 1
|
||||||
|
return candidate_components, quotas
|
||||||
|
|
||||||
|
quotas[active_components] = 1
|
||||||
|
remaining = sensor_num - len(active_components)
|
||||||
|
while remaining > 0:
|
||||||
|
available = active_components[
|
||||||
|
quotas[active_components] < capacities[active_components]
|
||||||
|
]
|
||||||
|
if len(available) == 0:
|
||||||
|
raise ValueError("连通区域中的候选节点不足,无法分配监测点名额")
|
||||||
|
|
||||||
|
weights = component_lengths[available]
|
||||||
|
if float(weights.sum()) <= 0:
|
||||||
|
weights = (capacities[available] - quotas[available]).astype(
|
||||||
|
np.float64,
|
||||||
|
copy=False,
|
||||||
|
)
|
||||||
|
ideal = remaining * weights / float(weights.sum())
|
||||||
|
whole = np.minimum(
|
||||||
|
np.floor(ideal).astype(np.int64),
|
||||||
|
capacities[available] - quotas[available],
|
||||||
|
)
|
||||||
|
whole_count = int(whole.sum())
|
||||||
|
if whole_count:
|
||||||
|
quotas[available] += whole
|
||||||
|
remaining -= whole_count
|
||||||
|
continue
|
||||||
|
|
||||||
|
fractional = ideal - np.floor(ideal)
|
||||||
|
order = np.lexsort(
|
||||||
|
(
|
||||||
|
available,
|
||||||
|
-component_best_scores[available],
|
||||||
|
-weights,
|
||||||
|
-fractional,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for component in available[order]:
|
||||||
|
quotas[component] += 1
|
||||||
|
remaining -= 1
|
||||||
|
if remaining == 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
return candidate_components, quotas
|
||||||
|
|
||||||
|
|
||||||
|
def _select_component_positions(
|
||||||
|
coverage_graph: csr_matrix,
|
||||||
|
candidates: _CandidatePool,
|
||||||
|
component_positions: np.ndarray,
|
||||||
|
existing_positions: list[int],
|
||||||
|
*,
|
||||||
|
quota: int,
|
||||||
|
) -> list[int]:
|
||||||
|
"""Select one component's sensors with score-aware farthest-first search."""
|
||||||
|
|
||||||
|
local_coordinates = candidates.coordinates[component_positions]
|
||||||
|
local_names = candidates.names[component_positions]
|
||||||
|
local_scores = candidates.scores[component_positions]
|
||||||
|
nearest_geographic = np.full(len(component_positions), np.inf)
|
||||||
|
nearest_topological = np.full(len(component_positions), np.inf)
|
||||||
|
|
||||||
|
for position in existing_positions:
|
||||||
|
nearest_geographic = np.minimum(
|
||||||
|
nearest_geographic,
|
||||||
|
np.linalg.norm(
|
||||||
|
local_coordinates - candidates.coordinates[position],
|
||||||
|
axis=1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing_positions:
|
||||||
|
all_local = np.ones(len(component_positions), dtype=bool)
|
||||||
|
seed_eligible = _eligible_gap_positions(
|
||||||
|
_relative_gap(nearest_geographic, all_local),
|
||||||
|
all_local,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
seed_eligible = np.arange(len(component_positions), dtype=np.int64)
|
||||||
|
|
||||||
|
seed = _highest_scoring_position(
|
||||||
|
local_names,
|
||||||
|
local_scores,
|
||||||
|
seed_eligible,
|
||||||
|
)
|
||||||
|
selected_local = [seed]
|
||||||
|
remaining = np.ones(len(component_positions), dtype=bool)
|
||||||
|
remaining[seed] = False
|
||||||
|
|
||||||
|
while len(selected_local) < quota:
|
||||||
|
newest = selected_local[-1]
|
||||||
|
geographic_distance = np.linalg.norm(
|
||||||
|
local_coordinates - local_coordinates[newest],
|
||||||
|
axis=1,
|
||||||
|
)
|
||||||
|
nearest_geographic = np.minimum(
|
||||||
|
nearest_geographic,
|
||||||
|
geographic_distance,
|
||||||
|
)
|
||||||
|
|
||||||
|
source = int(candidates.full_indices[component_positions[newest]])
|
||||||
|
topological_distance = dijkstra(
|
||||||
|
coverage_graph,
|
||||||
|
directed=False,
|
||||||
|
indices=source,
|
||||||
|
return_predecessors=False,
|
||||||
|
)[candidates.full_indices[component_positions]]
|
||||||
|
nearest_topological = np.minimum(
|
||||||
|
nearest_topological,
|
||||||
|
topological_distance,
|
||||||
|
)
|
||||||
|
|
||||||
|
coverage_gap = np.maximum(
|
||||||
|
_relative_gap(nearest_geographic, remaining),
|
||||||
|
_relative_gap(nearest_topological, remaining),
|
||||||
|
)
|
||||||
|
eligible_local = _eligible_gap_positions(coverage_gap, remaining)
|
||||||
|
next_local = _highest_scoring_position(
|
||||||
|
local_names,
|
||||||
|
local_scores,
|
||||||
|
eligible_local,
|
||||||
|
)
|
||||||
|
selected_local.append(next_local)
|
||||||
|
remaining[next_local] = False
|
||||||
|
|
||||||
|
return [int(component_positions[position]) for position in selected_local]
|
||||||
|
|
||||||
|
|
||||||
|
def _geographic_coverage_metrics(
|
||||||
|
candidate_coordinates: np.ndarray,
|
||||||
|
selected_positions: list[int],
|
||||||
|
) -> tuple[float, float, float]:
|
||||||
|
normalized = _isotropic_coordinates(candidate_coordinates)
|
||||||
|
nearest = np.full(len(normalized), np.inf)
|
||||||
|
for position in selected_positions:
|
||||||
|
nearest = np.minimum(
|
||||||
|
nearest,
|
||||||
|
np.linalg.norm(normalized - normalized[position], axis=1),
|
||||||
|
)
|
||||||
|
|
||||||
|
selected_coordinates = normalized[selected_positions]
|
||||||
|
if len(selected_positions) < 2:
|
||||||
|
minimum_gap = 0.0
|
||||||
|
else:
|
||||||
|
pairwise = np.linalg.norm(
|
||||||
|
selected_coordinates[:, None, :] - selected_coordinates[None, :, :],
|
||||||
|
axis=2,
|
||||||
|
)
|
||||||
|
np.fill_diagonal(pairwise, np.inf)
|
||||||
|
minimum_gap = float(pairwise.min())
|
||||||
|
return (
|
||||||
|
float(nearest.max()),
|
||||||
|
float(np.quantile(nearest, 0.95)),
|
||||||
|
minimum_gap,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _select_sensor_nodes(
|
||||||
|
prepared: _PreparedNetwork,
|
||||||
|
log_sensitivity: np.ndarray,
|
||||||
|
hydraulic_distance_sums: np.ndarray,
|
||||||
|
*,
|
||||||
|
sensor_num: int,
|
||||||
|
) -> list[str]:
|
||||||
|
candidate_indices = prepared.candidate_indices
|
||||||
|
if len(candidate_indices) < sensor_num:
|
||||||
|
raise ValueError(
|
||||||
|
"满足最小管径要求的候选节点少于请求的监测点数量:"
|
||||||
|
f"候选 {len(candidate_indices)} 个,请求 {sensor_num} 个"
|
||||||
|
)
|
||||||
|
|
||||||
|
candidates = _build_candidate_pool(
|
||||||
|
prepared,
|
||||||
|
log_sensitivity,
|
||||||
|
hydraulic_distance_sums,
|
||||||
|
)
|
||||||
|
candidate_components, component_quotas = _allocate_component_quotas(
|
||||||
|
prepared.coverage_graph,
|
||||||
|
candidates.full_indices,
|
||||||
|
candidates.scores,
|
||||||
|
sensor_num=sensor_num,
|
||||||
|
)
|
||||||
|
selected_positions: list[int] = []
|
||||||
|
quota_components = np.flatnonzero(component_quotas)
|
||||||
|
component_order = np.lexsort(
|
||||||
|
(quota_components, -component_quotas[quota_components])
|
||||||
|
)
|
||||||
|
for component in quota_components[component_order]:
|
||||||
|
component_positions = np.flatnonzero(candidate_components == component)
|
||||||
|
selected_positions.extend(
|
||||||
|
_select_component_positions(
|
||||||
|
prepared.coverage_graph,
|
||||||
|
candidates,
|
||||||
|
component_positions,
|
||||||
|
selected_positions,
|
||||||
|
quota=int(component_quotas[component]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
selected_array = np.asarray(selected_positions, dtype=np.int64)
|
||||||
|
selected_order = np.lexsort(
|
||||||
|
(
|
||||||
|
candidates.names[selected_array],
|
||||||
|
-candidates.scores[selected_array],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
selected_positions = selected_array[selected_order].tolist()
|
||||||
|
maximum_radius, p95_radius, minimum_gap = _geographic_coverage_metrics(
|
||||||
|
prepared.coordinates[candidate_indices],
|
||||||
|
selected_positions,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Sensitivity placement coverage: components=%d max_radius=%.6f "
|
||||||
|
"p95_radius=%.6f min_sensor_gap=%.6f",
|
||||||
|
int(np.count_nonzero(component_quotas)),
|
||||||
|
maximum_radius,
|
||||||
|
p95_radius,
|
||||||
|
minimum_gap,
|
||||||
|
)
|
||||||
|
return [str(candidates.names[position]) for position in selected_positions]
|
||||||
|
|
||||||
|
|
||||||
|
def optimize_sensor_placement(
|
||||||
|
wn: wntr.network.WaterNetworkModel,
|
||||||
|
sensor_num: int,
|
||||||
|
min_diameter: int,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Return deterministic pressure monitoring nodes for a loaded network.
|
||||||
|
|
||||||
|
``min_diameter`` is expressed in millimetres, matching the HTTP contract.
|
||||||
|
A node is a valid installation candidate when at least one incident pipe
|
||||||
|
meets the threshold. All valid hydraulic nodes still participate in the
|
||||||
|
sensitivity calculation so small pipes continue to influence the result.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if sensor_num <= 0:
|
||||||
|
raise ValueError("监测点数量必须大于 0")
|
||||||
|
if min_diameter < 0:
|
||||||
|
raise ValueError("最小管径不能小于 0")
|
||||||
|
|
||||||
|
total_started = perf_counter()
|
||||||
|
simulation_started = total_started
|
||||||
|
results = _run_hydraulic_simulation(wn)
|
||||||
|
simulation_seconds = perf_counter() - simulation_started
|
||||||
|
|
||||||
|
preparation_started = perf_counter()
|
||||||
|
prepared = _prepare_network(wn, results, min_diameter=min_diameter)
|
||||||
|
preparation_seconds = perf_counter() - preparation_started
|
||||||
|
|
||||||
|
sensitivity_started = perf_counter()
|
||||||
|
log_sensitivity = _estimate_log_pressure_sensitivity(prepared)
|
||||||
|
sensitivity_seconds = perf_counter() - sensitivity_started
|
||||||
|
|
||||||
|
distance_started = perf_counter()
|
||||||
|
hydraulic_distance_sums = _estimate_hydraulic_distance_sums(prepared)
|
||||||
|
distance_seconds = perf_counter() - distance_started
|
||||||
|
|
||||||
|
selection_started = perf_counter()
|
||||||
|
selected = _select_sensor_nodes(
|
||||||
|
prepared,
|
||||||
|
log_sensitivity,
|
||||||
|
hydraulic_distance_sums,
|
||||||
|
sensor_num=sensor_num,
|
||||||
|
)
|
||||||
|
selection_seconds = perf_counter() - selection_started
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Sensitivity placement completed: nodes=%d pipes=%d candidates=%d "
|
||||||
|
"sensors=%d seconds=%.3f "
|
||||||
|
"(simulation=%.3f preparation=%.3f sensitivity=%.3f "
|
||||||
|
"distance=%.3f selection=%.3f)",
|
||||||
|
len(prepared.node_names),
|
||||||
|
prepared.incidence.shape[1],
|
||||||
|
len(prepared.candidate_indices),
|
||||||
|
len(selected),
|
||||||
|
perf_counter() - total_started,
|
||||||
|
simulation_seconds,
|
||||||
|
preparation_seconds,
|
||||||
|
sensitivity_seconds,
|
||||||
|
distance_seconds,
|
||||||
|
selection_seconds,
|
||||||
|
)
|
||||||
|
return selected
|
||||||
|
|
||||||
|
|
||||||
|
def optimize_sensor_placement_from_inp(
|
||||||
|
inp_path: str | Path,
|
||||||
|
sensor_num: int,
|
||||||
|
min_diameter: int,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Load an EPANET INP model and run the unified placement algorithm."""
|
||||||
|
|
||||||
|
wn = wntr.network.WaterNetworkModel(str(inp_path))
|
||||||
|
return optimize_sensor_placement(
|
||||||
|
wn,
|
||||||
|
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"]
|
||||||
+75
-55
@@ -1,19 +1,40 @@
|
|||||||
# ...existing code...
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import matplotlib.pyplot as plt
|
import matplotlib.pyplot as plt
|
||||||
from pykalman import KalmanFilter
|
from pykalman import KalmanFilter
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from app.algorithms._utils import fill_time_gaps
|
||||||
|
|
||||||
def clean_flow_data_kf(input_csv_path: str, show_plot: bool = False) -> str:
|
|
||||||
|
def clean_flow_data_kf(
|
||||||
|
input_csv_path: str, show_plot: bool = False, fill_gaps: bool = True
|
||||||
|
) -> str:
|
||||||
"""
|
"""
|
||||||
读取 input_csv_path 中的每列时间序列,使用一维 Kalman 滤波平滑并用预测值替换基于 3σ 检测出的异常点。
|
读取 input_csv_path 中的每列时间序列,使用一维 Kalman 滤波平滑并用预测值替换基于 3σ 检测出的异常点。
|
||||||
保存输出为:<input_filename>_cleaned.xlsx(与输入同目录),并返回输出文件的绝对路径。
|
保存输出为:<input_filename>_cleaned.xlsx(与输入同目录),并返回输出文件的绝对路径。
|
||||||
仅保留输入文件路径作为参数(按要求)。
|
仅保留输入文件路径作为参数(按要求)。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_csv_path: CSV 文件路径
|
||||||
|
show_plot: 是否显示可视化
|
||||||
|
fill_gaps: 是否先补齐时间缺口(默认 True)
|
||||||
"""
|
"""
|
||||||
# 读取 CSV
|
# 读取 CSV
|
||||||
data = pd.read_csv(input_csv_path, header=0, index_col=None, encoding="utf-8")
|
data = pd.read_csv(input_csv_path, header=0, index_col=None, encoding="utf-8")
|
||||||
|
|
||||||
|
# 补齐时间缺口(如果数据包含 time 列)
|
||||||
|
if fill_gaps and "time" in data.columns:
|
||||||
|
data = fill_time_gaps(
|
||||||
|
data, time_col="time", freq="1min", short_gap_threshold=10
|
||||||
|
)
|
||||||
|
|
||||||
|
# 分离时间列和数值列
|
||||||
|
time_col_data = None
|
||||||
|
if "time" in data.columns:
|
||||||
|
time_col_data = data["time"]
|
||||||
|
data = data.drop(columns=["time"])
|
||||||
|
|
||||||
# 存储 Kalman 平滑结果
|
# 存储 Kalman 平滑结果
|
||||||
data_kf = pd.DataFrame(index=data.index, columns=data.columns)
|
data_kf = pd.DataFrame(index=data.index, columns=data.columns)
|
||||||
# 平滑每一列
|
# 平滑每一列
|
||||||
@@ -63,6 +84,10 @@ def clean_flow_data_kf(input_csv_path: str, show_plot: bool = False) -> str:
|
|||||||
)
|
)
|
||||||
cleaned_data.loc[anomaly_idx, f"{col}_cleaned"] = data_kf.loc[anomaly_idx, col]
|
cleaned_data.loc[anomaly_idx, f"{col}_cleaned"] = data_kf.loc[anomaly_idx, col]
|
||||||
|
|
||||||
|
# 如果原始数据包含时间列,将其添加回结果
|
||||||
|
if time_col_data is not None:
|
||||||
|
cleaned_data.insert(0, "time", time_col_data)
|
||||||
|
|
||||||
# 构造输出文件名:在输入文件名基础上加后缀 _cleaned.xlsx
|
# 构造输出文件名:在输入文件名基础上加后缀 _cleaned.xlsx
|
||||||
input_dir = os.path.dirname(os.path.abspath(input_csv_path))
|
input_dir = os.path.dirname(os.path.abspath(input_csv_path))
|
||||||
input_base = os.path.splitext(os.path.basename(input_csv_path))[0]
|
input_base = os.path.splitext(os.path.basename(input_csv_path))[0]
|
||||||
@@ -117,22 +142,33 @@ def clean_flow_data_kf(input_csv_path: str, show_plot: bool = False) -> str:
|
|||||||
return os.path.abspath(output_path)
|
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 检测出的异常点。
|
接收一个 DataFrame 数据结构,使用一维 Kalman 滤波平滑并用预测值替换基于 IQR 检测出的异常点。
|
||||||
区分合理的0值(流量转换)和异常的0值(连续多个0或孤立0)。
|
区分合理的0值(流量转换)和异常的0值(连续多个0或孤立0)。
|
||||||
返回完整的清洗后的字典数据结构。
|
返回完整的清洗后 DataFrame。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: 输入 DataFrame(可包含 time 列)
|
||||||
|
show_plot: 是否显示可视化
|
||||||
"""
|
"""
|
||||||
# 使用传入的 DataFrame
|
# 使用传入的 DataFrame
|
||||||
data = data.copy()
|
data = data.copy()
|
||||||
# 替换0值,填充NaN值
|
|
||||||
data_filled = data.replace(0, np.nan)
|
|
||||||
|
|
||||||
# 对异常0值进行插值:先用前后均值填充,再用ffill/bfill处理剩余NaN
|
# 补齐时间缺口(如果启用且数据包含 time 列)
|
||||||
data_filled = data_filled.interpolate(method="linear", limit_direction="both")
|
data_filled = fill_time_gaps(
|
||||||
|
data, time_col="time", freq="1min", short_gap_threshold=10
|
||||||
|
)
|
||||||
|
|
||||||
# 处理剩余的0值和NaN值
|
# 保存 time 列用于最后合并
|
||||||
data_filled = data_filled.ffill().bfill()
|
time_col_series = None
|
||||||
|
if "time" in data_filled.columns:
|
||||||
|
time_col_series = data_filled["time"]
|
||||||
|
|
||||||
|
# 移除 time 列用于后续清洗
|
||||||
|
data_filled = data_filled.drop(columns=["time"])
|
||||||
|
|
||||||
# 存储 Kalman 平滑结果
|
# 存储 Kalman 平滑结果
|
||||||
data_kf = pd.DataFrame(index=data_filled.index, columns=data_filled.columns)
|
data_kf = pd.DataFrame(index=data_filled.index, columns=data_filled.columns)
|
||||||
@@ -192,28 +228,47 @@ def clean_flow_data_df_kf(data: pd.DataFrame, show_plot: bool = False) -> dict:
|
|||||||
plt.rcParams["axes.unicode_minus"] = False
|
plt.rcParams["axes.unicode_minus"] = False
|
||||||
if show_plot and len(data.columns) > 0:
|
if show_plot and len(data.columns) > 0:
|
||||||
sensor_to_plot = data.columns[0]
|
sensor_to_plot = data.columns[0]
|
||||||
|
|
||||||
|
# 定义x轴
|
||||||
|
n = len(data)
|
||||||
|
time = np.arange(n)
|
||||||
|
n_filled = len(data_filled)
|
||||||
|
time_filled = np.arange(n_filled)
|
||||||
|
|
||||||
plt.figure(figsize=(12, 8))
|
plt.figure(figsize=(12, 8))
|
||||||
|
|
||||||
plt.subplot(2, 1, 1)
|
plt.subplot(2, 1, 1)
|
||||||
plt.plot(
|
plt.plot(
|
||||||
data.index,
|
time,
|
||||||
data[sensor_to_plot],
|
data[sensor_to_plot],
|
||||||
label="原始监测值",
|
label="原始监测值",
|
||||||
marker="o",
|
marker="o",
|
||||||
markersize=3,
|
markersize=3,
|
||||||
alpha=0.7,
|
alpha=0.7,
|
||||||
)
|
)
|
||||||
abnormal_zero_idx = data.index[data_filled[sensor_to_plot].isna()]
|
|
||||||
|
# 修正:检查 data_filled 的异常值,绘制在 time_filled 上
|
||||||
|
abnormal_zero_mask = data_filled[sensor_to_plot].isna()
|
||||||
|
# 如果目的是检查0值,应该用 == 0。这里保留 isna() 但修正索引引用,防止crash。
|
||||||
|
# 如果原意是 isna() 则在 fillna 后通常没有 na。假设用户可能想检查 0 值?
|
||||||
|
# 基于 "异常0值" 的标签,改为检查 0 值更合理,但为了保险起见,
|
||||||
|
# 如果 isna() 返回空,就不画。防止索引越界是主要的。
|
||||||
|
abnormal_zero_idx = data_filled.index[abnormal_zero_mask]
|
||||||
|
|
||||||
if len(abnormal_zero_idx) > 0:
|
if len(abnormal_zero_idx) > 0:
|
||||||
|
# 注意:如果 abnormal_zero_idx 是基于 data_filled 的索引(0..M-1),
|
||||||
|
# 直接作为 x 坐标即可,因为 time_filled 也是 0..M-1
|
||||||
|
# 而 y 值应该取自 data_filled 或 data_kf,取 data 会越界
|
||||||
plt.plot(
|
plt.plot(
|
||||||
abnormal_zero_idx,
|
abnormal_zero_idx,
|
||||||
data[sensor_to_plot].loc[abnormal_zero_idx],
|
data_filled[sensor_to_plot].loc[abnormal_zero_idx],
|
||||||
"mo",
|
"mo",
|
||||||
markersize=8,
|
markersize=8,
|
||||||
label="异常0值",
|
label="异常值(NaN)",
|
||||||
)
|
)
|
||||||
|
|
||||||
plt.plot(
|
plt.plot(
|
||||||
data.index, data_kf[sensor_to_plot], label="Kalman滤波预测值", linewidth=2
|
time_filled, data_kf[sensor_to_plot], label="Kalman滤波预测值", linewidth=2
|
||||||
)
|
)
|
||||||
anomaly_idx = anomalies_info[sensor_to_plot].index
|
anomaly_idx = anomalies_info[sensor_to_plot].index
|
||||||
if len(anomaly_idx) > 0:
|
if len(anomaly_idx) > 0:
|
||||||
@@ -231,7 +286,7 @@ def clean_flow_data_df_kf(data: pd.DataFrame, show_plot: bool = False) -> dict:
|
|||||||
|
|
||||||
plt.subplot(2, 1, 2)
|
plt.subplot(2, 1, 2)
|
||||||
plt.plot(
|
plt.plot(
|
||||||
data.index,
|
time_filled,
|
||||||
cleaned_data[sensor_to_plot],
|
cleaned_data[sensor_to_plot],
|
||||||
label="修复后监测值",
|
label="修复后监测值",
|
||||||
marker="o",
|
marker="o",
|
||||||
@@ -246,44 +301,9 @@ def clean_flow_data_df_kf(data: pd.DataFrame, show_plot: bool = False) -> dict:
|
|||||||
plt.tight_layout()
|
plt.tight_layout()
|
||||||
plt.show()
|
plt.show()
|
||||||
|
|
||||||
|
# 将 time 列添加回结果
|
||||||
|
if time_col_series is not None:
|
||||||
|
cleaned_data.insert(0, "time", time_col_series)
|
||||||
|
|
||||||
# 返回完整的修复后字典
|
# 返回完整的修复后字典
|
||||||
return cleaned_data
|
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("测试完成:函数运行正常")
|
|
||||||
@@ -0,0 +1,543 @@
|
|||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import os
|
||||||
|
|
||||||
|
ID_LIKE_COLUMNS = {
|
||||||
|
"id",
|
||||||
|
"device_id",
|
||||||
|
"node_id",
|
||||||
|
"sensor_id",
|
||||||
|
"monitor_id",
|
||||||
|
"junction_id",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_time_frame(data: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
"""返回按时间排序的副本,并尽量将 time 列解析为时间类型。"""
|
||||||
|
data = data.copy()
|
||||||
|
if "time" in data.columns:
|
||||||
|
data["time"] = pd.to_datetime(data["time"], errors="coerce")
|
||||||
|
data = data.sort_values(["time"]).reset_index(drop=True)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _select_pressure_columns(data: pd.DataFrame) -> tuple[list[str], list[str]]:
|
||||||
|
"""区分需要清洗的数值列与需要原样保留的列。"""
|
||||||
|
value_cols: list[str] = []
|
||||||
|
keep_cols: list[str] = []
|
||||||
|
for col in data.columns:
|
||||||
|
if col == "time":
|
||||||
|
continue
|
||||||
|
col_key = col.lower()
|
||||||
|
if col_key in ID_LIKE_COLUMNS or col_key.endswith("_id"):
|
||||||
|
keep_cols.append(col)
|
||||||
|
continue
|
||||||
|
numeric = pd.to_numeric(data[col], errors="coerce")
|
||||||
|
if numeric.notna().sum() == 0 or numeric.nunique(dropna=True) <= 1:
|
||||||
|
keep_cols.append(col)
|
||||||
|
else:
|
||||||
|
value_cols.append(col)
|
||||||
|
return value_cols, keep_cols
|
||||||
|
|
||||||
|
|
||||||
|
def _robust_scale(values: pd.Series) -> float:
|
||||||
|
"""基于 MAD 计算稳健尺度。"""
|
||||||
|
series = pd.to_numeric(values, errors="coerce").dropna()
|
||||||
|
if series.empty:
|
||||||
|
return 1.0
|
||||||
|
median = series.median()
|
||||||
|
mad = (series - median).abs().median()
|
||||||
|
if pd.notna(mad) and mad > 0:
|
||||||
|
return float(1.4826 * mad)
|
||||||
|
iqr = series.quantile(0.75) - series.quantile(0.25)
|
||||||
|
if pd.notna(iqr) and iqr > 0:
|
||||||
|
return float(iqr / 1.349)
|
||||||
|
std = series.std()
|
||||||
|
if pd.notna(std) and std > 0:
|
||||||
|
return float(std)
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def _shrink_toward_baseline(observed: float, baseline: float, scale: float) -> float:
|
||||||
|
"""把观测值向基线值收缩,scale 越小,修复越强。"""
|
||||||
|
if pd.isna(observed):
|
||||||
|
return baseline
|
||||||
|
if pd.isna(baseline):
|
||||||
|
return observed
|
||||||
|
diff = observed - baseline
|
||||||
|
weight = scale / (abs(diff) + scale)
|
||||||
|
return float(baseline + diff * weight)
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_time_frequency(time_values: pd.Series | pd.Index) -> pd.Timedelta:
|
||||||
|
"""从时间序列中推断采样频率,失败时默认 15 分钟。"""
|
||||||
|
parsed = pd.to_datetime(pd.Series(time_values), errors="coerce").dropna().sort_values()
|
||||||
|
if len(parsed) < 2:
|
||||||
|
return pd.Timedelta(minutes=15)
|
||||||
|
|
||||||
|
diffs = parsed.diff().dropna()
|
||||||
|
diffs = diffs[diffs > pd.Timedelta(0)]
|
||||||
|
if diffs.empty:
|
||||||
|
return pd.Timedelta(minutes=15)
|
||||||
|
|
||||||
|
mode = diffs.mode()
|
||||||
|
return mode.iloc[0] if not mode.empty else diffs.median()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_local_pressure_baseline(series: pd.Series) -> pd.Series:
|
||||||
|
"""基于局部插值与中值滤波构造平滑基线。"""
|
||||||
|
baseline = _safe_time_interpolate(series)
|
||||||
|
baseline = baseline.rolling(window=5, center=True, min_periods=1).median()
|
||||||
|
baseline = _safe_time_interpolate(baseline)
|
||||||
|
return baseline.ffill().bfill()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_seasonal_pressure_baseline(series: pd.Series) -> pd.Series:
|
||||||
|
"""按一天内的同一时刻构造季节性基线,适合日周期压力数据。"""
|
||||||
|
if not isinstance(series.index, pd.DatetimeIndex):
|
||||||
|
return pd.Series(np.nan, index=series.index, dtype=float)
|
||||||
|
|
||||||
|
slot_labels = pd.Series(series.index.strftime("%H:%M:%S"), index=series.index)
|
||||||
|
return series.groupby(slot_labels).transform("median")
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_pressure_spikes(series: pd.Series, local_baseline: pd.Series) -> pd.Series:
|
||||||
|
"""识别单点异常上升/下降尖峰,避免过度修正正常波动。"""
|
||||||
|
residual = series - local_baseline
|
||||||
|
neighbor_center = (series.shift(1) + series.shift(-1)) / 2
|
||||||
|
curvature = series - neighbor_center
|
||||||
|
|
||||||
|
residual_scale = max(_robust_scale(residual), 1e-6)
|
||||||
|
curvature_scale = max(_robust_scale(curvature), 1e-6)
|
||||||
|
direction_flip = ((series - series.shift(1)) * (series.shift(-1) - series) < 0).fillna(False)
|
||||||
|
|
||||||
|
return (
|
||||||
|
residual.abs() > 3.5 * residual_scale
|
||||||
|
) & (
|
||||||
|
curvature.abs() > 3.0 * curvature_scale
|
||||||
|
) & direction_flip
|
||||||
|
|
||||||
|
|
||||||
|
def _fill_pressure_gaps(
|
||||||
|
original: pd.Series,
|
||||||
|
repaired: pd.Series,
|
||||||
|
local_baseline: pd.Series,
|
||||||
|
seasonal_baseline: pd.Series,
|
||||||
|
) -> pd.Series:
|
||||||
|
"""短缺口用局部插值,长缺口优先使用同一时刻的季节性轨迹。"""
|
||||||
|
missing_mask = original.isna()
|
||||||
|
if not missing_mask.any():
|
||||||
|
return repaired
|
||||||
|
|
||||||
|
gap_groups = (missing_mask != missing_mask.shift(fill_value=False)).cumsum()
|
||||||
|
gap_lengths = missing_mask.groupby(gap_groups).transform("sum").where(missing_mask, 0)
|
||||||
|
|
||||||
|
filled = repaired.copy()
|
||||||
|
short_gap_mask = missing_mask & (gap_lengths < 4)
|
||||||
|
long_gap_mask = missing_mask & ~short_gap_mask
|
||||||
|
|
||||||
|
filled[short_gap_mask] = local_baseline[short_gap_mask]
|
||||||
|
long_gap_fill = seasonal_baseline.where(seasonal_baseline.notna(), local_baseline)
|
||||||
|
filled[long_gap_mask] = long_gap_fill[long_gap_mask]
|
||||||
|
return filled
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_pressure_series(series: pd.Series) -> pd.Series:
|
||||||
|
"""清洗单个压力时间序列。"""
|
||||||
|
series = pd.to_numeric(series, errors="coerce").astype(float)
|
||||||
|
local_baseline = _build_local_pressure_baseline(series)
|
||||||
|
spike_mask = _detect_pressure_spikes(series, local_baseline)
|
||||||
|
|
||||||
|
repaired = series.copy()
|
||||||
|
repaired[spike_mask] = local_baseline[spike_mask]
|
||||||
|
|
||||||
|
seasonal_baseline = _build_seasonal_pressure_baseline(repaired)
|
||||||
|
repaired = _fill_pressure_gaps(series, repaired, local_baseline, seasonal_baseline)
|
||||||
|
|
||||||
|
if repaired.isna().any():
|
||||||
|
repaired = repaired.where(repaired.notna(), local_baseline)
|
||||||
|
return repaired.ffill().bfill()
|
||||||
|
|
||||||
|
|
||||||
|
def _format_time_column(data: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
"""统一输出时间格式,方便下游直接按 ISO 字符串解析。"""
|
||||||
|
if "time" not in data.columns:
|
||||||
|
return data
|
||||||
|
|
||||||
|
formatted = data.copy()
|
||||||
|
time_values = pd.to_datetime(formatted["time"], errors="coerce")
|
||||||
|
if time_values.isna().all():
|
||||||
|
return formatted
|
||||||
|
|
||||||
|
if time_values.dt.tz is not None:
|
||||||
|
time_strings = time_values.dt.strftime("%Y-%m-%dT%H:%M:%S%z")
|
||||||
|
time_strings = time_strings.str.replace(
|
||||||
|
r"([+-]\d{2})(\d{2})$",
|
||||||
|
r"\1:\2",
|
||||||
|
regex=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
time_strings = time_values.dt.strftime("%Y-%m-%dT%H:%M:%S")
|
||||||
|
|
||||||
|
formatted["time"] = time_strings.where(time_values.notna(), formatted["time"])
|
||||||
|
return formatted
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_snapshot_time_grid(data: pd.DataFrame, freq: pd.Timedelta) -> pd.DataFrame:
|
||||||
|
"""仅补齐时间轴,不提前填充值,避免长缺口丢失原始形状特征。"""
|
||||||
|
expanded = data.copy()
|
||||||
|
expanded["time"] = pd.to_datetime(expanded["time"], errors="coerce")
|
||||||
|
expanded = expanded.dropna(subset=["time"]).sort_values("time")
|
||||||
|
if expanded.empty:
|
||||||
|
return data
|
||||||
|
|
||||||
|
indexed = expanded.set_index("time")
|
||||||
|
full_index = pd.date_range(indexed.index.min(), indexed.index.max(), freq=freq)
|
||||||
|
indexed = indexed.reindex(full_index)
|
||||||
|
indexed.index.name = "time"
|
||||||
|
return indexed.reset_index()
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_datetime_index(values: pd.Series | pd.Index | list[object]) -> pd.DatetimeIndex | None:
|
||||||
|
"""尽量把时间值标准化为 DatetimeIndex;失败则返回 None。"""
|
||||||
|
parsed = pd.to_datetime(values, errors="coerce")
|
||||||
|
try:
|
||||||
|
datetime_index = pd.DatetimeIndex(parsed)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if datetime_index.isna().all():
|
||||||
|
return None
|
||||||
|
return datetime_index
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_time_interpolate(series: pd.Series) -> pd.Series:
|
||||||
|
"""仅在索引确实是 DatetimeIndex 时使用 time interpolation。"""
|
||||||
|
if isinstance(series.index, pd.DatetimeIndex):
|
||||||
|
return series.interpolate(method="time", limit_direction="both")
|
||||||
|
return series.interpolate(limit_direction="both")
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_long_form_identifier(data: pd.DataFrame, value_cols: list[str], keep_cols: list[str]) -> str | None:
|
||||||
|
"""识别 time/id/value 长表结构。"""
|
||||||
|
if "time" not in data.columns or len(value_cols) != 1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
identifier_candidates = [
|
||||||
|
col
|
||||||
|
for col in keep_cols
|
||||||
|
if col.lower() in ID_LIKE_COLUMNS or col.lower().endswith("_id")
|
||||||
|
]
|
||||||
|
if len(identifier_candidates) != 1:
|
||||||
|
return None
|
||||||
|
if not data["time"].duplicated().any():
|
||||||
|
return None
|
||||||
|
return identifier_candidates[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_long_form_pressure(
|
||||||
|
data: pd.DataFrame,
|
||||||
|
value_col: str,
|
||||||
|
identifier_col: str,
|
||||||
|
keep_cols: list[str],
|
||||||
|
fill_gaps: bool,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""按测点拆分 long-form 压力数据,再逐列清洗后恢复原结构。"""
|
||||||
|
data = _normalize_time_frame(data)
|
||||||
|
wide_df = (
|
||||||
|
data[[identifier_col, "time", value_col]]
|
||||||
|
.pivot(index="time", columns=identifier_col, values=value_col)
|
||||||
|
.reset_index()
|
||||||
|
)
|
||||||
|
|
||||||
|
sensor_cols = [col for col in wide_df.columns if col != "time"]
|
||||||
|
cleaned_wide = _clean_snapshot_pressure(wide_df, sensor_cols, keep_cols=[], fill_gaps=fill_gaps)
|
||||||
|
|
||||||
|
cleaned_long = cleaned_wide.melt(
|
||||||
|
id_vars="time",
|
||||||
|
var_name=identifier_col,
|
||||||
|
value_name=value_col,
|
||||||
|
)
|
||||||
|
|
||||||
|
passthrough_cols = [col for col in keep_cols if col != identifier_col]
|
||||||
|
if passthrough_cols:
|
||||||
|
metadata = data[[identifier_col] + passthrough_cols].drop_duplicates(subset=[identifier_col])
|
||||||
|
cleaned_long = cleaned_long.merge(metadata, on=identifier_col, how="left")
|
||||||
|
|
||||||
|
try:
|
||||||
|
cleaned_long[identifier_col] = cleaned_long[identifier_col].astype(data[identifier_col].dtype)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
cleaned_long = cleaned_long.sort_values(["time", identifier_col]).reset_index(drop=True)
|
||||||
|
ordered_cols = ["time", identifier_col] + passthrough_cols + [value_col]
|
||||||
|
cleaned_long = cleaned_long[[col for col in ordered_cols if col in cleaned_long.columns]]
|
||||||
|
return cleaned_long
|
||||||
|
|
||||||
|
|
||||||
|
def _build_time_slot_frame(
|
||||||
|
data: pd.DataFrame, value_col: str, expected_slots: int
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""把重复时间点整理成 time x slot 的矩阵。"""
|
||||||
|
grouped = data.groupby("time", sort=True)
|
||||||
|
times = list(grouped.groups.keys())
|
||||||
|
slot_frame = pd.DataFrame(index=pd.Index(times, name="time"), columns=range(expected_slots), dtype=float)
|
||||||
|
|
||||||
|
for time_value, group in grouped:
|
||||||
|
values = pd.to_numeric(group[value_col], errors="coerce").tolist()
|
||||||
|
for slot_idx, value in enumerate(values[:expected_slots]):
|
||||||
|
slot_frame.loc[time_value, slot_idx] = value
|
||||||
|
return slot_frame
|
||||||
|
|
||||||
|
|
||||||
|
def _slot_baseline(slot_frame: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
"""对每个槽位做时间插值和平滑,得到基线轨迹。"""
|
||||||
|
baseline = pd.DataFrame(index=slot_frame.index, columns=slot_frame.columns, dtype=float)
|
||||||
|
for col in slot_frame.columns:
|
||||||
|
series = slot_frame[col].astype(float)
|
||||||
|
series = _safe_time_interpolate(series)
|
||||||
|
series = series.rolling(window=5, center=True, min_periods=1).median()
|
||||||
|
series = _safe_time_interpolate(series).ffill().bfill()
|
||||||
|
baseline[col] = series
|
||||||
|
return baseline
|
||||||
|
|
||||||
|
|
||||||
|
def _choose_insertion_position(
|
||||||
|
observed: list[float], baseline_row: pd.Series, expected_slots: int
|
||||||
|
) -> int:
|
||||||
|
"""为少一个观测值的时间组选择最合理的插入位置。"""
|
||||||
|
missing_count = expected_slots - len(observed)
|
||||||
|
if missing_count <= 0:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
best_pos = 0
|
||||||
|
best_cost = float("inf")
|
||||||
|
for insert_pos in range(expected_slots):
|
||||||
|
cost = 0.0
|
||||||
|
obs_idx = 0
|
||||||
|
for slot_idx in range(expected_slots):
|
||||||
|
if slot_idx == insert_pos:
|
||||||
|
continue
|
||||||
|
obs_value = observed[obs_idx]
|
||||||
|
base_value = float(baseline_row.iloc[slot_idx])
|
||||||
|
if pd.notna(obs_value) and pd.notna(base_value):
|
||||||
|
cost += abs(obs_value - base_value)
|
||||||
|
obs_idx += 1
|
||||||
|
if cost < best_cost:
|
||||||
|
best_cost = cost
|
||||||
|
best_pos = insert_pos
|
||||||
|
return best_pos
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_repeated_timestamp_pressure(
|
||||||
|
data: pd.DataFrame, value_col: str, keep_cols: list[str]
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""针对同一时间点重复采样的压力数据进行修复。"""
|
||||||
|
data = _normalize_time_frame(data)
|
||||||
|
grouped_sizes = data.groupby("time").size()
|
||||||
|
if grouped_sizes.empty:
|
||||||
|
return data
|
||||||
|
|
||||||
|
expected_slots = int(grouped_sizes.mode().iloc[0]) if not grouped_sizes.mode().empty else int(grouped_sizes.max())
|
||||||
|
expected_slots = max(expected_slots, int(grouped_sizes.max()))
|
||||||
|
slot_frame = _build_time_slot_frame(data, value_col, expected_slots)
|
||||||
|
baseline_frame = _slot_baseline(slot_frame)
|
||||||
|
|
||||||
|
residuals = slot_frame - baseline_frame
|
||||||
|
slot_scales = {
|
||||||
|
col: max(_robust_scale(residuals[col]), 1e-6) for col in residuals.columns
|
||||||
|
}
|
||||||
|
|
||||||
|
cleaned_rows: list[dict[str, object]] = []
|
||||||
|
grouped = data.groupby("time", sort=True)
|
||||||
|
for time_value, group in grouped:
|
||||||
|
observed_values = pd.to_numeric(group[value_col], errors="coerce").tolist()
|
||||||
|
baseline_row = baseline_frame.loc[time_value]
|
||||||
|
insert_pos = _choose_insertion_position(observed_values, baseline_row, expected_slots)
|
||||||
|
|
||||||
|
cleaned_values: list[float] = []
|
||||||
|
obs_idx = 0
|
||||||
|
for slot_idx in range(expected_slots):
|
||||||
|
if slot_idx == insert_pos and len(observed_values) < expected_slots:
|
||||||
|
cleaned_values.append(float(baseline_row.iloc[slot_idx]))
|
||||||
|
continue
|
||||||
|
|
||||||
|
if obs_idx >= len(observed_values):
|
||||||
|
cleaned_values.append(float(baseline_row.iloc[slot_idx]))
|
||||||
|
continue
|
||||||
|
|
||||||
|
observed = observed_values[obs_idx]
|
||||||
|
baseline = float(baseline_row.iloc[slot_idx])
|
||||||
|
cleaned_values.append(
|
||||||
|
_shrink_toward_baseline(observed, baseline, slot_scales.get(slot_idx, 1.0))
|
||||||
|
)
|
||||||
|
obs_idx += 1
|
||||||
|
|
||||||
|
# 其余字段原样保留;常量列(如 id)直接复制第一条记录即可
|
||||||
|
template_row = group.iloc[0].to_dict()
|
||||||
|
for slot_idx, cleaned_value in enumerate(cleaned_values):
|
||||||
|
row = dict(template_row)
|
||||||
|
row["time"] = time_value
|
||||||
|
row[value_col] = cleaned_value
|
||||||
|
cleaned_rows.append(row)
|
||||||
|
|
||||||
|
cleaned_df = pd.DataFrame(cleaned_rows)
|
||||||
|
cleaned_df = cleaned_df.sort_values(["time"]).reset_index(drop=True)
|
||||||
|
ordered_cols = ["time"] + keep_cols + [value_col]
|
||||||
|
ordered_cols = [col for col in ordered_cols if col in cleaned_df.columns]
|
||||||
|
remaining_cols = [col for col in cleaned_df.columns if col not in ordered_cols]
|
||||||
|
cleaned_df = cleaned_df[ordered_cols + remaining_cols]
|
||||||
|
return _format_time_column(cleaned_df)
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_snapshot_pressure(
|
||||||
|
data: pd.DataFrame, value_cols: list[str], keep_cols: list[str], fill_gaps: bool
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""针对单条时间序列或多列快照数据进行稳健修复。"""
|
||||||
|
data = _normalize_time_frame(data)
|
||||||
|
if fill_gaps and "time" in data.columns:
|
||||||
|
freq = _infer_time_frequency(data["time"])
|
||||||
|
data = _expand_snapshot_time_grid(data, freq)
|
||||||
|
data["time"] = pd.to_datetime(data["time"], errors="coerce")
|
||||||
|
data = data.sort_values(["time"]).reset_index(drop=True)
|
||||||
|
|
||||||
|
cleaned_df = data.copy()
|
||||||
|
time_index = (
|
||||||
|
_safe_datetime_index(cleaned_df["time"])
|
||||||
|
if "time" in cleaned_df.columns
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if time_index is None:
|
||||||
|
time_index = pd.RangeIndex(start=0, stop=len(cleaned_df))
|
||||||
|
for col in value_cols:
|
||||||
|
series = pd.Series(
|
||||||
|
pd.to_numeric(cleaned_df[col], errors="coerce").to_numpy(),
|
||||||
|
index=time_index,
|
||||||
|
dtype=float,
|
||||||
|
)
|
||||||
|
cleaned_df[col] = _clean_pressure_series(series).to_numpy()
|
||||||
|
|
||||||
|
ordered_cols = ["time"] + keep_cols + value_cols
|
||||||
|
ordered_cols = [col for col in ordered_cols if col in cleaned_df.columns]
|
||||||
|
remaining_cols = [col for col in cleaned_df.columns if col not in ordered_cols]
|
||||||
|
cleaned_df = cleaned_df[ordered_cols + remaining_cols]
|
||||||
|
return _format_time_column(cleaned_df)
|
||||||
|
|
||||||
|
|
||||||
|
def clean_pressure_data_km(
|
||||||
|
input_csv_path: str, show_plot: bool = False, fill_gaps: bool = True
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
读取输入 CSV,基于时间结构进行稳健修复。输出为 <input_basename>_cleaned.xlsx(同目录)。
|
||||||
|
原始数据在 sheet 'raw_pressure_data',处理后数据在 sheet 'cleaned_pressusre_data'。
|
||||||
|
返回输出文件的绝对路径。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_csv_path: CSV 文件路径
|
||||||
|
show_plot: 是否显示可视化
|
||||||
|
fill_gaps: 是否先补齐时间缺口(默认 True)
|
||||||
|
"""
|
||||||
|
# 读取 CSV
|
||||||
|
input_csv_path = os.path.abspath(input_csv_path)
|
||||||
|
data = pd.read_csv(input_csv_path, header=0, index_col=None, encoding="utf-8")
|
||||||
|
data = _normalize_time_frame(data)
|
||||||
|
value_cols, keep_cols = _select_pressure_columns(data)
|
||||||
|
has_repeated_time = "time" in data.columns and data["time"].duplicated().any()
|
||||||
|
identifier_col = _detect_long_form_identifier(data, value_cols, keep_cols)
|
||||||
|
|
||||||
|
if identifier_col is not None:
|
||||||
|
data_repaired = _clean_long_form_pressure(
|
||||||
|
data,
|
||||||
|
value_cols[0],
|
||||||
|
identifier_col,
|
||||||
|
keep_cols,
|
||||||
|
fill_gaps,
|
||||||
|
)
|
||||||
|
elif has_repeated_time and len(value_cols) == 1:
|
||||||
|
data_repaired = _clean_repeated_timestamp_pressure(data, value_cols[0], keep_cols)
|
||||||
|
else:
|
||||||
|
data_repaired = _clean_snapshot_pressure(data, value_cols, keep_cols, fill_gaps)
|
||||||
|
|
||||||
|
# 可选可视化(只展示首个数值列)
|
||||||
|
plt.rcParams["font.sans-serif"] = ["SimHei"]
|
||||||
|
plt.rcParams["axes.unicode_minus"] = False
|
||||||
|
if show_plot and value_cols:
|
||||||
|
plot_col = value_cols[0]
|
||||||
|
if "time" in data_repaired.columns:
|
||||||
|
x = pd.to_datetime(data_repaired["time"], errors="coerce")
|
||||||
|
else:
|
||||||
|
x = np.arange(len(data_repaired))
|
||||||
|
plt.figure(figsize=(12, 6))
|
||||||
|
plt.plot(x, pd.to_numeric(data_repaired[plot_col], errors="coerce"), label="cleaned")
|
||||||
|
plt.xlabel("时间" if "time" in data_repaired.columns else "序号")
|
||||||
|
plt.ylabel("压力监测值")
|
||||||
|
plt.title(f"{plot_col} 清洗结果")
|
||||||
|
plt.legend()
|
||||||
|
plt.show()
|
||||||
|
|
||||||
|
# 保存到 Excel:两个 sheet
|
||||||
|
input_dir = os.path.dirname(os.path.abspath(input_csv_path))
|
||||||
|
input_base = os.path.splitext(os.path.basename(input_csv_path))[0]
|
||||||
|
output_filename = f"{input_base}_cleaned.xlsx"
|
||||||
|
output_path = os.path.join(input_dir, output_filename)
|
||||||
|
|
||||||
|
# 如果原始数据包含时间列,将其添加回结果
|
||||||
|
data_for_save = data.copy()
|
||||||
|
data_repaired_for_save = data_repaired.copy()
|
||||||
|
|
||||||
|
if os.path.exists(output_path):
|
||||||
|
os.remove(output_path) # 覆盖同名文件
|
||||||
|
with pd.ExcelWriter(output_path, engine="openpyxl") as writer:
|
||||||
|
data_for_save.to_excel(writer, sheet_name="raw_pressure_data", index=False)
|
||||||
|
data_repaired_for_save.to_excel(
|
||||||
|
writer, sheet_name="cleaned_pressusre_data", index=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# 返回输出文件的绝对路径
|
||||||
|
return os.path.abspath(output_path)
|
||||||
|
|
||||||
|
|
||||||
|
def clean_pressure_data_df_km(data: pd.DataFrame, show_plot: bool = False) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
接收一个 DataFrame 数据结构,使用时间感知的稳健修复方法清洗压力数据。
|
||||||
|
返回清洗后的 DataFrame。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: 输入 DataFrame(可包含 time 列)
|
||||||
|
show_plot: 是否显示可视化
|
||||||
|
"""
|
||||||
|
# 使用传入的 DataFrame
|
||||||
|
data = data.copy()
|
||||||
|
data = _normalize_time_frame(data)
|
||||||
|
value_cols, keep_cols = _select_pressure_columns(data)
|
||||||
|
has_repeated_time = "time" in data.columns and data["time"].duplicated().any()
|
||||||
|
identifier_col = _detect_long_form_identifier(data, value_cols, keep_cols)
|
||||||
|
|
||||||
|
if identifier_col is not None:
|
||||||
|
data_repaired = _clean_long_form_pressure(
|
||||||
|
data,
|
||||||
|
value_cols[0],
|
||||||
|
identifier_col,
|
||||||
|
keep_cols,
|
||||||
|
fill_gaps=True,
|
||||||
|
)
|
||||||
|
elif has_repeated_time and len(value_cols) == 1:
|
||||||
|
data_repaired = _clean_repeated_timestamp_pressure(data, value_cols[0], keep_cols)
|
||||||
|
else:
|
||||||
|
data_repaired = _clean_snapshot_pressure(data, value_cols, keep_cols, fill_gaps=True)
|
||||||
|
|
||||||
|
if show_plot and value_cols:
|
||||||
|
plt.rcParams["font.sans-serif"] = ["SimHei"]
|
||||||
|
plt.rcParams["axes.unicode_minus"] = False
|
||||||
|
plot_col = value_cols[0]
|
||||||
|
x = pd.to_datetime(data_repaired["time"], errors="coerce") if "time" in data_repaired.columns else np.arange(len(data_repaired))
|
||||||
|
plt.figure(figsize=(12, 6))
|
||||||
|
plt.plot(x, pd.to_numeric(data_repaired[plot_col], errors="coerce"), label="cleaned")
|
||||||
|
plt.xlabel("时间" if "time" in data_repaired.columns else "序号")
|
||||||
|
plt.ylabel("压力监测值")
|
||||||
|
plt.title(f"{plot_col} 清洗结果")
|
||||||
|
plt.legend()
|
||||||
|
plt.show()
|
||||||
|
|
||||||
|
return data_repaired
|
||||||
@@ -1,654 +0,0 @@
|
|||||||
# 改进灵敏度法
|
|
||||||
import networkx
|
|
||||||
import numpy as np
|
|
||||||
import pandas
|
|
||||||
import wntr
|
|
||||||
import pandas as pd
|
|
||||||
import copy
|
|
||||||
import matplotlib.pyplot as plt
|
|
||||||
import networkx as nx
|
|
||||||
from sklearn.cluster import KMeans
|
|
||||||
from wntr.epanet.toolkit import EpanetException
|
|
||||||
from numpy.linalg import slogdet
|
|
||||||
import random
|
|
||||||
from tjnetwork import *
|
|
||||||
from matplotlib.lines import Line2D
|
|
||||||
from sklearn.cluster import SpectralClustering
|
|
||||||
import libpysal as ps
|
|
||||||
from spopt.region import Skater
|
|
||||||
from shapely.geometry import Point
|
|
||||||
import geopandas as gpd
|
|
||||||
from sklearn.metrics import pairwise_distances
|
|
||||||
import project_info
|
|
||||||
|
|
||||||
# 2025/03/12
|
|
||||||
# Step1: 获取节点坐标
|
|
||||||
def getCoor(wn: wntr.network.WaterNetworkModel) -> pandas.DataFrame:
|
|
||||||
"""
|
|
||||||
获取管网模型的节点坐标
|
|
||||||
:param wn: 由wntr生成的模型
|
|
||||||
:return: 节点坐标
|
|
||||||
"""
|
|
||||||
# site: pandas.Series
|
|
||||||
# index:节点名称(wn.node_name_list)
|
|
||||||
# values:每个节点的坐标,格式为 tuple(如 (x, y) 或 (x, y, z))
|
|
||||||
site = wn.query_node_attribute('coordinates')
|
|
||||||
# Coor: pandas.Series
|
|
||||||
# index:与site相同(节点名称)。
|
|
||||||
# values:坐标转换为numpy.ndarray(如array([10.5, 20.3]))
|
|
||||||
Coor = site.apply(lambda x: np.array(x)) # 将节点坐标转换为numpy数组
|
|
||||||
# x, y: list[float]
|
|
||||||
x = [] # 存储所有节点的 x 坐标
|
|
||||||
y = [] # 存储所有节点的 y 坐标
|
|
||||||
for i in range(0, len(Coor)):
|
|
||||||
x.append(Coor.values[i][0]) # 将 x 坐标存入 x 列表。
|
|
||||||
y.append(Coor.values[i][1]) # 将 y 坐标存入 y 列表
|
|
||||||
# xy: dict[str, list], x、y 坐标的字典
|
|
||||||
xy = {'x': x, 'y': y}
|
|
||||||
# Coor_node: pandas.DataFrame, 存储节点 x, y 坐标的 DataFrame
|
|
||||||
Coor_node = pd.DataFrame(xy, index=wn.node_name_list, columns=['x', 'y'])
|
|
||||||
return Coor_node
|
|
||||||
|
|
||||||
|
|
||||||
# 2025/03/12
|
|
||||||
# Step2: KMeans 聚类
|
|
||||||
# 将节点用kmeans根据坐标分为k组,存入字典g
|
|
||||||
def kgroup(coor: pandas.DataFrame, knum: int) -> dict[int, list[str]]:
|
|
||||||
"""
|
|
||||||
使用KMeans聚类,将节点坐标分组
|
|
||||||
:param coor: 存储所有节点的坐标数据
|
|
||||||
:param knum: 需要分成的聚类数
|
|
||||||
:return: 聚类结果字典
|
|
||||||
"""
|
|
||||||
g = {}
|
|
||||||
# estimator: sklearn.cluster.KMeans,KMeans 聚类模型
|
|
||||||
estimator = KMeans(n_clusters=knum)
|
|
||||||
estimator.fit(coor)
|
|
||||||
# label_pred: numpy.ndarray(int),每个点的类别标签
|
|
||||||
label_pred = estimator.labels_
|
|
||||||
for i in range(0, knum):
|
|
||||||
g[i] = coor[label_pred == i].index.tolist()
|
|
||||||
return g
|
|
||||||
|
|
||||||
|
|
||||||
def skater_partition(G, n_clusters):
|
|
||||||
"""
|
|
||||||
使用 SKATER 算法对输入的无向图 G 进行区域划分,
|
|
||||||
保证每个划分区域在图论意义上是连通的,
|
|
||||||
同时依据节点坐标的空间信息进行划分。
|
|
||||||
|
|
||||||
参数:
|
|
||||||
G: networkx.Graph
|
|
||||||
带有节点坐标属性(键为 'pos')的无向图。
|
|
||||||
n_clusters: int
|
|
||||||
希望划分的区域数量。
|
|
||||||
|
|
||||||
返回:
|
|
||||||
groups: dict
|
|
||||||
字典形式的聚类结果,键为区域编号,值为该区域内的节点列表。
|
|
||||||
"""
|
|
||||||
# 1. 获取所有节点坐标,假设每个节点都有 'pos' 属性
|
|
||||||
pos = nx.get_node_attributes(G, 'pos')
|
|
||||||
nodes = list(G.nodes())
|
|
||||||
# 构造坐标数组:每行为 [x, y]
|
|
||||||
coords = np.array([pos[node] for node in nodes])
|
|
||||||
|
|
||||||
# 2. 构造 GeoDataFrame:创建 DataFrame 并生成 geometry 列
|
|
||||||
df = pd.DataFrame(coords, columns=['x', 'y'], index=nodes)
|
|
||||||
# 利用 shapely 的 Point 构造空间位置
|
|
||||||
df['geometry'] = df.apply(lambda row: Point(row['x'], row['y']), axis=1)
|
|
||||||
gdf = gpd.GeoDataFrame(df, geometry='geometry')
|
|
||||||
|
|
||||||
# 3. 构造空间权重矩阵,使用 4 近邻方法(k=4,可根据实际情况调整)
|
|
||||||
w = ps.weights.KNN.from_array(coords, k=4)
|
|
||||||
w.transform = 'R'
|
|
||||||
|
|
||||||
# 4. 调用 SKATER:新版本 API 要求传入 gdf, w 以及 attrs_name(这里使用 'x' 和 'y' 作为属性)
|
|
||||||
skater = Skater(gdf, w, attrs_name=['x', 'y'], n_clusters=n_clusters)
|
|
||||||
skater.solve()
|
|
||||||
|
|
||||||
# 5. 获取聚类标签,构造成字典格式
|
|
||||||
labels = skater.labels_
|
|
||||||
groups = {}
|
|
||||||
for label, node in zip(labels, nodes):
|
|
||||||
groups.setdefault(label, []).append(node)
|
|
||||||
|
|
||||||
return groups
|
|
||||||
|
|
||||||
|
|
||||||
def spectral_partition(G, n_clusters):
|
|
||||||
"""
|
|
||||||
利用谱聚类算法对图 G 进行分区:
|
|
||||||
1. 根据所有节点的空间坐标计算欧氏距离矩阵;
|
|
||||||
2. 利用高斯核函数构造相似度矩阵;
|
|
||||||
3. 使用 SpectralClustering 进行归一化割,返回分区结果。
|
|
||||||
|
|
||||||
参数:
|
|
||||||
G: networkx.Graph
|
|
||||||
每个节点需要有 'pos' 属性,其值为 (x, y) 坐标。
|
|
||||||
n_clusters: int
|
|
||||||
希望划分的聚类数目。
|
|
||||||
|
|
||||||
返回:
|
|
||||||
groups: dict
|
|
||||||
键为聚类标签,值为该聚类对应的节点列表。
|
|
||||||
"""
|
|
||||||
# 1. 获取节点空间坐标,注意保证每个节点都有 'pos' 属性
|
|
||||||
pos_dict = nx.get_node_attributes(G, 'pos')
|
|
||||||
nodes = list(G.nodes())
|
|
||||||
coords = np.array([pos_dict[node] for node in nodes])
|
|
||||||
|
|
||||||
# 2. 计算节点之间的欧氏距离矩阵
|
|
||||||
D = pairwise_distances(coords, metric='euclidean')
|
|
||||||
|
|
||||||
# 3. 计算 sigma 值:这里取所有距离的均值,当然也可以根据实际情况调整
|
|
||||||
sigma = np.mean(D)
|
|
||||||
|
|
||||||
# 4. 构造相似度矩阵:使用高斯核函数
|
|
||||||
# A(i, j) = exp( -d(i,j)^2 / (2*sigma^2) )
|
|
||||||
A = np.exp(- (D ** 2) / (2 * sigma ** 2))
|
|
||||||
|
|
||||||
# 5. 使用谱聚类进行图分区
|
|
||||||
clustering = SpectralClustering(n_clusters=n_clusters,
|
|
||||||
affinity='precomputed',
|
|
||||||
random_state=0)
|
|
||||||
labels = clustering.fit_predict(A)
|
|
||||||
|
|
||||||
# 6. 构造字典形式的分区结果
|
|
||||||
groups = {}
|
|
||||||
for label, node in zip(labels, nodes):
|
|
||||||
groups.setdefault(label, []).append(node)
|
|
||||||
|
|
||||||
return groups
|
|
||||||
|
|
||||||
# 2025/03/12
|
|
||||||
# Step3: wn_func类,水力计算
|
|
||||||
# wn_func 主要用于计算:
|
|
||||||
# 水力距离(hydraulic length):即节点之间的水力阻力。
|
|
||||||
# 灵敏度分析(sensitivity analysis):用于优化测压点的布置。
|
|
||||||
# 一些与水力相关的函数,包括 CtoS:求水力距离,stafun:求状态函数F
|
|
||||||
# # diff:求F对P的导数,返回灵敏度矩阵A
|
|
||||||
# # sensitivity:返回灵敏度和总灵敏度
|
|
||||||
class wn_func(object):
|
|
||||||
|
|
||||||
# Step3.1: 初始化
|
|
||||||
def __init__(self, wn: wntr.network.WaterNetworkModel, min_diameter: int):
|
|
||||||
"""
|
|
||||||
获取管网模型信息
|
|
||||||
:param wn: 由wntr生成的模型
|
|
||||||
:param min_diameter: 安装的最小管径
|
|
||||||
"""
|
|
||||||
# self.results: wntr.sim.results.SimulationResults,仿真结果,包含压力、流量、水头等数据
|
|
||||||
self.results = wntr.sim.EpanetSimulator(wn).run_sim() # 存储运行结果
|
|
||||||
self.wn = wn
|
|
||||||
# self.q:pandas.DataFrame,管道流量,索引为时间步长,列为管道名称
|
|
||||||
self.q = self.results.link['flowrate']
|
|
||||||
# ReservoirIndex / Tankindex: list[str],水库 / 水箱节点名称列表
|
|
||||||
ReservoirIndex = wn.reservoir_name_list
|
|
||||||
Tankindex = wn.tank_name_list
|
|
||||||
# 删除水库节点,删除与直接水库相连的虚拟管道
|
|
||||||
# self.pipes: list[str],所有管道的名称
|
|
||||||
self.pipes = wn.pipe_name_list
|
|
||||||
# self.nodes: list[str],所有节点的名称
|
|
||||||
self.nodes = wn.node_name_list
|
|
||||||
# self.coordinates:pandas.Series,节点坐标,索引为节点名,值为 (x, y) 坐标的 tuple
|
|
||||||
self.coordinates = wn.query_node_attribute('coordinates')
|
|
||||||
# allpumps / allvalves: list[str],所有泵/阀门名称列表
|
|
||||||
allpumps = wn.pump_name_list
|
|
||||||
allvalves = wn.valve_name_list
|
|
||||||
# pumpstnode / pumpednode / valvestnode / valveednode: list[str],存储泵和阀门 起终点节点的名称
|
|
||||||
pumpstnode = []
|
|
||||||
pumpednode = []
|
|
||||||
valvestnode = []
|
|
||||||
valveednode = []
|
|
||||||
# Reservoirpipe / Reservoirednode: list[str],记录与水库相关的管道和节点
|
|
||||||
Reservoirpipe = []
|
|
||||||
Reservoirednode = []
|
|
||||||
for pump in allpumps:
|
|
||||||
pumpstnode.append(wn.links[pump].start_node.name)
|
|
||||||
pumpednode.append(wn.links[pump].end_node.name)
|
|
||||||
for valve in allvalves:
|
|
||||||
valvestnode.append(wn.links[valve].start_node.name)
|
|
||||||
valveednode.append(wn.links[valve].end_node.name)
|
|
||||||
for pipe in self.pipes:
|
|
||||||
if wn.links[pipe].start_node.name in ReservoirIndex:
|
|
||||||
Reservoirpipe.append(pipe)
|
|
||||||
Reservoirednode.append(wn.links[pipe].end_node.name)
|
|
||||||
if wn.links[pipe].start_node.name in Tankindex:
|
|
||||||
Reservoirpipe.append(pipe)
|
|
||||||
Reservoirednode.append(wn.links[pipe].end_node.name)
|
|
||||||
if wn.links[pipe].end_node.name in Tankindex:
|
|
||||||
Reservoirpipe.append(pipe)
|
|
||||||
Reservoirednode.append(wn.links[pipe].start_node.name)
|
|
||||||
# 泵的起终点、tank、reservoir
|
|
||||||
# self.delnodes: list[str],需要删除的节点(包括水库、泵、阀门连接的节点)
|
|
||||||
self.delnodes = list(
|
|
||||||
set(ReservoirIndex).union(Tankindex, pumpstnode, pumpednode, valvestnode, valveednode, Reservoirednode))
|
|
||||||
# 泵、起终点为tank、reservoir的管道
|
|
||||||
# self.delpipes: list[str],需要删除的管道(包括水库、泵、阀门连接的管道)
|
|
||||||
self.delpipes = list(set(wn.pump_name_list).union(wn.valve_name_list).union(Reservoirpipe))
|
|
||||||
self.pipes = [pipe for pipe in wn.pipe_name_list if pipe not in self.delpipes]
|
|
||||||
# self.L: list[float],所有管道的长度(以米为单位)
|
|
||||||
self.L = wn.query_link_attribute('length')[self.pipes].tolist()
|
|
||||||
self.n = len(self.nodes)
|
|
||||||
self.m = len(self.pipes)
|
|
||||||
# self.unit_headloss: list[float],单位水头损失(headloss 数据的第一行,单位:米/km)
|
|
||||||
self.unit_headloss = self.results.link['headloss'].iloc[0, :].tolist()
|
|
||||||
##
|
|
||||||
self.delnodes1 = list(set(ReservoirIndex).union(Tankindex))
|
|
||||||
|
|
||||||
# === 改动新增部分:筛选管径小于 min_diameter 的管道节点 ===
|
|
||||||
self.less_than_min_diameter_junction_list = []
|
|
||||||
for pipe in self.pipes:
|
|
||||||
diameter = wn.links[pipe].diameter
|
|
||||||
if diameter < min_diameter:
|
|
||||||
start_node = wn.links[pipe].start_node.name
|
|
||||||
end_node = wn.links[pipe].end_node.name
|
|
||||||
self.less_than_min_diameter_junction_list.extend([start_node, end_node])
|
|
||||||
# 去重
|
|
||||||
self.less_than_min_diameter_junction_list = list(set(self.less_than_min_diameter_junction_list))
|
|
||||||
|
|
||||||
# Step3.2: 计算水力距离
|
|
||||||
def CtoS(self):
|
|
||||||
"""
|
|
||||||
计算水力距离矩阵
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
# 水力距离:当行索引对应的节点为控制点时,列索引对应的节点距离控制点的(路径*水头损失)的最小值
|
|
||||||
# nodes:list[str](节点名称)
|
|
||||||
nodes = copy.deepcopy(self.nodes)
|
|
||||||
# pipes:list[str](管道名称)
|
|
||||||
pipes = self.pipes
|
|
||||||
wn = self.wn
|
|
||||||
# n / m:int(节点数 / 管道数)
|
|
||||||
n = self.n
|
|
||||||
m = self.m
|
|
||||||
s1 = [0] * m
|
|
||||||
q = self.q
|
|
||||||
L = self.L
|
|
||||||
# H1:pandas.DataFrame,水头数据,索引为时间步长,列为节点名
|
|
||||||
H1 = self.results.node['head'].T
|
|
||||||
# hh:list[float],计算管道两端水头之差
|
|
||||||
hh = []
|
|
||||||
# 水头损失
|
|
||||||
for p in pipes:
|
|
||||||
h1 = self.wn.links[p].start_node.name
|
|
||||||
h1 = H1.loc[str(h1)]
|
|
||||||
h2 = self.wn.links[p].end_node.name
|
|
||||||
h2 = H1.loc[str(h2)]
|
|
||||||
hh.append(abs(h1 - h2))
|
|
||||||
hh = np.array(hh)
|
|
||||||
# headloss:pandas.DataFrame,管道水头损失矩阵
|
|
||||||
headloss = pd.DataFrame(hh, index=pipes).T
|
|
||||||
# s1:管道阻力系数,s2:将管道阻力系数与管道的起始节点和终止节点对应
|
|
||||||
hf = pd.DataFrame(np.array([0] * (n ** 2)).reshape(n, n), index=nodes, columns=nodes, dtype=float)
|
|
||||||
weightL = pd.DataFrame(np.array([0] * (n ** 2)).reshape(n, n), index=nodes, columns=nodes, dtype=float)
|
|
||||||
# s2为对应管道起始节点与终止节点的粗糙度系数矩阵,index代表起始节点,columns代表终止节点
|
|
||||||
G = nx.DiGraph()
|
|
||||||
for i in range(0, m):
|
|
||||||
pipe = pipes[i]
|
|
||||||
a = wn.links[pipe].start_node.name
|
|
||||||
b = wn.links[pipe].end_node.name
|
|
||||||
if q.loc[0, pipe] > 0:
|
|
||||||
hf.loc[a, b] = headloss.loc[0, pipe]
|
|
||||||
weightL.loc[a, b] = headloss.loc[0, pipe] * L[i]
|
|
||||||
G.add_weighted_edges_from([(a, b, weightL.loc[a, b])])
|
|
||||||
|
|
||||||
else:
|
|
||||||
hf.loc[b, a] = headloss.loc[0, pipe]
|
|
||||||
weightL.loc[b, a] = headloss.loc[0, pipe] * L[i]
|
|
||||||
G.add_weighted_edges_from([(b, a, weightL.loc[b, a])])
|
|
||||||
|
|
||||||
hydraulicL = pd.DataFrame(np.array([0] * (n ** 2)).reshape(n, n), index=nodes, columns=nodes, dtype=float)
|
|
||||||
|
|
||||||
for a in nodes:
|
|
||||||
if a in G.nodes:
|
|
||||||
d = nx.shortest_path_length(G, source=a, weight='weight')
|
|
||||||
for b in list(d.keys()):
|
|
||||||
hydraulicL.loc[a, b] = d[b]
|
|
||||||
|
|
||||||
hydraulicL = hydraulicL.drop(self.delnodes)
|
|
||||||
hydraulicL = hydraulicL.drop(self.delnodes, axis=1)
|
|
||||||
|
|
||||||
# 求加权水力距离
|
|
||||||
return hydraulicL, G
|
|
||||||
|
|
||||||
# Step3.3: 计算灵敏度矩阵
|
|
||||||
# 获取关系矩阵
|
|
||||||
def get_Conn(self):
|
|
||||||
"""
|
|
||||||
计算管网连接关系矩阵
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
m = self.wn.num_links
|
|
||||||
n = self.wn.num_nodes
|
|
||||||
p = self.wn.num_pumps
|
|
||||||
v = self.wn.num_valves
|
|
||||||
|
|
||||||
self.nonjunc_index = []
|
|
||||||
self.non_link_index = []
|
|
||||||
for r in self.wn.reservoirs():
|
|
||||||
self.nonjunc_index.append(r[0])
|
|
||||||
for t in self.wn.tanks():
|
|
||||||
self.nonjunc_index.append(t[0])
|
|
||||||
# Conn:numpy.matrix,节点-管道连接矩阵,起点 -1,终点 1
|
|
||||||
Conn = np.mat(np.zeros([n, m - p - v])) # 节点和管道的关系矩阵,行为节点,列为管道,起点为-1,终点为1
|
|
||||||
# NConn:numpy.matrix,节点-节点连接矩阵,有管道相连的地方设为 1
|
|
||||||
NConn = np.mat(np.zeros([n, n])) # 节点之间的关系,之间有管道为1,反之为0
|
|
||||||
# pipes:list[str],去除泵和阀门的管道列表
|
|
||||||
pipes = [pipe for pipe in self.wn.pipes() if pipe not in self.wn.pumps() and pipe not in self.wn.valves()]
|
|
||||||
for pipe_name, pipe in pipes:
|
|
||||||
start = self.wn.node_name_list.index(pipe.start_node_name)
|
|
||||||
end = self.wn.node_name_list.index(pipe.end_node_name)
|
|
||||||
p_index = self.wn.link_name_list.index(pipe_name)
|
|
||||||
Conn[start, p_index] = -1
|
|
||||||
Conn[end, p_index] = 1
|
|
||||||
NConn[start, end] = 1
|
|
||||||
NConn[end, start] = 1
|
|
||||||
self.A = Conn
|
|
||||||
link_name_list = [link for link in self.wn.link_name_list if
|
|
||||||
link not in self.wn.pump_name_list and link not in self.wn.valve_name_list]
|
|
||||||
self.A2 = pd.DataFrame(self.A, index=self.wn.node_name_list, columns=link_name_list)
|
|
||||||
self.A2 = self.A2.drop(self.delnodes)
|
|
||||||
for pipe in self.delpipes:
|
|
||||||
if pipe not in self.wn.pump_name_list and pipe not in self.wn.valve_name_list:
|
|
||||||
self.A2 = self.A2.drop(columns=pipe)
|
|
||||||
self.junc_list = self.A2.index
|
|
||||||
self.A2 = np.mat(self.A2) # 节点管道关系
|
|
||||||
self.A3 = NConn
|
|
||||||
|
|
||||||
def Jaco(self, hL: pandas.DataFrame):
|
|
||||||
"""
|
|
||||||
计算灵敏度矩阵(节点压力对粗糙度变化的响应)
|
|
||||||
:param hL: 水力距离矩阵
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
# global result
|
|
||||||
# A:numpy.matrix, 节点-管道关系矩阵
|
|
||||||
A = self.A2
|
|
||||||
wn = self.wn
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = wntr.sim.EpanetSimulator(wn).run_sim()
|
|
||||||
except EpanetException:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
h = result.link['headloss'][self.pipes].values[0]
|
|
||||||
q = result.link['flowrate'][self.pipes].values[0]
|
|
||||||
l = self.wn.query_link_attribute('length')[self.pipes]
|
|
||||||
C = self.wn.query_link_attribute('roughness')[self.pipes]
|
|
||||||
# headloss:numpy.ndarray,水头损失数组
|
|
||||||
headloss = np.array(h)
|
|
||||||
# 调整流量方向
|
|
||||||
for i in range(0, len(q)):
|
|
||||||
if q[i] < 0:
|
|
||||||
A[:, i] = -A[:, i]
|
|
||||||
# q:numpy.ndarray,流量数组
|
|
||||||
q = np.abs(q)
|
|
||||||
# 两个灵敏度矩阵
|
|
||||||
# B / S:numpy.matrix,灵敏度计算的中间矩阵
|
|
||||||
B = np.mat(np.diag(q / ((1.852 * headloss) + 1e-10)))
|
|
||||||
S = np.mat(np.diag(q / C))
|
|
||||||
# X:numpy.matrix, 灵敏度矩阵
|
|
||||||
X = A * B * A.T
|
|
||||||
try:
|
|
||||||
det = np.linalg.det(X)
|
|
||||||
except RuntimeError as e:
|
|
||||||
sign, logdet = slogdet(X) # 防止溢出
|
|
||||||
det = sign * np.exp(logdet)
|
|
||||||
if det != 0:
|
|
||||||
J_H_Cw = X.I * A * S
|
|
||||||
# J_H_Q = -X.I
|
|
||||||
J_q_Cw = S - B * A.T * X.I * A * S # 去掉了delnodes和delpipes
|
|
||||||
# J_q_Q = B * A.T * X.I
|
|
||||||
else: # 当X不可逆
|
|
||||||
J_H_Cw = np.linalg.pinv(X) @ A @ S
|
|
||||||
# J_H_Q = -np.linalg.pinv(X)
|
|
||||||
J_q_Cw = S - B * A.T * np.linalg.pinv(X) * A * S
|
|
||||||
# J_q_Q = B * A.T * np.linalg.pinv(X)
|
|
||||||
|
|
||||||
Sen_pressure = []
|
|
||||||
S_pressure = np.abs(J_H_Cw).sum(axis=1).tolist() # 修改为绝对值
|
|
||||||
for ss in S_pressure:
|
|
||||||
Sen_pressure.append(ss[0])
|
|
||||||
# 求总灵敏度
|
|
||||||
SS_pressure = copy.deepcopy(hL)
|
|
||||||
for i in range(0, len(Sen_pressure)):
|
|
||||||
SS_pressure.iloc[i, :] = SS_pressure.iloc[i, :] * Sen_pressure[i]
|
|
||||||
SS = copy.deepcopy(hL)
|
|
||||||
for i in range(0, len(Sen_pressure)):
|
|
||||||
SS.iloc[i, :] = SS.iloc[i, :] * Sen_pressure[i]
|
|
||||||
# SS[i,j]:节点nodes[i]的灵敏度*该节点到nodes[j]的水力距离
|
|
||||||
return SS
|
|
||||||
|
|
||||||
|
|
||||||
# 2025/03/12
|
|
||||||
# Step4: 传感器布置优化
|
|
||||||
# Sensorplacement
|
|
||||||
# weight:分配权重
|
|
||||||
# sensor:传感器布置的位置
|
|
||||||
class Sensorplacement(wn_func):
|
|
||||||
"""
|
|
||||||
Sensorplacement 类继承了 wn_func 类,并且用于计算和优化传感器布置的位置。
|
|
||||||
"""
|
|
||||||
def __init__(self, wn: wntr.network.WaterNetworkModel, sensornum: int, min_diameter: int):
|
|
||||||
"""
|
|
||||||
|
|
||||||
:param wn: 由wntr生成的模型
|
|
||||||
:param sensornum: 传感器的数量
|
|
||||||
:param min_diameter: 安装的最小管径
|
|
||||||
"""
|
|
||||||
wn_func.__init__(self, wn, min_diameter=min_diameter)
|
|
||||||
self.sensornum = sensornum
|
|
||||||
|
|
||||||
# 1.某个节点到所有节点的加权距离之和
|
|
||||||
# 2.某个节点到该组内所有节点的加权距离之和
|
|
||||||
def sensor(self, SS: pandas.DataFrame, G: networkx.Graph, group: dict[int, list[str]]):
|
|
||||||
"""
|
|
||||||
sensor 方法是用来根据灵敏度矩阵 SS 和加权图 G 来确定传感器布置位置的
|
|
||||||
:param SS: 灵敏度矩阵,每个节点的行和列代表不同节点,矩阵元素表示节点间的灵敏度。SS.iloc[i, :] 表示第 i 行对应节点 i 到所有其他节点的灵敏度
|
|
||||||
:param G: 加权图,表示管网的拓扑结构,每个节点通过管道连接。图的边的权重通常是根据水力距离或者流量等计算的
|
|
||||||
:param group: 节点分组,字典的键是分组编号,值是该组的节点名称列表
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
# 传感器布置个数以及位置
|
|
||||||
# W = self.weight()
|
|
||||||
n = self.n - len(self.delnodes)
|
|
||||||
nodes = copy.deepcopy(self.nodes)
|
|
||||||
for node in self.delnodes:
|
|
||||||
nodes.remove(node)
|
|
||||||
# sumSS:list[float],每个节点到其他节点的灵敏度之和。SS.iloc[i, :] 返回第 i 个节点与所有其他节点的灵敏度值,sum(SS.iloc[i, :]) 计算这些灵敏度值的总和。
|
|
||||||
sumSS = []
|
|
||||||
for i in range(0, n):
|
|
||||||
sumSS.append(sum(SS.iloc[i, :]))
|
|
||||||
# 一个整数范围,表示每个节点的索引,用作sumSS_ DataFrame的索引
|
|
||||||
indices = range(0, n)
|
|
||||||
# sumSS_:pandas.DataFrame,将 sumSS 转换成 DataFrame 格式,并且将节点的总灵敏度保存到 CSV 文件 sumSS_data.csv 中
|
|
||||||
sumSS_ = pd.DataFrame(np.array(sumSS), index=indices)
|
|
||||||
# sumSS_.to_csv('sumSS_data.csv') # 存储节点总灵敏度
|
|
||||||
|
|
||||||
# sumSS:pandas.DataFrame,sumSS 被转换为 DataFrame 类型,并且按总灵敏度(即灵敏度之和)降序排列。此时,sumSS 是按节点的灵敏度之和排序的 DataFrame
|
|
||||||
sumSS = pd.DataFrame(np.array(sumSS), index=nodes)
|
|
||||||
sumSS = sumSS.sort_values(by=[0], ascending=[False])
|
|
||||||
# sensorindex:list[str],用于存储根据灵敏度排序选出的传感器位置的节点名称,存储根据总灵敏度排序的节点列表,用于传感器布置
|
|
||||||
sensorindex = []
|
|
||||||
# sensorindex_2:list[str],用于存储每组内根据灵敏度排序选出的传感器位置的节点名称,存储每个组内根据灵敏度排序选择的传感器节点
|
|
||||||
sensorindex_2 = []
|
|
||||||
# group_S:dict[int, pandas.DataFrame],存储每个组内的灵敏度矩阵
|
|
||||||
group_S = {}
|
|
||||||
# group_sumSS:dict[int, list[float]],存储每个组内节点的总灵敏度,值为每个组内节点灵敏度之和的列表
|
|
||||||
group_sumSS = {}
|
|
||||||
|
|
||||||
# 改动
|
|
||||||
for i in range(0, len(group)):
|
|
||||||
for node in self.delnodes:
|
|
||||||
# 这里的group[i]是每个组的节点列表,代码首先去除已经被标记为删除的节点self.delnodes
|
|
||||||
if node in group[i]:
|
|
||||||
group[i].remove(node)
|
|
||||||
group_S[i] = SS.loc[group[i], group[i]]
|
|
||||||
# 对每个组内的节点,计算组内节点的总灵敏度(group_sumSS[i])。它将每个组内节点的灵敏度值相加,并且按灵敏度降序排序
|
|
||||||
group_sumSS[i] = []
|
|
||||||
for j in range(0, len(group[i])):
|
|
||||||
group_sumSS[i].append(sum(group_S[i].iloc[j, :]))
|
|
||||||
group_sumSS[i] = pd.DataFrame(np.array(group_sumSS[i]), index=group[i])
|
|
||||||
group_sumSS[i] = group_sumSS[i].sort_values(by=[0], ascending=[False])
|
|
||||||
for node in self.less_than_min_diameter_junction_list:
|
|
||||||
# 这里的group_sumSS[i]是每个分组的灵敏度节点排序列表,去除已经被标记为删除的节点self.less_than_min_diameter_junction_list
|
|
||||||
if node in group_sumSS[i]:
|
|
||||||
group_sumSS[i].remove(node)
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 1.选sumSS最大的节点,然后把这个节点所在的那个组删掉,就可以不再从这个组选点。再重新排序选sumSS最大的;
|
|
||||||
# 2.在每组内选group_sumSS最大的节点
|
|
||||||
# 在这个循环中,首先选择灵敏度最高的节点Smaxnode并添加到sensorindex。然后根据灵敏度排序,删除已选的节点并继续选择下一个灵敏度最大的节点。这个过程用于选择传感器的位置
|
|
||||||
sensornum = self.sensornum
|
|
||||||
for i in range(0, sensornum):
|
|
||||||
# Smaxnode:str,最大灵敏度节点,sumSS.index[0] 表示灵敏度最高的节点
|
|
||||||
Smaxnode = sumSS.index[0]
|
|
||||||
sensorindex.append(Smaxnode)
|
|
||||||
sensorindex_2.append(group_sumSS[i].index[0])
|
|
||||||
|
|
||||||
for key, value in group.items():
|
|
||||||
if Smaxnode in value:
|
|
||||||
sumSS = sumSS.drop(index=group[key])
|
|
||||||
continue
|
|
||||||
|
|
||||||
sumSS = sumSS.sort_values(by=[0], ascending=[False])
|
|
||||||
|
|
||||||
return sensorindex, sensorindex_2
|
|
||||||
|
|
||||||
|
|
||||||
# 2025/03/13
|
|
||||||
def get_ID(name: str, sensor_num: int, min_diameter: int) -> list[str]:
|
|
||||||
"""
|
|
||||||
获取布置测压点的坐标,初始测压点布置根据灵敏度来布置,计算初始情况下的校准过程的error
|
|
||||||
:param name: 数据库名称
|
|
||||||
:param sensor_num: 测压点数目
|
|
||||||
:param min_diameter: 安装的最小管径
|
|
||||||
:return: 测压点节点ID
|
|
||||||
"""
|
|
||||||
# inp_file_real:str,输入文件名,表示原始水力模型文件的路径,该文件格式为 EPANET 输入文件(.inp),包含管网的结构信息、节点、管道、泵等数据
|
|
||||||
inp_file_real = f'./db_inp/{name}.db.inp'
|
|
||||||
# sensornum:int,需要布置的传感器数量
|
|
||||||
# sensornum = sensor_num
|
|
||||||
# wn_real:wntr.network.WaterNetworkModel,加载 EPANET 水力模型
|
|
||||||
wn_real = wntr.network.WaterNetworkModel(inp_file_real) # 真实粗糙度的原始管网
|
|
||||||
# sim_real:wntr.sim.EpanetSimulator,创建一个水力仿真器对象
|
|
||||||
sim_real = wntr.sim.EpanetSimulator(wn_real)
|
|
||||||
# results_real:wntr.sim.results.SimulationResults,运行仿真并返回结果
|
|
||||||
results_real = sim_real.run_sim()
|
|
||||||
|
|
||||||
# real_C:list[float],包含所有管道粗糙度的列表
|
|
||||||
real_C = wn_real.query_link_attribute('roughness').tolist()
|
|
||||||
# wn_fun1:wn_func(继承自 object),创建 wn_func 类的实例,传入 wn_real 水力模型对象。wn_func 用于计算管网相关的水力属性,比如水力距离、灵敏度等
|
|
||||||
wn_fun1 = wn_func(wn_real, min_diameter=min_diameter)
|
|
||||||
# nodes:list[str],管网的节点名称列表
|
|
||||||
nodes = wn_fun1.nodes
|
|
||||||
# delnodes:list[str],被删除的节点(如水库、泵、阀门连接的节点等)
|
|
||||||
delnodes = wn_fun1.delnodes
|
|
||||||
# Coor_node:pandas.DataFrame
|
|
||||||
Coor_node = getCoor(wn_real)
|
|
||||||
Coor_node = Coor_node.drop(wn_fun1.delnodes)
|
|
||||||
nodes = [node for node in wn_fun1.nodes if node not in delnodes]
|
|
||||||
# coordinates:pandas.Series,存储所有节点的坐标,类型为 Series,索引为节点名称,值为 (x, y) 坐标对
|
|
||||||
coordinates = wn_fun1.coordinates
|
|
||||||
|
|
||||||
# 随机产生监测点
|
|
||||||
# junctionnum:int,nodes 的长度,表示节点的数量
|
|
||||||
junctionnum = len(nodes)
|
|
||||||
# random_numbers:list[int],使用 random.sample 随机选择 sensornum(20)个节点的编号。它返回一个不重复的随机编号列表
|
|
||||||
# random_numbers = random.sample(range(junctionnum), sensor_num)
|
|
||||||
# for i in range(sensor_num):
|
|
||||||
# # print(random_numbers[i])
|
|
||||||
|
|
||||||
wn_fun1.get_Conn()
|
|
||||||
# hL:pandas.DataFrame,水力距离矩阵,表示每个节点到其他节点的水力阻力
|
|
||||||
# G:networkx.DiGraph,加权有向图,表示管网的拓扑结构,节点之间的边带有权重
|
|
||||||
hL, G = wn_fun1.CtoS()
|
|
||||||
# SS:pandas.DataFrame,灵敏度矩阵,表示每个节点对管网变化(如粗糙度、流量等)的响应
|
|
||||||
SS = wn_fun1.Jaco(hL)
|
|
||||||
# group:dict[int, list[str]],使用 kgroup 函数将节点按坐标分成若干组,每组包含的节点数不一定相同。group 是一个字典,键为分组编号,值为节点名列表
|
|
||||||
|
|
||||||
G1 = wn_real.to_graph()
|
|
||||||
G1 = G1.to_undirected() # 变为无向图
|
|
||||||
|
|
||||||
group = kgroup(Coor_node, sensor_num)
|
|
||||||
# group = skater_partition(G1, sensor_num)
|
|
||||||
# group = spectral_partition(G1, sensor_num)
|
|
||||||
|
|
||||||
# print(group)
|
|
||||||
# --------------------- 保存 group 数据 ---------------------
|
|
||||||
# 将 group 数据转换为一个“长格式”的 DataFrame,
|
|
||||||
# 每一行记录一个节点及其所属的分组
|
|
||||||
# group_data = []
|
|
||||||
# for group_id, node_list in group.items():
|
|
||||||
# for node in node_list:
|
|
||||||
# group_data.append({"Group": group_id, "Node": node})
|
|
||||||
#
|
|
||||||
# df_group = pd.DataFrame(group_data)
|
|
||||||
#
|
|
||||||
# # 保存为 Excel 文件,文件名为 "group.xlsx";index=False 表示不保存行索引
|
|
||||||
# df_group.to_excel("group.xlsx", index=False)
|
|
||||||
|
|
||||||
# wn_fun:Sensorplacement(继承自wn_func)
|
|
||||||
# 创建Sensorplacement类的实例,传入水力网络模型wn_real和传感器数量sensornum。Sensorplacement用于计算和布置传感器
|
|
||||||
wn_fun = Sensorplacement(wn_real, sensor_num, min_diameter=min_diameter)
|
|
||||||
wn_fun.__dict__.update(wn_fun1.__dict__)
|
|
||||||
# sensorindex:list[str],初始传感器布置位置的节点名称
|
|
||||||
# sensorindex_2:list[str],根据分组选择的传感器位置
|
|
||||||
sensorindex, sensorindex_2 = wn_fun.sensor(SS, G, group) # 初始的sensorindex
|
|
||||||
# print(str(sensor_num), "个测压点,测压点位置:", sensorindex)
|
|
||||||
|
|
||||||
|
|
||||||
# 重新打开数据库
|
|
||||||
# if is_project_open(name=name):
|
|
||||||
# close_project(name=name)
|
|
||||||
# open_project(name=name)
|
|
||||||
# for node_id in sensorindex :
|
|
||||||
# sensor_coord[node_id] = get_node_coord(name=name, node_id=node_id)
|
|
||||||
# close_project(name=name)
|
|
||||||
# print(sensor_coord)
|
|
||||||
# # 分区画图
|
|
||||||
# colorlist = ['lightpink', 'coral', 'rosybrown', 'olive', 'powderblue', 'lightskyblue', 'steelblue', 'peachpuff','brown','silver','indigo','lime','gold','violet','maroon','navy','teal','magenta','cyan',
|
|
||||||
# 'burlywood', 'tan', 'slategrey', 'thistle', 'lightseagreen', 'lightgreen', 'red','blue','yellow','orange','purple','grey','green','pink','lightblue','beige','chartreuse','turquoise','lavender','fuchsia','coral']
|
|
||||||
# G = wn_real.to_graph()
|
|
||||||
# G = G.to_undirected() # 变为无向图
|
|
||||||
# pos = nx.get_node_attributes(G, 'pos')
|
|
||||||
# pass
|
|
||||||
#
|
|
||||||
# for i in range(0, sensor_num):
|
|
||||||
# ax = plt.gca()
|
|
||||||
# ax.set_title(inp_file_real + str(sensor_num))
|
|
||||||
# nodes = nx.draw_networkx_nodes(G, pos, nodelist=group[i], node_color=colorlist[i], node_size=10)
|
|
||||||
# nodes = nx.draw_networkx_nodes(G, pos,
|
|
||||||
# nodelist=sensorindex_2, node_color='red', node_size=70, node_shape='*'
|
|
||||||
# )
|
|
||||||
# edges = nx.draw_networkx_edges(G, pos)
|
|
||||||
# ax.spines['top'].set_visible(False)
|
|
||||||
# ax.spines['right'].set_visible(False)
|
|
||||||
# ax.spines['bottom'].set_visible(False)
|
|
||||||
# ax.spines['left'].set_visible(False)
|
|
||||||
# plt.savefig(inp_file_real + str(sensor_num) + ".png", dpi=300)
|
|
||||||
# plt.show()
|
|
||||||
#
|
|
||||||
# wntr.graphics.plot_network(wn_real, node_attribute=sensorindex_2, node_size=50, node_labels=False,
|
|
||||||
# title=inp_file_real + '_Projetion' + str(sensor_num))
|
|
||||||
# plt.savefig(inp_file_real + '_S' + str(sensor_num) + ".png", dpi=300)
|
|
||||||
# plt.show()
|
|
||||||
return sensorindex
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
sensorindex = get_ID(name=project_info.name, sensor_num=20, min_diameter=300)
|
|
||||||
|
|
||||||
print(sensorindex)
|
|
||||||
# 将 sensor_coord 字典转换为 DataFrame,
|
|
||||||
# 使用 orient='index' 表示字典的键作为 DataFrame 的行索引,
|
|
||||||
# 数据中每个键对应的 value 是一个子字典,其键 'x' 和 'y' 成为 DataFrame 的列名
|
|
||||||
# df_sensor_coord = pd.DataFrame.from_dict(sensor_coord, orient='index')
|
|
||||||
#
|
|
||||||
# # 将索引名称设为 'Node'
|
|
||||||
# df_sensor_coord.index.name = 'Node'
|
|
||||||
#
|
|
||||||
# # 保存到 Excel 文件
|
|
||||||
# df_sensor_coord.to_excel("sensor_coord.xlsx", index=True)
|
|
||||||
@@ -1,557 +0,0 @@
|
|||||||
# 改进灵敏度法
|
|
||||||
import networkx
|
|
||||||
import numpy as np
|
|
||||||
import pandas
|
|
||||||
import wntr
|
|
||||||
import pandas as pd
|
|
||||||
import copy
|
|
||||||
import matplotlib.pyplot as plt
|
|
||||||
import networkx as nx
|
|
||||||
from sklearn.cluster import KMeans
|
|
||||||
from wntr.epanet.toolkit import EpanetException
|
|
||||||
from numpy.linalg import slogdet
|
|
||||||
import random
|
|
||||||
from tjnetwork import *
|
|
||||||
import project_info
|
|
||||||
|
|
||||||
# 2025/03/12
|
|
||||||
# Step1: 获取节点坐标
|
|
||||||
def getCoor(wn: wntr.network.WaterNetworkModel) -> pandas.DataFrame:
|
|
||||||
"""
|
|
||||||
获取管网模型的节点坐标
|
|
||||||
:param wn: 由wntr生成的模型
|
|
||||||
:return: 节点坐标
|
|
||||||
"""
|
|
||||||
# site: pandas.Series
|
|
||||||
# index:节点名称(wn.node_name_list)
|
|
||||||
# values:每个节点的坐标,格式为 tuple(如 (x, y) 或 (x, y, z))
|
|
||||||
site = wn.query_node_attribute('coordinates')
|
|
||||||
# Coor: pandas.Series
|
|
||||||
# index:与site相同(节点名称)。
|
|
||||||
# values:坐标转换为numpy.ndarray(如array([10.5, 20.3]))
|
|
||||||
Coor = site.apply(lambda x: np.array(x)) # 将节点坐标转换为numpy数组
|
|
||||||
# x, y: list[float]
|
|
||||||
x = [] # 存储所有节点的 x 坐标
|
|
||||||
y = [] # 存储所有节点的 y 坐标
|
|
||||||
for i in range(0, len(Coor)):
|
|
||||||
x.append(Coor.values[i][0]) # 将 x 坐标存入 x 列表。
|
|
||||||
y.append(Coor.values[i][1]) # 将 y 坐标存入 y 列表
|
|
||||||
# xy: dict[str, list], x、y 坐标的字典
|
|
||||||
xy = {'x': x, 'y': y}
|
|
||||||
# Coor_node: pandas.DataFrame, 存储节点 x, y 坐标的 DataFrame
|
|
||||||
Coor_node = pd.DataFrame(xy, index=wn.node_name_list, columns=['x', 'y'])
|
|
||||||
return Coor_node
|
|
||||||
|
|
||||||
|
|
||||||
# 2025/03/12
|
|
||||||
# Step2: KMeans 聚类
|
|
||||||
# 将节点用kmeans根据坐标分为k组,存入字典g
|
|
||||||
def kgroup(coor: pandas.DataFrame, knum: int) -> dict[int, list[str]]:
|
|
||||||
"""
|
|
||||||
使用KMeans聚类,将节点坐标分组
|
|
||||||
:param coor: 存储所有节点的坐标数据
|
|
||||||
:param knum: 需要分成的聚类数
|
|
||||||
:return: 聚类结果字典
|
|
||||||
"""
|
|
||||||
g = {}
|
|
||||||
# estimator: sklearn.cluster.KMeans,KMeans 聚类模型
|
|
||||||
estimator = KMeans(n_clusters=knum)
|
|
||||||
estimator.fit(coor)
|
|
||||||
# label_pred: numpy.ndarray(int),每个点的类别标签
|
|
||||||
label_pred = estimator.labels_
|
|
||||||
for i in range(0, knum):
|
|
||||||
g[i] = coor[label_pred == i].index.tolist()
|
|
||||||
return g
|
|
||||||
|
|
||||||
|
|
||||||
# 2025/03/12
|
|
||||||
# Step3: wn_func类,水力计算
|
|
||||||
# wn_func 主要用于计算:
|
|
||||||
# 水力距离(hydraulic length):即节点之间的水力阻力。
|
|
||||||
# 灵敏度分析(sensitivity analysis):用于优化测压点的布置。
|
|
||||||
# 一些与水力相关的函数,包括 CtoS:求水力距离,stafun:求状态函数F
|
|
||||||
# # diff:求F对P的导数,返回灵敏度矩阵A
|
|
||||||
# # sensitivity:返回灵敏度和总灵敏度
|
|
||||||
class wn_func(object):
|
|
||||||
|
|
||||||
# Step3.1: 初始化
|
|
||||||
def __init__(self, wn: wntr.network.WaterNetworkModel):
|
|
||||||
"""
|
|
||||||
获取管网模型信息
|
|
||||||
:param wn: 由wntr生成的模型
|
|
||||||
"""
|
|
||||||
# self.results: wntr.sim.results.SimulationResults,仿真结果,包含压力、流量、水头等数据
|
|
||||||
self.results = wntr.sim.EpanetSimulator(wn).run_sim() # 存储运行结果
|
|
||||||
self.wn = wn
|
|
||||||
# self.q:pandas.DataFrame,管道流量,索引为时间步长,列为管道名称
|
|
||||||
self.q = self.results.link['flowrate']
|
|
||||||
# ReservoirIndex / Tankindex: list[str],水库 / 水箱节点名称列表
|
|
||||||
ReservoirIndex = wn.reservoir_name_list
|
|
||||||
Tankindex = wn.tank_name_list
|
|
||||||
# 删除水库节点,删除与直接水库相连的虚拟管道
|
|
||||||
# self.pipes: list[str],所有管道的名称
|
|
||||||
self.pipes = wn.pipe_name_list
|
|
||||||
# self.nodes: list[str],所有节点的名称
|
|
||||||
self.nodes = wn.node_name_list
|
|
||||||
# self.coordinates:pandas.Series,节点坐标,索引为节点名,值为 (x, y) 坐标的 tuple
|
|
||||||
self.coordinates = wn.query_node_attribute('coordinates')
|
|
||||||
# allpumps / allvalves: list[str],所有泵/阀门名称列表
|
|
||||||
allpumps = wn.pump_name_list
|
|
||||||
allvalves = wn.valve_name_list
|
|
||||||
# pumpstnode / pumpednode / valvestnode / valveednode: list[str],存储泵和阀门 起终点节点的名称
|
|
||||||
pumpstnode = []
|
|
||||||
pumpednode = []
|
|
||||||
valvestnode = []
|
|
||||||
valveednode = []
|
|
||||||
# Reservoirpipe / Reservoirednode: list[str],记录与水库相关的管道和节点
|
|
||||||
Reservoirpipe = []
|
|
||||||
Reservoirednode = []
|
|
||||||
for pump in allpumps:
|
|
||||||
pumpstnode.append(wn.links[pump].start_node.name)
|
|
||||||
pumpednode.append(wn.links[pump].end_node.name)
|
|
||||||
for valve in allvalves:
|
|
||||||
valvestnode.append(wn.links[valve].start_node.name)
|
|
||||||
valveednode.append(wn.links[valve].end_node.name)
|
|
||||||
for pipe in self.pipes:
|
|
||||||
if wn.links[pipe].start_node.name in ReservoirIndex:
|
|
||||||
Reservoirpipe.append(pipe)
|
|
||||||
Reservoirednode.append(wn.links[pipe].end_node.name)
|
|
||||||
if wn.links[pipe].start_node.name in Tankindex:
|
|
||||||
Reservoirpipe.append(pipe)
|
|
||||||
Reservoirednode.append(wn.links[pipe].end_node.name)
|
|
||||||
if wn.links[pipe].end_node.name in Tankindex:
|
|
||||||
Reservoirpipe.append(pipe)
|
|
||||||
Reservoirednode.append(wn.links[pipe].start_node.name)
|
|
||||||
# 泵的起终点、tank、reservoir
|
|
||||||
# self.delnodes: list[str],需要删除的节点(包括水库、泵、阀门连接的节点)
|
|
||||||
self.delnodes = list(
|
|
||||||
set(ReservoirIndex).union(Tankindex, pumpstnode, pumpednode, valvestnode, valveednode, Reservoirednode))
|
|
||||||
# 泵、起终点为tank、reservoir的管道
|
|
||||||
# self.delpipes: list[str],需要删除的管道(包括水库、泵、阀门连接的管道)
|
|
||||||
self.delpipes = list(set(wn.pump_name_list).union(wn.valve_name_list).union(Reservoirpipe))
|
|
||||||
self.pipes = [pipe for pipe in wn.pipe_name_list if pipe not in self.delpipes]
|
|
||||||
# self.L: list[float],所有管道的长度(以米为单位)
|
|
||||||
self.L = wn.query_link_attribute('length')[self.pipes].tolist()
|
|
||||||
self.n = len(self.nodes)
|
|
||||||
self.m = len(self.pipes)
|
|
||||||
# self.unit_headloss: list[float],单位水头损失(headloss 数据的第一行,单位:米/km)
|
|
||||||
self.unit_headloss = self.results.link['headloss'].iloc[0, :].tolist()
|
|
||||||
##
|
|
||||||
self.delnodes1 = list(set(ReservoirIndex).union(Tankindex))
|
|
||||||
|
|
||||||
# Step3.2: 计算水力距离
|
|
||||||
def CtoS(self):
|
|
||||||
"""
|
|
||||||
计算水力距离矩阵
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
# 水力距离:当行索引对应的节点为控制点时,列索引对应的节点距离控制点的(路径*水头损失)的最小值
|
|
||||||
# nodes:list[str](节点名称)
|
|
||||||
nodes = copy.deepcopy(self.nodes)
|
|
||||||
# pipes:list[str](管道名称)
|
|
||||||
pipes = self.pipes
|
|
||||||
wn = self.wn
|
|
||||||
# n / m:int(节点数 / 管道数)
|
|
||||||
n = self.n
|
|
||||||
m = self.m
|
|
||||||
s1 = [0] * m
|
|
||||||
q = self.q
|
|
||||||
L = self.L
|
|
||||||
# H1:pandas.DataFrame,水头数据,索引为时间步长,列为节点名
|
|
||||||
H1 = self.results.node['head'].T
|
|
||||||
# hh:list[float],计算管道两端水头之差
|
|
||||||
hh = []
|
|
||||||
# 水头损失
|
|
||||||
for p in pipes:
|
|
||||||
h1 = self.wn.links[p].start_node.name
|
|
||||||
h1 = H1.loc[str(h1)]
|
|
||||||
h2 = self.wn.links[p].end_node.name
|
|
||||||
h2 = H1.loc[str(h2)]
|
|
||||||
hh.append(abs(h1 - h2))
|
|
||||||
hh = np.array(hh)
|
|
||||||
# headloss:pandas.DataFrame,管道水头损失矩阵
|
|
||||||
headloss = pd.DataFrame(hh, index=pipes).T
|
|
||||||
# s1:管道阻力系数,s2:将管道阻力系数与管道的起始节点和终止节点对应
|
|
||||||
hf = pd.DataFrame(np.array([0] * (n ** 2)).reshape(n, n), index=nodes, columns=nodes, dtype=float)
|
|
||||||
weightL = pd.DataFrame(np.array([0] * (n ** 2)).reshape(n, n), index=nodes, columns=nodes, dtype=float)
|
|
||||||
# s2为对应管道起始节点与终止节点的粗糙度系数矩阵,index代表起始节点,columns代表终止节点
|
|
||||||
G = nx.DiGraph()
|
|
||||||
for i in range(0, m):
|
|
||||||
pipe = pipes[i]
|
|
||||||
a = wn.links[pipe].start_node.name
|
|
||||||
b = wn.links[pipe].end_node.name
|
|
||||||
if q.loc[0, pipe] > 0:
|
|
||||||
hf.loc[a, b] = headloss.loc[0, pipe]
|
|
||||||
weightL.loc[a, b] = headloss.loc[0, pipe] * L[i]
|
|
||||||
G.add_weighted_edges_from([(a, b, weightL.loc[a, b])])
|
|
||||||
|
|
||||||
else:
|
|
||||||
hf.loc[b, a] = headloss.loc[0, pipe]
|
|
||||||
weightL.loc[b, a] = headloss.loc[0, pipe] * L[i]
|
|
||||||
G.add_weighted_edges_from([(b, a, weightL.loc[b, a])])
|
|
||||||
|
|
||||||
hydraulicL = pd.DataFrame(np.array([0] * (n ** 2)).reshape(n, n), index=nodes, columns=nodes, dtype=float)
|
|
||||||
|
|
||||||
for a in nodes:
|
|
||||||
if a in G.nodes:
|
|
||||||
d = nx.shortest_path_length(G, source=a, weight='weight')
|
|
||||||
for b in list(d.keys()):
|
|
||||||
hydraulicL.loc[a, b] = d[b]
|
|
||||||
|
|
||||||
hydraulicL = hydraulicL.drop(self.delnodes)
|
|
||||||
hydraulicL = hydraulicL.drop(self.delnodes, axis=1)
|
|
||||||
|
|
||||||
# 求加权水力距离
|
|
||||||
return hydraulicL, G
|
|
||||||
|
|
||||||
# Step3.3: 计算灵敏度矩阵
|
|
||||||
# 获取关系矩阵
|
|
||||||
def get_Conn(self):
|
|
||||||
"""
|
|
||||||
计算管网连接关系矩阵
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
m = self.wn.num_links
|
|
||||||
n = self.wn.num_nodes
|
|
||||||
p = self.wn.num_pumps
|
|
||||||
v = self.wn.num_valves
|
|
||||||
|
|
||||||
self.nonjunc_index = []
|
|
||||||
self.non_link_index = []
|
|
||||||
for r in self.wn.reservoirs():
|
|
||||||
self.nonjunc_index.append(r[0])
|
|
||||||
for t in self.wn.tanks():
|
|
||||||
self.nonjunc_index.append(t[0])
|
|
||||||
# Conn:numpy.matrix,节点-管道连接矩阵,起点 -1,终点 1
|
|
||||||
Conn = np.mat(np.zeros([n, m - p - v])) # 节点和管道的关系矩阵,行为节点,列为管道,起点为-1,终点为1
|
|
||||||
# NConn:numpy.matrix,节点-节点连接矩阵,有管道相连的地方设为 1
|
|
||||||
NConn = np.mat(np.zeros([n, n])) # 节点之间的关系,之间有管道为1,反之为0
|
|
||||||
# pipes:list[str],去除泵和阀门的管道列表
|
|
||||||
pipes = [pipe for pipe in self.wn.pipes() if pipe not in self.wn.pumps() and pipe not in self.wn.valves()]
|
|
||||||
for pipe_name, pipe in pipes:
|
|
||||||
start = self.wn.node_name_list.index(pipe.start_node_name)
|
|
||||||
end = self.wn.node_name_list.index(pipe.end_node_name)
|
|
||||||
p_index = self.wn.link_name_list.index(pipe_name)
|
|
||||||
Conn[start, p_index] = -1
|
|
||||||
Conn[end, p_index] = 1
|
|
||||||
NConn[start, end] = 1
|
|
||||||
NConn[end, start] = 1
|
|
||||||
self.A = Conn
|
|
||||||
link_name_list = [link for link in self.wn.link_name_list if
|
|
||||||
link not in self.wn.pump_name_list and link not in self.wn.valve_name_list]
|
|
||||||
self.A2 = pd.DataFrame(self.A, index=self.wn.node_name_list, columns=link_name_list)
|
|
||||||
self.A2 = self.A2.drop(self.delnodes)
|
|
||||||
for pipe in self.delpipes:
|
|
||||||
if pipe not in self.wn.pump_name_list and pipe not in self.wn.valve_name_list:
|
|
||||||
self.A2 = self.A2.drop(columns=pipe)
|
|
||||||
self.junc_list = self.A2.index
|
|
||||||
self.A2 = np.mat(self.A2) # 节点管道关系
|
|
||||||
self.A3 = NConn
|
|
||||||
|
|
||||||
def Jaco(self, hL: pandas.DataFrame):
|
|
||||||
"""
|
|
||||||
计算灵敏度矩阵(节点压力对粗糙度变化的响应)
|
|
||||||
:param hL: 水力距离矩阵
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
# global result
|
|
||||||
# A:numpy.matrix, 节点-管道关系矩阵
|
|
||||||
A = self.A2
|
|
||||||
wn = self.wn
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = wntr.sim.EpanetSimulator(wn).run_sim()
|
|
||||||
except EpanetException:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
h = result.link['headloss'][self.pipes].values[0]
|
|
||||||
q = result.link['flowrate'][self.pipes].values[0]
|
|
||||||
l = self.wn.query_link_attribute('length')[self.pipes]
|
|
||||||
C = self.wn.query_link_attribute('roughness')[self.pipes]
|
|
||||||
# headloss:numpy.ndarray,水头损失数组
|
|
||||||
headloss = np.array(h)
|
|
||||||
# 调整流量方向
|
|
||||||
for i in range(0, len(q)):
|
|
||||||
if q[i] < 0:
|
|
||||||
A[:, i] = -A[:, i]
|
|
||||||
# q:numpy.ndarray,流量数组
|
|
||||||
q = np.abs(q)
|
|
||||||
# 两个灵敏度矩阵
|
|
||||||
# B / S:numpy.matrix,灵敏度计算的中间矩阵
|
|
||||||
B = np.mat(np.diag(q / ((1.852 * headloss) + 1e-10)))
|
|
||||||
S = np.mat(np.diag(q / C))
|
|
||||||
# X:numpy.matrix, 灵敏度矩阵
|
|
||||||
X = A * B * A.T
|
|
||||||
try:
|
|
||||||
det = np.linalg.det(X)
|
|
||||||
except RuntimeError as e:
|
|
||||||
sign, logdet = slogdet(X) # 防止溢出
|
|
||||||
det = sign * np.exp(logdet)
|
|
||||||
if det != 0:
|
|
||||||
J_H_Cw = X.I * A * S
|
|
||||||
# J_H_Q = -X.I
|
|
||||||
J_q_Cw = S - B * A.T * X.I * A * S # 去掉了delnodes和delpipes
|
|
||||||
# J_q_Q = B * A.T * X.I
|
|
||||||
else: # 当X不可逆
|
|
||||||
J_H_Cw = np.linalg.pinv(X) @ A @ S
|
|
||||||
# J_H_Q = -np.linalg.pinv(X)
|
|
||||||
J_q_Cw = S - B * A.T * np.linalg.pinv(X) * A * S
|
|
||||||
# J_q_Q = B * A.T * np.linalg.pinv(X)
|
|
||||||
|
|
||||||
Sen_pressure = []
|
|
||||||
S_pressure = np.abs(J_H_Cw).sum(axis=1).tolist() # 修改为绝对值
|
|
||||||
for ss in S_pressure:
|
|
||||||
Sen_pressure.append(ss[0])
|
|
||||||
# 求总灵敏度
|
|
||||||
SS_pressure = copy.deepcopy(hL)
|
|
||||||
for i in range(0, len(Sen_pressure)):
|
|
||||||
SS_pressure.iloc[i, :] = SS_pressure.iloc[i, :] * Sen_pressure[i]
|
|
||||||
SS = copy.deepcopy(hL)
|
|
||||||
for i in range(0, len(Sen_pressure)):
|
|
||||||
SS.iloc[i, :] = SS.iloc[i, :] * Sen_pressure[i]
|
|
||||||
# SS[i,j]:节点nodes[i]的灵敏度*该节点到nodes[j]的水力距离
|
|
||||||
return SS
|
|
||||||
|
|
||||||
|
|
||||||
# 2025/03/12
|
|
||||||
# Step4: 传感器布置优化
|
|
||||||
# Sensorplacement
|
|
||||||
# weight:分配权重
|
|
||||||
# sensor:传感器布置的位置
|
|
||||||
class Sensorplacement(wn_func):
|
|
||||||
"""
|
|
||||||
Sensorplacement 类继承了 wn_func 类,并且用于计算和优化传感器布置的位置。
|
|
||||||
"""
|
|
||||||
def __init__(self, wn: wntr.network.WaterNetworkModel, sensornum: int):
|
|
||||||
"""
|
|
||||||
|
|
||||||
:param wn: 由wntr生成的模型
|
|
||||||
:param sensornum: 传感器的数量
|
|
||||||
"""
|
|
||||||
wn_func.__init__(self, wn)
|
|
||||||
self.sensornum = sensornum
|
|
||||||
|
|
||||||
# 1.某个节点到所有节点的加权距离之和
|
|
||||||
# 2.某个节点到该组内所有节点的加权距离之和
|
|
||||||
def sensor(self, SS: pandas.DataFrame, G: networkx.Graph, group: dict[int, list[str]]):
|
|
||||||
"""
|
|
||||||
sensor 方法是用来根据灵敏度矩阵 SS 和加权图 G 来确定传感器布置位置的
|
|
||||||
:param SS: 灵敏度矩阵,每个节点的行和列代表不同节点,矩阵元素表示节点间的灵敏度。SS.iloc[i, :] 表示第 i 行对应节点 i 到所有其他节点的灵敏度
|
|
||||||
:param G: 加权图,表示管网的拓扑结构,每个节点通过管道连接。图的边的权重通常是根据水力距离或者流量等计算的
|
|
||||||
:param group: 节点分组,字典的键是分组编号,值是该组的节点名称列表
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
# 传感器布置个数以及位置
|
|
||||||
# W = self.weight()
|
|
||||||
n = self.n - len(self.delnodes)
|
|
||||||
nodes = copy.deepcopy(self.nodes)
|
|
||||||
for node in self.delnodes:
|
|
||||||
nodes.remove(node)
|
|
||||||
# sumSS:list[float],每个节点到其他节点的灵敏度之和。SS.iloc[i, :] 返回第 i 个节点与所有其他节点的灵敏度值,sum(SS.iloc[i, :]) 计算这些灵敏度值的总和。
|
|
||||||
sumSS = []
|
|
||||||
for i in range(0, n):
|
|
||||||
sumSS.append(sum(SS.iloc[i, :]))
|
|
||||||
# 一个整数范围,表示每个节点的索引,用作sumSS_ DataFrame的索引
|
|
||||||
indices = range(0, n)
|
|
||||||
# sumSS_:pandas.DataFrame,将 sumSS 转换成 DataFrame 格式,并且将节点的总灵敏度保存到 CSV 文件 sumSS_data.csv 中
|
|
||||||
sumSS_ = pd.DataFrame(np.array(sumSS), index=indices)
|
|
||||||
sumSS_.to_csv('sumSS_data.csv') # 存储节点总灵敏度
|
|
||||||
# sumSS:pandas.DataFrame,sumSS 被转换为 DataFrame 类型,并且按总灵敏度(即灵敏度之和)降序排列。此时,sumSS 是按节点的灵敏度之和排序的 DataFrame
|
|
||||||
sumSS = pd.DataFrame(np.array(sumSS), index=nodes)
|
|
||||||
sumSS = sumSS.sort_values(by=[0], ascending=[False])
|
|
||||||
# sensorindex:list[str],用于存储根据灵敏度排序选出的传感器位置的节点名称,存储根据总灵敏度排序的节点列表,用于传感器布置
|
|
||||||
sensorindex = []
|
|
||||||
# sensorindex_2:list[str],用于存储每组内根据灵敏度排序选出的传感器位置的节点名称,存储每个组内根据灵敏度排序选择的传感器节点
|
|
||||||
sensorindex_2 = []
|
|
||||||
# group_S:dict[int, pandas.DataFrame],存储每个组内的灵敏度矩阵
|
|
||||||
group_S = {}
|
|
||||||
# group_sumSS:dict[int, list[float]],存储每个组内节点的总灵敏度,值为每个组内节点灵敏度之和的列表
|
|
||||||
group_sumSS = {}
|
|
||||||
for i in range(0, len(group)):
|
|
||||||
for node in self.delnodes:
|
|
||||||
# 这里的group[i]是每个组的节点列表,代码首先去除已经被标记为删除的节点self.delnodes
|
|
||||||
if node in group[i]:
|
|
||||||
group[i].remove(node)
|
|
||||||
group_S[i] = SS.loc[group[i], group[i]]
|
|
||||||
# 对每个组内的节点,计算组内节点的总灵敏度(group_sumSS[i])。它将每个组内节点的灵敏度值相加,并且按灵敏度降序排序
|
|
||||||
group_sumSS[i] = []
|
|
||||||
for j in range(0, len(group[i])):
|
|
||||||
group_sumSS[i].append(sum(group_S[i].iloc[j, :]))
|
|
||||||
group_sumSS[i] = pd.DataFrame(np.array(group_sumSS[i]), index=group[i])
|
|
||||||
group_sumSS[i] = group_sumSS[i].sort_values(by=[0], ascending=[False])
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 1.选sumSS最大的节点,然后把这个节点所在的那个组删掉,就可以不再从这个组选点。再重新排序选sumSS最大的;
|
|
||||||
# 2.在每组内选group_sumSS最大的节点
|
|
||||||
# 在这个循环中,首先选择灵敏度最高的节点Smaxnode并添加到sensorindex。然后根据灵敏度排序,删除已选的节点并继续选择下一个灵敏度最大的节点。这个过程用于选择传感器的位置
|
|
||||||
sensornum = self.sensornum
|
|
||||||
for i in range(0, sensornum):
|
|
||||||
# Smaxnode:str,最大灵敏度节点,sumSS.index[0] 表示灵敏度最高的节点
|
|
||||||
Smaxnode = sumSS.index[0]
|
|
||||||
sensorindex.append(Smaxnode)
|
|
||||||
sensorindex_2.append(group_sumSS[i].index[0])
|
|
||||||
|
|
||||||
for key, value in group.items():
|
|
||||||
if Smaxnode in value:
|
|
||||||
sumSS = sumSS.drop(index=group[key])
|
|
||||||
continue
|
|
||||||
|
|
||||||
sumSS = sumSS.sort_values(by=[0], ascending=[False])
|
|
||||||
|
|
||||||
return sensorindex, sensorindex_2
|
|
||||||
|
|
||||||
|
|
||||||
# 2025/03/13
|
|
||||||
def get_sensor_coord(name: str, sensor_num: int) -> dict[str, float]:
|
|
||||||
"""
|
|
||||||
获取布置测压点的坐标,初始测压点布置根据灵敏度来布置,计算初始情况下的校准过程的error
|
|
||||||
:param name: 数据库名称
|
|
||||||
:param sensor_num: 测压点数目
|
|
||||||
:return: 测压点坐标字典
|
|
||||||
"""
|
|
||||||
# inp_file_real:str,输入文件名,表示原始水力模型文件的路径,该文件格式为 EPANET 输入文件(.inp),包含管网的结构信息、节点、管道、泵等数据
|
|
||||||
inp_file_real = f'./db_inp/{name}.db.inp'
|
|
||||||
# sensornum:int,需要布置的传感器数量
|
|
||||||
# sensornum = sensor_num
|
|
||||||
# wn_real:wntr.network.WaterNetworkModel,加载 EPANET 水力模型
|
|
||||||
wn_real = wntr.network.WaterNetworkModel(inp_file_real) # 真实粗糙度的原始管网
|
|
||||||
# sim_real:wntr.sim.EpanetSimulator,创建一个水力仿真器对象
|
|
||||||
sim_real = wntr.sim.EpanetSimulator(wn_real)
|
|
||||||
# results_real:wntr.sim.results.SimulationResults,运行仿真并返回结果
|
|
||||||
results_real = sim_real.run_sim()
|
|
||||||
|
|
||||||
# real_C:list[float],包含所有管道粗糙度的列表
|
|
||||||
real_C = wn_real.query_link_attribute('roughness').tolist()
|
|
||||||
# wn_fun1:wn_func(继承自 object),创建 wn_func 类的实例,传入 wn_real 水力模型对象。wn_func 用于计算管网相关的水力属性,比如水力距离、灵敏度等
|
|
||||||
wn_fun1 = wn_func(wn_real)
|
|
||||||
# nodes:list[str],管网的节点名称列表
|
|
||||||
nodes = wn_fun1.nodes
|
|
||||||
# delnodes:list[str],被删除的节点(如水库、泵、阀门连接的节点等)
|
|
||||||
delnodes = wn_fun1.delnodes
|
|
||||||
# Coor_node:pandas.DataFrame
|
|
||||||
Coor_node = getCoor(wn_real)
|
|
||||||
Coor_node = Coor_node.drop(wn_fun1.delnodes)
|
|
||||||
nodes = [node for node in wn_fun1.nodes if node not in delnodes]
|
|
||||||
# coordinates:pandas.Series,存储所有节点的坐标,类型为 Series,索引为节点名称,值为 (x, y) 坐标对
|
|
||||||
coordinates = wn_fun1.coordinates
|
|
||||||
|
|
||||||
# 随机产生监测点
|
|
||||||
# junctionnum:int,nodes 的长度,表示节点的数量
|
|
||||||
junctionnum = len(nodes)
|
|
||||||
# random_numbers:list[int],使用 random.sample 随机选择 sensornum(20)个节点的编号。它返回一个不重复的随机编号列表
|
|
||||||
# random_numbers = random.sample(range(junctionnum), sensor_num)
|
|
||||||
# for i in range(sensor_num):
|
|
||||||
# # print(random_numbers[i])
|
|
||||||
|
|
||||||
wn_fun1.get_Conn()
|
|
||||||
# hL:pandas.DataFrame,水力距离矩阵,表示每个节点到其他节点的水力阻力
|
|
||||||
# G:networkx.DiGraph,加权有向图,表示管网的拓扑结构,节点之间的边带有权重
|
|
||||||
hL, G = wn_fun1.CtoS()
|
|
||||||
# SS:pandas.DataFrame,灵敏度矩阵,表示每个节点对管网变化(如粗糙度、流量等)的响应
|
|
||||||
SS = wn_fun1.Jaco(hL)
|
|
||||||
# group:dict[int, list[str]],使用 kgroup 函数将节点按坐标分成若干组,每组包含的节点数不一定相同。group 是一个字典,键为分组编号,值为节点名列表
|
|
||||||
group = kgroup(Coor_node, sensor_num)
|
|
||||||
# wn_fun:Sensorplacement(继承自wn_func)
|
|
||||||
# 创建Sensorplacement类的实例,传入水力网络模型wn_real和传感器数量sensornum。Sensorplacement用于计算和布置传感器
|
|
||||||
wn_fun = Sensorplacement(wn_real, sensor_num)
|
|
||||||
wn_fun.__dict__.update(wn_fun1.__dict__)
|
|
||||||
# sensorindex:list[str],初始传感器布置位置的节点名称
|
|
||||||
# sensorindex_2:list[str],根据分组选择的传感器位置
|
|
||||||
sensorindex, sensorindex_2 = wn_fun.sensor(SS, G, group) # 初始的sensorindex
|
|
||||||
# print(str(sensor_num), "个测压点,测压点位置:", sensorindex)
|
|
||||||
sensor_coord = {}
|
|
||||||
# 重新打开数据库
|
|
||||||
if is_project_open(name=name):
|
|
||||||
close_project(name=name)
|
|
||||||
open_project(name=name)
|
|
||||||
for node_id in sensorindex:
|
|
||||||
sensor_coord[node_id] = get_node_coord(name=name, node_id=node_id)
|
|
||||||
close_project(name=name)
|
|
||||||
# print(sensor_coord)
|
|
||||||
return sensor_coord
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
sensor_coord = get_sensor_coord(name=project_info.name, sensor_num=20)
|
|
||||||
print(sensor_coord)
|
|
||||||
# '''
|
|
||||||
# 初始测压点布置根据灵敏度来布置,计算初始情况下的校准过程的error
|
|
||||||
# '''
|
|
||||||
#
|
|
||||||
# # inp_file_real:str,输入文件名,表示原始水力模型文件的路径,该文件格式为 EPANET 输入文件(.inp),包含管网的结构信息、节点、管道、泵等数据
|
|
||||||
# inp_file_real = './db_inp/bb.db.inp'
|
|
||||||
# # sensornum:int,需要布置的传感器数量
|
|
||||||
# sensornum = 20
|
|
||||||
# # wn_real:wntr.network.WaterNetworkModel,加载 EPANET 水力模型
|
|
||||||
# wn_real = wntr.network.WaterNetworkModel(inp_file_real) # 真实粗糙度的原始管网
|
|
||||||
# # sim_real:wntr.sim.EpanetSimulator,创建一个水力仿真器对象
|
|
||||||
# sim_real = wntr.sim.EpanetSimulator(wn_real)
|
|
||||||
# # results_real:wntr.sim.results.SimulationResults,运行仿真并返回结果
|
|
||||||
# results_real = sim_real.run_sim()
|
|
||||||
#
|
|
||||||
# # real_C:list[float],包含所有管道粗糙度的列表
|
|
||||||
# real_C = wn_real.query_link_attribute('roughness').tolist()
|
|
||||||
# # wn_fun1:wn_func(继承自 object),创建 wn_func 类的实例,传入 wn_real 水力模型对象。wn_func 用于计算管网相关的水力属性,比如水力距离、灵敏度等
|
|
||||||
# wn_fun1 = wn_func(wn_real)
|
|
||||||
# # nodes:list[str],管网的节点名称列表
|
|
||||||
# nodes = wn_fun1.nodes
|
|
||||||
# # delnodes:list[str],被删除的节点(如水库、泵、阀门连接的节点等)
|
|
||||||
# delnodes = wn_fun1.delnodes
|
|
||||||
# # Coor_node:pandas.DataFrame
|
|
||||||
# Coor_node = getCoor(wn_real)
|
|
||||||
# Coor_node = Coor_node.drop(wn_fun1.delnodes)
|
|
||||||
# nodes = [node for node in wn_fun1.nodes if node not in delnodes]
|
|
||||||
# # coordinates:pandas.Series,存储所有节点的坐标,类型为 Series,索引为节点名称,值为 (x, y) 坐标对
|
|
||||||
# coordinates = wn_fun1.coordinates
|
|
||||||
#
|
|
||||||
# # 随机产生监测点
|
|
||||||
# # junctionnum:int,nodes 的长度,表示节点的数量
|
|
||||||
# junctionnum = len(nodes)
|
|
||||||
# # random_numbers:list[int],使用 random.sample 随机选择 sensornum(20)个节点的编号。它返回一个不重复的随机编号列表
|
|
||||||
# random_numbers = random.sample(range(junctionnum), sensornum)
|
|
||||||
# for i in range(sensornum):
|
|
||||||
# print(random_numbers[i])
|
|
||||||
#
|
|
||||||
# wn_fun1.get_Conn()
|
|
||||||
# # hL:pandas.DataFrame,水力距离矩阵,表示每个节点到其他节点的水力阻力
|
|
||||||
# # G:networkx.DiGraph,加权有向图,表示管网的拓扑结构,节点之间的边带有权重
|
|
||||||
# hL, G = wn_fun1.CtoS()
|
|
||||||
# # SS:pandas.DataFrame,灵敏度矩阵,表示每个节点对管网变化(如粗糙度、流量等)的响应
|
|
||||||
# SS = wn_fun1.Jaco(hL)
|
|
||||||
# # group:dict[int, list[str]],使用 kgroup 函数将节点按坐标分成若干组,每组包含的节点数不一定相同。group 是一个字典,键为分组编号,值为节点名列表
|
|
||||||
# group = kgroup(Coor_node, sensornum)
|
|
||||||
# # wn_fun:Sensorplacement(继承自wn_func)
|
|
||||||
# # 创建Sensorplacement类的实例,传入水力网络模型wn_real和传感器数量sensornum。Sensorplacement用于计算和布置传感器
|
|
||||||
# wn_fun = Sensorplacement(wn_real, sensornum)
|
|
||||||
# wn_fun.__dict__.update(wn_fun1.__dict__)
|
|
||||||
# # sensorindex:list[str],初始传感器布置位置的节点名称
|
|
||||||
# # sensorindex_2:list[str],根据分组选择的传感器位置
|
|
||||||
# sensorindex, sensorindex_2 = wn_fun.sensor(SS, G, group) # 初始的sensorindex
|
|
||||||
# print(str(sensornum), "个测压点,测压点位置:", sensorindex)
|
|
||||||
|
|
||||||
# # 分区画图
|
|
||||||
# colorlist = ['lightpink', 'coral', 'rosybrown', 'olive', 'powderblue', 'lightskyblue', 'steelblue', 'peachpuff','brown','silver','indigo','lime','gold','violet','maroon','navy','teal','magenta','cyan',
|
|
||||||
# 'burlywood', 'tan', 'slategrey', 'thistle', 'lightseagreen', 'lightgreen', 'red','blue','yellow','orange','purple','grey','green','pink','lightblue','beige','chartreuse','turquoise','lavender','fuchsia','coral']
|
|
||||||
# G = wn_real.to_graph()
|
|
||||||
# G = G.to_undirected() # 变为无向图
|
|
||||||
# pos = nx.get_node_attributes(G, 'pos')
|
|
||||||
# pass
|
|
||||||
# for i in range(0, sensornum):
|
|
||||||
# ax = plt.gca()
|
|
||||||
# ax.set_title(inp_file_real + str(sensornum))
|
|
||||||
# nodes = nx.draw_networkx_nodes(G, pos, nodelist=group[i], node_color=colorlist[i], node_size=20)
|
|
||||||
# nodes = nx.draw_networkx_nodes(G, pos,
|
|
||||||
# nodelist=sensorindex_2, node_color='black', node_size=70, node_shape='*'
|
|
||||||
# )
|
|
||||||
# edges = nx.draw_networkx_edges(G, pos)
|
|
||||||
# ax.spines['top'].set_visible(False)
|
|
||||||
# ax.spines['right'].set_visible(False)
|
|
||||||
# ax.spines['bottom'].set_visible(False)
|
|
||||||
# ax.spines['left'].set_visible(False)
|
|
||||||
# plt.savefig(inp_file_real + str(sensornum) + ".png")
|
|
||||||
# plt.show()
|
|
||||||
#
|
|
||||||
# wntr.graphics.plot_network(wn_real, node_attribute=sensorindex_2, node_size=50, node_labels=False,
|
|
||||||
# title=inp_file_real + '_Projetion' + str(sensornum))
|
|
||||||
# plt.savefig(inp_file_real + '_S' + str(sensornum) + ".png")
|
|
||||||
# plt.show()
|
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from typing import Generic, TypeVar
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedList(list[T], Generic[T]):
|
||||||
|
"""A page of items carrying the total count from its data source."""
|
||||||
|
|
||||||
|
def __init__(self, items: Iterable[T], *, total: int) -> None:
|
||||||
|
super().__init__(items)
|
||||||
|
self.total = total
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
|
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."""
|
||||||
|
|
||||||
|
type: str
|
||||||
|
title: str
|
||||||
|
status: int
|
||||||
|
detail: str
|
||||||
|
instance: str
|
||||||
|
code: str
|
||||||
|
trace_id: str
|
||||||
|
errors: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def _trace_id(request: Request) -> str:
|
||||||
|
return request.headers.get("X-Request-Id") or str(uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
def _problem_response(
|
||||||
|
request: Request,
|
||||||
|
*,
|
||||||
|
status_code: int,
|
||||||
|
title: str,
|
||||||
|
detail: str,
|
||||||
|
code: str,
|
||||||
|
errors: list[dict[str, Any]] | None = None,
|
||||||
|
) -> JSONResponse:
|
||||||
|
problem = ProblemDetails(
|
||||||
|
type=f"https://tjwater.example/problems/{code.replace('_', '-')}",
|
||||||
|
title=title,
|
||||||
|
status=status_code,
|
||||||
|
detail=detail,
|
||||||
|
instance=request.url.path,
|
||||||
|
code=code,
|
||||||
|
trace_id=_trace_id(request),
|
||||||
|
errors=errors or [],
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status_code,
|
||||||
|
content=problem.model_dump(mode="json"),
|
||||||
|
media_type="application/problem+json",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
exc: RequestValidationError,
|
||||||
|
) -> JSONResponse:
|
||||||
|
return _problem_response(
|
||||||
|
request,
|
||||||
|
status_code=422,
|
||||||
|
title="Validation error",
|
||||||
|
detail="Request validation failed",
|
||||||
|
code="validation_error",
|
||||||
|
errors=exc.errors(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@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",
|
||||||
|
404: "not_found",
|
||||||
|
409: "conflict",
|
||||||
|
422: "validation_error",
|
||||||
|
503: "dependency_unavailable",
|
||||||
|
}
|
||||||
|
return _problem_response(
|
||||||
|
request,
|
||||||
|
status_code=exc.status_code,
|
||||||
|
title=code_by_status.get(exc.status_code, "request_error")
|
||||||
|
.replace("_", " ")
|
||||||
|
.title(),
|
||||||
|
detail=detail,
|
||||||
|
code=code_by_status.get(exc.status_code, "request_error"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Header
|
||||||
|
|
||||||
|
from app.auth.metadata_dependencies import (
|
||||||
|
get_current_metadata_user,
|
||||||
|
get_metadata_repository,
|
||||||
|
)
|
||||||
|
from app.auth.permissions import resolve_permissions
|
||||||
|
from app.auth.project_dependencies import resolve_project_context
|
||||||
|
from app.domain.schemas.access import AccessContextResponse
|
||||||
|
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/access-context", response_model=AccessContextResponse)
|
||||||
|
async def get_access_context(
|
||||||
|
x_project_id: str | None = Header(default=None, alias="X-Project-Id"),
|
||||||
|
current_user=Depends(get_current_metadata_user),
|
||||||
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||||
|
) -> AccessContextResponse:
|
||||||
|
project_context = (
|
||||||
|
await resolve_project_context(x_project_id, current_user, metadata_repo)
|
||||||
|
if x_project_id
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
permissions = resolve_permissions(
|
||||||
|
project_role=project_context.project_role if project_context else None,
|
||||||
|
system_role=current_user.role,
|
||||||
|
is_superuser=current_user.is_superuser,
|
||||||
|
)
|
||||||
|
return AccessContextResponse(
|
||||||
|
user_id=current_user.id,
|
||||||
|
username=current_user.username,
|
||||||
|
system_role=current_user.role,
|
||||||
|
is_system_admin=current_user.is_superuser or current_user.role == "admin",
|
||||||
|
project_id=project_context.project_id if project_context else None,
|
||||||
|
project_role=project_context.project_role if project_context else None,
|
||||||
|
permissions=sorted(permissions),
|
||||||
|
)
|
||||||
@@ -0,0 +1,702 @@
|
|||||||
|
from typing import List
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Response, status
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.engine.url import make_url
|
||||||
|
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
|
from app.auth.metadata_dependencies import (
|
||||||
|
get_current_metadata_admin,
|
||||||
|
get_metadata_repository,
|
||||||
|
)
|
||||||
|
from app.core.audit import AuditAction, log_audit_event
|
||||||
|
from app.domain.schemas.admin_metadata import (
|
||||||
|
AdminProjectCreateRequest,
|
||||||
|
AdminProjectResponse,
|
||||||
|
AdminProjectUpdateRequest,
|
||||||
|
MetadataUsersBatchSyncRequest,
|
||||||
|
MetadataUserResponse,
|
||||||
|
MetadataUserSyncRequest,
|
||||||
|
MetadataUserSyncResult,
|
||||||
|
MetadataUserUpdateRequest,
|
||||||
|
ProjectDatabaseHealthResponse,
|
||||||
|
ProjectDatabaseHealthRequest,
|
||||||
|
ProjectDatabaseResponse,
|
||||||
|
ProjectDatabaseUpsertRequest,
|
||||||
|
ProjectDbRole,
|
||||||
|
ProjectMemberCreateRequest,
|
||||||
|
ProjectMemberResponse,
|
||||||
|
ProjectMemberUpdateRequest,
|
||||||
|
)
|
||||||
|
from app.infra.db.metadb import models
|
||||||
|
from app.infra.db.metadb.repositories.metadata_repository import (
|
||||||
|
MetadataRepository,
|
||||||
|
ProjectDbRouting,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _project_response(project: models.Project) -> AdminProjectResponse:
|
||||||
|
return AdminProjectResponse(
|
||||||
|
project_id=project.id,
|
||||||
|
name=project.name,
|
||||||
|
code=project.code,
|
||||||
|
description=project.description,
|
||||||
|
gs_workspace=project.gs_workspace,
|
||||||
|
map_extent=project.map_extent,
|
||||||
|
status=project.status,
|
||||||
|
created_at=project.created_at,
|
||||||
|
updated_at=project.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _project_database_response(
|
||||||
|
record: models.ProjectDatabase,
|
||||||
|
) -> ProjectDatabaseResponse:
|
||||||
|
return ProjectDatabaseResponse(
|
||||||
|
id=record.id,
|
||||||
|
project_id=record.project_id,
|
||||||
|
db_role=record.db_role,
|
||||||
|
db_type=record.db_type,
|
||||||
|
pool_min_size=record.pool_min_size,
|
||||||
|
pool_max_size=record.pool_max_size,
|
||||||
|
has_dsn=bool(record.dsn_encrypted),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _database_audit_payload(payload: ProjectDatabaseUpsertRequest) -> dict:
|
||||||
|
return {
|
||||||
|
"db_role": payload.db_role,
|
||||||
|
"db_type": _db_type_for_role(payload.db_role),
|
||||||
|
"pool_min_size": payload.pool_min_size,
|
||||||
|
"pool_max_size": payload.pool_max_size,
|
||||||
|
"dsn_updated": payload.dsn is not None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _to_async_sqlalchemy_url(dsn: str) -> str:
|
||||||
|
parsed = make_url(dsn)
|
||||||
|
if parsed.drivername in {"postgresql", "postgres"}:
|
||||||
|
parsed = parsed.set(drivername="postgresql+psycopg")
|
||||||
|
return parsed.render_as_string(hide_password=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _db_type_for_role(db_role: str) -> str:
|
||||||
|
if db_role == "iot_data":
|
||||||
|
return "timescaledb"
|
||||||
|
return "postgresql"
|
||||||
|
|
||||||
|
|
||||||
|
def _status_for_config_value_error(exc: ValueError) -> int:
|
||||||
|
if "DATABASE_ENCRYPTION_KEY" in str(exc):
|
||||||
|
return status.HTTP_503_SERVICE_UNAVAILABLE
|
||||||
|
return status.HTTP_400_BAD_REQUEST
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_database_connection(routing: ProjectDbRouting) -> None:
|
||||||
|
engine = create_async_engine(
|
||||||
|
_to_async_sqlalchemy_url(routing.dsn),
|
||||||
|
pool_size=1,
|
||||||
|
max_overflow=0,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
await conn.execute(text("SELECT 1"))
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def _database_health_error_detail(exc: Exception) -> str:
|
||||||
|
message = str(exc)
|
||||||
|
lower_message = message.lower()
|
||||||
|
if "password authentication failed" in lower_message:
|
||||||
|
return "连通性测试失败:用户名或密码错误,请检查 DSN 中的账号密码。"
|
||||||
|
if "connection refused" in lower_message:
|
||||||
|
return "连通性测试失败:目标主机或端口拒绝连接,请检查地址、端口和服务状态。"
|
||||||
|
if "timeout" in lower_message or "timed out" in lower_message:
|
||||||
|
return "连通性测试失败:连接超时,请检查网络、防火墙和数据库服务状态。"
|
||||||
|
if "could not translate host name" in lower_message or "name or service not known" in lower_message:
|
||||||
|
return "连通性测试失败:数据库主机名无法解析,请检查 DSN 中的主机地址。"
|
||||||
|
first_line = message.splitlines()[0] if message else exc.__class__.__name__
|
||||||
|
return f"连通性测试失败:{first_line}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _upsert_and_audit_metadata_user(
|
||||||
|
payload: MetadataUserSyncRequest,
|
||||||
|
*,
|
||||||
|
current_user,
|
||||||
|
metadata_repo: MetadataRepository,
|
||||||
|
response_status: int,
|
||||||
|
) -> MetadataUserResponse:
|
||||||
|
user = await metadata_repo.upsert_user_from_keycloak(
|
||||||
|
keycloak_id=payload.keycloak_id,
|
||||||
|
username=payload.username,
|
||||||
|
email=str(payload.email),
|
||||||
|
role=payload.role,
|
||||||
|
is_active=payload.is_active,
|
||||||
|
)
|
||||||
|
await log_audit_event(
|
||||||
|
action=AuditAction.UPDATE,
|
||||||
|
user_id=current_user.id,
|
||||||
|
resource_type="metadata_user",
|
||||||
|
resource_id=str(user.id),
|
||||||
|
request_data=payload.model_dump(mode="json"),
|
||||||
|
response_status=response_status,
|
||||||
|
session=metadata_repo.session,
|
||||||
|
)
|
||||||
|
return MetadataUserResponse.model_validate(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/users/me", response_model=MetadataUserResponse)
|
||||||
|
async def get_metadata_admin_me(
|
||||||
|
current_user=Depends(get_current_metadata_admin),
|
||||||
|
) -> MetadataUserResponse:
|
||||||
|
return MetadataUserResponse.model_validate(current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/user-syncs", response_model=MetadataUserResponse)
|
||||||
|
async def sync_metadata_user(
|
||||||
|
payload: MetadataUserSyncRequest,
|
||||||
|
current_user=Depends(get_current_metadata_admin),
|
||||||
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||||
|
) -> MetadataUserResponse:
|
||||||
|
try:
|
||||||
|
return await _upsert_and_audit_metadata_user(
|
||||||
|
payload,
|
||||||
|
current_user=current_user,
|
||||||
|
metadata_repo=metadata_repo,
|
||||||
|
response_status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
except IntegrityError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="User keycloak_id, username, or email conflicts with an existing user",
|
||||||
|
) from exc
|
||||||
|
except SQLAlchemyError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail=f"Metadata database error: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/user-syncs/batches", response_model=List[MetadataUserSyncResult])
|
||||||
|
async def sync_metadata_users_batch(
|
||||||
|
payload: MetadataUsersBatchSyncRequest,
|
||||||
|
current_user=Depends(get_current_metadata_admin),
|
||||||
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||||
|
) -> List[MetadataUserSyncResult]:
|
||||||
|
results: list[MetadataUserSyncResult] = []
|
||||||
|
for item in payload.users:
|
||||||
|
try:
|
||||||
|
user = await _upsert_and_audit_metadata_user(
|
||||||
|
item,
|
||||||
|
current_user=current_user,
|
||||||
|
metadata_repo=metadata_repo,
|
||||||
|
response_status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
except IntegrityError as exc:
|
||||||
|
results.append(
|
||||||
|
MetadataUserSyncResult(
|
||||||
|
keycloak_id=item.keycloak_id,
|
||||||
|
success=False,
|
||||||
|
error="User keycloak_id, username, or email conflicts with an existing user",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await metadata_repo.session.rollback()
|
||||||
|
except SQLAlchemyError as exc:
|
||||||
|
results.append(
|
||||||
|
MetadataUserSyncResult(
|
||||||
|
keycloak_id=item.keycloak_id,
|
||||||
|
success=False,
|
||||||
|
error=f"Metadata database error: {exc}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await metadata_repo.session.rollback()
|
||||||
|
else:
|
||||||
|
results.append(
|
||||||
|
MetadataUserSyncResult(
|
||||||
|
keycloak_id=item.keycloak_id,
|
||||||
|
success=True,
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/users", response_model=List[MetadataUserResponse])
|
||||||
|
async def list_metadata_users(
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(100, ge=1, le=1000),
|
||||||
|
current_user=Depends(get_current_metadata_admin),
|
||||||
|
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]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/projects", response_model=List[AdminProjectResponse])
|
||||||
|
async def list_admin_projects(
|
||||||
|
current_user=Depends(get_current_metadata_admin),
|
||||||
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||||
|
) -> List[AdminProjectResponse]:
|
||||||
|
projects = await metadata_repo.list_project_records()
|
||||||
|
return [_project_response(project) for project in projects]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/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,
|
||||||
|
current_user=Depends(get_current_metadata_admin),
|
||||||
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||||
|
) -> AdminProjectResponse:
|
||||||
|
try:
|
||||||
|
project = await metadata_repo.create_project(
|
||||||
|
name=payload.name,
|
||||||
|
code=payload.code,
|
||||||
|
description=payload.description,
|
||||||
|
gs_workspace=payload.gs_workspace,
|
||||||
|
map_extent=payload.map_extent,
|
||||||
|
status=payload.status,
|
||||||
|
creator_user_id=current_user.id,
|
||||||
|
)
|
||||||
|
except IntegrityError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Project code or workspace conflicts with an existing project",
|
||||||
|
) from exc
|
||||||
|
except SQLAlchemyError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail=f"Metadata database error: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
await log_audit_event(
|
||||||
|
action=AuditAction.CREATE,
|
||||||
|
user_id=current_user.id,
|
||||||
|
project_id=project.id,
|
||||||
|
resource_type="project",
|
||||||
|
resource_id=str(project.id),
|
||||||
|
request_data=payload.model_dump(mode="json"),
|
||||||
|
response_status=status.HTTP_201_CREATED,
|
||||||
|
session=metadata_repo.session,
|
||||||
|
)
|
||||||
|
return _project_response(project)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/admin/projects/{project_id}",
|
||||||
|
response_model=AdminProjectResponse,
|
||||||
|
)
|
||||||
|
async def update_admin_project(
|
||||||
|
payload: AdminProjectUpdateRequest,
|
||||||
|
project_id: UUID = Path(...),
|
||||||
|
current_user=Depends(get_current_metadata_admin),
|
||||||
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||||
|
) -> AdminProjectResponse:
|
||||||
|
updates = payload.model_dump(mode="json", exclude_unset=True)
|
||||||
|
try:
|
||||||
|
project = await metadata_repo.update_project(project_id, updates=updates)
|
||||||
|
except IntegrityError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Project code or workspace conflicts with an existing project",
|
||||||
|
) from exc
|
||||||
|
except SQLAlchemyError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail=f"Metadata database error: {exc}",
|
||||||
|
) from exc
|
||||||
|
if project is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||||
|
|
||||||
|
await log_audit_event(
|
||||||
|
action=AuditAction.UPDATE,
|
||||||
|
user_id=current_user.id,
|
||||||
|
project_id=project.id,
|
||||||
|
resource_type="project",
|
||||||
|
resource_id=str(project.id),
|
||||||
|
request_data=updates,
|
||||||
|
response_status=status.HTTP_200_OK,
|
||||||
|
session=metadata_repo.session,
|
||||||
|
)
|
||||||
|
return _project_response(project)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/admin/projects/{project_id}/databases",
|
||||||
|
response_model=List[ProjectDatabaseResponse],
|
||||||
|
)
|
||||||
|
async def list_project_databases(
|
||||||
|
project_id: UUID = Path(...),
|
||||||
|
current_user=Depends(get_current_metadata_admin),
|
||||||
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||||
|
) -> List[ProjectDatabaseResponse]:
|
||||||
|
project = await metadata_repo.get_project_by_id(project_id)
|
||||||
|
if project is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||||
|
records = await metadata_repo.list_project_databases(project_id)
|
||||||
|
return [_project_database_response(record) for record in records]
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/admin/projects/{project_id}/databases",
|
||||||
|
response_model=ProjectDatabaseResponse,
|
||||||
|
)
|
||||||
|
async def upsert_project_database(
|
||||||
|
payload: ProjectDatabaseUpsertRequest,
|
||||||
|
project_id: UUID = Path(...),
|
||||||
|
current_user=Depends(get_current_metadata_admin),
|
||||||
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||||
|
) -> ProjectDatabaseResponse:
|
||||||
|
project = await metadata_repo.get_project_by_id(project_id)
|
||||||
|
if project is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||||
|
try:
|
||||||
|
routing = (
|
||||||
|
ProjectDbRouting(
|
||||||
|
project_id=project_id,
|
||||||
|
db_role=payload.db_role,
|
||||||
|
db_type=_db_type_for_role(payload.db_role),
|
||||||
|
dsn=payload.dsn,
|
||||||
|
pool_min_size=payload.pool_min_size,
|
||||||
|
pool_max_size=payload.pool_max_size,
|
||||||
|
)
|
||||||
|
if payload.dsn
|
||||||
|
else await metadata_repo.get_project_db_routing(project_id, payload.db_role)
|
||||||
|
)
|
||||||
|
if routing is None:
|
||||||
|
raise ValueError("dsn is required when creating project database config")
|
||||||
|
await _check_database_connection(routing)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=_status_for_config_value_error(exc),
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=_database_health_error_detail(exc),
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
record = await metadata_repo.upsert_project_database_config(
|
||||||
|
project_id,
|
||||||
|
db_role=payload.db_role,
|
||||||
|
db_type=_db_type_for_role(payload.db_role),
|
||||||
|
dsn=payload.dsn,
|
||||||
|
pool_min_size=payload.pool_min_size,
|
||||||
|
pool_max_size=payload.pool_max_size,
|
||||||
|
)
|
||||||
|
except IntegrityError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Project database role conflicts with an existing config",
|
||||||
|
) from exc
|
||||||
|
except SQLAlchemyError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail=f"Metadata database error: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
await log_audit_event(
|
||||||
|
action=AuditAction.CONFIG_CHANGE,
|
||||||
|
user_id=current_user.id,
|
||||||
|
project_id=project_id,
|
||||||
|
resource_type="project_database",
|
||||||
|
resource_id=payload.db_role,
|
||||||
|
request_data=_database_audit_payload(payload),
|
||||||
|
response_status=status.HTTP_200_OK,
|
||||||
|
session=metadata_repo.session,
|
||||||
|
)
|
||||||
|
return _project_database_response(record)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/admin/projects/{project_id}/databases/{db_role}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
)
|
||||||
|
async def delete_project_database(
|
||||||
|
project_id: UUID = Path(...),
|
||||||
|
db_role: ProjectDbRole = Path(...),
|
||||||
|
current_user=Depends(get_current_metadata_admin),
|
||||||
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||||
|
) -> None:
|
||||||
|
removed = await metadata_repo.delete_project_database_config(project_id, db_role)
|
||||||
|
if not removed:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Project database config not found",
|
||||||
|
)
|
||||||
|
await log_audit_event(
|
||||||
|
action=AuditAction.CONFIG_CHANGE,
|
||||||
|
user_id=current_user.id,
|
||||||
|
project_id=project_id,
|
||||||
|
resource_type="project_database",
|
||||||
|
resource_id=db_role,
|
||||||
|
request_data={"deleted": True},
|
||||||
|
response_status=status.HTTP_204_NO_CONTENT,
|
||||||
|
session=metadata_repo.session,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/admin/projects/{project_id}/databases/{db_role}/health-checks",
|
||||||
|
response_model=ProjectDatabaseHealthResponse,
|
||||||
|
)
|
||||||
|
async def check_project_database_health(
|
||||||
|
response: Response,
|
||||||
|
project_id: UUID = Path(...),
|
||||||
|
db_role: ProjectDbRole = Path(...),
|
||||||
|
payload: ProjectDatabaseHealthRequest | None = None,
|
||||||
|
current_user=Depends(get_current_metadata_admin),
|
||||||
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||||
|
) -> ProjectDatabaseHealthResponse:
|
||||||
|
dsn_to_test = payload.dsn if payload and payload.dsn else None
|
||||||
|
if dsn_to_test:
|
||||||
|
routing = ProjectDbRouting(
|
||||||
|
project_id=project_id,
|
||||||
|
db_role=db_role,
|
||||||
|
db_type=_db_type_for_role(db_role),
|
||||||
|
dsn=dsn_to_test,
|
||||||
|
pool_min_size=1,
|
||||||
|
pool_max_size=1,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
routing = await metadata_repo.get_project_db_routing(project_id, db_role)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail=f"Project database routing DSN is invalid: {exc}",
|
||||||
|
) from exc
|
||||||
|
if routing is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Project database config not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await _check_database_connection(routing)
|
||||||
|
except Exception as exc: # health endpoint should return diagnostic status
|
||||||
|
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||||
|
return ProjectDatabaseHealthResponse(
|
||||||
|
project_id=project_id,
|
||||||
|
db_role=db_role,
|
||||||
|
db_type=routing.db_type,
|
||||||
|
ok=False,
|
||||||
|
detail=_database_health_error_detail(exc),
|
||||||
|
)
|
||||||
|
return ProjectDatabaseHealthResponse(
|
||||||
|
project_id=project_id,
|
||||||
|
db_role=db_role,
|
||||||
|
db_type=routing.db_type,
|
||||||
|
ok=True,
|
||||||
|
detail="连通性测试通过",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/users/{user_id}", response_model=MetadataUserResponse)
|
||||||
|
async def get_metadata_user(
|
||||||
|
user_id: UUID = Path(...),
|
||||||
|
current_user=Depends(get_current_metadata_admin),
|
||||||
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||||
|
) -> MetadataUserResponse:
|
||||||
|
user = await metadata_repo.get_user_by_id(user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
|
return MetadataUserResponse.model_validate(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/admin/users/{user_id}", response_model=MetadataUserResponse)
|
||||||
|
async def update_metadata_user(
|
||||||
|
payload: MetadataUserUpdateRequest,
|
||||||
|
user_id: UUID = Path(...),
|
||||||
|
current_user=Depends(get_current_metadata_admin),
|
||||||
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||||
|
) -> MetadataUserResponse:
|
||||||
|
updates = payload.model_dump(mode="json", exclude_unset=True)
|
||||||
|
if user_id == current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Users cannot modify themselves",
|
||||||
|
)
|
||||||
|
user = await metadata_repo.update_user_admin(
|
||||||
|
user_id,
|
||||||
|
updates=updates,
|
||||||
|
)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
|
|
||||||
|
await log_audit_event(
|
||||||
|
action=AuditAction.UPDATE,
|
||||||
|
user_id=current_user.id,
|
||||||
|
resource_type="metadata_user",
|
||||||
|
resource_id=str(user.id),
|
||||||
|
request_data=updates,
|
||||||
|
response_status=status.HTTP_200_OK,
|
||||||
|
session=metadata_repo.session,
|
||||||
|
)
|
||||||
|
return MetadataUserResponse.model_validate(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/admin/projects/{project_id}/members",
|
||||||
|
response_model=List[ProjectMemberResponse],
|
||||||
|
)
|
||||||
|
async def list_project_members(
|
||||||
|
project_id: UUID = Path(...),
|
||||||
|
current_user=Depends(get_current_metadata_admin),
|
||||||
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||||
|
) -> List[ProjectMemberResponse]:
|
||||||
|
project = await metadata_repo.get_project_by_id(project_id)
|
||||||
|
if project is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
|
||||||
|
)
|
||||||
|
members = await metadata_repo.list_project_members(project_id)
|
||||||
|
return [ProjectMemberResponse(**member.__dict__) for member in members]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/admin/projects/{project_id}/members",
|
||||||
|
response_model=ProjectMemberResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def add_project_member(
|
||||||
|
payload: ProjectMemberCreateRequest,
|
||||||
|
project_id: UUID = Path(...),
|
||||||
|
current_user=Depends(get_current_metadata_admin),
|
||||||
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||||
|
) -> ProjectMemberResponse:
|
||||||
|
if payload.user_id == current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Users cannot modify their own project membership",
|
||||||
|
)
|
||||||
|
project = await metadata_repo.get_project_by_id(project_id)
|
||||||
|
if project is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
|
||||||
|
)
|
||||||
|
user = await metadata_repo.get_user_by_id(payload.user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
|
existing = await metadata_repo.get_project_membership(project_id, payload.user_id)
|
||||||
|
if existing is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="User is already a project member",
|
||||||
|
)
|
||||||
|
|
||||||
|
membership = await metadata_repo.add_project_member(
|
||||||
|
project_id, payload.user_id, payload.project_role
|
||||||
|
)
|
||||||
|
await log_audit_event(
|
||||||
|
action=AuditAction.PERMISSION_CHANGE,
|
||||||
|
user_id=current_user.id,
|
||||||
|
project_id=project_id,
|
||||||
|
resource_type="project_member",
|
||||||
|
resource_id=str(payload.user_id),
|
||||||
|
request_data=payload.model_dump(mode="json"),
|
||||||
|
response_status=status.HTTP_201_CREATED,
|
||||||
|
session=metadata_repo.session,
|
||||||
|
)
|
||||||
|
return ProjectMemberResponse(
|
||||||
|
id=membership.id,
|
||||||
|
user_id=membership.user_id,
|
||||||
|
project_id=membership.project_id,
|
||||||
|
project_role=membership.project_role,
|
||||||
|
username=user.username,
|
||||||
|
email=user.email,
|
||||||
|
is_active=user.is_active,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/admin/projects/{project_id}/members/{user_id}",
|
||||||
|
response_model=ProjectMemberResponse,
|
||||||
|
)
|
||||||
|
async def update_project_member(
|
||||||
|
payload: ProjectMemberUpdateRequest,
|
||||||
|
project_id: UUID = Path(...),
|
||||||
|
user_id: UUID = Path(...),
|
||||||
|
current_user=Depends(get_current_metadata_admin),
|
||||||
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||||
|
) -> ProjectMemberResponse:
|
||||||
|
if user_id == current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Users cannot modify their own project membership",
|
||||||
|
)
|
||||||
|
user = await metadata_repo.get_user_by_id(user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
|
membership = await metadata_repo.update_project_member_role(
|
||||||
|
project_id, user_id, payload.project_role
|
||||||
|
)
|
||||||
|
if membership is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Project member not found"
|
||||||
|
)
|
||||||
|
await log_audit_event(
|
||||||
|
action=AuditAction.PERMISSION_CHANGE,
|
||||||
|
user_id=current_user.id,
|
||||||
|
project_id=project_id,
|
||||||
|
resource_type="project_member",
|
||||||
|
resource_id=str(user_id),
|
||||||
|
request_data=payload.model_dump(mode="json"),
|
||||||
|
response_status=status.HTTP_200_OK,
|
||||||
|
session=metadata_repo.session,
|
||||||
|
)
|
||||||
|
return ProjectMemberResponse(
|
||||||
|
id=membership.id,
|
||||||
|
user_id=membership.user_id,
|
||||||
|
project_id=membership.project_id,
|
||||||
|
project_role=membership.project_role,
|
||||||
|
username=user.username,
|
||||||
|
email=user.email,
|
||||||
|
is_active=user.is_active,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/admin/projects/{project_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def remove_project_member(
|
||||||
|
project_id: UUID = Path(...),
|
||||||
|
user_id: UUID = Path(...),
|
||||||
|
current_user=Depends(get_current_metadata_admin),
|
||||||
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||||
|
) -> None:
|
||||||
|
if user_id == current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Users cannot modify their own project membership",
|
||||||
|
)
|
||||||
|
removed = await metadata_repo.remove_project_member(project_id, user_id)
|
||||||
|
if not removed:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Project member not found"
|
||||||
|
)
|
||||||
|
await log_audit_event(
|
||||||
|
action=AuditAction.PERMISSION_CHANGE,
|
||||||
|
user_id=current_user.id,
|
||||||
|
project_id=project_id,
|
||||||
|
resource_type="project_member",
|
||||||
|
resource_id=str(user_id),
|
||||||
|
response_status=status.HTTP_204_NO_CONTENT,
|
||||||
|
session=metadata_repo.session,
|
||||||
|
)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.auth.keycloak_dependencies import get_current_keycloak_payload
|
||||||
|
from app.auth.metadata_dependencies import get_current_metadata_user
|
||||||
|
from app.auth.project_dependencies import (
|
||||||
|
ProjectContext,
|
||||||
|
get_project_context,
|
||||||
|
)
|
||||||
|
from app.auth.permissions import permissions_for_context
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAuthContextResponse(BaseModel):
|
||||||
|
user_id: str
|
||||||
|
keycloak_sub: str
|
||||||
|
username: str
|
||||||
|
role: str
|
||||||
|
is_superuser: bool
|
||||||
|
project_id: str
|
||||||
|
network: str
|
||||||
|
project_role: str
|
||||||
|
permissions: list[str]
|
||||||
|
token_expires_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/agent-auth-context", response_model=AgentAuthContextResponse)
|
||||||
|
async def get_agent_auth_context(
|
||||||
|
ctx: ProjectContext = Depends(get_project_context),
|
||||||
|
current_user=Depends(get_current_metadata_user),
|
||||||
|
keycloak_payload: dict = Depends(get_current_keycloak_payload),
|
||||||
|
) -> AgentAuthContextResponse:
|
||||||
|
exp = keycloak_payload.get("exp")
|
||||||
|
token_expires_at = (
|
||||||
|
datetime.fromtimestamp(exp, tz=timezone.utc).isoformat()
|
||||||
|
if isinstance(exp, int)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return AgentAuthContextResponse(
|
||||||
|
user_id=str(current_user.id),
|
||||||
|
keycloak_sub=str(current_user.keycloak_id),
|
||||||
|
username=current_user.username,
|
||||||
|
role=current_user.role,
|
||||||
|
is_superuser=current_user.is_superuser,
|
||||||
|
project_id=str(ctx.project_id),
|
||||||
|
network=ctx.project_code,
|
||||||
|
project_role=ctx.project_role,
|
||||||
|
permissions=sorted(permissions_for_context(ctx)),
|
||||||
|
token_expires_at=token_expires_at,
|
||||||
|
)
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, Request, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth.metadata_dependencies import (
|
||||||
|
get_current_metadata_admin,
|
||||||
|
get_current_metadata_user,
|
||||||
|
)
|
||||||
|
from app.api.pagination import PaginatedList
|
||||||
|
from app.core.audit import AuditAction, log_audit_event
|
||||||
|
from app.domain.schemas.audit import AuditLogResponse
|
||||||
|
from app.infra.db.metadb.database import get_metadata_session
|
||||||
|
from app.infra.db.metadb.repositories.audit_repository import AuditRepository
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class SessionAuditEventRequest(BaseModel):
|
||||||
|
event: Literal["login", "logout"]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_audit_repository(
|
||||||
|
session: AsyncSession = Depends(get_metadata_session),
|
||||||
|
) -> AuditRepository:
|
||||||
|
return AuditRepository(session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/audit-logs",
|
||||||
|
summary="查询审计日志",
|
||||||
|
description="查询审计日志(仅管理员)",
|
||||||
|
response_model=list[AuditLogResponse],
|
||||||
|
)
|
||||||
|
async def get_audit_logs(
|
||||||
|
user_id: UUID | None = Query(None, description="按用户ID过滤"),
|
||||||
|
project_id: UUID | None = Query(None, description="按项目ID过滤"),
|
||||||
|
action: str | None = Query(None, description="按操作类型过滤"),
|
||||||
|
resource_type: str | None = Query(None, description="按资源类型过滤"),
|
||||||
|
start_time: datetime | None = Query(None, description="开始时间"),
|
||||||
|
end_time: datetime | None = Query(None, description="结束时间"),
|
||||||
|
skip: int = Query(0, ge=0, description="跳过记录数"),
|
||||||
|
limit: int = Query(100, ge=1, le=1000, description="限制记录数"),
|
||||||
|
_current_user=Depends(get_current_metadata_admin),
|
||||||
|
audit_repo: AuditRepository = Depends(get_audit_repository),
|
||||||
|
) -> list[AuditLogResponse]:
|
||||||
|
items = await audit_repo.get_logs(
|
||||||
|
user_id=user_id,
|
||||||
|
project_id=project_id,
|
||||||
|
action=action,
|
||||||
|
resource_type=resource_type,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
skip=skip,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
total = await audit_repo.get_log_count(
|
||||||
|
user_id=user_id,
|
||||||
|
project_id=project_id,
|
||||||
|
action=action,
|
||||||
|
resource_type=resource_type,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
)
|
||||||
|
return PaginatedList(items, total=total)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/audit-logs/count",
|
||||||
|
summary="获取审计日志总数",
|
||||||
|
description="获取审计日志总数(仅管理员)",
|
||||||
|
)
|
||||||
|
async def get_audit_logs_count(
|
||||||
|
user_id: UUID | None = Query(None, description="按用户ID过滤"),
|
||||||
|
project_id: UUID | None = Query(None, description="按项目ID过滤"),
|
||||||
|
action: str | None = Query(None, description="按操作类型过滤"),
|
||||||
|
resource_type: str | None = Query(None, description="按资源类型过滤"),
|
||||||
|
start_time: datetime | None = Query(None, description="开始时间"),
|
||||||
|
end_time: datetime | None = Query(None, description="结束时间"),
|
||||||
|
_current_user=Depends(get_current_metadata_admin),
|
||||||
|
audit_repo: AuditRepository = Depends(get_audit_repository),
|
||||||
|
) -> dict:
|
||||||
|
count = await audit_repo.get_log_count(
|
||||||
|
user_id=user_id,
|
||||||
|
project_id=project_id,
|
||||||
|
action=action,
|
||||||
|
resource_type=resource_type,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
)
|
||||||
|
return {"count": count}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/audit-events", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def record_session_event(
|
||||||
|
payload: SessionAuditEventRequest,
|
||||||
|
request: Request,
|
||||||
|
current_user=Depends(get_current_metadata_user),
|
||||||
|
session: AsyncSession = Depends(get_metadata_session),
|
||||||
|
) -> None:
|
||||||
|
await log_audit_event(
|
||||||
|
action=AuditAction.LOGIN if payload.event == "login" else AuditAction.LOGOUT,
|
||||||
|
user_id=current_user.id,
|
||||||
|
resource_type="session",
|
||||||
|
resource_id=str(current_user.keycloak_id),
|
||||||
|
ip_address=request.client.host if request.client else None,
|
||||||
|
request_method=request.method,
|
||||||
|
request_path=request.url.path,
|
||||||
|
response_status=status.HTTP_204_NO_CONTENT,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/audit-logs/mine",
|
||||||
|
summary="查询我的审计日志",
|
||||||
|
description="查询当前用户的审计日志",
|
||||||
|
response_model=list[AuditLogResponse],
|
||||||
|
)
|
||||||
|
async def get_my_audit_logs(
|
||||||
|
action: str | None = Query(None, description="按操作类型过滤"),
|
||||||
|
start_time: datetime | None = Query(None, description="开始时间"),
|
||||||
|
end_time: datetime | None = Query(None, description="结束时间"),
|
||||||
|
skip: int = Query(0, ge=0, description="跳过记录数"),
|
||||||
|
limit: int = Query(100, ge=1, le=1000, description="限制记录数"),
|
||||||
|
current_user=Depends(get_current_metadata_user),
|
||||||
|
audit_repo: AuditRepository = Depends(get_audit_repository),
|
||||||
|
) -> list[AuditLogResponse]:
|
||||||
|
items = await audit_repo.get_logs(
|
||||||
|
user_id=current_user.id,
|
||||||
|
action=action,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
skip=skip,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
total = await audit_repo.get_log_count(
|
||||||
|
user_id=current_user.id,
|
||||||
|
action=action,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
)
|
||||||
|
return PaginatedList(items, total=total)
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
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 (
|
||||||
|
run_burst_detection,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class BurstDetectionRequest(BaseModel):
|
||||||
|
"""爆管检测请求模型"""
|
||||||
|
network: str = Field(..., description="管网名称(或数据库名称)")
|
||||||
|
observed_pressure_data: (
|
||||||
|
dict[str, list[Any]] | list[dict[str, Any]] | list[list[Any]] | None
|
||||||
|
) = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"压力观测数据。支持列式字典 {sensor_id: [values,...]}、"
|
||||||
|
"逐时刻对象数组 [{sensor_id: value,...}, ...]、"
|
||||||
|
"或二维数组 [[t1_s1, t1_s2], [t2_s1, t2_s2], ...]。"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
points_per_day: int = Field(1440, description="每天的数据点数")
|
||||||
|
mu: int = Field(100, description="异常值检测的参数")
|
||||||
|
iforest_params: dict[str, Any] | None = Field(None, description="隔离森林算法参数")
|
||||||
|
target_time: datetime | None = Field(
|
||||||
|
None,
|
||||||
|
description="目标侦测时刻;为空时自动使用最近一个完整的监测时刻",
|
||||||
|
)
|
||||||
|
sampling_interval_minutes: int | None = Field(
|
||||||
|
None,
|
||||||
|
ge=1,
|
||||||
|
le=1440,
|
||||||
|
description="采样间隔(分钟);为空时根据压力 SCADA 传输频率自动推断",
|
||||||
|
)
|
||||||
|
scada_start: datetime | None = Field(None, description="SCADA数据起始时间")
|
||||||
|
scada_end: datetime | None = Field(None, description="SCADA数据结束时间")
|
||||||
|
sensor_nodes: list[str] | None = Field(None, description="传感器节点列表")
|
||||||
|
scheme_name: str | None = Field(None, description="方案名称")
|
||||||
|
data_source: str = Field("monitoring", description="数据来源:monitoring(监测)或simulation(模拟)")
|
||||||
|
simulation_run_id: UUID | None = Field(None, description="分析模拟运行 ID")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/burst-detections",
|
||||||
|
summary="执行爆管检测",
|
||||||
|
description="基于压力观测数据和其他参数执行爆管检测分析"
|
||||||
|
)
|
||||||
|
async def detect_burst(
|
||||||
|
data: BurstDetectionRequest = Body(..., description="爆管检测请求数据"),
|
||||||
|
username: str = Depends(get_current_keycloak_username),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
执行爆管检测分析。
|
||||||
|
|
||||||
|
使用异常检测算法(隔离森林)识别压力时间序列中的异常,
|
||||||
|
将其作为潜在的爆管事件。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: 包含管网名称(或数据库名称)、压力数据及相关参数的请求体
|
||||||
|
username: 当前认证用户名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含检测结果的字典
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 当处理过程中发生错误时
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
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))
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
from typing import Any
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
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 (
|
||||||
|
run_burst_location_by_network,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class BurstLocationRequest(BaseModel):
|
||||||
|
"""爆管定位请求模型"""
|
||||||
|
network: str = Field(..., description="管网名称(或数据库名称)")
|
||||||
|
data_source: Literal["monitoring", "simulation"] = Field("monitoring", description="数据来源:monitoring(监测)或simulation(模拟)")
|
||||||
|
pressure_scada_ids: list[str] | None = Field(None, description="压力SCADA传感器ID列表")
|
||||||
|
burst_pressure: dict[str, float] | list[dict[str, Any]] | None = Field(None, description="爆管时的压力数据")
|
||||||
|
normal_pressure: dict[str, float] | list[dict[str, Any]] | None = Field(None, description="正常时的压力数据")
|
||||||
|
burst_leakage: float = Field(..., description="爆管时的漏水量")
|
||||||
|
flow_scada_ids: list[str] | None = Field(None, description="流量SCADA传感器ID列表")
|
||||||
|
burst_flow: dict[str, float] | list[dict[str, Any]] | None = Field(None, description="爆管时的流量数据")
|
||||||
|
normal_flow: dict[str, float] | list[dict[str, Any]] | None = Field(None, description="正常时的流量数据")
|
||||||
|
min_dpressure: float = Field(2.0, description="最小压力差(bar)")
|
||||||
|
basic_pressure: float = Field(10.0, description="基准压力(bar)")
|
||||||
|
scada_burst_start: datetime | None = Field(None, description="爆管/模拟方案开始时间")
|
||||||
|
scada_burst_end: datetime | None = Field(None, description="爆管/模拟方案结束时间")
|
||||||
|
scada_normal_start: datetime | None = Field(None, description="监测数据正常工况开始时间")
|
||||||
|
scada_normal_end: datetime | None = Field(None, description="监测数据正常工况结束时间")
|
||||||
|
use_scada_flow: bool = Field(False, description="是否使用SCADA流量数据")
|
||||||
|
scheme_name: str | None = Field(None, description="爆管定位运行名称")
|
||||||
|
simulation_run_id: UUID | None = Field(None, description="分析模拟运行 ID")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/burst-locations",
|
||||||
|
summary="执行爆管定位",
|
||||||
|
description="基于压力和流量数据定位管网中的爆管位置"
|
||||||
|
)
|
||||||
|
async def locate_burst(
|
||||||
|
data: BurstLocationRequest = Body(..., description="爆管定位请求数据"),
|
||||||
|
username: str = Depends(get_current_keycloak_username),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
执行爆管定位分析。
|
||||||
|
|
||||||
|
使用压力和流量SCADA数据,通过对比爆管和正常状态下的数据差异,
|
||||||
|
定位管网中的爆管位置。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: 包含管网名称(或数据库名称)、压力、流量数据及相关参数的请求体
|
||||||
|
username: 当前认证用户名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含定位结果的字典
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 当数据类型或值不正确时
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
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))
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Query
|
||||||
|
from app.services.tjnetwork import (
|
||||||
|
ChangeSet,
|
||||||
|
get_control,
|
||||||
|
get_control_schema,
|
||||||
|
get_rule,
|
||||||
|
get_rule_schema,
|
||||||
|
set_control,
|
||||||
|
set_rule,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/network-schemas/control", summary="获取控制架构", description="获取网络中控制对象的架构定义")
|
||||||
|
def fastapi_get_control_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||||
|
"""获取控制架构。
|
||||||
|
|
||||||
|
返回指定网络中控制对象的属性架构定义。
|
||||||
|
"""
|
||||||
|
return get_control_schema(network)
|
||||||
|
|
||||||
|
@router.get("/controls/properties", summary="获取控制属性", description="获取指定网络中的控制属性信息")
|
||||||
|
def fastapi_get_control_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
|
||||||
|
"""获取控制属性。
|
||||||
|
|
||||||
|
返回指定网络中的控制对象属性信息。
|
||||||
|
"""
|
||||||
|
return get_control(network)
|
||||||
|
|
||||||
|
@router.patch("/controls/properties", response_model=None, summary="设置控制属性", description="更新指定网络中的控制属性")
|
||||||
|
def fastapi_set_control_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""设置控制属性。
|
||||||
|
|
||||||
|
更新指定网络中的控制属性值。
|
||||||
|
"""
|
||||||
|
props = payload
|
||||||
|
return set_control(network, ChangeSet(props))
|
||||||
|
|
||||||
|
@router.get("/rule-schemas", summary="获取规则架构", description="获取网络中规则对象的架构定义")
|
||||||
|
def fastapi_get_rule_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||||
|
"""获取规则架构。
|
||||||
|
|
||||||
|
返回指定网络中规则对象的属性架构定义。
|
||||||
|
"""
|
||||||
|
return get_rule_schema(network)
|
||||||
|
|
||||||
|
@router.get("/rule-properties", summary="获取规则属性", description="获取指定网络中的规则属性信息")
|
||||||
|
def fastapi_get_rule_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
|
||||||
|
"""获取规则属性。
|
||||||
|
|
||||||
|
返回指定网络中的规则对象属性信息。
|
||||||
|
"""
|
||||||
|
return get_rule(network)
|
||||||
|
|
||||||
|
@router.patch("/rule-properties", response_model=None, summary="设置规则属性", description="更新指定网络中的规则属性")
|
||||||
|
def fastapi_set_rule_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""设置规则属性。
|
||||||
|
|
||||||
|
更新指定网络中的规则属性值。
|
||||||
|
"""
|
||||||
|
props = payload
|
||||||
|
return set_rule(network, ChangeSet(props))
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Query
|
||||||
|
from app.services.tjnetwork import (
|
||||||
|
ChangeSet,
|
||||||
|
add_curve,
|
||||||
|
delete_curve,
|
||||||
|
get_curve,
|
||||||
|
get_curve_schema,
|
||||||
|
get_curves,
|
||||||
|
is_curve,
|
||||||
|
set_curve,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/network-schemas/curve", summary="获取曲线架构", description="获取网络中曲线对象的架构定义")
|
||||||
|
def fastapi_get_curve_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||||
|
"""获取曲线架构。
|
||||||
|
|
||||||
|
返回指定网络中曲线对象的属性架构定义。
|
||||||
|
"""
|
||||||
|
return get_curve_schema(network)
|
||||||
|
|
||||||
|
@router.post("/curves", response_model=None, summary="添加曲线", description="在网络中添加一条新的曲线")
|
||||||
|
def fastapi_add_curve(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
curve: str = Query(..., description="曲线ID"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""添加曲线。
|
||||||
|
|
||||||
|
在指定网络中创建一条新的曲线,并设置其初始属性。
|
||||||
|
"""
|
||||||
|
props = payload
|
||||||
|
ps = {
|
||||||
|
"id": curve,
|
||||||
|
} | props
|
||||||
|
return add_curve(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.delete("/curves", response_model=None, summary="删除曲线", description="从网络中删除指定的曲线")
|
||||||
|
def fastapi_delete_curve(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
curve: str = Query(..., description="曲线ID")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""删除曲线。
|
||||||
|
|
||||||
|
从指定网络中删除指定的曲线及其相关数据。
|
||||||
|
"""
|
||||||
|
ps = {"id": curve}
|
||||||
|
return delete_curve(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.get("/curves/properties", summary="获取曲线属性", description="获取指定曲线的属性信息")
|
||||||
|
def fastapi_get_curve_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
curve: str = Query(..., description="曲线ID")
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""获取曲线属性。
|
||||||
|
|
||||||
|
返回指定曲线的所有属性信息。
|
||||||
|
"""
|
||||||
|
return get_curve(network, curve)
|
||||||
|
|
||||||
|
@router.patch("/curves/properties", response_model=None, summary="设置曲线属性", description="更新指定曲线的属性")
|
||||||
|
def fastapi_set_curve_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
curve: str = Query(..., description="曲线ID"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""设置曲线属性。
|
||||||
|
|
||||||
|
更新指定曲线的属性值。
|
||||||
|
"""
|
||||||
|
props = payload
|
||||||
|
ps = {"id": curve} | props
|
||||||
|
return set_curve(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.get("/curves", summary="获取所有曲线", description="获取网络中的所有曲线列表")
|
||||||
|
def fastapi_get_curves(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[str]:
|
||||||
|
"""获取所有曲线。
|
||||||
|
|
||||||
|
返回指定网络中的所有曲线ID列表。
|
||||||
|
"""
|
||||||
|
return get_curves(network)
|
||||||
|
|
||||||
|
@router.get("/curves/existence", summary="检查曲线存在性", description="检查指定的曲线是否存在")
|
||||||
|
def fastapi_is_curve(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
curve: str = Query(..., description="曲线ID")
|
||||||
|
) -> bool:
|
||||||
|
"""检查曲线是否存在。
|
||||||
|
|
||||||
|
判断指定的曲线是否在网络中存在。
|
||||||
|
"""
|
||||||
|
return is_curve(network, curve)
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Query
|
||||||
|
from app.services.tjnetwork import (
|
||||||
|
ChangeSet,
|
||||||
|
get_energy,
|
||||||
|
get_energy_schema,
|
||||||
|
get_option_v3,
|
||||||
|
get_option_v3_schema,
|
||||||
|
get_pump_energy,
|
||||||
|
get_pump_energy_schema,
|
||||||
|
get_time,
|
||||||
|
get_time_schema,
|
||||||
|
set_energy,
|
||||||
|
set_option_v3,
|
||||||
|
set_pump_energy,
|
||||||
|
set_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/network-schemas/time", summary="获取时间选项架构", description="获取网络中时间选项的架构定义")
|
||||||
|
def fastapi_get_time_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||||
|
"""获取时间选项架构。
|
||||||
|
|
||||||
|
返回指定网络中时间相关选项的属性架构定义。
|
||||||
|
"""
|
||||||
|
return get_time_schema(network)
|
||||||
|
|
||||||
|
@router.get("/network-options/time", summary="获取时间选项属性", description="获取指定网络中的时间选项属性信息")
|
||||||
|
def fastapi_get_time_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
|
||||||
|
"""获取时间选项属性。
|
||||||
|
|
||||||
|
返回指定网络中的时间相关选项属性。
|
||||||
|
"""
|
||||||
|
return get_time(network)
|
||||||
|
|
||||||
|
@router.patch("/time-properties", response_model=None, summary="设置时间选项属性", description="更新指定网络中的时间选项属性")
|
||||||
|
def fastapi_set_time_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""设置时间选项属性。
|
||||||
|
|
||||||
|
更新指定网络中的时间相关选项属性值。
|
||||||
|
"""
|
||||||
|
props = payload
|
||||||
|
return set_time(network, ChangeSet(props))
|
||||||
|
|
||||||
|
@router.get("/network-schemas/energy", summary="获取能耗选项架构", description="获取网络中能耗选项的架构定义")
|
||||||
|
def fastapi_get_energy_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||||
|
"""获取能耗选项架构。
|
||||||
|
|
||||||
|
返回指定网络中能耗相关选项的属性架构定义。
|
||||||
|
"""
|
||||||
|
return get_energy_schema(network)
|
||||||
|
|
||||||
|
@router.get("/network-options/energy", summary="获取能耗选项属性", description="获取指定网络中的能耗选项属性信息")
|
||||||
|
def fastapi_get_energy_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
|
||||||
|
"""获取能耗选项属性。
|
||||||
|
|
||||||
|
返回指定网络中的能耗相关选项属性。
|
||||||
|
"""
|
||||||
|
return get_energy(network)
|
||||||
|
|
||||||
|
@router.patch("/energy-properties", response_model=None, summary="设置能耗选项属性", description="更新指定网络中的能耗选项属性")
|
||||||
|
def fastapi_set_energy_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""设置能耗选项属性。
|
||||||
|
|
||||||
|
更新指定网络中的能耗相关选项属性值。
|
||||||
|
"""
|
||||||
|
props = payload
|
||||||
|
return set_energy(network, ChangeSet(props))
|
||||||
|
|
||||||
|
@router.get("/network-schemas/pump-energy", summary="获取泵能耗选项架构", description="获取网络中泵能耗选项的架构定义")
|
||||||
|
def fastapi_get_pump_energy_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||||
|
"""获取泵能耗选项架构。
|
||||||
|
|
||||||
|
返回指定网络中泵能耗相关选项的属性架构定义。
|
||||||
|
"""
|
||||||
|
return get_pump_energy_schema(network)
|
||||||
|
|
||||||
|
@router.get("/network-options/pump-energy", summary="获取泵能耗属性", description="获取指定泵的能耗属性信息")
|
||||||
|
def fastapi_get_pump_energy_proeprties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
pump: str = Query(..., description="泵ID")
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""获取泵能耗属性。
|
||||||
|
|
||||||
|
返回指定泵的能耗相关属性。
|
||||||
|
"""
|
||||||
|
return get_pump_energy(network, pump)
|
||||||
|
|
||||||
|
@router.patch("/network-options/pump-energy", response_model=None, summary="设置泵能耗属性", description="更新指定泵的能耗属性")
|
||||||
|
def fastapi_set_pump_energy_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
pump: str = Query(..., description="泵ID"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""设置泵能耗属性。
|
||||||
|
|
||||||
|
更新指定泵的能耗相关属性值。
|
||||||
|
"""
|
||||||
|
props = payload
|
||||||
|
ps = {"id": pump} | props
|
||||||
|
return set_pump_energy(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.get("/network-schemas/option", summary="获取选项架构", description="获取网络中选项对象的架构定义")
|
||||||
|
def fastapi_get_option_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||||
|
"""获取选项架构。
|
||||||
|
|
||||||
|
返回指定网络中选项对象的属性架构定义。
|
||||||
|
"""
|
||||||
|
return get_option_v3_schema(network)
|
||||||
|
|
||||||
|
@router.get("/network-options", summary="获取选项属性", description="获取指定网络中的选项属性信息")
|
||||||
|
def fastapi_get_option_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
|
||||||
|
"""获取选项属性。
|
||||||
|
|
||||||
|
返回指定网络中的选项对象属性信息。
|
||||||
|
"""
|
||||||
|
return get_option_v3(network)
|
||||||
|
|
||||||
|
@router.patch("/network-options", response_model=None, summary="设置选项属性", description="更新指定网络中的选项属性")
|
||||||
|
def fastapi_set_option_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""设置选项属性。
|
||||||
|
|
||||||
|
更新指定网络中的选项属性值。
|
||||||
|
"""
|
||||||
|
props = payload
|
||||||
|
return set_option_v3(network, ChangeSet(props))
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Query
|
||||||
|
from app.services.tjnetwork import (
|
||||||
|
ChangeSet,
|
||||||
|
add_pattern,
|
||||||
|
delete_pattern,
|
||||||
|
get_pattern,
|
||||||
|
get_pattern_schema,
|
||||||
|
get_patterns,
|
||||||
|
is_pattern,
|
||||||
|
set_pattern,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/network-schemas/pattern", summary="获取模式架构", description="获取网络中模式对象的架构定义")
|
||||||
|
def fastapi_get_pattern_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||||
|
"""获取模式架构。
|
||||||
|
|
||||||
|
返回指定网络中模式对象的属性架构定义。
|
||||||
|
"""
|
||||||
|
return get_pattern_schema(network)
|
||||||
|
|
||||||
|
@router.post("/patterns", response_model=None, summary="添加模式", description="在网络中添加一个新的模式")
|
||||||
|
def fastapi_add_pattern(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
pattern: str = Query(..., description="模式ID"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""添加模式。
|
||||||
|
|
||||||
|
在指定网络中创建一个新的模式,并设置其初始属性。
|
||||||
|
"""
|
||||||
|
props = payload
|
||||||
|
ps = {
|
||||||
|
"id": pattern,
|
||||||
|
} | props
|
||||||
|
return add_pattern(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.delete("/patterns", response_model=None, summary="删除模式", description="从网络中删除指定的模式")
|
||||||
|
def fastapi_delete_pattern(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
pattern: str = Query(..., description="模式ID")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""删除模式。
|
||||||
|
|
||||||
|
从指定网络中删除指定的模式及其相关数据。
|
||||||
|
"""
|
||||||
|
ps = {"id": pattern}
|
||||||
|
return delete_pattern(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.get("/patterns/properties", summary="获取模式属性", description="获取指定模式的属性信息")
|
||||||
|
def fastapi_get_pattern_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
pattern: str = Query(..., description="模式ID")
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""获取模式属性。
|
||||||
|
|
||||||
|
返回指定模式的所有属性信息。
|
||||||
|
"""
|
||||||
|
return get_pattern(network, pattern)
|
||||||
|
|
||||||
|
@router.patch("/patterns/properties", response_model=None, summary="设置模式属性", description="更新指定模式的属性")
|
||||||
|
def fastapi_set_pattern_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
pattern: str = Query(..., description="模式ID"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""设置模式属性。
|
||||||
|
|
||||||
|
更新指定模式的属性值。
|
||||||
|
"""
|
||||||
|
props = payload
|
||||||
|
ps = {"id": pattern} | props
|
||||||
|
return set_pattern(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.get("/patterns/existence", summary="检查模式存在性", description="检查指定的模式是否存在")
|
||||||
|
def fastapi_is_pattern(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
pattern: str = Query(..., description="模式ID")
|
||||||
|
) -> bool:
|
||||||
|
"""检查模式是否存在。
|
||||||
|
|
||||||
|
判断指定的模式是否在网络中存在。
|
||||||
|
"""
|
||||||
|
return is_pattern(network, pattern)
|
||||||
|
|
||||||
|
@router.get("/patterns", summary="获取所有模式", description="获取网络中的所有模式列表")
|
||||||
|
def fastapi_get_patterns(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[str]:
|
||||||
|
"""获取所有模式。
|
||||||
|
|
||||||
|
返回指定网络中的所有模式ID列表。
|
||||||
|
"""
|
||||||
|
return get_patterns(network)
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Query
|
||||||
|
from app.services.tjnetwork import (
|
||||||
|
ChangeSet,
|
||||||
|
add_mixing,
|
||||||
|
add_source,
|
||||||
|
delete_mixing,
|
||||||
|
delete_source,
|
||||||
|
get_emitter,
|
||||||
|
get_emitter_schema,
|
||||||
|
get_mixing,
|
||||||
|
get_mixing_schema,
|
||||||
|
get_pipe_reaction,
|
||||||
|
get_pipe_reaction_schema,
|
||||||
|
get_quality,
|
||||||
|
get_quality_schema,
|
||||||
|
get_reaction,
|
||||||
|
get_reaction_schema,
|
||||||
|
get_source,
|
||||||
|
get_source_schema,
|
||||||
|
get_tank_reaction,
|
||||||
|
get_tank_reaction_schema,
|
||||||
|
set_emitter,
|
||||||
|
set_mixing,
|
||||||
|
set_pipe_reaction,
|
||||||
|
set_quality,
|
||||||
|
set_reaction,
|
||||||
|
set_source,
|
||||||
|
set_tank_reaction,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/network-schemas/quality", summary="获取水质架构", description="获取网络中水质对象的架构定义")
|
||||||
|
def fastapi_get_quality_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||||
|
"""获取水质架构。
|
||||||
|
|
||||||
|
返回指定网络中水质对象的属性架构定义。
|
||||||
|
"""
|
||||||
|
return get_quality_schema(network)
|
||||||
|
|
||||||
|
@router.get("/quality-configurations/properties", summary="获取水质属性", description="获取指定节点的水质属性信息")
|
||||||
|
def fastapi_get_quality_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
node: str = Query(..., description="节点ID")
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""获取水质属性。
|
||||||
|
|
||||||
|
返回指定节点的水质属性信息。
|
||||||
|
"""
|
||||||
|
return get_quality(network, node)
|
||||||
|
|
||||||
|
@router.patch("/quality-configurations/properties", response_model=None, summary="设置水质属性", description="更新指定节点的水质属性")
|
||||||
|
def fastapi_set_quality_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""设置水质属性。
|
||||||
|
|
||||||
|
更新指定节点的水质属性值。
|
||||||
|
"""
|
||||||
|
props = payload
|
||||||
|
return set_quality(network, ChangeSet(props))
|
||||||
|
|
||||||
|
@router.get("/network-schemas/emitter", summary="获取发射器架构", description="获取网络中发射器对象的架构定义")
|
||||||
|
def fastapi_get_emitter_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||||
|
"""获取发射器架构。
|
||||||
|
|
||||||
|
返回指定网络中发射器对象的属性架构定义。
|
||||||
|
"""
|
||||||
|
return get_emitter_schema(network)
|
||||||
|
|
||||||
|
@router.get("/emitters/properties", summary="获取发射器属性", description="获取指定连接点的发射器属性信息")
|
||||||
|
def fastapi_get_emitter_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
junction: str = Query(..., description="连接点ID")
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""获取发射器属性。
|
||||||
|
|
||||||
|
返回指定连接点的发射器属性信息。
|
||||||
|
"""
|
||||||
|
return get_emitter(network, junction)
|
||||||
|
|
||||||
|
@router.patch("/emitters/properties", response_model=None, summary="设置发射器属性", description="更新指定连接点的发射器属性")
|
||||||
|
def fastapi_set_emitter_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
junction: str = Query(..., description="连接点ID"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""设置发射器属性。
|
||||||
|
|
||||||
|
更新指定连接点的发射器属性值。
|
||||||
|
"""
|
||||||
|
props = payload
|
||||||
|
ps = {"junction": junction} | props
|
||||||
|
return set_emitter(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.get("/network-schemas/source", summary="获取水源架构", description="获取网络中水源对象的架构定义")
|
||||||
|
def fastapi_get_source_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||||
|
"""获取水源架构。
|
||||||
|
|
||||||
|
返回指定网络中水源对象的属性架构定义。
|
||||||
|
"""
|
||||||
|
return get_source_schema(network)
|
||||||
|
|
||||||
|
@router.get("/sources/detail", summary="获取水源属性", description="获取指定节点的水源属性信息")
|
||||||
|
def fastapi_get_source(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
node: str = Query(..., description="节点ID")
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""获取水源属性。
|
||||||
|
|
||||||
|
返回指定节点的水源属性信息。
|
||||||
|
"""
|
||||||
|
return get_source(network, node)
|
||||||
|
|
||||||
|
@router.patch("/sources", response_model=None, summary="设置水源属性", description="更新指定节点的水源属性")
|
||||||
|
def fastapi_set_source(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""设置水源属性。
|
||||||
|
|
||||||
|
更新指定节点的水源属性值。
|
||||||
|
"""
|
||||||
|
props = payload
|
||||||
|
return set_source(network, ChangeSet(props))
|
||||||
|
|
||||||
|
@router.post("/sources", response_model=None, summary="添加水源", description="在网络中添加一个新的水源")
|
||||||
|
def fastapi_add_source(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""添加水源。
|
||||||
|
|
||||||
|
在指定网络中创建一个新的水源,并设置其初始属性。
|
||||||
|
"""
|
||||||
|
props = payload
|
||||||
|
return add_source(network, ChangeSet(props))
|
||||||
|
|
||||||
|
@router.delete("/sources", response_model=None, summary="删除水源", description="从网络中删除指定节点的水源")
|
||||||
|
def fastapi_delete_source(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
node: str = Query(..., description="节点ID")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""删除水源。
|
||||||
|
|
||||||
|
从指定网络中删除指定节点的水源。
|
||||||
|
"""
|
||||||
|
props = {"node": node}
|
||||||
|
return delete_source(network, ChangeSet(props))
|
||||||
|
|
||||||
|
@router.get("/network-schemas/reaction", summary="获取反应架构", description="获取网络中反应对象的架构定义")
|
||||||
|
def fastapi_get_reaction_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||||
|
"""获取反应架构。
|
||||||
|
|
||||||
|
返回指定网络中反应对象的属性架构定义。
|
||||||
|
"""
|
||||||
|
return get_reaction_schema(network)
|
||||||
|
|
||||||
|
@router.get("/reactions/detail", summary="获取反应属性", description="获取指定网络中的反应属性信息")
|
||||||
|
def fastapi_get_reaction(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
|
||||||
|
"""获取反应属性。
|
||||||
|
|
||||||
|
返回指定网络中的反应属性信息。
|
||||||
|
"""
|
||||||
|
return get_reaction(network)
|
||||||
|
|
||||||
|
@router.patch("/reactions", response_model=None, summary="设置反应属性", description="更新指定网络中的反应属性")
|
||||||
|
def fastapi_set_reaction(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""设置反应属性。
|
||||||
|
|
||||||
|
更新指定网络中的反应属性值。
|
||||||
|
"""
|
||||||
|
props = payload
|
||||||
|
return set_reaction(network, ChangeSet(props))
|
||||||
|
|
||||||
|
@router.get("/network-schemas/pipe-reaction", summary="获取管道反应架构", description="获取网络中管道反应对象的架构定义")
|
||||||
|
def fastapi_get_pipe_reaction_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||||
|
"""获取管道反应架构。
|
||||||
|
|
||||||
|
返回指定网络中管道反应对象的属性架构定义。
|
||||||
|
"""
|
||||||
|
return get_pipe_reaction_schema(network)
|
||||||
|
|
||||||
|
@router.get("/pipe-reactions/detail", summary="获取管道反应属性", description="获取指定管道的反应属性信息")
|
||||||
|
def fastapi_get_pipe_reaction(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
pipe: str = Query(..., description="管道ID")
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""获取管道反应属性。
|
||||||
|
|
||||||
|
返回指定管道的反应属性信息。
|
||||||
|
"""
|
||||||
|
return get_pipe_reaction(network, pipe)
|
||||||
|
|
||||||
|
@router.patch("/pipe-reactions", response_model=None, summary="设置管道反应属性", description="更新指定管道的反应属性")
|
||||||
|
def fastapi_set_pipe_reaction(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""设置管道反应属性。
|
||||||
|
|
||||||
|
更新指定管道的反应属性值。
|
||||||
|
"""
|
||||||
|
props = payload
|
||||||
|
return set_pipe_reaction(network, ChangeSet(props))
|
||||||
|
|
||||||
|
@router.get("/network-schemas/tank-reaction", summary="获取水池反应架构", description="获取网络中水池反应对象的架构定义")
|
||||||
|
def fastapi_get_tank_reaction_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||||
|
"""获取水池反应架构。
|
||||||
|
|
||||||
|
返回指定网络中水池反应对象的属性架构定义。
|
||||||
|
"""
|
||||||
|
return get_tank_reaction_schema(network)
|
||||||
|
|
||||||
|
@router.get("/tank-reactions/detail", summary="获取水池反应属性", description="获取指定水池的反应属性信息")
|
||||||
|
def fastapi_get_tank_reaction(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
tank: str = Query(..., description="水池ID")
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""获取水池反应属性。
|
||||||
|
|
||||||
|
返回指定水池的反应属性信息。
|
||||||
|
"""
|
||||||
|
return get_tank_reaction(network, tank)
|
||||||
|
|
||||||
|
@router.patch("/tank-reactions", response_model=None, summary="设置水池反应属性", description="更新指定水池的反应属性")
|
||||||
|
def fastapi_set_tank_reaction(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""设置水池反应属性。
|
||||||
|
|
||||||
|
更新指定水池的反应属性值。
|
||||||
|
"""
|
||||||
|
props = payload
|
||||||
|
return set_tank_reaction(network, ChangeSet(props))
|
||||||
|
|
||||||
|
@router.get("/network-schemas/mixing", summary="获取混合架构", description="获取网络中混合对象的架构定义")
|
||||||
|
def fastapi_get_mixing_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||||
|
"""获取混合架构。
|
||||||
|
|
||||||
|
返回指定网络中混合对象的属性架构定义。
|
||||||
|
"""
|
||||||
|
return get_mixing_schema(network)
|
||||||
|
|
||||||
|
@router.get("/mixing-configurations/detail", summary="获取混合属性", description="获取指定水池的混合属性信息")
|
||||||
|
def fastapi_get_mixing(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
tank: str = Query(..., description="水池ID")
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""获取混合属性。
|
||||||
|
|
||||||
|
返回指定水池的混合属性信息。
|
||||||
|
"""
|
||||||
|
return get_mixing(network, tank)
|
||||||
|
|
||||||
|
@router.patch("/mixing-configurations", response_model=None, summary="设置混合属性", description="更新指定水池的混合属性")
|
||||||
|
def fastapi_set_mixing(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""设置混合属性。
|
||||||
|
|
||||||
|
更新指定水池的混合属性值。
|
||||||
|
"""
|
||||||
|
props = payload
|
||||||
|
return set_mixing(network, ChangeSet(props))
|
||||||
|
|
||||||
|
@router.post("/mixing-configurations", response_model=None, summary="添加混合", description="在网络中添加一个新的混合")
|
||||||
|
def fastapi_add_mixing(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""添加混合。
|
||||||
|
|
||||||
|
在指定网络中创建一个新的混合,并设置其初始属性。
|
||||||
|
"""
|
||||||
|
props = payload
|
||||||
|
return add_mixing(network, ChangeSet(props))
|
||||||
|
|
||||||
|
@router.delete("/mixing-configurations", response_model=None, summary="删除混合", description="从网络中删除指定的混合")
|
||||||
|
def fastapi_delete_mixing(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""删除混合。
|
||||||
|
|
||||||
|
从指定网络中删除指定的混合及其相关数据。
|
||||||
|
"""
|
||||||
|
props = payload
|
||||||
|
return delete_mixing(network, ChangeSet(props))
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Query
|
||||||
|
from app.services.tjnetwork import (
|
||||||
|
ChangeSet,
|
||||||
|
add_label,
|
||||||
|
add_vertex,
|
||||||
|
delete_label,
|
||||||
|
delete_vertex,
|
||||||
|
get_all_vertex_links,
|
||||||
|
get_all_vertices,
|
||||||
|
get_backdrop,
|
||||||
|
get_backdrop_schema,
|
||||||
|
get_label,
|
||||||
|
get_label_schema,
|
||||||
|
get_vertex,
|
||||||
|
get_vertex_schema,
|
||||||
|
set_backdrop,
|
||||||
|
set_label,
|
||||||
|
set_vertex,
|
||||||
|
)
|
||||||
|
from fastapi.responses import PlainTextResponse
|
||||||
|
import json
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/network-schemas/vertex", summary="获取图形元素架构", description="获取网络中图形元素对象的架构定义")
|
||||||
|
def fastapi_get_vertex_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||||
|
"""获取图形元素架构。
|
||||||
|
|
||||||
|
返回指定网络中图形元素对象的属性架构定义。
|
||||||
|
"""
|
||||||
|
return get_vertex_schema(network)
|
||||||
|
|
||||||
|
@router.get("/visual-elements/properties", summary="获取图形元素属性", description="获取指定图形元素的属性信息")
|
||||||
|
def fastapi_get_vertex_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
link: str = Query(..., description="图形元素链接")
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""获取图形元素属性。
|
||||||
|
|
||||||
|
返回指定图形元素的所有属性信息。
|
||||||
|
"""
|
||||||
|
return get_vertex(network, link)
|
||||||
|
|
||||||
|
@router.patch("/visual-elements/properties", response_model=None, summary="设置图形元素属性", description="更新指定图形元素的属性")
|
||||||
|
def fastapi_set_vertex_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""设置图形元素属性。
|
||||||
|
|
||||||
|
更新指定图形元素的属性值。
|
||||||
|
"""
|
||||||
|
props = payload
|
||||||
|
return set_vertex(network, ChangeSet(props))
|
||||||
|
|
||||||
|
@router.post("/visual-elements", response_model=None, summary="添加图形元素", description="在网络中添加一个新的图形元素")
|
||||||
|
def fastapi_add_vertex(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""添加图形元素。
|
||||||
|
|
||||||
|
在指定网络中创建一个新的图形元素,并设置其初始属性。
|
||||||
|
"""
|
||||||
|
props = payload
|
||||||
|
return add_vertex(network, ChangeSet(props))
|
||||||
|
|
||||||
|
@router.delete("/visual-elements", response_model=None, summary="删除图形元素", description="从网络中删除指定的图形元素")
|
||||||
|
def fastapi_delete_vertex(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""删除图形元素。
|
||||||
|
|
||||||
|
从指定网络中删除指定的图形元素及其相关数据。
|
||||||
|
"""
|
||||||
|
props = payload
|
||||||
|
return delete_vertex(network, ChangeSet(props))
|
||||||
|
|
||||||
|
@router.get("/visual-elements/links", response_class=PlainTextResponse, summary="获取所有图形元素链接", description="获取网络中的所有图形元素链接列表")
|
||||||
|
def fastapi_get_all_vertex_links(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[str]:
|
||||||
|
"""获取所有图形元素链接。
|
||||||
|
|
||||||
|
返回指定网络中的所有图形元素链接列表。
|
||||||
|
"""
|
||||||
|
return json.dumps(get_all_vertex_links(network))
|
||||||
|
|
||||||
|
@router.get("/all-vertices", response_class=PlainTextResponse, summary="获取所有图形元素", description="获取网络中的所有图形元素详细信息")
|
||||||
|
def fastapi_get_all_vertices(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[dict[str, Any]]:
|
||||||
|
"""获取所有图形元素。
|
||||||
|
|
||||||
|
返回指定网络中的所有图形元素详细信息。
|
||||||
|
"""
|
||||||
|
return json.dumps(get_all_vertices(network))
|
||||||
|
|
||||||
|
@router.get("/network-schemas/label", summary="获取标签架构", description="获取网络中标签对象的架构定义")
|
||||||
|
def fastapi_get_label_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||||
|
"""获取标签架构。
|
||||||
|
|
||||||
|
返回指定网络中标签对象的属性架构定义。
|
||||||
|
"""
|
||||||
|
return get_label_schema(network)
|
||||||
|
|
||||||
|
@router.get("/labels/properties", summary="获取标签属性", description="获取指定坐标处的标签属性信息")
|
||||||
|
def fastapi_get_label_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
x: float = Query(..., description="X坐标"),
|
||||||
|
y: float = Query(..., description="Y坐标")
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""获取标签属性。
|
||||||
|
|
||||||
|
返回指定坐标处的标签属性信息。
|
||||||
|
"""
|
||||||
|
return get_label(network, x, y)
|
||||||
|
|
||||||
|
@router.patch("/labels/properties", response_model=None, summary="设置标签属性", description="更新指定标签的属性")
|
||||||
|
def fastapi_set_label_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""设置标签属性。
|
||||||
|
|
||||||
|
更新指定标签的属性值。
|
||||||
|
"""
|
||||||
|
props = payload
|
||||||
|
return set_label(network, ChangeSet(props))
|
||||||
|
|
||||||
|
@router.post("/labels", response_model=None, summary="添加标签", description="在网络中添加一个新的标签")
|
||||||
|
def fastapi_add_label(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""添加标签。
|
||||||
|
|
||||||
|
在指定网络中创建一个新的标签,并设置其初始属性。
|
||||||
|
"""
|
||||||
|
props = payload
|
||||||
|
return add_label(network, ChangeSet(props))
|
||||||
|
|
||||||
|
@router.delete("/labels", response_model=None, summary="删除标签", description="从网络中删除指定的标签")
|
||||||
|
def fastapi_delete_label(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""删除标签。
|
||||||
|
|
||||||
|
从指定网络中删除指定的标签及其相关数据。
|
||||||
|
"""
|
||||||
|
props = payload
|
||||||
|
return delete_label(network, ChangeSet(props))
|
||||||
|
|
||||||
|
@router.get("/network-schemas/backdrop", summary="获取背景架构", description="获取网络中背景对象的架构定义")
|
||||||
|
def fastapi_get_backdrop_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||||
|
"""获取背景架构。
|
||||||
|
|
||||||
|
返回指定网络中背景对象的属性架构定义。
|
||||||
|
"""
|
||||||
|
return get_backdrop_schema(network)
|
||||||
|
|
||||||
|
@router.get("/backdrops/properties", summary="获取背景属性", description="获取指定网络的背景属性信息")
|
||||||
|
def fastapi_get_backdrop_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
|
||||||
|
"""获取背景属性。
|
||||||
|
|
||||||
|
返回指定网络的背景属性信息。
|
||||||
|
"""
|
||||||
|
return get_backdrop(network)
|
||||||
|
|
||||||
|
@router.patch("/backdrops/properties", response_model=None, summary="设置背景属性", description="更新指定网络的背景属性")
|
||||||
|
def fastapi_set_backdrop_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""设置背景属性。
|
||||||
|
|
||||||
|
更新指定网络的背景属性值。
|
||||||
|
"""
|
||||||
|
props = payload
|
||||||
|
return set_backdrop(network, ChangeSet(props))
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
|
||||||
|
from app.services.geocoding import (
|
||||||
|
TiandituGeocodeRequest,
|
||||||
|
TiandituGeocodingAPIError,
|
||||||
|
TiandituGeocodingConfigError,
|
||||||
|
geocode_tianditu,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/geocoding-requests",
|
||||||
|
summary="Tianditu Geocoding",
|
||||||
|
description="调用天地图地理编码服务,将结构化地址转换为经纬度",
|
||||||
|
)
|
||||||
|
async def tianditu_geocode(request: TiandituGeocodeRequest) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return await geocode_tianditu(request)
|
||||||
|
except TiandituGeocodingConfigError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
except TiandituGeocodingAPIError as exc:
|
||||||
|
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Body
|
||||||
|
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.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: dict[str, list[Any]] | list[dict[str, Any]] | None = (
|
||||||
|
Field(None, description="观测的压力数据;文件路径不属于公共 API 输入")
|
||||||
|
)
|
||||||
|
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="流量单位")
|
||||||
|
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, 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="传感器节点列表")
|
||||||
|
scheme_name: str | None = Field(None, description="方案名称")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/leakage-identifications",
|
||||||
|
summary="执行漏损识别",
|
||||||
|
description="基于压力观测数据和遗传算法识别管网中的漏损位置和大小"
|
||||||
|
)
|
||||||
|
async def identify_leakage(
|
||||||
|
data: LeakageIdentifyRequest = Body(..., description="漏损识别请求数据"),
|
||||||
|
username: str = Depends(get_current_keycloak_username),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
执行漏损识别分析。
|
||||||
|
|
||||||
|
使用遗传算法对比模型计算和实测压力数据,
|
||||||
|
识别管网中的漏损节点和漏水量。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: 包含管网名称(或数据库名称)、压力数据及优化参数的请求体
|
||||||
|
username: 当前认证用户名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含识别结果的字典
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 当处理过程中发生错误时
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
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))
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import logging
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Query, Path
|
||||||
|
import psycopg
|
||||||
|
from psycopg import AsyncConnection
|
||||||
|
|
||||||
|
from app.auth.project_dependencies import (
|
||||||
|
ProjectContext,
|
||||||
|
get_project_context,
|
||||||
|
get_project_pg_connection,
|
||||||
|
get_project_timescale_connection,
|
||||||
|
get_metadata_repository,
|
||||||
|
)
|
||||||
|
from app.auth.metadata_dependencies import get_current_metadata_user
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.domain.schemas.metadata import (
|
||||||
|
ProjectMetaResponse,
|
||||||
|
ProjectSummaryResponse,
|
||||||
|
)
|
||||||
|
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/projects/current/metadata", summary="获取项目元数据", description="获取当前项目的元数据和配置信息", response_model=ProjectMetaResponse)
|
||||||
|
async def get_project_metadata(
|
||||||
|
ctx: ProjectContext = Depends(get_project_context),
|
||||||
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取项目元数据
|
||||||
|
|
||||||
|
返回当前项目的完整元数据,包括项目基本信息和项目权限
|
||||||
|
"""
|
||||||
|
project = await metadata_repo.get_project_by_id(ctx.project_id)
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
|
||||||
|
)
|
||||||
|
return ProjectMetaResponse(
|
||||||
|
project_id=project.id,
|
||||||
|
name=project.name,
|
||||||
|
code=project.code,
|
||||||
|
description=project.description,
|
||||||
|
gs_workspace=project.gs_workspace,
|
||||||
|
map_extent=project.map_extent,
|
||||||
|
status=project.status,
|
||||||
|
project_role=ctx.project_role,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/projects", summary="列出用户项目", description="获取当前用户有权限的所有项目列表", response_model=list[ProjectSummaryResponse])
|
||||||
|
async def list_user_projects(
|
||||||
|
current_user=Depends(get_current_metadata_user),
|
||||||
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
列出用户的所有项目
|
||||||
|
|
||||||
|
返回当前用户有权限访问的项目摘要列表
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
projects = await metadata_repo.list_projects_for_user(current_user.id)
|
||||||
|
except SQLAlchemyError as exc:
|
||||||
|
logger.error(
|
||||||
|
"Metadata DB error while listing projects for user %s",
|
||||||
|
current_user.id,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail=f"Metadata database error: {exc}",
|
||||||
|
) from exc
|
||||||
|
return [
|
||||||
|
ProjectSummaryResponse(
|
||||||
|
project_id=project.project_id,
|
||||||
|
name=project.name,
|
||||||
|
code=project.code,
|
||||||
|
description=project.description,
|
||||||
|
gs_workspace=project.gs_workspace,
|
||||||
|
status=project.status,
|
||||||
|
project_role=project.project_role,
|
||||||
|
)
|
||||||
|
for project in projects
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/projects/current/database-health", summary="检查数据库健康状态", description="检查项目数据库连接的健康状况")
|
||||||
|
async def project_db_health(
|
||||||
|
pg_conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
ts_conn: AsyncConnection = Depends(get_project_timescale_connection),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
检查数据库健康状态
|
||||||
|
|
||||||
|
检查PostgreSQL和TimescaleDB数据库的连接状态
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
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,
|
||||||
|
detail=f"Project PostgreSQL health check failed: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with ts_conn.cursor() as cur:
|
||||||
|
await cur.execute("SELECT 1")
|
||||||
|
except psycopg.Error as exc:
|
||||||
|
logger.error("Project TimescaleDB health check failed", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail=f"Project TimescaleDB health check failed: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return {"postgres": "ok", "timescale": "ok"}
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import NamedTemporaryFile
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
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,
|
||||||
|
get_metadata_repository,
|
||||||
|
)
|
||||||
|
from app.auth.project_dependencies import (
|
||||||
|
ProjectContext,
|
||||||
|
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()
|
||||||
|
|
||||||
|
MAX_INP_FILE_BYTES = 50 * 1024 * 1024
|
||||||
|
INP_SECTIONS = ("[TITLE]", "[JUNCTIONS]", "[RESERVOIRS]", "[TANKS]", "[PIPES]")
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_active_project(project_id: UUID, metadata_repo: MetadataRepository):
|
||||||
|
project = await metadata_repo.get_project_by_id(project_id)
|
||||||
|
if project is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Project not found",
|
||||||
|
)
|
||||||
|
if project.status != "active":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Project is not active",
|
||||||
|
)
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_inp_bytes(content: bytes, filename: str) -> str:
|
||||||
|
if Path(filename).suffix.lower() != ".inp":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Only .inp model files are accepted",
|
||||||
|
)
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="INP file is empty",
|
||||||
|
)
|
||||||
|
if len(content) > MAX_INP_FILE_BYTES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
|
detail="INP file exceeds the 50 MiB limit",
|
||||||
|
)
|
||||||
|
for encoding in ("utf-8-sig", "gb18030"):
|
||||||
|
try:
|
||||||
|
text = content.decode(encoding)
|
||||||
|
break
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="INP file encoding is not supported",
|
||||||
|
)
|
||||||
|
upper_text = text.upper()
|
||||||
|
if not any(section in upper_text for section in INP_SECTIONS):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid INP file structure",
|
||||||
|
)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_upload(file: UploadFile) -> tuple[bytes, str]:
|
||||||
|
filename = Path(file.filename or "").name
|
||||||
|
content = await file.read(MAX_INP_FILE_BYTES + 1)
|
||||||
|
normalized = _validate_inp_bytes(content, filename).encode("utf-8")
|
||||||
|
return normalized, filename
|
||||||
|
|
||||||
|
|
||||||
|
async def _audit_model_change(
|
||||||
|
*,
|
||||||
|
request: Request,
|
||||||
|
current_user,
|
||||||
|
metadata_repo: MetadataRepository,
|
||||||
|
project_id: UUID,
|
||||||
|
action: str,
|
||||||
|
) -> None:
|
||||||
|
await log_audit_event(
|
||||||
|
action=AuditAction.UPDATE,
|
||||||
|
user_id=current_user.id,
|
||||||
|
project_id=project_id,
|
||||||
|
resource_type="hydraulic_model",
|
||||||
|
resource_id=action,
|
||||||
|
request_data={"operation": action},
|
||||||
|
ip_address=request.client.host if request.client else None,
|
||||||
|
request_method=request.method,
|
||||||
|
request_path=request.url.path,
|
||||||
|
response_status=status.HTTP_200_OK,
|
||||||
|
session=metadata_repo.session,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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}"
|
||||||
|
target_path = target_dir / f"{model_name}.inp"
|
||||||
|
target_path.write_bytes(content)
|
||||||
|
return run_inp(model_name)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
temp_file.write(content)
|
||||||
|
temp_path = Path(temp_file.name)
|
||||||
|
network_update(str(temp_path), project_code)
|
||||||
|
finally:
|
||||||
|
if temp_path is not 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,
|
||||||
|
detail=f"数据库操作失败: {exc}",
|
||||||
|
) 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,
|
||||||
|
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="导入桌面端水力模型",
|
||||||
|
)
|
||||||
|
async def import_project_model(
|
||||||
|
request: Request,
|
||||||
|
project_id: UUID = ApiPath(...),
|
||||||
|
file: UploadFile = File(..., description="桌面端导出的 INP 模型文件"),
|
||||||
|
current_user=Depends(get_current_metadata_admin),
|
||||||
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||||
|
) -> dict:
|
||||||
|
project = await _get_active_project(project_id, metadata_repo)
|
||||||
|
content, filename = await _read_upload(file)
|
||||||
|
result = await _run_uploaded_inp(content)
|
||||||
|
await _audit_model_change(
|
||||||
|
request=request,
|
||||||
|
current_user=current_user,
|
||||||
|
metadata_repo=metadata_repo,
|
||||||
|
project_id=project.id,
|
||||||
|
action="import",
|
||||||
|
)
|
||||||
|
return {"project_id": str(project.id), "filename": filename, "result": result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/admin/projects/{project_id}/model-imports",
|
||||||
|
summary="更新桌面端水力模型",
|
||||||
|
)
|
||||||
|
async def update_project_model(
|
||||||
|
request: Request,
|
||||||
|
project_id: UUID = ApiPath(...),
|
||||||
|
file: UploadFile = File(..., description="桌面端导出的 INP 模型文件"),
|
||||||
|
current_user=Depends(get_current_metadata_admin),
|
||||||
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||||
|
) -> dict:
|
||||||
|
project = await _get_active_project(project_id, metadata_repo)
|
||||||
|
content, filename = await _read_upload(file)
|
||||||
|
routing = await resolve_project_business_routing(
|
||||||
|
ProjectContext(
|
||||||
|
project_id=project.id,
|
||||||
|
project_code=project.code,
|
||||||
|
user_id=current_user.id,
|
||||||
|
project_role="owner",
|
||||||
|
system_role=current_user.role,
|
||||||
|
is_superuser=current_user.is_superuser,
|
||||||
|
),
|
||||||
|
metadata_repo,
|
||||||
|
)
|
||||||
|
with activate_project_routing(routing):
|
||||||
|
await _apply_model_update(content, project.code)
|
||||||
|
await _audit_model_change(
|
||||||
|
request=request,
|
||||||
|
current_user=current_user,
|
||||||
|
metadata_repo=metadata_repo,
|
||||||
|
project_id=project.id,
|
||||||
|
action="update",
|
||||||
|
)
|
||||||
|
return {"project_id": str(project.id), "filename": filename, "updated": True}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Query
|
||||||
|
from app.services.tjnetwork import (
|
||||||
|
ChangeSet,
|
||||||
|
calculate_demand_to_network,
|
||||||
|
calculate_demand_to_nodes,
|
||||||
|
calculate_demand_to_region,
|
||||||
|
get_demand,
|
||||||
|
get_demand_schema,
|
||||||
|
set_demand,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
############################################################
|
||||||
|
# demand 9.[DEMANDS]
|
||||||
|
############################################################
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/network-schemas/demand",
|
||||||
|
summary="获取需水量属性架构",
|
||||||
|
description="获取指定水网中需水量(Demand)的属性架构定义"
|
||||||
|
)
|
||||||
|
def fastapi_get_demand_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
获取需水量属性架构。
|
||||||
|
|
||||||
|
返回指定水网的需水量属性架构,包括所有可配置的属性及其类型定义。
|
||||||
|
"""
|
||||||
|
return get_demand_schema(network)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/demands/properties",
|
||||||
|
summary="获取需水量属性",
|
||||||
|
description="获取指定水网中节点的需水量属性信息"
|
||||||
|
)
|
||||||
|
def fastapi_get_demand_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
junction: str = Query(..., description="节点ID")
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
获取节点的需水量属性。
|
||||||
|
|
||||||
|
返回指定节点的所有需水量信息,包括需水量值、水压等级等。
|
||||||
|
"""
|
||||||
|
return get_demand(network, junction)
|
||||||
|
|
||||||
|
|
||||||
|
# example: set_demand(p, ChangeSet({'junction': 'j1', 'demands': [{'demand': 10.0, 'pattern': None, 'category': 'x'}, {'demand': 20.0, 'pattern': None, 'category': None}]}))
|
||||||
|
@router.patch(
|
||||||
|
"/demands/properties",
|
||||||
|
response_model=None,
|
||||||
|
summary="设置需水量属性",
|
||||||
|
description="设置指定水网中节点的需水量属性信息"
|
||||||
|
)
|
||||||
|
def fastapi_set_demand_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
junction: str = Query(..., description="节点ID"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置节点的需水量属性。
|
||||||
|
|
||||||
|
修改指定节点的需水量信息。请求体应包含需水量值、水压等级等属性。
|
||||||
|
"""
|
||||||
|
props = payload
|
||||||
|
ps = {"junction": junction} | props
|
||||||
|
return set_demand(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
############################################################
|
||||||
|
# water distribution 36.[Water Distribution]
|
||||||
|
############################################################
|
||||||
|
@router.post(
|
||||||
|
"/demands/to-nodes",
|
||||||
|
summary="计算需水量到节点分配",
|
||||||
|
description="将总需水量按指定方式分配到多个节点"
|
||||||
|
)
|
||||||
|
def fastapi_calculate_demand_to_nodes(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> dict[str, float]:
|
||||||
|
"""
|
||||||
|
计算需水量到节点分配。
|
||||||
|
|
||||||
|
将指定的总需水量均匀或按比例分配到指定的节点列表中。
|
||||||
|
|
||||||
|
请求体格式:
|
||||||
|
{
|
||||||
|
"demand": 需水量值(float),
|
||||||
|
"nodes": 节点ID列表(list[str])
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
props = payload
|
||||||
|
demand = props["demand"]
|
||||||
|
nodes = props["nodes"]
|
||||||
|
return calculate_demand_to_nodes(network, demand, nodes)
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/demands/to-region",
|
||||||
|
summary="计算需水量到区域分配",
|
||||||
|
description="将总需水量按区域特征分配到该区域内的节点"
|
||||||
|
)
|
||||||
|
def fastapi_calculate_demand_to_region(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> dict[str, float]:
|
||||||
|
"""
|
||||||
|
计算需水量到区域分配。
|
||||||
|
|
||||||
|
根据区域内节点的特征(如面积、人口等)将总需水量分配到该区域的各个节点。
|
||||||
|
|
||||||
|
请求体格式:
|
||||||
|
{
|
||||||
|
"demand": 需水量值(float),
|
||||||
|
"region": 区域ID(str)
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
props = payload
|
||||||
|
demand = props["demand"]
|
||||||
|
region = props["region"]
|
||||||
|
return calculate_demand_to_region(network, demand, region)
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/demands/to-network",
|
||||||
|
summary="计算需水量到整网分配",
|
||||||
|
description="将需水量均匀分配到整个水网的所有需水节点"
|
||||||
|
)
|
||||||
|
def fastapi_calculate_demand_to_network(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
demand: float = Query(..., description="总需水量(m³/h)", gt=0)
|
||||||
|
) -> dict[str, float]:
|
||||||
|
"""
|
||||||
|
计算需水量到整网分配。
|
||||||
|
|
||||||
|
将指定的需水量均匀分配到整个水网的所有需水节点。
|
||||||
|
"""
|
||||||
|
return calculate_demand_to_network(network, demand)
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Query
|
||||||
|
from app.services.tjnetwork import (
|
||||||
|
ChangeSet,
|
||||||
|
delete_junction,
|
||||||
|
delete_pipe,
|
||||||
|
delete_pump,
|
||||||
|
delete_reservoir,
|
||||||
|
delete_tank,
|
||||||
|
delete_valve,
|
||||||
|
get_all_scada_info,
|
||||||
|
get_element_properties,
|
||||||
|
get_element_properties_with_type,
|
||||||
|
get_element_type,
|
||||||
|
get_element_type_value,
|
||||||
|
get_link_properties,
|
||||||
|
get_link_type,
|
||||||
|
get_links,
|
||||||
|
get_node_links,
|
||||||
|
get_node_properties,
|
||||||
|
get_node_type,
|
||||||
|
get_nodes,
|
||||||
|
get_scada_info,
|
||||||
|
get_status,
|
||||||
|
get_status_schema,
|
||||||
|
get_title,
|
||||||
|
get_title_schema,
|
||||||
|
is_junction,
|
||||||
|
is_link,
|
||||||
|
is_node,
|
||||||
|
is_pipe,
|
||||||
|
is_pump,
|
||||||
|
is_reservoir,
|
||||||
|
is_tank,
|
||||||
|
is_valve,
|
||||||
|
set_status,
|
||||||
|
set_title,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
############################################################
|
||||||
|
# type
|
||||||
|
############################################################
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/nodes/existence",
|
||||||
|
summary="检查节点有效性",
|
||||||
|
description="检查指定ID是否为水网中的有效节点"
|
||||||
|
)
|
||||||
|
def fastapi_is_node(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
node: str = Query(..., description="节点ID")
|
||||||
|
) -> bool:
|
||||||
|
"""检查指定ID是否为节点。"""
|
||||||
|
return is_node(network, node)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/junctions/existence",
|
||||||
|
summary="检查是否为接点",
|
||||||
|
description="检查指定ID是否为水网中的接点(需求点)"
|
||||||
|
)
|
||||||
|
def fastapi_is_junction(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
node: str = Query(..., description="节点ID")
|
||||||
|
) -> bool:
|
||||||
|
"""检查指定ID是否为接点。"""
|
||||||
|
return is_junction(network, node)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/reservoirs/existence",
|
||||||
|
summary="检查是否为水源",
|
||||||
|
description="检查指定ID是否为水网中的水源(水库/河流)"
|
||||||
|
)
|
||||||
|
def fastapi_is_reservoir(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
node: str = Query(..., description="节点ID")
|
||||||
|
) -> bool:
|
||||||
|
"""检查指定ID是否为水源。"""
|
||||||
|
return is_reservoir(network, node)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/tanks/existence",
|
||||||
|
summary="检查是否为蓄水池",
|
||||||
|
description="检查指定ID是否为水网中的蓄水池"
|
||||||
|
)
|
||||||
|
def fastapi_is_tank(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
node: str = Query(..., description="节点ID")
|
||||||
|
) -> bool:
|
||||||
|
"""检查指定ID是否为蓄水池。"""
|
||||||
|
return is_tank(network, node)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/links/existence",
|
||||||
|
summary="检查管线有效性",
|
||||||
|
description="检查指定ID是否为水网中的有效管线"
|
||||||
|
)
|
||||||
|
def fastapi_is_link(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
link: str = Query(..., description="管线ID")
|
||||||
|
) -> bool:
|
||||||
|
"""检查指定ID是否为管线。"""
|
||||||
|
return is_link(network, link)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/pipes/existence",
|
||||||
|
summary="检查是否为管道",
|
||||||
|
description="检查指定ID是否为水网中的管道"
|
||||||
|
)
|
||||||
|
def fastapi_is_pipe(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
link: str = Query(..., description="管线ID")
|
||||||
|
) -> bool:
|
||||||
|
"""检查指定ID是否为管道。"""
|
||||||
|
return is_pipe(network, link)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/pumps/existence",
|
||||||
|
summary="检查是否为泵",
|
||||||
|
description="检查指定ID是否为水网中的泵"
|
||||||
|
)
|
||||||
|
def fastapi_is_pump(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
link: str = Query(..., description="管线ID")
|
||||||
|
) -> bool:
|
||||||
|
"""检查指定ID是否为泵。"""
|
||||||
|
return is_pump(network, link)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/valves/existence",
|
||||||
|
summary="检查是否为阀门",
|
||||||
|
description="检查指定ID是否为水网中的阀门"
|
||||||
|
)
|
||||||
|
def fastapi_is_valve(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
link: str = Query(..., description="管线ID")
|
||||||
|
) -> bool:
|
||||||
|
"""检查指定ID是否为阀门。"""
|
||||||
|
return is_valve(network, link)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/node-types",
|
||||||
|
summary="获取节点类型",
|
||||||
|
description="获取指定节点的类型(接点/水源/蓄水池)"
|
||||||
|
)
|
||||||
|
def fastapi_get_node_type(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
node: str = Query(..., description="节点ID")
|
||||||
|
) -> str:
|
||||||
|
"""获取节点的类型标识。"""
|
||||||
|
return get_node_type(network, node)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/link-types",
|
||||||
|
summary="获取管线类型",
|
||||||
|
description="获取指定管线的类型(管道/泵/阀门)"
|
||||||
|
)
|
||||||
|
def fastapi_get_link_type(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
link: str = Query(..., description="管线ID")
|
||||||
|
) -> str:
|
||||||
|
"""获取管线的类型标识。"""
|
||||||
|
return get_link_type(network, link)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/element-types",
|
||||||
|
summary="获取元素类型",
|
||||||
|
description="获取指定元素的类型(节点或管线)"
|
||||||
|
)
|
||||||
|
def fastapi_get_element_type(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
element: str = Query(..., description="元素ID")
|
||||||
|
) -> str:
|
||||||
|
"""获取元素的类型标识。"""
|
||||||
|
return get_element_type(network, element)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/element-type-values",
|
||||||
|
summary="获取元素类型值",
|
||||||
|
description="获取指定元素的类型数值标识"
|
||||||
|
)
|
||||||
|
def fastapi_get_element_type_value(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
element: str = Query(..., description="元素ID")
|
||||||
|
) -> int:
|
||||||
|
"""获取元素的类型数值。"""
|
||||||
|
return get_element_type_value(network, element)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/nodes",
|
||||||
|
summary="获取所有节点",
|
||||||
|
description="获取指定水网中的所有节点ID列表"
|
||||||
|
)
|
||||||
|
def fastapi_get_nodes(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[str]:
|
||||||
|
"""获取水网中所有节点的ID列表。"""
|
||||||
|
return get_nodes(network)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/links",
|
||||||
|
summary="获取所有管线",
|
||||||
|
description="获取指定水网中的所有管线ID列表"
|
||||||
|
)
|
||||||
|
def fastapi_get_links(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[str]:
|
||||||
|
"""获取水网中所有管线的ID列表。"""
|
||||||
|
return get_links(network)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/node-links",
|
||||||
|
summary="获取节点的关联管线",
|
||||||
|
description="获取指定节点连接的所有管线ID列表"
|
||||||
|
)
|
||||||
|
def get_node_links_endpoint(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
node: str = Query(..., description="节点ID")
|
||||||
|
) -> list[str]:
|
||||||
|
"""获取节点关联的所有管线。"""
|
||||||
|
return get_node_links(network, node)
|
||||||
|
|
||||||
|
############################################################
|
||||||
|
# Node & Link properties
|
||||||
|
############################################################
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/node-properties",
|
||||||
|
summary="获取节点属性",
|
||||||
|
description="获取指定节点的所有属性信息"
|
||||||
|
)
|
||||||
|
def fast_get_node_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
node: str = Query(..., description="节点ID")
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""获取节点的完整属性信息。"""
|
||||||
|
return get_node_properties(network, node)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/link-properties",
|
||||||
|
summary="获取管线属性",
|
||||||
|
description="获取指定管线的所有属性信息"
|
||||||
|
)
|
||||||
|
def fast_get_link_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
link: str = Query(..., description="管线ID")
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""获取管线的完整属性信息。"""
|
||||||
|
return get_link_properties(network, link)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/scada-properties",
|
||||||
|
summary="获取SCADA点属性",
|
||||||
|
description="获取指定SCADA点的属性信息"
|
||||||
|
)
|
||||||
|
def fast_get_scada_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
scada: str = Query(..., description="SCADA点ID")
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""获取SCADA点的属性信息。"""
|
||||||
|
return get_scada_info(network, scada)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/all-scada-properties",
|
||||||
|
summary="获取所有SCADA点属性",
|
||||||
|
description="获取指定水网中所有SCADA点的属性信息"
|
||||||
|
)
|
||||||
|
def fast_get_all_scada_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""获取水网中所有SCADA点的属性列表。"""
|
||||||
|
return get_all_scada_info(network)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/element-properties-with-types",
|
||||||
|
summary="获取指定类型元素属性",
|
||||||
|
description="获取指定类型的元素属性信息"
|
||||||
|
)
|
||||||
|
def fast_get_element_properties_with_type(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
elementtype: str = Query(..., description="元素类型"),
|
||||||
|
element: str = Query(..., description="元素ID")
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""获取指定类型元素的属性。"""
|
||||||
|
return get_element_properties_with_type(network, elementtype, element)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/element-properties",
|
||||||
|
summary="获取元素属性",
|
||||||
|
description="获取指定元素的属性信息"
|
||||||
|
)
|
||||||
|
def fast_get_element_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
element: str = Query(..., description="元素ID")
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""获取元素的完整属性信息。"""
|
||||||
|
return get_element_properties(network, element)
|
||||||
|
|
||||||
|
############################################################
|
||||||
|
# title 1.[TITLE]
|
||||||
|
############################################################
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/title-schemas",
|
||||||
|
summary="获取标题属性架构",
|
||||||
|
description="获取指定水网的标题(标题)属性架构定义"
|
||||||
|
)
|
||||||
|
def fast_get_title_schema(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||||
|
) -> dict[str, dict[str, Any]]:
|
||||||
|
"""获取水网标题的属性架构。"""
|
||||||
|
return get_title_schema(network)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/titles",
|
||||||
|
summary="获取水网标题属性",
|
||||||
|
description="获取指定水网的标题(Title)信息"
|
||||||
|
)
|
||||||
|
def fast_get_title(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
|
||||||
|
"""获取水网的标题属性。"""
|
||||||
|
return get_title(network)
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/titles",
|
||||||
|
response_model=None,
|
||||||
|
summary="设置水网标题属性",
|
||||||
|
description="设置指定水网的标题(Title)信息"
|
||||||
|
)
|
||||||
|
def fastapi_set_title(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""设置水网的标题属性。"""
|
||||||
|
props = payload
|
||||||
|
return set_title(network, ChangeSet(props))
|
||||||
|
|
||||||
|
############################################################
|
||||||
|
# status 10.[STATUS]
|
||||||
|
############################################################
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/status-schemas",
|
||||||
|
summary="获取状态属性架构",
|
||||||
|
description="获取指定水网的状态(Status)属性架构定义"
|
||||||
|
)
|
||||||
|
def fastapi_get_status_schema(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||||
|
) -> dict[str, dict[str, Any]]:
|
||||||
|
"""获取水网状态的属性架构。"""
|
||||||
|
return get_status_schema(network)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/status",
|
||||||
|
summary="获取管线状态",
|
||||||
|
description="获取指定管线的状态信息"
|
||||||
|
)
|
||||||
|
def fastapi_get_status(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
link: str = Query(..., description="管线ID")
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""获取管线的状态属性。"""
|
||||||
|
return get_status(network, link)
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/status-properties",
|
||||||
|
response_model=None,
|
||||||
|
summary="设置管线状态",
|
||||||
|
description="设置指定管线的状态信息"
|
||||||
|
)
|
||||||
|
def fastapi_set_status_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
link: str = Query(..., description="管线ID"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""设置管线的状态属性。"""
|
||||||
|
props = payload
|
||||||
|
ps = {"link": link} | props
|
||||||
|
return set_status(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
############################################################
|
||||||
|
# General Deletion
|
||||||
|
############################################################
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/nodes",
|
||||||
|
response_model=None,
|
||||||
|
summary="删除节点",
|
||||||
|
description="删除指定的节点(接点/水源/蓄水池)"
|
||||||
|
)
|
||||||
|
def fastapi_delete_node(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
node: str = Query(..., description="节点ID")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""删除指定的节点。自动识别节点类型并调用相应的删除操作。"""
|
||||||
|
ps = {"id": node}
|
||||||
|
if is_junction(network, node):
|
||||||
|
return delete_junction(network, ChangeSet(ps))
|
||||||
|
elif is_reservoir(network, node):
|
||||||
|
return delete_reservoir(network, ChangeSet(ps))
|
||||||
|
elif is_tank(network, node):
|
||||||
|
return delete_tank(network, ChangeSet(ps))
|
||||||
|
return ChangeSet() # Should probably raise error or return empty
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/links",
|
||||||
|
response_model=None,
|
||||||
|
summary="删除管线",
|
||||||
|
description="删除指定的管线(管道/泵/阀门)"
|
||||||
|
)
|
||||||
|
def fastapi_delete_link(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
link: str = Query(..., description="管线ID")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""删除指定的管线。自动识别管线类型并调用相应的删除操作。"""
|
||||||
|
ps = {"id": link}
|
||||||
|
if is_pipe(network, link):
|
||||||
|
return delete_pipe(network, ChangeSet(ps))
|
||||||
|
elif is_pump(network, link):
|
||||||
|
return delete_pump(network, ChangeSet(ps))
|
||||||
|
elif is_valve(network, link):
|
||||||
|
return delete_valve(network, ChangeSet(ps))
|
||||||
|
return ChangeSet()
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Query
|
||||||
|
|
||||||
|
from app.services.tjnetwork import (
|
||||||
|
get_major_node_coords,
|
||||||
|
get_major_pipe_nodes,
|
||||||
|
get_network_in_extent,
|
||||||
|
get_network_link_nodes,
|
||||||
|
get_node_coord,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
############################################################
|
||||||
|
# coord 24.[COORDINATES]
|
||||||
|
############################################################
|
||||||
|
|
||||||
|
# @router.get("/getcoordschema/")
|
||||||
|
# async def fastapi_get_coord_schema(network: str) -> dict[str, dict[str, Any]]:
|
||||||
|
# return get_coord_schema(network)
|
||||||
|
|
||||||
|
# @router.get("/getcoord/")
|
||||||
|
# async def fastapi_get_coord(network: str, node: str) -> dict[str, Any]:
|
||||||
|
# return get_coord(network, node)
|
||||||
|
|
||||||
|
# # 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 = payload
|
||||||
|
# return set_coord(network, ChangeSet(props))
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/node-coords",
|
||||||
|
summary="获取节点坐标",
|
||||||
|
description="获取指定节点的地理坐标(X, Y)"
|
||||||
|
)
|
||||||
|
def fastapi_get_node_coord(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
node: str = Query(..., description="节点ID")
|
||||||
|
) -> dict[str, float] | None:
|
||||||
|
"""获取节点的地理坐标信息。"""
|
||||||
|
return get_node_coord(network, node)
|
||||||
|
|
||||||
|
# Additional geometry queries found in main.py logic (implicit or explicit)
|
||||||
|
@router.get(
|
||||||
|
"/network-in-extents",
|
||||||
|
summary="获取范围内的网络元素",
|
||||||
|
description="获取指定地理范围内的网络节点和管线"
|
||||||
|
)
|
||||||
|
def fastapi_get_network_in_extent(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
x1: float = Query(..., description="范围左下角X坐标", alias="x1"),
|
||||||
|
y1: float = Query(..., description="范围左下角Y坐标", alias="y1"),
|
||||||
|
x2: float = Query(..., description="范围右上角X坐标", alias="x2"),
|
||||||
|
y2: float = Query(..., description="范围右上角Y坐标", alias="y2")
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""获取地理范围内的网络几何信息。"""
|
||||||
|
return get_network_in_extent(network, x1, y1, x2, y2)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/majornode-coords",
|
||||||
|
summary="获取主要节点坐标",
|
||||||
|
description="获取直径大于等于指定值的节点坐标"
|
||||||
|
)
|
||||||
|
def fastapi_get_majornode_coords(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
diameter: int = Query(..., description="最小直径(mm)", gt=0)
|
||||||
|
) -> dict[str, dict[str, float]]:
|
||||||
|
"""获取主要节点的坐标。只返回直径大于等于指定值的节点。"""
|
||||||
|
return get_major_node_coords(network, diameter)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/major-pipe-nodes",
|
||||||
|
summary="获取主要管道节点",
|
||||||
|
description="获取直径大于等于指定值的管道的节点ID"
|
||||||
|
)
|
||||||
|
def fastapi_get_major_pipe_nodes(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
diameter: int = Query(..., description="最小直径(mm)", gt=0)
|
||||||
|
) -> list[str] | None:
|
||||||
|
"""获取主要管道节点。只返回直径大于等于指定值的管道。"""
|
||||||
|
return get_major_pipe_nodes(network, diameter)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/network-link-nodes",
|
||||||
|
summary="获取网络管线节点",
|
||||||
|
description="获取指定水网所有管线的起点和终点节点"
|
||||||
|
)
|
||||||
|
def fastapi_get_network_link_nodes(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||||
|
) -> list[str] | None:
|
||||||
|
"""获取网络中所有管线的连接节点。"""
|
||||||
|
return get_network_link_nodes(network)
|
||||||
|
|
||||||
|
# @router.get("/getallcoords/")
|
||||||
|
# async def fastapi_get_all_coords(network: str) -> list[Any]:
|
||||||
|
# return get_all_coords(network)
|
||||||
|
|
||||||
|
# @router.get("/projectcoordinates/")
|
||||||
|
# async def fastapi_project_coordinates(
|
||||||
|
# network: str, from_epsg: int, to_epsg: int
|
||||||
|
# ) -> ChangeSet:
|
||||||
|
# return project_coordinates(network, from_epsg, to_epsg)
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Query
|
||||||
|
from app.services.tjnetwork import (
|
||||||
|
ChangeSet,
|
||||||
|
add_junction,
|
||||||
|
delete_junction,
|
||||||
|
get_all_junctions,
|
||||||
|
get_junction,
|
||||||
|
get_junction_schema,
|
||||||
|
set_junction,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/network-schemas/junction", summary="获取节点架构", description="获取指定项目的节点属性架构和数据类型定义。")
|
||||||
|
def fast_get_junction_schema(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||||
|
) -> dict[str, dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
获取节点架构信息。
|
||||||
|
|
||||||
|
返回指定项目的节点属性架构,包括所有属性的类型和约束信息。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
"""
|
||||||
|
return get_junction_schema(network)
|
||||||
|
|
||||||
|
@router.post("/junctions", response_model=None, summary="添加节点", description="在供水网络中添加新的节点,指定节点ID和空间坐标。")
|
||||||
|
def fastapi_add_junction(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
junction: str = Query(..., description="节点 ID"),
|
||||||
|
x: float = Query(..., description="X 坐标"),
|
||||||
|
y: float = Query(..., description="Y 坐标"),
|
||||||
|
z: float = Query(..., description="标高(海拔高度)")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
添加新节点到供水网络。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
junction: 节点 ID
|
||||||
|
x: X 坐标值
|
||||||
|
y: Y 坐标值
|
||||||
|
z: 标高(海拔高度)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ChangeSet: 包含变更信息的结果
|
||||||
|
"""
|
||||||
|
ps = {"id": junction, "x": x, "y": y, "elevation": z}
|
||||||
|
return add_junction(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.delete("/junctions", response_model=None, summary="删除节点", description="从供水网络中删除指定的节点。")
|
||||||
|
def fastapi_delete_junction(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
junction: str = Query(..., description="节点 ID")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
删除指定的节点。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
junction: 节点 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ChangeSet: 包含变更信息的结果
|
||||||
|
"""
|
||||||
|
ps = {"id": junction}
|
||||||
|
return delete_junction(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.get("/junctions/elevation", summary="获取节点标高", description="获取指定节点的标高(海拔高度)。")
|
||||||
|
def fastapi_get_junction_elevation(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
junction: str = Query(..., description="节点 ID")
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
获取节点的标高值。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
junction: 节点 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
float: 节点标高值
|
||||||
|
"""
|
||||||
|
ps = get_junction(network, junction)
|
||||||
|
return ps["elevation"]
|
||||||
|
|
||||||
|
@router.get("/junctions/x", summary="获取节点 X 坐标", description="获取指定节点的 X 坐标值。")
|
||||||
|
def fastapi_get_junction_x(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
junction: str = Query(..., description="节点 ID")
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
获取节点的 X 坐标。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
junction: 节点 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
float: 节点 X 坐标值
|
||||||
|
"""
|
||||||
|
ps = get_junction(network, junction)
|
||||||
|
return ps["x"]
|
||||||
|
|
||||||
|
@router.get("/junctions/y", summary="获取节点 Y 坐标", description="获取指定节点的 Y 坐标值。")
|
||||||
|
def fastapi_get_junction_y(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
junction: str = Query(..., description="节点 ID")
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
获取节点的 Y 坐标。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
junction: 节点 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
float: 节点 Y 坐标值
|
||||||
|
"""
|
||||||
|
ps = get_junction(network, junction)
|
||||||
|
return ps["y"]
|
||||||
|
|
||||||
|
@router.get("/junctions/coord", summary="获取节点坐标", description="获取指定节点的 X 和 Y 坐标。")
|
||||||
|
def fastapi_get_junction_coord(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
junction: str = Query(..., description="节点 ID")
|
||||||
|
) -> dict[str, float]:
|
||||||
|
"""
|
||||||
|
获取节点的坐标信息。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
junction: 节点 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: 包含 x 和 y 坐标的字典
|
||||||
|
"""
|
||||||
|
ps = get_junction(network, junction)
|
||||||
|
coord = {"x": ps["x"], "y": ps["y"]}
|
||||||
|
return coord
|
||||||
|
|
||||||
|
@router.get("/junctions/demand", summary="获取节点需水量", description="获取指定节点的需水量。")
|
||||||
|
def fastapi_get_junction_demand(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
junction: str = Query(..., description="节点 ID")
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
获取节点的需水量。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
junction: 节点 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
float: 节点的需水量值
|
||||||
|
"""
|
||||||
|
ps = get_junction(network, junction)
|
||||||
|
return ps["demand"]
|
||||||
|
|
||||||
|
@router.get("/junctions/pattern", summary="获取节点需水模式", description="获取指定节点的需水模式标识。")
|
||||||
|
def fastapi_get_junction_pattern(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
junction: str = Query(..., description="节点 ID")
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
获取节点的需水模式。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
junction: 节点 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: 节点的需水模式标识
|
||||||
|
"""
|
||||||
|
ps = get_junction(network, junction)
|
||||||
|
return ps["pattern"]
|
||||||
|
|
||||||
|
@router.patch("/junctions/elevation", response_model=None, summary="设置节点标高", description="设置指定节点的标高值。")
|
||||||
|
def fastapi_set_junction_elevation(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
junction: str = Query(..., description="节点 ID"),
|
||||||
|
elevation: float = Query(..., description="标高(海拔高度)")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置节点的标高。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
junction: 节点 ID
|
||||||
|
elevation: 标高值
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ChangeSet: 包含变更信息的结果
|
||||||
|
"""
|
||||||
|
ps = {"id": junction, "elevation": elevation}
|
||||||
|
return set_junction(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.patch("/junctions/x", response_model=None, summary="设置节点 X 坐标", description="设置指定节点的 X 坐标值。")
|
||||||
|
def fastapi_set_junction_x(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
junction: str = Query(..., description="节点 ID"),
|
||||||
|
x: float = Query(..., description="X 坐标值")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置节点的 X 坐标。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
junction: 节点 ID
|
||||||
|
x: X 坐标值
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ChangeSet: 包含变更信息的结果
|
||||||
|
"""
|
||||||
|
ps = {"id": junction, "x": x}
|
||||||
|
return set_junction(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.patch("/junctions/y", response_model=None, summary="设置节点 Y 坐标", description="设置指定节点的 Y 坐标值。")
|
||||||
|
def fastapi_set_junction_y(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
junction: str = Query(..., description="节点 ID"),
|
||||||
|
y: float = Query(..., description="Y 坐标值")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置节点的 Y 坐标。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
junction: 节点 ID
|
||||||
|
y: Y 坐标值
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ChangeSet: 包含变更信息的结果
|
||||||
|
"""
|
||||||
|
ps = {"id": junction, "y": y}
|
||||||
|
return set_junction(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.patch("/junctions/coord", response_model=None, summary="设置节点坐标", description="设置指定节点的 X 和 Y 坐标。")
|
||||||
|
def fastapi_set_junction_coord(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
junction: str = Query(..., description="节点 ID"),
|
||||||
|
x: float = Query(..., description="X 坐标值"),
|
||||||
|
y: float = Query(..., description="Y 坐标值")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置节点的坐标。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
junction: 节点 ID
|
||||||
|
x: X 坐标值
|
||||||
|
y: Y 坐标值
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ChangeSet: 包含变更信息的结果
|
||||||
|
"""
|
||||||
|
ps = {"id": junction, "x": x, "y": y}
|
||||||
|
return set_junction(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.patch("/junctions/demand", response_model=None, summary="设置节点需水量", description="设置指定节点的需水量。")
|
||||||
|
def fastapi_set_junction_demand(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
junction: str = Query(..., description="节点 ID"),
|
||||||
|
demand: float = Query(..., description="需水量值")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置节点的需水量。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
junction: 节点 ID
|
||||||
|
demand: 需水量值
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ChangeSet: 包含变更信息的结果
|
||||||
|
"""
|
||||||
|
ps = {"id": junction, "demand": demand}
|
||||||
|
return set_junction(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.patch("/junctions/pattern", response_model=None, summary="设置节点需水模式", description="设置指定节点的需水模式标识。")
|
||||||
|
def fastapi_set_junction_pattern(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
junction: str = Query(..., description="节点 ID"),
|
||||||
|
pattern: str = Query(..., description="需水模式标识")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置节点的需水模式。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
junction: 节点 ID
|
||||||
|
pattern: 需水模式标识
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ChangeSet: 包含变更信息的结果
|
||||||
|
"""
|
||||||
|
ps = {"id": junction, "pattern": pattern}
|
||||||
|
return set_junction(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.get("/junctions/properties", summary="获取节点属性", description="获取指定节点的所有属性信息。")
|
||||||
|
def fastapi_get_junction_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
junction: str = Query(..., description="节点 ID")
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
获取节点的完整属性信息。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
junction: 节点 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: 包含节点所有属性的字典
|
||||||
|
"""
|
||||||
|
return get_junction(network, junction)
|
||||||
|
|
||||||
|
@router.get("/junctions", summary="获取所有节点属性", description="获取指定项目中所有节点的属性信息。")
|
||||||
|
def fastapi_get_all_junction_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
获取所有节点的属性信息列表。
|
||||||
|
|
||||||
|
此端点返回指定项目中所有节点的详细属性。缓存查询结果以提高性能。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: 包含所有节点属性的列表
|
||||||
|
"""
|
||||||
|
# 缓存查询结果提高性能
|
||||||
|
results = get_all_junctions(network)
|
||||||
|
return results
|
||||||
|
|
||||||
|
@router.patch("/junctions/properties", response_model=None, summary="批量设置节点属性", description="批量设置指定节点的多个属性。")
|
||||||
|
def fastapi_set_junction_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
junction: str = Query(..., description="节点 ID"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
批量设置节点属性。
|
||||||
|
|
||||||
|
允许一次性设置节点的多个属性,如坐标、标高、需水量等。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
junction: 节点 ID
|
||||||
|
req: 包含属性和值的 JSON 请求体
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ChangeSet: 包含变更信息的结果
|
||||||
|
"""
|
||||||
|
props = payload
|
||||||
|
ps = {"id": junction} | props
|
||||||
|
return set_junction(network, ChangeSet(ps))
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Query
|
||||||
|
from app.services.tjnetwork import (
|
||||||
|
ChangeSet,
|
||||||
|
PIPE_STATUS_OPEN,
|
||||||
|
add_pipe,
|
||||||
|
delete_pipe,
|
||||||
|
get_all_pipes,
|
||||||
|
get_pipe,
|
||||||
|
get_pipe_schema,
|
||||||
|
set_pipe,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/network-schemas/pipe", summary="获取管道模式", description="获取管道对象的模式定义,包含所有可用字段及其类型")
|
||||||
|
def fastapi_get_pipe_schema(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||||
|
) -> dict[str, dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
获取管道数据模式定义。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含管道模式信息的字典
|
||||||
|
"""
|
||||||
|
return get_pipe_schema(network)
|
||||||
|
|
||||||
|
@router.post("/pipes", response_model=None, summary="添加管道", description="向网络中添加新的管道,需要提供管道的基本参数如长度、管径、粗糙度等")
|
||||||
|
def fastapi_add_pipe(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
pipe: str = Query(..., description="管道标识符"),
|
||||||
|
node1: str = Query(..., description="管道起始节点ID"),
|
||||||
|
node2: str = Query(..., description="管道终止节点ID"),
|
||||||
|
length: float = Query(0, description="管道长度(单位:米)"),
|
||||||
|
diameter: float = Query(0, description="管道管径(单位:毫米)"),
|
||||||
|
roughness: float = Query(0, description="管道粗糙度"),
|
||||||
|
minor_loss: float = Query(0, description="管道局部阻力系数"),
|
||||||
|
status: str = Query(PIPE_STATUS_OPEN, description="管道状态(开启/关闭)"),
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
添加新管道到网络。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
pipe: 管道ID
|
||||||
|
node1: 起始节点ID
|
||||||
|
node2: 终止节点ID
|
||||||
|
length: 管道长度
|
||||||
|
diameter: 管道管径
|
||||||
|
roughness: 管道粗糙度
|
||||||
|
minor_loss: 局部阻力系数
|
||||||
|
status: 管道状态
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ChangeSet对象,包含本次操作的变更信息
|
||||||
|
"""
|
||||||
|
ps = {
|
||||||
|
"id": pipe,
|
||||||
|
"node1": node1,
|
||||||
|
"node2": node2,
|
||||||
|
"length": length,
|
||||||
|
"diameter": diameter,
|
||||||
|
"roughness": roughness,
|
||||||
|
"minor_loss": minor_loss,
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
|
return add_pipe(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.delete("/pipes", response_model=None, summary="删除管道", description="从网络中删除指定的管道")
|
||||||
|
def fastapi_delete_pipe(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
pipe: str = Query(..., description="要删除的管道ID")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
删除管道。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
pipe: 管道ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ChangeSet对象,包含本次删除操作的变更信息
|
||||||
|
"""
|
||||||
|
ps = {"id": pipe}
|
||||||
|
return delete_pipe(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.get("/pipes/node1", summary="获取管道起始节点", description="获取指定管道的起始节点ID")
|
||||||
|
def fastapi_get_pipe_node1(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
pipe: str = Query(..., description="管道ID")
|
||||||
|
) -> str | None:
|
||||||
|
"""
|
||||||
|
获取管道的起始节点。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
pipe: 管道ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
起始节点ID,如果不存在则返回None
|
||||||
|
"""
|
||||||
|
ps = get_pipe(network, pipe)
|
||||||
|
return ps["node1"]
|
||||||
|
|
||||||
|
@router.get("/pipes/node2", summary="获取管道终止节点", description="获取指定管道的终止节点ID")
|
||||||
|
def fastapi_get_pipe_node2(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
pipe: str = Query(..., description="管道ID")
|
||||||
|
) -> str | None:
|
||||||
|
"""
|
||||||
|
获取管道的终止节点。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
pipe: 管道ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
终止节点ID,如果不存在则返回None
|
||||||
|
"""
|
||||||
|
ps = get_pipe(network, pipe)
|
||||||
|
return ps["node2"]
|
||||||
|
|
||||||
|
@router.get("/pipes/length", summary="获取管道长度", description="获取指定管道的长度")
|
||||||
|
def fastapi_get_pipe_length(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
pipe: str = Query(..., description="管道ID")
|
||||||
|
) -> float | None:
|
||||||
|
"""
|
||||||
|
获取管道长度。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
pipe: 管道ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
管道长度(单位:米),如果不存在则返回None
|
||||||
|
"""
|
||||||
|
ps = get_pipe(network, pipe)
|
||||||
|
return ps["length"]
|
||||||
|
|
||||||
|
@router.get("/pipes/diameter", summary="获取管道管径", description="获取指定管道的管径")
|
||||||
|
def fastapi_get_pipe_diameter(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
pipe: str = Query(..., description="管道ID")
|
||||||
|
) -> float | None:
|
||||||
|
"""
|
||||||
|
获取管道管径。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
pipe: 管道ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
管道管径(单位:毫米),如果不存在则返回None
|
||||||
|
"""
|
||||||
|
ps = get_pipe(network, pipe)
|
||||||
|
return ps["diameter"]
|
||||||
|
|
||||||
|
@router.get("/pipes/roughness", summary="获取管道粗糙度", description="获取指定管道的粗糙度")
|
||||||
|
def fastapi_get_pipe_roughness(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
pipe: str = Query(..., description="管道ID")
|
||||||
|
) -> float | None:
|
||||||
|
"""
|
||||||
|
获取管道粗糙度。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
pipe: 管道ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
管道粗糙度值,如果不存在则返回None
|
||||||
|
"""
|
||||||
|
ps = get_pipe(network, pipe)
|
||||||
|
return ps["roughness"]
|
||||||
|
|
||||||
|
@router.get("/pipes/minor-loss", summary="获取管道局部阻力系数", description="获取指定管道的局部阻力系数")
|
||||||
|
def fastapi_get_pipe_minor_loss(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
pipe: str = Query(..., description="管道ID")
|
||||||
|
) -> float | None:
|
||||||
|
"""
|
||||||
|
获取管道局部阻力系数。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
pipe: 管道ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
局部阻力系数,如果不存在则返回None
|
||||||
|
"""
|
||||||
|
ps = get_pipe(network, pipe)
|
||||||
|
return ps["minor_loss"]
|
||||||
|
|
||||||
|
@router.get("/pipes/status", summary="获取管道状态", description="获取指定管道的状态(开启或关闭)")
|
||||||
|
def fastapi_get_pipe_status(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
pipe: str = Query(..., description="管道ID")
|
||||||
|
) -> str | None:
|
||||||
|
"""
|
||||||
|
获取管道状态。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
pipe: 管道ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
管道状态(开启/关闭),如果不存在则返回None
|
||||||
|
"""
|
||||||
|
ps = get_pipe(network, pipe)
|
||||||
|
return ps["status"]
|
||||||
|
|
||||||
|
@router.patch("/pipes/node1", response_model=None, summary="设置管道起始节点", description="设置指定管道的起始节点")
|
||||||
|
def fastapi_set_pipe_node1(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
pipe: str = Query(..., description="管道ID"),
|
||||||
|
node1: str = Query(..., description="新的起始节点ID")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置管道起始节点。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
pipe: 管道ID
|
||||||
|
node1: 新的起始节点ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ChangeSet对象,包含本次修改的变更信息
|
||||||
|
"""
|
||||||
|
ps = {"id": pipe, "node1": node1}
|
||||||
|
return set_pipe(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.patch("/pipes/node2", response_model=None, summary="设置管道终止节点", description="设置指定管道的终止节点")
|
||||||
|
def fastapi_set_pipe_node2(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
pipe: str = Query(..., description="管道ID"),
|
||||||
|
node2: str = Query(..., description="新的终止节点ID")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置管道终止节点。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
pipe: 管道ID
|
||||||
|
node2: 新的终止节点ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ChangeSet对象,包含本次修改的变更信息
|
||||||
|
"""
|
||||||
|
ps = {"id": pipe, "node2": node2}
|
||||||
|
return set_pipe(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.patch("/pipes/length", response_model=None, summary="设置管道长度", description="设置指定管道的长度")
|
||||||
|
def fastapi_set_pipe_length(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
pipe: str = Query(..., description="管道ID"),
|
||||||
|
length: float = Query(..., description="新的管道长度(单位:米)")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置管道长度。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
pipe: 管道ID
|
||||||
|
length: 新的管道长度
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ChangeSet对象,包含本次修改的变更信息
|
||||||
|
"""
|
||||||
|
ps = {"id": pipe, "length": length}
|
||||||
|
return set_pipe(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.patch("/pipes/diameter", response_model=None, summary="设置管道管径", description="设置指定管道的管径")
|
||||||
|
def fastapi_set_pipe_diameter(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
pipe: str = Query(..., description="管道ID"),
|
||||||
|
diameter: float = Query(..., description="新的管道管径(单位:毫米)")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置管道管径。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
pipe: 管道ID
|
||||||
|
diameter: 新的管道管径
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ChangeSet对象,包含本次修改的变更信息
|
||||||
|
"""
|
||||||
|
ps = {"id": pipe, "diameter": diameter}
|
||||||
|
return set_pipe(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.patch("/pipes/roughness", response_model=None, summary="设置管道粗糙度", description="设置指定管道的粗糙度")
|
||||||
|
def fastapi_set_pipe_roughness(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
pipe: str = Query(..., description="管道ID"),
|
||||||
|
roughness: float = Query(..., description="新的管道粗糙度值")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置管道粗糙度。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
pipe: 管道ID
|
||||||
|
roughness: 新的管道粗糙度
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ChangeSet对象,包含本次修改的变更信息
|
||||||
|
"""
|
||||||
|
ps = {"id": pipe, "roughness": roughness}
|
||||||
|
return set_pipe(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.patch("/pipes/minor-loss", response_model=None, summary="设置管道局部阻力系数", description="设置指定管道的局部阻力系数")
|
||||||
|
def fastapi_set_pipe_minor_loss(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
pipe: str = Query(..., description="管道ID"),
|
||||||
|
minor_loss: float = Query(..., description="新的局部阻力系数值")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置管道局部阻力系数。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
pipe: 管道ID
|
||||||
|
minor_loss: 新的局部阻力系数
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ChangeSet对象,包含本次修改的变更信息
|
||||||
|
"""
|
||||||
|
ps = {"id": pipe, "minor_loss": minor_loss}
|
||||||
|
return set_pipe(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.patch("/pipes/status", response_model=None, summary="设置管道状态", description="设置指定管道的状态(开启或关闭)")
|
||||||
|
def fastapi_set_pipe_status(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
pipe: str = Query(..., description="管道ID"),
|
||||||
|
status: str = Query(..., description="新的管道状态(开启/关闭)")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置管道状态。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
pipe: 管道ID
|
||||||
|
status: 新的管道状态
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ChangeSet对象,包含本次修改的变更信息
|
||||||
|
"""
|
||||||
|
ps = {"id": pipe, "status": status}
|
||||||
|
return set_pipe(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.get("/pipes/properties", summary="获取管道属性", description="获取指定管道的所有属性信息")
|
||||||
|
def fastapi_get_pipe_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
pipe: str = Query(..., description="管道ID")
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
获取管道的所有属性。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
pipe: 管道ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含管道所有属性的字典
|
||||||
|
"""
|
||||||
|
return get_pipe(network, pipe)
|
||||||
|
|
||||||
|
@router.get("/pipes", summary="获取所有管道属性", description="获取网络中所有管道的属性信息列表")
|
||||||
|
def fastapi_get_all_pipe_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
获取网络中所有管道的属性。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含所有管道属性的字典列表
|
||||||
|
"""
|
||||||
|
# 缓存查询结果提高性能
|
||||||
|
results = get_all_pipes(network)
|
||||||
|
return results
|
||||||
|
|
||||||
|
@router.patch("/pipes/properties", response_model=None, summary="设置管道属性", description="批量设置指定管道的多个属性")
|
||||||
|
def fastapi_set_pipe_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
pipe: str = Query(..., description="管道ID"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
批量设置管道属性。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
pipe: 管道ID
|
||||||
|
req: 请求体,包含要设置的属性及其值
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ChangeSet对象,包含本次修改的变更信息
|
||||||
|
"""
|
||||||
|
props = payload
|
||||||
|
ps = {"id": pipe} | props
|
||||||
|
return set_pipe(network, ChangeSet(ps))
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Query
|
||||||
|
from app.services.tjnetwork import (
|
||||||
|
ChangeSet,
|
||||||
|
add_pump,
|
||||||
|
delete_pump,
|
||||||
|
get_all_pumps,
|
||||||
|
get_pump,
|
||||||
|
get_pump_schema,
|
||||||
|
set_pump,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/network-schemas/pump", summary="获取水泵模式", description="获取水泵对象的模式定义,包含所有可用字段及其类型")
|
||||||
|
def fastapi_get_pump_schema(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||||
|
) -> dict[str, dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
获取水泵数据模式定义。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含水泵模式信息的字典
|
||||||
|
"""
|
||||||
|
return get_pump_schema(network)
|
||||||
|
|
||||||
|
@router.post("/pumps", response_model=None, summary="添加水泵", description="向网络中添加新的水泵,需要提供水泵的基本参数如功率等")
|
||||||
|
def fastapi_add_pump(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
pump: str = Query(..., description="水泵标识符"),
|
||||||
|
node1: str = Query(..., description="水泵起始节点ID"),
|
||||||
|
node2: str = Query(..., description="水泵终止节点ID"),
|
||||||
|
power: float = Query(0.0, description="水泵功率(单位:千瓦)")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
添加新水泵到网络。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
pump: 水泵ID
|
||||||
|
node1: 起始节点ID
|
||||||
|
node2: 终止节点ID
|
||||||
|
power: 水泵功率
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ChangeSet对象,包含本次操作的变更信息
|
||||||
|
"""
|
||||||
|
ps = {"id": pump, "node1": node1, "node2": node2, "power": power}
|
||||||
|
return add_pump(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.delete("/pumps", response_model=None, summary="删除水泵", description="从网络中删除指定的水泵")
|
||||||
|
def fastapi_delete_pump(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
pump: str = Query(..., description="要删除的水泵ID")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
删除水泵。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
pump: 水泵ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ChangeSet对象,包含本次删除操作的变更信息
|
||||||
|
"""
|
||||||
|
ps = {"id": pump}
|
||||||
|
return delete_pump(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.get("/pumps/node1", summary="获取水泵起始节点", description="获取指定水泵的起始节点ID")
|
||||||
|
def fastapi_get_pump_node1(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
pump: str = Query(..., description="水泵ID")
|
||||||
|
) -> str | None:
|
||||||
|
"""
|
||||||
|
获取水泵的起始节点。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
pump: 水泵ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
起始节点ID,如果不存在则返回None
|
||||||
|
"""
|
||||||
|
ps = get_pump(network, pump)
|
||||||
|
return ps["node1"]
|
||||||
|
|
||||||
|
@router.get("/pumps/node2", summary="获取水泵终止节点", description="获取指定水泵的终止节点ID")
|
||||||
|
def fastapi_get_pump_node2(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
pump: str = Query(..., description="水泵ID")
|
||||||
|
) -> str | None:
|
||||||
|
"""
|
||||||
|
获取水泵的终止节点。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
pump: 水泵ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
终止节点ID,如果不存在则返回None
|
||||||
|
"""
|
||||||
|
ps = get_pump(network, pump)
|
||||||
|
return ps["node2"]
|
||||||
|
|
||||||
|
@router.patch("/pumps/node1", response_model=None, summary="设置水泵起始节点", description="设置指定水泵的起始节点")
|
||||||
|
def fastapi_set_pump_node1(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
pump: str = Query(..., description="水泵ID"),
|
||||||
|
node1: str = Query(..., description="新的起始节点ID")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置水泵起始节点。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
pump: 水泵ID
|
||||||
|
node1: 新的起始节点ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ChangeSet对象,包含本次修改的变更信息
|
||||||
|
"""
|
||||||
|
ps = {"id": pump, "node1": node1}
|
||||||
|
return set_pump(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.patch("/pumps/node2", response_model=None, summary="设置水泵终止节点", description="设置指定水泵的终止节点")
|
||||||
|
def fastapi_set_pump_node2(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
pump: str = Query(..., description="水泵ID"),
|
||||||
|
node2: str = Query(..., description="新的终止节点ID")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置水泵终止节点。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
pump: 水泵ID
|
||||||
|
node2: 新的终止节点ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ChangeSet对象,包含本次修改的变更信息
|
||||||
|
"""
|
||||||
|
ps = {"id": pump, "node2": node2}
|
||||||
|
return set_pump(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.get("/pumps/properties", summary="获取水泵属性", description="获取指定水泵的所有属性信息")
|
||||||
|
def fastapi_get_pump_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
pump: str = Query(..., description="水泵ID")
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
获取水泵的所有属性。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
pump: 水泵ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含水泵所有属性的字典
|
||||||
|
"""
|
||||||
|
return get_pump(network, pump)
|
||||||
|
|
||||||
|
@router.get("/pumps", summary="获取所有水泵属性", description="获取网络中所有水泵的属性信息列表")
|
||||||
|
def fastapi_get_all_pump_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
获取网络中所有水泵的属性。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含所有水泵属性的字典列表
|
||||||
|
"""
|
||||||
|
# 缓存查询结果提高性能
|
||||||
|
results = get_all_pumps(network)
|
||||||
|
return results
|
||||||
|
|
||||||
|
@router.patch("/pumps/properties", response_model=None, summary="设置水泵属性", description="批量设置指定水泵的多个属性")
|
||||||
|
def fastapi_set_pump_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
pump: str = Query(..., description="水泵ID"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
批量设置水泵属性。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
pump: 水泵ID
|
||||||
|
req: 请求体,包含要设置的属性及其值
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ChangeSet对象,包含本次修改的变更信息
|
||||||
|
"""
|
||||||
|
props = payload
|
||||||
|
ps = {"id": pump} | props
|
||||||
|
return set_pump(network, ChangeSet(ps))
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Query
|
||||||
|
|
||||||
|
from app.services.tjnetwork import (
|
||||||
|
ChangeSet,
|
||||||
|
add_region,
|
||||||
|
delete_region,
|
||||||
|
get_nodes_in_region,
|
||||||
|
get_region,
|
||||||
|
get_region_schema,
|
||||||
|
get_regions,
|
||||||
|
set_region,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/network-schemas/region", summary="获取区域属性架构")
|
||||||
|
def get_region_schema_endpoint(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
) -> dict[str, dict[str, Any]]:
|
||||||
|
return get_region_schema(network)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/regions", summary="获取区域列表")
|
||||||
|
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="获取区域信息")
|
||||||
|
def get_region_endpoint(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
id: str = Query(..., description="区域 ID"),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return get_region(network, id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/regions/nodes", summary="获取区域节点")
|
||||||
|
def get_region_nodes_endpoint(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
id: str = Query(..., description="区域 ID"),
|
||||||
|
) -> list[str]:
|
||||||
|
return get_nodes_in_region(network, id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/regions", summary="修改区域", response_model=None)
|
||||||
|
def set_region_endpoint(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
payload: dict[str, Any] = Body(...),
|
||||||
|
) -> ChangeSet:
|
||||||
|
return set_region(network, ChangeSet(payload))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/regions", summary="添加区域", response_model=None)
|
||||||
|
def add_region_endpoint(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
payload: dict[str, Any] = Body(...),
|
||||||
|
) -> ChangeSet:
|
||||||
|
payload["boundary"] = [tuple(point[:2]) for point in payload.get("boundary", [])]
|
||||||
|
return add_region(network, ChangeSet(payload))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/regions", summary="删除区域", response_model=None)
|
||||||
|
def delete_region_endpoint(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
payload: dict[str, Any] = Body(...),
|
||||||
|
) -> ChangeSet:
|
||||||
|
return delete_region(network, ChangeSet(payload))
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Query
|
||||||
|
from app.services.tjnetwork import (
|
||||||
|
ChangeSet,
|
||||||
|
add_reservoir,
|
||||||
|
delete_reservoir,
|
||||||
|
get_all_reservoirs,
|
||||||
|
get_reservoir,
|
||||||
|
get_reservoir_schema,
|
||||||
|
set_reservoir,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/network-schemas/reservoir",
|
||||||
|
summary="获取水库模式",
|
||||||
|
description="获取指定供水网络中所有水库的模式/属性字段定义"
|
||||||
|
)
|
||||||
|
def fast_get_reservoir_schema(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||||
|
) -> dict[str, dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
获取水库模式定义。
|
||||||
|
|
||||||
|
该端点返回指定网络中水库对象的模式定义,包括所有可用的属性字段。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
水库属性的模式定义字典
|
||||||
|
"""
|
||||||
|
return get_reservoir_schema(network)
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/reservoirs",
|
||||||
|
response_model=None,
|
||||||
|
summary="添加水库",
|
||||||
|
description="在指定供水网络中添加新的水库/水源节点"
|
||||||
|
)
|
||||||
|
def fastapi_add_reservoir(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
reservoir: str = Query(..., description="水库的唯一标识符"),
|
||||||
|
x: float = Query(..., description="水库的X坐标"),
|
||||||
|
y: float = Query(..., description="水库的Y坐标"),
|
||||||
|
head: float = Query(..., description="水库的水头/总水头(米)")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
添加新的水库/水源节点。
|
||||||
|
|
||||||
|
在指定的供水网络中创建一个新的水库,并设置其坐标和水头参数。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
reservoir: 水库的唯一标识符
|
||||||
|
x: 水库的X坐标位置
|
||||||
|
y: 水库的Y坐标位置
|
||||||
|
head: 水库的供水水头(以米为单位)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含操作变更集的ChangeSet对象
|
||||||
|
"""
|
||||||
|
ps = {"id": reservoir, "x": x, "y": y, "head": head}
|
||||||
|
return add_reservoir(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/reservoirs",
|
||||||
|
response_model=None,
|
||||||
|
summary="删除水库",
|
||||||
|
description="从指定供水网络中删除指定的水库/水源节点"
|
||||||
|
)
|
||||||
|
def fastapi_delete_reservoir(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
reservoir: str = Query(..., description="要删除的水库的唯一标识符")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
删除指定的水库节点。
|
||||||
|
|
||||||
|
从指定的供水网络中删除一个水库及其相关的所有连接关系。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
reservoir: 水库的唯一标识符
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含操作变更集的ChangeSet对象
|
||||||
|
"""
|
||||||
|
ps = {"id": reservoir}
|
||||||
|
return delete_reservoir(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/reservoirs/head",
|
||||||
|
summary="获取水库水头",
|
||||||
|
description="获取指定水库的供水水头/总水头值"
|
||||||
|
)
|
||||||
|
def fastapi_get_reservoir_head(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
reservoir: str = Query(..., description="水库的唯一标识符")
|
||||||
|
) -> float | None:
|
||||||
|
"""
|
||||||
|
获取水库的水头参数。
|
||||||
|
|
||||||
|
返回指定水库的供水水头(总水头),单位为米。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
reservoir: 水库的唯一标识符
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
水库的水头值(米),如果水库不存在则返回None
|
||||||
|
"""
|
||||||
|
ps = get_reservoir(network, reservoir)
|
||||||
|
return ps["head"]
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/reservoirs/pattern",
|
||||||
|
summary="获取水库模式",
|
||||||
|
description="获取指定水库的运行模式/供水模式"
|
||||||
|
)
|
||||||
|
def fastapi_get_reservoir_pattern(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
reservoir: str = Query(..., description="水库的唯一标识符")
|
||||||
|
) -> str | None:
|
||||||
|
"""
|
||||||
|
获取水库的运行模式。
|
||||||
|
|
||||||
|
返回指定水库的供水模式,如固定水头模式、时间序列模式等。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
reservoir: 水库的唯一标识符
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
水库的运行模式字符串,如果水库不存在则返回None
|
||||||
|
"""
|
||||||
|
ps = get_reservoir(network, reservoir)
|
||||||
|
return ps["pattern"]
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/reservoirs/x",
|
||||||
|
summary="获取水库X坐标",
|
||||||
|
description="获取指定水库的X坐标位置"
|
||||||
|
)
|
||||||
|
def fastapi_get_reservoir_x(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
reservoir: str = Query(..., description="水库的唯一标识符")
|
||||||
|
) -> dict[str, float] | None:
|
||||||
|
"""
|
||||||
|
获取水库的X坐标。
|
||||||
|
|
||||||
|
返回指定水库的X轴坐标值。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
reservoir: 水库的唯一标识符
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
水库的X坐标值,如果水库不存在则返回None
|
||||||
|
"""
|
||||||
|
ps = get_reservoir(network, reservoir)
|
||||||
|
return ps["x"]
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/reservoirs/y",
|
||||||
|
summary="获取水库Y坐标",
|
||||||
|
description="获取指定水库的Y坐标位置"
|
||||||
|
)
|
||||||
|
def fastapi_get_reservoir_y(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
reservoir: str = Query(..., description="水库的唯一标识符")
|
||||||
|
) -> dict[str, float] | None:
|
||||||
|
"""
|
||||||
|
获取水库的Y坐标。
|
||||||
|
|
||||||
|
返回指定水库的Y轴坐标值。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
reservoir: 水库的唯一标识符
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
水库的Y坐标值,如果水库不存在则返回None
|
||||||
|
"""
|
||||||
|
ps = get_reservoir(network, reservoir)
|
||||||
|
return ps["y"]
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/reservoirs/coord",
|
||||||
|
summary="获取水库坐标",
|
||||||
|
description="获取指定水库的平面坐标(X和Y坐标)"
|
||||||
|
)
|
||||||
|
def fastapi_get_reservoir_coord(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
reservoir: str = Query(..., description="水库的唯一标识符")
|
||||||
|
) -> dict[str, float] | None:
|
||||||
|
"""
|
||||||
|
获取水库的坐标。
|
||||||
|
|
||||||
|
返回指定水库的平面坐标,包含水库ID、X坐标和Y坐标。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
reservoir: 水库的唯一标识符
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含water库ID和X、Y坐标的字典,如果水库不存在则返回None
|
||||||
|
"""
|
||||||
|
ps = get_reservoir(network, reservoir)
|
||||||
|
coord = {"id": reservoir, "x": ps["x"], "y": ps["y"]}
|
||||||
|
return coord
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/reservoirs/head",
|
||||||
|
response_model=None,
|
||||||
|
summary="设置水库水头",
|
||||||
|
description="更新指定水库的供水水头/总水头值"
|
||||||
|
)
|
||||||
|
def fastapi_set_reservoir_head(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
reservoir: str = Query(..., description="水库的唯一标识符"),
|
||||||
|
head: float = Query(..., description="新的水头值(米)")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置水库的水头参数。
|
||||||
|
|
||||||
|
更新指定水库的供水水头(总水头)值。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
reservoir: 水库的唯一标识符
|
||||||
|
head: 新的水头值(以米为单位)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含操作变更集的ChangeSet对象
|
||||||
|
"""
|
||||||
|
ps = {"id": reservoir, "head": head}
|
||||||
|
return set_reservoir(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/reservoirs/pattern",
|
||||||
|
response_model=None,
|
||||||
|
summary="设置水库模式",
|
||||||
|
description="更新指定水库的运行模式/供水模式"
|
||||||
|
)
|
||||||
|
def fastapi_set_reservoir_pattern(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
reservoir: str = Query(..., description="水库的唯一标识符"),
|
||||||
|
pattern: str = Query(..., description="新的运行模式")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置水库的运行模式。
|
||||||
|
|
||||||
|
更新指定水库的供水模式,如固定水头模式、时间序列模式等。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
reservoir: 水库的唯一标识符
|
||||||
|
pattern: 新的运行模式字符串
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含操作变更集的ChangeSet对象
|
||||||
|
"""
|
||||||
|
ps = {"id": reservoir, "pattern": pattern}
|
||||||
|
return set_reservoir(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/reservoirs/x",
|
||||||
|
response_model=None,
|
||||||
|
summary="设置水库X坐标",
|
||||||
|
description="更新指定水库的X坐标位置"
|
||||||
|
)
|
||||||
|
def fastapi_set_reservoir_x(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
reservoir: str = Query(..., description="水库的唯一标识符"),
|
||||||
|
x: float = Query(..., description="新的X坐标值")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置水库的X坐标。
|
||||||
|
|
||||||
|
更新指定水库的X轴坐标值。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
reservoir: 水库的唯一标识符
|
||||||
|
x: 新的X坐标值
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含操作变更集的ChangeSet对象
|
||||||
|
"""
|
||||||
|
ps = {"id": reservoir, "x": x}
|
||||||
|
return set_reservoir(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/reservoirs/y",
|
||||||
|
response_model=None,
|
||||||
|
summary="设置水库Y坐标",
|
||||||
|
description="更新指定水库的Y坐标位置"
|
||||||
|
)
|
||||||
|
def fastapi_set_reservoir_y(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
reservoir: str = Query(..., description="水库的唯一标识符"),
|
||||||
|
y: float = Query(..., description="新的Y坐标值")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置水库的Y坐标。
|
||||||
|
|
||||||
|
更新指定水库的Y轴坐标值。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
reservoir: 水库的唯一标识符
|
||||||
|
y: 新的Y坐标值
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含操作变更集的ChangeSet对象
|
||||||
|
"""
|
||||||
|
ps = {"id": reservoir, "y": y}
|
||||||
|
return set_reservoir(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/reservoirs/coord",
|
||||||
|
response_model=None,
|
||||||
|
summary="设置水库坐标",
|
||||||
|
description="更新指定水库的平面坐标(X和Y坐标)"
|
||||||
|
)
|
||||||
|
def fastapi_set_reservoir_coord(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
reservoir: str = Query(..., description="水库的唯一标识符"),
|
||||||
|
x: float = Query(..., description="新的X坐标值"),
|
||||||
|
y: float = Query(..., description="新的Y坐标值")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置水库的坐标。
|
||||||
|
|
||||||
|
更新指定水库的平面坐标,包括X和Y坐标。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
reservoir: 水库的唯一标识符
|
||||||
|
x: 新的X坐标值
|
||||||
|
y: 新的Y坐标值
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含操作变更集的ChangeSet对象
|
||||||
|
"""
|
||||||
|
ps = {"id": reservoir, "x": x, "y": y}
|
||||||
|
return set_reservoir(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/reservoirs/properties",
|
||||||
|
summary="获取水库属性",
|
||||||
|
description="获取指定水库的所有属性"
|
||||||
|
)
|
||||||
|
def fastapi_get_reservoir_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
reservoir: str = Query(..., description="水库的唯一标识符")
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
获取水库的所有属性。
|
||||||
|
|
||||||
|
返回指定水库的完整属性信息,包括ID、坐标、水头、模式等所有属性。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
reservoir: 水库的唯一标识符
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含水库所有属性的字典
|
||||||
|
"""
|
||||||
|
return get_reservoir(network, reservoir)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/reservoirs",
|
||||||
|
summary="获取所有水库属性",
|
||||||
|
description="获取指定供水网络中所有水库的属性"
|
||||||
|
)
|
||||||
|
def fastapi_get_all_reservoir_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
获取所有水库的属性。
|
||||||
|
|
||||||
|
返回指定供水网络中所有水库的完整属性信息列表。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含所有水库属性的字典列表
|
||||||
|
"""
|
||||||
|
results = get_all_reservoirs(network)
|
||||||
|
return results
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/reservoirs/properties",
|
||||||
|
response_model=None,
|
||||||
|
summary="设置水库属性",
|
||||||
|
description="批量更新指定水库的多个属性"
|
||||||
|
)
|
||||||
|
def fastapi_set_reservoir_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
reservoir: str = Query(..., description="水库的唯一标识符"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置水库的多个属性。
|
||||||
|
|
||||||
|
批量更新指定水库的属性。属性通过JSON请求体传递。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
reservoir: 水库的唯一标识符
|
||||||
|
req: HTTP请求对象,包含JSON格式的属性数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含操作变更集的ChangeSet对象
|
||||||
|
"""
|
||||||
|
props = payload
|
||||||
|
ps = {"id": reservoir} | props
|
||||||
|
return set_reservoir(network, ChangeSet(ps))
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Query
|
||||||
|
from app.services.tjnetwork import (
|
||||||
|
ChangeSet,
|
||||||
|
get_tag,
|
||||||
|
get_tag_schema,
|
||||||
|
get_tags,
|
||||||
|
set_tag,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
############################################################
|
||||||
|
# tag 8.[TAGS]
|
||||||
|
############################################################
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/network-schemas/tag",
|
||||||
|
summary="获取标签属性架构",
|
||||||
|
description="获取指定水网的标签(Tag)属性架构定义"
|
||||||
|
)
|
||||||
|
def fastapi_get_tag_schema(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||||
|
) -> dict[str, dict[str, Any]]:
|
||||||
|
"""获取标签的属性架构。"""
|
||||||
|
return get_tag_schema(network)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/tags/detail",
|
||||||
|
summary="获取标签信息",
|
||||||
|
description="获取指定类型和ID的标签信息"
|
||||||
|
)
|
||||||
|
def fastapi_get_tag(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
t_type: str = Query(..., description="标签类型"),
|
||||||
|
id: str = Query(..., description="元素ID")
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""获取标签信息。"""
|
||||||
|
return get_tag(network, t_type, id)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/tags",
|
||||||
|
summary="获取所有标签",
|
||||||
|
description="获取指定水网中的所有标签信息"
|
||||||
|
)
|
||||||
|
def fastapi_get_tags(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""获取水网中所有标签的列表。"""
|
||||||
|
tags = get_tags(network)
|
||||||
|
return tags
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/tags",
|
||||||
|
response_model=None,
|
||||||
|
summary="设置标签",
|
||||||
|
description="为指定元素设置或修改标签信息"
|
||||||
|
)
|
||||||
|
def fastapi_set_tag(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""设置标签信息。"""
|
||||||
|
props = payload
|
||||||
|
return set_tag(network, ChangeSet(props))
|
||||||
@@ -0,0 +1,565 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Query
|
||||||
|
from app.services.tjnetwork import (
|
||||||
|
ChangeSet,
|
||||||
|
add_tank,
|
||||||
|
delete_tank,
|
||||||
|
get_all_tanks,
|
||||||
|
get_tank,
|
||||||
|
get_tank_schema,
|
||||||
|
set_tank,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/network-schemas/tank", summary="获取水箱模式", description="获取指定网络的水箱数据结构模式定义")
|
||||||
|
def fast_get_tank_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
获取水箱的数据结构模式。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含水箱属性的模式定义字典
|
||||||
|
"""
|
||||||
|
return get_tank_schema(network)
|
||||||
|
|
||||||
|
@router.post("/tanks", summary="新增水箱", description="向指定网络中新增一个水箱", response_model=None)
|
||||||
|
def fastapi_add_tank(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
tank: str = Query(..., description="水箱ID"),
|
||||||
|
x: float = Query(..., description="X坐标"),
|
||||||
|
y: float = Query(..., description="Y坐标"),
|
||||||
|
elevation: float = Query(..., description="标高"),
|
||||||
|
init_level: float = Query(0, description="初始水位"),
|
||||||
|
min_level: float = Query(0, description="最小水位"),
|
||||||
|
max_level: float = Query(0, description="最大水位"),
|
||||||
|
diameter: float = Query(0, description="直径"),
|
||||||
|
min_vol: float = Query(0, description="最小体积"),
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
创建新水箱。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
tank: 水箱ID
|
||||||
|
x: X坐标
|
||||||
|
y: Y坐标
|
||||||
|
elevation: 水箱标高
|
||||||
|
init_level: 初始水位,默认为0
|
||||||
|
min_level: 最小水位,默认为0
|
||||||
|
max_level: 最大水位,默认为0
|
||||||
|
diameter: 水箱直径,默认为0
|
||||||
|
min_vol: 最小体积,默认为0
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含变更信息的ChangeSet对象
|
||||||
|
"""
|
||||||
|
ps = {
|
||||||
|
"id": tank,
|
||||||
|
"x": x,
|
||||||
|
"y": y,
|
||||||
|
"elevation": elevation,
|
||||||
|
"init_level": init_level,
|
||||||
|
"min_level": min_level,
|
||||||
|
"max_level": max_level,
|
||||||
|
"diameter": diameter,
|
||||||
|
"min_vol": min_vol,
|
||||||
|
}
|
||||||
|
return add_tank(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.delete("/tanks", summary="删除水箱", description="删除指定网络中的水箱", response_model=None)
|
||||||
|
def fastapi_delete_tank(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
tank: str = Query(..., description="水箱ID")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
删除指定的水箱。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
tank: 水箱ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含变更信息的ChangeSet对象
|
||||||
|
"""
|
||||||
|
ps = {"id": tank}
|
||||||
|
return delete_tank(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.get("/tanks/elevation", summary="获取水箱标高", description="获取指定水箱的标高值")
|
||||||
|
def fastapi_get_tank_elevation(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
tank: str = Query(..., description="水箱ID")
|
||||||
|
) -> float | None:
|
||||||
|
"""
|
||||||
|
获取水箱的标高。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
tank: 水箱ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
水箱标高值,如果不存在则返回None
|
||||||
|
"""
|
||||||
|
ps = get_tank(network, tank)
|
||||||
|
return ps["elevation"]
|
||||||
|
|
||||||
|
@router.get("/tanks/init-level", summary="获取水箱初始水位", description="获取指定水箱的初始水位值")
|
||||||
|
def fastapi_get_tank_init_level(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
tank: str = Query(..., description="水箱ID")
|
||||||
|
) -> float | None:
|
||||||
|
"""
|
||||||
|
获取水箱的初始水位。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
tank: 水箱ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
水箱初始水位值,如果不存在则返回None
|
||||||
|
"""
|
||||||
|
ps = get_tank(network, tank)
|
||||||
|
return ps["init_level"]
|
||||||
|
|
||||||
|
@router.get("/tanks/min-level", summary="获取水箱最小水位", description="获取指定水箱的最小水位值")
|
||||||
|
def fastapi_get_tank_min_level(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
tank: str = Query(..., description="水箱ID")
|
||||||
|
) -> float | None:
|
||||||
|
"""
|
||||||
|
获取水箱的最小水位。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
tank: 水箱ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
水箱最小水位值,如果不存在则返回None
|
||||||
|
"""
|
||||||
|
ps = get_tank(network, tank)
|
||||||
|
return ps["min_level"]
|
||||||
|
|
||||||
|
@router.get("/tanks/max-level", summary="获取水箱最大水位", description="获取指定水箱的最大水位值")
|
||||||
|
def fastapi_get_tank_max_level(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
tank: str = Query(..., description="水箱ID")
|
||||||
|
) -> float | None:
|
||||||
|
"""
|
||||||
|
获取水箱的最大水位。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
tank: 水箱ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
水箱最大水位值,如果不存在则返回None
|
||||||
|
"""
|
||||||
|
ps = get_tank(network, tank)
|
||||||
|
return ps["max_level"]
|
||||||
|
|
||||||
|
@router.get("/tanks/diameter", summary="获取水箱直径", description="获取指定水箱的直径值")
|
||||||
|
def fastapi_get_tank_diameter(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
tank: str = Query(..., description="水箱ID")
|
||||||
|
) -> float | None:
|
||||||
|
"""
|
||||||
|
获取水箱的直径。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
tank: 水箱ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
水箱直径值,如果不存在则返回None
|
||||||
|
"""
|
||||||
|
ps = get_tank(network, tank)
|
||||||
|
return ps["diameter"]
|
||||||
|
|
||||||
|
@router.get("/tanks/min-vol", summary="获取水箱最小体积", description="获取指定水箱的最小体积值")
|
||||||
|
def fastapi_get_tank_min_vol(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
tank: str = Query(..., description="水箱ID")
|
||||||
|
) -> float | None:
|
||||||
|
"""
|
||||||
|
获取水箱的最小体积。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
tank: 水箱ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
水箱最小体积值,如果不存在则返回None
|
||||||
|
"""
|
||||||
|
ps = get_tank(network, tank)
|
||||||
|
return ps["min_vol"]
|
||||||
|
|
||||||
|
@router.get("/tanks/vol-curve", summary="获取水箱容积曲线", description="获取指定水箱的容积曲线标识")
|
||||||
|
def fastapi_get_tank_vol_curve(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
tank: str = Query(..., description="水箱ID")
|
||||||
|
) -> str | None:
|
||||||
|
"""
|
||||||
|
获取水箱的容积曲线。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
tank: 水箱ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
水箱容积曲线标识,如果不存在则返回None
|
||||||
|
"""
|
||||||
|
ps = get_tank(network, tank)
|
||||||
|
return ps["vol_curve"]
|
||||||
|
|
||||||
|
@router.get("/tanks/overflow", summary="获取水箱溢流口", description="获取指定水箱的溢流口配置")
|
||||||
|
def fastapi_get_tank_overflow(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
tank: str = Query(..., description="水箱ID")
|
||||||
|
) -> str | None:
|
||||||
|
"""
|
||||||
|
获取水箱的溢流口配置。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
tank: 水箱ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
水箱溢流口配置,如果不存在则返回None
|
||||||
|
"""
|
||||||
|
ps = get_tank(network, tank)
|
||||||
|
return ps["overflow"]
|
||||||
|
|
||||||
|
@router.get("/tanks/x", summary="获取水箱X坐标", description="获取指定水箱的X坐标值")
|
||||||
|
def fastapi_get_tank_x(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
tank: str = Query(..., description="水箱ID")
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
获取水箱的X坐标。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
tank: 水箱ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
水箱X坐标值
|
||||||
|
"""
|
||||||
|
ps = get_tank(network, tank)
|
||||||
|
return ps["x"]
|
||||||
|
|
||||||
|
@router.get("/tanks/y", summary="获取水箱Y坐标", description="获取指定水箱的Y坐标值")
|
||||||
|
def fastapi_get_tank_y(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
tank: str = Query(..., description="水箱ID")
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
获取水箱的Y坐标。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
tank: 水箱ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
水箱Y坐标值
|
||||||
|
"""
|
||||||
|
ps = get_tank(network, tank)
|
||||||
|
return ps["y"]
|
||||||
|
|
||||||
|
@router.get("/tanks/coord", summary="获取水箱坐标", description="获取指定水箱的X和Y坐标")
|
||||||
|
def fastapi_get_tank_coord(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
tank: str = Query(..., description="水箱ID")
|
||||||
|
) -> dict[str, float]:
|
||||||
|
"""
|
||||||
|
获取水箱的坐标。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
tank: 水箱ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含x和y坐标的字典
|
||||||
|
"""
|
||||||
|
ps = get_tank(network, tank)
|
||||||
|
coord = {"x": ps["x"], "y": ps["y"]}
|
||||||
|
return coord
|
||||||
|
|
||||||
|
@router.patch("/tanks/elevation", summary="设置水箱标高", description="设置指定水箱的标高值", response_model=None)
|
||||||
|
def fastapi_set_tank_elevation(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
tank: str = Query(..., description="水箱ID"),
|
||||||
|
elevation: float = Query(..., description="新的标高值")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置水箱的标高。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
tank: 水箱ID
|
||||||
|
elevation: 新的标高值
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含变更信息的ChangeSet对象
|
||||||
|
"""
|
||||||
|
ps = {"id": tank, "elevation": elevation}
|
||||||
|
return set_tank(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.patch("/tanks/init-level", summary="设置水箱初始水位", description="设置指定水箱的初始水位值", response_model=None)
|
||||||
|
def fastapi_set_tank_init_level(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
tank: str = Query(..., description="水箱ID"),
|
||||||
|
init_level: float = Query(..., description="新的初始水位值")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置水箱的初始水位。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
tank: 水箱ID
|
||||||
|
init_level: 新的初始水位值
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含变更信息的ChangeSet对象
|
||||||
|
"""
|
||||||
|
ps = {"id": tank, "init_level": init_level}
|
||||||
|
return set_tank(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.patch("/tanks/min-level", summary="设置水箱最小水位", description="设置指定水箱的最小水位值", response_model=None)
|
||||||
|
def fastapi_set_tank_min_level(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
tank: str = Query(..., description="水箱ID"),
|
||||||
|
min_level: float = Query(..., description="新的最小水位值")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置水箱的最小水位。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
tank: 水箱ID
|
||||||
|
min_level: 新的最小水位值
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含变更信息的ChangeSet对象
|
||||||
|
"""
|
||||||
|
ps = {"id": tank, "min_level": min_level}
|
||||||
|
return set_tank(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.patch("/tanks/max-level", summary="设置水箱最大水位", description="设置指定水箱的最大水位值", response_model=None)
|
||||||
|
def fastapi_set_tank_max_level(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
tank: str = Query(..., description="水箱ID"),
|
||||||
|
max_level: float = Query(..., description="新的最大水位值")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置水箱的最大水位。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
tank: 水箱ID
|
||||||
|
max_level: 新的最大水位值
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含变更信息的ChangeSet对象
|
||||||
|
"""
|
||||||
|
ps = {"id": tank, "max_level": max_level}
|
||||||
|
return set_tank(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.patch("/tanks/diameter", summary="设置水箱直径", description="设置指定水箱的直径值", response_model=None)
|
||||||
|
def fastapi_set_tank_diameter(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
tank: str = Query(..., description="水箱ID"),
|
||||||
|
diameter: float = Query(..., description="新的直径值")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置水箱的直径。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
tank: 水箱ID
|
||||||
|
diameter: 新的直径值
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含变更信息的ChangeSet对象
|
||||||
|
"""
|
||||||
|
ps = {"id": tank, "diameter": diameter}
|
||||||
|
return set_tank(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.patch("/tanks/min-vol", summary="设置水箱最小体积", description="设置指定水箱的最小体积值", response_model=None)
|
||||||
|
def fastapi_set_tank_min_vol(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
tank: str = Query(..., description="水箱ID"),
|
||||||
|
min_vol: float = Query(..., description="新的最小体积值")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置水箱的最小体积。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
tank: 水箱ID
|
||||||
|
min_vol: 新的最小体积值
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含变更信息的ChangeSet对象
|
||||||
|
"""
|
||||||
|
ps = {"id": tank, "min_vol": min_vol}
|
||||||
|
return set_tank(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.patch("/tanks/vol-curve", summary="设置水箱容积曲线", description="设置指定水箱的容积曲线标识", response_model=None)
|
||||||
|
def fastapi_set_tank_vol_curve(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
tank: str = Query(..., description="水箱ID"),
|
||||||
|
vol_curve: str = Query(..., description="新的容积曲线标识")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置水箱的容积曲线。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
tank: 水箱ID
|
||||||
|
vol_curve: 新的容积曲线标识
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含变更信息的ChangeSet对象
|
||||||
|
"""
|
||||||
|
ps = {"id": tank, "vol_curve": vol_curve}
|
||||||
|
return set_tank(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.patch("/tanks/overflow", summary="设置水箱溢流口", description="设置指定水箱的溢流口配置", response_model=None)
|
||||||
|
def fastapi_set_tank_overflow(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
tank: str = Query(..., description="水箱ID"),
|
||||||
|
overflow: str = Query(..., description="新的溢流口配置")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置水箱的溢流口配置。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
tank: 水箱ID
|
||||||
|
overflow: 新的溢流口配置
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含变更信息的ChangeSet对象
|
||||||
|
"""
|
||||||
|
ps = {"id": tank, "overflow": overflow}
|
||||||
|
return set_tank(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.patch("/tanks/x", summary="设置水箱X坐标", description="设置指定水箱的X坐标值", response_model=None)
|
||||||
|
def fastapi_set_tank_x(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
tank: str = Query(..., description="水箱ID"),
|
||||||
|
x: float = Query(..., description="新的X坐标值")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置水箱的X坐标。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
tank: 水箱ID
|
||||||
|
x: 新的X坐标值
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含变更信息的ChangeSet对象
|
||||||
|
"""
|
||||||
|
ps = {"id": tank, "x": x}
|
||||||
|
return set_tank(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.patch("/tanks/y", summary="设置水箱Y坐标", description="设置指定水箱的Y坐标值", response_model=None)
|
||||||
|
def fastapi_set_tank_y(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
tank: str = Query(..., description="水箱ID"),
|
||||||
|
y: float = Query(..., description="新的Y坐标值")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置水箱的Y坐标。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
tank: 水箱ID
|
||||||
|
y: 新的Y坐标值
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含变更信息的ChangeSet对象
|
||||||
|
"""
|
||||||
|
ps = {"id": tank, "y": y}
|
||||||
|
return set_tank(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.patch("/tanks/coord", summary="设置水箱坐标", description="设置指定水箱的X和Y坐标", response_model=None)
|
||||||
|
def fastapi_set_tank_coord(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
tank: str = Query(..., description="水箱ID"),
|
||||||
|
x: float = Query(..., description="新的X坐标值"),
|
||||||
|
y: float = Query(..., description="新的Y坐标值")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置水箱的坐标。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
tank: 水箱ID
|
||||||
|
x: 新的X坐标值
|
||||||
|
y: 新的Y坐标值
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含变更信息的ChangeSet对象
|
||||||
|
"""
|
||||||
|
ps = {"id": tank, "x": x, "y": y}
|
||||||
|
return set_tank(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.get("/tanks/properties", summary="获取水箱属性", description="获取指定水箱的所有属性")
|
||||||
|
def fastapi_get_tank_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
tank: str = Query(..., description="水箱ID")
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
获取水箱的所有属性。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
tank: 水箱ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含水箱所有属性的字典
|
||||||
|
"""
|
||||||
|
return get_tank(network, tank)
|
||||||
|
|
||||||
|
@router.get("/tanks", summary="获取所有水箱属性", description="获取指定网络中所有水箱的属性")
|
||||||
|
def fastapi_get_all_tank_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
获取网络中所有水箱的属性。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含所有水箱属性的字典列表
|
||||||
|
"""
|
||||||
|
# 缓存查询结果提高性能
|
||||||
|
results = get_all_tanks(network)
|
||||||
|
return results
|
||||||
|
|
||||||
|
@router.patch("/tanks/properties", summary="设置水箱属性", description="批量设置指定水箱的多个属性", response_model=None)
|
||||||
|
def fastapi_set_tank_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
tank: str = Query(..., description="水箱ID"),
|
||||||
|
payload: dict[str, Any] = Body(...)
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
批量设置水箱的属性。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network: 管网名称(或数据库名称)
|
||||||
|
tank: 水箱ID
|
||||||
|
req: 包含水箱属性的请求体(JSON格式)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含变更信息的ChangeSet对象
|
||||||
|
"""
|
||||||
|
props = payload
|
||||||
|
ps = {"id": tank} | props
|
||||||
|
return set_tank(network, ChangeSet(ps))
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Query
|
||||||
|
from app.services.tjnetwork import (
|
||||||
|
ChangeSet,
|
||||||
|
VALVES_TYPE_PRV,
|
||||||
|
add_valve,
|
||||||
|
delete_valve,
|
||||||
|
get_all_valves,
|
||||||
|
get_valve,
|
||||||
|
get_valve_schema,
|
||||||
|
set_valve,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/network-schemas/valve",
|
||||||
|
summary="获取阀门架构",
|
||||||
|
description="获取指定水网中所有阀门的架构和字段定义",
|
||||||
|
)
|
||||||
|
def fastapi_get_valve_schema(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||||
|
) -> dict[str, dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
获取阀门架构。
|
||||||
|
|
||||||
|
返回指定水网中所有阀门类型的完整架构定义,包括字段名称、类型和默认值。
|
||||||
|
"""
|
||||||
|
return get_valve_schema(network)
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/valves",
|
||||||
|
response_model=None,
|
||||||
|
summary="添加阀门",
|
||||||
|
description="在指定的水网中添加新的阀门",
|
||||||
|
)
|
||||||
|
def fastapi_add_valve(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
valve: str = Query(..., description="阀门ID"),
|
||||||
|
node1: str = Query(..., description="起点节点ID"),
|
||||||
|
node2: str = Query(..., description="终点节点ID"),
|
||||||
|
diameter: float = Query(0, description="阀门直径(mm)"),
|
||||||
|
v_type: str = Query(VALVES_TYPE_PRV, description="阀门类型"),
|
||||||
|
setting: float = Query(0, description="阀门开度/设置值"),
|
||||||
|
minor_loss: float = Query(0, description="损失系数"),
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
添加新的阀门。
|
||||||
|
|
||||||
|
在指定的水网中创建一个新的阀门,设置其连接的两个节点、直径、类型、开度和损失系数。
|
||||||
|
"""
|
||||||
|
ps = {
|
||||||
|
"id": valve,
|
||||||
|
"node1": node1,
|
||||||
|
"node2": node2,
|
||||||
|
"diameter": diameter,
|
||||||
|
"v_type": v_type,
|
||||||
|
"setting": setting,
|
||||||
|
"minor_loss": minor_loss,
|
||||||
|
}
|
||||||
|
|
||||||
|
return add_valve(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/valves",
|
||||||
|
response_model=None,
|
||||||
|
summary="删除阀门",
|
||||||
|
description="从指定的水网中删除指定的阀门",
|
||||||
|
)
|
||||||
|
def fastapi_delete_valve(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
valve: str = Query(..., description="阀门ID"),
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
删除阀门。
|
||||||
|
|
||||||
|
从指定的水网中删除指定ID的阀门。
|
||||||
|
"""
|
||||||
|
ps = {"id": valve}
|
||||||
|
return delete_valve(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/valves/node1",
|
||||||
|
summary="获取阀门起点节点",
|
||||||
|
description="获取指定阀门连接的起点节点ID",
|
||||||
|
)
|
||||||
|
def fastapi_get_valve_node1(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
valve: str = Query(..., description="阀门ID"),
|
||||||
|
) -> str | None:
|
||||||
|
"""
|
||||||
|
获取阀门的起点节点。
|
||||||
|
|
||||||
|
返回指定阀门连接的起点(第一个)节点的ID。
|
||||||
|
"""
|
||||||
|
ps = get_valve(network, valve)
|
||||||
|
return ps["node1"]
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/valves/node2",
|
||||||
|
summary="获取阀门终点节点",
|
||||||
|
description="获取指定阀门连接的终点节点ID",
|
||||||
|
)
|
||||||
|
def fastapi_get_valve_node2(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
valve: str = Query(..., description="阀门ID"),
|
||||||
|
) -> str | None:
|
||||||
|
"""
|
||||||
|
获取阀门的终点节点。
|
||||||
|
|
||||||
|
返回指定阀门连接的终点(第二个)节点的ID。
|
||||||
|
"""
|
||||||
|
ps = get_valve(network, valve)
|
||||||
|
return ps["node2"]
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/valves/diameter",
|
||||||
|
summary="获取阀门直径",
|
||||||
|
description="获取指定阀门的直径",
|
||||||
|
)
|
||||||
|
def fastapi_get_valve_diameter(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
valve: str = Query(..., description="阀门ID"),
|
||||||
|
) -> float | None:
|
||||||
|
"""
|
||||||
|
获取阀门的直径。
|
||||||
|
|
||||||
|
返回指定阀门的直径值(单位:mm)。
|
||||||
|
"""
|
||||||
|
ps = get_valve(network, valve)
|
||||||
|
return ps["diameter"]
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/valves/type",
|
||||||
|
summary="获取阀门类型",
|
||||||
|
description="获取指定阀门的类型",
|
||||||
|
)
|
||||||
|
def fastapi_get_valve_type(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
valve: str = Query(..., description="阀门ID"),
|
||||||
|
) -> str | None:
|
||||||
|
"""
|
||||||
|
获取阀门的类型。
|
||||||
|
|
||||||
|
返回指定阀门的类型(例如:减压阀、调节阀等)。
|
||||||
|
"""
|
||||||
|
ps = get_valve(network, valve)
|
||||||
|
return ps["type"]
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/valves/setting",
|
||||||
|
summary="获取阀门开度",
|
||||||
|
description="获取指定阀门的开度/设置值",
|
||||||
|
)
|
||||||
|
def fastapi_get_valve_setting(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
valve: str = Query(..., description="阀门ID"),
|
||||||
|
) -> float | None:
|
||||||
|
"""
|
||||||
|
获取阀门的开度。
|
||||||
|
|
||||||
|
返回指定阀门的开度/设置值。
|
||||||
|
"""
|
||||||
|
ps = get_valve(network, valve)
|
||||||
|
return ps["setting"]
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/valves/minor-loss",
|
||||||
|
summary="获取阀门损失系数",
|
||||||
|
description="获取指定阀门的损失系数",
|
||||||
|
)
|
||||||
|
def fastapi_get_valve_minor_loss(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
valve: str = Query(..., description="阀门ID"),
|
||||||
|
) -> float | None:
|
||||||
|
"""
|
||||||
|
获取阀门的损失系数。
|
||||||
|
|
||||||
|
返回指定阀门的损失系数值,用于计算流体通过阀门的压力损失。
|
||||||
|
"""
|
||||||
|
ps = get_valve(network, valve)
|
||||||
|
return ps["minor_loss"]
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/valves/node1",
|
||||||
|
response_model=None,
|
||||||
|
summary="设置阀门起点节点",
|
||||||
|
description="设置指定阀门的起点节点",
|
||||||
|
)
|
||||||
|
def fastapi_set_valve_node1(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
valve: str = Query(..., description="阀门ID"),
|
||||||
|
node1: str = Query(..., description="新的起点节点ID"),
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置阀门的起点节点。
|
||||||
|
|
||||||
|
更新指定阀门的起点节点连接。
|
||||||
|
"""
|
||||||
|
ps = {"id": valve, "node1": node1}
|
||||||
|
return set_valve(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/valves/node2",
|
||||||
|
response_model=None,
|
||||||
|
summary="设置阀门终点节点",
|
||||||
|
description="设置指定阀门的终点节点",
|
||||||
|
)
|
||||||
|
def fastapi_set_valve_node2(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
valve: str = Query(..., description="阀门ID"),
|
||||||
|
node2: str = Query(..., description="新的终点节点ID"),
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置阀门的终点节点。
|
||||||
|
|
||||||
|
更新指定阀门的终点节点连接。
|
||||||
|
"""
|
||||||
|
ps = {"id": valve, "node2": node2}
|
||||||
|
return set_valve(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/valves/diameter",
|
||||||
|
response_model=None,
|
||||||
|
summary="设置阀门直径",
|
||||||
|
description="设置指定阀门的直径",
|
||||||
|
)
|
||||||
|
def fastapi_set_valve_diameter(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
valve: str = Query(..., description="阀门ID"),
|
||||||
|
diameter: float = Query(..., description="新的直径值(mm)"),
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置阀门的直径。
|
||||||
|
|
||||||
|
更新指定阀门的直径值。
|
||||||
|
"""
|
||||||
|
ps = {"id": valve, "diameter": diameter}
|
||||||
|
return set_valve(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/valves/type",
|
||||||
|
response_model=None,
|
||||||
|
summary="设置阀门类型",
|
||||||
|
description="设置指定阀门的类型",
|
||||||
|
)
|
||||||
|
def fastapi_set_valve_type(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
valve: str = Query(..., description="阀门ID"),
|
||||||
|
type: str = Query(..., description="新的阀门类型"),
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置阀门的类型。
|
||||||
|
|
||||||
|
更新指定阀门的类型(例如:减压阀、调节阀等)。
|
||||||
|
"""
|
||||||
|
ps = {"id": valve, "type": type}
|
||||||
|
return set_valve(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/valves/setting",
|
||||||
|
response_model=None,
|
||||||
|
summary="设置阀门开度",
|
||||||
|
description="设置指定阀门的开度/设置值",
|
||||||
|
)
|
||||||
|
def fastapi_set_valve_setting(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
valve: str = Query(..., description="阀门ID"),
|
||||||
|
setting: float = Query(..., description="新的开度值"),
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
设置阀门的开度。
|
||||||
|
|
||||||
|
更新指定阀门的开度/设置值。
|
||||||
|
"""
|
||||||
|
ps = {"id": valve, "setting": setting}
|
||||||
|
return set_valve(network, ChangeSet(ps))
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/valves/properties",
|
||||||
|
summary="获取阀门所有属性",
|
||||||
|
description="获取指定阀门的所有属性",
|
||||||
|
)
|
||||||
|
def fastapi_get_valve_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
valve: str = Query(..., description="阀门ID"),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
获取阀门的所有属性。
|
||||||
|
|
||||||
|
返回指定阀门的完整属性集合,包括ID、节点、直径、类型、开度和损失系数。
|
||||||
|
"""
|
||||||
|
return get_valve(network, valve)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/valves",
|
||||||
|
summary="获取所有阀门属性",
|
||||||
|
description="获取指定水网中所有阀门的属性",
|
||||||
|
)
|
||||||
|
def fastapi_get_all_valve_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
获取所有阀门的属性。
|
||||||
|
|
||||||
|
返回指定水网中所有阀门的完整属性列表。
|
||||||
|
"""
|
||||||
|
# 缓存查询结果提高性能
|
||||||
|
results = get_all_valves(network)
|
||||||
|
return results
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/valves/properties",
|
||||||
|
response_model=None,
|
||||||
|
summary="批量设置阀门属性",
|
||||||
|
description="批量设置指定阀门的多个属性",
|
||||||
|
)
|
||||||
|
def fastapi_set_valve_properties(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
valve: str = Query(..., description="阀门ID"),
|
||||||
|
payload: dict[str, Any] = Body(...),
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
批量设置阀门的属性。
|
||||||
|
|
||||||
|
更新指定阀门的一个或多个属性,通过JSON请求体传递要更新的属性。
|
||||||
|
"""
|
||||||
|
props = payload
|
||||||
|
ps = {"id": valve} | props
|
||||||
|
return set_valve(network, ChangeSet(ps))
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import json
|
||||||
|
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.domain.schemas.metadata import ProjectMetaResponse
|
||||||
|
from app.services.tjnetwork import (
|
||||||
|
ChangeSet,
|
||||||
|
export_inp,
|
||||||
|
get_all_vertices,
|
||||||
|
get_all_scada_info,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/projects/current", summary="获取项目信息", description="从数据库获取项目的详细信息,包括地图范围等。", response_model=ProjectMetaResponse)
|
||||||
|
async def get_project_info_endpoint(
|
||||||
|
network: str = Query(..., description="管网名称(或项目代码)"),
|
||||||
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取项目信息
|
||||||
|
|
||||||
|
- **network**: 管网名称(或项目代码)
|
||||||
|
"""
|
||||||
|
project_detail = await metadata_repo.get_project_detail_by_code(network)
|
||||||
|
if not project_detail:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Project {network} not found")
|
||||||
|
return ProjectMetaResponse(
|
||||||
|
project_id=project_detail.project_id,
|
||||||
|
name=project_detail.name,
|
||||||
|
code=project_detail.code,
|
||||||
|
description=project_detail.description,
|
||||||
|
gs_workspace=project_detail.gs_workspace,
|
||||||
|
map_extent=project_detail.map_extent,
|
||||||
|
status=project_detail.status,
|
||||||
|
project_role="viewer", # Default role for public access
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/projects/current/exports/change-set", response_model=None, summary="导出项目为 ChangeSet", description="导出项目的变更集 (ChangeSet),包含顶点、SCADA 元素、DMA、SA、VD 等信息。")
|
||||||
|
def export_inp_endpoint(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
version: str = Query(..., description="版本号 (通常用于增量更新)")
|
||||||
|
) -> ChangeSet:
|
||||||
|
"""
|
||||||
|
导出项目为 ChangeSet
|
||||||
|
|
||||||
|
- **network**: 管网名称(或数据库名称)
|
||||||
|
- **version**: 版本号
|
||||||
|
"""
|
||||||
|
cs = export_inp(network, version)
|
||||||
|
op = cs.operations[0]
|
||||||
|
op["vertex"] = json.dumps(get_all_vertices(network))
|
||||||
|
op["scada"] = json.dumps(get_all_scada_info(network))
|
||||||
|
|
||||||
|
return cs
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from psycopg import AsyncConnection
|
||||||
|
|
||||||
|
from app.infra.db.postgresql.analysis import AnalysisRepository
|
||||||
|
from app.auth.project_dependencies import get_project_pg_connection
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_database_connection(
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
):
|
||||||
|
"""获取数据库连接"""
|
||||||
|
yield conn
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/analysis/runs", summary="获取分析运行列表")
|
||||||
|
async def get_analysis_runs(
|
||||||
|
conn: AsyncConnection = Depends(get_database_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)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/analysis/runs/{run_id}", summary="获取分析运行")
|
||||||
|
async def get_analysis_run(
|
||||||
|
run_id: UUID,
|
||||||
|
conn: AsyncConnection = Depends(get_database_connection),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取所有爆管定位结果
|
||||||
|
|
||||||
|
返回项目中所有的爆管定位分析结果
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
run = await AnalysisRepository.get_run(conn, run_id)
|
||||||
|
if run is None:
|
||||||
|
raise HTTPException(status_code=404, detail="分析运行不存在")
|
||||||
|
return run
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"查询分析运行时发生错误: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
根据爆管事件ID查询爆管定位结果
|
||||||
|
|
||||||
|
参数:
|
||||||
|
burst_incident: 爆管事件的唯一标识符
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await AnalysisRepository.list_results(conn, run_id, result_type)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"查询分析结果时发生错误: {str(e)}",
|
||||||
|
)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from psycopg import AsyncConnection
|
||||||
|
|
||||||
|
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 设备结构")
|
||||||
|
def get_scada_device_schema() -> dict[str, dict[str, Any]]:
|
||||||
|
return get_scada_info_schema("")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/scada-devices",
|
||||||
|
summary="获取 SCADA 设备列表",
|
||||||
|
response_model=list[ScadaDeviceResponse],
|
||||||
|
)
|
||||||
|
async def get_scada_devices(
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
return await ScadaInfoRepository.get_scadas(conn)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/scada-devices/{device_id}",
|
||||||
|
summary="获取 SCADA 设备",
|
||||||
|
response_model=ScadaDeviceResponse,
|
||||||
|
)
|
||||||
|
async def get_scada_device(
|
||||||
|
device_id: str,
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
device = await ScadaInfoRepository.get_scada(conn, device_id)
|
||||||
|
if device is None:
|
||||||
|
raise HTTPException(status_code=404, detail="SCADA 设备不存在")
|
||||||
|
return device
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import quote
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
|
from app.auth.metadata_dependencies import get_current_metadata_user
|
||||||
|
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,
|
||||||
|
SensorPlacementOptimizeRequest,
|
||||||
|
SensorPlacementSchemeResponse,
|
||||||
|
SensorPlacementUpdateRequest,
|
||||||
|
)
|
||||||
|
from app.services.sensor_placement import (
|
||||||
|
SensorPlacementConflictError,
|
||||||
|
SensorPlacementNotFoundError,
|
||||||
|
SensorPlacementValidationError,
|
||||||
|
build_sensor_placement_workbook,
|
||||||
|
can_edit_sensor_placement,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _project_network(network: str, project_context: ProjectContext) -> str:
|
||||||
|
if network != project_context.project_code:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="请求的管网不属于当前项目",
|
||||||
|
)
|
||||||
|
return project_context.project_code
|
||||||
|
|
||||||
|
|
||||||
|
def _can_modify_project(project_context: ProjectContext) -> bool:
|
||||||
|
return project_context.project_role == "member"
|
||||||
|
|
||||||
|
|
||||||
|
def _require_project_write(
|
||||||
|
project_context: ProjectContext,
|
||||||
|
) -> None:
|
||||||
|
if not _can_modify_project(project_context):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="当前项目角色为只读,不能修改监测点方案",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _service_http_error(exc: Exception) -> HTTPException:
|
||||||
|
if isinstance(exc, SensorPlacementNotFoundError):
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=str(exc),
|
||||||
|
)
|
||||||
|
if isinstance(exc, SensorPlacementConflictError):
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=str(exc),
|
||||||
|
)
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=str(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_run_response(
|
||||||
|
network: str,
|
||||||
|
run_id: UUID,
|
||||||
|
current_user: Any,
|
||||||
|
project_context: ProjectContext,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
run = get_sensor_placement_run(network, run_id)
|
||||||
|
return {
|
||||||
|
**run,
|
||||||
|
"can_edit": (
|
||||||
|
_can_modify_project(project_context)
|
||||||
|
and can_edit_sensor_placement(current_user, run)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
except (
|
||||||
|
SensorPlacementNotFoundError,
|
||||||
|
SensorPlacementValidationError,
|
||||||
|
) as exc:
|
||||||
|
raise _service_http_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/sensor-placement-candidates/{node_id}",
|
||||||
|
response_model=SensorPointResponse,
|
||||||
|
summary="获取监测点候选节点详情",
|
||||||
|
)
|
||||||
|
async def get_sensor_placement_candidate_detail(
|
||||||
|
node_id: str = Path(..., min_length=1, max_length=32),
|
||||||
|
project_context: ProjectContext = Depends(get_project_context),
|
||||||
|
_routing: ActiveProjectRouting = Depends(use_project_business_routing),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return await run_in_threadpool(
|
||||||
|
get_sensor_placement_candidate,
|
||||||
|
project_context.project_code,
|
||||||
|
node_id,
|
||||||
|
)
|
||||||
|
except SensorPlacementValidationError as exc:
|
||||||
|
raise _service_http_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/sensor-placement-runs",
|
||||||
|
response_model=SensorPlacementSchemeResponse,
|
||||||
|
summary="创建并返回监测点优化方案",
|
||||||
|
)
|
||||||
|
async def optimize_sensor_placement_scheme(
|
||||||
|
payload: SensorPlacementOptimizeRequest,
|
||||||
|
project_context: ProjectContext = Depends(get_project_context),
|
||||||
|
current_user=Depends(get_current_metadata_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
network = _project_network(payload.network, project_context)
|
||||||
|
_require_project_write(project_context)
|
||||||
|
optimizer = (
|
||||||
|
optimize_sensor_placement_by_sensitivity
|
||||||
|
if payload.method == "sensitivity"
|
||||||
|
else optimize_sensor_placement_by_kmeans
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
created = await run_in_threadpool(
|
||||||
|
optimizer,
|
||||||
|
project_code=network,
|
||||||
|
run_name=payload.run_name,
|
||||||
|
sensor_count=payload.sensor_count,
|
||||||
|
min_diameter=payload.min_diameter,
|
||||||
|
created_by=current_user.username,
|
||||||
|
)
|
||||||
|
run = get_sensor_placement_run(network, created["run_id"])
|
||||||
|
return {**run, "can_edit": True}
|
||||||
|
except (
|
||||||
|
SensorPlacementConflictError,
|
||||||
|
SensorPlacementValidationError,
|
||||||
|
ValueError,
|
||||||
|
) as exc:
|
||||||
|
raise _service_http_error(exc) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Sensor placement optimization failed")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="监测点优化失败,请稍后重试",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/sensor-placement-runs",
|
||||||
|
response_model=list[SensorPlacementSchemeResponse],
|
||||||
|
summary="获取监测点优化运行",
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
project_context.project_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/sensor-placement-runs/{run_id}",
|
||||||
|
response_model=SensorPlacementSchemeResponse,
|
||||||
|
summary="获取监测点方案详情",
|
||||||
|
)
|
||||||
|
def get_sensor_placement_run_detail(
|
||||||
|
run_id: UUID,
|
||||||
|
network: str = Query(..., min_length=1),
|
||||||
|
project_context: ProjectContext = Depends(get_project_context),
|
||||||
|
current_user=Depends(get_current_metadata_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return _get_run_response(
|
||||||
|
_project_network(network, project_context),
|
||||||
|
run_id,
|
||||||
|
current_user,
|
||||||
|
project_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/sensor-placement-runs/{run_id}",
|
||||||
|
response_model=SensorPlacementSchemeResponse,
|
||||||
|
summary="覆盖保存监测点方案",
|
||||||
|
)
|
||||||
|
def overwrite_sensor_placement_run(
|
||||||
|
run_id: UUID,
|
||||||
|
payload: SensorPlacementUpdateRequest,
|
||||||
|
network: str = Query(..., min_length=1),
|
||||||
|
project_context: ProjectContext = Depends(get_project_context),
|
||||||
|
current_user=Depends(get_current_metadata_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
network = _project_network(network, project_context)
|
||||||
|
_require_project_write(project_context)
|
||||||
|
run = _get_run_response(
|
||||||
|
network,
|
||||||
|
run_id,
|
||||||
|
current_user,
|
||||||
|
project_context,
|
||||||
|
)
|
||||||
|
if not run["can_edit"]:
|
||||||
|
raise HTTPException(status_code=403, detail="无权修改该监测点优化运行")
|
||||||
|
|
||||||
|
try:
|
||||||
|
updated = update_sensor_placement_run(
|
||||||
|
network,
|
||||||
|
run_id,
|
||||||
|
expected_sensor_locations=payload.expected_sensor_locations,
|
||||||
|
sensor_locations=payload.sensor_locations,
|
||||||
|
)
|
||||||
|
return {**updated, "can_edit": True}
|
||||||
|
except (
|
||||||
|
SensorPlacementConflictError,
|
||||||
|
SensorPlacementNotFoundError,
|
||||||
|
SensorPlacementValidationError,
|
||||||
|
) as exc:
|
||||||
|
raise _service_http_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/sensor-placement-runs/{run_id}/exports/excel",
|
||||||
|
summary="导出监测点工程清单",
|
||||||
|
)
|
||||||
|
async def export_sensor_placement_excel(
|
||||||
|
run_id: UUID,
|
||||||
|
payload: SensorPlacementExportRequest,
|
||||||
|
network: str = Query(..., min_length=1),
|
||||||
|
project_context: ProjectContext = Depends(get_project_context),
|
||||||
|
current_user=Depends(get_current_metadata_user),
|
||||||
|
) -> StreamingResponse:
|
||||||
|
network = _project_network(network, project_context)
|
||||||
|
run = _get_run_response(
|
||||||
|
network,
|
||||||
|
run_id,
|
||||||
|
current_user,
|
||||||
|
project_context,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
payload.sensor_locations != run["sensor_locations"]
|
||||||
|
and not run["can_edit"]
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=403, detail="无权导出该方案的未保存草稿")
|
||||||
|
|
||||||
|
try:
|
||||||
|
workbook = await run_in_threadpool(
|
||||||
|
build_sensor_placement_workbook,
|
||||||
|
network=network,
|
||||||
|
scheme=run,
|
||||||
|
sensor_location=payload.sensor_locations,
|
||||||
|
adjustment_status=payload.adjustment_status,
|
||||||
|
)
|
||||||
|
except SensorPlacementValidationError as exc:
|
||||||
|
raise _service_http_error(exc) from exc
|
||||||
|
|
||||||
|
filename = f"{run['name']}_监测点清单.xlsx"
|
||||||
|
encoded_filename = quote(filename)
|
||||||
|
return StreamingResponse(
|
||||||
|
workbook,
|
||||||
|
media_type=(
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
),
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": (
|
||||||
|
f"attachment; filename*=UTF-8''{encoded_filename}"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
from typing import Any, List, Literal, Optional
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
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
|
||||||
|
from app.services.tjnetwork import (
|
||||||
|
run_project,
|
||||||
|
run_project_return_dict,
|
||||||
|
)
|
||||||
|
from app.services.simulation_scenarios import (
|
||||||
|
burst_analysis,
|
||||||
|
valve_close_analysis,
|
||||||
|
flushing_analysis,
|
||||||
|
contaminant_simulation,
|
||||||
|
pressure_regulation,
|
||||||
|
)
|
||||||
|
from app.services.valve_isolation import analyze_valve_isolation
|
||||||
|
from app.domain.time import (
|
||||||
|
parse_aware_time,
|
||||||
|
parse_clock_duration_seconds,
|
||||||
|
parse_utc_time,
|
||||||
|
)
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class RunSimulationManuallyByDate(BaseModel):
|
||||||
|
name: str = Field(..., description="管网名称(或数据库名称)")
|
||||||
|
start_time: str = Field(..., description="开始时间 (ISO 8601 / RFC3339,必须显式带时区)")
|
||||||
|
duration: int = Field(..., gt=0, description="持续时间 (分钟)")
|
||||||
|
|
||||||
|
@field_validator("start_time")
|
||||||
|
@classmethod
|
||||||
|
def validate_start_time_timezone(cls, value: str) -> str:
|
||||||
|
parse_aware_time(value, field_name="start_time")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
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: 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",
|
||||||
|
)
|
||||||
|
if hydraulic_step_seconds <= 0:
|
||||||
|
raise ValueError("HYDRAULIC TIMESTEP must be greater than 0.")
|
||||||
|
hydraulic_step = timedelta(seconds=hydraulic_step_seconds)
|
||||||
|
scada_mappings = simulation.query_corresponding_element_id_and_query_id(
|
||||||
|
network_name
|
||||||
|
)
|
||||||
|
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"),
|
||||||
|
scada_mappings=scada_mappings,
|
||||||
|
)
|
||||||
|
current_time += hydraulic_step
|
||||||
|
|
||||||
|
|
||||||
|
# 必须用这个PlainTextResponse,不然每个key都有引号
|
||||||
|
@router.post("/project-runs", response_class=PlainTextResponse, summary="运行项目模拟", description="基于指定的管网项目运行标准水力模拟,返回纯文本格式的模拟报告。")
|
||||||
|
def run_project_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")) -> str:
|
||||||
|
"""
|
||||||
|
运行项目模拟
|
||||||
|
|
||||||
|
- **network**: 管网名称(或数据库名称)
|
||||||
|
|
||||||
|
运行指定管网项目的标准水力模拟并返回文本报告。
|
||||||
|
"""
|
||||||
|
return run_project(network)
|
||||||
|
|
||||||
|
|
||||||
|
# DingZQ, 2025-02-04, 返回dict[str, Any]
|
||||||
|
# output 和 report
|
||||||
|
# output 是 json
|
||||||
|
# report 是 text
|
||||||
|
@router.post("/project-return-dict-runs", summary="运行项目模拟(返回字典)", description="基于指定的管网项目运行标准水力模拟,返回JSON格式的字典,包含输出数据和报告文本。")
|
||||||
|
def run_project_return_dict_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
运行项目模拟(返回字典)
|
||||||
|
|
||||||
|
- **network**: 管网名称(或数据库名称)
|
||||||
|
|
||||||
|
返回字典包含:
|
||||||
|
- output: JSON格式的模拟输出数据
|
||||||
|
- report: 文本格式的模拟报告
|
||||||
|
|
||||||
|
运行指定管网项目的标准水力模拟并返回字典结果。
|
||||||
|
"""
|
||||||
|
return run_project_return_dict(network)
|
||||||
|
|
||||||
|
|
||||||
|
# Analysis Endpoints
|
||||||
|
@router.post("/burst-analyses", summary="爆管分析(高级)", description="高级版本的爆管分析,支持在指定时间点修改泵控制模式和阀门开度,以分析这些改变对爆管影响的作用。支持固定泵和变速泵的独立控制。")
|
||||||
|
def fastapi_burst_analysis(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
modify_pattern_start_time: str = Query(..., description="模式修改开始时间(ISO 8601格式)"),
|
||||||
|
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:
|
||||||
|
"""
|
||||||
|
爆管分析(高级版本)
|
||||||
|
|
||||||
|
- **network**: 管网名称(或数据库名称)
|
||||||
|
- **modify_pattern_start_time**: 模式修改开始时间
|
||||||
|
- **burst_ID**: 爆管节点/管段ID列表
|
||||||
|
- **burst_size**: 爆管流量大小列表(与burst_ID对应)
|
||||||
|
- **modify_total_duration**: 模拟总时长(秒)
|
||||||
|
- **scheme_name**: 分析方案名称
|
||||||
|
|
||||||
|
支持在指定时间修改泵控制模式和阀门开度。
|
||||||
|
"""
|
||||||
|
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,
|
||||||
|
burst_ID=burst_ID,
|
||||||
|
burst_size=burst_size,
|
||||||
|
modify_total_duration=modify_total_duration,
|
||||||
|
scheme_name=scheme_name,
|
||||||
|
username=username,
|
||||||
|
)
|
||||||
|
return "success"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/valve-closure-analyses", response_class=PlainTextResponse, summary="阀门关闭分析(高级)", description="高级版本的阀门关闭分析,支持同时关闭多个阀门,并在指定持续时间内进行模拟。返回纯文本格式的分析结果。")
|
||||||
|
def fastapi_valve_close_analysis(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
start_time: str = Query(..., description="阀门关闭开始时间(ISO 8601格式)"),
|
||||||
|
valves: List[str] = Query(..., description="要关闭的阀门ID列表"),
|
||||||
|
duration: int | None = Query(None, description="模拟持续时间(秒),默认900秒"),
|
||||||
|
scheme_name: str = Query(..., description="阀门关闭方案名称"),
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
阀门关闭分析(高级版本)
|
||||||
|
|
||||||
|
- **network**: 管网名称(或数据库名称)
|
||||||
|
- **start_time**: 阀门关闭开始时间
|
||||||
|
- **valves**: 要关闭的阀门ID列表
|
||||||
|
- **duration**: 模拟持续时间(秒,可选,默认900)
|
||||||
|
- **scheme_name**: 阀门关闭方案名称
|
||||||
|
|
||||||
|
支持同时关闭多个阀门进行分析。
|
||||||
|
"""
|
||||||
|
result = valve_close_analysis(
|
||||||
|
name=network,
|
||||||
|
modify_pattern_start_time=start_time,
|
||||||
|
modify_total_duration=duration or 900,
|
||||||
|
modify_valve_opening={valve_id: 0.0 for valve_id in valves},
|
||||||
|
scheme_name=scheme_name,
|
||||||
|
)
|
||||||
|
return result or "success"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/valve-isolation-analyses", summary="阀门隔离分析", description="分析当发生突发事件时,通过关闭指定阀门进行隔离,确定哪些阀门必须关闭、哪些可选关闭,以及隔离的可行性。")
|
||||||
|
def valve_isolation_endpoint(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
accident_element: List[str] = Query(..., description="发生事故的管段/节点ID列表"),
|
||||||
|
disabled_valves: List[str] = Query(None, description="已故障的阀门ID列表(可选)"),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
阀门隔离分析
|
||||||
|
|
||||||
|
- **network**: 管网名称(或数据库名称)
|
||||||
|
- **accident_element**: 发生事故的管段/节点ID列表
|
||||||
|
- **disabled_valves**: 已故障的阀门ID列表(可选)
|
||||||
|
|
||||||
|
返回隔离方案,包括:
|
||||||
|
- must_close_valves: 必须关闭的阀门列表
|
||||||
|
- optional_valves: 可选关闭的阀门列表
|
||||||
|
- affected_nodes: 受影响的节点列表;不可隔离时为空列表
|
||||||
|
- affected_node_count: 受影响的节点总数
|
||||||
|
- isolatable: 是否可以有效隔离
|
||||||
|
"""
|
||||||
|
# result = {
|
||||||
|
# "accident_element": "P461309",
|
||||||
|
# "accident_elements": ["P461309"],
|
||||||
|
# "affected_nodes": [
|
||||||
|
# "J316629_A",
|
||||||
|
# "J317037_B",
|
||||||
|
# "J317060_B",
|
||||||
|
# "J408189_B",
|
||||||
|
# "J499996",
|
||||||
|
# "J524940",
|
||||||
|
# "J535933",
|
||||||
|
# "J58841",
|
||||||
|
# ],
|
||||||
|
# "isolatable": True,
|
||||||
|
# "must_close_valves": ["210521658", "V12974", "V12986", "V12993"],
|
||||||
|
# "optional_valves": [],
|
||||||
|
# }
|
||||||
|
result = analyze_valve_isolation(network, accident_element, disabled_valves)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/flushing-analyses", response_class=PlainTextResponse, summary="冲洗分析(高级)", description="高级版本的冲洗分析,支持按状态和设置值控制多个可选阀门,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。")
|
||||||
|
def fastapi_flushing_analysis(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
start_time: str = Query(..., description="冲洗开始时间(ISO 8601格式)"),
|
||||||
|
valves: List[str] | None = Query(None, description="参与控制的阀门ID列表(可选)"),
|
||||||
|
valves_k: List[float] | None = Query(
|
||||||
|
None, description="对应各阀门的开度列表(0-1,可选,与valves同时提供)"
|
||||||
|
),
|
||||||
|
valve_statuses: List[Literal["OPEN", "CLOSED", "ACTIVE"]] | None = Query(
|
||||||
|
None, description="对应各阀门的开关状态列表(OPEN、CLOSED或ACTIVE,可选)"
|
||||||
|
),
|
||||||
|
valve_settings: List[str] | None = Query(
|
||||||
|
None, description="对应各阀门的设置值列表(ACTIVE状态下必填)"
|
||||||
|
),
|
||||||
|
drainage_node_ID: str = Query(..., description="排污节点ID"),
|
||||||
|
flush_flow: float = Query(0, description="冲洗流量(L/s),0表示自动计算"),
|
||||||
|
duration: int | None = Query(None, description="模拟持续时间(秒),默认900秒"),
|
||||||
|
scheme_name: str = Query(..., description="冲洗方案名称"),
|
||||||
|
username: str = Depends(get_current_keycloak_username),
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
冲洗分析(高级版本)
|
||||||
|
|
||||||
|
- **network**: 管网名称(或数据库名称)
|
||||||
|
- **start_time**: 冲洗开始时间
|
||||||
|
- **valves**: 参与控制的阀门ID列表(可选)
|
||||||
|
- **valves_k**: 各阀门的开度列表(0-1,可选,与valves同时提供)
|
||||||
|
- **valve_statuses**: 各阀门的开关状态列表(OPEN、CLOSED或ACTIVE,可选)
|
||||||
|
- **valve_settings**: 各阀门的设置值列表(ACTIVE状态下必填)
|
||||||
|
- **drainage_node_ID**: 排污节点ID
|
||||||
|
- **flush_flow**: 冲洗流量(L/s)
|
||||||
|
- **duration**: 模拟持续时间(秒,可选,默认900)
|
||||||
|
- **scheme_name**: 冲洗方案名称
|
||||||
|
|
||||||
|
支持多阀联合冲洗操作。
|
||||||
|
"""
|
||||||
|
valve_opening = None
|
||||||
|
valve_control = None
|
||||||
|
if valve_statuses is not None and valves_k is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail="valve_statuses 和 valves_k 不能同时提供",
|
||||||
|
)
|
||||||
|
if valve_settings is not None and valve_statuses is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail="valve_settings 必须与 valve_statuses 同时提供",
|
||||||
|
)
|
||||||
|
if valves is None:
|
||||||
|
if (
|
||||||
|
valves_k is not None
|
||||||
|
or valve_statuses is not None
|
||||||
|
or valve_settings is not None
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail="阀门控制参数必须与 valves 同时提供",
|
||||||
|
)
|
||||||
|
elif valve_statuses is not None:
|
||||||
|
if len(valves) != len(valve_statuses):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422, detail="valves 和 valve_statuses 的数量必须一致"
|
||||||
|
)
|
||||||
|
if valve_settings is not None and len(valves) != len(valve_settings):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422, detail="valves 和 valve_settings 的数量必须一致"
|
||||||
|
)
|
||||||
|
|
||||||
|
settings = valve_settings or [""] * len(valves)
|
||||||
|
valve_control = {}
|
||||||
|
for valve_id, raw_status, raw_setting in zip(
|
||||||
|
valves, valve_statuses, settings
|
||||||
|
):
|
||||||
|
status = raw_status
|
||||||
|
setting = raw_setting.strip()
|
||||||
|
if status == "ACTIVE" and not setting:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"ACTIVE 状态的阀门 {valve_id} 必须提供设置值",
|
||||||
|
)
|
||||||
|
|
||||||
|
control: dict[str, str] = {"status": status}
|
||||||
|
if status == "ACTIVE":
|
||||||
|
control["setting"] = setting
|
||||||
|
valve_control[valve_id] = control
|
||||||
|
elif valves_k is not None:
|
||||||
|
if len(valves) != len(valves_k):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422, detail="valves 和 valves_k 的数量必须一致"
|
||||||
|
)
|
||||||
|
valve_opening = {
|
||||||
|
valve_id: float(valve_k)
|
||||||
|
for valve_id, valve_k in zip(valves, valves_k)
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail="提供 valves 时必须同时提供 valve_statuses 或 valves_k",
|
||||||
|
)
|
||||||
|
result = flushing_analysis(
|
||||||
|
name=network,
|
||||||
|
modify_pattern_start_time=start_time,
|
||||||
|
modify_total_duration=duration or 900,
|
||||||
|
modify_valve_opening=valve_opening,
|
||||||
|
valve_control=valve_control,
|
||||||
|
drainage_node_ID=drainage_node_ID,
|
||||||
|
flushing_flow=flush_flow,
|
||||||
|
scheme_name=scheme_name,
|
||||||
|
username=username,
|
||||||
|
)
|
||||||
|
return result or "success"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/contaminant-simulations", response_class=PlainTextResponse, summary="污染物模拟", description="对管网中的污染物扩散进行模拟,评估污染源对管网的影响范围和浓度分布。支持指定污染源位置、污染浓度和扩散模式。")
|
||||||
|
def fastapi_contaminant_simulation(
|
||||||
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
|
start_time: str = Query(..., description="污染开始时间(ISO 8601格式)"),
|
||||||
|
source: str = Query(..., description="污染源节点ID"),
|
||||||
|
concentration: float = Query(..., description="污染浓度(mg/L)"),
|
||||||
|
duration: int = Query(..., description="模拟持续时间(秒)"),
|
||||||
|
scheme_name: str = Query(..., description="模拟方案名称"),
|
||||||
|
pattern: str | None = Query(None, description="污染源模式ID(可选)"),
|
||||||
|
username: str = Depends(get_current_keycloak_username),
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
污染物模拟
|
||||||
|
|
||||||
|
- **network**: 管网名称(或数据库名称)
|
||||||
|
- **start_time**: 污染开始时间
|
||||||
|
- **source**: 污染源节点ID
|
||||||
|
- **concentration**: 污染浓度(mg/L)
|
||||||
|
- **duration**: 模拟持续时间(秒)
|
||||||
|
- **scheme_name**: 模拟方案名称
|
||||||
|
- **pattern**: 污染源模式ID(可选)
|
||||||
|
|
||||||
|
用于评估管网中污染物的传播和影响范围。
|
||||||
|
"""
|
||||||
|
result = contaminant_simulation(
|
||||||
|
name=network,
|
||||||
|
modify_pattern_start_time=start_time,
|
||||||
|
scheme_name=scheme_name,
|
||||||
|
modify_total_duration=duration,
|
||||||
|
source=source,
|
||||||
|
concentration=concentration,
|
||||||
|
source_pattern=pattern,
|
||||||
|
username=username,
|
||||||
|
)
|
||||||
|
return result or "success"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pressure-regulation-analyses", summary="压力调节(高级)", description="高级版本的压力调节分析,通过JSON请求体提供详细的控制参数,包括固定泵和变速泵的独立控制、水箱初始水位等。")
|
||||||
|
def fastapi_pressure_regulation(data: PressureRegulation = Body(..., description="压力调节控制参数")) -> str:
|
||||||
|
"""
|
||||||
|
压力调节(高级版本)
|
||||||
|
|
||||||
|
请求体参数:
|
||||||
|
- **network**: 管网名称(或数据库名称)
|
||||||
|
- **start_time**: 控制开始时间
|
||||||
|
- **pump_control**: 泵控制策略字典
|
||||||
|
- **tank_init_level**: 水箱初始水位字典(可选)
|
||||||
|
- **duration**: 模拟持续时间(秒,可选,默认900)
|
||||||
|
- **scheme_name**: 控制方案名称
|
||||||
|
|
||||||
|
支持固定泵和变速泵的独立控制。
|
||||||
|
"""
|
||||||
|
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():
|
||||||
|
if pump_id in variable_pumps:
|
||||||
|
variable_pump_pattern[pump_id] = values
|
||||||
|
else:
|
||||||
|
fixed_pump_pattern[pump_id] = values
|
||||||
|
pressure_regulation(
|
||||||
|
name=item["network"],
|
||||||
|
modify_pattern_start_time=item["start_time"],
|
||||||
|
modify_total_duration=item["duration"] or 900,
|
||||||
|
modify_tank_initial_level=item["tank_init_level"],
|
||||||
|
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("/simulation-runs", summary="手动运行日期指定模拟", description="根据指定的开始时间和持续时间,手动运行水力模拟。开始时间必须是显式带时区的 ISO 8601 / RFC3339 时间。")
|
||||||
|
def fastapi_run_simulation_manually_by_date(
|
||||||
|
data: RunSimulationManuallyByDate = Body(..., description="模拟运行参数"),
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
手动运行日期指定模拟
|
||||||
|
|
||||||
|
请求体参数:
|
||||||
|
- **name**: 管网名称(或数据库名称)
|
||||||
|
- **start_time**: 开始时间(ISO 8601 / RFC3339,必须显式带时区)
|
||||||
|
- **duration**: 模拟持续时间(分钟)
|
||||||
|
|
||||||
|
系统将从指定时间开始,按15分钟间隔多次运行模拟。
|
||||||
|
每次模拟间隔15分钟,直至达到指定的总持续时间。
|
||||||
|
"""
|
||||||
|
item = data.model_dump()
|
||||||
|
try:
|
||||||
|
start_time = parse_utc_time(item["start_time"], field_name="start_time")
|
||||||
|
run_simulation_manually_by_date(
|
||||||
|
item["name"], start_time, item["duration"]
|
||||||
|
)
|
||||||
|
return {"status": "success"}
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from psycopg import AsyncConnection
|
||||||
|
|
||||||
|
from app.infra.db.timescaledb.repositories.analysis import AnalysisResultsRepository
|
||||||
|
|
||||||
|
from .dependencies import get_timescale_connection
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/timeseries/analysis/runs/{run_id}/nodes/{node_id}")
|
||||||
|
async def get_analysis_node_series(
|
||||||
|
run_id: UUID,
|
||||||
|
node_id: str,
|
||||||
|
start_time: datetime = Query(...),
|
||||||
|
end_time: datetime = Query(...),
|
||||||
|
field: str = Query(...),
|
||||||
|
conn: AsyncConnection = Depends(get_timescale_connection),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await AnalysisResultsRepository.get_node_series(
|
||||||
|
conn, run_id, node_id, start_time, end_time, field
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/timeseries/analysis/runs/{run_id}/links/{link_id}")
|
||||||
|
async def get_analysis_link_series(
|
||||||
|
run_id: UUID,
|
||||||
|
link_id: str,
|
||||||
|
start_time: datetime = Query(...),
|
||||||
|
end_time: datetime = Query(...),
|
||||||
|
field: str = Query(...),
|
||||||
|
conn: AsyncConnection = Depends(get_timescale_connection),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await AnalysisResultsRepository.get_link_series(
|
||||||
|
conn, run_id, link_id, start_time, end_time, field
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/timeseries/analysis/runs/{run_id}/values")
|
||||||
|
async def get_analysis_values_at_time(
|
||||||
|
run_id: UUID,
|
||||||
|
result_time: datetime = Query(...),
|
||||||
|
element_type: str = Query(..., pattern="^(node|link)$"),
|
||||||
|
field: str = Query(...),
|
||||||
|
conn: AsyncConnection = Depends(get_timescale_connection),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await AnalysisResultsRepository.get_values_at_time(
|
||||||
|
conn, run_id, element_type, result_time, field
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from datetime import datetime
|
||||||
|
from psycopg import AsyncConnection
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from app.services.timeseries_analysis import TimeseriesAnalysisService
|
||||||
|
from .dependencies import get_timescale_connection, get_postgres_connection
|
||||||
|
|
||||||
|
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;为空时查询实时数据"),
|
||||||
|
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错误
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
device_ids_list = (
|
||||||
|
[id.strip() for id in device_ids.split(",") if id.strip()]
|
||||||
|
if device_ids
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
|
||||||
|
if run_id is not None:
|
||||||
|
result = await TimeseriesAnalysisService.get_scada_associated_analysis_simulation_data(
|
||||||
|
timescale_conn,
|
||||||
|
postgres_conn,
|
||||||
|
device_ids_list,
|
||||||
|
start_time,
|
||||||
|
end_time,
|
||||||
|
run_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result = (
|
||||||
|
await TimeseriesAnalysisService.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 TimeseriesAnalysisService.get_analysis_simulation_data(
|
||||||
|
timescale_conn,
|
||||||
|
feature_infos_list,
|
||||||
|
start_time,
|
||||||
|
end_time,
|
||||||
|
run_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result = await TimeseriesAnalysisService.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 TimeseriesAnalysisService.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))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/timeseries/scada-cleaning-runs", summary="清洗SCADA监测数据")
|
||||||
|
async def clean_scada_data(
|
||||||
|
device_ids: str = Query(..., description="设备ID列表或 'all' 表示清洗所有设备"),
|
||||||
|
start_time: datetime = Query(..., description="清洗数据的开始时间"),
|
||||||
|
end_time: datetime = Query(..., description="清洗数据的结束时间"),
|
||||||
|
timescale_conn: AsyncConnection = Depends(get_timescale_connection),
|
||||||
|
postgres_conn: AsyncConnection = Depends(get_postgres_connection),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
清洗SCADA监测数据
|
||||||
|
|
||||||
|
根据device_ids查询monitored_value,清洗后更新cleaned_value。
|
||||||
|
支持清洗指定设备或所有设备的数据。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_ids: 设备ID列表,用逗号分隔,或 'all' 表示清洗所有设备
|
||||||
|
start_time: 清洗数据的开始时间
|
||||||
|
end_time: 清洗数据的结束时间
|
||||||
|
timescale_conn: TimescaleDB连接
|
||||||
|
postgres_conn: PostgreSQL连接
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
清洗结果信息
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 当清洗过程出现错误时返回400错误
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if device_ids == "all":
|
||||||
|
device_ids_list = []
|
||||||
|
else:
|
||||||
|
device_ids_list = (
|
||||||
|
[id.strip() for id in device_ids.split(",") if id.strip()]
|
||||||
|
if device_ids
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
return await TimeseriesAnalysisService.clean_scada_data(
|
||||||
|
timescale_conn, postgres_conn, device_ids_list, start_time, end_time
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pipeline-health-predictions", summary="预测管道健康状况")
|
||||||
|
async def predict_pipeline_health(
|
||||||
|
query_time: datetime = Query(..., description="查询时间"),
|
||||||
|
timescale_conn: AsyncConnection = Depends(get_timescale_connection),
|
||||||
|
postgres_conn: AsyncConnection = Depends(get_postgres_connection),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
预测管道健康状况
|
||||||
|
|
||||||
|
根据管网名称和当前时间,查询管道信息和实时数据,
|
||||||
|
使用随机生存森林模型预测管道的生存概率。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query_time: 查询时间
|
||||||
|
timescale_conn: TimescaleDB连接
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
预测结果列表,每个元素包含 link_id 和对应的生存函数
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 当模型文件不存在返回404错误,其他错误返回400或500错误
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await TimeseriesAnalysisService.predict_pipeline_health(
|
||||||
|
timescale_conn, postgres_conn, query_time
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"内部服务器错误: {str(e)}")
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from fastapi import Depends
|
||||||
|
from psycopg import AsyncConnection
|
||||||
|
|
||||||
|
from app.auth.project_dependencies import (
|
||||||
|
get_project_pg_connection,
|
||||||
|
get_project_timescale_connection,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_timescale_connection(
|
||||||
|
conn: AsyncConnection = Depends(get_project_timescale_connection),
|
||||||
|
):
|
||||||
|
yield conn
|
||||||
|
|
||||||
|
|
||||||
|
async def get_postgres_connection(
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
):
|
||||||
|
yield conn
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
TIME_WITH_TZ_DESC = "ISO 8601 / RFC 3339 时间,必须显式带时区;可直接传 UTC+8,服务端会先转换为 UTC 再处理。"
|
||||||
|
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[RealtimeLinkBatchItem] = Body(..., description="同一时间点的管道快照数据"),
|
||||||
|
conn: AsyncConnection = Depends(get_timescale_connection)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
批量插入实时管道数据
|
||||||
|
|
||||||
|
将管道的实时监测数据批量插入时间序列数据库。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: 管道数据列表
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
插入成功的记录数
|
||||||
|
"""
|
||||||
|
await RealtimeRepository.insert_links_batch(
|
||||||
|
conn, [item.model_dump() for item in data]
|
||||||
|
)
|
||||||
|
return {"message": f"Inserted {len(data)} records"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/timeseries/realtime/links",
|
||||||
|
summary="查询实时管道数据",
|
||||||
|
description="按时间范围查询实时管道数据。start_time 和 end_time 必须显式带时区;允许传 UTC+8,服务端会先归一化为 UTC 再执行查询。",
|
||||||
|
)
|
||||||
|
async def get_realtime_links(
|
||||||
|
start_time: datetime = Query(..., description=TIME_RANGE_START_DESC),
|
||||||
|
end_time: datetime = Query(..., description=TIME_RANGE_END_DESC),
|
||||||
|
conn: AsyncConnection = Depends(get_timescale_connection),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
查询指定时间范围内的实时管道数据
|
||||||
|
|
||||||
|
根据时间范围查询所有实时管道的监测值。传入时间必须显式包含时区,
|
||||||
|
可以直接使用 UTC+8,服务端会先统一转换为 UTC 再参与数据库查询。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_time: 查询开始时间
|
||||||
|
end_time: 查询结束时间
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
实时管道数据列表
|
||||||
|
"""
|
||||||
|
return await RealtimeRepository.get_links_by_time_range(conn, start_time, end_time)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/timeseries/realtime/links",
|
||||||
|
summary="删除实时管道数据",
|
||||||
|
description="按时间范围删除实时管道数据。start_time 和 end_time 必须显式带时区;允许传 UTC+8,服务端按请求中的绝对时间删除对应 UTC 数据。",
|
||||||
|
)
|
||||||
|
async def delete_realtime_links(
|
||||||
|
start_time: datetime = Query(..., description=TIME_RANGE_START_DESC),
|
||||||
|
end_time: datetime = Query(..., description=TIME_RANGE_END_DESC),
|
||||||
|
conn: AsyncConnection = Depends(get_timescale_connection),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
删除指定时间范围内的实时管道数据
|
||||||
|
|
||||||
|
删除在指定时间范围内的所有实时管道监测数据。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_time: 删除开始时间
|
||||||
|
end_time: 删除结束时间
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
删除结果信息
|
||||||
|
"""
|
||||||
|
await RealtimeRepository.delete_links_by_time_range(conn, start_time, end_time)
|
||||||
|
return {"message": "Deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/timeseries/realtime/links/{link_id}/field", summary="更新实时管道字段")
|
||||||
|
async def update_realtime_link_field(
|
||||||
|
link_id: str = Path(..., description="管道ID"),
|
||||||
|
time: datetime = Query(..., description=f"要更新记录的时间戳。{TIME_WITH_TZ_DESC}"),
|
||||||
|
field: str = Query(..., description="要更新的字段名称"),
|
||||||
|
value: float = Query(..., description="更新的字段值"),
|
||||||
|
conn: AsyncConnection = Depends(get_timescale_connection),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
更新指定管道的字段值
|
||||||
|
|
||||||
|
更新实时管道在特定时间的某个字段数据。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
link_id: 管道ID
|
||||||
|
time: 数据时间戳
|
||||||
|
field: 字段名称
|
||||||
|
value: 字段新值
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
更新结果信息
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 当字段不存在或更新失败时返回400错误
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await RealtimeRepository.update_link_field(conn, time, link_id, field, value)
|
||||||
|
return {"message": "Updated successfully"}
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/timeseries/realtime/nodes/batches", status_code=201, summary="批量插入实时节点数据")
|
||||||
|
async def insert_realtime_nodes(
|
||||||
|
data: List[RealtimeNodeBatchItem] = Body(..., description="同一时间点的节点快照数据"),
|
||||||
|
conn: AsyncConnection = Depends(get_timescale_connection)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
批量插入实时节点数据
|
||||||
|
|
||||||
|
将节点的实时监测数据批量插入时间序列数据库。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: 节点数据列表
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
插入成功的记录数
|
||||||
|
"""
|
||||||
|
await RealtimeRepository.insert_nodes_batch(
|
||||||
|
conn, [item.model_dump() for item in data]
|
||||||
|
)
|
||||||
|
return {"message": f"Inserted {len(data)} records"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/timeseries/realtime/nodes",
|
||||||
|
summary="查询实时节点数据",
|
||||||
|
description="按时间范围查询实时节点数据。start_time 和 end_time 必须显式带时区;允许传 UTC+8,服务端会先归一化为 UTC 再执行查询。",
|
||||||
|
)
|
||||||
|
async def get_realtime_nodes(
|
||||||
|
start_time: datetime = Query(..., description=TIME_RANGE_START_DESC),
|
||||||
|
end_time: datetime = Query(..., description=TIME_RANGE_END_DESC),
|
||||||
|
conn: AsyncConnection = Depends(get_timescale_connection),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
查询指定时间范围内的实时节点数据
|
||||||
|
|
||||||
|
根据时间范围查询所有实时节点的监测值。传入时间必须显式包含时区,
|
||||||
|
可以直接使用 UTC+8,服务端会先统一转换为 UTC 再参与数据库查询。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_time: 查询开始时间
|
||||||
|
end_time: 查询结束时间
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
实时节点数据列表
|
||||||
|
"""
|
||||||
|
return await RealtimeRepository.get_nodes_by_time_range(conn, start_time, end_time)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/timeseries/realtime/nodes",
|
||||||
|
summary="删除实时节点数据",
|
||||||
|
description="按时间范围删除实时节点数据。start_time 和 end_time 必须显式带时区;允许传 UTC+8,服务端按请求中的绝对时间删除对应 UTC 数据。",
|
||||||
|
)
|
||||||
|
async def delete_realtime_nodes(
|
||||||
|
start_time: datetime = Query(..., description=TIME_RANGE_START_DESC),
|
||||||
|
end_time: datetime = Query(..., description=TIME_RANGE_END_DESC),
|
||||||
|
conn: AsyncConnection = Depends(get_timescale_connection),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
删除指定时间范围内的实时节点数据
|
||||||
|
|
||||||
|
删除在指定时间范围内的所有实时节点监测数据。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_time: 删除开始时间
|
||||||
|
end_time: 删除结束时间
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
删除结果信息
|
||||||
|
"""
|
||||||
|
await RealtimeRepository.delete_nodes_by_time_range(conn, start_time, end_time)
|
||||||
|
return {"message": "Deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/timeseries/realtime/simulation-results", status_code=201, summary="存储实时模拟结果")
|
||||||
|
async def store_realtime_simulation_result(
|
||||||
|
node_result_list: List[dict] = Body(..., description="节点模拟结果列表"),
|
||||||
|
link_result_list: List[dict] = Body(..., description="管道模拟结果列表"),
|
||||||
|
result_start_time: str = Query(..., description=f"模拟结果开始时间。{TIME_WITH_TZ_DESC}"),
|
||||||
|
conn: AsyncConnection = Depends(get_timescale_connection),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
存储实时模拟结果到时间序列数据库
|
||||||
|
|
||||||
|
将节点和管道的实时模拟计算结果批量存储到TimescaleDB数据库。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
node_result_list: 节点模拟结果列表
|
||||||
|
link_result_list: 管道模拟结果列表
|
||||||
|
result_start_time: 模拟结果对应的起始时间
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
存储结果信息
|
||||||
|
"""
|
||||||
|
await RealtimeRepository.store_realtime_simulation_result(
|
||||||
|
conn, node_result_list, link_result_list, result_start_time
|
||||||
|
)
|
||||||
|
return {"message": "Simulation results stored successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/timeseries/realtime/records",
|
||||||
|
summary="按时间和属性查询实时数据",
|
||||||
|
description="查询指定时间点的实时属性值。query_time 必须显式带时区;允许传 UTC+8,服务端会先归一化为 UTC 再执行查询。",
|
||||||
|
)
|
||||||
|
async def query_realtime_records_by_time_property(
|
||||||
|
query_time: str = Query(..., description=f"查询时间。{TIME_WITH_TZ_DESC}"),
|
||||||
|
type: str = Query(..., description="数据类型,pipe(管道)或 junction(节点)"),
|
||||||
|
property: str = Query(..., description="要查询的属性名称"),
|
||||||
|
conn: AsyncConnection = Depends(get_timescale_connection),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
按指定时间和属性查询所有实时监测数据
|
||||||
|
|
||||||
|
查询在特定时间点,所有指定类型元素的特定属性值。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query_time: 查询时间
|
||||||
|
type: 元素类型(pipe或junction)
|
||||||
|
property: 属性名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
查询结果列表
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 当查询参数无效时返回400错误
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
results = await RealtimeRepository.query_all_record_by_time_property(
|
||||||
|
conn, query_time, type, property
|
||||||
|
)
|
||||||
|
return {"results": results}
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/timeseries/realtime/simulation-results",
|
||||||
|
summary="按ID和时间查询实时模拟数据",
|
||||||
|
description="查询指定元素在某一时间点的实时模拟结果。query_time 必须显式带时区;允许传 UTC+8,服务端会先归一化为 UTC 再执行查询。",
|
||||||
|
)
|
||||||
|
async def query_realtime_simulation_by_id_time(
|
||||||
|
id: str = Query(..., description="元素ID(管道ID或节点ID)"),
|
||||||
|
type: str = Query(..., description="元素类型,pipe(管道)或 junction(节点)"),
|
||||||
|
query_time: str = Query(..., description=f"查询时间。{TIME_WITH_TZ_DESC}"),
|
||||||
|
conn: AsyncConnection = Depends(get_timescale_connection),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
按指定ID和时间查询实时模拟结果
|
||||||
|
|
||||||
|
查询特定元素在某一时间点的实时模拟数据。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
id: 元素ID
|
||||||
|
type: 元素类型(pipe或junction)
|
||||||
|
query_time: 查询时间
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
模拟结果数据
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 当查询参数无效时返回400错误
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
results = await RealtimeRepository.query_simulation_result_by_id_time(
|
||||||
|
conn, id, type, query_time
|
||||||
|
)
|
||||||
|
return {"result": results}
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
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.infra.db.postgresql.scada import ScadaInfoRepository
|
||||||
|
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
|
||||||
|
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[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监测数据
|
||||||
|
|
||||||
|
将多个设备的实时监测数据批量插入时间序列数据库。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: SCADA设备监测数据列表,每项包含device_id、时间戳和监测值等信息
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
插入成功的记录数
|
||||||
|
"""
|
||||||
|
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"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/timeseries/scada-readings", summary="按设备ID和时间范围查询SCADA数据")
|
||||||
|
async def get_scada_by_ids_time_range(
|
||||||
|
start_time: datetime = Query(..., description="查询开始时间"),
|
||||||
|
end_time: datetime = Query(..., description="查询结束时间"),
|
||||||
|
device_ids: str = Query(
|
||||||
|
..., description="设备ID列表,逗号分隔,如 'device1,device2,device3'"
|
||||||
|
),
|
||||||
|
conn: AsyncConnection = Depends(get_timescale_connection),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
按设备ID和时间范围查询SCADA监测数据
|
||||||
|
|
||||||
|
查询多个设备在指定时间范围内的所有监测数据。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_time: 查询开始时间
|
||||||
|
end_time: 查询结束时间
|
||||||
|
device_ids: 设备ID列表,用逗号分隔
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SCADA监测数据列表
|
||||||
|
"""
|
||||||
|
device_ids_list = (
|
||||||
|
[id.strip() for id in device_ids.split(",") if id.strip()] if device_ids else []
|
||||||
|
)
|
||||||
|
return await ScadaRepository.get_scada_by_ids_time_range(
|
||||||
|
conn, device_ids_list, start_time, end_time
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/timeseries/scada-readings/fields", summary="按设备ID、字段和时间范围查询SCADA数据"
|
||||||
|
)
|
||||||
|
async def get_scada_field_by_ids_time_range(
|
||||||
|
start_time: datetime = Query(..., description="查询开始时间"),
|
||||||
|
end_time: datetime = Query(..., description="查询结束时间"),
|
||||||
|
field: str = Query(..., description="要查询的字段名称"),
|
||||||
|
device_ids: str = Query(
|
||||||
|
..., description="设备ID列表,逗号分隔,如 'device1,device2,device3'"
|
||||||
|
),
|
||||||
|
conn: AsyncConnection = Depends(get_timescale_connection),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
按设备ID、字段和时间范围查询特定SCADA数据
|
||||||
|
|
||||||
|
查询多个设备在指定时间范围内的特定字段监测数据。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_time: 查询开始时间
|
||||||
|
end_time: 查询结束时间
|
||||||
|
field: 字段名称
|
||||||
|
device_ids: 设备ID列表,用逗号分隔
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SCADA字段数据列表
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 当字段不存在或查询参数无效时返回400错误
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
device_ids_list = (
|
||||||
|
[id.strip() for id in device_ids.split(",") if id.strip()]
|
||||||
|
if device_ids
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
return await ScadaRepository.get_scada_field_by_id_time_range(
|
||||||
|
conn, device_ids_list, start_time, end_time, field
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/timeseries/scada-readings/{device_id}/field", summary="更新SCADA设备字段")
|
||||||
|
async def update_scada_field(
|
||||||
|
device_id: str = Path(..., description="设备ID"),
|
||||||
|
time: datetime = Query(..., description="更新数据的时间戳"),
|
||||||
|
field: str = Query(..., description="要更新的字段名称"),
|
||||||
|
value: float = Query(..., description="更新的字段值"),
|
||||||
|
conn: AsyncConnection = Depends(get_timescale_connection),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
更新指定设备的字段值
|
||||||
|
|
||||||
|
更新SCADA设备在特定时间的某个字段监测数据。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_id: 设备ID
|
||||||
|
time: 数据时间戳
|
||||||
|
field: 字段名称
|
||||||
|
value: 字段新值
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
更新结果信息
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 当字段不存在或更新失败时返回400错误
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await ScadaRepository.update_scada_field(conn, time, device_id, field, value)
|
||||||
|
return {"message": "Updated successfully"}
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/timeseries/scada-readings", summary="按设备ID和时间范围删除SCADA数据")
|
||||||
|
async def delete_scada_data(
|
||||||
|
device_id: str = Query(..., description="设备ID"),
|
||||||
|
start_time: datetime = Query(..., description="删除开始时间"),
|
||||||
|
end_time: datetime = Query(..., description="删除结束时间"),
|
||||||
|
conn: AsyncConnection = Depends(get_timescale_connection),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
删除指定设备和时间范围内的SCADA数据
|
||||||
|
|
||||||
|
删除在指定时间范围内的特定设备监测数据。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_id: 设备ID
|
||||||
|
start_time: 删除开始时间
|
||||||
|
end_time: 删除结束时间
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
删除结果信息
|
||||||
|
"""
|
||||||
|
await ScadaRepository.delete_scada_by_id_time_range(
|
||||||
|
conn, device_id, start_time, end_time
|
||||||
|
)
|
||||||
|
return {"message": "Deleted successfully"}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
|
||||||
|
from app.services.web_search import (
|
||||||
|
BochaSearchAPIError,
|
||||||
|
BochaSearchConfigError,
|
||||||
|
WebSearchRequest,
|
||||||
|
search_bocha_web,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/web-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
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import re
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from copy import copy
|
||||||
|
from functools import wraps
|
||||||
|
from typing import Any, Generic, TypeVar, get_args, get_origin
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
from fastapi.routing import APIRoute
|
||||||
|
from pydantic import BaseModel, JsonValue, create_model
|
||||||
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
from app.api.problem_details import ProblemDetails
|
||||||
|
from app.api.pagination import PaginatedList
|
||||||
|
from app.api.v1.router import api_router as handler_api_router
|
||||||
|
from app.auth.metadata_dependencies import get_current_metadata_user
|
||||||
|
from app.auth.project_dependencies import (
|
||||||
|
ProjectContext,
|
||||||
|
get_project_business_routing,
|
||||||
|
get_project_context,
|
||||||
|
get_project_simulation_routing,
|
||||||
|
)
|
||||||
|
from app.infra.db.project_routing import (
|
||||||
|
ActiveProjectRouting,
|
||||||
|
activate_project_routing,
|
||||||
|
)
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
class Page(BaseModel, Generic[T]):
|
||||||
|
items: list[T]
|
||||||
|
total: int
|
||||||
|
limit: int
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
_DERIVE_USERNAME = {
|
||||||
|
"pressure_sensor_placement_sensitivity_endpoint": "username",
|
||||||
|
"pressure_sensor_placement_kmeans_endpoint": "username",
|
||||||
|
"fastapi_pressure_sensor_placement": "user_name",
|
||||||
|
}
|
||||||
|
_PUBLIC_PARAMETER_RENAMES = {
|
||||||
|
"burst_ID": "burst_id",
|
||||||
|
"drainage_node_ID": "drainage_node_id",
|
||||||
|
}
|
||||||
|
_MODEL_NAME_IS_NETWORK = {"RunSimulationManuallyByDate", "PressureSensorPlacement"}
|
||||||
|
_MODEL_USERNAME_FROM_AUTH = {"PressureSensorPlacement"}
|
||||||
|
_TIMESCALE_ROUTED_ENDPOINT_MODULES = {
|
||||||
|
"app.api.v1.endpoints.burst_detection",
|
||||||
|
"app.api.v1.endpoints.burst_location",
|
||||||
|
"app.api.v1.endpoints.leakage",
|
||||||
|
"app.api.v1.endpoints.simulation",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_name(name: str) -> str:
|
||||||
|
for prefix in ("fastapi_", "fast_"):
|
||||||
|
if name.startswith(prefix):
|
||||||
|
name = name[len(prefix) :]
|
||||||
|
break
|
||||||
|
if name.endswith("_endpoint"):
|
||||||
|
name = name[: -len("_endpoint")]
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _rest_body_model(annotation):
|
||||||
|
if not inspect.isclass(annotation) or not issubclass(annotation, BaseModel):
|
||||||
|
return None
|
||||||
|
project_fields = {
|
||||||
|
name
|
||||||
|
for name in ("network", "network_name")
|
||||||
|
if name in annotation.model_fields
|
||||||
|
}
|
||||||
|
if annotation.__name__ in _MODEL_NAME_IS_NETWORK and "name" in annotation.model_fields:
|
||||||
|
project_fields.add("name")
|
||||||
|
username_fields = (
|
||||||
|
{
|
||||||
|
name
|
||||||
|
for name in ("username", "user_name")
|
||||||
|
if name in annotation.model_fields
|
||||||
|
}
|
||||||
|
if annotation.__name__ in _MODEL_USERNAME_FROM_AUTH
|
||||||
|
else set()
|
||||||
|
)
|
||||||
|
excluded_fields = project_fields | username_fields
|
||||||
|
if not excluded_fields:
|
||||||
|
return None
|
||||||
|
|
||||||
|
public_fields = {
|
||||||
|
name: (field.annotation, copy(field))
|
||||||
|
for name, field in annotation.model_fields.items()
|
||||||
|
if name not in excluded_fields
|
||||||
|
}
|
||||||
|
public_model = create_model(
|
||||||
|
f"{annotation.__name__}Rest",
|
||||||
|
__module__=annotation.__module__,
|
||||||
|
**public_fields,
|
||||||
|
)
|
||||||
|
return annotation, public_model, project_fields, username_fields
|
||||||
|
|
||||||
|
|
||||||
|
def _with_header_project_context(endpoint, route_name: str):
|
||||||
|
signature = inspect.signature(endpoint)
|
||||||
|
network_parameters = [
|
||||||
|
name for name in ("network", "network_name") if name in signature.parameters
|
||||||
|
]
|
||||||
|
if route_name in _NAME_IS_NETWORK and "name" in signature.parameters:
|
||||||
|
network_parameters.append("name")
|
||||||
|
username_parameter = _DERIVE_USERNAME.get(route_name)
|
||||||
|
parameter_renames = {
|
||||||
|
internal: public
|
||||||
|
for internal, public in _PUBLIC_PARAMETER_RENAMES.items()
|
||||||
|
if internal in signature.parameters
|
||||||
|
}
|
||||||
|
body_models = {
|
||||||
|
name: body_model
|
||||||
|
for name, parameter in signature.parameters.items()
|
||||||
|
if (body_model := _rest_body_model(parameter.annotation)) is not None
|
||||||
|
}
|
||||||
|
model_has_username = any(model[3] for model in body_models.values())
|
||||||
|
if (
|
||||||
|
not network_parameters
|
||||||
|
and not username_parameter
|
||||||
|
and not parameter_renames
|
||||||
|
and not body_models
|
||||||
|
):
|
||||||
|
return endpoint
|
||||||
|
|
||||||
|
existing_context_parameter = next(
|
||||||
|
(
|
||||||
|
name
|
||||||
|
for name, parameter in signature.parameters.items()
|
||||||
|
if parameter.annotation is ProjectContext
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
injected_context_name = existing_context_parameter or "_rest_project_context"
|
||||||
|
injected_routing_name = "_rest_project_routing"
|
||||||
|
injected_user_name = "_rest_current_user"
|
||||||
|
|
||||||
|
@wraps(endpoint)
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
project_routing = kwargs.pop(injected_routing_name, None)
|
||||||
|
if not isinstance(project_routing, ActiveProjectRouting):
|
||||||
|
raise RuntimeError("REST project database routing was not resolved")
|
||||||
|
project_context = kwargs.get(injected_context_name)
|
||||||
|
if not isinstance(project_context, ProjectContext):
|
||||||
|
raise RuntimeError("REST project context was not resolved")
|
||||||
|
if not existing_context_parameter:
|
||||||
|
kwargs.pop(injected_context_name, None)
|
||||||
|
for parameter_name in network_parameters:
|
||||||
|
kwargs[parameter_name] = project_context.project_code
|
||||||
|
if username_parameter:
|
||||||
|
kwargs[username_parameter] = kwargs[injected_user_name].username
|
||||||
|
kwargs.pop(injected_user_name, None)
|
||||||
|
for internal_name, public_name in parameter_renames.items():
|
||||||
|
kwargs[internal_name] = kwargs.pop(public_name)
|
||||||
|
for parameter_name, (
|
||||||
|
original_model,
|
||||||
|
_public_model,
|
||||||
|
project_fields,
|
||||||
|
username_fields,
|
||||||
|
) in body_models.items():
|
||||||
|
data = kwargs[parameter_name].model_dump()
|
||||||
|
data.update(
|
||||||
|
{field_name: project_context.project_code for field_name in project_fields}
|
||||||
|
)
|
||||||
|
if username_fields:
|
||||||
|
current_user = kwargs[injected_user_name]
|
||||||
|
data.update(
|
||||||
|
{field_name: current_user.username for field_name in username_fields}
|
||||||
|
)
|
||||||
|
kwargs[parameter_name] = original_model.model_validate(data)
|
||||||
|
if model_has_username:
|
||||||
|
kwargs.pop(injected_user_name, None)
|
||||||
|
with activate_project_routing(project_routing):
|
||||||
|
return await _call_endpoint(endpoint, *args, **kwargs)
|
||||||
|
|
||||||
|
parameters = []
|
||||||
|
for name, parameter in signature.parameters.items():
|
||||||
|
if name in network_parameters or name == username_parameter:
|
||||||
|
continue
|
||||||
|
public_name = parameter_renames.get(name, name)
|
||||||
|
if public_name != name:
|
||||||
|
default = copy(parameter.default)
|
||||||
|
default.alias = public_name
|
||||||
|
default.validation_alias = public_name
|
||||||
|
default.serialization_alias = public_name
|
||||||
|
parameter = parameter.replace(name=public_name, default=default)
|
||||||
|
if name in body_models:
|
||||||
|
parameter = parameter.replace(annotation=body_models[name][1])
|
||||||
|
parameters.append(parameter)
|
||||||
|
routing_dependency = (
|
||||||
|
get_project_simulation_routing
|
||||||
|
if (
|
||||||
|
endpoint.__module__ in _TIMESCALE_ROUTED_ENDPOINT_MODULES
|
||||||
|
)
|
||||||
|
else get_project_business_routing
|
||||||
|
)
|
||||||
|
if not existing_context_parameter:
|
||||||
|
parameters.append(
|
||||||
|
inspect.Parameter(
|
||||||
|
injected_context_name,
|
||||||
|
kind=inspect.Parameter.KEYWORD_ONLY,
|
||||||
|
annotation=ProjectContext,
|
||||||
|
default=Depends(get_project_context),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
parameters.append(
|
||||||
|
inspect.Parameter(
|
||||||
|
injected_routing_name,
|
||||||
|
kind=inspect.Parameter.KEYWORD_ONLY,
|
||||||
|
annotation=ActiveProjectRouting,
|
||||||
|
default=Depends(routing_dependency),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if username_parameter or model_has_username:
|
||||||
|
parameters.append(
|
||||||
|
inspect.Parameter(
|
||||||
|
injected_user_name,
|
||||||
|
kind=inspect.Parameter.KEYWORD_ONLY,
|
||||||
|
default=Depends(get_current_metadata_user),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
wrapper.__signature__ = signature.replace(parameters=parameters)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def _with_pagination(endpoint):
|
||||||
|
signature = inspect.signature(endpoint)
|
||||||
|
handler_limit_parameter = "limit" if "limit" in signature.parameters else None
|
||||||
|
handler_offset_parameter = next(
|
||||||
|
(
|
||||||
|
parameter_name
|
||||||
|
for parameter_name in ("offset", "skip")
|
||||||
|
if parameter_name in signature.parameters
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
handler_handles_pagination = bool(
|
||||||
|
handler_limit_parameter or handler_offset_parameter
|
||||||
|
)
|
||||||
|
|
||||||
|
@wraps(endpoint)
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
if handler_handles_pagination:
|
||||||
|
limit = kwargs.get(handler_limit_parameter, 0)
|
||||||
|
offset = kwargs.get(handler_offset_parameter, 0)
|
||||||
|
else:
|
||||||
|
limit = kwargs.pop("_rest_limit")
|
||||||
|
offset = kwargs.pop("_rest_offset")
|
||||||
|
result = await _call_endpoint(endpoint, *args, **kwargs)
|
||||||
|
if not isinstance(result, list):
|
||||||
|
return result
|
||||||
|
if handler_handles_pagination:
|
||||||
|
if not isinstance(result, PaginatedList):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Paginated handler {endpoint.__name__!r} must return "
|
||||||
|
"PaginatedList with the real total"
|
||||||
|
)
|
||||||
|
return Page(
|
||||||
|
items=result,
|
||||||
|
total=result.total,
|
||||||
|
limit=limit or len(result),
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
return Page(
|
||||||
|
items=result[offset : offset + limit],
|
||||||
|
total=len(result),
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
parameters = list(signature.parameters.values())
|
||||||
|
if not handler_handles_pagination:
|
||||||
|
parameters.extend(
|
||||||
|
[
|
||||||
|
inspect.Parameter(
|
||||||
|
"_rest_limit",
|
||||||
|
kind=inspect.Parameter.KEYWORD_ONLY,
|
||||||
|
annotation=int,
|
||||||
|
default=Query(100, ge=1, le=1000, alias="limit"),
|
||||||
|
),
|
||||||
|
inspect.Parameter(
|
||||||
|
"_rest_offset",
|
||||||
|
kind=inspect.Parameter.KEYWORD_ONLY,
|
||||||
|
annotation=int,
|
||||||
|
default=Query(0, ge=0, alias="offset"),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
wrapper.__signature__ = signature.replace(parameters=parameters)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def _with_jsonable_response(endpoint):
|
||||||
|
"""Normalize untyped handler results before JsonValue validation."""
|
||||||
|
|
||||||
|
@wraps(endpoint)
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
result = await _call_endpoint(endpoint, *args, **kwargs)
|
||||||
|
if isinstance(result, Response):
|
||||||
|
return result
|
||||||
|
return jsonable_encoder(result)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def _adapt_route(route: APIRoute) -> APIRoute:
|
||||||
|
methods = route.methods or set()
|
||||||
|
if len(methods) != 1:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"REST route {route.name!r} must declare exactly one HTTP method"
|
||||||
|
)
|
||||||
|
method = next(iter(methods))
|
||||||
|
responses = dict(route.responses or {})
|
||||||
|
for status_code, description in (
|
||||||
|
(401, "Authentication required"),
|
||||||
|
(403, "Insufficient permission"),
|
||||||
|
(404, "Resource not found"),
|
||||||
|
(409, "Resource conflict"),
|
||||||
|
(422, "Validation error"),
|
||||||
|
(503, "Dependency unavailable"),
|
||||||
|
):
|
||||||
|
responses.setdefault(
|
||||||
|
status_code,
|
||||||
|
{"model": ProblemDetails, "description": description},
|
||||||
|
)
|
||||||
|
|
||||||
|
endpoint = _with_header_project_context(route.endpoint, route.name)
|
||||||
|
response_model = route.response_model
|
||||||
|
has_untyped_response = response_model is None
|
||||||
|
if has_untyped_response:
|
||||||
|
endpoint = _with_jsonable_response(endpoint)
|
||||||
|
if get_origin(response_model) is list:
|
||||||
|
item_type = get_args(response_model)[0] if get_args(response_model) else JsonValue
|
||||||
|
response_model = Page[item_type]
|
||||||
|
endpoint = _with_pagination(endpoint)
|
||||||
|
|
||||||
|
clean_name = _clean_name(route.name)
|
||||||
|
creates_resource = clean_name.startswith(
|
||||||
|
("add_", "create_", "copy_", "import_", "insert_", "store_", "take_", "upload_")
|
||||||
|
) or route.name == "fastapi_pressure_sensor_placement"
|
||||||
|
status_code = (
|
||||||
|
204
|
||||||
|
if method == "DELETE"
|
||||||
|
else 201
|
||||||
|
if method == "POST" and creates_resource
|
||||||
|
else route.status_code
|
||||||
|
)
|
||||||
|
if status_code == 204:
|
||||||
|
response_model = None
|
||||||
|
elif response_model is None:
|
||||||
|
response_model = JsonValue
|
||||||
|
|
||||||
|
return APIRoute(
|
||||||
|
path=route.path,
|
||||||
|
endpoint=endpoint,
|
||||||
|
response_model=response_model,
|
||||||
|
status_code=status_code,
|
||||||
|
tags=route.tags,
|
||||||
|
dependencies=route.dependencies,
|
||||||
|
summary=route.summary,
|
||||||
|
description=route.description,
|
||||||
|
response_description=route.response_description,
|
||||||
|
responses=responses,
|
||||||
|
deprecated=False,
|
||||||
|
name=route.name,
|
||||||
|
methods={method},
|
||||||
|
operation_id=f"{method.lower()}_{re.sub(r'[^a-z0-9]+', '_', route.path).strip('_')}",
|
||||||
|
response_model_include=route.response_model_include,
|
||||||
|
response_model_exclude=route.response_model_exclude,
|
||||||
|
response_model_by_alias=route.response_model_by_alias,
|
||||||
|
response_model_exclude_unset=route.response_model_exclude_unset,
|
||||||
|
response_model_exclude_defaults=route.response_model_exclude_defaults,
|
||||||
|
response_model_exclude_none=route.response_model_exclude_none,
|
||||||
|
include_in_schema=route.include_in_schema,
|
||||||
|
response_class=route.response_class,
|
||||||
|
callbacks=route.callbacks,
|
||||||
|
openapi_extra=route.openapi_extra,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_rest_router(routes: Iterable[Any]) -> APIRouter:
|
||||||
|
router = APIRouter()
|
||||||
|
seen: dict[tuple[str, str], APIRoute] = {}
|
||||||
|
operation_ids: set[str] = set()
|
||||||
|
|
||||||
|
for route in routes:
|
||||||
|
if not isinstance(route, APIRoute):
|
||||||
|
continue
|
||||||
|
|
||||||
|
methods = route.methods or set()
|
||||||
|
if len(methods) != 1:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"REST route {route.name!r} must declare exactly one HTTP method"
|
||||||
|
)
|
||||||
|
method = next(iter(methods))
|
||||||
|
key = (method, route.path)
|
||||||
|
if key in seen:
|
||||||
|
previous = seen[key]
|
||||||
|
raise RuntimeError(
|
||||||
|
"REST route collision for "
|
||||||
|
f"{method} {route.path}: {previous.name!r} and {route.name!r}."
|
||||||
|
)
|
||||||
|
|
||||||
|
adapted = _adapt_route(route)
|
||||||
|
if adapted.operation_id in operation_ids:
|
||||||
|
adapted.operation_id = f"{adapted.operation_id}_{route.name}"
|
||||||
|
seen[key] = route
|
||||||
|
operation_ids.add(adapted.operation_id or "")
|
||||||
|
router.routes.append(adapted)
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
api_router = build_rest_router(handler_api_router.routes)
|
||||||
+189
-13
@@ -1,20 +1,196 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from app.api.v1.endpoints import (
|
from app.api.v1.endpoints import (
|
||||||
auth,
|
access,
|
||||||
|
admin_metadata,
|
||||||
|
agent_auth,
|
||||||
|
audit,
|
||||||
|
burst_detection,
|
||||||
|
burst_location,
|
||||||
|
geocoding,
|
||||||
|
leakage,
|
||||||
|
meta,
|
||||||
|
model_import,
|
||||||
project,
|
project,
|
||||||
network_elements,
|
project_data,
|
||||||
simulation,
|
|
||||||
scada,
|
scada,
|
||||||
extension,
|
sensor_placement,
|
||||||
snapshots
|
simulation,
|
||||||
|
web_search,
|
||||||
|
)
|
||||||
|
from app.api.v1.endpoints.components import (
|
||||||
|
controls,
|
||||||
|
curves,
|
||||||
|
options,
|
||||||
|
patterns,
|
||||||
|
quality,
|
||||||
|
visuals,
|
||||||
|
)
|
||||||
|
from app.api.v1.endpoints.network import (
|
||||||
|
demands,
|
||||||
|
general,
|
||||||
|
geometry,
|
||||||
|
junctions,
|
||||||
|
pipes,
|
||||||
|
pumps,
|
||||||
|
regions,
|
||||||
|
reservoirs,
|
||||||
|
tags,
|
||||||
|
tanks,
|
||||||
|
valves,
|
||||||
|
)
|
||||||
|
from app.api.v1.endpoints.timeseries import (
|
||||||
|
analysis as ts_analysis,
|
||||||
|
composite as ts_composite,
|
||||||
|
realtime as ts_realtime,
|
||||||
|
scada as ts_scada,
|
||||||
|
)
|
||||||
|
from app.auth.permissions import (
|
||||||
|
BURST_RUN,
|
||||||
|
OPTIMIZATION_RUN,
|
||||||
|
SCADA_CLEAN,
|
||||||
|
SCADA_VIEW,
|
||||||
|
SIMULATION_RUN,
|
||||||
|
SIMULATION_VIEW,
|
||||||
|
WEBGIS_EDIT,
|
||||||
|
WEBGIS_VIEW,
|
||||||
|
require_method_permission,
|
||||||
|
require_permission,
|
||||||
)
|
)
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
webgis_access = Depends(
|
||||||
api_router.include_router(project.router, prefix="/projects", tags=["projects"])
|
require_method_permission(
|
||||||
api_router.include_router(network_elements.router, prefix="/elements", tags=["network-elements"])
|
read_permission=WEBGIS_VIEW,
|
||||||
api_router.include_router(simulation.router, prefix="/simulation", tags=["simulation"])
|
write_permission=WEBGIS_EDIT,
|
||||||
api_router.include_router(scada.router, prefix="/scada", tags=["scada"])
|
)
|
||||||
api_router.include_router(extension.router, prefix="/extension", tags=["extension"])
|
)
|
||||||
api_router.include_router(snapshots.router, prefix="/snapshots", tags=["snapshots"])
|
scada_access = Depends(
|
||||||
|
require_method_permission(
|
||||||
|
read_permission=SCADA_VIEW,
|
||||||
|
write_permission=SCADA_CLEAN,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
simulation_access = Depends(
|
||||||
|
require_method_permission(
|
||||||
|
read_permission=SIMULATION_VIEW,
|
||||||
|
write_permission=SIMULATION_RUN,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
webgis_view_access = Depends(require_permission(WEBGIS_VIEW))
|
||||||
|
simulation_run_access = Depends(require_permission(SIMULATION_RUN))
|
||||||
|
burst_run_access = Depends(require_permission(BURST_RUN))
|
||||||
|
optimization_run_access = Depends(require_permission(OPTIMIZATION_RUN))
|
||||||
|
|
||||||
|
# Core services
|
||||||
|
api_router.include_router(access.router, tags=["Access Control"])
|
||||||
|
api_router.include_router(agent_auth.router, tags=["Agent Auth"])
|
||||||
|
api_router.include_router(
|
||||||
|
admin_metadata.router,
|
||||||
|
tags=["Metadata Admin"],
|
||||||
|
)
|
||||||
|
api_router.include_router(model_import.router, tags=["Model Administration"])
|
||||||
|
api_router.include_router(audit.router, tags=["Audit Logs"])
|
||||||
|
api_router.include_router(meta.router, tags=["Metadata"])
|
||||||
|
api_router.include_router(
|
||||||
|
project.router,
|
||||||
|
tags=["Project"],
|
||||||
|
dependencies=[webgis_access],
|
||||||
|
)
|
||||||
|
|
||||||
|
# WebGIS data
|
||||||
|
for endpoint_router, tag in (
|
||||||
|
(general.router, "Network General"),
|
||||||
|
(junctions.router, "Junctions"),
|
||||||
|
(reservoirs.router, "Reservoirs"),
|
||||||
|
(tanks.router, "Tanks"),
|
||||||
|
(pipes.router, "Pipes"),
|
||||||
|
(pumps.router, "Pumps"),
|
||||||
|
(valves.router, "Valves"),
|
||||||
|
(tags.router, "Tags"),
|
||||||
|
(demands.router, "Demands"),
|
||||||
|
(geometry.router, "Geometry & Coordinates"),
|
||||||
|
(regions.router, "Regions & DMAs"),
|
||||||
|
(curves.router, "Curves"),
|
||||||
|
(patterns.router, "Patterns"),
|
||||||
|
(controls.router, "Controls & Rules"),
|
||||||
|
(options.router, "Options"),
|
||||||
|
(quality.router, "Quality"),
|
||||||
|
(visuals.router, "Visuals"),
|
||||||
|
):
|
||||||
|
api_router.include_router(
|
||||||
|
endpoint_router,
|
||||||
|
tags=[tag],
|
||||||
|
dependencies=[webgis_access],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Simulation and analysis
|
||||||
|
api_router.include_router(
|
||||||
|
simulation.router,
|
||||||
|
tags=["Simulation Control"],
|
||||||
|
dependencies=[simulation_run_access],
|
||||||
|
)
|
||||||
|
api_router.include_router(
|
||||||
|
scada.router,
|
||||||
|
tags=["SCADA Metadata"],
|
||||||
|
dependencies=[scada_access],
|
||||||
|
)
|
||||||
|
api_router.include_router(
|
||||||
|
sensor_placement.router,
|
||||||
|
tags=["Sensor Placement"],
|
||||||
|
dependencies=[optimization_run_access],
|
||||||
|
)
|
||||||
|
api_router.include_router(
|
||||||
|
web_search.router,
|
||||||
|
tags=["Web Search"],
|
||||||
|
dependencies=[webgis_view_access],
|
||||||
|
)
|
||||||
|
api_router.include_router(
|
||||||
|
geocoding.router,
|
||||||
|
tags=["Geocoding"],
|
||||||
|
dependencies=[webgis_view_access],
|
||||||
|
)
|
||||||
|
api_router.include_router(
|
||||||
|
leakage.router,
|
||||||
|
tags=["Leakage"],
|
||||||
|
dependencies=[burst_run_access],
|
||||||
|
)
|
||||||
|
api_router.include_router(
|
||||||
|
burst_detection.router,
|
||||||
|
tags=["Burst Detection"],
|
||||||
|
dependencies=[burst_run_access],
|
||||||
|
)
|
||||||
|
api_router.include_router(
|
||||||
|
burst_location.router,
|
||||||
|
tags=["Burst Location"],
|
||||||
|
dependencies=[burst_run_access],
|
||||||
|
)
|
||||||
|
|
||||||
|
# TimescaleDB data
|
||||||
|
for endpoint_router, tag in (
|
||||||
|
(ts_realtime.router, "TimescaleDB - Realtime"),
|
||||||
|
(ts_analysis.router, "TimescaleDB - Analysis"),
|
||||||
|
):
|
||||||
|
api_router.include_router(
|
||||||
|
endpoint_router,
|
||||||
|
tags=[tag],
|
||||||
|
dependencies=[simulation_access],
|
||||||
|
)
|
||||||
|
|
||||||
|
for endpoint_router, tag in (
|
||||||
|
(ts_scada.router, "TimescaleDB - SCADA"),
|
||||||
|
(ts_composite.router, "TimescaleDB - Composite"),
|
||||||
|
):
|
||||||
|
api_router.include_router(
|
||||||
|
endpoint_router,
|
||||||
|
tags=[tag],
|
||||||
|
dependencies=[scada_access],
|
||||||
|
)
|
||||||
|
|
||||||
|
api_router.include_router(
|
||||||
|
project_data.router,
|
||||||
|
tags=["Project Data"],
|
||||||
|
dependencies=[webgis_view_access],
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""
|
||||||
|
This module is reserved for future implementation of audit logging and compliance features.
|
||||||
|
|
||||||
|
Current implementation of audit logging can be found in `app.core.audit`.
|
||||||
|
Future expansion may include:
|
||||||
|
- Comprehensive audit trails for all system actions
|
||||||
|
- Compliance reporting (e.g., for industrial control systems)
|
||||||
|
- Anomaly detection in user behavior
|
||||||
|
"""
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
from fastapi import Depends, HTTPException, status
|
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
|
||||||
from app.core.config import settings
|
|
||||||
from jose import jwt, JWTError
|
|
||||||
|
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/login/access-token")
|
|
||||||
|
|
||||||
async def get_current_user(token: str = Depends(oauth2_scheme)):
|
|
||||||
credentials_exception = HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Could not validate credentials",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
|
||||||
username: str = payload.get("sub")
|
|
||||||
if username is None:
|
|
||||||
raise credentials_exception
|
|
||||||
except JWTError:
|
|
||||||
raise credentials_exception
|
|
||||||
return username
|
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import time
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
oauth2_optional = OAuth2PasswordBearer(
|
||||||
|
tokenUrl="keycloak", auto_error=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_keycloak_token(token: str) -> dict:
|
||||||
|
if not settings.KEYCLOAK_PUBLIC_KEY:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Keycloak public key is not configured",
|
||||||
|
)
|
||||||
|
|
||||||
|
key = settings.KEYCLOAK_PUBLIC_KEY.replace("\\n", "\n")
|
||||||
|
|
||||||
|
payload = jwt.decode(
|
||||||
|
token,
|
||||||
|
key,
|
||||||
|
algorithms=[settings.KEYCLOAK_ALGORITHM],
|
||||||
|
audience=settings.KEYCLOAK_AUDIENCE or None,
|
||||||
|
)
|
||||||
|
if settings.KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS <= 0:
|
||||||
|
return payload
|
||||||
|
|
||||||
|
issued_at = payload.get("iat")
|
||||||
|
if not isinstance(issued_at, (int, float)) or (
|
||||||
|
time.time() >= issued_at + settings.KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS
|
||||||
|
):
|
||||||
|
raise JWTError("Keycloak access token is older than the allowed maximum age")
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_keycloak_payload(
|
||||||
|
token: str | None = Depends(oauth2_optional),
|
||||||
|
) -> dict:
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Not authenticated",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return _decode_keycloak_token(token)
|
||||||
|
except JWTError as exc:
|
||||||
|
# logger.warning("Keycloak token validation failed: %s", exc)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid token",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_keycloak_sub(
|
||||||
|
payload: dict = Depends(get_current_keycloak_payload),
|
||||||
|
) -> UUID:
|
||||||
|
sub = payload.get("sub")
|
||||||
|
if not sub:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Missing subject claim",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return UUID(sub)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid subject claim",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def get_keycloak_preferred_username(payload: dict) -> str:
|
||||||
|
username = payload.get("preferred_username")
|
||||||
|
if not username:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Missing preferred_username claim",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
return str(username)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_keycloak_username(
|
||||||
|
payload: dict = Depends(get_current_keycloak_payload),
|
||||||
|
) -> str:
|
||||||
|
return get_keycloak_preferred_username(payload)
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth.keycloak_dependencies import (
|
||||||
|
get_current_keycloak_payload,
|
||||||
|
get_keycloak_preferred_username,
|
||||||
|
)
|
||||||
|
from app.infra.db.metadb.database import get_metadata_session
|
||||||
|
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_metadata_repository(
|
||||||
|
session: AsyncSession = Depends(get_metadata_session),
|
||||||
|
) -> MetadataRepository:
|
||||||
|
return MetadataRepository(session)
|
||||||
|
|
||||||
|
|
||||||
|
def _keycloak_sub_from_payload(payload: dict) -> UUID:
|
||||||
|
sub = payload.get("sub")
|
||||||
|
if not sub:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Missing subject claim",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return UUID(str(sub))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid subject claim",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _email_from_payload(payload: dict) -> str | None:
|
||||||
|
email = payload.get("email")
|
||||||
|
return str(email) if email else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_metadata_user(
|
||||||
|
keycloak_payload: dict = Depends(get_current_keycloak_payload),
|
||||||
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||||
|
):
|
||||||
|
keycloak_sub = _keycloak_sub_from_payload(keycloak_payload)
|
||||||
|
username = get_keycloak_preferred_username(keycloak_payload)
|
||||||
|
try:
|
||||||
|
user = await metadata_repo.get_user_by_keycloak_id(keycloak_sub)
|
||||||
|
except SQLAlchemyError as exc:
|
||||||
|
logger.error(
|
||||||
|
"Metadata DB error while resolving current user",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Metadata database is unavailable",
|
||||||
|
) from exc
|
||||||
|
if not user or not user.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
user = await metadata_repo.refresh_user_keycloak_snapshot(
|
||||||
|
user,
|
||||||
|
username=username,
|
||||||
|
email=_email_from_payload(keycloak_payload),
|
||||||
|
)
|
||||||
|
except SQLAlchemyError as exc:
|
||||||
|
logger.error(
|
||||||
|
"Metadata DB error while refreshing current user snapshot",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Metadata database is unavailable",
|
||||||
|
) from exc
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_metadata_admin(
|
||||||
|
user=Depends(get_current_metadata_user),
|
||||||
|
):
|
||||||
|
if user.is_superuser or user.role == "admin":
|
||||||
|
return user
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _AuthBypassUser:
|
||||||
|
id: UUID = UUID(int=0)
|
||||||
|
role: str = "admin"
|
||||||
|
is_superuser: bool = True
|
||||||
|
is_active: bool = True
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
|
|
||||||
|
from app.auth.project_dependencies import ProjectContext, get_project_context
|
||||||
|
|
||||||
|
WEBGIS_VIEW = "webgis.view"
|
||||||
|
WEBGIS_EDIT = "webgis.edit"
|
||||||
|
SCADA_VIEW = "scada.view"
|
||||||
|
SCADA_CLEAN = "scada.clean"
|
||||||
|
SIMULATION_VIEW = "simulation.view"
|
||||||
|
SIMULATION_RUN = "simulation.run"
|
||||||
|
BURST_VIEW = "burst.view"
|
||||||
|
BURST_RUN = "burst.run"
|
||||||
|
RISK_VIEW = "risk.view"
|
||||||
|
RISK_RUN = "risk.run"
|
||||||
|
OPTIMIZATION_VIEW = "optimization.view"
|
||||||
|
OPTIMIZATION_RUN = "optimization.run"
|
||||||
|
MODEL_IMPORT = "model.import"
|
||||||
|
AUDIT_VIEW = "audit.view"
|
||||||
|
ENVIRONMENT_MANAGE = "environment.manage"
|
||||||
|
MEMBERSHIP_MANAGE = "membership.manage"
|
||||||
|
|
||||||
|
PROJECT_MEMBER_PERMISSIONS = frozenset(
|
||||||
|
{
|
||||||
|
WEBGIS_VIEW,
|
||||||
|
WEBGIS_EDIT,
|
||||||
|
SCADA_VIEW,
|
||||||
|
SCADA_CLEAN,
|
||||||
|
SIMULATION_VIEW,
|
||||||
|
SIMULATION_RUN,
|
||||||
|
BURST_VIEW,
|
||||||
|
BURST_RUN,
|
||||||
|
RISK_VIEW,
|
||||||
|
RISK_RUN,
|
||||||
|
OPTIMIZATION_VIEW,
|
||||||
|
OPTIMIZATION_RUN,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
PROJECT_VIEWER_PERMISSIONS = frozenset(
|
||||||
|
{
|
||||||
|
WEBGIS_VIEW,
|
||||||
|
SCADA_VIEW,
|
||||||
|
SIMULATION_VIEW,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
SYSTEM_ADMIN_PERMISSIONS = frozenset(
|
||||||
|
{
|
||||||
|
MODEL_IMPORT,
|
||||||
|
AUDIT_VIEW,
|
||||||
|
ENVIRONMENT_MANAGE,
|
||||||
|
MEMBERSHIP_MANAGE,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
PROJECT_ROLE_PERMISSIONS: dict[str, frozenset[str]] = {
|
||||||
|
"member": PROJECT_MEMBER_PERMISSIONS,
|
||||||
|
"viewer": PROJECT_VIEWER_PERMISSIONS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_permissions(
|
||||||
|
*,
|
||||||
|
project_role: str | None,
|
||||||
|
system_role: str,
|
||||||
|
is_superuser: bool,
|
||||||
|
) -> frozenset[str]:
|
||||||
|
permissions = set(PROJECT_ROLE_PERMISSIONS.get(project_role or "", frozenset()))
|
||||||
|
if is_superuser or system_role == "admin":
|
||||||
|
permissions.update(SYSTEM_ADMIN_PERMISSIONS)
|
||||||
|
return frozenset(permissions)
|
||||||
|
|
||||||
|
|
||||||
|
def permissions_for_context(ctx: ProjectContext) -> frozenset[str]:
|
||||||
|
return resolve_permissions(
|
||||||
|
project_role=ctx.project_role,
|
||||||
|
system_role=ctx.system_role,
|
||||||
|
is_superuser=ctx.is_superuser,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _permission_denied(permission: str) -> HTTPException:
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail={
|
||||||
|
"code": "permission_denied",
|
||||||
|
"permission": permission,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _enforce_project_scope(request: Request, ctx: ProjectContext) -> None:
|
||||||
|
requested_network = (
|
||||||
|
request.path_params.get("network")
|
||||||
|
or request.query_params.get("network")
|
||||||
|
)
|
||||||
|
if not requested_network:
|
||||||
|
content_type = request.headers.get("content-type", "")
|
||||||
|
if content_type.startswith("application/json"):
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except (ValueError, RuntimeError):
|
||||||
|
payload = None
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
requested_network = payload.get("network")
|
||||||
|
|
||||||
|
if requested_network and str(requested_network) != ctx.project_code:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail={
|
||||||
|
"code": "project_scope_denied",
|
||||||
|
"project_id": str(ctx.project_id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require_permission(
|
||||||
|
permission: str,
|
||||||
|
) -> Callable[..., Awaitable[ProjectContext]]:
|
||||||
|
async def dependency(
|
||||||
|
request: Request,
|
||||||
|
ctx: ProjectContext = Depends(get_project_context),
|
||||||
|
) -> ProjectContext:
|
||||||
|
if permission not in permissions_for_context(ctx):
|
||||||
|
raise _permission_denied(permission)
|
||||||
|
await _enforce_project_scope(request, ctx)
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
return dependency
|
||||||
|
|
||||||
|
|
||||||
|
def require_method_permission(
|
||||||
|
*,
|
||||||
|
read_permission: str,
|
||||||
|
write_permission: str,
|
||||||
|
) -> Callable[..., Awaitable[ProjectContext]]:
|
||||||
|
async def dependency(
|
||||||
|
request: Request,
|
||||||
|
ctx: ProjectContext = Depends(get_project_context),
|
||||||
|
) -> ProjectContext:
|
||||||
|
permission = (
|
||||||
|
read_permission
|
||||||
|
if request.method.upper() in {"GET", "HEAD", "OPTIONS"}
|
||||||
|
else write_permission
|
||||||
|
)
|
||||||
|
if permission not in permissions_for_context(ctx):
|
||||||
|
raise _permission_denied(permission)
|
||||||
|
await _enforce_project_scope(request, ctx)
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
return dependency
|
||||||
|
|
||||||
|
|
||||||
|
def has_permission(user: Any, project_role: str | None, permission: str) -> bool:
|
||||||
|
return permission in resolve_permissions(
|
||||||
|
project_role=project_role,
|
||||||
|
system_role=str(getattr(user, "role", "user")),
|
||||||
|
is_superuser=bool(getattr(user, "is_superuser", False)),
|
||||||
|
)
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import logging
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import Depends, Header, HTTPException, status
|
||||||
|
from psycopg import AsyncConnection
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth.metadata_dependencies import get_current_metadata_user
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.infra.db.dynamic_manager import project_connection_manager
|
||||||
|
from app.infra.db.metadb.database import get_metadata_session
|
||||||
|
from app.infra.db.metadb.repositories.metadata_repository import (
|
||||||
|
MetadataRepository,
|
||||||
|
ProjectDbRouting,
|
||||||
|
)
|
||||||
|
from app.infra.db.project_routing import ActiveProjectRouting, activate_project_routing
|
||||||
|
|
||||||
|
DB_ROLE_BIZ_DATA = "biz_data"
|
||||||
|
DB_ROLE_IOT_DATA = "iot_data"
|
||||||
|
DB_TYPE_POSTGRES = "postgresql"
|
||||||
|
DB_TYPE_TIMESCALE = "timescaledb"
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProjectContext:
|
||||||
|
project_id: UUID
|
||||||
|
project_code: str
|
||||||
|
user_id: UUID
|
||||||
|
project_role: str
|
||||||
|
system_role: str = "user"
|
||||||
|
is_superuser: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
async def get_metadata_repository(
|
||||||
|
session: AsyncSession = Depends(get_metadata_session),
|
||||||
|
) -> MetadataRepository:
|
||||||
|
return MetadataRepository(session)
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_project_context(
|
||||||
|
x_project_id: str,
|
||||||
|
current_user,
|
||||||
|
metadata_repo: MetadataRepository,
|
||||||
|
) -> ProjectContext:
|
||||||
|
try:
|
||||||
|
project_uuid = UUID(x_project_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid project id"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
project = await metadata_repo.get_project_by_id(project_uuid)
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
|
||||||
|
)
|
||||||
|
if project.status != "active":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Project is not active"
|
||||||
|
)
|
||||||
|
|
||||||
|
membership_role = await metadata_repo.get_membership_role(
|
||||||
|
project_uuid, current_user.id
|
||||||
|
)
|
||||||
|
if not membership_role:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="No access to project"
|
||||||
|
)
|
||||||
|
except SQLAlchemyError as exc:
|
||||||
|
logger.error(
|
||||||
|
"Metadata DB error while resolving project context",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Metadata database is unavailable",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return ProjectContext(
|
||||||
|
project_id=project.id,
|
||||||
|
project_code=project.code,
|
||||||
|
user_id=current_user.id,
|
||||||
|
project_role=membership_role,
|
||||||
|
system_role=current_user.role,
|
||||||
|
is_superuser=current_user.is_superuser,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_project_context(
|
||||||
|
x_project_id: str = Header(..., alias="X-Project-Id"),
|
||||||
|
current_user=Depends(get_current_metadata_user),
|
||||||
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||||
|
) -> ProjectContext:
|
||||||
|
return await resolve_project_context(x_project_id, current_user, metadata_repo)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_project_business_routing(
|
||||||
|
ctx: ProjectContext = Depends(get_project_context),
|
||||||
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||||
|
) -> ActiveProjectRouting:
|
||||||
|
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,
|
||||||
|
) -> ActiveProjectRouting:
|
||||||
|
business = await _get_project_routing(
|
||||||
|
metadata_repo,
|
||||||
|
ctx.project_id,
|
||||||
|
DB_ROLE_BIZ_DATA,
|
||||||
|
DB_TYPE_POSTGRES,
|
||||||
|
"PostgreSQL",
|
||||||
|
)
|
||||||
|
return ActiveProjectRouting(
|
||||||
|
project_code=ctx.project_code,
|
||||||
|
business_dsn=business.dsn,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_project_simulation_routing(
|
||||||
|
ctx: ProjectContext = Depends(get_project_context),
|
||||||
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||||
|
) -> ActiveProjectRouting:
|
||||||
|
return await resolve_project_simulation_routing(ctx, metadata_repo)
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_project_simulation_routing(
|
||||||
|
ctx: ProjectContext,
|
||||||
|
metadata_repo: MetadataRepository,
|
||||||
|
) -> ActiveProjectRouting:
|
||||||
|
business = await _get_project_routing(
|
||||||
|
metadata_repo,
|
||||||
|
ctx.project_id,
|
||||||
|
DB_ROLE_BIZ_DATA,
|
||||||
|
DB_TYPE_POSTGRES,
|
||||||
|
"PostgreSQL",
|
||||||
|
)
|
||||||
|
timescale = await _get_project_routing(
|
||||||
|
metadata_repo,
|
||||||
|
ctx.project_id,
|
||||||
|
DB_ROLE_IOT_DATA,
|
||||||
|
DB_TYPE_TIMESCALE,
|
||||||
|
"TimescaleDB",
|
||||||
|
)
|
||||||
|
return ActiveProjectRouting(
|
||||||
|
project_code=ctx.project_code,
|
||||||
|
business_dsn=business.dsn,
|
||||||
|
timescale_dsn=timescale.dsn,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_project_routing(
|
||||||
|
metadata_repo: MetadataRepository,
|
||||||
|
project_id: UUID,
|
||||||
|
db_role: str,
|
||||||
|
expected_db_type: str,
|
||||||
|
database_label: str,
|
||||||
|
) -> ProjectDbRouting:
|
||||||
|
try:
|
||||||
|
routing = await metadata_repo.get_project_db_routing(project_id, db_role)
|
||||||
|
except ValueError as exc:
|
||||||
|
logger.error(
|
||||||
|
"Invalid project %s routing DSN configuration",
|
||||||
|
database_label,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail=f"Project {database_label} routing DSN is invalid: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if not routing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail=f"Project {database_label} not configured",
|
||||||
|
)
|
||||||
|
if routing.db_type != expected_db_type:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail=f"Project {database_label} type mismatch",
|
||||||
|
)
|
||||||
|
return routing
|
||||||
|
|
||||||
|
|
||||||
|
async def get_project_pg_connection(
|
||||||
|
ctx: ProjectContext = Depends(get_project_context),
|
||||||
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||||
|
) -> AsyncGenerator[AsyncConnection, 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
|
||||||
|
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,
|
||||||
|
) as conn:
|
||||||
|
yield conn
|
||||||
|
|
||||||
|
|
||||||
|
async def get_project_timescale_connection(
|
||||||
|
ctx: ProjectContext = Depends(get_project_context),
|
||||||
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||||
|
) -> AsyncGenerator[AsyncConnection, None]:
|
||||||
|
routing = await _get_project_routing(
|
||||||
|
metadata_repo,
|
||||||
|
ctx.project_id,
|
||||||
|
DB_ROLE_IOT_DATA,
|
||||||
|
DB_TYPE_TIMESCALE,
|
||||||
|
"TimescaleDB",
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
) as conn:
|
||||||
|
yield conn
|
||||||
+147
-3
@@ -1,3 +1,147 @@
|
|||||||
# Placeholder for audit logic
|
"""
|
||||||
async def log_audit_event(event_type: str, user_id: str, details: dict):
|
审计日志模块
|
||||||
pass
|
|
||||||
|
记录系统关键操作,用于安全审计和合规追踪
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
import logging
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AuditAction:
|
||||||
|
"""审计操作类型常量"""
|
||||||
|
|
||||||
|
# 认证相关
|
||||||
|
LOGIN = "LOGIN"
|
||||||
|
LOGOUT = "LOGOUT"
|
||||||
|
REGISTER = "REGISTER"
|
||||||
|
PASSWORD_CHANGE = "PASSWORD_CHANGE"
|
||||||
|
|
||||||
|
# 数据操作
|
||||||
|
CREATE = "CREATE"
|
||||||
|
READ = "READ"
|
||||||
|
UPDATE = "UPDATE"
|
||||||
|
DELETE = "DELETE"
|
||||||
|
|
||||||
|
# 权限相关
|
||||||
|
PERMISSION_CHANGE = "PERMISSION_CHANGE"
|
||||||
|
ROLE_CHANGE = "ROLE_CHANGE"
|
||||||
|
|
||||||
|
# 系统操作
|
||||||
|
CONFIG_CHANGE = "CONFIG_CHANGE"
|
||||||
|
SYSTEM_START = "SYSTEM_START"
|
||||||
|
SYSTEM_STOP = "SYSTEM_STOP"
|
||||||
|
|
||||||
|
|
||||||
|
async def log_audit_event(
|
||||||
|
action: str,
|
||||||
|
user_id: Optional[UUID] = None,
|
||||||
|
project_id: Optional[UUID] = None,
|
||||||
|
resource_type: Optional[str] = None,
|
||||||
|
resource_id: Optional[str] = None,
|
||||||
|
ip_address: Optional[str] = None,
|
||||||
|
request_method: Optional[str] = None,
|
||||||
|
request_path: Optional[str] = None,
|
||||||
|
request_data: Optional[dict] = None,
|
||||||
|
response_status: Optional[int] = None,
|
||||||
|
session=None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
记录审计日志
|
||||||
|
|
||||||
|
Args:
|
||||||
|
action: 操作类型
|
||||||
|
user_id: 用户ID
|
||||||
|
project_id: 项目ID
|
||||||
|
resource_type: 资源类型
|
||||||
|
resource_id: 资源ID
|
||||||
|
ip_address: IP地址
|
||||||
|
request_method: 请求方法
|
||||||
|
request_path: 请求路径
|
||||||
|
request_data: 请求数据(敏感字段需脱敏)
|
||||||
|
response_status: 响应状态码
|
||||||
|
session: 元数据库会话(可选)
|
||||||
|
"""
|
||||||
|
from app.infra.db.metadb.database import SessionLocal
|
||||||
|
from app.infra.db.metadb.repositories.audit_repository import AuditRepository
|
||||||
|
|
||||||
|
if request_data:
|
||||||
|
request_data = sanitize_sensitive_data(request_data)
|
||||||
|
|
||||||
|
if session is None:
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
audit_repo = AuditRepository(session)
|
||||||
|
await audit_repo.create_log(
|
||||||
|
user_id=user_id,
|
||||||
|
project_id=project_id,
|
||||||
|
action=action,
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
ip_address=ip_address,
|
||||||
|
request_method=request_method,
|
||||||
|
request_path=request_path,
|
||||||
|
request_data=request_data,
|
||||||
|
response_status=response_status,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
audit_repo = AuditRepository(session)
|
||||||
|
await audit_repo.create_log(
|
||||||
|
user_id=user_id,
|
||||||
|
project_id=project_id,
|
||||||
|
action=action,
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
ip_address=ip_address,
|
||||||
|
request_method=request_method,
|
||||||
|
request_path=request_path,
|
||||||
|
request_data=request_data,
|
||||||
|
response_status=response_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Audit log created: action=%s, user=%s, project=%s, resource=%s:%s",
|
||||||
|
action,
|
||||||
|
user_id,
|
||||||
|
project_id,
|
||||||
|
resource_type,
|
||||||
|
resource_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_sensitive_data(data: dict) -> dict:
|
||||||
|
"""
|
||||||
|
脱敏敏感数据
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: 原始数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
脱敏后的数据
|
||||||
|
"""
|
||||||
|
sensitive_fields = [
|
||||||
|
"password",
|
||||||
|
"passwd",
|
||||||
|
"pwd",
|
||||||
|
"secret",
|
||||||
|
"token",
|
||||||
|
"api_key",
|
||||||
|
"apikey",
|
||||||
|
"dsn",
|
||||||
|
"credit_card",
|
||||||
|
"ssn",
|
||||||
|
"social_security",
|
||||||
|
]
|
||||||
|
|
||||||
|
sanitized = data.copy()
|
||||||
|
|
||||||
|
for key in sanitized:
|
||||||
|
if isinstance(sanitized[key], dict):
|
||||||
|
sanitized[key] = sanitize_sensitive_data(sanitized[key])
|
||||||
|
elif any(sensitive in key.lower() for sensitive in sensitive_fields):
|
||||||
|
sanitized[key] = "***REDACTED***"
|
||||||
|
|
||||||
|
return sanitized
|
||||||
|
|||||||
+145
-12
@@ -1,11 +1,17 @@
|
|||||||
from pydantic_settings import BaseSettings
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import quote_plus
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
PROJECT_NAME: str = "TJWater Server"
|
PROJECT_NAME: str = "TJWater Server"
|
||||||
|
ENVIRONMENT: str = "production"
|
||||||
API_V1_STR: str = "/api/v1"
|
API_V1_STR: str = "/api/v1"
|
||||||
SECRET_KEY: str = "your-secret-key-here" # Change in production
|
NETWORK_NAME: str = "default_network"
|
||||||
ALGORITHM: str = "HS256"
|
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
# 敏感配置加密密钥 (Fernet)
|
||||||
|
DATABASE_ENCRYPTION_KEY: str = ""
|
||||||
|
|
||||||
# Database Config (PostgreSQL)
|
# Database Config (PostgreSQL)
|
||||||
DB_NAME: str = "tjwater"
|
DB_NAME: str = "tjwater"
|
||||||
@@ -14,17 +20,144 @@ class Settings(BaseSettings):
|
|||||||
DB_USER: str = "postgres"
|
DB_USER: str = "postgres"
|
||||||
DB_PASSWORD: str = "password"
|
DB_PASSWORD: str = "password"
|
||||||
|
|
||||||
# InfluxDB
|
# Database Config (TimescaleDB)
|
||||||
INFLUXDB_URL: str = "http://localhost:8086"
|
TIMESCALEDB_DB_NAME: str = "tjwater"
|
||||||
INFLUXDB_TOKEN: str = "token"
|
TIMESCALEDB_DB_HOST: str = "localhost"
|
||||||
INFLUXDB_ORG: str = "org"
|
TIMESCALEDB_DB_PORT: str = "5433"
|
||||||
INFLUXDB_BUCKET: str = "bucket"
|
TIMESCALEDB_DB_USER: str = "postgres"
|
||||||
|
TIMESCALEDB_DB_PASSWORD: str = "password"
|
||||||
|
# Metadata Database Config (PostgreSQL)
|
||||||
|
METADATA_DB_NAME: str = "system_hub"
|
||||||
|
METADATA_DB_HOST: str = "localhost"
|
||||||
|
METADATA_DB_PORT: str = "5432"
|
||||||
|
METADATA_DB_USER: str = "postgres"
|
||||||
|
METADATA_DB_PASSWORD: str = "password"
|
||||||
|
|
||||||
|
METADATA_DB_POOL_SIZE: int = 5
|
||||||
|
METADATA_DB_MAX_OVERFLOW: int = 10
|
||||||
|
|
||||||
|
PROJECT_PG_CACHE_SIZE: int = 16
|
||||||
|
PROJECT_TS_CACHE_SIZE: int = 16
|
||||||
|
PROJECT_PG_POOL_MIN_SIZE: int = 0
|
||||||
|
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 = 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 = ""
|
||||||
|
KEYCLOAK_ALGORITHM: str = "RS256"
|
||||||
|
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
|
||||||
|
|
||||||
|
# Tianditu Geocoding API
|
||||||
|
TIANDITU_GEOCODER_TOKEN: str = ""
|
||||||
|
TIANDITU_GEOCODER_URL: str = "https://api.tianditu.gov.cn/geocoder"
|
||||||
|
TIANDITU_GEOCODER_TIMEOUT_SECONDS: float = 30.0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def SQLALCHEMY_DATABASE_URI(self) -> str:
|
def SQLALCHEMY_DATABASE_URI(self) -> str:
|
||||||
return f"postgresql://{self.DB_USER}:{self.DB_PASSWORD}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
|
db_password = quote_plus(self.DB_PASSWORD)
|
||||||
|
return f"postgresql://{self.DB_USER}:{db_password}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def METADATA_DATABASE_URI(self) -> str:
|
||||||
|
metadata_password = quote_plus(self.METADATA_DB_PASSWORD)
|
||||||
|
return (
|
||||||
|
f"postgresql+psycopg://{self.METADATA_DB_USER}:{metadata_password}"
|
||||||
|
f"@{self.METADATA_DB_HOST}:{self.METADATA_DB_PORT}/{self.METADATA_DB_NAME}"
|
||||||
|
)
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=Path(__file__).resolve().parents[2] / ".env",
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
class Config:
|
|
||||||
env_file = ".env"
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
||||||
|
|
||||||
|
def get_pgconn_string(
|
||||||
|
db_name: Optional[str] = None,
|
||||||
|
db_host: Optional[str] = None,
|
||||||
|
db_port: Optional[str] = None,
|
||||||
|
db_user: Optional[str] = None,
|
||||||
|
db_password: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Return PostgreSQL connection string in psycopg conninfo format."""
|
||||||
|
resolved_db_name = db_name or settings.DB_NAME
|
||||||
|
resolved_db_host = db_host or settings.DB_HOST
|
||||||
|
resolved_db_port = db_port or settings.DB_PORT
|
||||||
|
resolved_db_user = db_user or settings.DB_USER
|
||||||
|
resolved_db_password = db_password or settings.DB_PASSWORD
|
||||||
|
return (
|
||||||
|
f"dbname={resolved_db_name} host={resolved_db_host} port={resolved_db_port} "
|
||||||
|
f"user={resolved_db_user} password={resolved_db_password}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_pg_config() -> dict:
|
||||||
|
"""Return PostgreSQL configuration except password."""
|
||||||
|
return {
|
||||||
|
"name": settings.DB_NAME,
|
||||||
|
"host": settings.DB_HOST,
|
||||||
|
"port": settings.DB_PORT,
|
||||||
|
"user": settings.DB_USER,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_pg_password() -> str:
|
||||||
|
"""Return PostgreSQL password (use with care)."""
|
||||||
|
return settings.DB_PASSWORD
|
||||||
|
|
||||||
|
|
||||||
|
def get_timescaledb_pgconn_string(
|
||||||
|
db_name: Optional[str] = None,
|
||||||
|
db_host: Optional[str] = None,
|
||||||
|
db_port: Optional[str] = None,
|
||||||
|
db_user: Optional[str] = None,
|
||||||
|
db_password: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Return TimescaleDB connection string in psycopg conninfo format."""
|
||||||
|
resolved_db_name = db_name or settings.TIMESCALEDB_DB_NAME
|
||||||
|
resolved_db_host = db_host or settings.TIMESCALEDB_DB_HOST
|
||||||
|
resolved_db_port = db_port or settings.TIMESCALEDB_DB_PORT
|
||||||
|
resolved_db_user = db_user or settings.TIMESCALEDB_DB_USER
|
||||||
|
resolved_db_password = db_password or settings.TIMESCALEDB_DB_PASSWORD
|
||||||
|
return (
|
||||||
|
f"dbname={resolved_db_name} host={resolved_db_host} port={resolved_db_port} "
|
||||||
|
f"user={resolved_db_user} password={resolved_db_password}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_timescaledb_pg_config() -> dict:
|
||||||
|
"""Return TimescaleDB configuration except password."""
|
||||||
|
return {
|
||||||
|
"name": settings.TIMESCALEDB_DB_NAME,
|
||||||
|
"host": settings.TIMESCALEDB_DB_HOST,
|
||||||
|
"port": settings.TIMESCALEDB_DB_PORT,
|
||||||
|
"user": settings.TIMESCALEDB_DB_USER,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_timescaledb_pg_password() -> str:
|
||||||
|
"""Return TimescaleDB password (use with care)."""
|
||||||
|
return settings.TIMESCALEDB_DB_PASSWORD
|
||||||
|
|||||||
+117
-4
@@ -1,9 +1,122 @@
|
|||||||
# Placeholder for encryption logic
|
from cryptography.fernet import Fernet
|
||||||
|
from typing import Optional
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
class Encryptor:
|
class Encryptor:
|
||||||
|
"""
|
||||||
|
使用 Fernet (对称加密) 实现数据加密/解密
|
||||||
|
适用于加密敏感配置、用户数据等
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, key: Optional[bytes] = None):
|
||||||
|
"""
|
||||||
|
初始化加密器
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: 加密密钥,如果为 None 则从环境变量读取
|
||||||
|
"""
|
||||||
|
if key is None:
|
||||||
|
key_str = os.getenv("DATABASE_ENCRYPTION_KEY") or settings.DATABASE_ENCRYPTION_KEY
|
||||||
|
if not key_str:
|
||||||
|
raise ValueError(
|
||||||
|
"DATABASE_ENCRYPTION_KEY not found in environment variables or .env. "
|
||||||
|
"Generate one using: Encryptor.generate_key()"
|
||||||
|
)
|
||||||
|
key = key_str.encode()
|
||||||
|
|
||||||
|
self.fernet = Fernet(key)
|
||||||
|
|
||||||
def encrypt(self, data: str) -> str:
|
def encrypt(self, data: str) -> str:
|
||||||
return data # Implement actual encryption
|
"""
|
||||||
|
加密字符串
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: 待加密的明文字符串
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Base64 编码的加密字符串
|
||||||
|
"""
|
||||||
|
if not data:
|
||||||
|
return data
|
||||||
|
|
||||||
|
encrypted_bytes = self.fernet.encrypt(data.encode())
|
||||||
|
return encrypted_bytes.decode()
|
||||||
|
|
||||||
def decrypt(self, data: str) -> str:
|
def decrypt(self, data: str) -> str:
|
||||||
return data # Implement actual decryption
|
"""
|
||||||
|
解密字符串
|
||||||
|
|
||||||
encryptor = Encryptor()
|
Args:
|
||||||
|
data: Base64 编码的加密字符串
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
解密后的明文字符串
|
||||||
|
"""
|
||||||
|
if not data:
|
||||||
|
return data
|
||||||
|
|
||||||
|
decrypted_bytes = self.fernet.decrypt(data.encode())
|
||||||
|
return decrypted_bytes.decode()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_key() -> str:
|
||||||
|
"""
|
||||||
|
生成新的 Fernet 加密密钥
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Base64 编码的密钥字符串
|
||||||
|
"""
|
||||||
|
key = Fernet.generate_key()
|
||||||
|
return key.decode()
|
||||||
|
|
||||||
|
|
||||||
|
# 全局加密器实例(懒加载)
|
||||||
|
_encryptor: Optional[Encryptor] = None
|
||||||
|
_database_encryptor: Optional[Encryptor] = None
|
||||||
|
|
||||||
|
|
||||||
|
def is_encryption_configured() -> bool:
|
||||||
|
return is_database_encryption_configured()
|
||||||
|
|
||||||
|
|
||||||
|
def is_database_encryption_configured() -> bool:
|
||||||
|
return bool(
|
||||||
|
os.getenv("DATABASE_ENCRYPTION_KEY")
|
||||||
|
or settings.DATABASE_ENCRYPTION_KEY
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_encryptor() -> Encryptor:
|
||||||
|
"""获取全局加密器实例"""
|
||||||
|
global _encryptor
|
||||||
|
if _encryptor is None:
|
||||||
|
_encryptor = Encryptor()
|
||||||
|
return _encryptor
|
||||||
|
|
||||||
|
|
||||||
|
def get_database_encryptor() -> Encryptor:
|
||||||
|
"""获取 project DB DSN 专用加密器实例"""
|
||||||
|
global _database_encryptor
|
||||||
|
if _database_encryptor is None:
|
||||||
|
key_str = (
|
||||||
|
os.getenv("DATABASE_ENCRYPTION_KEY")
|
||||||
|
or settings.DATABASE_ENCRYPTION_KEY
|
||||||
|
)
|
||||||
|
if not key_str:
|
||||||
|
raise ValueError(
|
||||||
|
"DATABASE_ENCRYPTION_KEY not found in environment variables or .env. "
|
||||||
|
"Generate one using: Encryptor.generate_key()"
|
||||||
|
)
|
||||||
|
_database_encryptor = Encryptor(key=key_str.encode())
|
||||||
|
return _database_encryptor
|
||||||
|
|
||||||
|
|
||||||
|
# 向后兼容(延迟加载)
|
||||||
|
def __getattr__(name):
|
||||||
|
if name == "encryptor":
|
||||||
|
return get_encryptor()
|
||||||
|
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user