refactor(db)!: finalize pooled WNDB v2 migration

This commit is contained in:
2026-08-27 17:26:22 +08:00
parent fa188af0b1
commit b74799a39d
105 changed files with 4988 additions and 5565 deletions
+7 -5
View File
@@ -40,13 +40,15 @@ METADATA_DB_USER="tjwater"
METADATA_DB_PASSWORD="password"
# Per-project synchronous connection pools
PROJECT_PG_CACHE_SIZE="50"
PROJECT_TS_CACHE_SIZE="50"
PROJECT_PG_CACHE_SIZE="16"
PROJECT_TS_CACHE_SIZE="16"
PROJECT_PG_POOL_MIN_SIZE="0"
PROJECT_PG_POOL_SIZE="5"
PROJECT_PG_MAX_OVERFLOW="10"
PROJECT_PG_POOL_SIZE="4"
PROJECT_PG_MAX_OVERFLOW="2"
PROJECT_TS_POOL_MIN_SIZE="0"
PROJECT_TS_POOL_MAX_SIZE="10"
PROJECT_TS_POOL_MAX_SIZE="4"
WNDB_TEMPLATE_DB_NAME="tjwater_v2_template"
WNDB_TEMP_DB_MAX_COUNT="8"
# ============================================
# Keycloak JWT (可选)
+2 -2
View File
@@ -69,11 +69,11 @@ docker compose -f infra/docker/docker-compose.yml config
项目级 REST 请求通过 `X-Project-Id` 解析元数据中的数据库配置:
- `biz_data` DSN 用于管网业务数据;`{project_code}_template`模拟临时库沿用该 DSN 的主机、端口与凭据,仅替换数据库名。
- `biz_data` DSN 用于管网业务数据;版本模板固定由 `WNDB_TEMPLATE_DB_NAME` 配置(当前为 `tjwater_v2_template`),模拟临时库沿用该 DSN 的主机、端口与凭据,仅替换数据库名。
- `iot_data` DSN 用于 TimescaleDB,始终使用元数据配置的完整 DSN,不再从项目代码推导数据库名。
- 元数据、业务库和 TimescaleDB 可以部署在同一主机,也可以分别部署。
使用模板复制或临时方案库的模拟功能时,`biz_data` 账号必须具备现有数据库创建、删除和连接终止操作所需的 PostgreSQL 权限
使用模板复制或临时方案库的模拟功能时,`biz_data` 账号必须具备数据库创建和删除权限;只有显式删除项目时才会终止该项目的现有数据库会话,普通复制不会主动中断复制源会话
## 测试与发布
+20 -24
View File
@@ -1,9 +1,7 @@
import numpy as np
from functools import wraps
from app.services.tjnetwork import (
ChangeSet,
close_project,
copy_project,
delete_project,
get_pattern,
get_patterns,
get_pump,
@@ -11,9 +9,6 @@ from app.services.tjnetwork import (
get_status,
get_tank,
get_time,
have_project,
is_project_open,
open_project,
read_all,
run_project,
set_pattern,
@@ -29,6 +24,7 @@ import pytz
import requests
import time
import app.services.project_info as project_info
from app.native.wndb.core.projects import temporary_project_database
from app.services.time_api import parse_clock_duration_seconds
url_path = 'http://10.101.15.16:9000/loong' # 内网
@@ -572,9 +568,6 @@ def trim_time_flag(url_date_time:str)->str:
# 单时间步长模拟
def run_simulation(name:str,start_datetime:str,end_datetime:str=None, duration:int=900)->str:
if(is_project_open(name)):
close_project(name)
open_project(name)
#get_current_data(cur_datetime)
#extract the patternindex from datetime
#e.g. 0: the first time step for 00:00-00:14; 1: the second step for 00:15-00:30
@@ -654,32 +647,38 @@ def run_simulation(name:str,start_datetime:str,end_datetime:str=None, duration:i
# 在线模拟
def _clean_extended_simulation(func):
@wraps(func)
def wrapper(name: str, simulation_type: str, *args, **kwargs):
if simulation_type.upper() != "EXTENDED":
return func(name, simulation_type, *args, **kwargs)
with temporary_project_database(name, "extended_simulation") as temporary:
kwargs["_temporary_project"] = temporary
return func(name, simulation_type, *args, **kwargs)
return wrapper
@_clean_extended_simulation
def run_simulation_ex(name: str, simulation_type: str, start_datetime: str,
end_datetime: str = None, duration: int = 0,
pump_control: dict[str, list] = None, tank_initial_level_control: dict[str, float] = None,
region_demand_control: dict[str, float] = None, valve_control: dict[str, dict] = None,
downloading_prohibition: bool = False) -> str:
downloading_prohibition: bool = False,
_temporary_project: str | None = None) -> str:
time_cost_start = time.perf_counter()
print('{} -- Hydraulic simulation started.'.format(
datetime.now(pytz.timezone('Asia/Shanghai')).strftime('%Y-%m-%d %H:%M:%S')))
if is_project_open(name):
close_project(name)
if simulation_type.upper() == 'REALTIME': # 实时模拟(修改原数据库)
name_c = name
elif simulation_type.upper() == 'EXTENDED': # 扩展模拟(复制数据库)
name_c = '_'.join([name, 'c'])
if have_project(name_c):
if is_project_open(name_c):
close_project(name_c)
delete_project(name_c)
copy_project(name, name_c) # 备份项目
if _temporary_project is None:
raise RuntimeError("Extended simulation isolation was not prepared")
name_c = _temporary_project
else:
raise Exception('Incorrect simulation type, choose in (realtime, extended)')
open_project(name_c)
# 时间处理
# extract the pattern index from datetime
# e.g. 0: the first time step for 00:00-00:14; 1: the second step for 00:15-00:30
@@ -848,8 +847,6 @@ def run_simulation_ex(name: str, simulation_type: str, start_datetime: str,
datetime.now(pytz.timezone('Asia/Shanghai')).strftime('%Y-%m-%d %H:%M:%S'),
time_cost_end - time_cost_start))
close_project(name_c)
return result
@@ -858,7 +855,6 @@ if __name__ == '__main__':
# tQ=get_current_total_Q()
# print(f"the current tQ is {tQ}\n")
# data=get_hist_data(ids,conver_beingtime_to_ucttime('2024-04-10 15:05:00'),conver_beingtime_to_ucttime('2024-04-10 15:10:00'))
# open_project("beibeizone")
# read_inp("beibeizone","beibeizone-export_nochinese.inp")
# run_simulation("beibeizone","2024-04-01T08:00:00Z")
# read_inp('bb_server', 'model20_en.inp')
+42 -91
View File
@@ -1,5 +1,6 @@
import json
from datetime import datetime
from functools import wraps
from math import pi, sqrt
import pytz
@@ -9,6 +10,7 @@ from app.algorithms.simulation.runner import (
run_simulation_ex,
from_clock_to_seconds_2,
)
from app.native.wndb.core.projects import temporary_project_database
from app.services.tjnetwork import (
ChangeSet,
OPTION_DEMAND_MODEL_PDA,
@@ -16,9 +18,6 @@ from app.services.tjnetwork import (
SOURCE_TYPE_SETPOINT,
add_pattern,
add_source,
close_project,
copy_project,
delete_project,
get_demand,
get_emitter,
get_node_links,
@@ -27,10 +26,7 @@ from app.services.tjnetwork import (
get_pipe,
get_source,
get_time,
have_project,
is_junction,
is_project_open,
open_project,
set_demand,
set_emitter,
set_option,
@@ -39,11 +35,23 @@ from app.services.tjnetwork import (
)
def _isolated_analysis(purpose: str):
def decorator(func):
@wraps(func)
def wrapper(name: str, *args, **kwargs):
with temporary_project_database(name, purpose) as temporary:
kwargs["_temporary_project"] = temporary
return func(name, *args, **kwargs)
return wrapper
return decorator
############################################################
# burst analysis 01
############################################################
def convert_to_local_unit(proj: str, emitters: float) -> float:
open_project(proj)
proj_opt = get_option(proj)
str_unit = proj_opt.get("UNITS")
@@ -61,6 +69,7 @@ def convert_to_local_unit(proj: str, emitters: float) -> float:
return emitters
@_isolated_analysis("burst_analysis")
def burst_analysis(
name: str,
modify_pattern_start_time: str,
@@ -72,6 +81,7 @@ def burst_analysis(
modify_valve_opening: dict[str, float] = None,
scheme_name: str = None,
username: str | None = None,
_temporary_project: str | None = None,
) -> None:
"""
爆管模拟
@@ -101,21 +111,17 @@ def burst_analysis(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Analysis."
)
new_name = f"burst_Anal_{name}"
if have_project(new_name):
if is_project_open(new_name):
close_project(new_name)
delete_project(new_name)
if _temporary_project is None:
raise RuntimeError("Burst analysis isolation was not prepared")
new_name = _temporary_project
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Copying Database."
)
copy_project(name + "_template", new_name)
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Opening Database."
)
open_project(new_name)
simulation.run_simulation(
name=new_name,
simulation_type="manually_temporary",
@@ -207,20 +213,19 @@ def burst_analysis(
)
# step 3. restore the base model status
# execute_undo(name) #有疑惑
if is_project_open(new_name):
close_project(new_name)
delete_project(new_name)
############################################################
# valve closing analysis 02
############################################################
@_isolated_analysis("valve_close_analysis")
def valve_close_analysis(
name: str,
modify_pattern_start_time: str,
modify_total_duration: int = 900,
modify_valve_opening: dict[str, float] = None,
scheme_name: str = None,
_temporary_project: str | None = None,
) -> None:
"""
关阀模拟
@@ -235,21 +240,17 @@ def valve_close_analysis(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Analysis."
)
new_name = f"valve_close_Anal_{name}"
if have_project(new_name):
if is_project_open(new_name):
close_project(new_name)
delete_project(new_name)
if _temporary_project is None:
raise RuntimeError("Valve-close analysis isolation was not prepared")
new_name = _temporary_project
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Copying Database."
)
copy_project(name + "_template", new_name)
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Opening Database."
)
open_project(new_name)
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Database Loading OK."
@@ -287,9 +288,6 @@ def valve_close_analysis(
# step 3. restore the base model
# for valve in valves:
# execute_undo(name)
if is_project_open(new_name):
close_project(new_name)
delete_project(new_name)
# return result
@@ -297,6 +295,7 @@ def valve_close_analysis(
# flushing analysis 03
# Pipe_Flushing_Analysis(prj_name,date_time, Valve_id_list, Drainage_Node_Id, Flushing_flow[opt], Flushing_duration[opt])->out_file:string
############################################################
@_isolated_analysis("flushing_analysis")
def flushing_analysis(
name: str,
modify_pattern_start_time: str,
@@ -307,6 +306,7 @@ def flushing_analysis(
scheme_name: str = None,
username: str | None = None,
valve_control: dict[str, dict] = None,
_temporary_project: str | None = None,
) -> None:
"""
管道冲洗模拟
@@ -334,23 +334,17 @@ def flushing_analysis(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Analysis."
)
new_name = f"flushing_Anal_{name}"
if have_project(new_name):
if is_project_open(new_name):
close_project(new_name)
delete_project(new_name)
# if is_project_open(name):
# close_project(name)
if _temporary_project is None:
raise RuntimeError("Flushing analysis isolation was not prepared")
new_name = _temporary_project
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Copying Database."
)
copy_project(name + "_template", new_name)
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Opening Database."
)
open_project(new_name)
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Database Loading OK."
@@ -449,9 +443,6 @@ def flushing_analysis(
scheme_detail=scheme_detail,
)
# step 4. restore the base model
if is_project_open(new_name):
close_project(new_name)
delete_project(new_name)
# return result
@@ -459,6 +450,7 @@ def flushing_analysis(
# Contaminant simulation 04
#
############################################################
@_isolated_analysis("contaminant_simulation")
def contaminant_simulation(
name: str,
modify_pattern_start_time: str, # 模拟开始时间,格式为'2024-11-25T09:00:00+08:00'
@@ -468,6 +460,7 @@ def contaminant_simulation(
scheme_name: str = None,
source_pattern: str = None, # 污染源时间变化模式名称
username: str | None = None,
_temporary_project: str | None = None,
) -> None:
"""
污染模拟
@@ -494,23 +487,17 @@ def contaminant_simulation(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Analysis."
)
new_name = f"contaminant_Sim_{name}"
if have_project(new_name):
if is_project_open(new_name):
close_project(new_name)
delete_project(new_name)
# if is_project_open(name):
# close_project(name)
if _temporary_project is None:
raise RuntimeError("Contaminant simulation isolation was not prepared")
new_name = _temporary_project
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Copying Database."
)
copy_project(name + "_template", new_name)
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Opening Database."
)
open_project(new_name)
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Database Loading OK."
@@ -599,9 +586,6 @@ def contaminant_simulation(
# for i in range(1,operation_step):
# execute_undo(name)
if is_project_open(new_name):
close_project(new_name)
delete_project(new_name)
############################################################
@@ -623,29 +607,7 @@ def age_analysis(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Analysis."
)
new_name = f"age_Anal_{name}"
if have_project(new_name):
if is_project_open(new_name):
close_project(new_name)
delete_project(new_name)
# if is_project_open(name):
# close_project(name)
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Copying Database."
)
copy_project(name + "_template", new_name)
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Opening Database."
)
open_project(new_name)
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Database Loading OK."
)
# step 1. run simulation
with temporary_project_database(name, "age_analysis") as new_name:
result = run_simulation_ex(
new_name,
"realtime",
@@ -657,11 +619,7 @@ def age_analysis(
output_data = simulation_result.get("output")
if not isinstance(output_data, dict):
raise RuntimeError("run_simulation_ex did not return JSON output content")
# step 2. restore the base model status
# execute_undo(name) #有疑惑
if is_project_open(new_name):
close_project(new_name)
delete_project(new_name)
nodes_age = []
node_result = output_data.get("node_results") or []
for node in node_result:
@@ -680,6 +638,7 @@ def age_analysis(
############################################################
@_isolated_analysis("pressure_regulation")
def pressure_regulation(
name: str,
modify_pattern_start_time: str,
@@ -688,6 +647,7 @@ def pressure_regulation(
modify_fixed_pump_pattern: dict[str, list] = None,
modify_variable_pump_pattern: dict[str, list] = None,
scheme_name: str = None,
_temporary_project: str | None = None,
) -> None:
"""
区域调压模拟,用来模拟未来15分钟内,开关水泵对区域压力的影响
@@ -704,23 +664,17 @@ def pressure_regulation(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Analysis."
)
new_name = f"pressure_regulation_{name}"
if have_project(new_name):
if is_project_open(new_name):
close_project(new_name)
delete_project(new_name)
# if is_project_open(name):
# close_project(name)
if _temporary_project is None:
raise RuntimeError("Pressure-regulation isolation was not prepared")
new_name = _temporary_project
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Copying Database."
)
copy_project(name + "_template", new_name)
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Opening Database."
)
open_project(new_name)
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Database Loading OK."
@@ -751,7 +705,4 @@ def pressure_regulation(
scheme_name=scheme_name,
result_db_name=name,
)
if is_project_open(new_name):
close_project(new_name)
delete_project(new_name)
# return result
+13 -13
View File
@@ -1,8 +1,11 @@
from app.native.wndb.commands.executor import execute_batch_command
from app.native.wndb.core.database import ChangeSet
from app.native.wndb.gis.region_geometry import Topology, get_nodes_in_region
from app.native.wndb.model.demands import get_demand
from app.native.wndb.model.elements import get_nodes, is_junction
from app.native.wndb.gis.network_views import (
get_junction_demands,
sum_junction_base_demand,
)
from app.native.wndb.model.elements import get_nodes
DISTRIBUTION_TYPE_ADD = 'ADD'
@@ -28,7 +31,7 @@ def calculate_demand_to_nodes(name: str, demand: float, nodes: list[str]) -> dic
result: dict[str, float] = {}
for node, value in t_nodes.items():
if not is_junction(name, node):
if value["type"] != "junction":
continue
demand_per_node = 0.0
for link in value['links']:
@@ -68,15 +71,19 @@ def distribute_demand_to_nodes(name: str, demand: float, nodes: list[str], type:
demand_per_length = demand / length_sum
cs = ChangeSet()
demands_by_junction = get_junction_demands(
name,
[node for node, value in t_nodes.items() if value["type"] == "junction"],
)
for node, value in t_nodes.items():
if not is_junction(name, node):
if value["type"] != "junction":
continue
demand_per_node = 0.0
for link in value['links']:
demand_per_node += abs(t_links[link]['length']) * demand_per_length * 0.5
ds = get_demand(name, node)['demands']
ds = demands_by_junction.get(node, [])
if len(ds) == 0:
ds = [{'demand': demand_per_node, 'pattern': None, 'category': None}]
elif type == DISTRIBUTION_TYPE_ADD:
@@ -94,11 +101,4 @@ def distribute_demand_to_region(name: str, demand: float, region: str, type: str
def get_total_base_demand(name: str, region: str) -> float:
nodes = get_nodes_in_region(name, region)
t_demands=0.0
for node in nodes:
if not is_junction(name, node):
continue
ds = get_demand(name, node)['demands']
t_demands= t_demands+ds[0]['demand']
return t_demands
return sum_junction_base_demand(name, nodes)
+20
View File
@@ -8,6 +8,8 @@ from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from app.native.wndb.core.database import MaterializedViewRefreshAfterCommitError
class ProblemDetails(BaseModel):
"""RFC 9457 compatible error response used by the REST contract."""
@@ -53,6 +55,24 @@ def _problem_response(
def install_problem_details_handlers(app: FastAPI) -> None:
@app.exception_handler(MaterializedViewRefreshAfterCommitError)
async def materialized_view_refresh_error_handler(
request: Request,
exc: MaterializedViewRefreshAfterCommitError,
) -> JSONResponse:
response = _problem_response(
request,
status_code=503,
title="Materialized view refresh failed",
detail=(
f"Project {exc.project!r} changes were committed, but GIS query "
"views could not be refreshed. Do not repeat the write blindly."
),
code="materialized_view_refresh_failed_after_commit",
)
response.headers["X-TJWater-Changes-Committed"] = "true"
return response
@app.exception_handler(RequestValidationError)
async def validation_error_handler(
request: Request,
+13 -12
View File
@@ -1,5 +1,6 @@
from fastapi import APIRouter, Request, Query, Path, Body
from typing import Any, List, Dict, Union
from typing import Any
from fastapi import APIRouter, Body, Query
from app.services.tjnetwork import (
ChangeSet,
get_control,
@@ -13,7 +14,7 @@ from app.services.tjnetwork import (
router = APIRouter()
@router.get("/network-schemas/control", summary="获取控制架构", description="获取网络中控制对象的架构定义")
async def fastapi_get_control_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_control_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取控制架构。
返回指定网络中控制对象的属性架构定义。
@@ -21,7 +22,7 @@ async def fastapi_get_control_schema(network: str = Query(..., description="管
return get_control_schema(network)
@router.get("/controls/properties", summary="获取控制属性", description="获取指定网络中的控制属性信息")
async def fastapi_get_control_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
def fastapi_get_control_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
"""获取控制属性。
返回指定网络中的控制对象属性信息。
@@ -29,19 +30,19 @@ async def fastapi_get_control_properties(network: str = Query(..., description="
return get_control(network)
@router.patch("/controls/properties", response_model=None, summary="设置控制属性", description="更新指定网络中的控制属性")
async def fastapi_set_control_properties(
def fastapi_set_control_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置控制属性。
更新指定网络中的控制属性值。
"""
props = await req.json()
props = payload
return set_control(network, ChangeSet(props))
@router.get("/rule-schemas", summary="获取规则架构", description="获取网络中规则对象的架构定义")
async def fastapi_get_rule_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_rule_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取规则架构。
返回指定网络中规则对象的属性架构定义。
@@ -49,7 +50,7 @@ async def fastapi_get_rule_schema(network: str = Query(..., description="管网
return get_rule_schema(network)
@router.get("/rule-properties", summary="获取规则属性", description="获取指定网络中的规则属性信息")
async def fastapi_get_rule_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
def fastapi_get_rule_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
"""获取规则属性。
返回指定网络中的规则对象属性信息。
@@ -57,13 +58,13 @@ async def fastapi_get_rule_properties(network: str = Query(..., description="管
return get_rule(network)
@router.patch("/rule-properties", response_model=None, summary="设置规则属性", description="更新指定网络中的规则属性")
async def fastapi_set_rule_properties(
def fastapi_set_rule_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置规则属性。
更新指定网络中的规则属性值。
"""
props = await req.json()
props = payload
return set_rule(network, ChangeSet(props))
+14 -13
View File
@@ -1,5 +1,6 @@
from fastapi import APIRouter, Request, Query, Path, Body
from typing import Any, List, Dict, Union
from typing import Any
from fastapi import APIRouter, Body, Query
from app.services.tjnetwork import (
ChangeSet,
add_curve,
@@ -14,7 +15,7 @@ from app.services.tjnetwork import (
router = APIRouter()
@router.get("/network-schemas/curve", summary="获取曲线架构", description="获取网络中曲线对象的架构定义")
async def fastapi_get_curve_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_curve_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取曲线架构。
返回指定网络中曲线对象的属性架构定义。
@@ -22,23 +23,23 @@ async def fastapi_get_curve_schema(network: str = Query(..., description="管网
return get_curve_schema(network)
@router.post("/curves", response_model=None, summary="添加曲线", description="在网络中添加一条新的曲线")
async def fastapi_add_curve(
def fastapi_add_curve(
network: str = Query(..., description="管网名称(或数据库名称)"),
curve: str = Query(..., description="曲线ID"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""添加曲线。
在指定网络中创建一条新的曲线,并设置其初始属性。
"""
props = await req.json()
props = payload
ps = {
"id": curve,
} | props
return add_curve(network, ChangeSet(ps))
@router.delete("/curves", response_model=None, summary="删除曲线", description="从网络中删除指定的曲线")
async def fastapi_delete_curve(
def fastapi_delete_curve(
network: str = Query(..., description="管网名称(或数据库名称)"),
curve: str = Query(..., description="曲线ID")
) -> ChangeSet:
@@ -50,7 +51,7 @@ async def fastapi_delete_curve(
return delete_curve(network, ChangeSet(ps))
@router.get("/curves/properties", summary="获取曲线属性", description="获取指定曲线的属性信息")
async def fastapi_get_curve_properties(
def fastapi_get_curve_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
curve: str = Query(..., description="曲线ID")
) -> dict[str, Any]:
@@ -61,21 +62,21 @@ async def fastapi_get_curve_properties(
return get_curve(network, curve)
@router.patch("/curves/properties", response_model=None, summary="设置曲线属性", description="更新指定曲线的属性")
async def fastapi_set_curve_properties(
def fastapi_set_curve_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
curve: str = Query(..., description="曲线ID"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置曲线属性。
更新指定曲线的属性值。
"""
props = await req.json()
props = payload
ps = {"id": curve} | props
return set_curve(network, ChangeSet(ps))
@router.get("/curves", summary="获取所有曲线", description="获取网络中的所有曲线列表")
async def fastapi_get_curves(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[str]:
def fastapi_get_curves(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[str]:
"""获取所有曲线。
返回指定网络中的所有曲线ID列表。
@@ -83,7 +84,7 @@ async def fastapi_get_curves(network: str = Query(..., description="管网名称
return get_curves(network)
@router.get("/curves/existence", summary="检查曲线存在性", description="检查指定的曲线是否存在")
async def fastapi_is_curve(
def fastapi_is_curve(
network: str = Query(..., description="管网名称(或数据库名称)"),
curve: str = Query(..., description="曲线ID")
) -> bool:
+23 -22
View File
@@ -1,5 +1,6 @@
from fastapi import APIRouter, Request, Query, Path, Body
from typing import Any, List, Dict, Union
from typing import Any
from fastapi import APIRouter, Body, Query
from app.services.tjnetwork import (
ChangeSet,
get_energy,
@@ -19,7 +20,7 @@ from app.services.tjnetwork import (
router = APIRouter()
@router.get("/network-schemas/time", summary="获取时间选项架构", description="获取网络中时间选项的架构定义")
async def fastapi_get_time_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_time_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取时间选项架构。
返回指定网络中时间相关选项的属性架构定义。
@@ -27,7 +28,7 @@ async def fastapi_get_time_schema(network: str = Query(..., description="管网
return get_time_schema(network)
@router.get("/network-options/time", summary="获取时间选项属性", description="获取指定网络中的时间选项属性信息")
async def fastapi_get_time_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
def fastapi_get_time_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
"""获取时间选项属性。
返回指定网络中的时间相关选项属性。
@@ -35,19 +36,19 @@ async def fastapi_get_time_properties(network: str = Query(..., description="管
return get_time(network)
@router.patch("/time-properties", response_model=None, summary="设置时间选项属性", description="更新指定网络中的时间选项属性")
async def fastapi_set_time_properties(
def fastapi_set_time_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置时间选项属性。
更新指定网络中的时间相关选项属性值。
"""
props = await req.json()
props = payload
return set_time(network, ChangeSet(props))
@router.get("/network-schemas/energy", summary="获取能耗选项架构", description="获取网络中能耗选项的架构定义")
async def fastapi_get_energy_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_energy_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取能耗选项架构。
返回指定网络中能耗相关选项的属性架构定义。
@@ -55,7 +56,7 @@ async def fastapi_get_energy_schema(network: str = Query(..., description="管
return get_energy_schema(network)
@router.get("/network-options/energy", summary="获取能耗选项属性", description="获取指定网络中的能耗选项属性信息")
async def fastapi_get_energy_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
def fastapi_get_energy_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
"""获取能耗选项属性。
返回指定网络中的能耗相关选项属性。
@@ -63,19 +64,19 @@ async def fastapi_get_energy_properties(network: str = Query(..., description="
return get_energy(network)
@router.patch("/energy-properties", response_model=None, summary="设置能耗选项属性", description="更新指定网络中的能耗选项属性")
async def fastapi_set_energy_properties(
def fastapi_set_energy_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置能耗选项属性。
更新指定网络中的能耗相关选项属性值。
"""
props = await req.json()
props = payload
return set_energy(network, ChangeSet(props))
@router.get("/network-schemas/pump-energy", summary="获取泵能耗选项架构", description="获取网络中泵能耗选项的架构定义")
async def fastapi_get_pump_energy_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_pump_energy_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取泵能耗选项架构。
返回指定网络中泵能耗相关选项的属性架构定义。
@@ -83,7 +84,7 @@ async def fastapi_get_pump_energy_schema(network: str = Query(..., description="
return get_pump_energy_schema(network)
@router.get("/network-options/pump-energy", summary="获取泵能耗属性", description="获取指定泵的能耗属性信息")
async def fastapi_get_pump_energy_proeprties(
def fastapi_get_pump_energy_proeprties(
network: str = Query(..., description="管网名称(或数据库名称)"),
pump: str = Query(..., description="泵ID")
) -> dict[str, Any]:
@@ -94,21 +95,21 @@ async def fastapi_get_pump_energy_proeprties(
return get_pump_energy(network, pump)
@router.patch("/network-options/pump-energy", response_model=None, summary="设置泵能耗属性", description="更新指定泵的能耗属性")
async def fastapi_set_pump_energy_properties(
def fastapi_set_pump_energy_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
pump: str = Query(..., description="泵ID"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置泵能耗属性。
更新指定泵的能耗相关属性值。
"""
props = await req.json()
props = payload
ps = {"id": pump} | props
return set_pump_energy(network, ChangeSet(ps))
@router.get("/network-schemas/option", summary="获取选项架构", description="获取网络中选项对象的架构定义")
async def fastapi_get_option_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_option_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取选项架构。
返回指定网络中选项对象的属性架构定义。
@@ -116,7 +117,7 @@ async def fastapi_get_option_schema(network: str = Query(..., description="管
return get_option_v3_schema(network)
@router.get("/network-options", summary="获取选项属性", description="获取指定网络中的选项属性信息")
async def fastapi_get_option_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
def fastapi_get_option_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
"""获取选项属性。
返回指定网络中的选项对象属性信息。
@@ -124,13 +125,13 @@ async def fastapi_get_option_properties(network: str = Query(..., description="
return get_option_v3(network)
@router.patch("/network-options", response_model=None, summary="设置选项属性", description="更新指定网络中的选项属性")
async def fastapi_set_option_properties(
def fastapi_set_option_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置选项属性。
更新指定网络中的选项属性值。
"""
props = await req.json()
props = payload
return set_option_v3(network, ChangeSet(props))
+14 -13
View File
@@ -1,5 +1,6 @@
from fastapi import APIRouter, Request, Query, Path, Body
from typing import Any, List, Dict, Union
from typing import Any
from fastapi import APIRouter, Body, Query
from app.services.tjnetwork import (
ChangeSet,
add_pattern,
@@ -14,7 +15,7 @@ from app.services.tjnetwork import (
router = APIRouter()
@router.get("/network-schemas/pattern", summary="获取模式架构", description="获取网络中模式对象的架构定义")
async def fastapi_get_pattern_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_pattern_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取模式架构。
返回指定网络中模式对象的属性架构定义。
@@ -22,23 +23,23 @@ async def fastapi_get_pattern_schema(network: str = Query(..., description="管
return get_pattern_schema(network)
@router.post("/patterns", response_model=None, summary="添加模式", description="在网络中添加一个新的模式")
async def fastapi_add_pattern(
def fastapi_add_pattern(
network: str = Query(..., description="管网名称(或数据库名称)"),
pattern: str = Query(..., description="模式ID"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""添加模式。
在指定网络中创建一个新的模式,并设置其初始属性。
"""
props = await req.json()
props = payload
ps = {
"id": pattern,
} | props
return add_pattern(network, ChangeSet(ps))
@router.delete("/patterns", response_model=None, summary="删除模式", description="从网络中删除指定的模式")
async def fastapi_delete_pattern(
def fastapi_delete_pattern(
network: str = Query(..., description="管网名称(或数据库名称)"),
pattern: str = Query(..., description="模式ID")
) -> ChangeSet:
@@ -50,7 +51,7 @@ async def fastapi_delete_pattern(
return delete_pattern(network, ChangeSet(ps))
@router.get("/patterns/properties", summary="获取模式属性", description="获取指定模式的属性信息")
async def fastapi_get_pattern_properties(
def fastapi_get_pattern_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
pattern: str = Query(..., description="模式ID")
) -> dict[str, Any]:
@@ -61,21 +62,21 @@ async def fastapi_get_pattern_properties(
return get_pattern(network, pattern)
@router.patch("/patterns/properties", response_model=None, summary="设置模式属性", description="更新指定模式的属性")
async def fastapi_set_pattern_properties(
def fastapi_set_pattern_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
pattern: str = Query(..., description="模式ID"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置模式属性。
更新指定模式的属性值。
"""
props = await req.json()
props = payload
ps = {"id": pattern} | props
return set_pattern(network, ChangeSet(ps))
@router.get("/patterns/existence", summary="检查模式存在性", description="检查指定的模式是否存在")
async def fastapi_is_pattern(
def fastapi_is_pattern(
network: str = Query(..., description="管网名称(或数据库名称)"),
pattern: str = Query(..., description="模式ID")
) -> bool:
@@ -86,7 +87,7 @@ async def fastapi_is_pattern(
return is_pattern(network, pattern)
@router.get("/patterns", summary="获取所有模式", description="获取网络中的所有模式列表")
async def fastapi_get_patterns(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[str]:
def fastapi_get_patterns(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[str]:
"""获取所有模式。
返回指定网络中的所有模式ID列表。
+48 -47
View File
@@ -1,5 +1,6 @@
from fastapi import APIRouter, Request, Query, Path, Body
from typing import Any, List, Dict, Union
from typing import Any
from fastapi import APIRouter, Body, Query
from app.services.tjnetwork import (
ChangeSet,
add_mixing,
@@ -32,7 +33,7 @@ from app.services.tjnetwork import (
router = APIRouter()
@router.get("/network-schemas/quality", summary="获取水质架构", description="获取网络中水质对象的架构定义")
async def fastapi_get_quality_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_quality_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取水质架构。
返回指定网络中水质对象的属性架构定义。
@@ -40,7 +41,7 @@ async def fastapi_get_quality_schema(network: str = Query(..., description="管
return get_quality_schema(network)
@router.get("/quality-configurations/properties", summary="获取水质属性", description="获取指定节点的水质属性信息")
async def fastapi_get_quality_properties(
def fastapi_get_quality_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
node: str = Query(..., description="节点ID")
) -> dict[str, Any]:
@@ -51,19 +52,19 @@ async def fastapi_get_quality_properties(
return get_quality(network, node)
@router.patch("/quality-configurations/properties", response_model=None, summary="设置水质属性", description="更新指定节点的水质属性")
async def fastapi_set_quality_properties(
def fastapi_set_quality_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置水质属性。
更新指定节点的水质属性值。
"""
props = await req.json()
props = payload
return set_quality(network, ChangeSet(props))
@router.get("/network-schemas/emitter", summary="获取发射器架构", description="获取网络中发射器对象的架构定义")
async def fastapi_get_emitter_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_emitter_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取发射器架构。
返回指定网络中发射器对象的属性架构定义。
@@ -71,7 +72,7 @@ async def fastapi_get_emitter_schema(network: str = Query(..., description="管
return get_emitter_schema(network)
@router.get("/emitters/properties", summary="获取发射器属性", description="获取指定连接点的发射器属性信息")
async def fastapi_get_emitter_properties(
def fastapi_get_emitter_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="连接点ID")
) -> dict[str, Any]:
@@ -82,21 +83,21 @@ async def fastapi_get_emitter_properties(
return get_emitter(network, junction)
@router.patch("/emitters/properties", response_model=None, summary="设置发射器属性", description="更新指定连接点的发射器属性")
async def fastapi_set_emitter_properties(
def fastapi_set_emitter_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="连接点ID"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置发射器属性。
更新指定连接点的发射器属性值。
"""
props = await req.json()
props = payload
ps = {"junction": junction} | props
return set_emitter(network, ChangeSet(ps))
@router.get("/network-schemas/source", summary="获取水源架构", description="获取网络中水源对象的架构定义")
async def fastapi_get_source_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_source_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取水源架构。
返回指定网络中水源对象的属性架构定义。
@@ -104,7 +105,7 @@ async def fastapi_get_source_schema(network: str = Query(..., description="管
return get_source_schema(network)
@router.get("/sources/detail", summary="获取水源属性", description="获取指定节点的水源属性信息")
async def fastapi_get_source(
def fastapi_get_source(
network: str = Query(..., description="管网名称(或数据库名称)"),
node: str = Query(..., description="节点ID")
) -> dict[str, Any]:
@@ -115,31 +116,31 @@ async def fastapi_get_source(
return get_source(network, node)
@router.patch("/sources", response_model=None, summary="设置水源属性", description="更新指定节点的水源属性")
async def fastapi_set_source(
def fastapi_set_source(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置水源属性。
更新指定节点的水源属性值。
"""
props = await req.json()
props = payload
return set_source(network, ChangeSet(props))
@router.post("/sources", response_model=None, summary="添加水源", description="在网络中添加一个新的水源")
async def fastapi_add_source(
def fastapi_add_source(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""添加水源。
在指定网络中创建一个新的水源,并设置其初始属性。
"""
props = await req.json()
props = payload
return add_source(network, ChangeSet(props))
@router.delete("/sources", response_model=None, summary="删除水源", description="从网络中删除指定节点的水源")
async def fastapi_delete_source(
def fastapi_delete_source(
network: str = Query(..., description="管网名称(或数据库名称)"),
node: str = Query(..., description="节点ID")
) -> ChangeSet:
@@ -151,7 +152,7 @@ async def fastapi_delete_source(
return delete_source(network, ChangeSet(props))
@router.get("/network-schemas/reaction", summary="获取反应架构", description="获取网络中反应对象的架构定义")
async def fastapi_get_reaction_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_reaction_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取反应架构。
返回指定网络中反应对象的属性架构定义。
@@ -159,7 +160,7 @@ async def fastapi_get_reaction_schema(network: str = Query(..., description="管
return get_reaction_schema(network)
@router.get("/reactions/detail", summary="获取反应属性", description="获取指定网络中的反应属性信息")
async def fastapi_get_reaction(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
def fastapi_get_reaction(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
"""获取反应属性。
返回指定网络中的反应属性信息。
@@ -167,19 +168,19 @@ async def fastapi_get_reaction(network: str = Query(..., description="管网名
return get_reaction(network)
@router.patch("/reactions", response_model=None, summary="设置反应属性", description="更新指定网络中的反应属性")
async def fastapi_set_reaction(
def fastapi_set_reaction(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置反应属性。
更新指定网络中的反应属性值。
"""
props = await req.json()
props = payload
return set_reaction(network, ChangeSet(props))
@router.get("/network-schemas/pipe-reaction", summary="获取管道反应架构", description="获取网络中管道反应对象的架构定义")
async def fastapi_get_pipe_reaction_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_pipe_reaction_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取管道反应架构。
返回指定网络中管道反应对象的属性架构定义。
@@ -187,7 +188,7 @@ async def fastapi_get_pipe_reaction_schema(network: str = Query(..., description
return get_pipe_reaction_schema(network)
@router.get("/pipe-reactions/detail", summary="获取管道反应属性", description="获取指定管道的反应属性信息")
async def fastapi_get_pipe_reaction(
def fastapi_get_pipe_reaction(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID")
) -> dict[str, Any]:
@@ -198,19 +199,19 @@ async def fastapi_get_pipe_reaction(
return get_pipe_reaction(network, pipe)
@router.patch("/pipe-reactions", response_model=None, summary="设置管道反应属性", description="更新指定管道的反应属性")
async def fastapi_set_pipe_reaction(
def fastapi_set_pipe_reaction(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置管道反应属性。
更新指定管道的反应属性值。
"""
props = await req.json()
props = payload
return set_pipe_reaction(network, ChangeSet(props))
@router.get("/network-schemas/tank-reaction", summary="获取水池反应架构", description="获取网络中水池反应对象的架构定义")
async def fastapi_get_tank_reaction_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_tank_reaction_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取水池反应架构。
返回指定网络中水池反应对象的属性架构定义。
@@ -218,7 +219,7 @@ async def fastapi_get_tank_reaction_schema(network: str = Query(..., description
return get_tank_reaction_schema(network)
@router.get("/tank-reactions/detail", summary="获取水池反应属性", description="获取指定水池的反应属性信息")
async def fastapi_get_tank_reaction(
def fastapi_get_tank_reaction(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水池ID")
) -> dict[str, Any]:
@@ -229,19 +230,19 @@ async def fastapi_get_tank_reaction(
return get_tank_reaction(network, tank)
@router.patch("/tank-reactions", response_model=None, summary="设置水池反应属性", description="更新指定水池的反应属性")
async def fastapi_set_tank_reaction(
def fastapi_set_tank_reaction(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置水池反应属性。
更新指定水池的反应属性值。
"""
props = await req.json()
props = payload
return set_tank_reaction(network, ChangeSet(props))
@router.get("/network-schemas/mixing", summary="获取混合架构", description="获取网络中混合对象的架构定义")
async def fastapi_get_mixing_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_mixing_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取混合架构。
返回指定网络中混合对象的属性架构定义。
@@ -249,7 +250,7 @@ async def fastapi_get_mixing_schema(network: str = Query(..., description="管
return get_mixing_schema(network)
@router.get("/mixing-configurations/detail", summary="获取混合属性", description="获取指定水池的混合属性信息")
async def fastapi_get_mixing(
def fastapi_get_mixing(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水池ID")
) -> dict[str, Any]:
@@ -260,37 +261,37 @@ async def fastapi_get_mixing(
return get_mixing(network, tank)
@router.patch("/mixing-configurations", response_model=None, summary="设置混合属性", description="更新指定水池的混合属性")
async def fastapi_set_mixing(
def fastapi_set_mixing(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置混合属性。
更新指定水池的混合属性值。
"""
props = await req.json()
props = payload
return set_mixing(network, ChangeSet(props))
@router.post("/mixing-configurations", response_model=None, summary="添加混合", description="在网络中添加一个新的混合")
async def fastapi_add_mixing(
def fastapi_add_mixing(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""添加混合。
在指定网络中创建一个新的混合,并设置其初始属性。
"""
props = await req.json()
props = payload
return add_mixing(network, ChangeSet(props))
@router.delete("/mixing-configurations", response_model=None, summary="删除混合", description="从网络中删除指定的混合")
async def fastapi_delete_mixing(
def fastapi_delete_mixing(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""删除混合。
从指定网络中删除指定的混合及其相关数据。
"""
props = await req.json()
props = payload
return delete_mixing(network, ChangeSet(props))
+32 -31
View File
@@ -1,5 +1,6 @@
from fastapi import APIRouter, Request, Query, Path, Body, Response
from typing import Any, List, Dict, Union
from typing import Any
from fastapi import APIRouter, Body, Query
from app.services.tjnetwork import (
ChangeSet,
add_label,
@@ -24,7 +25,7 @@ import json
router = APIRouter()
@router.get("/network-schemas/vertex", summary="获取图形元素架构", description="获取网络中图形元素对象的架构定义")
async def fastapi_get_vertex_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_vertex_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取图形元素架构。
返回指定网络中图形元素对象的属性架构定义。
@@ -32,7 +33,7 @@ async def fastapi_get_vertex_schema(network: str = Query(..., description="管
return get_vertex_schema(network)
@router.get("/visual-elements/properties", summary="获取图形元素属性", description="获取指定图形元素的属性信息")
async def fastapi_get_vertex_properties(
def fastapi_get_vertex_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
link: str = Query(..., description="图形元素链接")
) -> dict[str, Any]:
@@ -43,43 +44,43 @@ async def fastapi_get_vertex_properties(
return get_vertex(network, link)
@router.patch("/visual-elements/properties", response_model=None, summary="设置图形元素属性", description="更新指定图形元素的属性")
async def fastapi_set_vertex_properties(
def fastapi_set_vertex_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置图形元素属性。
更新指定图形元素的属性值。
"""
props = await req.json()
props = payload
return set_vertex(network, ChangeSet(props))
@router.post("/visual-elements", response_model=None, summary="添加图形元素", description="在网络中添加一个新的图形元素")
async def fastapi_add_vertex(
def fastapi_add_vertex(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""添加图形元素。
在指定网络中创建一个新的图形元素,并设置其初始属性。
"""
props = await req.json()
props = payload
return add_vertex(network, ChangeSet(props))
@router.delete("/visual-elements", response_model=None, summary="删除图形元素", description="从网络中删除指定的图形元素")
async def fastapi_delete_vertex(
def fastapi_delete_vertex(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""删除图形元素。
从指定网络中删除指定的图形元素及其相关数据。
"""
props = await req.json()
props = payload
return delete_vertex(network, ChangeSet(props))
@router.get("/visual-elements/links", response_class=PlainTextResponse, summary="获取所有图形元素链接", description="获取网络中的所有图形元素链接列表")
async def fastapi_get_all_vertex_links(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[str]:
def fastapi_get_all_vertex_links(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[str]:
"""获取所有图形元素链接。
返回指定网络中的所有图形元素链接列表。
@@ -87,7 +88,7 @@ async def fastapi_get_all_vertex_links(network: str = Query(..., description="
return json.dumps(get_all_vertex_links(network))
@router.get("/all-vertices", response_class=PlainTextResponse, summary="获取所有图形元素", description="获取网络中的所有图形元素详细信息")
async def fastapi_get_all_vertices(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[dict[str, Any]]:
def fastapi_get_all_vertices(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[dict[str, Any]]:
"""获取所有图形元素。
返回指定网络中的所有图形元素详细信息。
@@ -95,7 +96,7 @@ async def fastapi_get_all_vertices(network: str = Query(..., description="管网
return json.dumps(get_all_vertices(network))
@router.get("/network-schemas/label", summary="获取标签架构", description="获取网络中标签对象的架构定义")
async def fastapi_get_label_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_label_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取标签架构。
返回指定网络中标签对象的属性架构定义。
@@ -103,7 +104,7 @@ async def fastapi_get_label_schema(network: str = Query(..., description="管网
return get_label_schema(network)
@router.get("/labels/properties", summary="获取标签属性", description="获取指定坐标处的标签属性信息")
async def fastapi_get_label_properties(
def fastapi_get_label_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
x: float = Query(..., description="X坐标"),
y: float = Query(..., description="Y坐标")
@@ -115,43 +116,43 @@ async def fastapi_get_label_properties(
return get_label(network, x, y)
@router.patch("/labels/properties", response_model=None, summary="设置标签属性", description="更新指定标签的属性")
async def fastapi_set_label_properties(
def fastapi_set_label_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置标签属性。
更新指定标签的属性值。
"""
props = await req.json()
props = payload
return set_label(network, ChangeSet(props))
@router.post("/labels", response_model=None, summary="添加标签", description="在网络中添加一个新的标签")
async def fastapi_add_label(
def fastapi_add_label(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""添加标签。
在指定网络中创建一个新的标签,并设置其初始属性。
"""
props = await req.json()
props = payload
return add_label(network, ChangeSet(props))
@router.delete("/labels", response_model=None, summary="删除标签", description="从网络中删除指定的标签")
async def fastapi_delete_label(
def fastapi_delete_label(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""删除标签。
从指定网络中删除指定的标签及其相关数据。
"""
props = await req.json()
props = payload
return delete_label(network, ChangeSet(props))
@router.get("/network-schemas/backdrop", summary="获取背景架构", description="获取网络中背景对象的架构定义")
async def fastapi_get_backdrop_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_backdrop_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""获取背景架构。
返回指定网络中背景对象的属性架构定义。
@@ -159,7 +160,7 @@ async def fastapi_get_backdrop_schema(network: str = Query(..., description="管
return get_backdrop_schema(network)
@router.get("/backdrops/properties", summary="获取背景属性", description="获取指定网络的背景属性信息")
async def fastapi_get_backdrop_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
def fastapi_get_backdrop_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
"""获取背景属性。
返回指定网络的背景属性信息。
@@ -167,13 +168,13 @@ async def fastapi_get_backdrop_properties(network: str = Query(..., description=
return get_backdrop(network)
@router.patch("/backdrops/properties", response_model=None, summary="设置背景属性", description="更新指定网络的背景属性")
async def fastapi_set_backdrop_properties(
def fastapi_set_backdrop_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置背景属性。
更新指定网络的背景属性值。
"""
props = await req.json()
props = payload
return set_backdrop(network, ChangeSet(props))
+6 -7
View File
@@ -2,14 +2,11 @@ import logging
from fastapi import APIRouter, Depends, HTTPException, status, Query, Path
import psycopg
from psycopg import AsyncConnection
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.project_dependencies import (
ProjectContext,
get_project_context,
get_project_pg_session,
get_project_pg_connection,
get_project_timescale_connection,
get_metadata_repository,
)
@@ -90,7 +87,7 @@ async def list_user_projects(
@router.get("/projects/current/database-health", summary="检查数据库健康状态", description="检查项目数据库连接的健康状况")
async def project_db_health(
pg_session: AsyncSession = Depends(get_project_pg_session),
pg_conn: AsyncConnection = Depends(get_project_pg_connection),
ts_conn: AsyncConnection = Depends(get_project_timescale_connection),
):
"""
@@ -99,8 +96,10 @@ async def project_db_health(
检查PostgreSQL和TimescaleDB数据库的连接状态
"""
try:
await pg_session.execute(text("SELECT 1"))
except SQLAlchemyError as exc:
async with pg_conn.cursor() as cur:
await cur.execute("SELECT 1")
await cur.fetchone()
except psycopg.Error as exc:
logger.error("Project PostgreSQL health check failed", exc_info=True)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+16 -4
View File
@@ -12,6 +12,7 @@ from fastapi import (
UploadFile,
status,
)
from starlette.concurrency import run_in_threadpool
from app.auth.metadata_dependencies import (
get_current_metadata_admin,
@@ -24,6 +25,7 @@ from app.auth.project_dependencies import (
from app.core.audit import AuditAction, log_audit_event
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.tjnetwork import run_inp
@@ -87,8 +89,8 @@ def _validate_inp_bytes(content: bytes, filename: str) -> str:
async def _read_upload(file: UploadFile) -> tuple[bytes, str]:
filename = Path(file.filename or "").name
content = await file.read(MAX_INP_FILE_BYTES + 1)
_validate_inp_bytes(content, filename)
return content, filename
normalized = _validate_inp_bytes(content, filename).encode("utf-8")
return normalized, filename
async def _audit_model_change(
@@ -114,7 +116,7 @@ async def _audit_model_change(
)
async def _run_uploaded_inp(content: bytes) -> str:
def _run_uploaded_inp_sync(content: bytes) -> str:
target_dir = Path("inp")
target_dir.mkdir(parents=True, exist_ok=True)
model_name = f"admin_model_{uuid4().hex}"
@@ -123,7 +125,11 @@ async def _run_uploaded_inp(content: bytes) -> str:
return run_inp(model_name)
async def _update_from_inp(content: bytes, project_code: str) -> None:
async def _run_uploaded_inp(content: bytes) -> str:
return await run_in_threadpool(_run_uploaded_inp_sync, content)
def _update_from_inp_sync(content: bytes, project_code: str) -> None:
temp_path: Path | None = None
try:
with NamedTemporaryFile(suffix=".inp", delete=False) as temp_file:
@@ -135,9 +141,15 @@ async def _update_from_inp(content: bytes, project_code: str) -> None:
temp_path.unlink(missing_ok=True)
async def _update_from_inp(content: bytes, project_code: str) -> None:
await run_in_threadpool(_update_from_inp_sync, content, project_code)
async def _apply_model_update(content: bytes, project_code: str) -> None:
try:
await _update_from_inp(content, project_code)
except MaterializedViewRefreshAfterCommitError:
raise
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+15 -14
View File
@@ -1,5 +1,6 @@
from fastapi import APIRouter, Request, Query, Path, Body
from typing import Any, List, Dict, Union
from typing import Any
from fastapi import APIRouter, Body, Query
from app.services.tjnetwork import (
ChangeSet,
calculate_demand_to_network,
@@ -21,7 +22,7 @@ router = APIRouter()
summary="获取需水量属性架构",
description="获取指定水网中需水量(Demand)的属性架构定义"
)
async def fastapi_get_demand_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fastapi_get_demand_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""
获取需水量属性架构。
@@ -35,7 +36,7 @@ async def fastapi_get_demand_schema(network: str = Query(..., description="管
summary="获取需水量属性",
description="获取指定水网中节点的需水量属性信息"
)
async def fastapi_get_demand_properties(
def fastapi_get_demand_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点ID")
) -> dict[str, Any]:
@@ -54,17 +55,17 @@ async def fastapi_get_demand_properties(
summary="设置需水量属性",
description="设置指定水网中节点的需水量属性信息"
)
async def fastapi_set_demand_properties(
def fastapi_set_demand_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点ID"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""
设置节点的需水量属性。
修改指定节点的需水量信息。请求体应包含需水量值、水压等级等属性。
"""
props = await req.json()
props = payload
ps = {"junction": junction} | props
return set_demand(network, ChangeSet(ps))
@@ -76,9 +77,9 @@ async def fastapi_set_demand_properties(
summary="计算需水量到节点分配",
description="将总需水量按指定方式分配到多个节点"
)
async def fastapi_calculate_demand_to_nodes(
def fastapi_calculate_demand_to_nodes(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> dict[str, float]:
"""
计算需水量到节点分配。
@@ -91,7 +92,7 @@ async def fastapi_calculate_demand_to_nodes(
"nodes": 节点ID列表(list[str])
}
"""
props = await req.json()
props = payload
demand = props["demand"]
nodes = props["nodes"]
return calculate_demand_to_nodes(network, demand, nodes)
@@ -101,9 +102,9 @@ async def fastapi_calculate_demand_to_nodes(
summary="计算需水量到区域分配",
description="将总需水量按区域特征分配到该区域内的节点"
)
async def fastapi_calculate_demand_to_region(
def fastapi_calculate_demand_to_region(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> dict[str, float]:
"""
计算需水量到区域分配。
@@ -116,7 +117,7 @@ async def fastapi_calculate_demand_to_region(
"region": 区域ID(str)
}
"""
props = await req.json()
props = payload
demand = props["demand"]
region = props["region"]
return calculate_demand_to_region(network, demand, region)
@@ -126,7 +127,7 @@ async def fastapi_calculate_demand_to_region(
summary="计算需水量到整网分配",
description="将需水量均匀分配到整个水网的所有需水节点"
)
async def fastapi_calculate_demand_to_network(
def fastapi_calculate_demand_to_network(
network: str = Query(..., description="管网名称(或数据库名称)"),
demand: float = Query(..., description="总需水量(m³/h)", gt=0)
) -> dict[str, float]:
+35 -34
View File
@@ -1,5 +1,6 @@
from fastapi import APIRouter, Request, Query, Path, Body
from typing import Any, List, Dict, Union
from typing import Any
from fastapi import APIRouter, Body, Query
from app.services.tjnetwork import (
ChangeSet,
delete_junction,
@@ -48,7 +49,7 @@ router = APIRouter()
summary="检查节点有效性",
description="检查指定ID是否为水网中的有效节点"
)
async def fastapi_is_node(
def fastapi_is_node(
network: str = Query(..., description="管网名称(或数据库名称)"),
node: str = Query(..., description="节点ID")
) -> bool:
@@ -60,7 +61,7 @@ async def fastapi_is_node(
summary="检查是否为接点",
description="检查指定ID是否为水网中的接点(需求点)"
)
async def fastapi_is_junction(
def fastapi_is_junction(
network: str = Query(..., description="管网名称(或数据库名称)"),
node: str = Query(..., description="节点ID")
) -> bool:
@@ -72,7 +73,7 @@ async def fastapi_is_junction(
summary="检查是否为水源",
description="检查指定ID是否为水网中的水源(水库/河流)"
)
async def fastapi_is_reservoir(
def fastapi_is_reservoir(
network: str = Query(..., description="管网名称(或数据库名称)"),
node: str = Query(..., description="节点ID")
) -> bool:
@@ -84,7 +85,7 @@ async def fastapi_is_reservoir(
summary="检查是否为蓄水池",
description="检查指定ID是否为水网中的蓄水池"
)
async def fastapi_is_tank(
def fastapi_is_tank(
network: str = Query(..., description="管网名称(或数据库名称)"),
node: str = Query(..., description="节点ID")
) -> bool:
@@ -96,7 +97,7 @@ async def fastapi_is_tank(
summary="检查管线有效性",
description="检查指定ID是否为水网中的有效管线"
)
async def fastapi_is_link(
def fastapi_is_link(
network: str = Query(..., description="管网名称(或数据库名称)"),
link: str = Query(..., description="管线ID")
) -> bool:
@@ -108,7 +109,7 @@ async def fastapi_is_link(
summary="检查是否为管道",
description="检查指定ID是否为水网中的管道"
)
async def fastapi_is_pipe(
def fastapi_is_pipe(
network: str = Query(..., description="管网名称(或数据库名称)"),
link: str = Query(..., description="管线ID")
) -> bool:
@@ -120,7 +121,7 @@ async def fastapi_is_pipe(
summary="检查是否为泵",
description="检查指定ID是否为水网中的泵"
)
async def fastapi_is_pump(
def fastapi_is_pump(
network: str = Query(..., description="管网名称(或数据库名称)"),
link: str = Query(..., description="管线ID")
) -> bool:
@@ -132,7 +133,7 @@ async def fastapi_is_pump(
summary="检查是否为阀门",
description="检查指定ID是否为水网中的阀门"
)
async def fastapi_is_valve(
def fastapi_is_valve(
network: str = Query(..., description="管网名称(或数据库名称)"),
link: str = Query(..., description="管线ID")
) -> bool:
@@ -144,7 +145,7 @@ async def fastapi_is_valve(
summary="获取节点类型",
description="获取指定节点的类型(接点/水源/蓄水池)"
)
async def fastapi_get_node_type(
def fastapi_get_node_type(
network: str = Query(..., description="管网名称(或数据库名称)"),
node: str = Query(..., description="节点ID")
) -> str:
@@ -156,7 +157,7 @@ async def fastapi_get_node_type(
summary="获取管线类型",
description="获取指定管线的类型(管道/泵/阀门)"
)
async def fastapi_get_link_type(
def fastapi_get_link_type(
network: str = Query(..., description="管网名称(或数据库名称)"),
link: str = Query(..., description="管线ID")
) -> str:
@@ -168,7 +169,7 @@ async def fastapi_get_link_type(
summary="获取元素类型",
description="获取指定元素的类型(节点或管线)"
)
async def fastapi_get_element_type(
def fastapi_get_element_type(
network: str = Query(..., description="管网名称(或数据库名称)"),
element: str = Query(..., description="元素ID")
) -> str:
@@ -180,7 +181,7 @@ async def fastapi_get_element_type(
summary="获取元素类型值",
description="获取指定元素的类型数值标识"
)
async def fastapi_get_element_type_value(
def fastapi_get_element_type_value(
network: str = Query(..., description="管网名称(或数据库名称)"),
element: str = Query(..., description="元素ID")
) -> int:
@@ -192,7 +193,7 @@ async def fastapi_get_element_type_value(
summary="获取所有节点",
description="获取指定水网中的所有节点ID列表"
)
async def fastapi_get_nodes(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[str]:
def fastapi_get_nodes(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[str]:
"""获取水网中所有节点的ID列表。"""
return get_nodes(network)
@@ -201,7 +202,7 @@ async def fastapi_get_nodes(network: str = Query(..., description="管网名称
summary="获取所有管线",
description="获取指定水网中的所有管线ID列表"
)
async def fastapi_get_links(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[str]:
def fastapi_get_links(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[str]:
"""获取水网中所有管线的ID列表。"""
return get_links(network)
@@ -226,7 +227,7 @@ def get_node_links_endpoint(
summary="获取节点属性",
description="获取指定节点的所有属性信息"
)
async def fast_get_node_properties(
def fast_get_node_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
node: str = Query(..., description="节点ID")
) -> dict[str, Any]:
@@ -238,7 +239,7 @@ async def fast_get_node_properties(
summary="获取管线属性",
description="获取指定管线的所有属性信息"
)
async def fast_get_link_properties(
def fast_get_link_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
link: str = Query(..., description="管线ID")
) -> dict[str, Any]:
@@ -250,7 +251,7 @@ async def fast_get_link_properties(
summary="获取SCADA点属性",
description="获取指定SCADA点的属性信息"
)
async def fast_get_scada_properties(
def fast_get_scada_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
scada: str = Query(..., description="SCADA点ID")
) -> dict[str, Any]:
@@ -262,7 +263,7 @@ async def fast_get_scada_properties(
summary="获取所有SCADA点属性",
description="获取指定水网中所有SCADA点的属性信息"
)
async def fast_get_all_scada_properties(
def fast_get_all_scada_properties(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> list[dict[str, Any]]:
"""获取水网中所有SCADA点的属性列表。"""
@@ -273,7 +274,7 @@ async def fast_get_all_scada_properties(
summary="获取指定类型元素属性",
description="获取指定类型的元素属性信息"
)
async def fast_get_element_properties_with_type(
def fast_get_element_properties_with_type(
network: str = Query(..., description="管网名称(或数据库名称)"),
elementtype: str = Query(..., description="元素类型"),
element: str = Query(..., description="元素ID")
@@ -286,7 +287,7 @@ async def fast_get_element_properties_with_type(
summary="获取元素属性",
description="获取指定元素的属性信息"
)
async def fast_get_element_properties(
def fast_get_element_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
element: str = Query(..., description="元素ID")
) -> dict[str, Any]:
@@ -302,7 +303,7 @@ async def fast_get_element_properties(
summary="获取标题属性架构",
description="获取指定水网的标题(标题)属性架构定义"
)
async def fast_get_title_schema(
def fast_get_title_schema(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> dict[str, dict[str, Any]]:
"""获取水网标题的属性架构。"""
@@ -313,7 +314,7 @@ async def fast_get_title_schema(
summary="获取水网标题属性",
description="获取指定水网的标题(Title)信息"
)
async def fast_get_title(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
def fast_get_title(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
"""获取水网的标题属性。"""
return get_title(network)
@@ -323,12 +324,12 @@ async def fast_get_title(network: str = Query(..., description="管网名称(
summary="设置水网标题属性",
description="设置指定水网的标题(Title)信息"
)
async def fastapi_set_title(
def fastapi_set_title(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置水网的标题属性。"""
props = await req.json()
props = payload
return set_title(network, ChangeSet(props))
############################################################
@@ -340,7 +341,7 @@ async def fastapi_set_title(
summary="获取状态属性架构",
description="获取指定水网的状态(Status)属性架构定义"
)
async def fastapi_get_status_schema(
def fastapi_get_status_schema(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> dict[str, dict[str, Any]]:
"""获取水网状态的属性架构。"""
@@ -351,7 +352,7 @@ async def fastapi_get_status_schema(
summary="获取管线状态",
description="获取指定管线的状态信息"
)
async def fastapi_get_status(
def fastapi_get_status(
network: str = Query(..., description="管网名称(或数据库名称)"),
link: str = Query(..., description="管线ID")
) -> dict[str, Any]:
@@ -364,13 +365,13 @@ async def fastapi_get_status(
summary="设置管线状态",
description="设置指定管线的状态信息"
)
async def fastapi_set_status_properties(
def fastapi_set_status_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
link: str = Query(..., description="管线ID"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置管线的状态属性。"""
props = await req.json()
props = payload
ps = {"link": link} | props
return set_status(network, ChangeSet(ps))
@@ -384,7 +385,7 @@ async def fastapi_set_status_properties(
summary="删除节点",
description="删除指定的节点(接点/水源/蓄水池)"
)
async def fastapi_delete_node(
def fastapi_delete_node(
network: str = Query(..., description="管网名称(或数据库名称)"),
node: str = Query(..., description="节点ID")
) -> ChangeSet:
@@ -404,7 +405,7 @@ async def fastapi_delete_node(
summary="删除管线",
description="删除指定的管线(管道/泵/阀门)"
)
async def fastapi_delete_link(
def fastapi_delete_link(
network: str = Query(..., description="管网名称(或数据库名称)"),
link: str = Query(..., description="管线ID")
) -> ChangeSet:
+6 -6
View File
@@ -27,7 +27,7 @@ router = APIRouter()
# # example: set_coord(p, ChangeSet({'node': 'j1', 'x': 1.0, 'y': 2.0}))
# @router.post("/setcoord/", response_model=None)
# async def fastapi_set_coord(network: str, req: Request) -> ChangeSet:
# props = await req.json()
# props = payload
# return set_coord(network, ChangeSet(props))
@router.get(
@@ -35,7 +35,7 @@ router = APIRouter()
summary="获取节点坐标",
description="获取指定节点的地理坐标(X, Y)"
)
async def fastapi_get_node_coord(
def fastapi_get_node_coord(
network: str = Query(..., description="管网名称(或数据库名称)"),
node: str = Query(..., description="节点ID")
) -> dict[str, float] | None:
@@ -48,7 +48,7 @@ async def fastapi_get_node_coord(
summary="获取范围内的网络元素",
description="获取指定地理范围内的网络节点和管线"
)
async def fastapi_get_network_in_extent(
def fastapi_get_network_in_extent(
network: str = Query(..., description="管网名称(或数据库名称)"),
x1: float = Query(..., description="范围左下角X坐标", alias="x1"),
y1: float = Query(..., description="范围左下角Y坐标", alias="y1"),
@@ -63,7 +63,7 @@ async def fastapi_get_network_in_extent(
summary="获取主要节点坐标",
description="获取直径大于等于指定值的节点坐标"
)
async def fastapi_get_majornode_coords(
def fastapi_get_majornode_coords(
network: str = Query(..., description="管网名称(或数据库名称)"),
diameter: int = Query(..., description="最小直径(mm)", gt=0)
) -> dict[str, dict[str, float]]:
@@ -75,7 +75,7 @@ async def fastapi_get_majornode_coords(
summary="获取主要管道节点",
description="获取直径大于等于指定值的管道的节点ID"
)
async def fastapi_get_major_pipe_nodes(
def fastapi_get_major_pipe_nodes(
network: str = Query(..., description="管网名称(或数据库名称)"),
diameter: int = Query(..., description="最小直径(mm)", gt=0)
) -> list[str] | None:
@@ -87,7 +87,7 @@ async def fastapi_get_major_pipe_nodes(
summary="获取网络管线节点",
description="获取指定水网所有管线的起点和终点节点"
)
async def fastapi_get_network_link_nodes(
def fastapi_get_network_link_nodes(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> list[str] | None:
"""获取网络中所有管线的连接节点。"""
+23 -22
View File
@@ -1,5 +1,6 @@
from fastapi import APIRouter, Request, Query, Path, Body
from typing import Any, List, Dict, Union
from typing import Any
from fastapi import APIRouter, Body, Query
from app.services.tjnetwork import (
ChangeSet,
add_junction,
@@ -13,7 +14,7 @@ from app.services.tjnetwork import (
router = APIRouter()
@router.get("/network-schemas/junction", summary="获取节点架构", description="获取指定项目的节点属性架构和数据类型定义。")
async def fast_get_junction_schema(
def fast_get_junction_schema(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> dict[str, dict[str, Any]]:
"""
@@ -27,7 +28,7 @@ async def fast_get_junction_schema(
return get_junction_schema(network)
@router.post("/junctions", response_model=None, summary="添加节点", description="在供水网络中添加新的节点,指定节点ID和空间坐标。")
async def fastapi_add_junction(
def fastapi_add_junction(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID"),
x: float = Query(..., description="X 坐标"),
@@ -51,7 +52,7 @@ async def fastapi_add_junction(
return add_junction(network, ChangeSet(ps))
@router.delete("/junctions", response_model=None, summary="删除节点", description="从供水网络中删除指定的节点。")
async def fastapi_delete_junction(
def fastapi_delete_junction(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID")
) -> ChangeSet:
@@ -69,7 +70,7 @@ async def fastapi_delete_junction(
return delete_junction(network, ChangeSet(ps))
@router.get("/junctions/elevation", summary="获取节点标高", description="获取指定节点的标高(海拔高度)。")
async def fastapi_get_junction_elevation(
def fastapi_get_junction_elevation(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID")
) -> float:
@@ -87,7 +88,7 @@ async def fastapi_get_junction_elevation(
return ps["elevation"]
@router.get("/junctions/x", summary="获取节点 X 坐标", description="获取指定节点的 X 坐标值。")
async def fastapi_get_junction_x(
def fastapi_get_junction_x(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID")
) -> float:
@@ -105,7 +106,7 @@ async def fastapi_get_junction_x(
return ps["x"]
@router.get("/junctions/y", summary="获取节点 Y 坐标", description="获取指定节点的 Y 坐标值。")
async def fastapi_get_junction_y(
def fastapi_get_junction_y(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID")
) -> float:
@@ -123,7 +124,7 @@ async def fastapi_get_junction_y(
return ps["y"]
@router.get("/junctions/coord", summary="获取节点坐标", description="获取指定节点的 X 和 Y 坐标。")
async def fastapi_get_junction_coord(
def fastapi_get_junction_coord(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID")
) -> dict[str, float]:
@@ -142,7 +143,7 @@ async def fastapi_get_junction_coord(
return coord
@router.get("/junctions/demand", summary="获取节点需水量", description="获取指定节点的需水量。")
async def fastapi_get_junction_demand(
def fastapi_get_junction_demand(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID")
) -> float:
@@ -160,7 +161,7 @@ async def fastapi_get_junction_demand(
return ps["demand"]
@router.get("/junctions/pattern", summary="获取节点需水模式", description="获取指定节点的需水模式标识。")
async def fastapi_get_junction_pattern(
def fastapi_get_junction_pattern(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID")
) -> str:
@@ -178,7 +179,7 @@ async def fastapi_get_junction_pattern(
return ps["pattern"]
@router.patch("/junctions/elevation", response_model=None, summary="设置节点标高", description="设置指定节点的标高值。")
async def fastapi_set_junction_elevation(
def fastapi_set_junction_elevation(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID"),
elevation: float = Query(..., description="标高(海拔高度)")
@@ -198,7 +199,7 @@ async def fastapi_set_junction_elevation(
return set_junction(network, ChangeSet(ps))
@router.patch("/junctions/x", response_model=None, summary="设置节点 X 坐标", description="设置指定节点的 X 坐标值。")
async def fastapi_set_junction_x(
def fastapi_set_junction_x(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID"),
x: float = Query(..., description="X 坐标值")
@@ -218,7 +219,7 @@ async def fastapi_set_junction_x(
return set_junction(network, ChangeSet(ps))
@router.patch("/junctions/y", response_model=None, summary="设置节点 Y 坐标", description="设置指定节点的 Y 坐标值。")
async def fastapi_set_junction_y(
def fastapi_set_junction_y(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID"),
y: float = Query(..., description="Y 坐标值")
@@ -238,7 +239,7 @@ async def fastapi_set_junction_y(
return set_junction(network, ChangeSet(ps))
@router.patch("/junctions/coord", response_model=None, summary="设置节点坐标", description="设置指定节点的 X 和 Y 坐标。")
async def fastapi_set_junction_coord(
def fastapi_set_junction_coord(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID"),
x: float = Query(..., description="X 坐标值"),
@@ -260,7 +261,7 @@ async def fastapi_set_junction_coord(
return set_junction(network, ChangeSet(ps))
@router.patch("/junctions/demand", response_model=None, summary="设置节点需水量", description="设置指定节点的需水量。")
async def fastapi_set_junction_demand(
def fastapi_set_junction_demand(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID"),
demand: float = Query(..., description="需水量值")
@@ -280,7 +281,7 @@ async def fastapi_set_junction_demand(
return set_junction(network, ChangeSet(ps))
@router.patch("/junctions/pattern", response_model=None, summary="设置节点需水模式", description="设置指定节点的需水模式标识。")
async def fastapi_set_junction_pattern(
def fastapi_set_junction_pattern(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID"),
pattern: str = Query(..., description="需水模式标识")
@@ -300,7 +301,7 @@ async def fastapi_set_junction_pattern(
return set_junction(network, ChangeSet(ps))
@router.get("/junctions/properties", summary="获取节点属性", description="获取指定节点的所有属性信息。")
async def fastapi_get_junction_properties(
def fastapi_get_junction_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID")
) -> dict[str, Any]:
@@ -317,7 +318,7 @@ async def fastapi_get_junction_properties(
return get_junction(network, junction)
@router.get("/junctions", summary="获取所有节点属性", description="获取指定项目中所有节点的属性信息。")
async def fastapi_get_all_junction_properties(
def fastapi_get_all_junction_properties(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> list[dict[str, Any]]:
"""
@@ -336,10 +337,10 @@ async def fastapi_get_all_junction_properties(
return results
@router.patch("/junctions/properties", response_model=None, summary="批量设置节点属性", description="批量设置指定节点的多个属性。")
async def fastapi_set_junction_properties(
def fastapi_set_junction_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
junction: str = Query(..., description="节点 ID"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""
批量设置节点属性
@@ -354,6 +355,6 @@ async def fastapi_set_junction_properties(
Returns:
ChangeSet: 包含变更信息的结果
"""
props = await req.json()
props = payload
ps = {"id": junction} | props
return set_junction(network, ChangeSet(ps))
+25 -24
View File
@@ -1,5 +1,6 @@
from fastapi import APIRouter, Request, Query, Path, Body
from typing import Any, List, Dict, Union
from typing import Any
from fastapi import APIRouter, Body, Query
from app.services.tjnetwork import (
ChangeSet,
PIPE_STATUS_OPEN,
@@ -14,7 +15,7 @@ from app.services.tjnetwork import (
router = APIRouter()
@router.get("/network-schemas/pipe", summary="获取管道模式", description="获取管道对象的模式定义,包含所有可用字段及其类型")
async def fastapi_get_pipe_schema(
def fastapi_get_pipe_schema(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> dict[str, dict[str, Any]]:
"""
@@ -29,7 +30,7 @@ async def fastapi_get_pipe_schema(
return get_pipe_schema(network)
@router.post("/pipes", response_model=None, summary="添加管道", description="向网络中添加新的管道,需要提供管道的基本参数如长度、管径、粗糙度等")
async def fastapi_add_pipe(
def fastapi_add_pipe(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道标识符"),
node1: str = Query(..., description="管道起始节点ID"),
@@ -70,7 +71,7 @@ async def fastapi_add_pipe(
return add_pipe(network, ChangeSet(ps))
@router.delete("/pipes", response_model=None, summary="删除管道", description="从网络中删除指定的管道")
async def fastapi_delete_pipe(
def fastapi_delete_pipe(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="要删除的管道ID")
) -> ChangeSet:
@@ -88,7 +89,7 @@ async def fastapi_delete_pipe(
return delete_pipe(network, ChangeSet(ps))
@router.get("/pipes/node1", summary="获取管道起始节点", description="获取指定管道的起始节点ID")
async def fastapi_get_pipe_node1(
def fastapi_get_pipe_node1(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID")
) -> str | None:
@@ -106,7 +107,7 @@ async def fastapi_get_pipe_node1(
return ps["node1"]
@router.get("/pipes/node2", summary="获取管道终止节点", description="获取指定管道的终止节点ID")
async def fastapi_get_pipe_node2(
def fastapi_get_pipe_node2(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID")
) -> str | None:
@@ -124,7 +125,7 @@ async def fastapi_get_pipe_node2(
return ps["node2"]
@router.get("/pipes/length", summary="获取管道长度", description="获取指定管道的长度")
async def fastapi_get_pipe_length(
def fastapi_get_pipe_length(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID")
) -> float | None:
@@ -142,7 +143,7 @@ async def fastapi_get_pipe_length(
return ps["length"]
@router.get("/pipes/diameter", summary="获取管道管径", description="获取指定管道的管径")
async def fastapi_get_pipe_diameter(
def fastapi_get_pipe_diameter(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID")
) -> float | None:
@@ -160,7 +161,7 @@ async def fastapi_get_pipe_diameter(
return ps["diameter"]
@router.get("/pipes/roughness", summary="获取管道粗糙度", description="获取指定管道的粗糙度")
async def fastapi_get_pipe_roughness(
def fastapi_get_pipe_roughness(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID")
) -> float | None:
@@ -178,7 +179,7 @@ async def fastapi_get_pipe_roughness(
return ps["roughness"]
@router.get("/pipes/minor-loss", summary="获取管道局部阻力系数", description="获取指定管道的局部阻力系数")
async def fastapi_get_pipe_minor_loss(
def fastapi_get_pipe_minor_loss(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID")
) -> float | None:
@@ -196,7 +197,7 @@ async def fastapi_get_pipe_minor_loss(
return ps["minor_loss"]
@router.get("/pipes/status", summary="获取管道状态", description="获取指定管道的状态(开启或关闭)")
async def fastapi_get_pipe_status(
def fastapi_get_pipe_status(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID")
) -> str | None:
@@ -214,7 +215,7 @@ async def fastapi_get_pipe_status(
return ps["status"]
@router.patch("/pipes/node1", response_model=None, summary="设置管道起始节点", description="设置指定管道的起始节点")
async def fastapi_set_pipe_node1(
def fastapi_set_pipe_node1(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID"),
node1: str = Query(..., description="新的起始节点ID")
@@ -234,7 +235,7 @@ async def fastapi_set_pipe_node1(
return set_pipe(network, ChangeSet(ps))
@router.patch("/pipes/node2", response_model=None, summary="设置管道终止节点", description="设置指定管道的终止节点")
async def fastapi_set_pipe_node2(
def fastapi_set_pipe_node2(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID"),
node2: str = Query(..., description="新的终止节点ID")
@@ -254,7 +255,7 @@ async def fastapi_set_pipe_node2(
return set_pipe(network, ChangeSet(ps))
@router.patch("/pipes/length", response_model=None, summary="设置管道长度", description="设置指定管道的长度")
async def fastapi_set_pipe_length(
def fastapi_set_pipe_length(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID"),
length: float = Query(..., description="新的管道长度(单位:米)")
@@ -274,7 +275,7 @@ async def fastapi_set_pipe_length(
return set_pipe(network, ChangeSet(ps))
@router.patch("/pipes/diameter", response_model=None, summary="设置管道管径", description="设置指定管道的管径")
async def fastapi_set_pipe_diameter(
def fastapi_set_pipe_diameter(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID"),
diameter: float = Query(..., description="新的管道管径(单位:毫米)")
@@ -294,7 +295,7 @@ async def fastapi_set_pipe_diameter(
return set_pipe(network, ChangeSet(ps))
@router.patch("/pipes/roughness", response_model=None, summary="设置管道粗糙度", description="设置指定管道的粗糙度")
async def fastapi_set_pipe_roughness(
def fastapi_set_pipe_roughness(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID"),
roughness: float = Query(..., description="新的管道粗糙度值")
@@ -314,7 +315,7 @@ async def fastapi_set_pipe_roughness(
return set_pipe(network, ChangeSet(ps))
@router.patch("/pipes/minor-loss", response_model=None, summary="设置管道局部阻力系数", description="设置指定管道的局部阻力系数")
async def fastapi_set_pipe_minor_loss(
def fastapi_set_pipe_minor_loss(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID"),
minor_loss: float = Query(..., description="新的局部阻力系数值")
@@ -334,7 +335,7 @@ async def fastapi_set_pipe_minor_loss(
return set_pipe(network, ChangeSet(ps))
@router.patch("/pipes/status", response_model=None, summary="设置管道状态", description="设置指定管道的状态(开启或关闭)")
async def fastapi_set_pipe_status(
def fastapi_set_pipe_status(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID"),
status: str = Query(..., description="新的管道状态(开启/关闭)")
@@ -354,7 +355,7 @@ async def fastapi_set_pipe_status(
return set_pipe(network, ChangeSet(ps))
@router.get("/pipes/properties", summary="获取管道属性", description="获取指定管道的所有属性信息")
async def fastapi_get_pipe_properties(
def fastapi_get_pipe_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID")
) -> dict[str, Any]:
@@ -371,7 +372,7 @@ async def fastapi_get_pipe_properties(
return get_pipe(network, pipe)
@router.get("/pipes", summary="获取所有管道属性", description="获取网络中所有管道的属性信息列表")
async def fastapi_get_all_pipe_properties(
def fastapi_get_all_pipe_properties(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> list[dict[str, Any]]:
"""
@@ -388,10 +389,10 @@ async def fastapi_get_all_pipe_properties(
return results
@router.patch("/pipes/properties", response_model=None, summary="设置管道属性", description="批量设置指定管道的多个属性")
async def fastapi_set_pipe_properties(
def fastapi_set_pipe_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
pipe: str = Query(..., description="管道ID"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""
批量设置管道属性
@@ -404,6 +405,6 @@ async def fastapi_set_pipe_properties(
Returns:
ChangeSet对象包含本次修改的变更信息
"""
props = await req.json()
props = payload
ps = {"id": pipe} | props
return set_pipe(network, ChangeSet(ps))
+15 -14
View File
@@ -1,5 +1,6 @@
from fastapi import APIRouter, Request, Query, Path, Body
from typing import Any, List, Dict, Union
from typing import Any
from fastapi import APIRouter, Body, Query
from app.services.tjnetwork import (
ChangeSet,
add_pump,
@@ -13,7 +14,7 @@ from app.services.tjnetwork import (
router = APIRouter()
@router.get("/network-schemas/pump", summary="获取水泵模式", description="获取水泵对象的模式定义,包含所有可用字段及其类型")
async def fastapi_get_pump_schema(
def fastapi_get_pump_schema(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> dict[str, dict[str, Any]]:
"""
@@ -28,7 +29,7 @@ async def fastapi_get_pump_schema(
return get_pump_schema(network)
@router.post("/pumps", response_model=None, summary="添加水泵", description="向网络中添加新的水泵,需要提供水泵的基本参数如功率等")
async def fastapi_add_pump(
def fastapi_add_pump(
network: str = Query(..., description="管网名称(或数据库名称)"),
pump: str = Query(..., description="水泵标识符"),
node1: str = Query(..., description="水泵起始节点ID"),
@@ -52,7 +53,7 @@ async def fastapi_add_pump(
return add_pump(network, ChangeSet(ps))
@router.delete("/pumps", response_model=None, summary="删除水泵", description="从网络中删除指定的水泵")
async def fastapi_delete_pump(
def fastapi_delete_pump(
network: str = Query(..., description="管网名称(或数据库名称)"),
pump: str = Query(..., description="要删除的水泵ID")
) -> ChangeSet:
@@ -70,7 +71,7 @@ async def fastapi_delete_pump(
return delete_pump(network, ChangeSet(ps))
@router.get("/pumps/node1", summary="获取水泵起始节点", description="获取指定水泵的起始节点ID")
async def fastapi_get_pump_node1(
def fastapi_get_pump_node1(
network: str = Query(..., description="管网名称(或数据库名称)"),
pump: str = Query(..., description="水泵ID")
) -> str | None:
@@ -88,7 +89,7 @@ async def fastapi_get_pump_node1(
return ps["node1"]
@router.get("/pumps/node2", summary="获取水泵终止节点", description="获取指定水泵的终止节点ID")
async def fastapi_get_pump_node2(
def fastapi_get_pump_node2(
network: str = Query(..., description="管网名称(或数据库名称)"),
pump: str = Query(..., description="水泵ID")
) -> str | None:
@@ -106,7 +107,7 @@ async def fastapi_get_pump_node2(
return ps["node2"]
@router.patch("/pumps/node1", response_model=None, summary="设置水泵起始节点", description="设置指定水泵的起始节点")
async def fastapi_set_pump_node1(
def fastapi_set_pump_node1(
network: str = Query(..., description="管网名称(或数据库名称)"),
pump: str = Query(..., description="水泵ID"),
node1: str = Query(..., description="新的起始节点ID")
@@ -126,7 +127,7 @@ async def fastapi_set_pump_node1(
return set_pump(network, ChangeSet(ps))
@router.patch("/pumps/node2", response_model=None, summary="设置水泵终止节点", description="设置指定水泵的终止节点")
async def fastapi_set_pump_node2(
def fastapi_set_pump_node2(
network: str = Query(..., description="管网名称(或数据库名称)"),
pump: str = Query(..., description="水泵ID"),
node2: str = Query(..., description="新的终止节点ID")
@@ -146,7 +147,7 @@ async def fastapi_set_pump_node2(
return set_pump(network, ChangeSet(ps))
@router.get("/pumps/properties", summary="获取水泵属性", description="获取指定水泵的所有属性信息")
async def fastapi_get_pump_properties(
def fastapi_get_pump_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
pump: str = Query(..., description="水泵ID")
) -> dict[str, Any]:
@@ -163,7 +164,7 @@ async def fastapi_get_pump_properties(
return get_pump(network, pump)
@router.get("/pumps", summary="获取所有水泵属性", description="获取网络中所有水泵的属性信息列表")
async def fastapi_get_all_pump_properties(
def fastapi_get_all_pump_properties(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> list[dict[str, Any]]:
"""
@@ -180,10 +181,10 @@ async def fastapi_get_all_pump_properties(
return results
@router.patch("/pumps/properties", response_model=None, summary="设置水泵属性", description="批量设置指定水泵的多个属性")
async def fastapi_set_pump_properties(
def fastapi_set_pump_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
pump: str = Query(..., description="水泵ID"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""
批量设置水泵属性
@@ -196,6 +197,6 @@ async def fastapi_set_pump_properties(
Returns:
ChangeSet对象包含本次修改的变更信息
"""
props = await req.json()
props = payload
ps = {"id": pump} | props
return set_pump(network, ChangeSet(ps))
+13 -14
View File
@@ -1,6 +1,6 @@
from typing import Any
from fastapi import APIRouter, Query, Request
from fastapi import APIRouter, Body, Query
from app.services.tjnetwork import (
ChangeSet,
@@ -18,21 +18,21 @@ router = APIRouter()
@router.get("/network-schemas/region", summary="获取区域属性架构")
async def get_region_schema_endpoint(
def get_region_schema_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
) -> dict[str, dict[str, Any]]:
return get_region_schema(network)
@router.get("/regions", summary="获取区域列表")
async def get_regions_endpoint(
def get_regions_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
) -> list[dict[str, Any]]:
return [get_region(network, region_id) for region_id in get_regions(network)]
@router.get("/regions/detail", summary="获取区域信息")
async def get_region_endpoint(
def get_region_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
id: str = Query(..., description="区域 ID"),
) -> dict[str, Any]:
@@ -40,7 +40,7 @@ async def get_region_endpoint(
@router.get("/regions/nodes", summary="获取区域节点")
async def get_region_nodes_endpoint(
def get_region_nodes_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
id: str = Query(..., description="区域 ID"),
) -> list[str]:
@@ -48,26 +48,25 @@ async def get_region_nodes_endpoint(
@router.patch("/regions", summary="修改区域", response_model=None)
async def set_region_endpoint(
def set_region_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
request: Request = None,
payload: dict[str, Any] = Body(...),
) -> ChangeSet:
return set_region(network, ChangeSet(await request.json()))
return set_region(network, ChangeSet(payload))
@router.post("/regions", summary="添加区域", response_model=None)
async def add_region_endpoint(
def add_region_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
request: Request = None,
payload: dict[str, Any] = Body(...),
) -> ChangeSet:
payload = await request.json()
payload["boundary"] = [tuple(point[:2]) for point in payload.get("boundary", [])]
return add_region(network, ChangeSet(payload))
@router.delete("/regions", summary="删除区域", response_model=None)
async def delete_region_endpoint(
def delete_region_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
request: Request = None,
payload: dict[str, Any] = Body(...),
) -> ChangeSet:
return delete_region(network, ChangeSet(await request.json()))
return delete_region(network, ChangeSet(payload))
+21 -20
View File
@@ -1,5 +1,6 @@
from fastapi import APIRouter, Request, Query, Path, Body
from typing import Any, List, Dict, Union
from typing import Any
from fastapi import APIRouter, Body, Query
from app.services.tjnetwork import (
ChangeSet,
add_reservoir,
@@ -17,7 +18,7 @@ router = APIRouter()
summary="获取水库模式",
description="获取指定供水网络中所有水库的模式/属性字段定义"
)
async def fast_get_reservoir_schema(
def fast_get_reservoir_schema(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> dict[str, dict[str, Any]]:
"""
@@ -39,7 +40,7 @@ async def fast_get_reservoir_schema(
summary="添加水库",
description="在指定供水网络中添加新的水库/水源节点"
)
async def fastapi_add_reservoir(
def fastapi_add_reservoir(
network: str = Query(..., description="管网名称(或数据库名称)"),
reservoir: str = Query(..., description="水库的唯一标识符"),
x: float = Query(..., description="水库的X坐标"),
@@ -70,7 +71,7 @@ async def fastapi_add_reservoir(
summary="删除水库",
description="从指定供水网络中删除指定的水库/水源节点"
)
async def fastapi_delete_reservoir(
def fastapi_delete_reservoir(
network: str = Query(..., description="管网名称(或数据库名称)"),
reservoir: str = Query(..., description="要删除的水库的唯一标识符")
) -> ChangeSet:
@@ -94,7 +95,7 @@ async def fastapi_delete_reservoir(
summary="获取水库水头",
description="获取指定水库的供水水头/总水头值"
)
async def fastapi_get_reservoir_head(
def fastapi_get_reservoir_head(
network: str = Query(..., description="管网名称(或数据库名称)"),
reservoir: str = Query(..., description="水库的唯一标识符")
) -> float | None:
@@ -118,7 +119,7 @@ async def fastapi_get_reservoir_head(
summary="获取水库模式",
description="获取指定水库的运行模式/供水模式"
)
async def fastapi_get_reservoir_pattern(
def fastapi_get_reservoir_pattern(
network: str = Query(..., description="管网名称(或数据库名称)"),
reservoir: str = Query(..., description="水库的唯一标识符")
) -> str | None:
@@ -142,7 +143,7 @@ async def fastapi_get_reservoir_pattern(
summary="获取水库X坐标",
description="获取指定水库的X坐标位置"
)
async def fastapi_get_reservoir_x(
def fastapi_get_reservoir_x(
network: str = Query(..., description="管网名称(或数据库名称)"),
reservoir: str = Query(..., description="水库的唯一标识符")
) -> dict[str, float] | None:
@@ -166,7 +167,7 @@ async def fastapi_get_reservoir_x(
summary="获取水库Y坐标",
description="获取指定水库的Y坐标位置"
)
async def fastapi_get_reservoir_y(
def fastapi_get_reservoir_y(
network: str = Query(..., description="管网名称(或数据库名称)"),
reservoir: str = Query(..., description="水库的唯一标识符")
) -> dict[str, float] | None:
@@ -190,7 +191,7 @@ async def fastapi_get_reservoir_y(
summary="获取水库坐标",
description="获取指定水库的平面坐标(X和Y坐标)"
)
async def fastapi_get_reservoir_coord(
def fastapi_get_reservoir_coord(
network: str = Query(..., description="管网名称(或数据库名称)"),
reservoir: str = Query(..., description="水库的唯一标识符")
) -> dict[str, float] | None:
@@ -216,7 +217,7 @@ async def fastapi_get_reservoir_coord(
summary="设置水库水头",
description="更新指定水库的供水水头/总水头值"
)
async def fastapi_set_reservoir_head(
def fastapi_set_reservoir_head(
network: str = Query(..., description="管网名称(或数据库名称)"),
reservoir: str = Query(..., description="水库的唯一标识符"),
head: float = Query(..., description="新的水头值(米)")
@@ -243,7 +244,7 @@ async def fastapi_set_reservoir_head(
summary="设置水库模式",
description="更新指定水库的运行模式/供水模式"
)
async def fastapi_set_reservoir_pattern(
def fastapi_set_reservoir_pattern(
network: str = Query(..., description="管网名称(或数据库名称)"),
reservoir: str = Query(..., description="水库的唯一标识符"),
pattern: str = Query(..., description="新的运行模式")
@@ -270,7 +271,7 @@ async def fastapi_set_reservoir_pattern(
summary="设置水库X坐标",
description="更新指定水库的X坐标位置"
)
async def fastapi_set_reservoir_x(
def fastapi_set_reservoir_x(
network: str = Query(..., description="管网名称(或数据库名称)"),
reservoir: str = Query(..., description="水库的唯一标识符"),
x: float = Query(..., description="新的X坐标值")
@@ -297,7 +298,7 @@ async def fastapi_set_reservoir_x(
summary="设置水库Y坐标",
description="更新指定水库的Y坐标位置"
)
async def fastapi_set_reservoir_y(
def fastapi_set_reservoir_y(
network: str = Query(..., description="管网名称(或数据库名称)"),
reservoir: str = Query(..., description="水库的唯一标识符"),
y: float = Query(..., description="新的Y坐标值")
@@ -324,7 +325,7 @@ async def fastapi_set_reservoir_y(
summary="设置水库坐标",
description="更新指定水库的平面坐标(X和Y坐标)"
)
async def fastapi_set_reservoir_coord(
def fastapi_set_reservoir_coord(
network: str = Query(..., description="管网名称(或数据库名称)"),
reservoir: str = Query(..., description="水库的唯一标识符"),
x: float = Query(..., description="新的X坐标值"),
@@ -352,7 +353,7 @@ async def fastapi_set_reservoir_coord(
summary="获取水库属性",
description="获取指定水库的所有属性"
)
async def fastapi_get_reservoir_properties(
def fastapi_get_reservoir_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
reservoir: str = Query(..., description="水库的唯一标识符")
) -> dict[str, Any]:
@@ -375,7 +376,7 @@ async def fastapi_get_reservoir_properties(
summary="获取所有水库属性",
description="获取指定供水网络中所有水库的属性"
)
async def fastapi_get_all_reservoir_properties(
def fastapi_get_all_reservoir_properties(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> list[dict[str, Any]]:
"""
@@ -398,10 +399,10 @@ async def fastapi_get_all_reservoir_properties(
summary="设置水库属性",
description="批量更新指定水库的多个属性"
)
async def fastapi_set_reservoir_properties(
def fastapi_set_reservoir_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
reservoir: str = Query(..., description="水库的唯一标识符"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""
设置水库的多个属性
@@ -416,6 +417,6 @@ async def fastapi_set_reservoir_properties(
Returns:
包含操作变更集的ChangeSet对象
"""
props = await req.json()
props = payload
ps = {"id": reservoir} | props
return set_reservoir(network, ChangeSet(ps))
+9 -8
View File
@@ -1,5 +1,6 @@
from fastapi import APIRouter, Request, Query, Path, Body
from typing import Any, List, Dict, Union
from typing import Any
from fastapi import APIRouter, Body, Query
from app.services.tjnetwork import (
ChangeSet,
get_tag,
@@ -19,7 +20,7 @@ router = APIRouter()
summary="获取标签属性架构",
description="获取指定水网的标签(Tag)属性架构定义"
)
async def fastapi_get_tag_schema(
def fastapi_get_tag_schema(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> dict[str, dict[str, Any]]:
"""获取标签的属性架构。"""
@@ -30,7 +31,7 @@ async def fastapi_get_tag_schema(
summary="获取标签信息",
description="获取指定类型和ID的标签信息"
)
async def fastapi_get_tag(
def fastapi_get_tag(
network: str = Query(..., description="管网名称(或数据库名称)"),
t_type: str = Query(..., description="标签类型"),
id: str = Query(..., description="元素ID")
@@ -43,7 +44,7 @@ async def fastapi_get_tag(
summary="获取所有标签",
description="获取指定水网中的所有标签信息"
)
async def fastapi_get_tags(
def fastapi_get_tags(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> list[dict[str, Any]]:
"""获取水网中所有标签的列表。"""
@@ -56,10 +57,10 @@ async def fastapi_get_tags(
summary="设置标签",
description="为指定元素设置或修改标签信息"
)
async def fastapi_set_tag(
def fastapi_set_tag(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""设置标签信息。"""
props = await req.json()
props = payload
return set_tag(network, ChangeSet(props))
+33 -32
View File
@@ -1,5 +1,6 @@
from fastapi import APIRouter, Request, Query, Path, Body
from typing import Any, List, Dict, Union
from typing import Any
from fastapi import APIRouter, Body, Query
from app.services.tjnetwork import (
ChangeSet,
add_tank,
@@ -13,7 +14,7 @@ from app.services.tjnetwork import (
router = APIRouter()
@router.get("/network-schemas/tank", summary="获取水箱模式", description="获取指定网络的水箱数据结构模式定义")
async def fast_get_tank_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
def fast_get_tank_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
"""
获取水箱的数据结构模式
@@ -26,7 +27,7 @@ async def fast_get_tank_schema(network: str = Query(..., description="管网名
return get_tank_schema(network)
@router.post("/tanks", summary="新增水箱", description="向指定网络中新增一个水箱", response_model=None)
async def fastapi_add_tank(
def fastapi_add_tank(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
x: float = Query(..., description="X坐标"),
@@ -70,7 +71,7 @@ async def fastapi_add_tank(
return add_tank(network, ChangeSet(ps))
@router.delete("/tanks", summary="删除水箱", description="删除指定网络中的水箱", response_model=None)
async def fastapi_delete_tank(
def fastapi_delete_tank(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
) -> ChangeSet:
@@ -88,7 +89,7 @@ async def fastapi_delete_tank(
return delete_tank(network, ChangeSet(ps))
@router.get("/tanks/elevation", summary="获取水箱标高", description="获取指定水箱的标高值")
async def fastapi_get_tank_elevation(
def fastapi_get_tank_elevation(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
) -> float | None:
@@ -106,7 +107,7 @@ async def fastapi_get_tank_elevation(
return ps["elevation"]
@router.get("/tanks/init-level", summary="获取水箱初始水位", description="获取指定水箱的初始水位值")
async def fastapi_get_tank_init_level(
def fastapi_get_tank_init_level(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
) -> float | None:
@@ -124,7 +125,7 @@ async def fastapi_get_tank_init_level(
return ps["init_level"]
@router.get("/tanks/min-level", summary="获取水箱最小水位", description="获取指定水箱的最小水位值")
async def fastapi_get_tank_min_level(
def fastapi_get_tank_min_level(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
) -> float | None:
@@ -142,7 +143,7 @@ async def fastapi_get_tank_min_level(
return ps["min_level"]
@router.get("/tanks/max-level", summary="获取水箱最大水位", description="获取指定水箱的最大水位值")
async def fastapi_get_tank_max_level(
def fastapi_get_tank_max_level(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
) -> float | None:
@@ -160,7 +161,7 @@ async def fastapi_get_tank_max_level(
return ps["max_level"]
@router.get("/tanks/diameter", summary="获取水箱直径", description="获取指定水箱的直径值")
async def fastapi_get_tank_diameter(
def fastapi_get_tank_diameter(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
) -> float | None:
@@ -178,7 +179,7 @@ async def fastapi_get_tank_diameter(
return ps["diameter"]
@router.get("/tanks/min-vol", summary="获取水箱最小体积", description="获取指定水箱的最小体积值")
async def fastapi_get_tank_min_vol(
def fastapi_get_tank_min_vol(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
) -> float | None:
@@ -196,7 +197,7 @@ async def fastapi_get_tank_min_vol(
return ps["min_vol"]
@router.get("/tanks/vol-curve", summary="获取水箱容积曲线", description="获取指定水箱的容积曲线标识")
async def fastapi_get_tank_vol_curve(
def fastapi_get_tank_vol_curve(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
) -> str | None:
@@ -214,7 +215,7 @@ async def fastapi_get_tank_vol_curve(
return ps["vol_curve"]
@router.get("/tanks/overflow", summary="获取水箱溢流口", description="获取指定水箱的溢流口配置")
async def fastapi_get_tank_overflow(
def fastapi_get_tank_overflow(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
) -> str | None:
@@ -232,7 +233,7 @@ async def fastapi_get_tank_overflow(
return ps["overflow"]
@router.get("/tanks/x", summary="获取水箱X坐标", description="获取指定水箱的X坐标值")
async def fastapi_get_tank_x(
def fastapi_get_tank_x(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
) -> float:
@@ -250,7 +251,7 @@ async def fastapi_get_tank_x(
return ps["x"]
@router.get("/tanks/y", summary="获取水箱Y坐标", description="获取指定水箱的Y坐标值")
async def fastapi_get_tank_y(
def fastapi_get_tank_y(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
) -> float:
@@ -268,7 +269,7 @@ async def fastapi_get_tank_y(
return ps["y"]
@router.get("/tanks/coord", summary="获取水箱坐标", description="获取指定水箱的X和Y坐标")
async def fastapi_get_tank_coord(
def fastapi_get_tank_coord(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
) -> dict[str, float]:
@@ -287,7 +288,7 @@ async def fastapi_get_tank_coord(
return coord
@router.patch("/tanks/elevation", summary="设置水箱标高", description="设置指定水箱的标高值", response_model=None)
async def fastapi_set_tank_elevation(
def fastapi_set_tank_elevation(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
elevation: float = Query(..., description="新的标高值")
@@ -307,7 +308,7 @@ async def fastapi_set_tank_elevation(
return set_tank(network, ChangeSet(ps))
@router.patch("/tanks/init-level", summary="设置水箱初始水位", description="设置指定水箱的初始水位值", response_model=None)
async def fastapi_set_tank_init_level(
def fastapi_set_tank_init_level(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
init_level: float = Query(..., description="新的初始水位值")
@@ -327,7 +328,7 @@ async def fastapi_set_tank_init_level(
return set_tank(network, ChangeSet(ps))
@router.patch("/tanks/min-level", summary="设置水箱最小水位", description="设置指定水箱的最小水位值", response_model=None)
async def fastapi_set_tank_min_level(
def fastapi_set_tank_min_level(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
min_level: float = Query(..., description="新的最小水位值")
@@ -347,7 +348,7 @@ async def fastapi_set_tank_min_level(
return set_tank(network, ChangeSet(ps))
@router.patch("/tanks/max-level", summary="设置水箱最大水位", description="设置指定水箱的最大水位值", response_model=None)
async def fastapi_set_tank_max_level(
def fastapi_set_tank_max_level(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
max_level: float = Query(..., description="新的最大水位值")
@@ -367,7 +368,7 @@ async def fastapi_set_tank_max_level(
return set_tank(network, ChangeSet(ps))
@router.patch("/tanks/diameter", summary="设置水箱直径", description="设置指定水箱的直径值", response_model=None)
async def fastapi_set_tank_diameter(
def fastapi_set_tank_diameter(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
diameter: float = Query(..., description="新的直径值")
@@ -387,7 +388,7 @@ async def fastapi_set_tank_diameter(
return set_tank(network, ChangeSet(ps))
@router.patch("/tanks/min-vol", summary="设置水箱最小体积", description="设置指定水箱的最小体积值", response_model=None)
async def fastapi_set_tank_min_vol(
def fastapi_set_tank_min_vol(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
min_vol: float = Query(..., description="新的最小体积值")
@@ -407,7 +408,7 @@ async def fastapi_set_tank_min_vol(
return set_tank(network, ChangeSet(ps))
@router.patch("/tanks/vol-curve", summary="设置水箱容积曲线", description="设置指定水箱的容积曲线标识", response_model=None)
async def fastapi_set_tank_vol_curve(
def fastapi_set_tank_vol_curve(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
vol_curve: str = Query(..., description="新的容积曲线标识")
@@ -427,7 +428,7 @@ async def fastapi_set_tank_vol_curve(
return set_tank(network, ChangeSet(ps))
@router.patch("/tanks/overflow", summary="设置水箱溢流口", description="设置指定水箱的溢流口配置", response_model=None)
async def fastapi_set_tank_overflow(
def fastapi_set_tank_overflow(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
overflow: str = Query(..., description="新的溢流口配置")
@@ -447,7 +448,7 @@ async def fastapi_set_tank_overflow(
return set_tank(network, ChangeSet(ps))
@router.patch("/tanks/x", summary="设置水箱X坐标", description="设置指定水箱的X坐标值", response_model=None)
async def fastapi_set_tank_x(
def fastapi_set_tank_x(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
x: float = Query(..., description="新的X坐标值")
@@ -467,7 +468,7 @@ async def fastapi_set_tank_x(
return set_tank(network, ChangeSet(ps))
@router.patch("/tanks/y", summary="设置水箱Y坐标", description="设置指定水箱的Y坐标值", response_model=None)
async def fastapi_set_tank_y(
def fastapi_set_tank_y(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
y: float = Query(..., description="新的Y坐标值")
@@ -487,7 +488,7 @@ async def fastapi_set_tank_y(
return set_tank(network, ChangeSet(ps))
@router.patch("/tanks/coord", summary="设置水箱坐标", description="设置指定水箱的X和Y坐标", response_model=None)
async def fastapi_set_tank_coord(
def fastapi_set_tank_coord(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
x: float = Query(..., description="新的X坐标值"),
@@ -509,7 +510,7 @@ async def fastapi_set_tank_coord(
return set_tank(network, ChangeSet(ps))
@router.get("/tanks/properties", summary="获取水箱属性", description="获取指定水箱的所有属性")
async def fastapi_get_tank_properties(
def fastapi_get_tank_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID")
) -> dict[str, Any]:
@@ -526,7 +527,7 @@ async def fastapi_get_tank_properties(
return get_tank(network, tank)
@router.get("/tanks", summary="获取所有水箱属性", description="获取指定网络中所有水箱的属性")
async def fastapi_get_all_tank_properties(
def fastapi_get_all_tank_properties(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> list[dict[str, Any]]:
"""
@@ -543,10 +544,10 @@ async def fastapi_get_all_tank_properties(
return results
@router.patch("/tanks/properties", summary="设置水箱属性", description="批量设置指定水箱的多个属性", response_model=None)
async def fastapi_set_tank_properties(
def fastapi_set_tank_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
tank: str = Query(..., description="水箱ID"),
req: Request = None
payload: dict[str, Any] = Body(...)
) -> ChangeSet:
"""
批量设置水箱的属性
@@ -559,6 +560,6 @@ async def fastapi_set_tank_properties(
Returns:
包含变更信息的ChangeSet对象
"""
props = await req.json()
props = payload
ps = {"id": tank} | props
return set_tank(network, ChangeSet(ps))
+22 -21
View File
@@ -1,5 +1,6 @@
from fastapi import APIRouter, Request, Query, Path, Body
from typing import Any, List, Dict, Union
from typing import Any
from fastapi import APIRouter, Body, Query
from app.services.tjnetwork import (
ChangeSet,
VALVES_TYPE_PRV,
@@ -18,7 +19,7 @@ router = APIRouter()
summary="获取阀门架构",
description="获取指定水网中所有阀门的架构和字段定义",
)
async def fastapi_get_valve_schema(
def fastapi_get_valve_schema(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> dict[str, dict[str, Any]]:
"""
@@ -34,7 +35,7 @@ async def fastapi_get_valve_schema(
summary="添加阀门",
description="在指定的水网中添加新的阀门",
)
async def fastapi_add_valve(
def fastapi_add_valve(
network: str = Query(..., description="管网名称(或数据库名称)"),
valve: str = Query(..., description="阀门ID"),
node1: str = Query(..., description="起点节点ID"),
@@ -67,7 +68,7 @@ async def fastapi_add_valve(
summary="删除阀门",
description="从指定的水网中删除指定的阀门",
)
async def fastapi_delete_valve(
def fastapi_delete_valve(
network: str = Query(..., description="管网名称(或数据库名称)"),
valve: str = Query(..., description="阀门ID"),
) -> ChangeSet:
@@ -84,7 +85,7 @@ async def fastapi_delete_valve(
summary="获取阀门起点节点",
description="获取指定阀门连接的起点节点ID",
)
async def fastapi_get_valve_node1(
def fastapi_get_valve_node1(
network: str = Query(..., description="管网名称(或数据库名称)"),
valve: str = Query(..., description="阀门ID"),
) -> str | None:
@@ -101,7 +102,7 @@ async def fastapi_get_valve_node1(
summary="获取阀门终点节点",
description="获取指定阀门连接的终点节点ID",
)
async def fastapi_get_valve_node2(
def fastapi_get_valve_node2(
network: str = Query(..., description="管网名称(或数据库名称)"),
valve: str = Query(..., description="阀门ID"),
) -> str | None:
@@ -118,7 +119,7 @@ async def fastapi_get_valve_node2(
summary="获取阀门直径",
description="获取指定阀门的直径",
)
async def fastapi_get_valve_diameter(
def fastapi_get_valve_diameter(
network: str = Query(..., description="管网名称(或数据库名称)"),
valve: str = Query(..., description="阀门ID"),
) -> float | None:
@@ -135,7 +136,7 @@ async def fastapi_get_valve_diameter(
summary="获取阀门类型",
description="获取指定阀门的类型",
)
async def fastapi_get_valve_type(
def fastapi_get_valve_type(
network: str = Query(..., description="管网名称(或数据库名称)"),
valve: str = Query(..., description="阀门ID"),
) -> str | None:
@@ -152,7 +153,7 @@ async def fastapi_get_valve_type(
summary="获取阀门开度",
description="获取指定阀门的开度/设置值",
)
async def fastapi_get_valve_setting(
def fastapi_get_valve_setting(
network: str = Query(..., description="管网名称(或数据库名称)"),
valve: str = Query(..., description="阀门ID"),
) -> float | None:
@@ -169,7 +170,7 @@ async def fastapi_get_valve_setting(
summary="获取阀门损失系数",
description="获取指定阀门的损失系数",
)
async def fastapi_get_valve_minor_loss(
def fastapi_get_valve_minor_loss(
network: str = Query(..., description="管网名称(或数据库名称)"),
valve: str = Query(..., description="阀门ID"),
) -> float | None:
@@ -187,7 +188,7 @@ async def fastapi_get_valve_minor_loss(
summary="设置阀门起点节点",
description="设置指定阀门的起点节点",
)
async def fastapi_set_valve_node1(
def fastapi_set_valve_node1(
network: str = Query(..., description="管网名称(或数据库名称)"),
valve: str = Query(..., description="阀门ID"),
node1: str = Query(..., description="新的起点节点ID"),
@@ -206,7 +207,7 @@ async def fastapi_set_valve_node1(
summary="设置阀门终点节点",
description="设置指定阀门的终点节点",
)
async def fastapi_set_valve_node2(
def fastapi_set_valve_node2(
network: str = Query(..., description="管网名称(或数据库名称)"),
valve: str = Query(..., description="阀门ID"),
node2: str = Query(..., description="新的终点节点ID"),
@@ -225,7 +226,7 @@ async def fastapi_set_valve_node2(
summary="设置阀门直径",
description="设置指定阀门的直径",
)
async def fastapi_set_valve_diameter(
def fastapi_set_valve_diameter(
network: str = Query(..., description="管网名称(或数据库名称)"),
valve: str = Query(..., description="阀门ID"),
diameter: float = Query(..., description="新的直径值(mm"),
@@ -244,7 +245,7 @@ async def fastapi_set_valve_diameter(
summary="设置阀门类型",
description="设置指定阀门的类型",
)
async def fastapi_set_valve_type(
def fastapi_set_valve_type(
network: str = Query(..., description="管网名称(或数据库名称)"),
valve: str = Query(..., description="阀门ID"),
type: str = Query(..., description="新的阀门类型"),
@@ -263,7 +264,7 @@ async def fastapi_set_valve_type(
summary="设置阀门开度",
description="设置指定阀门的开度/设置值",
)
async def fastapi_set_valve_setting(
def fastapi_set_valve_setting(
network: str = Query(..., description="管网名称(或数据库名称)"),
valve: str = Query(..., description="阀门ID"),
setting: float = Query(..., description="新的开度值"),
@@ -281,7 +282,7 @@ async def fastapi_set_valve_setting(
summary="获取阀门所有属性",
description="获取指定阀门的所有属性",
)
async def fastapi_get_valve_properties(
def fastapi_get_valve_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
valve: str = Query(..., description="阀门ID"),
) -> dict[str, Any]:
@@ -297,7 +298,7 @@ async def fastapi_get_valve_properties(
summary="获取所有阀门属性",
description="获取指定水网中所有阀门的属性",
)
async def fastapi_get_all_valve_properties(
def fastapi_get_all_valve_properties(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> list[dict[str, Any]]:
"""
@@ -315,16 +316,16 @@ async def fastapi_get_all_valve_properties(
summary="批量设置阀门属性",
description="批量设置指定阀门的多个属性",
)
async def fastapi_set_valve_properties(
def fastapi_set_valve_properties(
network: str = Query(..., description="管网名称(或数据库名称)"),
valve: str = Query(..., description="阀门ID"),
req: Request = None,
payload: dict[str, Any] = Body(...),
) -> ChangeSet:
"""
批量设置阀门的属性
更新指定阀门的一个或多个属性通过JSON请求体传递要更新的属性
"""
props = await req.json()
props = payload
ps = {"id": valve} | props
return set_valve(network, ChangeSet(ps))
+4 -398
View File
@@ -1,43 +1,18 @@
import json
from fastapi import APIRouter, Request, HTTPException, Query, Path, Depends
from fastapi.responses import PlainTextResponse
from typing import Any, Dict, List
from fastapi import APIRouter, HTTPException, Query, Depends
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
from app.auth.project_dependencies import get_metadata_repository
from app.auth.permissions import (
ENVIRONMENT_MANAGE,
require_permission,
from app.auth.project_dependencies import (
get_metadata_repository,
)
from app.domain.schemas.metadata import ProjectMetaResponse
import app.services.project_info as project_info
from app.services.tjnetwork import (
ChangeSet,
list_project,
have_project,
create_project,
delete_project,
is_project_open,
open_project,
close_project,
copy_project,
export_inp,
read_inp,
dump_inp,
get_all_vertices,
get_all_scada_info,
convert_inp_v3_to_v2,
)
# For inp file upload/download
import os
from fastapi import Response, status
from fastapi.responses import FileResponse
inpDir = "data/" # Assuming data directory exists or is defined somewhere.
# In main.py it was likely global. For safety, let's use a relative path or get from config.
# But let's stick to what main.py probably used or a default.
router = APIRouter()
lockedPrjs: Dict[str, str] = {}
@router.get("/projects/current", summary="获取项目信息", description="从数据库获取项目的详细信息,包括地图范围等。", response_model=ProjectMetaResponse)
async def get_project_info_endpoint(
@@ -63,105 +38,8 @@ async def get_project_info_endpoint(
project_role="viewer", # Default role for public access
)
@router.get("/project-codes", summary="获取项目列表", description="获取服务器上所有可用的供水管网项目名称列表。")
async def list_projects_endpoint() -> list[str]:
"""
获取项目列表
返回所有已创建项目的名称列表
"""
return list_project()
@router.get("/projects/existence", summary="检查项目是否存在", description="检查指定名称的项目是否存在。")
async def have_project_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)")
):
"""
检查项目是否存在
- **network**: 管网名称或数据库名称
"""
return have_project(network)
@router.post("/projects", summary="创建新项目", description="创建一个新的供水管网项目。如果项目已存在,可能会覆盖或报错(取决于底层实现)。")
async def create_project_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
_=Depends(require_permission(ENVIRONMENT_MANAGE)),
):
"""
创建新项目
- **network**: 管网名称或数据库名称
"""
create_project(network)
return network
@router.delete("/projects", summary="删除项目", description="永久删除指定的供水管网项目。此操作不可恢复。")
async def delete_project_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
_=Depends(require_permission(ENVIRONMENT_MANAGE)),
):
"""
删除项目
- **network**: 管网名称或数据库名称
"""
delete_project(network)
return True
@router.get("/projects/current/status", summary="检查项目是否已打开", description="检查指定项目是否已被加载到内存中。")
async def is_project_open_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)")
):
"""
检查项目是否已打开
- **network**: 管网名称或数据库名称
"""
return is_project_open(network)
@router.post("/projects/current", summary="打开项目", description="将指定项目加载到内存中,并初始化数据库连接池。")
async def open_project_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)")
):
"""
打开项目
- **network**: 管网名称或数据库名称
"""
open_project(network)
return network
@router.delete("/projects/current", summary="关闭项目", description="将指定项目从内存中卸载,释放资源。")
async def close_project_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)")
):
"""
关闭项目
- **network**: 管网名称或数据库名称
"""
close_project(network)
return True
@router.post("/project-copies", summary="复制项目", description="将现有项目复制为新项目。")
async def copy_project_endpoint(
source: str = Query(..., description="管网名称(或数据库名称)"),
target: str = Query(..., description="管网名称(或数据库名称)"),
_=Depends(require_permission(ENVIRONMENT_MANAGE)),
):
"""
复制项目
- **source**: 管网名称或数据库名称
- **target**: 管网名称或数据库名称
"""
copy_project(source, target)
return True
@router.get("/projects/current/exports/change-set", response_model=None, summary="导出项目为 ChangeSet", description="导出项目的变更集 (ChangeSet),包含顶点、SCADA 元素、DMA、SA、VD 等信息。")
async def export_inp_endpoint(
def export_inp_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
version: str = Query(..., description="版本号 (通常用于增量更新)")
) -> ChangeSet:
@@ -173,279 +51,7 @@ async def export_inp_endpoint(
"""
cs = export_inp(network, version)
op = cs.operations[0]
open_project(network)
op["vertex"] = json.dumps(get_all_vertices(network))
op["scada"] = json.dumps(get_all_scada_info(network))
close_project(network)
return cs
@router.post("/projects/current/imports", summary="读取 INP 文件到项目", description="从服务器文件系统中读取指定的 INP 文件并加载到项目中。")
async def read_inp_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
inp: str = Query(..., description="INP 文件名 (不包含路径)")
) -> bool:
"""
读取 INP 文件到项目
- **network**: 管网名称或数据库名称
- **inp**: INP 文件名
"""
read_inp(network, inp)
return True
@router.post("/projects/current/exports/inp", summary="导出项目到 INP 文件", description="将项目当前状态保存为 INP 文件到服务器文件系统。")
async def dump_inp_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
inp: str = Query(..., description="目标文件名")
) -> bool:
"""
导出项目到 INP 文件
- **network**: 管网名称或数据库名称
- **inp**: 目标文件名
"""
dump_inp(network, inp)
return True
@router.get("/projects/current/lock", summary="检查项目是否被锁定", description="检查指定项目是否处于锁定状态。")
async def is_project_locked_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
):
"""
检查项目是否被锁定
- **network**: 管网名称或数据库名称
"""
return network in lockedPrjs.keys()
@router.get("/projects/current/lock/ownership", summary="检查项目是否被当前用户锁定", description="检查指定项目是否被当前访问地址 (IP) 锁定。")
async def is_project_locked_by_me_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
):
"""
检查项目是否被当前用户锁定
- **network**: 管网名称或数据库名称
"""
client_host = req.client.host
return lockedPrjs.get(network) == client_host
# 0 successfully locked
# 1 already locked by you
# 2 locked by others
@router.post("/projects/current/lock", summary="锁定项目", description="锁定指定项目以防止并发修改。")
async def lock_project_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
):
"""
锁定项目
返回值:
- **0**: 锁定成功
- **1**: 已被当前用户锁定
- **2**: 已被其他用户锁定
"""
client_host = req.client.host
if not network in lockedPrjs.keys():
lockedPrjs[network] = client_host
return 0
else:
if lockedPrjs.get(network) == client_host:
return 1
else:
return 2
@router.delete("/projects/current/lock", summary="解锁项目", description="释放对项目的锁定。")
def unlock_project_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
):
"""
解锁项目
只有锁定者才能解锁
"""
client_host = req.client.host
if lockedPrjs.get(network) == client_host:
print("delete key")
del lockedPrjs[network]
return True
return False
@router.get("/projects/current/files/inp", status_code=status.HTTP_200_OK, summary="下载 INP 文件", description="从服务器数据目录下载指定的 INP 文件。")
async def fastapi_download_inp(
name: str = Query(..., description="文件名"),
response: Response = None
):
"""
下载 INP 文件
- **name**: 文件名
"""
filePath = inpDir + name
if os.path.exists(filePath):
return FileResponse(
filePath, media_type="application/octet-stream", filename="inp.inp"
)
else:
response.status_code = status.HTTP_400_BAD_REQUEST
return True
# DingZQ, 2024-12-28, convert v3 to v2
@router.post("/project-conversions", response_model=None, summary="转换 INP V3 为 V2", description="将 EPANET 3.0 格式的 INP 内容转换为 2.x 格式。")
async def fastapi_convert_v3_to_v2(
req: Request
) -> ChangeSet:
"""
转换 INP V3 V2
- **req**: 请求体需包含 `{"inp": "..."}` 结构
"""
network = "v3Tov2"
jo_root = await req.json()
inp = jo_root["inp"]
cs = convert_inp_v3_to_v2(inp)
op = cs.operations[0]
open_project(network)
op["vertex"] = json.dumps(get_all_vertices(network))
op["scada"] = json.dumps(get_all_scada_info(network))
close_project(network)
return cs
async def read_inp_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
inp: str = Query(..., description="INP 文件名 (不包含路径)")
) -> bool:
"""
读取 INP 文件到项目
- **network**: 管网名称或数据库名称
- **inp**: INP 文件名
"""
read_inp(network, inp)
return True
async def dump_inp_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
inp: str = Query(..., description="目标文件名")
) -> bool:
"""
导出项目到 INP 文件
- **network**: 管网名称或数据库名称
- **inp**: 目标文件名
"""
dump_inp(network, inp)
return True
async def is_project_locked_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
):
"""
检查项目是否被锁定
- **network**: 管网名称或数据库名称
"""
return network in lockedPrjs.keys()
async def is_project_locked_by_me_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
):
"""
检查项目是否被当前用户锁定
- **network**: 管网名称或数据库名称
"""
client_host = req.client.host
return lockedPrjs.get(network) == client_host
# 0 successfully locked
# 1 already locked by you
# 2 locked by others
async def lock_project_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
):
"""
锁定项目
返回值:
- **0**: 锁定成功
- **1**: 已被当前用户锁定
- **2**: 已被其他用户锁定
"""
client_host = req.client.host
if not network in lockedPrjs.keys():
lockedPrjs[network] = client_host
return 0
else:
if lockedPrjs.get(network) == client_host:
return 1
else:
return 2
def unlock_project_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
req: Request = None
):
"""
解锁项目
只有锁定者才能解锁
"""
client_host = req.client.host
if lockedPrjs.get(network) == client_host:
print("delete key")
del lockedPrjs[network]
return True
return False
async def fastapi_download_inp(
name: str = Query(..., description="文件名"),
response: Response = None
):
"""
下载 INP 文件
- **name**: 文件名
"""
filePath = inpDir + name
if os.path.exists(filePath):
return FileResponse(
filePath, media_type="application/octet-stream", filename="inp.inp"
)
else:
response.status_code = status.HTTP_400_BAD_REQUEST
return True
# DingZQ, 2024-12-28, convert v3 to v2
async def fastapi_convert_v3_to_v2(
req: Request
) -> ChangeSet:
"""
转换 INP V3 V2
- **req**: 请求体需包含 `{"inp": "..."}` 结构
"""
network = "v3Tov2"
jo_root = await req.json()
inp = jo_root["inp"]
cs = convert_inp_v3_to_v2(inp)
op = cs.operations[0]
open_project(network)
op["vertex"] = json.dumps(get_all_vertices(network))
op["scada"] = json.dumps(get_all_scada_info(network))
close_project(network)
return cs
+8 -1
View File
@@ -12,7 +12,12 @@ from app.algorithms.sensor import (
pressure_sensor_placement_sensitivity,
)
from app.auth.metadata_dependencies import get_current_metadata_user
from app.auth.project_dependencies import ProjectContext, get_project_context
from app.auth.project_dependencies import (
ProjectContext,
get_project_context,
use_project_business_routing,
)
from app.infra.db.project_routing import ActiveProjectRouting
from app.domain.schemas.sensor_placement import (
SensorPointResponse,
SensorPlacementExportRequest,
@@ -106,6 +111,7 @@ def _get_run_response(
async def get_sensor_placement_candidate_detail(
node_id: str = Path(..., min_length=1, max_length=32),
project_context: ProjectContext = Depends(get_project_context),
_routing: ActiveProjectRouting = Depends(use_project_business_routing),
) -> dict[str, Any]:
try:
return await run_in_threadpool(
@@ -166,6 +172,7 @@ async def optimize_sensor_placement_scheme(
)
async def get_sensor_placement_runs(
project_context: ProjectContext = Depends(get_project_context),
_routing: ActiveProjectRouting = Depends(use_project_business_routing),
) -> list[dict[str, Any]]:
return await run_in_threadpool(
list_sensor_placement_runs,
+22 -22
View File
@@ -127,7 +127,7 @@ def run_simulation_manually_by_date(
# 必须用这个PlainTextResponse,不然每个key都有引号
@router.post("/project-runs", response_class=PlainTextResponse, summary="运行项目模拟", description="基于指定的管网项目运行标准水力模拟,返回纯文本格式的模拟报告。")
async def run_project_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")) -> str:
def run_project_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")) -> str:
"""
运行项目模拟
@@ -143,7 +143,7 @@ async def run_project_endpoint(network: str = Query(..., description="管网名
# output 是 json
# report 是 text
@router.post("/project-return-dict-runs", summary="运行项目模拟(返回字典)", description="基于指定的管网项目运行标准水力模拟,返回JSON格式的字典,包含输出数据和报告文本。")
async def run_project_return_dict_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
def run_project_return_dict_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
"""
运行项目模拟返回字典
@@ -160,7 +160,7 @@ async def run_project_return_dict_endpoint(network: str = Query(..., description
# put in inp folder, name without extension
@router.post("/inp-runs", summary="运行INP文件", description="运行指定INP文件格式的管网模型进行水力模拟。INP文件应该放在inp文件夹中,参数为文件名不含扩展名。")
async def run_inp_endpoint(network: str = Query(..., description="inp文件名(不含扩展名)")) -> str:
def run_inp_endpoint(network: str = Query(..., description="inp文件名(不含扩展名)")) -> str:
"""
运行INP文件
@@ -173,7 +173,7 @@ async def run_inp_endpoint(network: str = Query(..., description="inp文件名
# path is absolute path
@router.get("/outputs", summary="导出模拟输出", description="导出指定路径的模拟输出文件内容。参数应为绝对路径。")
async def dump_output_endpoint(output: str = Query(..., description="模拟输出文件的绝对路径")) -> str:
def dump_output_endpoint(output: str = Query(..., description="模拟输出文件的绝对路径")) -> str:
"""
导出模拟输出
@@ -186,7 +186,7 @@ async def dump_output_endpoint(output: str = Query(..., description="模拟输
# Analysis Endpoints
@router.post("/burst-analyses", summary="爆管分析(高级)", description="高级版本的爆管分析,支持在指定时间点修改泵控制模式和阀门开度,以分析这些改变对爆管影响的作用。支持固定泵和变速泵的独立控制。")
async def fastapi_burst_analysis(
def fastapi_burst_analysis(
network: str = Query(..., description="管网名称(或数据库名称)"),
modify_pattern_start_time: str = Query(..., description="模式修改开始时间(ISO 8601格式)"),
burst_ID: list[str] = Query(..., description="爆管节点/管段ID列表"),
@@ -220,7 +220,7 @@ async def fastapi_burst_analysis(
@router.post("/valve-closure-analyses", response_class=PlainTextResponse, summary="阀门关闭分析(高级)", description="高级版本的阀门关闭分析,支持同时关闭多个阀门,并在指定持续时间内进行模拟。返回纯文本格式的分析结果。")
async def fastapi_valve_close_analysis(
def fastapi_valve_close_analysis(
network: str = Query(..., description="管网名称(或数据库名称)"),
start_time: str = Query(..., description="阀门关闭开始时间(ISO 8601格式)"),
valves: List[str] = Query(..., description="要关闭的阀门ID列表"),
@@ -249,7 +249,7 @@ async def fastapi_valve_close_analysis(
@router.post("/valve-isolation-analyses", summary="阀门隔离分析", description="分析当发生突发事件时,通过关闭指定阀门进行隔离,确定哪些阀门必须关闭、哪些可选关闭,以及隔离的可行性。")
async def valve_isolation_endpoint(
def valve_isolation_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
accident_element: List[str] = Query(..., description="发生事故的管段/节点ID列表"),
disabled_valves: List[str] = Query(None, description="已故障的阀门ID列表(可选)"),
@@ -290,7 +290,7 @@ async def valve_isolation_endpoint(
@router.post("/flushing-analyses", response_class=PlainTextResponse, summary="冲洗分析(高级)", description="高级版本的冲洗分析,支持按状态和设置值控制多个可选阀门,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。")
async def fastapi_flushing_analysis(
def fastapi_flushing_analysis(
network: str = Query(..., description="管网名称(或数据库名称)"),
start_time: str = Query(..., description="冲洗开始时间(ISO 8601格式)"),
valves: List[str] | None = Query(None, description="参与控制的阀门ID列表(可选)"),
@@ -403,7 +403,7 @@ async def fastapi_flushing_analysis(
@router.post("/contaminant-simulations", response_class=PlainTextResponse, summary="污染物模拟", description="对管网中的污染物扩散进行模拟,评估污染源对管网的影响范围和浓度分布。支持指定污染源位置、污染浓度和扩散模式。")
async def fastapi_contaminant_simulation(
def fastapi_contaminant_simulation(
network: str = Query(..., description="管网名称(或数据库名称)"),
start_time: str = Query(..., description="污染开始时间(ISO 8601格式)"),
source: str = Query(..., description="污染源节点ID"),
@@ -440,7 +440,7 @@ async def fastapi_contaminant_simulation(
@router.post("/water-age-analyses", response_class=PlainTextResponse, summary="水龄分析(高级)", description="高级版本的水龄分析,在指定时间点进行分析,支持自定义模拟持续时间。返回纯文本格式的分析结果。")
async def fastapi_age_analysis(
def fastapi_age_analysis(
network: str = Query(..., description="管网名称(或数据库名称)"),
start_time: str = Query(..., description="分析开始时间(ISO 8601格式)"),
duration: int = Query(..., description="模拟持续时间(秒)"),
@@ -464,7 +464,7 @@ async def fastapi_age_analysis(
@router.post("/pressure-regulation-calculations", summary="压力调节(基础)", description="对管网的压力进行调节分析,通过控制泵的运行来维持目标节点的目标压力。此为基础版本。")
async def pressure_regulation_endpoint(
def pressure_regulation_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
target_node: str = Query(..., description="目标节点ID"),
target_pressure: float = Query(..., description="目标压力值(kPa"),
@@ -482,7 +482,7 @@ async def pressure_regulation_endpoint(
@router.post("/pressure-regulation-analyses", summary="压力调节(高级)", description="高级版本的压力调节分析,通过JSON请求体提供详细的控制参数,包括固定泵和变速泵的独立控制、水箱初始水位等。")
async def fastapi_pressure_regulation(data: PressureRegulation = Body(..., description="压力调节控制参数")) -> str:
def fastapi_pressure_regulation(data: PressureRegulation = Body(..., description="压力调节控制参数")) -> str:
"""
压力调节高级版本
@@ -496,7 +496,7 @@ async def fastapi_pressure_regulation(data: PressureRegulation = Body(..., descr
支持固定泵和变速泵的独立控制
"""
item = data.dict()
item = data.model_dump()
simulation.query_corresponding_element_id_and_query_id(item["network"])
fixed_pumps = set(globals.fixed_pumps_id.keys())
variable_pumps = set(globals.variable_pumps_id.keys())
@@ -520,7 +520,7 @@ async def fastapi_pressure_regulation(data: PressureRegulation = Body(..., descr
@router.post("/project-managements", summary="项目管理(高级)", description="高级版本的项目管理,通过JSON请求体提供详细的控制参数,包括泵控制策略、水箱初始水位和区域需水量控制。")
async def fastapi_project_management(data: ProjectManagement = Body(..., description="项目管理控制参数")) -> str:
def fastapi_project_management(data: ProjectManagement = Body(..., description="项目管理控制参数")) -> str:
"""
项目管理高级版本
@@ -533,7 +533,7 @@ async def fastapi_project_management(data: ProjectManagement = Body(..., descrip
支持多维度的项目管理
"""
item = data.dict()
item = data.model_dump()
return project_management(
prj_name=item["network"],
start_datetime=item["start_time"],
@@ -549,7 +549,7 @@ async def fastapi_project_management(data: ProjectManagement = Body(..., descrip
@router.post("/scheduling-analyses", summary="排程分析", description="对管网的供水排程进行分析,优化泵的运行时间和出水流量,平衡水厂出水、水箱进出水,满足用户需求。")
async def fastapi_scheduling_analysis(data: SchedulingAnalysis = Body(..., description="排程分析参数")) -> str:
def fastapi_scheduling_analysis(data: SchedulingAnalysis = Body(..., description="排程分析参数")) -> str:
"""
排程分析
@@ -563,7 +563,7 @@ async def fastapi_scheduling_analysis(data: SchedulingAnalysis = Body(..., descr
用于优化供水排程
"""
item = data.dict()
item = data.model_dump()
return scheduling_simulation(
item["network"],
item["start_time"],
@@ -575,7 +575,7 @@ async def fastapi_scheduling_analysis(data: SchedulingAnalysis = Body(..., descr
@router.post("/daily-scheduling-analyses", summary="日排程分析", description="对管网的每日供水排程进行分析,优化水库、水厂、水箱和用户需求的协调,制定合理的每日排程方案。")
async def fastapi_daily_scheduling_analysis(data: DailySchedulingAnalysis = Body(..., description="日排程分析参数")) -> str:
def fastapi_daily_scheduling_analysis(data: DailySchedulingAnalysis = Body(..., description="日排程分析参数")) -> str:
"""
日排程分析
@@ -590,7 +590,7 @@ async def fastapi_daily_scheduling_analysis(data: DailySchedulingAnalysis = Body
用于制定每日供水排程方案
"""
item = data.dict()
item = data.model_dump()
return daily_scheduling_simulation(
item["network"],
item["start_time"],
@@ -607,7 +607,7 @@ async def fastapi_daily_scheduling_analysis(data: DailySchedulingAnalysis = Body
@router.post("/pump-failure-events", summary="泵故障管理", description="记录和管理泵的故障状态,包括故障发生时间和受影响的泵列表。系统将记录故障日志并更新泵状态。")
async def fastapi_pump_failure(data: PumpFailureState = Body(..., description="泵故障状态信息")) -> str:
def fastapi_pump_failure(data: PumpFailureState = Body(..., description="泵故障状态信息")) -> str:
"""
泵故障管理
@@ -617,7 +617,7 @@ async def fastapi_pump_failure(data: PumpFailureState = Body(..., description="
系统将验证泵信息的有效性并更新故障状态文件
"""
item = data.dict()
item = data.model_dump()
with open("./pump_failure_message.txt", "a", encoding="utf-8-sig") as f1:
f1.write("[{}] {}\n".format(datetime.now().strftime("%Y-%m-%d %H:%M:%S"), item))
with open("./pump_failure_status.txt", "r", encoding="utf-8-sig") as f2:
@@ -651,7 +651,7 @@ async def fastapi_pump_failure(data: PumpFailureState = Body(..., description="
@router.post("/simulation-runs", summary="手动运行日期指定模拟", description="根据指定的开始时间和持续时间,手动运行水力模拟。开始时间必须是显式带时区的 ISO 8601 / RFC3339 时间。")
async def fastapi_run_simulation_manually_by_date(
def fastapi_run_simulation_manually_by_date(
data: RunSimulationManuallyByDate = Body(..., description="模拟运行参数"),
) -> dict[str, str]:
"""
+31 -4
View File
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Path, Body
from typing import List
from datetime import datetime
from psycopg import AsyncConnection
from pydantic import BaseModel
from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository
from .dependencies import get_timescale_connection
@@ -13,9 +14,31 @@ TIME_RANGE_START_DESC = f"时间范围开始时间。{TIME_WITH_TZ_DESC}"
TIME_RANGE_END_DESC = f"时间范围结束时间。{TIME_WITH_TZ_DESC}"
class RealtimeLinkBatchItem(BaseModel):
time: datetime
id: str
flow: float | None = None
friction: float | None = None
headloss: float | None = None
quality: float | None = None
reaction: float | None = None
setting: float | None = None
status: float | None = None
velocity: float | None = None
class RealtimeNodeBatchItem(BaseModel):
time: datetime
id: str
actual_demand: float | None = None
total_head: float | None = None
pressure: float | None = None
quality: float | None = None
@router.post("/timeseries/realtime/links/batches", status_code=201, summary="批量插入实时管道数据")
async def insert_realtime_links(
data: List[dict] = Body(..., description="管道数据列表,每项包含管道ID、时间戳等信息"),
data: List[RealtimeLinkBatchItem] = Body(..., description="同一时间点的管道快照数据"),
conn: AsyncConnection = Depends(get_timescale_connection)
):
"""
@@ -29,7 +52,9 @@ async def insert_realtime_links(
Returns:
插入成功的记录数
"""
await RealtimeRepository.insert_links_batch(conn, data)
await RealtimeRepository.insert_links_batch(
conn, [item.model_dump() for item in data]
)
return {"message": f"Inserted {len(data)} records"}
@@ -119,7 +144,7 @@ async def update_realtime_link_field(
@router.post("/timeseries/realtime/nodes/batches", status_code=201, summary="批量插入实时节点数据")
async def insert_realtime_nodes(
data: List[dict] = Body(..., description="节点数据列表,每项包含节点ID、时间戳等信息"),
data: List[RealtimeNodeBatchItem] = Body(..., description="同一时间点的节点快照数据"),
conn: AsyncConnection = Depends(get_timescale_connection)
):
"""
@@ -133,7 +158,9 @@ async def insert_realtime_nodes(
Returns:
插入成功的记录数
"""
await RealtimeRepository.insert_nodes_batch(conn, data)
await RealtimeRepository.insert_nodes_batch(
conn, [item.model_dump() for item in data]
)
return {"message": f"Inserted {len(data)} records"}
+12 -12
View File
@@ -11,6 +11,7 @@ from fastapi import APIRouter, Depends, Query
from fastapi.encoders import jsonable_encoder
from fastapi.routing import APIRoute
from pydantic import BaseModel, JsonValue, create_model
from starlette.concurrency import run_in_threadpool
from starlette.responses import Response
from app.api.problem_details import ProblemDetails
@@ -38,6 +39,14 @@ class Page(BaseModel, Generic[T]):
offset: int
async def _call_endpoint(endpoint, *args, **kwargs):
"""Call async handlers directly and offload synchronous handlers."""
if inspect.iscoroutinefunction(endpoint):
return await endpoint(*args, **kwargs)
result = await run_in_threadpool(endpoint, *args, **kwargs)
return await result if inspect.isawaitable(result) else result
_NAME_IS_NETWORK = {
"pressure_sensor_placement_sensitivity_endpoint",
"pressure_sensor_placement_kmeans_endpoint",
@@ -59,7 +68,6 @@ _TIMESCALE_ROUTED_ENDPOINT_MODULES = {
"app.api.v1.endpoints.leakage",
"app.api.v1.endpoints.simulation",
}
_TIMESCALE_ROUTED_ENDPOINT_NAMES = {"open_project_endpoint"}
def _clean_name(name: str) -> str:
@@ -183,10 +191,7 @@ def _with_header_project_context(endpoint, route_name: str):
if model_has_username:
kwargs.pop(injected_user_name, None)
with activate_project_routing(project_routing):
result = endpoint(*args, **kwargs)
if inspect.isawaitable(result):
return await result
return result
return await _call_endpoint(endpoint, *args, **kwargs)
parameters = []
for name, parameter in signature.parameters.items():
@@ -206,7 +211,6 @@ def _with_header_project_context(endpoint, route_name: str):
get_project_simulation_routing
if (
endpoint.__module__ in _TIMESCALE_ROUTED_ENDPOINT_MODULES
or endpoint.__name__ in _TIMESCALE_ROUTED_ENDPOINT_NAMES
)
else get_project_business_routing
)
@@ -262,9 +266,7 @@ def _with_pagination(endpoint):
else:
limit = kwargs.pop("_rest_limit")
offset = kwargs.pop("_rest_offset")
result = endpoint(*args, **kwargs)
if inspect.isawaitable(result):
result = await result
result = await _call_endpoint(endpoint, *args, **kwargs)
if not isinstance(result, list):
return result
if handler_handles_pagination:
@@ -313,9 +315,7 @@ def _with_jsonable_response(endpoint):
@wraps(endpoint)
async def wrapper(*args, **kwargs):
result = endpoint(*args, **kwargs)
if inspect.isawaitable(result):
result = await result
result = await _call_endpoint(endpoint, *args, **kwargs)
if isinstance(result, Response):
return result
return jsonable_encoder(result)
+33 -36
View File
@@ -16,7 +16,7 @@ from app.infra.db.metadb.repositories.metadata_repository import (
MetadataRepository,
ProjectDbRouting,
)
from app.infra.db.project_routing import ActiveProjectRouting
from app.infra.db.project_routing import ActiveProjectRouting, activate_project_routing
DB_ROLE_BIZ_DATA = "biz_data"
DB_ROLE_IOT_DATA = "iot_data"
@@ -107,6 +107,14 @@ async def get_project_business_routing(
return await resolve_project_business_routing(ctx, metadata_repo)
async def use_project_business_routing(
routing: ActiveProjectRouting = Depends(get_project_business_routing),
) -> AsyncGenerator[ActiveProjectRouting, None]:
"""Keep the routed BizDB active for an entire endpoint invocation."""
with activate_project_routing(routing):
yield routing
async def resolve_project_business_routing(
ctx: ProjectContext,
metadata_repo: MetadataRepository,
@@ -189,31 +197,6 @@ async def _get_project_routing(
return routing
async def get_project_pg_session(
ctx: ProjectContext = Depends(get_project_context),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> AsyncGenerator[AsyncSession, None]:
routing = await _get_project_routing(
metadata_repo,
ctx.project_id,
DB_ROLE_BIZ_DATA,
DB_TYPE_POSTGRES,
"PostgreSQL",
)
pool_min_size = routing.pool_min_size or settings.PROJECT_PG_POOL_SIZE
pool_max_size = routing.pool_max_size or settings.PROJECT_PG_POOL_SIZE
sessionmaker = await project_connection_manager.get_pg_sessionmaker(
ctx.project_id,
DB_ROLE_BIZ_DATA,
routing.dsn,
pool_min_size,
pool_max_size,
)
async with sessionmaker() as session:
yield session
async def get_project_pg_connection(
ctx: ProjectContext = Depends(get_project_context),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
@@ -226,16 +209,23 @@ async def get_project_pg_connection(
"PostgreSQL",
)
pool_min_size = routing.pool_min_size or settings.PROJECT_PG_POOL_SIZE
pool_max_size = routing.pool_max_size or settings.PROJECT_PG_POOL_SIZE
pool = await project_connection_manager.get_pg_pool(
pool_min_size = (
routing.pool_min_size
if routing.pool_min_size is not None
else settings.PROJECT_PG_POOL_MIN_SIZE
)
pool_max_size = (
routing.pool_max_size
if routing.pool_max_size is not None
else settings.PROJECT_PG_POOL_SIZE
)
async with project_connection_manager.pg_connection(
ctx.project_id,
DB_ROLE_BIZ_DATA,
routing.dsn,
pool_min_size,
pool_max_size,
)
async with pool.connection() as conn:
) as conn:
yield conn
@@ -251,14 +241,21 @@ async def get_project_timescale_connection(
"TimescaleDB",
)
pool_min_size = routing.pool_min_size or settings.PROJECT_TS_POOL_MIN_SIZE
pool_max_size = routing.pool_max_size or settings.PROJECT_TS_POOL_MAX_SIZE
pool = await project_connection_manager.get_timescale_pool(
pool_min_size = (
routing.pool_min_size
if routing.pool_min_size is not None
else settings.PROJECT_TS_POOL_MIN_SIZE
)
pool_max_size = (
routing.pool_max_size
if routing.pool_max_size is not None
else settings.PROJECT_TS_POOL_MAX_SIZE
)
async with project_connection_manager.timescale_connection(
ctx.project_id,
DB_ROLE_IOT_DATA,
routing.dsn,
pool_min_size,
pool_max_size,
)
async with pool.connection() as conn:
) as conn:
yield conn
+7 -5
View File
@@ -36,13 +36,15 @@ class Settings(BaseSettings):
METADATA_DB_POOL_SIZE: int = 5
METADATA_DB_MAX_OVERFLOW: int = 10
PROJECT_PG_CACHE_SIZE: int = 50
PROJECT_TS_CACHE_SIZE: int = 50
PROJECT_PG_CACHE_SIZE: int = 16
PROJECT_TS_CACHE_SIZE: int = 16
PROJECT_PG_POOL_MIN_SIZE: int = 0
PROJECT_PG_POOL_SIZE: int = 5
PROJECT_PG_MAX_OVERFLOW: int = 10
PROJECT_PG_POOL_SIZE: int = 4
PROJECT_PG_MAX_OVERFLOW: int = 2
PROJECT_TS_POOL_MIN_SIZE: int = 0
PROJECT_TS_POOL_MAX_SIZE: int = 10
PROJECT_TS_POOL_MAX_SIZE: int = 4
WNDB_TEMPLATE_DB_NAME: str = "tjwater_v2_template"
WNDB_TEMP_DB_MAX_COUNT: int = 8
# Keycloak access token verification
KEYCLOAK_PUBLIC_KEY: str = ""
+194 -125
View File
@@ -1,40 +1,29 @@
import asyncio
import logging
from collections import OrderedDict
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Dict
from uuid import UUID
from psycopg import AsyncConnection
from psycopg_pool import AsyncConnectionPool
from psycopg.rows import dict_row
from sqlalchemy.engine.url import make_url
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from app.core.config import settings
logger = logging.getLogger(__name__)
_check_async_connection = AsyncConnectionPool.check_connection
@dataclass(frozen=True)
class PgEngineEntry:
engine: AsyncEngine
sessionmaker: async_sessionmaker[AsyncSession]
connection_url: str
pool_min_size: int
pool_max_size: int
@dataclass(frozen=True)
@dataclass
class PoolEntry:
pool: AsyncConnectionPool
connection_url: str
pool_min_size: int
pool_max_size: int
borrow_count: int = 0
@dataclass(frozen=True)
@@ -45,83 +34,23 @@ class CacheKey:
class ProjectConnectionManager:
def __init__(self) -> None:
self._pg_cache: Dict[CacheKey, PgEngineEntry] = OrderedDict()
self._ts_cache: Dict[CacheKey, PoolEntry] = OrderedDict()
self._pg_raw_cache: Dict[CacheKey, PoolEntry] = OrderedDict()
self._pg_lock = asyncio.Lock()
self._retired_ts: list[tuple[CacheKey, PoolEntry]] = []
self._retired_pg: list[tuple[CacheKey, PoolEntry]] = []
self._ts_lock = asyncio.Lock()
self._pg_raw_lock = asyncio.Lock()
def _normalize_pg_url(self, url: str) -> str:
parsed = make_url(url)
if parsed.drivername in {"postgresql", "postgres"}:
parsed = parsed.set(drivername="postgresql+psycopg")
return parsed.render_as_string(hide_password=False)
async def get_pg_sessionmaker(
async def _get_timescale_pool_locked(
self,
project_id: UUID,
db_role: str,
connection_url: str,
pool_min_size: int,
pool_max_size: int,
) -> async_sessionmaker[AsyncSession]:
async with self._pg_lock:
normalized_url = self._normalize_pg_url(connection_url)
pool_min_size = max(1, pool_min_size)
pool_max_size = max(pool_min_size, pool_max_size)
key = CacheKey(project_id=project_id, db_role=db_role)
entry = self._pg_cache.get(key)
if entry:
if (
entry.connection_url == normalized_url
and entry.pool_min_size == pool_min_size
and entry.pool_max_size == pool_max_size
):
self._pg_cache.move_to_end(key)
return entry.sessionmaker
await entry.engine.dispose()
logger.info(
"Rebuilding PostgreSQL engine for project %s (%s) due to config change",
project_id,
db_role,
)
self._pg_cache.pop(key, None)
engine = create_async_engine(
normalized_url,
pool_size=pool_min_size,
max_overflow=max(0, pool_max_size - pool_min_size),
pool_pre_ping=True,
)
sessionmaker = async_sessionmaker(engine, expire_on_commit=False)
self._pg_cache[key] = PgEngineEntry(
engine=engine,
sessionmaker=sessionmaker,
connection_url=normalized_url,
pool_min_size=pool_min_size,
pool_max_size=pool_max_size,
)
await self._evict_pg_if_needed()
logger.info(
"Created PostgreSQL engine for project %s (%s)", project_id, db_role
)
return sessionmaker
async def get_timescale_pool(
self,
project_id: UUID,
db_role: str,
connection_url: str,
pool_min_size: int,
pool_max_size: int,
) -> AsyncConnectionPool:
async with self._ts_lock:
pool_min_size = max(1, pool_min_size)
pool_max_size = max(pool_min_size, pool_max_size)
) -> tuple[CacheKey, AsyncConnectionPool]:
pool_min_size = max(0, pool_min_size)
pool_max_size = max(1, pool_min_size, pool_max_size)
key = CacheKey(project_id=project_id, db_role=db_role)
entry = self._ts_cache.get(key)
if entry:
@@ -131,15 +60,12 @@ class ProjectConnectionManager:
and entry.pool_max_size == pool_max_size
):
self._ts_cache.move_to_end(key)
return entry.pool
await entry.pool.close()
return key, entry.pool
logger.info(
"Rebuilding TimescaleDB pool for project %s (%s) due to config change",
project_id,
db_role,
)
self._ts_cache.pop(key, None)
pool = AsyncConnectionPool(
conninfo=connection_url,
@@ -147,32 +73,33 @@ class ProjectConnectionManager:
max_size=pool_max_size,
open=False,
kwargs={"row_factory": dict_row},
check=_check_async_connection,
)
await pool.open()
if entry is not None:
if entry.borrow_count:
self._retired_ts.append((key, entry))
else:
await entry.pool.close()
self._ts_cache[key] = PoolEntry(
pool=pool,
connection_url=connection_url,
pool_min_size=pool_min_size,
pool_max_size=pool_max_size,
)
await self._evict_ts_if_needed()
logger.info(
"Created TimescaleDB pool for project %s (%s)", project_id, db_role
)
return pool
logger.info("Created TimescaleDB pool for project %s (%s)", project_id, db_role)
return key, pool
async def get_pg_pool(
async def _get_pg_pool_locked(
self,
project_id: UUID,
db_role: str,
connection_url: str,
pool_min_size: int,
pool_max_size: int,
) -> AsyncConnectionPool:
async with self._pg_raw_lock:
pool_min_size = max(1, pool_min_size)
pool_max_size = max(pool_min_size, pool_max_size)
) -> tuple[CacheKey, AsyncConnectionPool]:
pool_min_size = max(0, pool_min_size)
pool_max_size = max(1, pool_min_size, pool_max_size)
key = CacheKey(project_id=project_id, db_role=db_role)
entry = self._pg_raw_cache.get(key)
if entry:
@@ -182,15 +109,12 @@ class ProjectConnectionManager:
and entry.pool_max_size == pool_max_size
):
self._pg_raw_cache.move_to_end(key)
return entry.pool
await entry.pool.close()
return key, entry.pool
logger.info(
"Rebuilding PostgreSQL pool for project %s (%s) due to config change",
project_id,
db_role,
)
self._pg_raw_cache.pop(key, None)
pool = AsyncConnectionPool(
conninfo=connection_url,
@@ -198,33 +122,115 @@ class ProjectConnectionManager:
max_size=pool_max_size,
open=False,
kwargs={"row_factory": dict_row},
check=_check_async_connection,
)
await pool.open()
if entry is not None:
if entry.borrow_count:
self._retired_pg.append((key, entry))
else:
await entry.pool.close()
self._pg_raw_cache[key] = PoolEntry(
pool=pool,
connection_url=connection_url,
pool_min_size=pool_min_size,
pool_max_size=pool_max_size,
)
logger.info("Created PostgreSQL pool for project %s (%s)", project_id, db_role)
return key, pool
@asynccontextmanager
async def pg_connection(
self,
project_id: UUID,
db_role: str,
connection_url: str,
pool_min_size: int,
pool_max_size: int,
) -> AsyncIterator[AsyncConnection]:
async with self._pg_raw_lock:
key, pool = await self._get_pg_pool_locked(
project_id,
db_role,
connection_url,
pool_min_size,
pool_max_size,
)
borrowed_entry = self._pg_raw_cache[key]
borrowed_entry.borrow_count += 1
await self._evict_pg_raw_if_needed()
logger.info(
"Created PostgreSQL pool for project %s (%s)", project_id, db_role
)
return pool
async def _evict_pg_if_needed(self) -> None:
while len(self._pg_cache) > settings.PROJECT_PG_CACHE_SIZE:
key, entry = self._pg_cache.popitem(last=False)
await entry.engine.dispose()
logger.info(
"Evicted PostgreSQL engine for project %s (%s)",
key.project_id,
key.db_role,
)
try:
async with pool.connection() as conn:
yield conn
finally:
async with self._pg_raw_lock:
borrowed_entry.borrow_count -= 1
if borrowed_entry.borrow_count == 0 and borrowed_entry.pool is not (
self._pg_raw_cache.get(key).pool
if key in self._pg_raw_cache
else None
):
self._retired_pg = [
item for item in self._retired_pg if item[1] is not borrowed_entry
]
await borrowed_entry.pool.close()
await self._evict_pg_raw_if_needed()
async def _evict_ts_if_needed(self) -> None:
@asynccontextmanager
async def timescale_connection(
self,
project_id: UUID,
db_role: str,
connection_url: str,
pool_min_size: int,
pool_max_size: int,
) -> AsyncIterator[AsyncConnection]:
async with self._ts_lock:
key, pool = await self._get_timescale_pool_locked(
project_id,
db_role,
connection_url,
pool_min_size,
pool_max_size,
)
borrowed_entry = self._ts_cache[key]
borrowed_entry.borrow_count += 1
await self._evict_ts_if_needed()
try:
async with pool.connection() as conn:
yield conn
finally:
async with self._ts_lock:
borrowed_entry.borrow_count -= 1
if borrowed_entry.borrow_count == 0 and borrowed_entry.pool is not (
self._ts_cache.get(key).pool
if key in self._ts_cache
else None
):
self._retired_ts = [
item for item in self._retired_ts if item[1] is not borrowed_entry
]
await borrowed_entry.pool.close()
await self._evict_ts_if_needed()
async def _evict_ts_if_needed(
self, protected_key: CacheKey | None = None
) -> None:
while len(self._ts_cache) > settings.PROJECT_TS_CACHE_SIZE:
key, entry = self._ts_cache.popitem(last=False)
idle = next(
(
(key, entry)
for key, entry in self._ts_cache.items()
if entry.borrow_count == 0 and key != protected_key
),
None,
)
if idle is None:
return
key, entry = idle
self._ts_cache.pop(key)
await entry.pool.close()
logger.info(
"Evicted TimescaleDB pool for project %s (%s)",
@@ -232,9 +238,22 @@ class ProjectConnectionManager:
key.db_role,
)
async def _evict_pg_raw_if_needed(self) -> None:
async def _evict_pg_raw_if_needed(
self, protected_key: CacheKey | None = None
) -> None:
while len(self._pg_raw_cache) > settings.PROJECT_PG_CACHE_SIZE:
key, entry = self._pg_raw_cache.popitem(last=False)
idle = next(
(
(key, entry)
for key, entry in self._pg_raw_cache.items()
if entry.borrow_count == 0 and key != protected_key
),
None,
)
if idle is None:
return
key, entry = idle
self._pg_raw_cache.pop(key)
await entry.pool.close()
logger.info(
"Evicted PostgreSQL pool for project %s (%s)",
@@ -242,17 +261,61 @@ class ProjectConnectionManager:
key.db_role,
)
async def close_all(self) -> None:
async with self._pg_lock:
for key, entry in list(self._pg_cache.items()):
await entry.engine.dispose()
async def close_project(
self, project_id: UUID, db_role: str | None = None
) -> bool:
"""Close this worker's idle pools for a project.
Returns ``False`` without interrupting requests when any matching pool is
currently borrowed. Callers may retry after those requests complete.
"""
closed = True
for cache, lock, label in (
(self._ts_cache, self._ts_lock, "TimescaleDB"),
(self._pg_raw_cache, self._pg_raw_lock, "PostgreSQL"),
):
async with lock:
keys = [
key
for key in cache
if key.project_id == project_id
and (db_role is None or key.db_role == db_role)
]
for key in keys:
entry = cache[key]
if entry.borrow_count:
closed = False
continue
cache.pop(key)
await entry.pool.close()
logger.info(
"Closed PostgreSQL engine for project %s (%s)",
"Closed %s pool for project %s (%s)",
label,
key.project_id,
key.db_role,
)
self._pg_cache.clear()
for retired, lock in (
(self._retired_ts, self._ts_lock),
(self._retired_pg, self._pg_raw_lock),
):
async with lock:
matches = [
item
for item in retired
if item[0].project_id == project_id
and (db_role is None or item[0].db_role == db_role)
]
if any(entry.borrow_count for _key, entry in matches):
closed = False
for item in matches:
key, entry = item
if entry.borrow_count:
continue
retired.remove(item)
await entry.pool.close()
return closed
async def close_all(self) -> None:
async with self._ts_lock:
for key, entry in list(self._ts_cache.items()):
await entry.pool.close()
@@ -262,6 +325,9 @@ class ProjectConnectionManager:
key.db_role,
)
self._ts_cache.clear()
for _key, entry in self._retired_ts:
await entry.pool.close()
self._retired_ts.clear()
async with self._pg_raw_lock:
for key, entry in list(self._pg_raw_cache.items()):
@@ -272,6 +338,9 @@ class ProjectConnectionManager:
key.db_role,
)
self._pg_raw_cache.clear()
for _key, entry in self._retired_pg:
await entry.pool.close()
self._retired_pg.clear()
project_connection_manager = ProjectConnectionManager()
+30 -2
View File
@@ -5,9 +5,9 @@ from contextvars import ContextVar, Token
from dataclasses import dataclass
from typing import Iterator
from psycopg.conninfo import make_conninfo
from psycopg.conninfo import conninfo_to_dict, make_conninfo
from app.core.config import get_pgconn_string, get_timescaledb_pgconn_string
from app.core.config import get_pgconn_string, get_timescaledb_pgconn_string, settings
@dataclass(frozen=True)
@@ -16,6 +16,17 @@ class ActiveProjectRouting:
business_dsn: str
timescale_dsn: str | None = None
@property
def business_database_name(self) -> str:
"""Return the physical BizDB name selected by metadata routing."""
database_name = conninfo_to_dict(self.business_dsn).get("dbname")
if not database_name:
raise RuntimeError(
f"Business database routing for project {self.project_code!r} "
"does not contain a database name"
)
return database_name
_active_project_routing: ContextVar[ActiveProjectRouting | None] = ContextVar(
"active_project_routing",
@@ -42,6 +53,23 @@ def _dsn_for_database(dsn: str, database_name: str) -> str:
return make_conninfo(dsn, dbname=database_name)
def get_project_database_name(name: str) -> str:
"""Resolve a logical project code to its routed physical BizDB name."""
routing = get_active_project_routing()
if routing is not None and name == routing.project_code:
return routing.business_database_name
return name
def get_project_template_database_name(name: str | None = None) -> str:
"""Return the configured immutable template for the WNDB schema version.
The template belongs to the database schema version, not to an individual
logical project or its temporary physical database name.
"""
return settings.WNDB_TEMPLATE_DB_NAME
def get_project_pgconn_string(db_name: str | None = None) -> str:
routing = get_active_project_routing()
if routing is None:
+109 -81
View File
@@ -56,38 +56,40 @@ class CompositeQueries:
Raises:
ValueError: SCADA 设备未找到或字段无效时
"""
result = {}
scada_by_id = await CompositeQueries._get_project_scada_index(postgres_conn)
link_devices: dict[str, str] = {}
node_devices: dict[str, str] = {}
for device_id in device_ids:
target_scada = scada_by_id.get(device_id)
if not target_scada:
raise ValueError(f"SCADA device {device_id} not found")
scada_type = target_scada["device_type"]
element_id = (
target_scada["link_id"]
if scada_type in {"pipe_flow", "flow"}
else target_scada["node_id"]
)
if scada_type == "pipe_flow":
# 查询 link 模拟数据
res = await RealtimeRepository.get_link_field_by_time_range(
timescale_conn, start_time, end_time, element_id, "flow"
)
if scada_type in {"pipe_flow", "flow"}:
link_devices[device_id] = target_scada["link_id"]
elif scada_type == "pressure":
# 查询 node 模拟数据
res = await RealtimeRepository.get_node_field_by_time_range(
timescale_conn, start_time, end_time, element_id, "pressure"
)
node_devices[device_id] = target_scada["node_id"]
else:
raise ValueError(f"Unknown SCADA type: {scada_type}")
# 添加 scada_id 到每个数据项
for item in res:
item["scada_id"] = device_id
result[device_id] = res
return result
link_series = await RealtimeRepository.get_link_fields_by_ids_time_range(
timescale_conn, start_time, end_time,
list(dict.fromkeys(link_devices.values())), "flow",
)
node_series = await RealtimeRepository.get_node_fields_by_ids_time_range(
timescale_conn, start_time, end_time,
list(dict.fromkeys(node_devices.values())), "pressure",
)
return {
device_id: [
{**item, "scada_id": device_id}
for item in (
link_series.get(element_id, [])
if device_id in link_devices
else node_series.get(element_id, [])
)
]
for device_id, element_id in (link_devices | node_devices).items()
}
@staticmethod
async def get_scada_associated_analysis_simulation_data(
@@ -117,38 +119,41 @@ class CompositeQueries:
Raises:
ValueError: SCADA 设备未找到或字段无效时
"""
result = {}
scada_by_id = await CompositeQueries._get_project_scada_index(postgres_conn)
link_devices: dict[str, str] = {}
node_devices: dict[str, str] = {}
for device_id in device_ids:
target_scada = scada_by_id.get(device_id)
if not target_scada:
raise ValueError(f"SCADA device {device_id} not found")
scada_type = target_scada["device_type"]
element_id = (
target_scada["link_id"]
if scada_type in {"pipe_flow", "flow"}
else target_scada["node_id"]
)
if scada_type == "pipe_flow":
# 查询 link 模拟数据
res = await AnalysisResultsRepository.get_link_series(
timescale_conn, run_id, element_id, start_time, end_time, "flow"
)
if scada_type in {"pipe_flow", "flow"}:
link_devices[device_id] = target_scada["link_id"]
elif scada_type == "pressure":
# 查询 node 模拟数据
res = await AnalysisResultsRepository.get_node_series(
timescale_conn, run_id, element_id, start_time, end_time, "pressure"
)
node_devices[device_id] = target_scada["node_id"]
else:
raise ValueError(f"Unknown SCADA type: {scada_type}")
# 添加 scada_id 到每个数据项
for item in res:
item["scada_id"] = device_id
result[device_id] = res
return result
link_series = await AnalysisResultsRepository.get_series_by_ids(
timescale_conn, run_id, "link",
list(dict.fromkeys(link_devices.values())), start_time, end_time, "flow",
)
node_series = await AnalysisResultsRepository.get_series_by_ids(
timescale_conn, run_id, "node",
list(dict.fromkeys(node_devices.values())), start_time, end_time, "pressure",
)
return {
device_id: [
{**item, "scada_id": device_id}
for item in (
link_series.get(element_id, [])
if device_id in link_devices
else node_series.get(element_id, [])
)
]
for device_id, element_id in (link_devices | node_devices).items()
}
@staticmethod
async def get_realtime_simulation_data(
@@ -175,26 +180,33 @@ class CompositeQueries:
Raises:
ValueError: SCADA 设备未找到或字段无效时
"""
result = {}
pipe_ids: list[str] = []
junction_ids: list[str] = []
for feature_id, feature_type in feature_infos:
if feature_type.lower() == "pipe":
# 查询 link 模拟数据
res = await RealtimeRepository.get_link_field_by_time_range(
timescale_conn, start_time, end_time, feature_id, "flow"
)
pipe_ids.append(feature_id)
elif feature_type.lower() == "junction":
# 查询 node 模拟数据
res = await RealtimeRepository.get_node_field_by_time_range(
timescale_conn, start_time, end_time, feature_id, "pressure"
)
junction_ids.append(feature_id)
else:
raise ValueError(f"Unknown type: {feature_type}")
# 添加 scada_id 到每个数据项
for item in res:
item["feature_id"] = feature_id
result[feature_id] = res
return result
link_series = await RealtimeRepository.get_link_fields_by_ids_time_range(
timescale_conn, start_time, end_time, list(dict.fromkeys(pipe_ids)), "flow"
)
node_series = await RealtimeRepository.get_node_fields_by_ids_time_range(
timescale_conn, start_time, end_time,
list(dict.fromkeys(junction_ids)), "pressure",
)
return {
feature_id: [
{**item, "feature_id": feature_id}
for item in (
link_series.get(feature_id, [])
if feature_type.lower() == "pipe"
else node_series.get(feature_id, [])
)
]
for feature_id, feature_type in feature_infos
}
@staticmethod
async def get_analysis_simulation_data(
@@ -223,25 +235,34 @@ class CompositeQueries:
Raises:
ValueError: 当类型无效时
"""
result = {}
pipe_ids: list[str] = []
junction_ids: list[str] = []
for feature_id, feature_type in feature_infos:
if feature_type.lower() == "pipe":
# 查询 link 模拟数据
res = await AnalysisResultsRepository.get_link_series(
timescale_conn, run_id, feature_id, start_time, end_time, "flow"
)
pipe_ids.append(feature_id)
elif feature_type.lower() == "junction":
# 查询 node 模拟数据
res = await AnalysisResultsRepository.get_node_series(
timescale_conn, run_id, feature_id, start_time, end_time, "pressure"
)
junction_ids.append(feature_id)
else:
raise ValueError(f"Unknown type: {feature_type}")
# 添加 feature_id 到每个数据项
for item in res:
item["feature_id"] = feature_id
result[feature_id] = res
return result
link_series = await AnalysisResultsRepository.get_series_by_ids(
timescale_conn, run_id, "link", list(dict.fromkeys(pipe_ids)),
start_time, end_time, "flow",
)
node_series = await AnalysisResultsRepository.get_series_by_ids(
timescale_conn, run_id, "node", list(dict.fromkeys(junction_ids)),
start_time, end_time, "pressure",
)
return {
feature_id: [
{**item, "feature_id": feature_id}
for item in (
link_series.get(feature_id, [])
if feature_type.lower() == "pipe"
else node_series.get(feature_id, [])
)
]
for feature_id, feature_type in feature_infos
}
@staticmethod
async def get_element_associated_scada_data(
@@ -399,7 +420,7 @@ class CompositeQueries:
if scada_by_id[device_id]["device_type"] in {"pipe_flow", "flow"}
]
updated_rows = 0
cleaned_rows: list[tuple[datetime, str, float | None]] = []
for grouped_ids, cleaning_function in (
(pressure_ids, clean_pressure_data_df_km),
(flow_ids, clean_flow_data_df_kf),
@@ -422,18 +443,25 @@ class CompositeQueries:
if isinstance(time_value, datetime)
else datetime.fromisoformat(str(time_value))
)
await ScadaRepository.update_scada_field(
timescale_conn,
cleaned_rows.append(
(
time_dt,
device_id,
"cleaned_value",
value,
None if pd.isna(value) else float(value),
)
)
updated_rows += 1
if updated_rows == 0:
if not cleaned_rows:
raise ValueError("SCADA 数据清洗未产生任何数据库更新")
updated_rows = await ScadaRepository.update_scada_field_batch(
timescale_conn,
cleaned_rows,
"cleaned_value",
)
if updated_rows == 0:
raise ValueError("SCADA 清洗结果未匹配任何已有监测数据")
return "success"
@staticmethod
+3 -2
View File
@@ -259,7 +259,7 @@ class InternalQueries:
query = sql.SQL(
"SELECT btrim({}::text) AS id, time, {} FROM {}.{} "
"WHERE run_id = %s AND time >= %s AND time <= %s "
"AND btrim({}::text) = ANY(%s)"
"AND btrim({}::text) = ANY(%s) ORDER BY id, time"
).format(
sql.Identifier(id_column),
sql.Identifier(field),
@@ -274,7 +274,8 @@ class InternalQueries:
else:
query = sql.SQL(
"SELECT btrim({}::text) AS id, time, {} FROM {}.{} "
"WHERE time >= %s AND time <= %s AND btrim({}::text) = ANY(%s)"
"WHERE time >= %s AND time <= %s "
"AND btrim({}::text) = ANY(%s) ORDER BY id, time"
).format(
sql.Identifier(id_column),
sql.Identifier(field),
@@ -175,6 +175,53 @@ class AnalysisResultsRepository:
await cur.execute(query, (run_id, link_id, start_time, end_time))
return await cur.fetchall()
@staticmethod
async def get_series_by_ids(
conn: AsyncConnection,
run_id: UUID,
element_type: str,
element_ids: list[str],
start_time: datetime,
end_time: datetime,
field: str,
) -> dict[str, list[dict[str, Any]]]:
if element_type == "node":
table_name, id_column, valid_fields = (
"node_results", "node_id", AnalysisResultsRepository.NODE_FIELDS
)
elif element_type == "link":
table_name, id_column, valid_fields = (
"link_results", "link_id", AnalysisResultsRepository.LINK_FIELDS
)
else:
raise ValueError(f"invalid analysis element type: {element_type}")
if field not in valid_fields:
raise ValueError(f"invalid {element_type} result field: {field}")
result: dict[str, list[dict[str, Any]]] = {
element_id: [] for element_id in element_ids
}
if not element_ids:
return result
query = sql.SQL(
"SELECT {} AS element_id, time, {} AS value FROM analysis.{} "
"WHERE run_id = %s AND {} = ANY(%s) AND time BETWEEN %s AND %s "
"ORDER BY {}, time"
).format(
sql.Identifier(id_column),
sql.Identifier(field),
sql.Identifier(table_name),
sql.Identifier(id_column),
sql.Identifier(id_column),
)
async with conn.cursor() as cur:
await cur.execute(query, (run_id, element_ids, start_time, end_time))
for row in await cur.fetchall():
result.setdefault(str(row["element_id"]), []).append(
{"time": row["time"], "value": row["value"]}
)
return result
@staticmethod
async def get_values_at_time(
conn: AsyncConnection,
+120 -20
View File
@@ -7,6 +7,19 @@ from app.services.time_api import parse_utc_time
class RealtimeRepository:
@staticmethod
def _batch_time(data: List[dict]) -> datetime:
"""Return one normalized timestamp shared by every row in a snapshot."""
if not data:
raise ValueError("Realtime batch must not be empty")
times = {
parse_utc_time(item["time"], field_name="time")
for item in data
}
if len(times) != 1:
raise ValueError("Realtime batch must contain exactly one timestamp")
return times.pop()
# --- Link Simulation ---
@staticmethod
@@ -15,8 +28,7 @@ class RealtimeRepository:
if not data:
return
# 假设同一批次的数据时间是相同的
target_time = data[0]["time"]
target_time = RealtimeRepository._batch_time(data)
# 使用事务确保原子性
async with conn.transaction():
@@ -38,7 +50,7 @@ class RealtimeRepository:
for item in data:
await copy.write_row(
(
item["time"],
target_time,
item["id"],
item.get("flow"),
item.get("friction"),
@@ -57,8 +69,7 @@ class RealtimeRepository:
if not data:
return
# 假设同一批次的数据时间是相同的
target_time = data[0]["time"]
target_time = RealtimeRepository._batch_time(data)
# 使用事务确保原子性
with conn.transaction():
@@ -80,7 +91,7 @@ class RealtimeRepository:
for item in data:
copy.write_row(
(
item["time"],
target_time,
item["id"],
item.get("flow"),
item.get("friction"),
@@ -99,7 +110,8 @@ class RealtimeRepository:
) -> List[dict]:
async with conn.cursor() as cur:
await cur.execute(
"SELECT * FROM realtime.link_results WHERE time >= %s AND time <= %s AND link_id = %s",
"SELECT * FROM realtime.link_results WHERE time >= %s AND time <= %s "
"AND link_id = %s ORDER BY time",
(start_time, end_time, link_id),
)
return await cur.fetchall()
@@ -112,7 +124,8 @@ class RealtimeRepository:
normalized_end_time = parse_utc_time(end_time, field_name="end_time")
async with conn.cursor() as cur:
await cur.execute(
"SELECT * FROM realtime.link_results WHERE time >= %s AND time <= %s",
"SELECT * FROM realtime.link_results WHERE time >= %s AND time <= %s "
"ORDER BY time, link_id",
(normalized_start_time, normalized_end_time),
)
return await cur.fetchall()
@@ -140,7 +153,8 @@ class RealtimeRepository:
raise ValueError(f"Invalid field: {field}")
query = sql.SQL(
"SELECT time, {} FROM realtime.link_results WHERE time >= %s AND time <= %s AND link_id = %s"
"SELECT time, {} FROM realtime.link_results WHERE time >= %s "
"AND time <= %s AND link_id = %s ORDER BY time"
).format(sql.Identifier(field))
async with conn.cursor() as cur:
@@ -150,6 +164,36 @@ class RealtimeRepository:
{"time": row["time"].isoformat(), "value": row[field]} for row in rows
]
@staticmethod
async def get_link_fields_by_ids_time_range(
conn: AsyncConnection,
start_time: datetime,
end_time: datetime,
link_ids: list[str],
field: str,
) -> dict[str, list[dict[str, Any]]]:
valid_fields = {
"flow", "friction", "headloss", "quality", "reaction",
"setting", "status", "velocity",
}
if field not in valid_fields:
raise ValueError(f"Invalid field: {field}")
result = {link_id: [] for link_id in link_ids}
if not link_ids:
return result
query = sql.SQL(
"SELECT link_id, time, {} FROM realtime.link_results "
"WHERE time BETWEEN %s AND %s AND link_id = ANY(%s) "
"ORDER BY link_id, time"
).format(sql.Identifier(field))
async with conn.cursor() as cur:
await cur.execute(query, (start_time, end_time, link_ids))
for row in await cur.fetchall():
result.setdefault(str(row["link_id"]), []).append(
{"time": row["time"].isoformat(), "value": row[field]}
)
return result
@staticmethod
async def get_links_field_by_time_range(
conn: AsyncConnection,
@@ -172,7 +216,8 @@ class RealtimeRepository:
raise ValueError(f"Invalid field: {field}")
query = sql.SQL(
"SELECT link_id, time, {} FROM realtime.link_results WHERE time >= %s AND time <= %s"
"SELECT link_id, time, {} FROM realtime.link_results "
"WHERE time >= %s AND time <= %s ORDER BY link_id, time"
).format(sql.Identifier(field))
async with conn.cursor() as cur:
@@ -230,8 +275,7 @@ class RealtimeRepository:
if not data:
return
# 假设同一批次的数据时间是相同的
target_time = data[0]["time"]
target_time = RealtimeRepository._batch_time(data)
# 使用事务确保原子性
async with conn.transaction():
@@ -253,7 +297,7 @@ class RealtimeRepository:
for item in data:
await copy.write_row(
(
item["time"],
target_time,
item["id"],
item.get("actual_demand"),
item.get("total_head"),
@@ -267,8 +311,7 @@ class RealtimeRepository:
if not data:
return
# 假设同一批次的数据时间是相同的
target_time = data[0]["time"]
target_time = RealtimeRepository._batch_time(data)
# 使用事务确保原子性
with conn.transaction():
@@ -290,7 +333,7 @@ class RealtimeRepository:
for item in data:
copy.write_row(
(
item["time"],
target_time,
item["id"],
item.get("actual_demand"),
item.get("total_head"),
@@ -305,7 +348,8 @@ class RealtimeRepository:
) -> List[dict]:
async with conn.cursor() as cur:
await cur.execute(
"SELECT * FROM realtime.node_results WHERE time >= %s AND time <= %s AND node_id = %s",
"SELECT * FROM realtime.node_results WHERE time >= %s AND time <= %s "
"AND node_id = %s ORDER BY time",
(start_time, end_time, node_id),
)
return await cur.fetchall()
@@ -318,7 +362,8 @@ class RealtimeRepository:
normalized_end_time = parse_utc_time(end_time, field_name="end_time")
async with conn.cursor() as cur:
await cur.execute(
"SELECT * FROM realtime.node_results WHERE time >= %s AND time <= %s",
"SELECT * FROM realtime.node_results WHERE time >= %s AND time <= %s "
"ORDER BY time, node_id",
(normalized_start_time, normalized_end_time),
)
return await cur.fetchall()
@@ -336,7 +381,8 @@ class RealtimeRepository:
raise ValueError(f"Invalid field: {field}")
query = sql.SQL(
"SELECT time, {} FROM realtime.node_results WHERE time >= %s AND time <= %s AND node_id = %s"
"SELECT time, {} FROM realtime.node_results WHERE time >= %s "
"AND time <= %s AND node_id = %s ORDER BY time"
).format(sql.Identifier(field))
async with conn.cursor() as cur:
@@ -346,6 +392,33 @@ class RealtimeRepository:
{"time": row["time"].isoformat(), "value": row[field]} for row in rows
]
@staticmethod
async def get_node_fields_by_ids_time_range(
conn: AsyncConnection,
start_time: datetime,
end_time: datetime,
node_ids: list[str],
field: str,
) -> dict[str, list[dict[str, Any]]]:
valid_fields = {"actual_demand", "total_head", "pressure", "quality"}
if field not in valid_fields:
raise ValueError(f"Invalid field: {field}")
result = {node_id: [] for node_id in node_ids}
if not node_ids:
return result
query = sql.SQL(
"SELECT node_id, time, {} FROM realtime.node_results "
"WHERE time BETWEEN %s AND %s AND node_id = ANY(%s) "
"ORDER BY node_id, time"
).format(sql.Identifier(field))
async with conn.cursor() as cur:
await cur.execute(query, (start_time, end_time, node_ids))
for row in await cur.fetchall():
result.setdefault(str(row["node_id"]), []).append(
{"time": row["time"].isoformat(), "value": row[field]}
)
return result
@staticmethod
async def get_nodes_field_by_time_range(
conn: AsyncConnection, start_time: datetime, end_time: datetime, field: str
@@ -355,7 +428,8 @@ class RealtimeRepository:
raise ValueError(f"Invalid field: {field}")
query = sql.SQL(
"SELECT node_id, time, {} FROM realtime.node_results WHERE time >= %s AND time <= %s"
"SELECT node_id, time, {} FROM realtime.node_results "
"WHERE time >= %s AND time <= %s ORDER BY node_id, time"
).format(sql.Identifier(field))
async with conn.cursor() as cur:
@@ -459,6 +533,19 @@ class RealtimeRepository:
# transactions (savepoints), while this outer transaction guarantees
# that a link write failure also rolls back the node replacement.
async with conn.transaction():
async with conn.cursor() as cur:
await cur.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))",
(simulation_time,),
)
await cur.execute(
"DELETE FROM realtime.node_results WHERE time = %s",
(simulation_time,),
)
await cur.execute(
"DELETE FROM realtime.link_results WHERE time = %s",
(simulation_time,),
)
if node_data:
await RealtimeRepository.insert_nodes_batch(conn, node_data)
@@ -525,6 +612,19 @@ class RealtimeRepository:
# transactions (savepoints), while this outer transaction guarantees
# that a link write failure also rolls back the node replacement.
with conn.transaction():
with conn.cursor() as cur:
cur.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))",
(simulation_time,),
)
cur.execute(
"DELETE FROM realtime.node_results WHERE time = %s",
(simulation_time,),
)
cur.execute(
"DELETE FROM realtime.link_results WHERE time = %s",
(simulation_time,),
)
if node_data:
RealtimeRepository.insert_nodes_batch_sync(conn, node_data)
+32 -3
View File
@@ -35,7 +35,8 @@ class ScadaRepository:
) -> List[dict]:
async with conn.cursor() as cur:
await cur.execute(
"SELECT * FROM scada.measurements WHERE device_id = ANY(%s) AND time >= %s AND time <= %s",
"SELECT * FROM scada.measurements WHERE device_id = ANY(%s) "
"AND time >= %s AND time <= %s ORDER BY device_id, time",
(device_ids, start_time, end_time),
)
return await cur.fetchall()
@@ -49,7 +50,8 @@ class ScadaRepository:
) -> List[dict]:
with conn.cursor(row_factory=dict_row) as cur:
cur.execute(
"SELECT * FROM scada.measurements WHERE device_id = ANY(%s) AND time >= %s AND time <= %s",
"SELECT * FROM scada.measurements WHERE device_id = ANY(%s) "
"AND time >= %s AND time <= %s ORDER BY device_id, time",
(device_ids, start_time, end_time),
)
return cur.fetchall()
@@ -88,7 +90,9 @@ class ScadaRepository:
raise ValueError(f"Invalid field: {field}")
query = sql.SQL(
"SELECT device_id, time, {} FROM scada.measurements WHERE time >= %s AND time <= %s AND device_id = ANY(%s)"
"SELECT device_id, time, {} FROM scada.measurements "
"WHERE time >= %s AND time <= %s AND device_id = ANY(%s) "
"ORDER BY device_id, time"
).format(sql.Identifier(field))
async with conn.cursor() as cur:
@@ -122,6 +126,31 @@ class ScadaRepository:
if cur.rowcount == 0:
await cur.execute(insert_query, (time, device_id, value))
@staticmethod
async def update_scada_field_batch(
conn: AsyncConnection,
rows: list[tuple[datetime, str, float | None]],
field: str,
) -> int:
"""Update existing SCADA samples in one set-based statement."""
valid_fields = {"monitored_value", "cleaned_value"}
if field not in valid_fields:
raise ValueError(f"Invalid field: {field}")
if not rows:
return 0
query = sql.SQL(
"UPDATE scada.measurements AS measurement SET {} = batch.value "
"FROM unnest(%s::timestamptz[], %s::text[], %s::double precision[]) "
"AS batch(time, device_id, value) "
"WHERE measurement.time = batch.time "
"AND measurement.device_id = batch.device_id"
).format(sql.Identifier(field))
times, device_ids, values = zip(*rows)
async with conn.cursor() as cur:
await cur.execute(query, (list(times), list(device_ids), list(values)))
return cur.rowcount
@staticmethod
async def delete_scada_by_id_time_range(
conn: AsyncConnection, device_id: str, start_time: datetime, end_time: datetime
+2
View File
@@ -11,6 +11,7 @@ from app.core.config import settings
from app.infra.db.project_routing import get_project_timescale_pgconn_string
_check_connection = ConnectionPool.check_connection
_pools: OrderedDict[str, ConnectionPool] = OrderedDict()
_pool_conninfo: dict[str, str] = {}
_pool_borrows: dict[str, int] = {}
@@ -49,6 +50,7 @@ def get_timescale_pool(db_name: str) -> ConnectionPool:
min_size=settings.PROJECT_TS_POOL_MIN_SIZE,
max_size=settings.PROJECT_TS_POOL_MAX_SIZE,
kwargs={"row_factory": dict_row},
check=_check_connection,
open=True,
)
_pools[db_name] = pool
-6
View File
@@ -5,14 +5,12 @@ from fastapi.middleware.cors import CORSMiddleware
import logging
from datetime import datetime
import app.services.project_info as project_info
from app.api.problem_details import install_problem_details_handlers
from app.api.v1.rest_router import api_router
from app.infra.db.dynamic_manager import project_connection_manager
from app.infra.db.metadb.database import close_metadata_engine
from app.infra.db.timescaledb.sync_pool import close_all_timescale_pools
from app.native.wndb.core.connection import close_all_project_pools
from app.services.tjnetwork import open_project
from app.core.config import settings
# 导入审计中间件
@@ -30,10 +28,6 @@ async def lifespan(app: FastAPI):
logger.info("TJWater CloudService is starting...")
logger.info("**********************************************************")
if project_info.name:
print(project_info.name)
open_project(project_info.name)
yield
# 清理资源
await project_connection_manager.close_all()
+3 -4
View File
@@ -7,7 +7,7 @@ from ..model.reservoirs import unset_reservoir_by_pattern
from ..model.tanks import unset_tank_by_curve
from ..model.pumps import unset_pump_by_curve, unset_pump_by_pattern
from ..model.tags import delete_tag_by_node, delete_tag_by_link
from ..model.demands import delete_demand_by_junction, unset_demand_by_pattern
from ..model.demands import delete_demand_by_junction
from ..model.status import delete_status_by_link
from ..model.energy import delete_pump_energy_by_pump, unset_pump_energy_by_pattern, unset_pump_energy_by_curve
from ..model.emitters import delete_emitter_by_junction
@@ -167,7 +167,6 @@ def expand_pattern_delete(name: str, cs: ChangeSet) -> ChangeSet:
result.merge(unset_reservoir_by_pattern(name, id))
result.merge(unset_pump_by_pattern(name, id))
result.merge(unset_demand_by_pattern(name, id))
result.merge(unset_pump_energy_by_pattern(name, id))
result.merge(unset_source_by_pattern(name, id))
result.merge(cs)
@@ -191,7 +190,7 @@ def expand_curve_delete(name: str, cs: ChangeSet) -> ChangeSet:
return result
def expand_legacy_options_update(cs: ChangeSet) -> ChangeSet:
def expand_v2_options_update(cs: ChangeSet) -> ChangeSet:
cs.operations[0]['operation'] = API_UPDATE
cs.operations[0]['type'] = 'option'
new_cs = cs
@@ -239,6 +238,6 @@ _DELETE_REWRITERS: dict[str, DeleteRewriter] = {
}
_UPDATE_REWRITERS: dict[str, UpdateRewriter] = {
"option": expand_legacy_options_update,
"option": expand_v2_options_update,
"option_v3": expand_v3_options_update,
}
+6 -5
View File
@@ -2,13 +2,14 @@
from collections.abc import Callable
from ..core.connection import project_transaction
from ..core.database import (
API_ADD,
API_DELETE,
API_UPDATE,
ChangeSet,
refresh_materialized_views,
changes_affect_materialized_views,
model_mutation_transaction,
refresh_materialized_views_after_commit,
)
from ..gis.backdrop import set_backdrop
from ..gis.labels import add_label, delete_label, set_label
@@ -131,7 +132,7 @@ def _execute_delete_command(name: str, change_set: ChangeSet) -> ChangeSet:
def execute_batch_commands(name: str, change_set: ChangeSet) -> ChangeSet:
with project_transaction(name):
with model_mutation_transaction(name):
rewritten = ChangeSet()
for operation in change_set.operations:
rewritten.merge(expand_command(name, ChangeSet(operation)))
@@ -146,8 +147,8 @@ def execute_batch_commands(name: str, change_set: ChangeSet) -> ChangeSet:
elif operation_type == API_DELETE:
result.merge(_execute_delete_command(name, ChangeSet(operation)))
if rewritten.operations:
refresh_materialized_views(name)
if changes_affect_materialized_views(rewritten):
refresh_materialized_views_after_commit(name)
return result
+20
View File
@@ -11,6 +11,7 @@ from psycopg_pool import ConnectionPool
from app.core.config import settings
from app.infra.db.project_routing import get_project_pgconn_string
_check_connection = ConnectionPool.check_connection
_pools: OrderedDict[str, ConnectionPool] = OrderedDict()
_pool_conninfo: dict[str, str] = {}
_pool_borrows: dict[str, int] = {}
@@ -21,6 +22,10 @@ _active_project_connection: ContextVar[tuple[str, Connection] | None] = ContextV
"wndb_active_project_connection",
default=None,
)
_active_model_mutation_locks: ContextVar[frozenset[str]] = ContextVar(
"wndb_active_model_mutation_locks",
default=frozenset(),
)
def _close_pool(pool: ConnectionPool) -> None:
@@ -78,6 +83,7 @@ def get_project_pool(name: str) -> ConnectionPool:
min_size=settings.PROJECT_PG_POOL_MIN_SIZE,
max_size=settings.PROJECT_PG_POOL_SIZE + settings.PROJECT_PG_MAX_OVERFLOW,
kwargs={"autocommit": True, "row_factory": dict_row},
check=_check_connection,
open=True,
)
_pools[name] = pool
@@ -140,6 +146,7 @@ def get_admin_pool() -> ConnectionPool:
min_size=settings.PROJECT_PG_POOL_MIN_SIZE,
max_size=settings.PROJECT_PG_POOL_SIZE,
kwargs={"autocommit": True, "row_factory": dict_row},
check=_check_connection,
open=True,
)
_admin_pools[conninfo] = pool
@@ -192,10 +199,12 @@ def project_transaction(name: str) -> Iterator[Connection]:
try:
with pool.connection() as conn:
token = _active_project_connection.set((name, conn))
lock_token = _active_model_mutation_locks.set(frozenset())
try:
with conn.transaction():
yield conn
finally:
_active_model_mutation_locks.reset(lock_token)
_active_project_connection.reset(token)
finally:
with _registry_lock:
@@ -208,6 +217,17 @@ def is_project_transaction_active(name: str) -> bool:
return active is not None and active[0] == name
def is_model_mutation_lock_active(name: str) -> bool:
"""Return whether the current project transaction already owns its model lock."""
return name in _active_model_mutation_locks.get()
def mark_model_mutation_lock_active(name: str) -> None:
"""Record a transaction-scoped advisory lock to avoid duplicate round trips."""
locks = _active_model_mutation_locks.get()
_active_model_mutation_locks.set(locks | {name})
@contextmanager
def admin_connection() -> Iterator[Connection]:
"""Borrow a PostgreSQL administration connection from its pool."""
+102 -5
View File
@@ -1,10 +1,19 @@
from collections.abc import Mapping, Sequence
from typing import Any
from collections.abc import Iterator, Mapping, Sequence
from contextlib import contextmanager
from typing import Any, Callable
from psycopg import sql
from psycopg.rows import Row, dict_row
from .connection import is_project_transaction_active, project_connection
from app.infra.db.project_routing import get_project_database_name
from .connection import (
is_model_mutation_lock_active,
is_project_transaction_active,
mark_model_mutation_lock_active,
project_connection,
project_transaction,
)
API_ADD = "add"
API_UPDATE = "update"
@@ -61,6 +70,20 @@ class DatabaseCommand:
self.sql = statement
self.changes = changes
class MaterializedViewRefreshAfterCommitError(RuntimeError):
"""Report a failed view refresh without implying that the write rolled back."""
changes_committed = True
def __init__(self, project: str) -> None:
self.project = project
super().__init__(
f"Project {project!r} changes were committed, but materialized view "
"refresh failed"
)
QueryParams = Sequence[Any] | Mapping[str, Any]
@@ -78,6 +101,27 @@ def _execute(cur, query: str, params: QueryParams | None = None):
return cur.execute(query, params) if params is not None else cur.execute(query)
def acquire_model_mutation_lock(conn, name: str) -> None:
"""Serialize model replacement and ordinary WNDB mutations per database."""
if is_model_mutation_lock_active(name):
return
physical_name = get_project_database_name(name)
with conn.cursor() as cur:
cur.execute(
"select pg_advisory_xact_lock(hashtextextended(%s, 0))",
(f"tjwater:wndb:model:{physical_name}",),
)
mark_model_mutation_lock_active(name)
@contextmanager
def model_mutation_transaction(name: str) -> Iterator[Any]:
"""Open a project transaction and acquire its model lock before reading."""
with project_transaction(name) as conn:
acquire_model_mutation_lock(conn, name)
yield conn
def read(name: str, query: str, params: QueryParams | None = None) -> Row:
with project_connection(name) as conn, conn.cursor(row_factory=dict_row) as cur:
_execute(cur, query, params)
@@ -104,7 +148,14 @@ def try_read(
def write(name: str, query: str, params: QueryParams | None = None) -> None:
with project_connection(name) as conn, conn.cursor() as cur:
connection_context = (
project_connection(name)
if is_project_transaction_active(name)
else model_mutation_transaction(name)
)
with connection_context as conn:
acquire_model_mutation_lock(conn, name)
with conn.cursor() as cur:
_execute(cur, query, params)
@@ -114,6 +165,13 @@ def refresh_materialized_views(name: str, *, concurrently: bool = True) -> None:
cur.execute("CALL gis.refresh_all_materialized_views(%s)", (concurrently,))
def refresh_materialized_views_after_commit(name: str) -> None:
try:
refresh_materialized_views(name)
except Exception as exc:
raise MaterializedViewRefreshAfterCommitError(name) from exc
_MATERIALIZED_VIEW_SOURCES = (
"network.nodes",
"network.junctions",
@@ -135,9 +193,48 @@ def _affects_materialized_views(command: DatabaseCommand) -> bool:
return any(source in statement for source in _MATERIALIZED_VIEW_SOURCES)
_MATERIALIZED_VIEW_ELEMENT_TYPES = frozenset(
{
"junction",
"reservoir",
"tank",
"pipe",
"pump",
"valve",
"demand",
"vertex",
}
)
def changes_affect_materialized_views(change_set: ChangeSet) -> bool:
"""Return whether a dispatched WNDB batch changes a published GIS source."""
return any(
operation.get("type") in _MATERIALIZED_VIEW_ELEMENT_TYPES
for operation in change_set.operations
)
def execute_command(name: str, command: DatabaseCommand) -> ChangeSet:
"""Apply a model mutation without the removed database undo/redo journal."""
write(name, command.sql)
if _affects_materialized_views(command) and not is_project_transaction_active(name):
refresh_materialized_views(name)
refresh_materialized_views_after_commit(name)
return ChangeSet.from_list(command.changes)
def execute_locked_command(
name: str,
builder: Callable[[], DatabaseCommand | None],
) -> ChangeSet:
"""Build a read-modify-write command only after acquiring the model lock."""
nested_transaction = is_project_transaction_active(name)
command: DatabaseCommand | None = None
with model_mutation_transaction(name):
command = builder()
if command is None:
return ChangeSet()
result = execute_command(name, command)
if not nested_transaction and _affects_materialized_views(command):
refresh_materialized_views_after_commit(name)
return result
+284
View File
@@ -0,0 +1,284 @@
from psycopg import Connection, sql
from .connection import project_connection, project_transaction
from .database import acquire_model_mutation_lock
_MODEL_SCHEMAS = ("network", "gis")
_ALLOWED_EXTERNAL_REFERENCES = {
("analysis", "results", "network", "nodes"),
("analysis", "results", "network", "links"),
("asset", "scada_devices", "network", "nodes"),
("asset", "scada_devices", "network", "links"),
}
def _model_tables(conn: Connection) -> list[tuple[str, str]]:
with conn.cursor() as cur:
cur.execute(
"""
select n.nspname as schema_name, c.relname as table_name
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = any(%s) and c.relkind in ('r', 'p')
order by n.nspname, c.relname
""",
(list(_MODEL_SCHEMAS),),
)
return [(row["schema_name"], row["table_name"]) for row in cur.fetchall()]
def _copy_order(
conn: Connection, tables: list[tuple[str, str]]
) -> list[tuple[str, str]]:
table_set = set(tables)
dependencies: dict[tuple[str, str], set[tuple[str, str]]] = {
table: set() for table in tables
}
with conn.cursor() as cur:
cur.execute(
"""
select source_ns.nspname as source_schema,
source.relname as source_table,
target_ns.nspname as target_schema,
target.relname as target_table
from pg_constraint constraint_row
join pg_class source on source.oid = constraint_row.conrelid
join pg_namespace source_ns on source_ns.oid = source.relnamespace
join pg_class target on target.oid = constraint_row.confrelid
join pg_namespace target_ns on target_ns.oid = target.relnamespace
where constraint_row.contype = 'f'
and source_ns.nspname = any(%s)
and target_ns.nspname = any(%s)
""",
(list(_MODEL_SCHEMAS), list(_MODEL_SCHEMAS)),
)
for row in cur.fetchall():
source = (row["source_schema"], row["source_table"])
target = (row["target_schema"], row["target_table"])
if source in table_set and target in table_set and source != target:
dependencies[source].add(target)
ordered: list[tuple[str, str]] = []
remaining = set(tables)
while remaining:
ready = sorted(
table for table in remaining if not (dependencies[table] & remaining)
)
if not ready:
cycle = ", ".join(f"{schema}.{table}" for schema, table in sorted(remaining))
raise RuntimeError(f"Model table foreign-key cycle detected: {cycle}")
ordered.extend(ready)
remaining.difference_update(ready)
return ordered
def _table_columns(
conn: Connection, schema_name: str, table_name: str
) -> list[str]:
with conn.cursor() as cur:
cur.execute(
"""
select column_name
from information_schema.columns
where table_schema = %s and table_name = %s
and is_generated = 'NEVER'
order by ordinal_position
""",
(schema_name, table_name),
)
return [row["column_name"] for row in cur.fetchall()]
def _external_references(conn: Connection) -> set[tuple[str, str, str, str]]:
with conn.cursor() as cur:
cur.execute(
"""
select source_ns.nspname as source_schema,
source.relname as source_table,
target_ns.nspname as target_schema,
target.relname as target_table
from pg_constraint constraint_row
join pg_class source on source.oid = constraint_row.conrelid
join pg_namespace source_ns on source_ns.oid = source.relnamespace
join pg_class target on target.oid = constraint_row.confrelid
join pg_namespace target_ns on target_ns.oid = target.relnamespace
where constraint_row.contype = 'f'
and target_ns.nspname = any(%s)
and source_ns.nspname <> all(%s)
""",
(list(_MODEL_SCHEMAS), list(_MODEL_SCHEMAS)),
)
return {
(
row["source_schema"],
row["source_table"],
row["target_schema"],
row["target_table"],
)
for row in cur.fetchall()
}
def _copy_table(
source_conn: Connection,
target_conn: Connection,
schema_name: str,
table_name: str,
columns: list[str],
) -> None:
relation = sql.Identifier(schema_name, table_name)
column_list = sql.SQL(", ").join(map(sql.Identifier, columns))
copy_out = sql.SQL("copy {} ({}) to stdout").format(relation, column_list)
copy_in = sql.SQL("copy {} ({}) from stdin").format(relation, column_list)
with source_conn.cursor().copy(copy_out) as source_copy:
with target_conn.cursor().copy(copy_in) as target_copy:
for chunk in source_copy:
target_copy.write(chunk)
def replace_project_model(
target_project: str,
source_project: str,
*,
copy_source_scada: bool = False,
) -> None:
"""Atomically replace WNDB/GIS model tables from a validated staging DB.
Business and analysis tables remain in the target database. Historical
analysis rows keep references that still exist and become element-neutral
when an element disappeared. SCADA devices are retained only when their
bound node or link still exists in the replacement model.
``copy_source_scada`` is used only for temporary project clones. Normal INP
replacement keeps the target project's existing device mappings and drops
mappings whose model element no longer exists.
"""
with project_connection(source_project) as source_conn, source_conn.transaction():
with source_conn.cursor() as cur:
cur.execute("set transaction isolation level repeatable read, read only")
source_tables = _model_tables(source_conn)
source_columns = {
table: _table_columns(source_conn, *table) for table in source_tables
}
source_scada_columns = (
_table_columns(source_conn, "asset", "scada_devices")
if copy_source_scada
else []
)
copy_order = _copy_order(source_conn, source_tables)
with project_transaction(target_project) as target_conn:
acquire_model_mutation_lock(target_conn, target_project)
target_tables = _model_tables(target_conn)
if set(target_tables) != set(source_tables):
missing = sorted(set(source_tables) - set(target_tables))
extra = sorted(set(target_tables) - set(source_tables))
raise RuntimeError(
f"Staging/target model schema mismatch; missing={missing}, extra={extra}"
)
for table, columns in source_columns.items():
if _table_columns(target_conn, *table) != columns:
raise RuntimeError(
f"Staging/target columns differ for {table[0]}.{table[1]}"
)
if copy_source_scada and _table_columns(
target_conn, "asset", "scada_devices"
) != source_scada_columns:
raise RuntimeError(
"Source/target columns differ for asset.scada_devices"
)
unexpected = _external_references(target_conn) - _ALLOWED_EXTERNAL_REFERENCES
if unexpected:
formatted = ", ".join(
f"{source_schema}.{source_table}->{target_schema}.{target_table}"
for source_schema, source_table, target_schema, target_table in sorted(
unexpected
)
)
raise RuntimeError(
f"Model replacement has unsupported external references: {formatted}"
)
with target_conn.cursor() as cur:
if not copy_source_scada:
cur.execute(
"create temporary table model_scada_snapshot on commit drop "
"as table asset.scada_devices"
)
cur.execute("delete from asset.scada_devices")
cur.execute(
"create temporary table model_result_refs on commit drop as "
"select result_id, node_id, link_id from analysis.results"
)
cur.execute("update analysis.results set node_id = null, link_id = null")
for schema_name, table_name in reversed(copy_order):
cur.execute(
sql.SQL("delete from {}").format(
sql.Identifier(schema_name, table_name)
)
)
for schema_name, table_name in copy_order:
_copy_table(
source_conn,
target_conn,
schema_name,
table_name,
source_columns[(schema_name, table_name)],
)
if copy_source_scada:
_copy_table(
source_conn,
target_conn,
"asset",
"scada_devices",
source_scada_columns,
)
with target_conn.cursor() as cur:
if not copy_source_scada:
cur.execute(
"""
insert into asset.scada_devices
select snapshot.*
from model_scada_snapshot snapshot
where (
snapshot.node_id is not null
and exists (
select 1 from network.nodes node
where node.id = snapshot.node_id
)
) or (
snapshot.link_id is not null
and exists (
select 1 from network.links link
where link.id = snapshot.link_id
)
)
"""
)
cur.execute(
"""
update analysis.results result
set node_id = case
when exists (
select 1 from network.nodes node
where node.id = refs.node_id
) then refs.node_id
else null
end,
link_id = case
when exists (
select 1 from network.links link
where link.id = refs.link_id
) then refs.link_id
else null
end
from model_result_refs refs
where refs.result_id = result.result_id
"""
)
+205 -45
View File
@@ -1,13 +1,46 @@
from collections.abc import Iterable
from contextlib import contextmanager
import re
from uuid import uuid4
from psycopg import sql
from psycopg.rows import dict_row
from app.core.config import settings
from app.infra.db.project_routing import (
get_project_database_name,
get_project_template_database_name,
)
from .connection import (
admin_connection,
close_project_pool,
get_project_pool,
is_project_pool_open,
)
_server_databases = ["template0", "template1", "postgres", "project"]
_SERVER_DATABASES = frozenset({"template0", "template1", "postgres", "project"})
_TEMPORARY_DATABASE_PREFIX = "tjw_tmp_"
def _protected_databases() -> frozenset[str]:
return _SERVER_DATABASES | {
settings.METADATA_DB_NAME,
settings.WNDB_TEMPLATE_DB_NAME,
}
def _validate_project_database(name: str, *, allow_template_source: bool = False) -> None:
if not name:
raise ValueError("Project database name must not be empty")
protected = {database.casefold() for database in _protected_databases()}
is_template = name.casefold().endswith("_template")
if (
allow_template_source
and name.casefold() == settings.WNDB_TEMPLATE_DB_NAME.casefold()
):
return
if name.casefold() in protected or is_template:
raise ValueError(f"Database {name!r} is protected and cannot be managed as a project")
def list_project() -> list[str]:
@@ -16,92 +49,219 @@ def list_project() -> list[str]:
with conn.cursor(row_factory=dict_row) as cur:
for p in cur.execute(
"select datname from pg_database where datname <> all(%s) order by datname",
(_server_databases,),
(list(_protected_databases()),),
):
if not str(p["datname"]).casefold().endswith("_template"):
ps.append(p["datname"])
return ps
@contextmanager
def _database_locks(cur, *database_names: str):
"""Serialize physical database lifecycle operations across server workers."""
lock_names = sorted(set(database_names), key=str.casefold)
for database_name in lock_names:
cur.execute(
"select pg_advisory_lock(hashtextextended(%s, 0))",
(f"tjwater:wndb:{database_name}",),
)
try:
yield
finally:
for database_name in reversed(lock_names):
cur.execute(
"select pg_advisory_unlock(hashtextextended(%s, 0))",
(f"tjwater:wndb:{database_name}",),
)
@contextmanager
def _temporary_database_capacity(cur, database_name: str):
"""Serialize temporary creation and enforce a server-wide hard limit."""
if not database_name.startswith(_TEMPORARY_DATABASE_PREFIX):
yield
return
lock_name = "tjwater:wndb:temporary-database-capacity"
cur.execute("select pg_advisory_lock(hashtextextended(%s, 0))", (lock_name,))
try:
cur.execute(
"select count(*) as count from pg_database where datname like %s",
(f"{_TEMPORARY_DATABASE_PREFIX}%",),
)
row = cur.fetchone()
count = int(row["count"] if row is not None else 0)
limit = max(1, settings.WNDB_TEMP_DB_MAX_COUNT)
if count >= limit:
raise RuntimeError(
f"Temporary database limit reached ({count}/{limit}); "
"retry after an active analysis completes"
)
yield
finally:
cur.execute("select pg_advisory_unlock(hashtextextended(%s, 0))", (lock_name,))
def _database_allows_connections(cur, database_name: str) -> bool:
cur.execute(
"select datallowconn from pg_database where datname = %s",
(database_name,),
)
row = cur.fetchone()
if row is None:
raise ValueError(f"Database {database_name!r} does not exist")
return bool(row["datallowconn"])
def _set_database_connections(cur, database_name: str, *, allowed: bool) -> None:
cur.execute(
"update pg_database set datallowconn = %s where datname = %s",
(allowed, database_name),
)
def temporary_project_name(project: str, purpose: str) -> str:
"""Return a collision-resistant physical database name for one run."""
physical_name = get_project_database_name(project)
safe_purpose = re.sub(r"[^a-z0-9_]+", "_", purpose.casefold()).strip("_")
safe_project = re.sub(r"[^a-z0-9_]+", "_", physical_name.casefold()).strip("_")
prefix = (
f"{_TEMPORARY_DATABASE_PREFIX}{safe_purpose or 'run'}_"
f"{safe_project or 'project'}"
)[:29]
return f"{prefix}_{uuid4().hex}"
@contextmanager
def temporary_project_database(project: str, purpose: str):
"""Clone one project's runnable model into an isolated temporary database."""
temporary_name = temporary_project_name(project, purpose)
try:
copy_project(get_project_template_database_name(project), temporary_name)
# Import lazily to keep physical database lifecycle independent from
# model-copy implementation details at module import time.
from .database import refresh_materialized_views_after_commit
from .model_replace import replace_project_model
replace_project_model(
temporary_name,
project,
copy_source_scada=True,
)
refresh_materialized_views_after_commit(temporary_name)
yield temporary_name
finally:
if have_project(temporary_name):
delete_project(temporary_name)
@contextmanager
def temporary_template_database(name_hint: str, purpose: str):
"""Create an empty schema-only temporary database from the fixed template."""
temporary_name = temporary_project_name(name_hint, purpose)
try:
copy_project(get_project_template_database_name(name_hint), temporary_name)
yield temporary_name
finally:
if have_project(temporary_name):
delete_project(temporary_name)
def have_project(name: str) -> bool:
database_name = get_project_database_name(name)
with admin_connection() as conn:
with conn.cursor() as cur:
cur.execute("select 1 from pg_database where datname = %s", (name,))
cur.execute("select 1 from pg_database where datname = %s", (database_name,))
return cur.fetchone() is not None
def copy_project(source: str, new: str) -> None:
physical_source = get_project_database_name(source)
physical_new = get_project_database_name(new)
_validate_project_database(physical_source, allow_template_source=True)
_validate_project_database(physical_new)
close_project_pool(source)
close_project_pool(new)
with admin_connection() as admin_conn:
with admin_conn.cursor() as cur:
cur.execute(
"update pg_database set datallowconn = false where datname = %s",
(source,),
with _temporary_database_capacity(cur, physical_new):
with _database_locks(cur, physical_source, physical_new):
source_allowed = _database_allows_connections(cur, physical_source)
if source_allowed:
_set_database_connections(
cur,
physical_source,
allowed=False,
)
try:
cur.execute(
"select pg_terminate_backend(pid) from pg_stat_activity where datname = %s and pid <> pg_backend_pid()",
(source,),
"select pg_terminate_backend(pid) from pg_stat_activity "
"where datname = %s and pid <> pg_backend_pid()",
(physical_source,),
)
cur.execute(
sql.SQL("create database {} with template = {}").format(
sql.Identifier(new), sql.Identifier(source)
sql.Identifier(physical_new),
sql.Identifier(physical_source),
)
)
finally:
cur.execute(
"update pg_database set datallowconn = true where datname = %s",
(source,),
if source_allowed:
_set_database_connections(
cur,
physical_source,
allowed=True,
)
def create_project(name: str) -> None:
return copy_project("project", name)
return copy_project(get_project_template_database_name(name), name)
def delete_project(name: str) -> None:
database_name = get_project_database_name(name)
_validate_project_database(database_name)
close_project_pool(name)
with admin_connection() as conn:
with conn.cursor() as cur:
with _database_locks(cur, database_name):
was_allowed = _database_allows_connections(cur, database_name)
if was_allowed:
_set_database_connections(cur, database_name, allowed=False)
try:
cur.execute(
"select pg_terminate_backend(pid) from pg_stat_activity "
"where datname = %s and pid <> pg_backend_pid()",
(name,),
(database_name,),
)
cur.execute(
sql.SQL("drop database {}").format(sql.Identifier(name))
sql.SQL("drop database {}").format(
sql.Identifier(database_name)
)
)
except Exception:
if was_allowed:
_set_database_connections(cur, database_name, allowed=True)
raise
def clean_project(excluded: list[str] = []) -> None:
projects = list_project()
def clean_project(projects: Iterable[str]) -> None:
"""Delete only the explicitly supplied project databases."""
targets = list(dict.fromkeys(projects))
physical_targets = [get_project_database_name(project) for project in targets]
for database_name in physical_targets:
_validate_project_database(database_name)
if not targets:
return
with admin_connection() as conn:
with conn.cursor(row_factory=dict_row) as cur:
row = cur.execute("select current_database()").fetchone()
if row != None:
current_db = row["current_database"]
if current_db in projects:
projects.remove(current_db)
for project in projects:
if project in _server_databases or project in excluded:
continue
cur.execute(
"select pg_terminate_backend(pid) from pg_stat_activity "
"where datname = %s and pid <> pg_backend_pid()",
(project,),
)
cur.execute(
sql.SQL("drop database {}").format(sql.Identifier(project))
)
current_db = row["current_database"] if row is not None else None
if current_db in physical_targets:
raise ValueError(f"Cannot delete the current database {current_db!r}")
def open_project(name: str) -> None:
get_project_pool(name)
def is_project_open(name: str) -> bool:
return is_project_pool_open(name)
def close_project(name: str) -> None:
close_project_pool(name)
for project in targets:
delete_project(project)
+16 -54
View File
@@ -1,10 +1,5 @@
from psycopg.rows import dict_row
from ..core.database import read_all, sql_literal, try_read
from ..core.connection import project_connection
from ..core.database import read_all, sql_literal, try_read, write
from ..core.connection import project_connection
from ..model.elements import get_link_nodes
from psycopg.rows import dict_row
def sql_update_coord(node: str, x: float, y: float) -> str:
geom = f"st_setsrid(st_makepoint({sql_literal(x)}, {sql_literal(y)}), 900914)"
@@ -21,8 +16,8 @@ def sql_delete_coord(node: str) -> str:
def from_postgis_point(coord: str) -> dict[str, float]:
xy = coord.lower().removeprefix('point(').removesuffix(')').split(' ')
return { 'x': float(xy[0]), 'y': float(xy[1]) }
xy = coord.lower().removeprefix("point(").removesuffix(")").split(" ")
return {"x": float(xy[0]), "y": float(xy[1])}
def get_node_coord(name: str, node: str) -> dict[str, float]:
@@ -31,51 +26,15 @@ def get_node_coord(name: str, node: str) -> dict[str, float]:
"select st_astext(geom) as coord_geom from gis.node_geometries where node_id = %s",
(node,),
)
if row == None:
write(name, sql_insert_coord(node, 0.0, 0.0))
return {'x': 0.0, 'y': 0.0}
return from_postgis_point(row['coord_geom'])
# DingZQ 2025-01-03, get nodes in extent
# return node id list
# node_id:junction:x:y
def get_nodes_in_extent(name: str, x1: float, y1: float, x2: float, y2: float) -> list[str]:
nodes = []
objs = read_all(name, 'select node_id, st_astext(geom) as coord_geom from gis.node_geometries')
for obj in objs:
node_id = obj['node_id']
coord = from_postgis_point(obj['coord_geom'])
x = coord['x']
y = coord['y']
if x1 <= x <= x2 and y1 <= y <= y2:
nodes.append(f"{node_id}:junction:{x}:{y}")
return nodes
# DingZQ 2025-01-03, get links in extent
# return link id list
# link_id:pipe:node_id1:node_id2
def get_links_in_extent(name: str, x1: float, y1: float, x2: float, y2: float) -> list[str]:
node_ids = set([s.split(':')[0] for s in get_nodes_in_extent(name, x1, y1, x2, y2)])
all_link_ids = []
with project_connection(name) as conn:
with conn.cursor(row_factory=dict_row) as cur:
cur.execute("select link_id from network.pipes")
for record in cur:
all_link_ids.append(record['link_id'])
links = []
for link_id in all_link_ids:
nodes = get_link_nodes(name, link_id)
if nodes[0] in node_ids and nodes[1] in node_ids:
links.append(f"{link_id}:pipe:{nodes[0]}:{nodes[1]}")
return links
if row is None:
return {"x": 0.0, "y": 0.0}
return from_postgis_point(row["coord_geom"])
def node_has_coord(name: str, node: str) -> bool:
return try_read(
name, "select node_id from gis.node_geometries where node_id = %s", (node,)
) != None
) is not None
#--------------------------------------------------------------
@@ -94,11 +53,14 @@ def inp_in_coord(line: str) -> str:
def inp_out_coord(name: str) -> list[str]:
lines = []
objs = read_all(name, 'select node_id, st_astext(geom) as coord_geom from gis.node_geometries')
objs = read_all(
name,
"select node_id, st_astext(geom) as coord_geom from gis.node_geometries",
)
for obj in objs:
node = obj['node_id']
coord = from_postgis_point(obj['coord_geom'])
x = coord['x']
y = coord['y']
lines.append(f'{node} {x} {y}')
node = obj["node_id"]
coord = from_postgis_point(obj["coord_geom"])
x = coord["x"]
y = coord["y"]
lines.append(f"{node} {x} {y}")
return lines
+191
View File
@@ -0,0 +1,191 @@
"""Read-only projections backed by the GIS materialized-view query layer."""
from typing import Any
from ..core.database import read, read_all
def _node_coord_rows(
rows: list[Any],
) -> dict[str, dict[str, Any]]:
return {
str(row["id"]): {
"x": float(row["x"]),
"y": float(row["y"]),
"type": str(row["node_type"]),
}
for row in rows
}
def get_network_node_coords(name: str) -> dict[str, dict[str, Any]]:
"""Return every publishable node with one view-backed query."""
rows = read_all(
name,
"""
SELECT id, x, y, node_type
FROM gis.network_nodes
ORDER BY id
""",
)
return _node_coord_rows(rows)
def get_major_node_coords(
name: str, diameter: int
) -> dict[str, dict[str, Any]]:
"""Return endpoints of pipes above the requested diameter."""
rows = read_all(
name,
"""
SELECT n.id, n.x, n.y, n.node_type
FROM gis.pipes AS p
CROSS JOIN LATERAL (
VALUES (p.start_node_id), (p.end_node_id)
) AS endpoint(id)
JOIN gis.network_nodes AS n ON n.id = endpoint.id
WHERE p.diameter > %s
GROUP BY n.id, n.x, n.y, n.node_type
ORDER BY n.id
""",
(diameter,),
)
return _node_coord_rows(rows)
def get_network_link_nodes(name: str) -> list[str]:
"""Return every publishable link in the established API wire format."""
rows = read_all(
name,
"""
SELECT id, link_type, start_node_id, end_node_id
FROM gis.network_links
ORDER BY id
""",
)
return [
f"{row['id']}:{row['link_type']}:{row['start_node_id']}:{row['end_node_id']}"
for row in rows
]
def get_major_pipe_nodes(name: str, diameter: int) -> list[str]:
"""Return large pipes in the established API wire format."""
rows = read_all(
name,
"""
SELECT id, start_node_id, end_node_id
FROM gis.pipes
WHERE diameter > %s
ORDER BY id
""",
(diameter,),
)
return [
f"{row['id']}:pipe:{row['start_node_id']}:{row['end_node_id']}"
for row in rows
]
def get_topology_rows(
name: str, node_ids: list[str]
) -> tuple[list[Any], list[Any]]:
"""Load selected nodes and their internal links with two batch queries."""
if not node_ids:
return [], []
nodes = read_all(
name,
"""
SELECT n.id,
ST_X(g.geom) AS x,
ST_Y(g.geom) AS y,
n.node_type::text AS node_type
FROM network.nodes AS n
JOIN gis.node_geometries AS g ON g.node_id = n.id
WHERE n.id = ANY(%s)
ORDER BY n.id
""",
(node_ids,),
)
links = read_all(
name,
"""
SELECT l.id,
l.start_node_id,
l.end_node_id,
COALESCE(p.length, 0.0) AS length
FROM network.links AS l
LEFT JOIN network.pipes AS p ON p.link_id = l.id
WHERE l.start_node_id = ANY(%s)
AND l.end_node_id = ANY(%s)
ORDER BY l.id
""",
(node_ids, node_ids),
)
return nodes, links
def get_boundary_link_ids(name: str, node_ids: list[str]) -> list[str]:
"""Return links with exactly one endpoint inside the supplied node set."""
if not node_ids:
return []
rows = read_all(
name,
"""
SELECT id
FROM network.links
WHERE (start_node_id = ANY(%s)) <> (end_node_id = ANY(%s))
ORDER BY id
""",
(node_ids, node_ids),
)
return [str(row["id"]) for row in rows]
def get_junction_demands(
name: str, node_ids: list[str]
) -> dict[str, list[dict[str, Any]]]:
"""Read authoritative demands for selected junctions from model tables."""
if not node_ids:
return {}
rows = read_all(
name,
"""
SELECT junction_id,
sequence_no,
base_demand,
pattern_id,
category
FROM network.demands
WHERE junction_id = ANY(%s)
ORDER BY junction_id, sequence_no
""",
(node_ids,),
)
result: dict[str, list[dict[str, Any]]] = {}
for row in rows:
result.setdefault(str(row["junction_id"]), []).append(
{
"demand": float(row["base_demand"]),
"pattern": row["pattern_id"],
"category": row["category"],
}
)
return result
def sum_junction_base_demand(name: str, node_ids: list[str]) -> float:
"""Sum selected junction demand from the authoritative model table."""
if not node_ids:
return 0.0
row = read(
name,
"""
SELECT COALESCE(SUM(base_demand), 0.0) AS total_base_demand
FROM network.demands
WHERE junction_id = ANY(%s)
""",
(node_ids,),
)
return float(row["total_base_demand"])
+33 -47
View File
@@ -2,10 +2,8 @@ import platform
import math
from typing import Any
import pyclipper
from ..model.elements import get_node_links, get_link_nodes, is_pipe
from ..model.pipes import get_pipe
from ..core.database import read, try_read, read_all
from .coordinates import node_has_coord, get_node_coord
from .network_views import get_boundary_link_ids, get_topology_rows
def from_postgis_polygon(polygon: str) -> list[tuple[float, float]]:
@@ -42,21 +40,7 @@ def get_nodes_in_boundary(name: str, boundary: list[tuple[float, float]]) -> lis
def _get_links_on_boundary(name: str, nodes: list[str]) -> list[str]:
links: list[str] = []
for node in nodes:
node_links = get_node_links(name, node)
for link in node_links:
if link in links:
continue
link_nodes = get_link_nodes(name, link)
if link_nodes[0] in nodes and link_nodes[1] not in nodes:
links.append(link)
elif link_nodes[0] not in nodes and link_nodes[1] in nodes:
links.append(link)
return links
return get_boundary_link_ids(name, nodes)
def get_nodes_in_region(name: str, region_id: str) -> list[str]:
@@ -128,37 +112,39 @@ def _angle_of_node_link(node: str, link: str, nodes, links) -> float:
class Topology:
def __init__(self, db: str, nodes: list[str]) -> None:
self._nodes: dict[str, Any] = {}
self._max_x_node = ''
self._node_list: list[str] = []
for node in nodes:
if not node_has_coord(db, node):
continue
if get_node_links(db, node) == 0:
continue
self._nodes[node] = get_node_coord(db, node) | { 'links': [] }
self._node_list.append(node)
if self._max_x_node == '' or self._nodes[node]['x'] > self._nodes[self._max_x_node]['x']:
self._max_x_node = node
node_rows, link_rows = get_topology_rows(db, nodes)
self._nodes: dict[str, Any] = {
str(row["id"]): {
"x": float(row["x"]),
"y": float(row["y"]),
"type": str(row["node_type"]),
"links": [],
}
for row in node_rows
}
self._node_list = list(self._nodes)
self._max_x_node = max(
self._nodes,
key=lambda node_id: self._nodes[node_id]["x"],
default="",
)
self._links: dict[str, Any] = {}
self._link_list: list[str] = []
for node in self._nodes:
for link in get_node_links(db, node):
candidate = True
link_nodes = get_link_nodes(db, link)
for link_node in link_nodes:
if link_node not in self._nodes:
candidate = False
break
if candidate:
length = get_pipe(db, link)['length'] if is_pipe(db, link) else 0.0
self._links[link] = { 'node1' : link_nodes[0], 'node2' : link_nodes[1], 'length' : length }
self._link_list.append(link)
if link not in self._nodes[link_nodes[0]]['links']:
self._nodes[link_nodes[0]]['links'].append(link)
if link not in self._nodes[link_nodes[1]]['links']:
self._nodes[link_nodes[1]]['links'].append(link)
for row in link_rows:
link_id = str(row["id"])
node1 = str(row["start_node_id"])
node2 = str(row["end_node_id"])
if node1 not in self._nodes or node2 not in self._nodes:
continue
self._links[link_id] = {
"node1": node1,
"node2": node2,
"length": float(row["length"]),
}
self._nodes[node1]["links"].append(link_id)
self._nodes[node2]["links"].append(link_id)
self._link_list = list(self._links)
def nodes(self):
return self._nodes
+4 -21
View File
@@ -1,6 +1,6 @@
import os
from ..core.projects import close_project, have_project, is_project_open, open_project
from ..core.projects import have_project
from ..core.database import ChangeSet
from .sections import (
BACKDROP,
@@ -56,7 +56,7 @@ from ..model.reactions import inp_out_reaction
from ..model.mixing import inp_out_mixing
from ..model.times import inp_out_time
from ..model.reports import inp_out_report
from ..model.options_legacy import inp_out_option
from ..model.options_v2 import inp_out_option_v2
from ..model.options_v3 import inp_out_option_v3
from ..gis.coordinates import inp_out_coord
from ..gis.vertices import inp_out_vertex
@@ -72,11 +72,6 @@ def dump_inp(project: str, inp: str, version: str = '3'):
if not have_project(project):
return
project_open = is_project_open(project)
if not project_open:
open_project(project)
dir = os.getcwd()
path = os.path.join(dir, inp)
@@ -173,7 +168,7 @@ def dump_inp(project: str, inp: str, version: str = '3'):
if version == '3':
file.write('\n'.join(inp_out_option_v3(project)))
else:
file.write('\n'.join(inp_out_option(project)))
file.write('\n'.join(inp_out_option_v2(project)))
elif name == COORDINATES:
file.write('\n'.join(inp_out_coord(project)))
@@ -194,10 +189,6 @@ def dump_inp(project: str, inp: str, version: str = '3'):
file.close()
if not project_open:
close_project(project)
def export_inp(project: str, version: str = '3') -> ChangeSet:
if version != '3' and version != '2':
version = '2'
@@ -205,11 +196,6 @@ def export_inp(project: str, version: str = '3') -> ChangeSet:
if not have_project(project):
return ChangeSet()
project_open = is_project_open(project)
if not project_open:
open_project(project)
inp = ''
for name in section_name:
@@ -294,7 +280,7 @@ def export_inp(project: str, version: str = '3') -> ChangeSet:
if version == '3':
inp += '\n'.join(inp_out_option_v3(project))
else:
inp += '\n'.join(inp_out_option(project))
inp += '\n'.join(inp_out_option_v2(project))
elif name == COORDINATES:
inp += '\n'.join(inp_out_coord(project))
@@ -313,7 +299,4 @@ def export_inp(project: str, version: str = '3') -> ChangeSet:
inp += '\n'
if not project_open:
close_project(project)
return ChangeSet({'operation': 'export', 'inp': inp})
+68 -66
View File
@@ -1,18 +1,26 @@
import datetime
import logging
import os
from tempfile import NamedTemporaryFile
from psycopg import sql
from ..core.projects import (
close_project,
create_project,
copy_project,
delete_project,
have_project,
is_project_open,
open_project,
temporary_project_name,
temporary_template_database,
)
from app.infra.db.project_routing import get_project_template_database_name
from ..core.connection import project_transaction
from ..core.database import ChangeSet, refresh_materialized_views, sql_literal, write
from ..core.model_replace import replace_project_model
from ..core.database import (
ChangeSet,
refresh_materialized_views_after_commit,
sql_literal,
write,
)
from .sections import (
BACKDROP,
BOUND,
@@ -68,7 +76,7 @@ from ..model.reactions import inp_in_reaction
from ..model.mixing import inp_in_mixing
from ..model.times import inp_in_time
from ..model.reports import inp_in_report
from ..model.options_legacy import inp_in_option
from ..model.options_v2 import inp_in_option_v2
from ..model.options_v3 import inp_in_option_v3
from ..gis.coordinates import inp_in_coord
from ..gis.vertices import inp_in_vertex
@@ -82,10 +90,11 @@ from .exporter import export_inp
_S = "S"
_L = "L"
logger = logging.getLogger(__name__)
def _inp_in_option(section: list[str], version: str = "3") -> str:
return inp_in_option_v3(section) if version == "3" else inp_in_option(section)
return inp_in_option_v3(section) if version == "3" else inp_in_option_v2(section)
_handler = {
@@ -389,60 +398,49 @@ def read_inp(project: str, inp: str, version: str = "3") -> bool:
if version != "3" and version != "2":
version = "2"
if is_project_open(project):
close_project(project)
if not have_project(project):
raise ValueError(f"Project database {project!r} does not exist")
if have_project(project):
delete_project(project)
staging_project = temporary_project_name(project, "model_import")
replacement_committed = False
try:
copy_project(get_project_template_database_name(project), staging_project)
with project_transaction(staging_project):
parse_file(staging_project, inp, version)
replace_project_model(project, staging_project)
replacement_committed = True
finally:
try:
if have_project(staging_project):
delete_project(staging_project)
except Exception:
logger.exception(
"Failed to remove model-import staging database %s",
staging_project,
)
create_project(project)
open_project(project)
if replacement_committed:
refresh_materialized_views_after_commit(project)
with project_transaction(project):
parse_file(project, inp, version)
refresh_materialized_views(project)
"""try:
parse_file(project, inp, version)
except:
close_project(project)
delete_project(project)
return False"""
close_project(project)
return True
# DingZQ, 2024-12-28, convert v3 to v2
def convert_inp_v3_to_v2(inp: str) -> ChangeSet:
project = "v3Tov2"
if is_project_open(project):
close_project(project)
if have_project(project):
delete_project(project)
create_project(project)
open_project(project)
filename = f"inp/{project}_temp.inp"
if os.path.exists(filename):
os.remove(filename)
with open(filename, "w", encoding="utf-8") as f:
f.write(inp)
parse_file(project, filename, "3")
"""try:
parse_file(project, inp, version)
except:
close_project(project)
delete_project(project)
return False"""
temp_path: str | None = None
with temporary_template_database("conversion", "v3_to_v2") as project:
try:
with NamedTemporaryFile(
mode="w", suffix=".inp", encoding="utf-8", delete=False
) as temp_file:
temp_file.write(inp)
temp_path = temp_file.name
with project_transaction(project):
parse_file(project, temp_path, "3")
return export_inp(project, "2")
finally:
if temp_path is not None:
os.remove(temp_path)
def import_inp(project: str, cs: ChangeSet, version: str = "3") -> bool:
@@ -452,17 +450,21 @@ def import_inp(project: str, cs: ChangeSet, version: str = "3") -> bool:
if "inp" not in cs.operations[0]:
return False
filename = f"inp/{project}_temp.inp"
if os.path.exists(filename):
os.remove(filename)
_print_time(f'Start writing temp file "{filename}"...')
with open(filename, "w", encoding="utf-8") as f:
f.write(str(cs.operations[0]["inp"]))
_print_time(f'End writing temp file "{filename}"...')
result = read_inp(project, filename, version)
# os.remove(filename)
return result
temp_path: str | None = None
try:
with NamedTemporaryFile(
mode="w",
suffix=".inp",
prefix="tjwater_import_",
encoding="utf-8",
delete=False,
) as temp_file:
temp_file.write(str(cs.operations[0]["inp"]))
temp_path = temp_file.name
return read_inp(project, temp_path, version)
finally:
if temp_path is not None:
try:
os.remove(temp_path)
except FileNotFoundError:
pass
-13
View File
@@ -94,16 +94,3 @@ def delete_demand_by_junction(name: str, junction: str) -> ChangeSet:
if row is None:
return ChangeSet()
return ChangeSet(g_update_prefix | {'type': 'demand', 'junction': junction, 'demands': []})
def unset_demand_by_pattern(name: str, pattern: str) -> ChangeSet:
cs = ChangeSet()
rows = read_all(name, "select distinct junction_id as junction from network.demands where pattern_id = %s", (pattern,))
for row in rows:
ds = get_demand(name, row['junction'])
for d in ds['demands']:
d['pattern'] = None
cs.append(g_update_prefix | {'type': 'demand', 'junction': row['junction'], 'demands': ds['demands']})
return cs
+2 -36
View File
@@ -159,26 +159,6 @@ def get_nodes(name: str) -> list[str]:
return _get_all(name, _NODE)
def get_nodes_id_and_type(name: str) -> dict[str, str]:
rows = read_all_typed(name, "SELECT id, node_type FROM network.nodes", ())
return {row["id"]: row["node_type"] for row in rows}
def get_major_nodes(name: str, diameter: int) -> list[str]:
rows = read_all_typed(
name,
"""
SELECT DISTINCT endpoint
FROM network.links AS l
JOIN network.pipes AS p ON p.link_id = l.id
CROSS JOIN LATERAL (VALUES (l.start_node_id), (l.end_node_id)) AS e(endpoint)
WHERE p.diameter > %s
""",
(diameter,),
)
return [row["endpoint"] for row in rows]
def get_junctions(name: str) -> list[str]:
return _get_nodes_by_type(name, JUNCTION)
@@ -195,20 +175,6 @@ def get_links(name: str) -> list[str]:
return _get_all(name, _LINK)
def get_links_id_and_type(name: str) -> dict[str, str]:
rows = read_all_typed(name, "SELECT id, link_type FROM network.links", ())
return {row["id"]: row["link_type"] for row in rows}
def get_major_pipes(name: str, diameter: int) -> list[str]:
rows = read_all_typed(
name,
"SELECT link_id FROM network.pipes WHERE diameter > %s ORDER BY link_id",
(diameter,),
)
return [row["link_id"] for row in rows]
def get_pipes(name: str) -> list[str]:
return _get_links_by_type(name, PIPE)
@@ -247,10 +213,10 @@ def get_node_links(name: str, node_id: str) -> list[str]:
def get_all_node_links(name: str) -> dict[str, list[str]]:
"""Build the node adjacency map with one scan of the link table."""
"""Build the node adjacency map with one scan of the unified GIS view."""
rows = read_all_typed(
name,
"SELECT id, start_node_id, end_node_id FROM network.links ORDER BY id",
"SELECT id, start_node_id, end_node_id FROM gis.network_links ORDER BY id",
(),
)
result: dict[str, list[str]] = {}
+15 -6
View File
@@ -6,6 +6,7 @@ from ..core.database import (
ChangeSet,
DatabaseCommand,
execute_command,
execute_locked_command,
g_add_prefix,
g_delete_prefix,
g_update_prefix,
@@ -94,8 +95,12 @@ class Junction(object):
def as_dict(self) -> dict[str, Any]:
return { 'type': self.type, 'id': self.id, 'x': self.x, 'y': self.y, 'elevation': self.elevation }
def _set_junction(name: str, cs: ChangeSet) -> DatabaseCommand:
raw_new = get_junction(name, cs.operations[0]['id'])
def _set_junction(
name: str,
cs: ChangeSet,
current: dict[str, Any] | None = None,
) -> DatabaseCommand:
raw_new = current if current is not None else get_junction(name, cs.operations[0]['id'])
new_dict = cs.operations[0]
schema = get_junction_schema(name)
@@ -113,11 +118,15 @@ def _set_junction(name: str, cs: ChangeSet) -> DatabaseCommand:
def set_junction(name: str, cs: ChangeSet) -> ChangeSet:
if 'id' not in cs.operations[0]:
operation = cs.operations[0]
if 'id' not in operation:
return ChangeSet()
if get_junction(name, cs.operations[0]['id']) == {}:
return ChangeSet()
return execute_command(name, _set_junction(name, cs))
def build_command() -> DatabaseCommand | None:
current = get_junction(name, operation['id'])
return None if current == {} else _set_junction(name, cs, current)
return execute_locked_command(name, build_command)
def _add_junction(name: str, cs: ChangeSet) -> DatabaseCommand:
@@ -1,10 +1,8 @@
from psycopg import sql
from ..core.database import ChangeSet, g_update_prefix, read_all, sql_literal
from .options import get_option_schema, generate_v3
def _inp_in_option(section: list[str]) -> ChangeSet:
def _inp_in_option_v2(section: list[str]) -> ChangeSet:
if len(section) <= 0:
return ChangeSet()
@@ -34,9 +32,9 @@ def _inp_in_option(section: list[str]) -> ChangeSet:
return result
def inp_in_option(section: list[str]) -> str:
def inp_in_option_v2(section: list[str]) -> str:
sql = ''
result = _inp_in_option(section)
result = _inp_in_option_v2(section)
for op in result.operations:
for key in op.keys():
if key == 'operation' or key == 'type':
@@ -48,7 +46,7 @@ def inp_in_option(section: list[str]) -> str:
return sql
def inp_out_option(name: str) -> list[str]:
def inp_out_option_v2(name: str) -> list[str]:
lines = []
objs = read_all(name, "select key, value from network.simulation_settings where engine_version = 'legacy' order by key")
@@ -71,7 +69,7 @@ def inp_out_option(name: str) -> list[str]:
# why write this ?
if key == 'PRESSURE':
continue
# release version does not support new keys and has error message
# EPANET V2 does not support these newer keys.
if key == 'HTOL' or key == 'QTOL' or key == 'RQTOL':
continue
# ignore some weird settings for DDA
-2
View File
@@ -1,5 +1,3 @@
from psycopg import sql
from ..core.database import ChangeSet, g_update_prefix, read_all, sql_literal
from .options import get_option_schema, get_option_v3_schema, generate_v2, generate_v3
+4 -1
View File
@@ -93,7 +93,10 @@ def _delete_pattern(name: str, cs: ChangeSet) -> DatabaseCommand:
id = cs.operations[0]['id']
f_id = sql_literal(id)
statement = f"delete from network.patterns where id = {f_id};"
statement = (
f"update network.demands set pattern_id = null where pattern_id = {f_id};"
f"\ndelete from network.patterns where id = {f_id};"
)
change = g_delete_prefix | { 'type': 'pattern' } | { 'id': id }
+52 -8
View File
@@ -4,6 +4,7 @@ from ..core.database import (
ChangeSet,
DatabaseCommand,
execute_command,
execute_locked_command,
g_add_prefix,
g_delete_prefix,
g_update_prefix,
@@ -144,9 +145,12 @@ class Pipe(object):
def as_dict(self) -> dict[str, Any]:
return { 'type': self.type, 'id': self.id, 'node1': self.node1, 'node2': self.node2, 'length': self.length, 'diameter': self.diameter, 'roughness': self.roughness, 'minor_loss': self.minor_loss, 'status': self.status }
def _set_pipe(name: str, cs: ChangeSet) -> DatabaseCommand:
raw_new = get_pipe(name, cs.operations[0]['id'])
def _set_pipe(
name: str,
cs: ChangeSet,
current: dict[str, Any] | None = None,
) -> DatabaseCommand:
raw_new = current if current is not None else get_pipe(name, cs.operations[0]['id'])
new_dict = cs.operations[0]
schema = get_pipe_schema(name)
for key, value in schema.items():
@@ -154,19 +158,59 @@ def _set_pipe(name: str, cs: ChangeSet) -> DatabaseCommand:
raw_new[key] = new_dict[key]
new = Pipe(raw_new)
statement = f"update network.links set start_node_id = {new.f_node1}, end_node_id = {new.f_node2} where id = {new.f_id};"
statement += f"\nupdate network.pipes set length = {new.f_length}, diameter = {new.f_diameter}, roughness = {new.f_roughness}, minor_loss = {new.f_minor_loss}, status = {new.f_status} where link_id = {new.f_id};"
link_columns = {
'node1': ('start_node_id', new.f_node1),
'node2': ('end_node_id', new.f_node2),
}
pipe_columns = {
'length': ('length', new.f_length),
'diameter': ('diameter', new.f_diameter),
'roughness': ('roughness', new.f_roughness),
'minor_loss': ('minor_loss', new.f_minor_loss),
'status': ('status', new.f_status),
}
statements = []
link_assignments = [
f"{column} = {value}"
for field, (column, value) in link_columns.items()
if field in new_dict
]
if link_assignments:
statements.append(
f"update network.links set {', '.join(link_assignments)} where id = {new.f_id};"
)
pipe_assignments = [
f"{column} = {value}"
for field, (column, value) in pipe_columns.items()
if field in new_dict
]
if pipe_assignments:
statements.append(
f"update network.pipes set {', '.join(pipe_assignments)} where link_id = {new.f_id};"
)
statement = "\n".join(statements)
change = g_update_prefix | new.as_dict()
return DatabaseCommand(statement, [change])
def set_pipe(name: str, cs: ChangeSet) -> ChangeSet:
if 'id' not in cs.operations[0]:
operation = cs.operations[0]
if 'id' not in operation:
return ChangeSet()
if get_pipe(name, cs.operations[0]['id']) == {}:
mutable_fields = {
'node1', 'node2', 'length', 'diameter', 'roughness', 'minor_loss', 'status'
}
if not mutable_fields.intersection(operation):
return ChangeSet()
return execute_command(name, _set_pipe(name, cs))
def build_command() -> DatabaseCommand | None:
current = get_pipe(name, operation['id'])
if current == {}:
return None
return _set_pipe(name, cs, current)
return execute_locked_command(name, build_command)
def _add_pipe(name: str, cs: ChangeSet) -> DatabaseCommand:
+15 -6
View File
@@ -4,6 +4,7 @@ from ..core.database import (
ChangeSet,
DatabaseCommand,
execute_command,
execute_locked_command,
g_add_prefix,
g_delete_prefix,
g_update_prefix,
@@ -87,8 +88,12 @@ class Pump(object):
def as_dict(self) -> dict[str, Any]:
return { 'type': self.type, 'id': self.id, 'node1': self.node1, 'node2': self.node2, 'power': self.power, 'head': self.head, 'speed': self.speed, 'pattern': self.pattern }
def _set_pump(name: str, cs: ChangeSet) -> DatabaseCommand:
raw_new = get_pump(name, cs.operations[0]['id'])
def _set_pump(
name: str,
cs: ChangeSet,
current: dict[str, Any] | None = None,
) -> DatabaseCommand:
raw_new = current if current is not None else get_pump(name, cs.operations[0]['id'])
new_dict = cs.operations[0]
schema = get_pump_schema(name)
@@ -105,11 +110,15 @@ def _set_pump(name: str, cs: ChangeSet) -> DatabaseCommand:
def set_pump(name: str, cs: ChangeSet) -> ChangeSet:
if 'id' not in cs.operations[0]:
operation = cs.operations[0]
if 'id' not in operation:
return ChangeSet()
if get_pump(name, cs.operations[0]['id']) == {}:
return ChangeSet()
return execute_command(name, _set_pump(name, cs))
def build_command() -> DatabaseCommand | None:
current = get_pump(name, operation['id'])
return None if current == {} else _set_pump(name, cs, current)
return execute_locked_command(name, build_command)
def _add_pump(name: str, cs: ChangeSet) -> DatabaseCommand:
+15 -6
View File
@@ -4,6 +4,7 @@ from ..core.database import (
ChangeSet,
DatabaseCommand,
execute_command,
execute_locked_command,
g_add_prefix,
g_delete_prefix,
g_update_prefix,
@@ -85,8 +86,12 @@ class Reservoir(object):
def as_dict(self) -> dict[str, Any]:
return { 'type': self.type, 'id': self.id, 'x': self.x, 'y': self.y, 'head': self.head, 'pattern': self.pattern }
def _set_reservoir(name: str, cs: ChangeSet) -> DatabaseCommand:
raw_new = get_reservoir(name, cs.operations[0]['id'])
def _set_reservoir(
name: str,
cs: ChangeSet,
current: dict[str, Any] | None = None,
) -> DatabaseCommand:
raw_new = current if current is not None else get_reservoir(name, cs.operations[0]['id'])
new_dict = cs.operations[0]
schema = get_reservoir_schema(name)
@@ -104,11 +109,15 @@ def _set_reservoir(name: str, cs: ChangeSet) -> DatabaseCommand:
def set_reservoir(name: str, cs: ChangeSet) -> ChangeSet:
if 'id' not in cs.operations[0]:
operation = cs.operations[0]
if 'id' not in operation:
return ChangeSet()
if get_reservoir(name, cs.operations[0]['id']) == {}:
return ChangeSet()
return execute_command(name, _set_reservoir(name, cs))
def build_command() -> DatabaseCommand | None:
current = get_reservoir(name, operation['id'])
return None if current == {} else _set_reservoir(name, cs, current)
return execute_locked_command(name, build_command)
def _add_reservoir(name: str, cs: ChangeSet) -> DatabaseCommand:
+15 -6
View File
@@ -4,6 +4,7 @@ from ..core.database import (
ChangeSet,
DatabaseCommand,
execute_command,
execute_locked_command,
g_add_prefix,
g_delete_prefix,
g_update_prefix,
@@ -121,8 +122,12 @@ class Tank(object):
def as_dict(self) -> dict[str, Any]:
return { 'type': self.type, 'id': self.id, 'x': self.x, 'y': self.y, 'elevation': self.elevation, 'init_level': self.init_level, 'min_level': self.min_level, 'max_level': self.max_level, 'diameter': self.diameter, 'min_vol': self.min_vol, 'vol_curve': self.vol_curve, 'overflow': self.overflow }
def _set_tank(name: str, cs: ChangeSet) -> DatabaseCommand:
raw_new = get_tank(name, cs.operations[0]['id'])
def _set_tank(
name: str,
cs: ChangeSet,
current: dict[str, Any] | None = None,
) -> DatabaseCommand:
raw_new = current if current is not None else get_tank(name, cs.operations[0]['id'])
new_dict = cs.operations[0]
schema = get_tank_schema(name)
@@ -140,11 +145,15 @@ def _set_tank(name: str, cs: ChangeSet) -> DatabaseCommand:
def set_tank(name: str, cs: ChangeSet) -> ChangeSet:
if 'id' not in cs.operations[0]:
operation = cs.operations[0]
if 'id' not in operation:
return ChangeSet()
if get_tank(name, cs.operations[0]['id']) == {}:
return ChangeSet()
return execute_command(name, _set_tank(name, cs))
def build_command() -> DatabaseCommand | None:
current = get_tank(name, operation['id'])
return None if current == {} else _set_tank(name, cs, current)
return execute_locked_command(name, build_command)
def _add_tank(name: str, cs: ChangeSet) -> DatabaseCommand:
+15 -6
View File
@@ -4,6 +4,7 @@ from ..core.database import (
ChangeSet,
DatabaseCommand,
execute_command,
execute_locked_command,
g_add_prefix,
g_delete_prefix,
g_update_prefix,
@@ -94,8 +95,12 @@ class Valve(object):
def as_dict(self) -> dict[str, Any]:
return { 'type': self.type, 'id': self.id, 'node1': self.node1, 'node2': self.node2, 'diameter': self.diameter, 'v_type': self.v_type, 'setting': self.setting, 'minor_loss': self.minor_loss }
def _set_valve(name: str, cs: ChangeSet) -> DatabaseCommand:
raw_new = get_valve(name, cs.operations[0]['id'])
def _set_valve(
name: str,
cs: ChangeSet,
current: dict[str, Any] | None = None,
) -> DatabaseCommand:
raw_new = current if current is not None else get_valve(name, cs.operations[0]['id'])
new_dict = cs.operations[0]
schema = get_valve_schema(name)
@@ -112,11 +117,15 @@ def _set_valve(name: str, cs: ChangeSet) -> DatabaseCommand:
def set_valve(name: str, cs: ChangeSet) -> ChangeSet:
if 'id' not in cs.operations[0]:
operation = cs.operations[0]
if 'id' not in operation:
return ChangeSet()
if get_valve(name, cs.operations[0]['id']) == {}:
return ChangeSet()
return execute_command(name, _set_valve(name, cs))
def build_command() -> DatabaseCommand | None:
current = get_valve(name, operation['id'])
return None if current == {} else _set_valve(name, cs, current)
return execute_locked_command(name, build_command)
def _add_valve(name: str, cs: ChangeSet) -> DatabaseCommand:
+1 -1
View File
@@ -1,5 +1,5 @@
"""Service package.
Keep package initialization lightweight. Import concrete service modules directly,
for example: `from app.services.tjnetwork import open_project`.
for example: `from app.services.tjnetwork import get_junction`.
"""
+39 -12
View File
@@ -9,7 +9,6 @@ from app.services.tjnetwork import (
get_status,
get_tank,
get_time,
open_project,
read_all,
run_project,
set_demand,
@@ -34,7 +33,8 @@ import logging
import app.services.globals as globals
import app.services.project_info as project_info
from app.services.time_api import parse_beijing_time, parse_clock_duration_seconds
from app.native.wndb.core.connection import project_connection
from app.native.wndb.core.connection import project_connection, project_transaction
from app.native.wndb.core.database import refresh_materialized_views_after_commit
from app.infra.db.timescaledb.internal_queries import (
InternalQueries as TimescaleInternalQueries,
)
@@ -48,6 +48,31 @@ logging.basicConfig(
)
def _primary_demand(demand_set: dict) -> dict:
"""Return sequence-zero demand, creating it when a junction has none."""
demands = demand_set.setdefault("demands", [])
if not demands:
demands.append({"demand": 0.0, "pattern": None, "category": None})
return demands[0]
def _primary_demand_pattern(demand_set: dict) -> str:
"""Return the first configured pattern in deterministic sequence order."""
pattern = next(
(
demand.get("pattern")
for demand in demand_set.get("demands", [])
if demand.get("pattern")
),
None,
)
if pattern is None:
raise ValueError(
f"Junction {demand_set.get('junction')!r} has no demand pattern"
)
return str(pattern)
def query_corresponding_element_id_and_query_id(name: str) -> None:
"""Load realtime device-to-element mappings from the new asset schema."""
target_maps = {
@@ -221,15 +246,12 @@ def run_simulation(
# elif simulation_type.upper() == 'EXTENDED': # 扩展模拟(复制数据库)
# name_c = '_'.join([name, 'c'])
# if have_project(name_c):
# if is_project_open(name_c):
# close_project(name_c)
# delete_project(name_c)
# copy_project(name, name_c) # 备份项目
# else:
# raise Exception('Incorrect simulation type, choose in (realtime, extended)')
name_c = name
# 打开数据库
open_project(name_c)
with project_transaction(name_c):
dic_time = get_time(name_c)
print(dic_time)
@@ -374,9 +396,10 @@ def run_simulation(
for key, value in globals.demand_id.items()
}
for demand_name, value in demand_dict.items():
if value:
if value is not None and not np.isnan(float(value)):
demand_set = get_demand(name_c, demand_name)
demand_pattern = get_pattern(
name_c, get_demand(name_c, demand_name)["pattern"]
name_c, _primary_demand_pattern(demand_set)
)
if get_option(name_c)["UNITS"] == "LPS":
demand_pattern["factors"][modify_index] = (
@@ -426,7 +449,9 @@ def run_simulation(
for junction_name in modify_junction_base_demand.keys():
if not np.isnan(modify_junction_base_demand[junction_name]):
junction = get_demand(name_c, junction_name)
junction["demand"] = modify_junction_base_demand[junction_name]
_primary_demand(junction)["demand"] = (
modify_junction_base_demand[junction_name]
)
cs = ChangeSet()
cs.append(junction)
set_demand(name_c, cs)
@@ -447,10 +472,11 @@ def run_simulation(
for pump_name in modify_fixed_pump_pattern.keys():
if not np.isnan(modify_fixed_pump_pattern[pump_name][0]):
pump_pattern = get_pattern(
name_c, get_pump(name_c, pattern_name)["pattern"]
name_c, get_pump(name_c, pump_name)["pattern"]
)
pump_pattern["factors"][
modify_index : modify_index + len(modify_fixed_pump_pattern)
modify_index
: modify_index + len(modify_fixed_pump_pattern[pump_name])
] = modify_fixed_pump_pattern[pump_name]
cs = ChangeSet()
cs.append(pump_pattern)
@@ -464,7 +490,7 @@ def run_simulation(
value / 50 for value in modify_variable_pump_pattern[pump_name]
]
pump_pattern = get_pattern(
name_c, get_pump(name_c, pattern_name)["pattern"]
name_c, get_pump(name_c, pump_name)["pattern"]
)
pump_pattern["factors"][
modify_index : modify_index + len(modified_values)
@@ -496,6 +522,7 @@ def run_simulation(
set_status(name_c, cs)
# 运行并返回结果
result_data = json.loads(run_project(name_c))
refresh_materialized_views_after_commit(name_c)
time_cost_end = time.perf_counter()
print(
"{} -- Hydraulic simulation finished, cost time: {:.2f} s.".format(
+42 -54
View File
@@ -1,55 +1,60 @@
import json
from datetime import datetime
from functools import wraps
from math import pi
import pytz
from app.algorithms.simulation.runner import run_simulation_ex
from app.native.wndb.core.projects import temporary_project_database
from app.services.tjnetwork import (
close_project,
copy_project,
delete_project,
get_pipe,
get_tank,
have_project,
is_project_open,
open_project,
)
def _isolated_operation(purpose: str):
def decorator(func):
@wraps(func)
def wrapper(prj_name: str, *args, **kwargs):
with temporary_project_database(prj_name, purpose) as temporary:
kwargs["_temporary_project"] = temporary
return func(prj_name, *args, **kwargs)
return wrapper
return decorator
############################################################
# project management 07 ***暂时不使用,与业务需求无关***
############################################################
@_isolated_operation("project_management")
def project_management(
prj_name,
start_datetime,
pump_control,
tank_initial_level_control=None,
region_demand_control=None,
_temporary_project=None,
) -> str:
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Analysis."
)
new_name = f"project_management_{prj_name}"
if have_project(new_name):
if is_project_open(new_name):
close_project(new_name)
delete_project(new_name)
# if is_project_open(prj_name):
# close_project(prj_name)
if _temporary_project is None:
raise RuntimeError("Project-management isolation was not prepared")
new_name = _temporary_project
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Copying Database."
)
copy_project(prj_name + "_template", new_name)
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Opening Database."
)
open_project(new_name)
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Database Loading OK."
@@ -64,9 +69,6 @@ def project_management(
region_demand_control=region_demand_control,
downloading_prohibition=True,
)
if is_project_open(new_name):
close_project(new_name)
delete_project(new_name)
return result
@@ -75,31 +77,31 @@ def project_management(
############################################################
@_isolated_operation("scheduling")
def scheduling_simulation(
prj_name, start_time, pump_control, tank_id, water_plant_output_id, time_delta=300
prj_name,
start_time,
pump_control,
tank_id,
water_plant_output_id,
time_delta=300,
_temporary_project=None,
) -> str:
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Analysis."
)
new_name = f"scheduling_{prj_name}"
if have_project(new_name):
if is_project_open(new_name):
close_project(new_name)
delete_project(new_name)
# if is_project_open(prj_name):
# close_project(prj_name)
if _temporary_project is None:
raise RuntimeError("Scheduling isolation was not prepared")
new_name = _temporary_project
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Copying Database."
)
copy_project(prj_name + "_template", new_name)
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Opening Database."
)
open_project(new_name)
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Database Loading OK."
@@ -115,9 +117,6 @@ def scheduling_simulation(
if not isinstance(output_data, dict):
raise RuntimeError("run_simulation_ex did not return JSON output content")
if not is_project_open(new_name):
open_project(new_name)
tank = get_tank(new_name, tank_id) # 水塔信息
tank_floor_space = pi * pow(tank["diameter"] / 2, 2) # 水塔底面积(m^2)
tank_init_level = tank["init_level"] # 水塔初始水位(m)
@@ -158,38 +157,34 @@ def scheduling_simulation(
"tank_level": tank_level,
}
if is_project_open(new_name):
close_project(new_name)
delete_project(new_name)
return json.dumps(simulation_results)
@_isolated_operation("daily_scheduling")
def daily_scheduling_simulation(
prj_name, start_time, pump_control, reservoir_id, tank_id, water_plant_output_id
prj_name,
start_time,
pump_control,
reservoir_id,
tank_id,
water_plant_output_id,
_temporary_project=None,
) -> str:
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Analysis."
)
new_name = f"daily_scheduling_{prj_name}"
if have_project(new_name):
if is_project_open(new_name):
close_project(new_name)
delete_project(new_name)
# if is_project_open(prj_name):
# close_project(prj_name)
if _temporary_project is None:
raise RuntimeError("Daily-scheduling isolation was not prepared")
new_name = _temporary_project
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Copying Database."
)
copy_project(prj_name + "_template", new_name)
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Opening Database."
)
open_project(new_name)
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Database Loading OK."
@@ -209,9 +204,6 @@ def daily_scheduling_simulation(
if not isinstance(output_data, dict):
raise RuntimeError("run_simulation_ex did not return JSON output content")
if not is_project_open(new_name):
open_project(new_name)
node_results = output_data.get("node_results") or [] # [{'node': str, 'result': [{'pressure': float, 'head': float}]}]
water_plant_output_pressure = []
reservoir_level = []
@@ -235,8 +227,4 @@ def daily_scheduling_simulation(
"tank_level": tank_level,
}
if is_project_open(new_name):
close_project(new_name)
delete_project(new_name)
return json.dumps(simulation_results)
+6 -41
View File
@@ -32,14 +32,11 @@ from app.native.wndb.commands.api import (
)
from app.native.wndb.core.database import ChangeSet, read_all
from app.native.wndb.core.projects import (
close_project,
copy_project,
create_project,
delete_project,
have_project,
is_project_open,
list_project,
open_project,
)
from app.native.wndb.gis.backdrop import (
get_backdrop,
@@ -54,6 +51,12 @@ from app.native.wndb.gis.labels import (
get_label_schema,
set_label,
)
from app.native.wndb.gis.network_views import (
get_major_node_coords,
get_major_pipe_nodes,
get_network_link_nodes,
get_network_node_coords,
)
from app.native.wndb.gis.region_geometry import get_nodes_in_region
from app.native.wndb.gis.regions import (
add_region,
@@ -89,16 +92,11 @@ from app.native.wndb.model.elements import (
get_curves,
get_element_type,
get_element_type_value,
get_link_nodes,
get_link_type,
get_links,
get_links_id_and_type,
get_major_nodes,
get_major_pipes,
get_node_links,
get_node_type,
get_nodes,
get_nodes_id_and_type,
get_patterns,
get_regions,
is_curve,
@@ -280,39 +278,6 @@ def get_element_properties(name: str, element_id: str) -> dict[str, Any]:
return get_scada_info(name, element_id)
def get_network_node_coords(name: str) -> dict[str, dict[str, Any]]:
nodes = get_nodes_id_and_type(name)
return {
node_id: {**get_node_coord(name, node_id), "type": node_type}
for node_id, node_type in nodes.items()
}
def get_major_node_coords(name: str, diameter: int) -> dict[str, dict[str, Any]]:
node_types = get_nodes_id_and_type(name)
return {
node_id: {**get_node_coord(name, node_id), "type": node_types[node_id]}
for node_id in get_major_nodes(name, diameter)
}
def get_network_link_nodes(name: str) -> list[str]:
links = get_links_id_and_type(name)
return [
f"{link_id}:{link_type}:{nodes[0]}:{nodes[1]}"
for link_id, link_type in links.items()
if (nodes := get_link_nodes(name, link_id))
]
def get_major_pipe_nodes(name: str, diameter: int) -> list[str]:
return [
f"{link_id}:pipe:{nodes[0]}:{nodes[1]}"
for link_id in get_major_pipes(name, diameter)
if (nodes := get_link_nodes(name, link_id))
]
def get_network_in_extent(
name: str, x1: float, y1: float, x2: float, y2: float
) -> dict[str, Any]:
+1 -1
View File
@@ -3,7 +3,7 @@
"contracts": {
"server": {
"file": "server-v1.openapi.json",
"sha256": "404a196c0177faed2aa5b46ee86430a034dfe990e0a77a43428a727748a882b6"
"sha256": "34f67bf3b6f1da263d0271e5a1f3cb599c128c4b422e44d2d6d540d99f7855f4"
}
}
}
File diff suppressed because it is too large Load Diff
+22 -20
View File
@@ -1,29 +1,31 @@
# TJWater 数据库改造说明与当前结构
> 本文记录 2026-08-25 的数据库实际状态。结构、约束、行数、TimescaleDB chunk 和策略均直接读取数据库,不以仓库中的 SQL 脚本为依据。文中不包含主机、端口、账号、密码或 DSN。
> 本文记录截至 2026-08-27 的数据库实际状态。结构、约束、行数、TimescaleDB chunk 和策略均直接读取数据库,不以仓库中的 SQL 脚本为依据。文中不包含主机、端口、账号、密码或 DSN。
## 改造范围与当前状态
本次改造保留原 `tjwater` 业务库和时序库,新建 `tjwater_next` 作为隔离验证环境。元数据库仍为 `system_hub`新项目通过 `biz_data``iot_data` 两条路由分别关联业务库与时序库。项目完成迁移和联调后已切换为 `active`,原数据库没有被覆盖,仍可用于对照和回退。
本次改造保留原 `tjwater` 业务库和时序库,新建的隔离库完成验证后已正式重命名为 `tjwater_v2`。元数据库仍为 `system_hub`逻辑项目 `tjwater_next` 通过 `biz_data``iot_data` 两条路由分别关联 `tjwater_v2` 业务库与时序库。项目已切换为 `active`,原数据库没有被覆盖,仍可用于对照和回退。
命名约定:正式物理库名为 `tjwater_v2`WNDB 版本模板固定为 `tjwater_v2_template`。元数据库中的逻辑项目代码和 GeoServer 工作空间仍为 `tjwater_next`;这些是路由与图层限定名,不是物理库名。模板已创建并清空项目模型、SCADA、分析运行和物化视图数据,仅保留数据库结构、PostGIS 对象和必要配置键种子,普通连接已关闭。
已经完成的数据库修改包括:
- 新建并迁移 `tjwater_next` 业务库,将旧 `public` 中混合存放的管网、GIS、SCADA 配置和分析数据按领域拆分。
- 新建并迁移 `tjwater_next` 时序库,将 SCADA、实时计算和分析计算结果分开存放。
- 新建并迁移 `tjwater_v2` 业务库,将旧 `public` 中混合存放的管网、GIS、SCADA 配置和分析数据按领域拆分。
- 新建并迁移 `tjwater_v2` 时序库,将 SCADA、实时计算和分析计算结果分开存放。
- `realtime` 采用冷热数据策略,72 小时后的 chunk 自动转为有序列存。
- `analysis``stored_at` 分区,入库满 24 小时的 chunk 自动转为有序列存。
- GIS 查询层改用物化视图,当前 7 张物化视图均已填充。
- GeoServer 已建立 `tjwater_next` 工作空间和同名数据存储,业务库 `gis` schema 发布 7 个图层。GeoWebCache 的服务端与客户端缓存有效期均为 300 秒。
- GeoServer 已建立 `tjwater_next` 工作空间和同名数据存储,数据存储连接 `tjwater_v2` 业务库 `gis` schema 发布 7 个图层。GeoWebCache 的服务端与客户端缓存有效期均为 300 秒。
- 旧库中的 `operation``current_operation``batch_operation``operation_table``restore_operation``snapshot_operation` 没有进入新业务库。
- `system_hub.public` 补充了项目数据库外键、数据库路由约束、连接池约束、必要的非空约束,以及 5 张表和 44 个字段的中文数据库注释。
- 用户角色和项目角色仍是可扩展字符串,没有增加枚举检查约束。
- `audit_logs.user_id``audit_logs.project_id` 仍为逻辑关联,没有增加外键。
- 业务库 48 个表或物化视图、192 个字段,以及时序库 7 张表、47 个字段均已写入中文数据库注释。
- 后端已对接新 schema。WNDB、PostgreSQL 管理连接和同步 TimescaleDB 访问均使用有界连接池,闲置项目按最近使用顺序回收,实时覆盖写入使用单一事务
- 后端已对接新 schema。WNDB、PostgreSQL 管理连接和同步 TimescaleDB 访问均使用有界连接池,闲置项目按最近使用顺序回收;动态异步池采用代际切换,配置更新不会中断旧借用或阻塞新请求
- 后端批量元素查询读取 GIS 物化视图,模型增删改和 INP 导入提交后执行并发刷新;批量事务只刷新一次。
- `pattern_values``pattern_flow_samples``curve_points``demands``link_vertices` 的顺序号按所属父对象编号,主键已改为父对象 ID 与 `sequence_no` 的复合键。
`tjwater_next` 当前为 `active`。排水项目 `lingang` 已迁入 `system_hub.public`,供水和排水后端共用同一套项目、成员、数据库路由和审计表。
逻辑项目 `tjwater_next` 当前为 `active`。排水项目 `lingang` 已迁入 `system_hub.public`,供水和排水后端共用同一套项目、成员、数据库路由和审计表。
## 数据库总体关系
@@ -38,9 +40,9 @@ flowchart LR
OT["tjwater 时序库<br/>scada / realtime / scheme"]
end
subgraph NEXT["隔离验证数据库"]
NB["tjwater_next 业务库<br/>network / gis / asset / analysis"]
NT["tjwater_next 时序库<br/>scada / realtime / analysis"]
subgraph V2["v2 正式数据库"]
NB["tjwater_v2 业务库<br/>network / gis / asset / analysis"]
NT["tjwater_v2 时序库<br/>scada / realtime / analysis"]
end
GS["GeoServer<br/>tjwater_next 工作空间"]
@@ -48,8 +50,8 @@ flowchart LR
MP -->|"tjwater 的 biz_data"| OB
MP -->|"tjwater 的 iot_data"| OT
MP -->|"tjwater_next 的 biz_data"| NB
MP -->|"tjwater_next 的 iot_data"| NT
MP -->|"项目 tjwater_next 的 biz_data"| NB
MP -->|"项目 tjwater_next 的 iot_data"| NT
MP -->|"lingang 的两条数据库路由"| DRAIN["排水项目数据库"]
NB -->|"gis 物化视图"| GS -->|"WFS / WMTS"| WEB
```
@@ -146,7 +148,7 @@ erDiagram
## 相对原数据库的结构变化
以下对比以当前仍保留的原业务库 `tjwater`、原时序库 `tjwater` 与新库 `tjwater_next` 的实际对象为准。对象名称的对应关系表示业务实体或数据职责的迁移方向,不表示所有字段均一对一复制。
以下对比以当前仍保留的原业务库 `tjwater`、原时序库 `tjwater` 与新库 `tjwater_v2` 的实际对象为准。对象名称的对应关系表示业务实体或数据职责的迁移方向,不表示所有字段均一对一复制。
### 归并和调整
@@ -167,7 +169,7 @@ erDiagram
- 云端操作记录相关的 `operation``current_operation``batch_operation``operation_table``restore_operation``snapshot_operation` 未进入新业务库。该设计不再作为业务数据模型的一部分。
- 临时处理表 `temp_link_1``temp_link_2``temp_node``temp_region``temp_vd_topology` 未迁入。它们属于历史处理过程的中间对象,不应成为长期库结构。
- 原库中的 `_node``_link``_pattern``_curve``_region` 等内部或过渡表不再单独存在。新库以明确的领域表和外键关系表达同一类数据。
- 原库的 `tiger``topology` 扩展 schema 未在新库安装,`tjwater_next` 仅保留 PostGIS 及其 `public` 系统对象。
- 原库的 `tiger``topology` 扩展 schema 未在新库安装,`tjwater_v2` 仅保留 PostGIS 及其 `public` 系统对象。
### 尚未完整承接的范围
@@ -183,7 +185,7 @@ erDiagram
- `realtime` 两张 hypertable 已启用 72 小时后的列存压缩策略,`analysis` 两张 hypertable 按入库时间执行 24 小时冷热转换。`scada` 保持独立行存。
- 元数据库的 `public.project_databases` 增加项目外键、数据库角色和类型约束,以及连接池上下限约束,用于保证每个项目的业务库和时序库路由有效。
## 新业务库 tjwater_next
## 新业务库 tjwater_v2
新业务库使用 PostgreSQL 和 PostGIS。业务对象分布在 4 个 schema 中,`public` 只保留 PostGIS 提供的系统对象。
@@ -316,7 +318,7 @@ flowchart LR
### GeoServer 与前端图层
`system_hub.public.projects` 中的 `tjwater_next` 项目已配置 `gs_workspace=tjwater_next`,当前状态为 `active`。GeoServer 的 `tjwater_next` 数据存储连接同名业务库并限定到 `gis` schema,图层名称直接采用物化视图名称。7 个图层使用相同的项目管网发布边界,空图层和视口内没有要素的瓦片会返回空 MVT,不会产生越界错误。
`system_hub.public.projects` 中的 `tjwater_next` 项目已配置 `gs_workspace=tjwater_next`,当前状态为 `active`。GeoServer 的 `tjwater_next` 数据存储连接 `tjwater_v2` 业务库并限定到 `gis` schema,图层名称直接采用物化视图名称。7 个图层使用相同的项目管网发布边界,空图层和视口内没有要素的瓦片会返回空 MVT,不会产生越界错误。
| 前端数据源 | GeoServer 图层 | 几何 | 当前要素数 |
| --- | --- | --- | ---: |
@@ -384,7 +386,7 @@ erDiagram
这些表均为行存,没有启用压缩策略。方案结果使用 `scheme_name` 关联业务库中的方案记录。
## 新时序库 tjwater_next
## 新时序库 tjwater_v2
新时序库仍运行 TimescaleDB 2.21.3。业务时序数据分为 `scada``realtime``analysis`,迁移过程状态单独放在 `migration`
@@ -404,7 +406,7 @@ erDiagram
### realtime
`realtime.node_results``realtime.link_results` 保存当前实时计算窗口。主键保证同一时刻、同一元素只能有一条记录。相同时间窗口由后端先删除、再通过 `COPY` 批量插入;节点和连接两次替换位于同一个最外层事务,其中任一步失败都会整体回滚
`realtime.node_results``realtime.link_results` 保存当前实时计算窗口。主键保证同一时刻、同一元素只能有一条记录。每个批次强制只包含一个归一化时间戳;相同时间由后端在一个最外层事务中先同时删除节点和连接旧快照,再通过 `COPY` 写入非空侧。某一侧为空也会删除其旧数据,符合“同时间点整体覆盖”语义
两张表当前各有 19 个 chunk,均已转为列存,因为现有数据都早于 72 小时热窗口。数据库每小时执行一次策略检查,将 72 小时以前的 chunk 转为有序列存。节点结果按 `node_id, time DESC` 排序,连接结果按 `link_id, time DESC` 排序。新写入的数据使用 1 天 chunk,并在 72 小时内保持行存。
@@ -422,9 +424,9 @@ erDiagram
## 后端连接与事务
元数据库通过 SQLAlchemy 异步连接池访问;项目请求按 `system_hub.public.project_databases` 路由到业务库和时序库。异步业务查询和异步时序查询由项目级动态池管理,原生 WNDB 同步访问使用按数据库缓存的 `psycopg_pool.ConnectionPool`,数据库创建、复制和删除使用独立的 PostgreSQL 管理池,同步 TimescaleDB 访问也使用按数据库缓存的连接池。应用目录中已没有直接调用 `psycopg.connect` 的业务代码。
元数据库通过 SQLAlchemy 异步连接池访问;项目请求按 `system_hub.public.project_databases` 路由到业务库和时序库。异步业务查询和异步时序查询由项目级动态池管理,池条目记录借用数;配置更新建立新一代池,旧池只在已有借用归还后关闭。原生 WNDB 同步访问使用按数据库缓存且同样带借用计数`psycopg_pool.ConnectionPool`,数据库创建、复制和删除使用独立的 PostgreSQL 管理池及数据库级 advisory lock,同步 TimescaleDB 访问也使用按数据库缓存的连接池。应用目录中已没有直接调用 `psycopg.connect` 的业务代码。
WNDB 批量修改和 INP 数据导入在同一条池连接和同一事务中执行,提交后刷新 GIS 物化视图。实时节点和连接结果在一个事务中执行先删后写,同一结果时间使用事务级锁避免并发覆盖竞态;分析结果按 `run_id` 加事务级锁,防止同一运行被并发写入两次
WNDB 批量修改和模拟参数准备在同一条池连接和同一事务中执行,提交后刷新一次 GIS 物化视图。普通写入和 INP 整体替换使用同一项目级事务锁;INP 先在唯一暂存库校验,再从一致性快照事务替换当前库的 `network/gis` 模型表。临时分析库由固定模板建结构后复制当前项目模型、SCADA 映射并刷新视图。实时节点和连接结果在一个事务中执行整体先删后写,同一结果时间使用事务级锁;分析结果按 `run_id` 加事务级锁。Timescale 复合查询按节点/管段批量读取,SCADA 清洗使用单条集合更新,不再逐点往返
自动化真实数据库测试分别执行 64 次业务库和 64 次时序库并发借用,查询结果一致,连接均能归还池中。嵌套 WNDB 写入和分析运行生命周期测试会在外层强制回滚,数据库没有残留记录。`DatabaseCommand` 的 pattern 新增、修改、删除也在同一池化事务中完成,并验证了五张明细表的复合主键、级联解除需求模式关联、结果变更和整体回滚。实时覆盖测试确认第二批数据替换第一批数据,外层回滚后测试记录为 0。
+41 -9
View File
@@ -6,7 +6,9 @@
当前结构适合继续维护。WNDB 已按连接基础设施、管网模型、GIS、INP 和命令执行分组,原来的编号文件名、根目录聚合门面和星号导入已经移除。WDA、SCADA 资产查询和测压点选址也已离开底层模型目录。
本次调整了代码文件、导入关系、命令分派和 WNDB 内部命令对象,没有改变 HTTP 接口。历史撤销日志已从数据库中移除,内部接口不再保留无效的兼容字段。真实库回归时发现五张明细表错误地把局部顺序号设成全局主键,已在 `tjwater_next` 中改为父对象 ID 与 `sequence_no` 的复合主键。
本次调整了代码文件、导入关系、命令分派、项目生命周期接口和 WNDB 内部命令对象。无状态服务不再发布“打开、关闭、是否打开项目”三个旧 HTTP 操作,数据库连接在请求中按需从池借用。历史撤销日志已从数据库中移除,内部接口不再保留无效的兼容字段。真实库回归时发现五张明细表错误地把局部顺序号设成全局主键,已在 `tjwater_v2` 中改为父对象 ID 与 `sequence_no` 的复合主键。
`tjwater_v2` 是 v2 业务库和时序库的正式物理库名。元数据库中的逻辑项目代码仍为 `tjwater_next`,由项目路由指向 `tjwater_v2`;两者不必同名。版本模板固定为 `tjwater_v2_template`,不按项目代码动态派生。目前该模板已从实际 v2 结构创建、清空项目数据、刷新空物化视图并封存,压缩后约 19 MB。
## 当前目录
@@ -16,6 +18,7 @@ app/native/wndb/
├── core/
│ ├── connection.py
│ ├── database.py
│ ├── model_replace.py
│ └── projects.py
├── model/
│ ├── elements.py
@@ -28,6 +31,8 @@ app/native/wndb/
│ ├── patterns.py
│ ├── curves.py
│ ├── options.py
│ ├── options_v2.py
│ ├── options_v3.py
│ └── 其他 EPANET 模型模块
├── gis/
│ ├── coordinates.py
@@ -35,6 +40,7 @@ app/native/wndb/
│ ├── labels.py
│ ├── backdrop.py
│ ├── regions.py
│ ├── network_views.py
│ └── region_geometry.py
├── inp/
│ ├── sections.py
@@ -46,7 +52,7 @@ app/native/wndb/
└── executor.py
```
目录内共有 47 个 Python 文件,约 7,100 行`app/native/wndb/__init__.py` 只保留包说明,不再统一导出所有函数。调用方需要从具体职责模块导入,依赖来源可以直接从文件头确认。
目录内共有 49 个 Python 文件。`app/native/wndb/__init__.py` 只保留包说明,不再统一导出所有函数。调用方需要从具体职责模块导入,依赖来源可以直接从文件头确认。
## 各目录的职责
@@ -56,27 +62,44 @@ app/native/wndb/
`database.py` 提供 `ChangeSet``DatabaseCommand`、参数化查询和物化视图刷新。`DatabaseCommand` 只保存待执行的 SQL 和执行成功后返回给调用方的变更列表,不再生成或保存撤销 SQL。模型直接修改时按需刷新视图;批量命令在外层事务提交后只刷新一次。物化视图保留模型坐标 `x``y`,同时将供 GeoServer 使用的 `geom` 转换为 `EPSG:3857`,WNDB 查询不会把发布坐标误当成模型坐标。
`projects.py` 只负责项目数据库的创建、复制、打开、关闭和删除,不再混入模型查询
`projects.py` 只负责项目数据库的创建、复制、删除和异常安全的临时库上下文,不再保存“项目已打开”状态,也不混入模型查询。`postgres`、模板库、旧 WNDB 模板库 `project` 和元数据库均属于保护对象;模板复制源只能精确匹配 `WNDB_TEMPLATE_DB_NAME`,不能重新引入每项目 `_template`。批量清理不再扫描并删除服务器上的未知数据库,调用方必须显式提供每一个目标库名。数据库级 advisory lock 与 `datallowconn` 共同串行化多 worker 下的复制和删除。普通项目复制若复制源仍有其他会话会直接失败,不再主动终止正常请求
`model_replace.py` 在源库可重复读快照中读取 `network``gis` 基表,并按外键拓扑顺序复制到目标业务库。替换在单一事务内完成,不再删除并重建整个业务库;普通模型修改和整体替换共用同一项目级事务锁。INP 替换时,`analysis.results` 保留历史记录,只有新模型中不存在的元素引用会置空,`asset.scada_devices` 保留仍能匹配新节点或管段的设备;临时分析库则从当前项目复制模型和有效 SCADA 映射。
### model:管网模型和仿真配置
`model` 按业务实体命名,不再使用 `s2_junctions.py` 这类 INP 章节编号。节点、连接、模式、曲线、需求、规则和仿真设置都能从文件名直接定位。
`options_v2.py``options_v3.py` 分别负责 EPANET V2、V3 的 `[OPTIONS]` 章节导入导出;数据库中的 `engine_version = 'legacy'` 仍表示 V2 配置,仅作为现有存储标识保留。
每个实体模块保留三类紧密相关的函数:读取实体、生成并执行实体变更、转换该实体对应的一行或一段 INP 内容。完整文件的读取顺序、事务和项目生命周期由 `inp` 目录负责。因此,实体级编解码仍靠近实体定义,跨章节编排已经集中。
`elements.py` 保存节点、连接、模式、曲线和区域的通用类型判断及拓扑查询。它不再承担业务算法。
### gis:空间数据和区域几何
`coordinates.py``vertices.py``labels.py``backdrop.py` 对应原始 GIS 数据。`regions.py` 负责区域持久化,`region_geometry.py` 负责边界、凸包、膨胀和区域内元素查询。
`coordinates.py``vertices.py``labels.py``backdrop.py` 对应原始 GIS 数据。`regions.py` 负责区域持久化,`region_geometry.py` 负责边界、凸包、膨胀和区域内元素查询。`network_views.py` 是 GIS 查询视图的只读适配层,负责统一节点、链路、拓扑和需求投影的批量读取。坐标读取不会补写默认几何;缺少坐标时仅向调用方返回 `(0, 0)`,真实坐标的初始化仍由新增、导入或修复命令负责。
管网实体修改会调用坐标 SQL 辅助函数,区域几何也会读取管网拓扑。这里存在明确的模型与 GIS 协作,但没有模块导入环。现阶段继续拆出抽象接口只会增加层级,没有实际收益。
### GIS 统一查询视图
`tjwater_v2` 以现有 GIS 物化视图为基础增加了两个不保存重复数据的普通视图:
| 视图 | 来源 | 用途 |
| --- | --- | --- |
| `gis.network_nodes` | `gis.junctions``gis.reservoirs``gis.tanks` | 统一返回 `id`、模型坐标 `x/y``node_type` |
| `gis.network_links` | `gis.pipes``gis.pumps``gis.valves` | 统一返回 `id`、起止节点和 `link_type` |
普通视图始终读取底层物化视图当前内容,不需要单独刷新。管网修改提交后仍由 `gis.refresh_all_materialized_views` 刷新节点、管道、泵、阀门等物化视图,两个统一视图会随之得到最新结果。视图和全部字段均已在数据库中写入说明。
旧服务先读取节点或链路 ID,再逐个查询坐标、类型和端点,会对完整管网产生数万次往返。当前节点坐标、主干节点、全部链路、主干管道、区域边界链路、区域拓扑和用水量汇总均改为视图批量查询。没有调用方的 `get_nodes_in_extent``get_links_in_extent`,以及仅服务于旧聚合过程的四个模型辅助函数已删除;单元素详情接口仍保留原始模型查询。
### inp:文件级导入导出
`sections.py` 只保存 INP 章节名称和输出顺序。旧文件中混放的 `s1_title``s2_junction` 等命令类型常量已经移除。
`importer.py` 负责文件分段、导入顺序、项目事务、版本转换和导入后的物化视图刷新。`exporter.py` 负责按 EPANET 版本组织各章节并写出文件或 `ChangeSet`
`importer.py` 负责文件分段、导入顺序、项目事务、版本转换和导入后的物化视图刷新。INP 更新先从 `tjwater_v2_template` 创建唯一暂存库并完成解析,再在当前业务库中事务替换模型表。模型提交后即使暂存库清理失败也仍会刷新物化视图;清理失败会记录日志,不再遮蔽主操作。ChangeSet 导入使用每请求唯一临时文件并在 `finally` 删除,避免同项目并发导入互相覆盖。`exporter.py` 负责按 EPANET 版本组织各章节并写出文件或 `ChangeSet`
### commands:批量修改和级联关系
@@ -100,7 +123,9 @@ flowchart LR
EXECUTE --> COMMIT --> REFRESH
```
实体模块根据请求生成 `DatabaseCommand`,其中 `sql` 是要执行的语句,`changes` 是成功后的变更结果。批量命令先在同一个项目事务中展开级联关系,再依次执行 SQL;任何一步失败都会回滚整个事务。事务提交后统一刷新物化视图,避免一次批量修改触发多次刷新。直接调用单个实体修改时,如果当前没有外层项目事务,则由 `execute_command` 完成提交并按影响范围刷新视图。
实体模块根据请求生成 `DatabaseCommand`,其中 `sql` 是要执行的语句,`changes` 是成功后的变更结果。批量命令先在同一个项目事务中展开级联关系,再依次执行 SQL;任何写入步骤失败都会回滚整个事务。事务提交后统一刷新物化视图,避免一次批量修改触发多次刷新。直接调用单个实体修改时,如果当前没有外层项目事务,则由 `execute_command` 完成提交并按影响范围刷新视图。
物化视图采用并发刷新,因此刷新位于模型事务提交之后。若刷新失败,模型修改已经持久化,代码会抛出 `MaterializedViewRefreshAfterCommitError`。HTTP 层返回专用 Problem Details、`503``X-TJWater-Changes-Committed: true`,明确提示不能盲目重放原始写入。
旧实现中的 `DbChangeSet` 同时保存执行和撤销两套 SQL、两套变更结果,但数据库已经不再提供 operation 或 snapshot 撤销日志,这些字段没有消费者。当前代码已经删除 `undo_sql``undo_cs` 及各实体模块中的撤销 SQL 构造,也删除了只为撤销结果读取旧记录的查询和辅助方法。局部修改仍会读取一次当前记录,用于补齐请求中未提供的字段;这类读取属于更新语义,不是撤销机制。
@@ -117,6 +142,12 @@ flowchart LR
`tjnetwork.py` 从约 995 行缩减到约 320 行。它不再通过 WNDB 根包获得全部函数,只显式导入当前服务使用的能力。过时的 `scripts/test_tjnetwork.py` 依赖已移除的 operation、snapshot、DMA 和旧 SCADA API,已经一并删除。
### HTTP 执行边界
WNDB 当前使用同步 `psycopg` 连接池。`network/``components/` 和同步 EPANET 仿真接口统一声明为同步处理函数;公开 REST 路由的异步适配器把这些函数送入线程池,并把项目路由上下文传入工作线程,不会在事件循环线程上阻塞数据库或求解器。异步业务库和时序库访问使用带借用计数和代际切换的项目池:活跃旧池不会被 LRU 淘汰或强制关闭,配置变化后新请求立即使用新池,旧池在已有借用归还后关闭。元数据库保持独立 SQLAlchemy 异步池。
临时分析库先由固定 `tjwater_v2_template` 提供结构,再从当前项目的一致性快照复制 `network/gis` 模型与 SCADA 映射并刷新物化视图。V3→V2 格式转换不需要项目模型,单独使用空模板临时库。旧 `online_Analysis.py`、restore 和 open/close 项目脚本已经删除,不再保留每项目模板与 operation 恢复入口。
## 依赖方向
```mermaid
@@ -162,7 +193,8 @@ WNDB 根包不再作为依赖汇聚点。上层若只需要管道查询,应直
## 验证结果
- 本地 conda 环境全量测试:239 项通过,10 项按条件跳过。
- Docker 镜像构建成功,镜像内全量测试结果一致
- `tjwater_next` 真实数据库测试:8 项通过,覆盖业务库和时序库并发借用、嵌套事务回滚、分析运行生命周期、恶意标识符转义、五张明细表的复合主键,以及 WNDB pattern 增删改、级联解除需求关联和整体回滚
- 本地 conda 环境单元、鉴权和 API 测试:286 项通过,2 项按条件跳过。
- 一次性实库从 `tjwater_v2_template` 创建后,通过 INP 暂存解析和事务替换得到 11 个节点、13 条连接、11 条坐标及 9 条 junction 物化视图记录,验证后已完整删除
- `tjwater_v2` 统一视图覆盖 87,907 个节点和 91,054 条链路,与六个来源物化视图的合计数量一致。实测完整节点读取约 0.17 秒、完整链路读取约 0.10 秒、完整拓扑两次批量查询约 1.12 秒;耗时仅作为当前环境基线,不作为固定性能承诺
- `tjwater_v2` 真实数据库测试:11 项通过,覆盖业务库和时序库并发借用、失效连接自动重建、临时库模型/SCADA/视图完整克隆与清理、嵌套事务回滚、分析运行生命周期、恶意标识符转义、明细表复合主键、统一 GIS 查询视图,以及 WNDB pattern 增删改、级联解除需求关联和整体回滚。
- Python 编译、未使用导入扫描、撤销字段残留扫描和 `git diff --check` 均通过。
-3
View File
@@ -3,15 +3,12 @@ from Cython.Build import cythonize
setup(ext_modules=cythonize([
"tjnetwork.py",
"online_Analysis.py",
"sensitivity.py",
"run_simlation.py",
"run_simulation.py",
"get_hist_data.py",
"get_realValue.py",
"get_data.py",
"get_current_total_Q.py",
"get_current_status.py",
"simulation.py",
"time_api.py",
"api/*.py",
+25 -4
View File
@@ -1,5 +1,26 @@
from app.services.tjnetwork import clean_project, delete_project
import argparse
from collections.abc import Sequence
import sys
from pathlib import Path
if __name__ == '__main__':
clean_project()
delete_project('project')
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.native.wndb.core.projects import clean_project
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Delete explicitly named temporary project databases.",
)
parser.add_argument(
"--yes",
action="store_true",
required=True,
help="confirm permanent deletion of every named database",
)
parser.add_argument("projects", nargs="+", help="project database names to delete")
return parser.parse_args(argv)
if __name__ == "__main__":
clean_project(parse_args().projects)
-136
View File
@@ -1,136 +0,0 @@
import psycopg as pg
sql_create = [
"script/sql/create/0.base.sql",
"script/sql/create/1.title.sql",
"script/sql/create/2.junctions.sql",
"script/sql/create/3.reservoirs.sql",
"script/sql/create/4.tanks.sql",
"script/sql/create/5.pipes.sql",
"script/sql/create/6.pumps.sql",
"script/sql/create/7.valves.sql",
"script/sql/create/8.tags.sql",
"script/sql/create/9.demands.sql",
"script/sql/create/10.status.sql",
"script/sql/create/11.patterns.sql",
"script/sql/create/12.curves.sql",
"script/sql/create/13.controls.sql",
"script/sql/create/14.rules.sql",
"script/sql/create/15.energy.sql",
"script/sql/create/16.emitters.sql",
"script/sql/create/17.quality.sql",
"script/sql/create/18.sources.sql",
"script/sql/create/19.reactions.sql",
"script/sql/create/20.mixing.sql",
"script/sql/create/21.times.sql",
"script/sql/create/22.report.sql",
"script/sql/create/23.options.sql",
"script/sql/create/24.coordinates.sql",
"script/sql/create/25.vertices.sql",
"script/sql/create/26.labels.sql",
"script/sql/create/27.backdrop.sql",
"script/sql/create/28.end.sql",
"script/sql/create/29.scada_device.sql",
"script/sql/create/30.scada_device_data.sql",
"script/sql/create/31.scada_element.sql",
"script/sql/create/32.region.sql",
"script/sql/create/33.dma.sql",
"script/sql/create/34.sa.sql",
"script/sql/create/35.vd.sql",
"script/sql/create/36.wda.sql",
"script/sql/create/37.history_patterns_flows.sql",
"script/sql/create/38.scada_info.sql",
"script/sql/create/40.scheme_list.sql",
"script/sql/create/41.pipe_risk_probability.sql",
"script/sql/create/42.sensor_placement.sql",
"script/sql/create/43.burst_locate_result.sql",
"script/sql/create/44.leakage_identify_result.sql",
"script/sql/create/extension_data.sql",
"script/sql/create/operation.sql"
]
sql_drop = [
"script/sql/drop/operation.sql",
"script/sql/drop/extension_data.sql",
"script/sql/drop/43.burst_locate_result.sql",
"script/sql/drop/42.sensor_placement.sql",
"script/sql/drop/44.leakage_identify_result.sql",
"script/sql/drop/41.pipe_risk_probability.sql",
"script/sql/drop/40.scheme_list.sql",
"script/sql/drop/38.scada_info.sql",
"script/sql/drop/37.history_patterns_flows.sql",
"script/sql/drop/36.wda.sql",
"script/sql/drop/35.vd.sql",
"script/sql/drop/34.sa.sql",
"script/sql/drop/33.dma.sql",
"script/sql/drop/32.region.sql",
"script/sql/drop/31.scada_element.sql",
"script/sql/drop/30.scada_device_data.sql",
"script/sql/drop/29.scada_device.sql",
"script/sql/drop/28.end.sql",
"script/sql/drop/27.backdrop.sql",
"script/sql/drop/26.labels.sql",
"script/sql/drop/25.vertices.sql",
"script/sql/drop/24.coordinates.sql",
"script/sql/drop/23.options.sql",
"script/sql/drop/22.report.sql",
"script/sql/drop/21.times.sql",
"script/sql/drop/20.mixing.sql",
"script/sql/drop/19.reactions.sql",
"script/sql/drop/18.sources.sql",
"script/sql/drop/17.quality.sql",
"script/sql/drop/16.emitters.sql",
"script/sql/drop/15.energy.sql",
"script/sql/drop/14.rules.sql",
"script/sql/drop/13.controls.sql",
"script/sql/drop/12.curves.sql",
"script/sql/drop/11.patterns.sql",
"script/sql/drop/10.status.sql",
"script/sql/drop/9.demands.sql",
"script/sql/drop/8.tags.sql",
"script/sql/drop/7.valves.sql",
"script/sql/drop/6.pumps.sql",
"script/sql/drop/5.pipes.sql",
"script/sql/drop/4.tanks.sql",
"script/sql/drop/3.reservoirs.sql",
"script/sql/drop/2.junctions.sql",
"script/sql/drop/1.title.sql",
"script/sql/drop/0.base.sql"
]
def create_template():
with pg.connect(conninfo="dbname=postgres host=127.0.0.1", autocommit=True) as conn:
with conn.cursor() as cur:
cur.execute("create database project")
with pg.connect(conninfo="dbname=project host=127.0.0.1") as conn:
with conn.cursor() as cur:
cur.execute('create extension postgis cascade')
cur.execute('create extension pgrouting cascade')
for sql in sql_create:
with open(sql, "r", encoding="utf-8") as f:
cur.execute(f.read())
print(f'executed {sql}')
conn.commit()
def have_template():
with pg.connect(conninfo="dbname=postgres host=127.0.0.1", autocommit=True) as conn:
with conn.cursor() as cur:
cur.execute("select * from pg_database where datname = 'project'")
return cur.rowcount > 0
def delete_template():
with pg.connect(conninfo="dbname=project host=127.0.0.1") as conn:
with conn.cursor() as cur:
for sql in sql_drop:
with open(sql, "r", encoding="utf-8") as f:
cur.execute(f.read())
print(f'executed {sql}')
conn.commit()
with pg.connect(conninfo="dbname=postgres host=127.0.0.1", autocommit=True) as conn:
with conn.cursor() as cur:
cur.execute("drop database project")
if __name__ == "__main__":
if (have_template()):
delete_template()
create_template()
-2
View File
@@ -1,7 +1,5 @@
from app.services.tjnetwork import list_project, read_inp
read_inp("beibeizone","beibeizone.inp")
#open_project('beibeizone')
#generate_service_area("beibeizone",0.00001)
print(list_project())
-21
View File
@@ -1,21 +0,0 @@
from app.services.tjnetwork import calculate_service_area, open_project, read_inp
p = 'dev'
read_inp(p, f'./inp/net3.inp', '3')
open_project(p)
sass = calculate_service_area(p)
assert len(sass) == 25
assert sass[0]['River'] == ['River', '60', '61', '123', '601']
assert sass[0]['3'] == ['121', '120', '119', '117', '257', '151', '157', '115', '259', '261', '149', '159', '111', '113', '263', '147', '161', '197', '193', '105', '145', '163', '195', '191', '267', '107', '141', '164', '265', '187', '189', '143', '166', '169', '204', '15', '167', '171', '269', '173', '271', '199', '201', '203', '3', '20', '127', '125', '129', '153', '131', '139']
assert sass[0]['1'] == ['185', '184', '205', '273', '1', '40', '179', '177', '183', '181', '35']
assert sass[0]['2'] == ['207', '275', '2', '50', '255', '247', '253', '251', '241', '249', '239', '243', '237', '211', '229', '209', '213', '231', '208', '215', '206', '217', '219', '225']
print(sass[1])
assert sass[0]['River'] == ['River', '60', '61', '123', '601']
assert sass[0]['3'] == ['121', '120', '119', '117', '257', '151', '157', '115', '259', '261', '149', '159', '111', '113', '263', '147', '161', '197', '193', '145', '163', '195', '191', '141', '164', '265', '187', '143', '166', '169', '267', '204', '15', '167', '171', '269', '173', '199', '201', '203', '3', '20', '127', '125', '129', '153', '131', '139']
assert sass[0]['Lake'] == ['105', '107', 'Lake', '10', '101', '103', '109']
assert sass[0]['1'] == ['189', '185', '271', '184', '205', '273', '1', '40', '179', '177', '183', '181', '35']
assert sass[0]['2'] == ['207', '275', '2', '50', '255', '247', '253', '251', '241', '249', '239', '243', '237', '211', '229', '209', '213', '231', '208', '215', '206', '217', '219', '225']
-66
View File
@@ -1,66 +0,0 @@
from app.services.tjnetwork import api, get_all_service_area_ids, open_project
from get_realValue import *
from get_hist_data import *
import datetime
from api.s36_wda_cal import *
ids=['2498','3854','3853','2510','2514','4780','4854']
cur_data=None
def get_latest_cal_time()->datetime:
current_time=datetime.datetime.now()
return current_time
def get_current_data(str_datetime: str=None)->bool:
global cur_data
if str_datetime==None:
cur_data=get_realValue(ids)
else:
cur_date=get_hist_data(ids,str_datetime)
if cur_data ==None:
return False
return True
def get_current_total_Q(str_dt:str='')->float:
q_ids=['2498','3854','3853']
q_dn900=cur_data[q_ids[0]]
q_dn500=cur_data[q_ids[1]]
q_dn1000=cur_data[q_ids[2]]
total_q=q_dn1000+q_dn500+q_dn900
return total_q
def get_h_pressure()->float:
head_id='2510'
h_pressure=cur_data[head_id]
return h_pressure
def get_l_pressure()->float:
head_id='2514'
l_pressure=cur_data[head_id]
return l_pressure
def get_h_tank_leve()->float:
h_tank_id='4780'
h_tank_level=cur_data[h_tank_id]
return h_tank_level
def get_l_tank_leve()->float:
l_tank_id='4854'
l_tank_level=cur_data[l_tank_id]
return l_tank_level
# test interface
if __name__ == '__main__':
# if get_current_data()==True:
# tQ=get_current_total_Q()
# print(f"the current tQ is {tQ}\n")
# data=get_hist_data(ids,conver_beingtime_to_ucttime('2024-04-10 15:05:00'),conver_beingtime_to_ucttime('2024-04-10 15:10:00'))
open_project("beibeizone")
regions=get_all_service_area_ids("beibeizone")
for region in regions:
t_basedmds=api.s36_wda_cal.get_total_base_demand("beibeizone",region)
print(f"{region}:{t_basedmds}")
File diff suppressed because it is too large Load Diff
-8
View File
@@ -1,8 +0,0 @@
import sys
from app.services.tjnetwork import open_project
def main():
open_project('szh')
if __name__ == '__main__':
main()
-15
View File
@@ -1,15 +0,0 @@
import sys
from app.services.tjnetwork import close_project, open_project, restore
def main():
if len(sys.argv) != 2:
print("restore_project name")
return
p = sys.argv[1]
open_project(p)
restore(p)
close_project(p)
if __name__ == '__main__':
main()
-11
View File
@@ -1,11 +0,0 @@
from app.services.tjnetwork import close_project, list_project, open_project, restore
def main():
for p in list_project():
print(f'restore {p}...')
open_project(p)
restore(p)
close_project(p)
if __name__ == '__main__':
main()
-8
View File
@@ -1,8 +0,0 @@
from app.services.tjnetwork import open_project
from get_current_status import *
def run_simulation(cur_datetime:str=None)->str:
open_project('beibei_skeleton')
return
+8 -6
View File
@@ -2,7 +2,7 @@ from types import SimpleNamespace
from uuid import uuid4
from fastapi.testclient import TestClient
from sqlalchemy.exc import SQLAlchemyError
import psycopg
from tests.conftest import build_test_app, install_stub, load_module_from_path
@@ -15,7 +15,7 @@ def _load_meta_module(monkeypatch):
{
"ProjectContext": object,
"get_project_context": lambda: None,
"get_project_pg_session": lambda: None,
"get_project_pg_connection": lambda: None,
"get_project_timescale_connection": lambda: None,
"get_metadata_repository": lambda: None,
},
@@ -68,16 +68,18 @@ def test_meta_project_returns_map_extent(monkeypatch):
def test_meta_db_health_returns_503_for_postgres_errors(monkeypatch):
module = _load_meta_module(monkeypatch)
class BrokenSession:
async def execute(self, _query):
raise SQLAlchemyError("pg unavailable")
class BrokenConnection:
def cursor(self):
raise psycopg.OperationalError("pg unavailable")
class DummyTimescaleConnection:
def cursor(self):
raise AssertionError("timescale should not be queried after postgres failure")
app = build_test_app(module.router, "/api/v1")
app.dependency_overrides[module.get_project_pg_session] = lambda: BrokenSession()
app.dependency_overrides[module.get_project_pg_connection] = (
lambda: BrokenConnection()
)
app.dependency_overrides[module.get_project_timescale_connection] = lambda: DummyTimescaleConnection()
client = TestClient(app)
+47
View File
@@ -1,7 +1,9 @@
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
@@ -12,6 +14,7 @@ from app.auth.metadata_dependencies import (
)
from app.infra.db.metadb.repositories.metadata_repository import ProjectDbRouting
from app.infra.db.project_routing import get_project_pgconn_string
from app.native.wndb.core.database import MaterializedViewRefreshAfterCommitError
from tests.conftest import build_test_app
@@ -144,3 +147,47 @@ def test_model_update_uses_project_business_routing(monkeypatch):
"dsn": "postgresql://user:password@biz.example/routed_business",
}
repo.get_project_db_routing.assert_awaited_once_with(project_id, "biz_data")
def test_gb18030_upload_is_normalized_to_utf8() -> None:
content = "[TITLE]\n天津供水\n[JUNCTIONS]\n".encode("gb18030")
class FakeUpload:
filename = "model.inp"
async def read(self, _limit: int) -> bytes:
return content
normalized, filename = asyncio.run(model_import._read_upload(FakeUpload()))
assert filename == "model.inp"
assert normalized.decode("utf-8") == "[TITLE]\n天津供水\n[JUNCTIONS]\n"
def test_model_update_runs_blocking_import_in_threadpool(monkeypatch) -> None:
calls: list[tuple[object, tuple[object, ...]]] = []
async def fake_threadpool(function, *args):
calls.append((function, args))
monkeypatch.setattr(model_import, "run_in_threadpool", fake_threadpool)
asyncio.run(model_import._update_from_inp(b"[TITLE]\n", "demo"))
assert calls == [(model_import._update_from_inp_sync, (b"[TITLE]\n", "demo"))]
def test_committed_refresh_failure_is_not_wrapped_as_retryable_500(
monkeypatch,
) -> None:
error = MaterializedViewRefreshAfterCommitError("demo")
async def fail_update(_content: bytes, _project_code: str) -> None:
raise error
monkeypatch.setattr(model_import, "_update_from_inp", fail_update)
with pytest.raises(MaterializedViewRefreshAfterCommitError) as exc_info:
asyncio.run(model_import._apply_model_update(b"[TITLE]\n", "demo"))
assert exc_info.value is error
+43 -11
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
import inspect
from datetime import datetime, timezone
from pathlib import Path
from uuid import uuid4
@@ -12,6 +11,7 @@ from fastapi.testclient import TestClient
from app.api.v1.endpoints import simulation as simulation_endpoint
from app.api.pagination import PaginatedList
from app.api.problem_details import install_problem_details_handlers
from app.api.v1.rest_router import api_router, build_rest_router
from app.api.v1.router import api_router as source_api_router
from app.auth.project_dependencies import (
@@ -207,19 +207,51 @@ def test_valve_isolation_route_uses_the_isolation_handler() -> None:
assert route.name == "valve_isolation_endpoint"
def test_open_project_route_requires_business_and_timescale_routing() -> None:
route = next(
route
def test_legacy_project_pool_lifecycle_routes_are_not_published() -> None:
operations = {
(method, route.path)
for route in api_router.routes
if isinstance(route, APIRoute)
and route.path == "/projects/current"
and route.methods == {"POST"}
)
routing_parameter = inspect.signature(route.endpoint).parameters[
"_rest_project_routing"
]
for method in route.methods or set()
}
assert routing_parameter.default.dependency is get_project_simulation_routing
assert ("POST", "/projects/current") not in operations
assert ("DELETE", "/projects/current") not in operations
assert ("GET", "/projects/current/status") not in operations
def test_legacy_server_filesystem_inp_routes_are_not_published() -> None:
operations = {
(method, route.path)
for route in source_api_router.routes
if isinstance(route, APIRoute)
for method in route.methods or set()
}
assert operations.isdisjoint(
{
("POST", "/projects/current/imports"),
("POST", "/projects/current/exports/inp"),
("GET", "/projects/current/files/inp"),
}
)
def test_committed_write_refresh_failure_has_explicit_http_contract() -> None:
from app.native.wndb.core.database import MaterializedViewRefreshAfterCommitError
app = FastAPI()
install_problem_details_handlers(app)
@app.get("/probe")
def probe():
raise MaterializedViewRefreshAfterCommitError("project_a")
response = TestClient(app, raise_server_exceptions=False).get("/probe")
assert response.status_code == 503
assert response.headers["X-TJWater-Changes-Committed"] == "true"
assert response.json()["code"] == "materialized_view_refresh_failed_after_commit"
def test_valve_isolation_runtime_accepts_frontend_query(monkeypatch) -> None:
+17 -33
View File
@@ -18,6 +18,9 @@ class DummyChangeSet:
def _load_project_module(monkeypatch):
class DummyProjectContext:
project_code = "demo"
install_stub(monkeypatch, "app.services", package=True)
install_stub(
monkeypatch,
@@ -33,14 +36,9 @@ def _load_project_module(monkeypatch):
"have_project": lambda network: network == "demo",
"create_project": lambda network: None,
"delete_project": lambda network: None,
"is_project_open": lambda network: False,
"open_project": lambda network: None,
"close_project": lambda network: None,
"copy_project": lambda source, target: None,
"import_inp": lambda network, cs: {"ok": True},
"export_inp": lambda network, version: DummyChangeSet({"kind": "export"}),
"read_inp": lambda network, inp: True,
"dump_inp": lambda network, inp: True,
"get_all_vertices": lambda network: [],
"get_all_scada_info": lambda network: [],
"get_all_district_metering_areas": lambda network: [],
@@ -52,7 +50,12 @@ def _load_project_module(monkeypatch):
install_stub(
monkeypatch,
"app.auth.project_dependencies",
{"get_metadata_repository": lambda: None},
{
"ProjectContext": DummyProjectContext,
"get_metadata_repository": lambda: None,
"get_project_context": lambda: DummyProjectContext(),
"use_project_business_routing": lambda: None,
},
)
return load_module_from_path(
"tests_project_endpoints_module",
@@ -98,40 +101,21 @@ def test_project_info_returns_project_workspace(monkeypatch):
assert "geoserver" not in payload
def test_open_project_uses_unified_wndb_connection_path(monkeypatch):
def test_legacy_open_project_endpoint_is_removed(monkeypatch):
module = _load_project_module(monkeypatch)
called = []
monkeypatch.setattr(module, "open_project", lambda network: called.append(network))
client = TestClient(build_test_app(module.router, "/api/v1"))
response = client.post("/api/v1/projects/current", params={"network": "demo"})
assert response.status_code == 200
assert response.json() == "demo"
assert called == ["demo"]
assert response.status_code == 405
def test_project_lock_lifecycle(monkeypatch):
def test_legacy_physical_project_routes_are_removed(monkeypatch):
module = _load_project_module(monkeypatch)
module.lockedPrjs.clear()
client = TestClient(build_test_app(module.router, "/api/v1"))
first_lock = client.post("/api/v1/projects/current/lock", params={"network": "demo"})
second_lock = client.post("/api/v1/projects/current/lock", params={"network": "demo"})
locked_by_me = client.get(
"/api/v1/projects/current/lock/ownership",
params={"network": "demo"},
)
unlock = client.delete(
"/api/v1/projects/current/lock",
params={"network": "demo"},
)
locked = client.get("/api/v1/projects/current/lock", params={"network": "demo"})
assert first_lock.json() == 0
assert second_lock.json() == 1
assert locked_by_me.json() is True
assert unlock.json() is True
assert locked.json() is False
assert client.get("/api/v1/project-codes").status_code == 404
assert client.get("/api/v1/projects/existence").status_code == 404
assert client.post("/api/v1/project-copies").status_code == 404
assert client.get("/api/v1/projects/current/lock").status_code == 404
assert client.post("/api/v1/project-conversions").status_code == 404
+68
View File
@@ -0,0 +1,68 @@
from threading import get_ident
from uuid import uuid4
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.v1.endpoints.components import curves
from app.api.v1.rest_router import api_router
from app.auth.project_dependencies import (
ProjectContext,
get_project_business_routing,
get_project_context,
)
from app.infra.db.project_routing import ActiveProjectRouting, get_active_project_routing
def test_sync_wndb_endpoint_parses_body_and_runs_in_worker_thread(monkeypatch) -> None:
call: dict[str, object] = {}
project_id = uuid4()
user_id = uuid4()
context = ProjectContext(
project_id=project_id,
project_code="project_a",
user_id=user_id,
project_role="member",
)
routing = ActiveProjectRouting(
project_code="project_a",
business_dsn="postgresql://user:password@db.example/project_a",
)
async def override_context() -> ProjectContext:
call["event_loop_thread"] = get_ident()
return context
async def override_routing() -> ActiveProjectRouting:
return routing
def fake_add_curve(network, changes):
call["worker_thread"] = get_ident()
call["network"] = network
call["operations"] = changes.operations
call["routing"] = get_active_project_routing()
return {"ok": True}
monkeypatch.setattr(curves, "add_curve", fake_add_curve)
app = FastAPI()
app.include_router(api_router)
app.dependency_overrides[get_project_context] = override_context
app.dependency_overrides[get_project_business_routing] = override_routing
with TestClient(app) as client:
response = client.post(
"/curves",
params={"curve": "C-1"},
json={"points": [[0, 10], [1, 20]]},
)
assert response.status_code == 201
assert response.json() == {"ok": True}
assert call == {
"event_loop_thread": call["event_loop_thread"],
"worker_thread": call["worker_thread"],
"network": "project_a",
"operations": [{"id": "C-1", "points": [[0, 10], [1, 20]]}],
"routing": routing,
}
assert call["worker_thread"] != call["event_loop_thread"]
+121 -3
View File
@@ -1,12 +1,17 @@
import asyncio
import os
from concurrent.futures import ThreadPoolExecutor
from uuid import uuid4
import pytest
from psycopg import connect
from app.core.config import get_pgconn_string
from app.infra.db.dynamic_manager import ProjectConnectionManager
from app.infra.db.timescaledb.sync_pool import timescale_connection
from app.native.wndb.commands.api import delete_pattern_cascade
from app.native.wndb.core.connection import project_connection, project_transaction
from app.native.wndb.core.projects import have_project, temporary_project_database
from app.native.wndb.core.database import ChangeSet, g_delete_prefix, write
from app.native.wndb.model import demands, junctions, patterns
from app.services.scheme_management import create_analysis_run, update_analysis_run
@@ -17,7 +22,7 @@ pytestmark = pytest.mark.skipif(
reason="set RUN_DB_INTEGRATION=1 to test configured PostgreSQL databases",
)
PROJECT = os.getenv("DB_INTEGRATION_PROJECT", "tjwater_next")
PROJECT = os.getenv("DB_INTEGRATION_PROJECT", "tjwater_v2")
def _read_business_database(_: int) -> str:
@@ -46,6 +51,60 @@ def test_timeseries_pool_handles_concurrent_borrows() -> None:
assert names == [PROJECT] * 64
def test_temporary_project_clone_copies_model_scada_and_views() -> None:
def counts(project: str) -> dict:
with project_connection(project) as conn, conn.cursor() as cur:
cur.execute(
"""
select
(select count(*) from network.nodes) as nodes,
(select count(*) from network.links) as links,
(select count(*) from asset.scada_devices) as scada,
(select count(*) from gis.junctions) as mv_junctions,
(select count(*) from gis.pipes) as mv_pipes
"""
)
return dict(cur.fetchone())
source_counts = counts(PROJECT)
temporary = None
with temporary_project_database(PROJECT, "clone_validation") as temporary:
assert counts(temporary) == source_counts
assert temporary is not None
assert have_project(temporary) is False
def test_dynamic_pool_replaces_terminated_idle_connection() -> None:
async def exercise_pool() -> None:
manager = ProjectConnectionManager()
dsn = get_pgconn_string(db_name=PROJECT)
try:
project_id = uuid4()
async with manager.pg_connection(
project_id, "biz_data", dsn, 1, 1
) as conn:
async with conn.cursor() as cur:
await cur.execute("select pg_backend_pid()")
backend_pid = int((await cur.fetchone())["pg_backend_pid"])
with connect(dsn, autocommit=True) as admin_conn:
with admin_conn.cursor() as cur:
cur.execute("select pg_terminate_backend(%s)", (backend_pid,))
assert cur.fetchone()[0] is True
async with manager.pg_connection(
project_id, "biz_data", dsn, 1, 1
) as conn:
async with conn.cursor() as cur:
await cur.execute("select current_database()")
assert (await cur.fetchone())["current_database"] == PROJECT
finally:
await manager.close_all()
asyncio.run(exercise_pool())
def test_nested_wndb_writes_roll_back_as_one_transaction() -> None:
with pytest.raises(RuntimeError, match="force rollback"):
with project_transaction(PROJECT) as conn:
@@ -186,10 +245,55 @@ def test_wndb_ordered_detail_tables_use_parent_scoped_primary_keys() -> None:
assert actual == expected
def test_gis_unified_views_cover_all_materialized_network_layers() -> None:
with project_connection(PROJECT) as conn, conn.cursor() as cur:
cur.execute(
"""
SELECT
(SELECT COUNT(*) FROM gis.network_nodes) AS nodes,
(SELECT COUNT(*) FROM gis.junctions)
+ (SELECT COUNT(*) FROM gis.reservoirs)
+ (SELECT COUNT(*) FROM gis.tanks) AS source_nodes,
(SELECT COUNT(*) FROM gis.network_links) AS links,
(SELECT COUNT(*) FROM gis.pipes)
+ (SELECT COUNT(*) FROM gis.pumps)
+ (SELECT COUNT(*) FROM gis.valves) AS source_links
"""
)
counts = cur.fetchone()
cur.execute(
"""
SELECT obj_description('gis.network_nodes'::regclass) AS node_comment,
obj_description('gis.network_links'::regclass) AS link_comment
"""
)
comments = cur.fetchone()
cur.execute(
"""
SELECT COUNT(*) AS undocumented_columns
FROM pg_attribute
WHERE attrelid = ANY(
ARRAY['gis.network_nodes'::regclass, 'gis.network_links'::regclass]
)
AND attnum > 0
AND NOT attisdropped
AND col_description(attrelid, attnum) IS NULL
"""
)
undocumented_columns = cur.fetchone()["undocumented_columns"]
assert counts["nodes"] == counts["source_nodes"]
assert counts["links"] == counts["source_links"]
assert comments["node_comment"]
assert comments["link_comment"]
assert undocumented_columns == 0
def test_wndb_pattern_cascade_unsets_dependent_demand_atomically() -> None:
suffix = uuid4()
junction_id = f"integration-junction-{suffix}"
pattern_id = f"integration-cascade-{suffix}"
retained_pattern_id = f"integration-retained-{suffix}"
with pytest.raises(RuntimeError, match="force rollback"):
with project_transaction(PROJECT):
@@ -203,6 +307,10 @@ def test_wndb_pattern_cascade_unsets_dependent_demand_atomically() -> None:
PROJECT,
ChangeSet({"id": pattern_id, "factors": [1.0]}),
)
patterns.add_pattern(
PROJECT,
ChangeSet({"id": retained_pattern_id, "factors": [1.0]}),
)
demands.set_demand(
PROJECT,
ChangeSet(
@@ -213,7 +321,12 @@ def test_wndb_pattern_cascade_unsets_dependent_demand_atomically() -> None:
"demand": 1.0,
"pattern": pattern_id,
"category": "integration",
}
},
{
"demand": 2.0,
"pattern": retained_pattern_id,
"category": "retained",
},
],
}
),
@@ -226,7 +339,12 @@ def test_wndb_pattern_cascade_unsets_dependent_demand_atomically() -> None:
assert patterns.get_pattern(PROJECT, pattern_id) == {}
assert demands.get_demand(PROJECT, junction_id)["demands"] == [
{"demand": 1.0, "pattern": None, "category": "integration"}
{"demand": 1.0, "pattern": None, "category": "integration"},
{
"demand": 2.0,
"pattern": retained_pattern_id,
"category": "retained",
},
]
assert result.operations[-1] == {
"operation": "delete",
+33 -10
View File
@@ -1,4 +1,5 @@
import json
from contextlib import contextmanager
from tests.conftest import install_stub, load_module_from_path
@@ -29,7 +30,6 @@ def _load_scenarios_module(monkeypatch):
"SOURCE_TYPE_SETPOINT": "SOURCE_TYPE_SETPOINT",
"add_pattern": lambda *args, **kwargs: None,
"add_source": lambda *args, **kwargs: None,
"close_project": lambda *args, **kwargs: None,
"copy_project": lambda *args, **kwargs: None,
"delete_project": lambda *args, **kwargs: None,
"get_demand": lambda *args, **kwargs: None,
@@ -42,8 +42,6 @@ def _load_scenarios_module(monkeypatch):
"get_time": lambda *args, **kwargs: None,
"have_project": lambda *args, **kwargs: False,
"is_junction": lambda *args, **kwargs: False,
"is_project_open": lambda *args, **kwargs: False,
"open_project": lambda *args, **kwargs: None,
"set_demand": lambda *args, **kwargs: None,
"set_emitter": lambda *args, **kwargs: None,
"set_option": lambda *args, **kwargs: None,
@@ -61,12 +59,11 @@ def test_age_analysis_passes_duration_by_keyword(monkeypatch):
module = _load_scenarios_module(monkeypatch)
captured = {}
monkeypatch.setattr(module, "copy_project", lambda *args, **kwargs: None)
monkeypatch.setattr(module, "open_project", lambda *args, **kwargs: None)
monkeypatch.setattr(module, "close_project", lambda *args, **kwargs: None)
monkeypatch.setattr(module, "delete_project", lambda *args, **kwargs: None)
monkeypatch.setattr(module, "have_project", lambda *args, **kwargs: False)
monkeypatch.setattr(module, "is_project_open", lambda *args, **kwargs: False)
@contextmanager
def fake_temporary_project(project, purpose):
yield f"{purpose}_{project}_run"
monkeypatch.setattr(module, "temporary_project_database", fake_temporary_project)
def fake_run_simulation_ex(*args, **kwargs):
captured["args"] = args
@@ -78,7 +75,7 @@ def test_age_analysis_passes_duration_by_keyword(monkeypatch):
module.age_analysis("demo", "2026-06-03T07:00:00+08:00", 300)
assert captured["args"] == (
"age_Anal_demo",
"age_analysis_demo_run",
"realtime",
"2026-06-03T07:00:00+08:00",
)
@@ -86,3 +83,29 @@ def test_age_analysis_passes_duration_by_keyword(monkeypatch):
"duration": 300,
"downloading_prohibition": True,
}
def test_isolated_analysis_cleans_database_after_early_return(monkeypatch):
module = _load_scenarios_module(monkeypatch)
lifecycle: list[tuple[str, str]] = []
@contextmanager
def fake_temporary_project(project, purpose):
lifecycle.append(("create", purpose))
try:
yield "isolated_run"
finally:
lifecycle.append(("delete", purpose))
monkeypatch.setattr(
module, "temporary_project_database", fake_temporary_project
)
@module._isolated_analysis("probe")
def return_early(name, *, _temporary_project=None):
assert name == "demo"
assert _temporary_project == "isolated_run"
return "done"
assert return_early("demo") == "done"
assert lifecycle == [("create", "probe"), ("delete", "probe")]
+90 -2
View File
@@ -1,7 +1,11 @@
import inspect
import json
from contextlib import contextmanager
from unittest.mock import Mock
from uuid import uuid4
import pytest
def test_run_simulation_exposes_explicit_valve_control():
from app.services import simulation
@@ -9,6 +13,34 @@ def test_run_simulation_exposes_explicit_valve_control():
assert "valve_control" in inspect.signature(simulation.run_simulation).parameters
def test_extended_runner_cleans_temporary_database_after_failure(monkeypatch):
from app.algorithms.simulation import runner
lifecycle: list[tuple[str, str]] = []
@contextmanager
def temporary_project(project: str, purpose: str):
lifecycle.append(("create", project))
try:
yield "isolated_project"
finally:
lifecycle.append(("delete", project))
monkeypatch.setattr(runner, "temporary_project_database", temporary_project)
@runner._clean_extended_simulation
def fail(name, simulation_type, *, _temporary_project=None):
assert name == "demo"
assert simulation_type == "extended"
assert _temporary_project == "isolated_project"
raise RuntimeError("simulation failed")
with pytest.raises(RuntimeError, match="simulation failed"):
fail("demo", "extended")
assert lifecycle == [("create", "demo"), ("delete", "demo")]
def test_apply_valve_control_matches_runner_semantics(monkeypatch):
from app.services import simulation
@@ -46,12 +78,58 @@ def test_apply_valve_control_matches_runner_semantics(monkeypatch):
assert updates["V-k"]["setting"] == 0.1036 * pow(0.5, -3.105)
def test_primary_demand_update_preserves_additional_categories():
from app.services import simulation
demand_set = {
"junction": "J1",
"demands": [
{"demand": 1.0, "pattern": "P1", "category": "domestic"},
{"demand": 2.0, "pattern": "P2", "category": "industrial"},
],
}
simulation._primary_demand(demand_set)["demand"] = 3.0
assert demand_set["demands"] == [
{"demand": 3.0, "pattern": "P1", "category": "domestic"},
{"demand": 2.0, "pattern": "P2", "category": "industrial"},
]
assert simulation._primary_demand_pattern(demand_set) == "P1"
def test_primary_demand_is_created_for_empty_junction():
from app.services import simulation
demand_set = {"junction": "J1", "demands": []}
primary = simulation._primary_demand(demand_set)
assert primary == {"demand": 0.0, "pattern": None, "category": None}
with pytest.raises(ValueError, match="has no demand pattern"):
simulation._primary_demand_pattern(demand_set)
def test_extended_simulation_stores_results_by_run_id(monkeypatch):
from app.services import simulation
run_id = uuid4()
storage_calls: list[tuple] = []
monkeypatch.setattr(simulation, "open_project", lambda name: None)
transaction_calls: list[tuple[str, str]] = []
@contextmanager
def project_transaction(name):
transaction_calls.append(("begin", name))
try:
yield object()
finally:
transaction_calls.append(("end", name))
refresh_mock = Mock()
monkeypatch.setattr(simulation, "project_transaction", project_transaction)
monkeypatch.setattr(
simulation, "refresh_materialized_views_after_commit", refresh_mock
)
monkeypatch.setattr(
simulation,
"get_time",
@@ -104,6 +182,8 @@ def test_extended_simulation_stores_results_by_run_id(monkeypatch):
assert kwargs["db_name"] == "demo"
assert returned_run_id == run_id
assert lifecycle_calls[-1][1]["status"] == "completed"
assert transaction_calls == [("begin", "demo"), ("end", "demo")]
refresh_mock.assert_called_once_with("demo")
def test_extended_simulation_marks_run_failed_when_result_storage_fails(monkeypatch):
@@ -111,7 +191,15 @@ def test_extended_simulation_marks_run_failed_when_result_storage_fails(monkeypa
run_id = uuid4()
lifecycle_calls: list[tuple] = []
monkeypatch.setattr(simulation, "open_project", lambda name: None)
@contextmanager
def project_transaction(_name):
yield object()
monkeypatch.setattr(simulation, "project_transaction", project_transaction)
monkeypatch.setattr(
simulation, "refresh_materialized_views_after_commit", lambda _name: None
)
monkeypatch.setattr(
simulation,
"get_time",
+17
View File
@@ -0,0 +1,17 @@
import pytest
from scripts.clean_projects import parse_args
def test_clean_projects_requires_explicit_confirmation() -> None:
with pytest.raises(SystemExit) as exc_info:
parse_args(["temporary_project"])
assert exc_info.value.code == 2
def test_clean_projects_accepts_exact_targets_after_confirmation() -> None:
args = parse_args(["--yes", "temp_a", "temp_b"])
assert args.yes is True
assert args.projects == ["temp_a", "temp_b"]
+98 -8
View File
@@ -1,12 +1,102 @@
from app.infra.db.dynamic_manager import ProjectConnectionManager
import asyncio
from contextlib import asynccontextmanager
from uuid import uuid4
import pytest
from app.infra.db import dynamic_manager
def test_normalize_pg_url_preserves_password():
manager = ProjectConnectionManager()
class FakeAsyncPool:
created: list["FakeAsyncPool"] = []
url = manager._normalize_pg_url(
"postgresql://tjwater:secret@192.168.1.114:5433/tjwater"
)
def __init__(self, **kwargs) -> None:
self.kwargs = kwargs
self.closed = False
self.created.append(self)
assert url == "postgresql+psycopg://tjwater:secret@192.168.1.114:5433/tjwater"
assert "***" not in url
async def open(self) -> None:
return None
async def close(self) -> None:
self.closed = True
@asynccontextmanager
async def connection(self):
yield object()
def test_active_project_pool_is_not_evicted(monkeypatch) -> None:
async def exercise() -> None:
manager = dynamic_manager.ProjectConnectionManager()
first_id = uuid4()
second_id = uuid4()
async with manager.pg_connection(first_id, "biz_data", "dsn-1", 1, 2):
first_pool = manager._pg_raw_cache[
dynamic_manager.CacheKey(first_id, "biz_data")
].pool
async with manager.pg_connection(
second_id, "biz_data", "dsn-2", 1, 2
):
assert first_pool.closed is False
assert len(manager._pg_raw_cache) == 2
assert first_pool.closed is False
assert list(manager._pg_raw_cache) == [
dynamic_manager.CacheKey(first_id, "biz_data")
]
await manager.close_all()
FakeAsyncPool.created = []
monkeypatch.setattr(dynamic_manager, "AsyncConnectionPool", FakeAsyncPool)
monkeypatch.setattr(dynamic_manager.settings, "PROJECT_PG_CACHE_SIZE", 1)
asyncio.run(exercise())
def test_active_pool_uses_generation_replacement(monkeypatch) -> None:
async def exercise() -> None:
manager = dynamic_manager.ProjectConnectionManager()
project_id = uuid4()
async with manager.pg_connection(
project_id, "biz_data", "old-dsn", 1, 2
):
old_pool = FakeAsyncPool.created[0]
async with manager.pg_connection(
project_id, "biz_data", "new-dsn", 1, 2
):
assert old_pool.closed is False
assert len(manager._retired_pg) == 1
assert old_pool.closed is True
assert manager._retired_pg == []
await manager.close_all()
FakeAsyncPool.created = []
monkeypatch.setattr(dynamic_manager, "AsyncConnectionPool", FakeAsyncPool)
asyncio.run(exercise())
def test_close_project_does_not_interrupt_active_borrow(monkeypatch) -> None:
async def exercise() -> None:
manager = dynamic_manager.ProjectConnectionManager()
project_id = uuid4()
key = dynamic_manager.CacheKey(project_id, "iot_data")
async with manager.timescale_connection(
project_id, "iot_data", "ts-dsn", 1, 2
):
pool = manager._ts_cache[key].pool
assert await manager.close_project(project_id) is False
assert pool.closed is False
assert await manager.close_project(project_id) is True
assert pool.closed is True
assert key not in manager._ts_cache
FakeAsyncPool.created = []
monkeypatch.setattr(dynamic_manager, "AsyncConnectionPool", FakeAsyncPool)
asyncio.run(exercise())
+104
View File
@@ -0,0 +1,104 @@
import asyncio
from collections import OrderedDict
from contextlib import asynccontextmanager
from uuid import uuid4
from app.infra.db import dynamic_manager
from app.infra.db.timescaledb import sync_pool
from app.native.wndb.core import connection
class RecordingAsyncPool:
created: list[dict] = []
@staticmethod
async def check_connection(_conn) -> None:
return None
def __init__(self, **kwargs) -> None:
self.created.append(kwargs)
self.closed = False
async def open(self) -> None:
return None
async def close(self) -> None:
self.closed = True
@asynccontextmanager
async def connection(self):
yield object()
class RecordingPool:
created: list[dict] = []
@staticmethod
def check_connection(_conn) -> None:
return None
def __init__(self, **kwargs) -> None:
self.created.append(kwargs)
self.closed = False
def close(self) -> None:
self.closed = True
def test_dynamic_project_pools_check_connections_before_borrow(monkeypatch) -> None:
async def create_pools() -> None:
manager = dynamic_manager.ProjectConnectionManager()
async with manager.pg_connection(
uuid4(), "biz_data", "postgresql://user:password@db.example/biz", 1, 5
):
pass
async with manager.timescale_connection(
uuid4(), "iot_data", "postgresql://user:password@db.example/ts", 1, 5
):
pass
RecordingAsyncPool.created = []
monkeypatch.setattr(dynamic_manager, "AsyncConnectionPool", RecordingAsyncPool)
asyncio.run(create_pools())
assert len(RecordingAsyncPool.created) == 2
assert all(
options["check"] is dynamic_manager._check_async_connection
for options in RecordingAsyncPool.created
)
def test_synchronous_project_pools_check_connections_before_borrow(monkeypatch) -> None:
RecordingPool.created = []
monkeypatch.setattr(connection, "ConnectionPool", RecordingPool)
monkeypatch.setattr(connection, "_pools", OrderedDict())
monkeypatch.setattr(connection, "_pool_conninfo", {})
monkeypatch.setattr(connection, "_pool_borrows", {})
monkeypatch.setattr(connection, "_admin_pools", OrderedDict())
monkeypatch.setattr(connection, "_admin_pool_borrows", {})
monkeypatch.setattr(
connection,
"get_project_pgconn_string",
lambda db_name: f"postgresql://user:password@db.example/{db_name}",
)
connection.get_project_pool("tjwater_next")
connection.get_admin_pool()
monkeypatch.setattr(sync_pool, "ConnectionPool", RecordingPool)
monkeypatch.setattr(sync_pool, "_pools", OrderedDict())
monkeypatch.setattr(sync_pool, "_pool_conninfo", {})
monkeypatch.setattr(sync_pool, "_pool_borrows", {})
monkeypatch.setattr(
sync_pool,
"get_project_timescale_pgconn_string",
lambda db_name: f"postgresql://user:password@db.example/{db_name}",
)
sync_pool.get_timescale_pool("tjwater_next")
assert len(RecordingPool.created) == 3
assert [options["check"] for options in RecordingPool.created] == [
connection._check_connection,
connection._check_connection,
sync_pool._check_connection,
]
+34 -3
View File
@@ -5,7 +5,9 @@ from app.infra.db.project_routing import (
ActiveProjectRouting,
activate_project_routing,
get_active_project_routing,
get_project_database_name,
get_project_pgconn_string,
get_project_template_database_name,
get_project_timescale_pgconn_string,
)
@@ -35,9 +37,16 @@ def test_project_database_uses_exact_routing_dsn_for_project_code() -> None:
)
def test_business_template_keeps_server_and_timescale_ignores_legacy_db_name() -> None:
def test_business_template_keeps_server_and_timescale_ignores_legacy_db_name(
monkeypatch,
) -> None:
monkeypatch.setattr(
"app.infra.db.project_routing.settings.WNDB_TEMPLATE_DB_NAME",
"tjwater_v2_template",
)
with activate_project_routing(_routing()):
business = conninfo_to_dict(get_project_pgconn_string("project_a_template"))
template_name = get_project_template_database_name("project_a")
business = conninfo_to_dict(get_project_pgconn_string(template_name))
timescale = conninfo_to_dict(
get_project_timescale_pgconn_string("temporary_scheme")
)
@@ -45,7 +54,7 @@ def test_business_template_keeps_server_and_timescale_ignores_legacy_db_name() -
assert business == {
"user": "biz_user",
"password": "biz_password",
"dbname": "project_a_template",
"dbname": "tjwater_v2_template",
"host": "biz.example",
"port": "5432",
"sslmode": "require",
@@ -60,6 +69,28 @@ def test_business_template_keeps_server_and_timescale_ignores_legacy_db_name() -
}
def test_project_code_resolves_to_physical_business_database(monkeypatch) -> None:
monkeypatch.setattr(
"app.infra.db.project_routing.settings.WNDB_TEMPLATE_DB_NAME",
"tjwater_v2_template",
)
with activate_project_routing(_routing()):
assert get_project_database_name("project_a") == "biz_database"
assert get_project_database_name("temporary_run") == "temporary_run"
assert get_project_template_database_name("project_a") == "tjwater_v2_template"
def test_template_falls_back_to_config_outside_project_routing(monkeypatch) -> None:
monkeypatch.setattr(
"app.infra.db.project_routing.settings.WNDB_TEMPLATE_DB_NAME",
"tjwater_v2_template",
)
assert get_project_template_database_name("ignored-project-code") == (
"tjwater_v2_template"
)
def test_project_routing_is_nested_and_request_local() -> None:
first = _routing("project_a")
second = _routing("project_b")
+22 -6
View File
@@ -33,12 +33,19 @@ def _patch_project_scadas(monkeypatch):
def test_realtime_scada_simulation_uses_current_project_metadata(monkeypatch):
_patch_project_scadas(monkeypatch)
query_mock = AsyncMock(return_value=[{"time": START_TIME, "value": 26.5}])
query_mock = AsyncMock(
return_value={"J1": [{"time": START_TIME, "value": 26.5}]}
)
monkeypatch.setattr(
composite_queries.RealtimeRepository,
"get_node_field_by_time_range",
"get_node_fields_by_ids_time_range",
query_mock,
)
monkeypatch.setattr(
composite_queries.RealtimeRepository,
"get_link_fields_by_ids_time_range",
AsyncMock(return_value={}),
)
result = asyncio.run(
composite_queries.CompositeQueries.get_scada_associated_realtime_simulation_data(
@@ -55,17 +62,22 @@ def test_realtime_scada_simulation_uses_current_project_metadata(monkeypatch):
assert query_mock.await_args.args[1:] == (
START_TIME,
END_TIME,
"J1",
["J1"],
"pressure",
)
def test_analysis_scada_simulation_uses_current_project_metadata(monkeypatch):
_patch_project_scadas(monkeypatch)
query_mock = AsyncMock(return_value=[{"time": START_TIME, "value": 26.5}])
async def query_series(_conn, _run_id, element_type, element_ids, *_args):
if element_type == "node":
return {"J1": [{"time": START_TIME, "value": 26.5}]}
return {}
query_mock = AsyncMock(side_effect=query_series)
monkeypatch.setattr(
composite_queries.AnalysisResultsRepository,
"get_node_series",
"get_series_by_ids",
query_mock,
)
@@ -81,7 +93,11 @@ def test_analysis_scada_simulation_uses_current_project_metadata(monkeypatch):
)
assert result[PROJECT_SCADA["device_id"]][0]["scada_id"] == PROJECT_SCADA["device_id"]
assert query_mock.await_args.args[2:] == ("J1", START_TIME, END_TIME, "pressure")
assert query_mock.await_count == 2
node_call = next(
call for call in query_mock.await_args_list if call.args[2] == "node"
)
assert node_call.args[3:] == (["J1"], START_TIME, END_TIME, "pressure")
def test_element_scada_query_uses_current_project_metadata(monkeypatch):
+74 -1
View File
@@ -1,5 +1,5 @@
import asyncio
from contextlib import contextmanager
from contextlib import asynccontextmanager, contextmanager
from datetime import datetime, timezone
from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository
@@ -71,12 +71,23 @@ def test_get_nodes_by_time_range_normalizes_inputs_to_utc():
class _SyncTransactionConnection:
def __init__(self):
self.transactions = 0
self.calls: list[tuple[str, tuple]] = []
@contextmanager
def transaction(self):
self.transactions += 1
yield
@contextmanager
def cursor(self):
connection = self
class Cursor:
def execute(self, query, params):
connection.calls.append((query, params))
yield Cursor()
def test_realtime_node_and_link_replacement_share_outer_transaction(monkeypatch):
conn = _SyncTransactionConnection()
@@ -101,3 +112,65 @@ def test_realtime_node_and_link_replacement_share_outer_transaction(monkeypatch)
assert conn.transactions == 1
assert calls == ["nodes", "links"]
assert [query for query, _params in conn.calls] == [
"SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))",
"DELETE FROM realtime.node_results WHERE time = %s",
"DELETE FROM realtime.link_results WHERE time = %s",
]
def test_realtime_batch_rejects_multiple_timestamps_before_writing():
data = [
{"time": "2026-06-01T00:00:00Z", "id": "N1"},
{"time": "2026-06-01T00:15:00Z", "id": "N2"},
]
try:
RealtimeRepository.insert_nodes_batch_sync(object(), data)
except ValueError as exc:
assert str(exc) == "Realtime batch must contain exactly one timestamp"
else:
raise AssertionError("multiple realtime timestamps were accepted")
class _AsyncEmptySnapshotConnection:
def __init__(self):
self.calls: list[tuple[str, tuple]] = []
@asynccontextmanager
async def transaction(self):
yield
def cursor(self):
connection = self
class Cursor:
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return False
async def execute(self, query, params):
connection.calls.append((query, params))
return Cursor()
def test_empty_realtime_side_is_deleted_as_part_of_snapshot_replacement():
conn = _AsyncEmptySnapshotConnection()
asyncio.run(
RealtimeRepository.store_realtime_simulation_result(
conn,
node_result_list=[],
link_result_list=[],
result_start_time="2026-06-01T00:00:00Z",
)
)
assert [query for query, _params in conn.calls] == [
"SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))",
"DELETE FROM realtime.node_results WHERE time = %s",
"DELETE FROM realtime.link_results WHERE time = %s",
]
+4 -3
View File
@@ -32,9 +32,10 @@ def test_clean_scada_uses_current_project_metadata(monkeypatch):
),
)
update_mock = AsyncMock()
update_mock.return_value = 1
monkeypatch.setattr(
composite_queries.ScadaRepository,
"update_scada_field",
"update_scada_field_batch",
update_mock,
)
monkeypatch.setattr(
@@ -111,7 +112,7 @@ def test_clean_scada_rejects_zero_database_updates(monkeypatch):
update_mock = AsyncMock()
monkeypatch.setattr(
composite_queries.ScadaRepository,
"update_scada_field",
"update_scada_field_batch",
update_mock,
)
monkeypatch.setattr(
@@ -157,7 +158,7 @@ def test_clean_scada_propagates_write_failures(monkeypatch):
)
monkeypatch.setattr(
composite_queries.ScadaRepository,
"update_scada_field",
"update_scada_field_batch",
AsyncMock(side_effect=RuntimeError("database write failed")),
)
monkeypatch.setattr(
+28
View File
@@ -86,3 +86,31 @@ def test_update_scada_field_skips_insert_when_update_succeeds():
assert len(conn.cursor_instance.calls) == 1
assert "UPDATE scada.measurements SET" in conn.cursor_instance.calls[0][0]
def test_update_scada_field_batch_uses_one_set_based_statement():
ScadaRepository = _load_scada_repository()
conn = _FakeConnection(initial_rowcount=2)
first_time = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc)
second_time = datetime(2026, 1, 1, 0, 5, tzinfo=timezone.utc)
updated = asyncio.run(
ScadaRepository.update_scada_field_batch(
conn,
[
(first_time, "170490", 26.5),
(second_time, "170491", 27.0),
],
"cleaned_value",
)
)
assert updated == 2
assert len(conn.cursor_instance.calls) == 1
query, params = conn.cursor_instance.calls[0]
assert "unnest" in query.lower()
assert params == (
[first_time, second_time],
["170490", "170491"],
[26.5, 27.0],
)
+212 -4
View File
@@ -3,7 +3,9 @@ from contextlib import contextmanager
import pytest
from app.native.wndb.commands import executor
from app.native.wndb.core import database
from app.native.wndb.core.database import ChangeSet
from app.native.wndb.model import junctions, pipes, pumps, reservoirs, tanks, valves
def test_batch_commits_before_materialized_view_refresh(monkeypatch) -> None:
@@ -15,7 +17,7 @@ def test_batch_commits_before_materialized_view_refresh(monkeypatch) -> None:
yield object()
events.append("transaction-exit")
monkeypatch.setattr(executor, "project_transaction", fake_transaction)
monkeypatch.setattr(executor, "model_mutation_transaction", fake_transaction)
monkeypatch.setattr(
executor,
"expand_command",
@@ -28,7 +30,7 @@ def test_batch_commits_before_materialized_view_refresh(monkeypatch) -> None:
)
monkeypatch.setattr(
executor,
"refresh_materialized_views",
"refresh_materialized_views_after_commit",
lambda _name: events.append("refresh"),
)
@@ -56,7 +58,7 @@ def test_failed_batch_does_not_refresh_materialized_views(monkeypatch) -> None:
finally:
events.append("transaction-exit")
monkeypatch.setattr(executor, "project_transaction", fake_transaction)
monkeypatch.setattr(executor, "model_mutation_transaction", fake_transaction)
monkeypatch.setattr(
executor,
"expand_command",
@@ -70,7 +72,7 @@ def test_failed_batch_does_not_refresh_materialized_views(monkeypatch) -> None:
monkeypatch.setattr(executor, "_execute_update_command", fail_write)
monkeypatch.setattr(
executor,
"refresh_materialized_views",
"refresh_materialized_views_after_commit",
lambda _name: events.append("refresh"),
)
@@ -81,3 +83,209 @@ def test_failed_batch_does_not_refresh_materialized_views(monkeypatch) -> None:
)
assert events == ["transaction-enter", "write", "transaction-exit"]
def test_batch_option_update_does_not_refresh_materialized_views(monkeypatch) -> None:
events: list[str] = []
@contextmanager
def fake_transaction(_name: str):
events.append("transaction-enter")
yield object()
events.append("transaction-exit")
monkeypatch.setattr(executor, "model_mutation_transaction", fake_transaction)
monkeypatch.setattr(executor, "expand_command", lambda _name, cs: cs)
monkeypatch.setattr(
executor,
"_execute_update_command",
lambda _name, _change_set: events.append("write") or ChangeSet(),
)
monkeypatch.setattr(
executor,
"refresh_materialized_views_after_commit",
lambda _name: events.append("refresh"),
)
executor.execute_batch_commands(
"project_a",
ChangeSet({"operation": "update", "type": "option", "id": "duration"}),
)
assert events == ["transaction-enter", "write", "transaction-exit"]
def test_model_mutation_lock_is_acquired_once_per_transaction(monkeypatch) -> None:
state = {"held": False}
statements: list[tuple[str, tuple[str]]] = []
class FakeCursor:
def __enter__(self):
return self
def __exit__(self, *_args):
return None
def execute(self, statement: str, params: tuple[str]):
statements.append((statement, params))
class FakeConnection:
def cursor(self):
return FakeCursor()
monkeypatch.setattr(
database,
"is_model_mutation_lock_active",
lambda _name: state["held"],
raising=False,
)
monkeypatch.setattr(
database,
"mark_model_mutation_lock_active",
lambda _name: state.__setitem__("held", True),
raising=False,
)
monkeypatch.setattr(database, "get_project_database_name", lambda name: name)
conn = FakeConnection()
database.acquire_model_mutation_lock(conn, "project_a")
database.acquire_model_mutation_lock(conn, "project_a")
assert len(statements) == 1
def test_locked_command_builds_after_lock_and_refreshes_after_commit(
monkeypatch,
) -> None:
events: list[str] = []
@contextmanager
def fake_model_transaction(_name: str):
events.append("lock")
yield object()
events.append("commit")
def build_command() -> database.DatabaseCommand:
events.append("read-and-build")
return database.DatabaseCommand(
"UPDATE network.junctions SET elevation = 1",
[{"operation": "update", "type": "junction", "id": "J1"}],
)
monkeypatch.setattr(database, "model_mutation_transaction", fake_model_transaction)
monkeypatch.setattr(
database,
"is_project_transaction_active",
lambda _name: False,
)
monkeypatch.setattr(
database,
"execute_command",
lambda _name, command: events.append("write")
or ChangeSet.from_list(command.changes),
)
monkeypatch.setattr(
database,
"refresh_materialized_views_after_commit",
lambda _name: events.append("refresh"),
)
result = database.execute_locked_command("project_a", build_command)
assert events == ["lock", "read-and-build", "write", "commit", "refresh"]
assert result.operations[0]["id"] == "J1"
def test_pipe_patch_reads_under_lock_and_updates_only_supplied_columns(
monkeypatch,
) -> None:
events: list[str] = []
current = {
"id": "P1",
"node1": "J1",
"node2": "J2",
"length": 10.0,
"diameter": 100.0,
"roughness": 120.0,
"minor_loss": 0.0,
"status": "OPEN",
}
def fake_locked_command(_name: str, builder):
events.append("lock")
command = builder()
assert command is not None
captured.append(command)
events.append("refresh")
return ChangeSet.from_list(command.changes)
def fake_get_pipe(_name: str, _id: str):
events.append("read")
return current.copy()
captured: list[database.DatabaseCommand] = []
monkeypatch.setattr(
pipes,
"execute_locked_command",
fake_locked_command,
)
monkeypatch.setattr(pipes, "get_pipe", fake_get_pipe)
result = pipes.set_pipe(
"project_a",
ChangeSet({"operation": "update", "type": "pipe", "id": "P1", "length": 20}),
)
assert events == ["lock", "read", "refresh"]
assert len(captured) == 1
assert "length = 20.0" in captured[0].sql
assert "diameter" not in captured[0].sql
assert "update network.links" not in captured[0].sql.lower()
assert result.operations[0]["length"] == 20.0
@pytest.mark.parametrize(
("module", "setter_name", "getter_name", "builder_name"),
[
(junctions, "set_junction", "get_junction", "_set_junction"),
(reservoirs, "set_reservoir", "get_reservoir", "_set_reservoir"),
(tanks, "set_tank", "get_tank", "_set_tank"),
(pumps, "set_pump", "get_pump", "_set_pump"),
(valves, "set_valve", "get_valve", "_set_valve"),
],
)
def test_element_patch_reads_after_shared_model_lock(
monkeypatch,
module,
setter_name: str,
getter_name: str,
builder_name: str,
) -> None:
events: list[str] = []
current = {"id": "E1"}
def fake_getter(_name: str, _id: str):
events.append("read")
return current
def fake_builder(_name: str, _changes: ChangeSet, supplied_current):
events.append("build")
assert supplied_current is current
return database.DatabaseCommand("UPDATE network.nodes SET id = id", [])
def fake_locked_command(_name: str, builder):
events.append("lock")
assert builder() is not None
return ChangeSet()
monkeypatch.setattr(module, getter_name, fake_getter)
monkeypatch.setattr(module, builder_name, fake_builder)
monkeypatch.setattr(module, "execute_locked_command", fake_locked_command)
getattr(module, setter_name)(
"project_a",
ChangeSet({"operation": "update", "type": "element", "id": "E1"}),
)
assert events == ["lock", "read", "build"]
@@ -0,0 +1,54 @@
import ast
from pathlib import Path
def test_wndb_editor_routes_are_synchronous() -> None:
"""Sync psycopg-backed routes must let FastAPI schedule them in its thread pool."""
endpoints = Path(__file__).resolve().parents[2] / "app" / "api" / "v1" / "endpoints"
route_roots = [endpoints / "network", endpoints / "components"]
async_routes: list[str] = []
for root in route_roots:
for path in root.glob("*.py"):
tree = ast.parse(path.read_text(encoding="utf-8"))
for node in tree.body:
if not isinstance(node, ast.AsyncFunctionDef):
continue
is_route = any(
isinstance(decorator, ast.Call)
and isinstance(decorator.func, ast.Attribute)
and isinstance(decorator.func.value, ast.Name)
and decorator.func.value.id == "router"
for decorator in node.decorator_list
)
if is_route:
async_routes.append(f"{path.name}:{node.lineno}:{node.name}")
assert async_routes == []
def test_simulation_routes_are_synchronous() -> None:
"""EPANET and synchronous WNDB work must run in FastAPI's thread pool."""
path = (
Path(__file__).resolve().parents[2]
/ "app"
/ "api"
/ "v1"
/ "endpoints"
/ "simulation.py"
)
tree = ast.parse(path.read_text(encoding="utf-8"))
async_routes = [
node.name
for node in tree.body
if isinstance(node, ast.AsyncFunctionDef)
and any(
isinstance(decorator, ast.Call)
and isinstance(decorator.func, ast.Attribute)
and isinstance(decorator.func.value, ast.Name)
and decorator.func.value.id == "router"
for decorator in node.decorator_list
)
]
assert async_routes == []

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