diff --git a/.env.example b/.env.example index 212753a..75e9e4d 100644 --- a/.env.example +++ b/.env.example @@ -39,6 +39,15 @@ METADATA_DB_PORT="5432" 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_POOL_MIN_SIZE="0" +PROJECT_PG_POOL_SIZE="5" +PROJECT_PG_MAX_OVERFLOW="10" +PROJECT_TS_POOL_MIN_SIZE="0" +PROJECT_TS_POOL_MAX_SIZE="10" + # ============================================ # Keycloak JWT (可选) # ============================================ diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index 8a2ce3a..f88d263 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -8,17 +8,16 @@ on: jobs: build-test-publish-and-deploy: - uses: OrgTJWater/ci-templates/.gitea/workflows/container-cd.yml@main + uses: OrgTJWater/ci-templates/.gitea/workflows/container-cd.yml@68c8a9855391baa31d31523674f5812cd24ec604 with: image_name: gitea.waternetwork.cn/orgtjwater/tjwater-backend dockerfile: Dockerfile build_context: . test_command: | - test -f app/api/v1/endpoints/access.py - grep -Fq 'api_router.include_router(access.router' app/api/v1/router.py - grep -Fq '@router.get("/projects"' app/api/v1/endpoints/meta.py - grep -Fq '@router.get("/projects/current"' app/api/v1/endpoints/project.py - grep -Fq '@router.post("/audit-events"' app/api/v1/endpoints/audit.py + python -m compileall -q app + python -c "import app.main" + python scripts/check_openapi.py + pytest -q tests deploy_service: backend deploy_host: 192.168.1.114 secrets: diff --git a/.gitignore b/.gitignore index 46780ee..cafd240 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,4 @@ build/ *.dump .vscode/ app/algorithms/health/model/my_survival_forest_model_quxi.joblib -inp/ +/inp/ diff --git a/Dockerfile b/Dockerfile index f144e59..cfdb907 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,6 +17,11 @@ RUN uv pip install --system --no-cache-dir -r requirements.txt # 本地数据目录和环境变量在运行时通过 Compose 挂载或注入, # 不应进入镜像构建上下文。 COPY app ./app +COPY contracts ./contracts +COPY infra ./infra +COPY resources ./resources +COPY scripts/check_openapi.py ./scripts/check_openapi.py +COPY tests ./tests RUN python -c "from pathlib import Path; from zipfile import ZipFile; model_dir = Path('app/algorithms/health/model'); zip_path = model_dir / 'my_survival_forest_model_quxi.zip'; joblib_name = 'my_survival_forest_model_quxi.joblib'; joblib_path = model_dir / joblib_name; assert zip_path.exists(), f'Model archive not found: {zip_path}'; archive = ZipFile(zip_path); archive.extract(joblib_name, model_dir); archive.close(); assert joblib_path.exists(), f'Model file not extracted: {joblib_path}'" && \ rm -f app/algorithms/health/model/my_survival_forest_model_quxi.zip RUN mkdir -p db_inp temp data inp diff --git a/app/algorithms/sensor/__init__.py b/app/algorithms/sensor/__init__.py index b500fc4..b5737cd 100644 --- a/app/algorithms/sensor/__init__.py +++ b/app/algorithms/sensor/__init__.py @@ -5,7 +5,7 @@ from typing import Any from app.algorithms.sensor import kmeans as kmeans_sensor from app.algorithms.sensor import sensitivity -from app.native.wndb.s42_sensor_placement import create_sensor_placement +from app.infra.db.postgresql.sensor_placement import create_sensor_placement from app.services.sensor_placement import ( SensorPlacementConflictError, SensorPlacementValidationError, @@ -50,18 +50,18 @@ def _sensor_inp_lock(name: str): def _create_validated_placement( name: str, *, - scheme_name: str, + run_name: str, min_diameter: int, - username: str, - sensor_location: list[str], + created_by: str, + sensor_locations: list[str], ) -> dict[str, Any]: - validate_sensor_placement_nodes(name, sensor_location) + validate_sensor_placement_nodes(name, sensor_locations) return create_sensor_placement( name, - scheme_name=scheme_name, + run_name=run_name, min_diameter=min_diameter, - username=username, - sensor_location=sensor_location, + created_by=created_by, + sensor_locations=sensor_locations, ) @@ -89,10 +89,10 @@ def pressure_sensor_placement_sensitivity( ) return _create_validated_placement( name, - scheme_name=scheme_name, + run_name=scheme_name, min_diameter=min_diameter, - username=username, - sensor_location=sensor_location, + created_by=username, + sensor_locations=sensor_location, ) @@ -124,8 +124,8 @@ def pressure_sensor_placement_kmeans( ) return _create_validated_placement( name, - scheme_name=scheme_name, + run_name=scheme_name, min_diameter=min_diameter, - username=username, - sensor_location=sensor_location, + created_by=username, + sensor_locations=sensor_location, ) diff --git a/app/algorithms/simulation/runner.py b/app/algorithms/simulation/runner.py index b59fa55..f25acd4 100644 --- a/app/algorithms/simulation/runner.py +++ b/app/algorithms/simulation/runner.py @@ -454,8 +454,12 @@ class DataLoader: """读取选定pattern的保存的历史pattern信息(flow, factor)""" factors_list = [] flow_list = [] - patterns_info = read_all(project_name, - f"select * from history_patterns_flows where id = '{pattern_name}' order by _order") + patterns_info = read_all( + project_name, + "select flow, factor from network.pattern_flow_samples " + "where pattern_id = %s order by sequence_no", + (pattern_name,), + ) for item in patterns_info: flow_list.append(float(item['flow'])) factors_list.append(float(item['factor'])) diff --git a/app/algorithms/simulation/scenarios.py b/app/algorithms/simulation/scenarios.py index 2a36544..021696f 100644 --- a/app/algorithms/simulation/scenarios.py +++ b/app/algorithms/simulation/scenarios.py @@ -9,7 +9,6 @@ from app.algorithms.simulation.runner import ( run_simulation_ex, from_clock_to_seconds_2, ) -from app.services.scheme_management import store_scheme_info from app.services.tjnetwork import ( ChangeSet, OPTION_DEMAND_MODEL_PDA, @@ -111,8 +110,6 @@ def burst_analysis( datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S") + " -- Start Copying Database." ) - # CopyProjectEx()(name, new_name, - # ['operation', 'current_operation', 'restore_operation', 'batch_operation', 'operation_table']) copy_project(name + "_template", new_name) print( datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S") @@ -204,21 +201,15 @@ def burst_analysis( modify_valve_opening=modify_valve_opening, scheme_type="burst_analysis", scheme_name=scheme_name, + result_db_name=name, + scheme_username=username, + scheme_detail=scheme_detail, ) # step 3. restore the base model status # execute_undo(name) #有疑惑 if is_project_open(new_name): close_project(new_name) delete_project(new_name) - # 存储方案信息到 PG 数据库 - store_scheme_info( - name=name, - scheme_name=scheme_name, - scheme_type="burst_analysis", - username=username, - scheme_start_time=modify_pattern_start_time, - scheme_detail=scheme_detail, - ) ############################################################ @@ -253,8 +244,6 @@ def valve_close_analysis( datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S") + " -- Start Copying Database." ) - # CopyProjectEx()(name, new_name, - # ['operation', 'current_operation', 'restore_operation', 'batch_operation', 'operation_table']) copy_project(name + "_template", new_name) print( datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S") @@ -293,6 +282,7 @@ def valve_close_analysis( modify_valve_opening=modify_valve_opening, scheme_type="valve_close_Analysis", scheme_name=scheme_name, + result_db_name=name, ) # step 3. restore the base model # for valve in valves: @@ -355,8 +345,6 @@ def flushing_analysis( datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S") + " -- Start Copying Database." ) - # CopyProjectEx()(name, new_name, - # ['operation', 'current_operation', 'restore_operation', 'batch_operation', 'operation_table']) copy_project(name + "_template", new_name) print( datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S") @@ -456,21 +444,15 @@ def flushing_analysis( valve_control=valve_control, scheme_type="flushing_analysis", scheme_name=scheme_name, + result_db_name=name, + scheme_username=username, + 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 - # 存储方案信息到 PG 数据库 - store_scheme_info( - name=name, - scheme_name=scheme_name, - scheme_type="flushing_analysis", - username=username, - scheme_start_time=modify_pattern_start_time, - scheme_detail=scheme_detail, - ) ############################################################ @@ -523,8 +505,6 @@ def contaminant_simulation( datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S") + " -- Start Copying Database." ) - # CopyProjectEx()(name, new_name, - # ['operation', 'current_operation', 'restore_operation', 'batch_operation', 'operation_table']) copy_project(name + "_template", new_name) print( datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S") @@ -612,6 +592,9 @@ def contaminant_simulation( modify_total_duration=modify_total_duration, scheme_type="contaminant_analysis", scheme_name=scheme_name, + result_db_name=name, + scheme_username=username, + scheme_detail=scheme_detail, ) # for i in range(1,operation_step): @@ -619,15 +602,6 @@ def contaminant_simulation( if is_project_open(new_name): close_project(new_name) delete_project(new_name) - # 存储方案信息到 PG 数据库 - store_scheme_info( - name=name, - scheme_name=scheme_name, - scheme_type="contaminant_analysis", - username=username, - scheme_start_time=modify_pattern_start_time, - scheme_detail=scheme_detail, - ) ############################################################ @@ -660,8 +634,6 @@ def age_analysis( datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S") + " -- Start Copying Database." ) - # CopyProjectEx()(name, new_name, - # ['operation', 'current_operation', 'restore_operation', 'batch_operation', 'operation_table']) copy_project(name + "_template", new_name) print( datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S") @@ -743,8 +715,6 @@ def pressure_regulation( datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S") + " -- Start Copying Database." ) - # CopyProjectEx()(name, new_name, - # ['operation', 'current_operation', 'restore_operation', 'batch_operation', 'operation_table']) copy_project(name + "_template", new_name) print( datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S") @@ -779,6 +749,7 @@ def pressure_regulation( modify_variable_pump_pattern=modify_variable_pump_pattern, scheme_type="pressure_regulation", scheme_name=scheme_name, + result_db_name=name, ) if is_project_open(new_name): close_project(new_name) diff --git a/app/algorithms/water_demand/__init__.py b/app/algorithms/water_demand/__init__.py new file mode 100644 index 0000000..0d7bf25 --- /dev/null +++ b/app/algorithms/water_demand/__init__.py @@ -0,0 +1,19 @@ +"""Water-demand distribution algorithms.""" + +from .service import ( + calculate_demand_to_network, + calculate_demand_to_nodes, + calculate_demand_to_region, + distribute_demand_to_nodes, + distribute_demand_to_region, + get_total_base_demand, +) + +__all__ = [ + "calculate_demand_to_network", + "calculate_demand_to_nodes", + "calculate_demand_to_region", + "distribute_demand_to_nodes", + "distribute_demand_to_region", + "get_total_base_demand", +] diff --git a/app/native/wndb/s36_wda_cal.py b/app/algorithms/water_demand/service.py similarity index 90% rename from app/native/wndb/s36_wda_cal.py rename to app/algorithms/water_demand/service.py index 4583993..f6c4521 100644 --- a/app/native/wndb/s36_wda_cal.py +++ b/app/algorithms/water_demand/service.py @@ -1,8 +1,8 @@ -from .database import ChangeSet -from .s0_base import is_junction, get_nodes -from .s9_demands import get_demand -from .s32_region_util import Topology, get_nodes_in_region -from .batch_exe import execute_batch_command +from app.native.wndb.commands.executor import execute_batch_command +from app.native.wndb.core.database import ChangeSet +from app.native.wndb.gis.region_geometry import Topology, get_nodes_in_region +from app.native.wndb.model.demands import get_demand +from app.native.wndb.model.elements import get_nodes, is_junction DISTRIBUTION_TYPE_ADD = 'ADD' @@ -101,4 +101,4 @@ def get_total_base_demand(name:str,region:str)->float: ds = get_demand(name, node)['demands'] t_demands= t_demands+ds[0]['demand'] - return t_demands \ No newline at end of file + return t_demands diff --git a/app/api/v1/endpoints/burst_detection.py b/app/api/v1/endpoints/burst_detection.py index d9a0d17..98163de 100644 --- a/app/api/v1/endpoints/burst_detection.py +++ b/app/api/v1/endpoints/burst_detection.py @@ -1,5 +1,6 @@ from datetime import datetime from typing import Any +from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Body from pydantic import BaseModel, Field @@ -43,8 +44,7 @@ class BurstDetectionRequest(BaseModel): sensor_nodes: list[str] | None = Field(None, description="传感器节点列表") scheme_name: str | None = Field(None, description="方案名称") data_source: str = Field("monitoring", description="数据来源:monitoring(监测)或simulation(模拟)") - simulation_scheme_name: str | None = Field(None, description="模拟方案名称") - simulation_scheme_type: str | None = Field(None, description="模拟方案类型") + simulation_run_id: UUID | None = Field(None, description="分析模拟运行 ID") @router.post( diff --git a/app/api/v1/endpoints/burst_location.py b/app/api/v1/endpoints/burst_location.py index e6f52d0..669acbb 100644 --- a/app/api/v1/endpoints/burst_location.py +++ b/app/api/v1/endpoints/burst_location.py @@ -2,6 +2,7 @@ from typing import Any from datetime import datetime from typing import Literal +from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Body from pydantic import BaseModel, Field @@ -32,9 +33,8 @@ class BurstLocationRequest(BaseModel): scada_normal_start: datetime | None = Field(None, description="监测数据正常工况开始时间") scada_normal_end: datetime | None = Field(None, description="监测数据正常工况结束时间") use_scada_flow: bool = Field(False, description="是否使用SCADA流量数据") - scheme_name: str | None = Field(None, description="方案名称") - simulation_scheme_name: str | None = Field(None, description="模拟方案名称") - simulation_scheme_type: str | None = Field(None, description="模拟方案类型") + scheme_name: str | None = Field(None, description="爆管定位运行名称") + simulation_run_id: UUID | None = Field(None, description="分析模拟运行 ID") @router.post( diff --git a/app/api/v1/endpoints/components/controls.py b/app/api/v1/endpoints/components/controls.py index d406f20..fd3b653 100644 --- a/app/api/v1/endpoints/components/controls.py +++ b/app/api/v1/endpoints/components/controls.py @@ -1,7 +1,6 @@ from fastapi import APIRouter, Request, Query, Path, Body from typing import Any, List, Dict, Union from app.services.tjnetwork import ( - Any, ChangeSet, get_control, get_control_schema, diff --git a/app/api/v1/endpoints/components/curves.py b/app/api/v1/endpoints/components/curves.py index c462dab..81358f6 100644 --- a/app/api/v1/endpoints/components/curves.py +++ b/app/api/v1/endpoints/components/curves.py @@ -1,7 +1,6 @@ from fastapi import APIRouter, Request, Query, Path, Body from typing import Any, List, Dict, Union from app.services.tjnetwork import ( - Any, ChangeSet, add_curve, delete_curve, diff --git a/app/api/v1/endpoints/components/options.py b/app/api/v1/endpoints/components/options.py index 21083ee..d89d422 100644 --- a/app/api/v1/endpoints/components/options.py +++ b/app/api/v1/endpoints/components/options.py @@ -1,7 +1,6 @@ from fastapi import APIRouter, Request, Query, Path, Body from typing import Any, List, Dict, Union from app.services.tjnetwork import ( - Any, ChangeSet, get_energy, get_energy_schema, diff --git a/app/api/v1/endpoints/components/patterns.py b/app/api/v1/endpoints/components/patterns.py index bb6daea..81edfd3 100644 --- a/app/api/v1/endpoints/components/patterns.py +++ b/app/api/v1/endpoints/components/patterns.py @@ -1,7 +1,6 @@ from fastapi import APIRouter, Request, Query, Path, Body from typing import Any, List, Dict, Union from app.services.tjnetwork import ( - Any, ChangeSet, add_pattern, delete_pattern, diff --git a/app/api/v1/endpoints/components/quality.py b/app/api/v1/endpoints/components/quality.py index db3c6ca..2f5e940 100644 --- a/app/api/v1/endpoints/components/quality.py +++ b/app/api/v1/endpoints/components/quality.py @@ -1,11 +1,9 @@ from fastapi import APIRouter, Request, Query, Path, Body from typing import Any, List, Dict, Union from app.services.tjnetwork import ( - Any, ChangeSet, add_mixing, add_source, - api, delete_mixing, delete_source, get_emitter, @@ -23,6 +21,7 @@ from app.services.tjnetwork import ( get_tank_reaction, get_tank_reaction_schema, set_emitter, + set_mixing, set_pipe_reaction, set_quality, set_reaction, @@ -270,7 +269,7 @@ async def fastapi_set_mixing( 更新指定水池的混合属性值。 """ props = await req.json() - return api.set_mixing(network, ChangeSet(props)) + return set_mixing(network, ChangeSet(props)) @router.post("/mixing-configurations", response_model=None, summary="添加混合", description="在网络中添加一个新的混合") async def fastapi_add_mixing( diff --git a/app/api/v1/endpoints/components/visuals.py b/app/api/v1/endpoints/components/visuals.py index 7764d86..571fac8 100644 --- a/app/api/v1/endpoints/components/visuals.py +++ b/app/api/v1/endpoints/components/visuals.py @@ -1,7 +1,6 @@ from fastapi import APIRouter, Request, Query, Path, Body, Response from typing import Any, List, Dict, Union from app.services.tjnetwork import ( - Any, ChangeSet, add_label, add_vertex, diff --git a/app/api/v1/endpoints/extension.py b/app/api/v1/endpoints/extension.py deleted file mode 100644 index d9ce025..0000000 --- a/app/api/v1/endpoints/extension.py +++ /dev/null @@ -1,104 +0,0 @@ -from typing import List, Any -from fastapi import APIRouter, Request, HTTPException, Query, Body -from app.services.tjnetwork import ( - ChangeSet, - get_all_extension_data_keys, - get_all_extension_data, - get_extension_data, - set_extension_data -) - -router = APIRouter() - -@router.get( - "/all-extension-data-keys", - summary="获取所有扩展数据键", - description="获取指定网络的所有扩展数据的键列表" -) -async def get_all_extension_data_keys_endpoint( - network: str = Query(..., description="管网名称(或数据库名称)") -) -> list[str]: - """ - 获取所有扩展数据键。 - - 返回指定网络中所有可用的扩展数据键。 - - Args: - network: 管网名称(或数据库名称) - - Returns: - 扩展数据键列表 - """ - return get_all_extension_data_keys(network) - -@router.get( - "/all-extension-datas", - summary="获取所有扩展数据", - description="获取指定网络的所有扩展数据" -) -async def get_all_extension_data_endpoint( - network: str = Query(..., description="管网名称(或数据库名称)") -) -> dict[str, Any]: - """ - 获取所有扩展数据。 - - 返回指定网络的所有扩展数据及其值。 - - Args: - network: 管网名称(或数据库名称) - - Returns: - 扩展数据字典 - """ - return get_all_extension_data(network) - -@router.get( - "/extension-datas", - summary="获取指定扩展数据", - description="获取指定网络中指定键的扩展数据值" -) -async def get_extension_data_endpoint( - network: str = Query(..., description="管网名称(或数据库名称)"), - key: str = Query(..., description="扩展数据键") -) -> str | None: - """ - 获取指定扩展数据。 - - 返回指定网络中指定键对应的扩展数据值。 - - Args: - network: 管网名称(或数据库名称) - key: 扩展数据键 - - Returns: - 扩展数据值,如果不存在返回None - """ - return get_extension_data(network, key) - -@router.patch( - "/extension-datas", - response_model=None, - summary="设置扩展数据", - description="设置指定网络中的扩展数据" -) -async def set_extension_data_endpoint( - network: str = Query(..., description="管网名称(或数据库名称)"), - req: Request = None -) -> ChangeSet: - """ - 设置扩展数据。 - - 在指定网络中设置扩展数据,并返回变更集信息。 - - Args: - network: 管网名称(或数据库名称) - req: 包含扩展数据的请求体 - - Returns: - 变更集信息 - """ - props = await req.json() - print(props) - cs = set_extension_data(network, ChangeSet(props)) - print(cs.operations[0]) - return cs diff --git a/app/api/v1/endpoints/misc.py b/app/api/v1/endpoints/misc.py deleted file mode 100644 index 255fe47..0000000 --- a/app/api/v1/endpoints/misc.py +++ /dev/null @@ -1,62 +0,0 @@ -from typing import Any -from fastapi import APIRouter, Query -from fastapi.responses import JSONResponse -from fastapi import status -from pydantic import BaseModel -from app.services.tjnetwork import ( - get_all_sensor_placements, - get_all_burst_locate_results, -) - -router = APIRouter() - - -async def fastapi_get_json(): - """ - 获取JSON示例 - - 返回示例JSON格式的响应 - """ - return JSONResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content={ - "code": 400, - "message": "this is message", - "data": 123, - }, - ) - - -@router.get("/sensor-placement-schemes", summary="获取所有传感器位置", description="获取网络中所有传感器的放置位置信息") -async def fastapi_get_all_sensor_placements(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[dict[Any, Any]]: - """ - 获取所有传感器位置 - - 返回网络中所有传感器的放置位置及其配置信息 - """ - return get_all_sensor_placements(network) - - -@router.get("/burst-locations", summary="获取所有爆管定位结果", description="获取网络中所有爆管定位的分析结果") -async def fastapi_get_all_burst_locate_results(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[dict[Any, Any]]: - """ - 获取所有爆管定位结果 - - 返回网络中所有的爆管定位分析结果 - """ - return get_all_burst_locate_results(network) - - -class Item(BaseModel): - """测试数据模型""" - str_info: str - - -async def fastapi_test_dict(data: Item) -> dict[str, str]: - """ - 测试字典处理 - - 接收Item模型,返回其字典格式 - """ - item = data.dict() - return item diff --git a/app/api/v1/endpoints/network/demands.py b/app/api/v1/endpoints/network/demands.py index ac63be1..16a169f 100644 --- a/app/api/v1/endpoints/network/demands.py +++ b/app/api/v1/endpoints/network/demands.py @@ -1,7 +1,6 @@ from fastapi import APIRouter, Request, Query, Path, Body from typing import Any, List, Dict, Union from app.services.tjnetwork import ( - Any, ChangeSet, calculate_demand_to_network, calculate_demand_to_nodes, diff --git a/app/api/v1/endpoints/network/general.py b/app/api/v1/endpoints/network/general.py index 0873800..c87cfed 100644 --- a/app/api/v1/endpoints/network/general.py +++ b/app/api/v1/endpoints/network/general.py @@ -1,7 +1,6 @@ from fastapi import APIRouter, Request, Query, Path, Body from typing import Any, List, Dict, Union from app.services.tjnetwork import ( - Any, ChangeSet, delete_junction, delete_pipe, diff --git a/app/api/v1/endpoints/network/junctions.py b/app/api/v1/endpoints/network/junctions.py index b317ae5..7a6c06c 100644 --- a/app/api/v1/endpoints/network/junctions.py +++ b/app/api/v1/endpoints/network/junctions.py @@ -1,7 +1,6 @@ from fastapi import APIRouter, Request, Query, Path, Body from typing import Any, List, Dict, Union from app.services.tjnetwork import ( - Any, ChangeSet, add_junction, delete_junction, diff --git a/app/api/v1/endpoints/network/pipes.py b/app/api/v1/endpoints/network/pipes.py index c149771..a500180 100644 --- a/app/api/v1/endpoints/network/pipes.py +++ b/app/api/v1/endpoints/network/pipes.py @@ -1,7 +1,6 @@ from fastapi import APIRouter, Request, Query, Path, Body from typing import Any, List, Dict, Union from app.services.tjnetwork import ( - Any, ChangeSet, PIPE_STATUS_OPEN, add_pipe, diff --git a/app/api/v1/endpoints/network/pumps.py b/app/api/v1/endpoints/network/pumps.py index 2ef9431..3e6a85a 100644 --- a/app/api/v1/endpoints/network/pumps.py +++ b/app/api/v1/endpoints/network/pumps.py @@ -1,7 +1,6 @@ from fastapi import APIRouter, Request, Query, Path, Body from typing import Any, List, Dict, Union from app.services.tjnetwork import ( - Any, ChangeSet, add_pump, delete_pump, diff --git a/app/api/v1/endpoints/network/regions.py b/app/api/v1/endpoints/network/regions.py index 833852b..52ef2ee 100644 --- a/app/api/v1/endpoints/network/regions.py +++ b/app/api/v1/endpoints/network/regions.py @@ -1,534 +1,73 @@ -from fastapi import APIRouter, Request, Query, Path, Body -from typing import Any, List, Dict, Union +from typing import Any + +from fastapi import APIRouter, Query, Request + from app.services.tjnetwork import ( - Any, ChangeSet, - add_district_metering_area, add_region, - add_service_area, - add_virtual_district, - calculate_district_metering_area_for_network, - calculate_district_metering_area_for_nodes, - calculate_district_metering_area_for_region, - calculate_service_area, - calculate_virtual_district, - delete_district_metering_area, delete_region, - delete_service_area, - delete_virtual_district, - generate_district_metering_area, - generate_service_area, - generate_sub_district_metering_area, - generate_virtual_district, - get_all_district_metering_area_ids, - get_all_district_metering_areas, - get_all_service_areas, - get_all_virtual_districts, - get_district_metering_area, - get_district_metering_area_schema, + get_nodes_in_region, get_region, get_region_schema, - get_service_area, - get_service_area_schema, - get_virtual_district, - get_virtual_district_schema, - set_district_metering_area, + get_regions, set_region, - set_service_area, - set_virtual_district, ) + router = APIRouter() -############################################################ -# region 32 -############################################################ -@router.get( - "/network-schemas/region", - summary="获取区域属性架构", - description="获取指定水网的区域属性架构定义" -) -async def fastapi_get_region_schema( - network: str = Query(..., description="管网名称(或数据库名称)") +@router.get("/network-schemas/region", summary="获取区域属性架构") +async def get_region_schema_endpoint( + network: str = Query(..., description="管网名称(或数据库名称)"), ) -> dict[str, dict[str, Any]]: - """获取区域的属性架构。""" return get_region_schema(network) -@router.get( - "/regions/detail", - summary="获取区域信息", - description="获取指定ID的区域详细信息" -) -async def fastapi_get_region( + +@router.get("/regions", summary="获取区域列表") +async def get_regions_endpoint( network: str = Query(..., description="管网名称(或数据库名称)"), - id: str = Query(..., description="区域ID") +) -> 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( + network: str = Query(..., description="管网名称(或数据库名称)"), + id: str = Query(..., description="区域 ID"), ) -> dict[str, Any]: - """获取区域的详细信息。""" return get_region(network, id) -@router.patch( - "/regions", - response_model=None, - summary="设置区域属性", - description="修改指定区域的属性信息" -) -async def fastapi_set_region( + +@router.get("/regions/nodes", summary="获取区域节点") +async def get_region_nodes_endpoint( network: str = Query(..., description="管网名称(或数据库名称)"), - req: Request = None -) -> ChangeSet: - """设置区域属性。""" - props = await req.json() - return set_region(network, ChangeSet(props)) - -@router.post( - "/regions", - response_model=None, - summary="添加新区域", - description="向水网添加一个新的区域" -) -async def fastapi_add_region( - network: str = Query(..., description="管网名称(或数据库名称)"), - req: Request = None -) -> ChangeSet: - """添加新的区域。""" - props = await req.json() - return add_region(network, ChangeSet(props)) - -@router.delete( - "/regions", - response_model=None, - summary="删除区域", - description="删除指定的区域" -) -async def fastapi_delete_region( - network: str = Query(..., description="管网名称(或数据库名称)"), - req: Request = None -) -> ChangeSet: - """删除区域。""" - props = await req.json() - return delete_region(network, ChangeSet(props)) - - -############################################################ -# district_metering_area 33 -############################################################ - -@router.post( - "/district-metering-areas/for-region", - summary="计算区域内DMA分区", - description="为指定区域计算区域计量(DMA)分区方案" -) -async def fastapi_calculate_district_metering_area_for_region( - network: str = Query(..., description="管网名称(或数据库名称)"), - req: Request = None -) -> list[list[str]]: - """ - 计算区域内DMA分区。 - - 请求体格式: - { - "region": 区域ID(str), - "part_count": 分区数量(int), - "part_type": 分区类型(int) - } - """ - props = await req.json() - region = props["region"] - part_count = props["part_count"] - part_type = props["part_type"] - return calculate_district_metering_area_for_region( - network, region, part_count, part_type - ) - -@router.post( - "/district-metering-areas/for-network", - summary="计算整网DMA分区", - description="为整个水网计算区域计量(DMA)分区方案" -) -async def fastapi_calculate_district_metering_area_for_network( - network: str = Query(..., description="管网名称(或数据库名称)"), - req: Request = None -) -> list[list[str]]: - """ - 计算整网DMA分区。 - - 请求体格式: - { - "part_count": 分区数量(int), - "part_type": 分区类型(int) - } - """ - props = await req.json() - part_count = props["part_count"] - part_type = props["part_type"] - return calculate_district_metering_area_for_network(network, part_count, part_type) - -@router.get( - "/network-schemas/district-metering-area", - summary="获取DMA属性架构", - description="获取指定水网的区域计量(DMA)属性架构定义" -) -async def fastapi_get_district_metering_area_schema( - network: str = Query(..., description="管网名称(或数据库名称)"), -) -> dict[str, dict[str, Any]]: - """获取DMA的属性架构。""" - return get_district_metering_area_schema(network) - -@router.get( - "/district-metering-areas/detail", - summary="获取DMA信息", - description="获取指定ID的区域计量(DMA)详细信息" -) -async def fastapi_get_district_metering_area( - network: str = Query(..., description="管网名称(或数据库名称)"), - id: str = Query(..., description="DMA ID") -) -> dict[str, Any]: - """获取DMA的详细信息。""" - return get_district_metering_area(network, id) - -@router.patch( - "/district-metering-areas", - response_model=None, - summary="设置DMA属性", - description="修改指定DMA的属性信息" -) -async def fastapi_set_district_metering_area( - network: str = Query(..., description="管网名称(或数据库名称)"), - req: Request = None -) -> ChangeSet: - """设置DMA属性。""" - props = await req.json() - return set_district_metering_area(network, ChangeSet(props)) - -@router.post( - "/district-metering-areas", - response_model=None, - summary="添加新DMA", - description="向水网添加一个新的区域计量(DMA)" -) -async def fastapi_add_district_metering_area( - network: str = Query(..., description="管网名称(或数据库名称)"), - req: Request = None -) -> ChangeSet: - """添加新的DMA。""" - props = await req.json() - # boundary should be [(x,y), (x,y)] - boundary = props.get("boundary", []) - newBoundary = [] - for pt in boundary: - if len(pt) >= 2: - newBoundary.append((pt[0], pt[1])) - props["boundary"] = newBoundary - return add_district_metering_area(network, ChangeSet(props)) - -@router.delete( - "/district-metering-areas", - response_model=None, - summary="删除DMA", - description="删除指定的区域计量(DMA)" -) -async def fastapi_delete_district_metering_area( - network: str = Query(..., description="管网名称(或数据库名称)"), - req: Request = None -) -> ChangeSet: - """删除DMA。""" - props = await req.json() - return delete_district_metering_area(network, ChangeSet(props)) - -@router.get( - "/district-metering-areas/ids", - summary="获取所有DMA ID", - description="获取指定水网中所有DMA的ID列表" -) -async def fastapi_get_all_district_metering_area_ids( - network: str = Query(..., description="管网名称(或数据库名称)") + id: str = Query(..., description="区域 ID"), ) -> list[str]: - """获取所有DMA的ID列表。""" - return get_all_district_metering_area_ids(network) + return get_nodes_in_region(network, id) -@router.get( - "/district-metering-areas", - summary="获取所有DMA", - description="获取指定水网中所有DMA的详细信息" -) -async def getalldistrictmeteringareas( - network: str = Query(..., description="管网名称(或数据库名称)") -) -> list[dict[str, Any]]: - """获取所有DMA的详细信息列表。""" - return get_all_district_metering_areas(network) -@router.post( - "/district-metering-area-generation-runs", - response_model=None, - summary="生成DMA分区", - description="根据参数自动生成水网的DMA分区方案" -) -async def fastapi_generate_district_metering_area( +@router.patch("/regions", summary="修改区域", response_model=None) +async def set_region_endpoint( network: str = Query(..., description="管网名称(或数据库名称)"), - part_count: int = Query(..., description="分区数量", gt=0), - part_type: int = Query(..., description="分区类型"), - inflate_delta: float = Query(..., description="膨胀参数") + request: Request = None, ) -> ChangeSet: - """生成DMA分区。""" - return generate_district_metering_area( - network, part_count, part_type, inflate_delta - ) + return set_region(network, ChangeSet(await request.json())) -@router.post( - "/sub-district-metering-areas", - response_model=None, - summary="生成DMA子分区", - description="为指定DMA生成子DMA分区" -) -async def fastapi_generate_sub_district_metering_area( + +@router.post("/regions", summary="添加区域", response_model=None) +async def add_region_endpoint( network: str = Query(..., description="管网名称(或数据库名称)"), - dma: str = Query(..., description="DMA ID"), - part_count: int = Query(..., description="分区数量", gt=0), - part_type: int = Query(..., description="分区类型"), - inflate_delta: float = Query(..., description="膨胀参数") + request: Request = None, ) -> ChangeSet: - """生成DMA子分区。""" - return generate_sub_district_metering_area( - network, dma, part_count, part_type, inflate_delta - ) + payload = await request.json() + payload["boundary"] = [tuple(point[:2]) for point in payload.get("boundary", [])] + return add_region(network, ChangeSet(payload)) -############################################################ -# service_area 34 -############################################################ - -@router.post( - "/service-area-calculations", - summary="计算服务区", - description="计算指定水网的服务区分区,返回全部时间步结果" -) -async def fastapi_calculate_service_area( +@router.delete("/regions", summary="删除区域", response_model=None) +async def delete_region_endpoint( network: str = Query(..., description="管网名称(或数据库名称)"), -) -> list[dict[str, list[str]]]: - """计算服务区分区,返回全部时间步结果。""" - return calculate_service_area(network) - -@router.get( - "/network-schemas/service-area", - summary="获取服务区属性架构", - description="获取指定水网的服务区属性架构定义" -) -async def fastapi_get_service_area_schema( - network: str = Query(..., description="管网名称(或数据库名称)") -) -> dict[str, dict[str, Any]]: - """获取服务区的属性架构。""" - return get_service_area_schema(network) - -@router.get( - "/service-areas/detail", - summary="获取服务区信息", - description="获取指定ID的服务区详细信息" -) -async def fastapi_get_service_area( - network: str = Query(..., description="管网名称(或数据库名称)"), - id: str = Query(..., description="服务区ID") -) -> dict[str, Any]: - """获取服务区的详细信息。""" - return get_service_area(network, id) - -@router.patch( - "/service-areas", - response_model=None, - summary="设置服务区属性", - description="修改指定服务区的属性信息" -) -async def fastapi_set_service_area( - network: str = Query(..., description="管网名称(或数据库名称)"), - req: Request = None + request: Request = None, ) -> ChangeSet: - """设置服务区属性。""" - props = await req.json() - return set_service_area(network, ChangeSet(props)) - -@router.post( - "/service-areas", - response_model=None, - summary="添加新服务区", - description="向水网添加一个新的服务区" -) -async def fastapi_add_service_area( - network: str = Query(..., description="管网名称(或数据库名称)"), - req: Request = None -) -> ChangeSet: - """添加新的服务区。""" - props = await req.json() - return add_service_area(network, ChangeSet(props)) - -@router.delete( - "/service-areas", - response_model=None, - summary="删除服务区", - description="删除指定的服务区" -) -async def fastapi_delete_service_area( - network: str = Query(..., description="管网名称(或数据库名称)"), - req: Request = None -) -> ChangeSet: - """删除服务区。""" - props = await req.json() - return delete_service_area(network, ChangeSet(props)) - -@router.get( - "/service-areas", - summary="获取所有服务区", - description="获取指定水网中的所有服务区信息" -) -async def fastapi_get_all_service_areas( - network: str = Query(..., description="管网名称(或数据库名称)") -) -> list[dict[str, Any]]: - """获取所有服务区的信息列表。""" - return get_all_service_areas(network) - -@router.post( - "/service-area-generation-runs", - response_model=None, - summary="生成服务区分区", - description="根据参数自动生成水网的服务区分区" -) -async def fastapi_generate_service_area( - network: str = Query(..., description="管网名称(或数据库名称)"), - inflate_delta: float = Query(..., description="膨胀参数") -) -> ChangeSet: - """生成服务区分区。""" - return generate_service_area(network, inflate_delta) - - -############################################################ -# virtual_district 35 -############################################################ - -@router.post( - "/virtual-district-calculations", - summary="计算虚拟分区", - description="根据指定的压力监测节点作为中心节点计算虚拟分区方案" -) -async def fastapi_calculate_virtual_district( - network: str = Query(..., description="管网名称(或数据库名称)"), - centers: list[str] = Query(..., description="压力监测节点ID列表") -) -> dict[str, list[Any]]: - """计算虚拟分区。""" - return calculate_virtual_district(network, centers) - -@router.get( - "/network-schemas/virtual-district", - summary="获取虚拟分区属性架构", - description="获取指定水网的虚拟分区属性架构定义" -) -async def fastapi_get_virtual_district_schema( - network: str = Query(..., description="管网名称(或数据库名称)"), -) -> dict[str, dict[str, Any]]: - """获取虚拟分区的属性架构。""" - return get_virtual_district_schema(network) - -@router.get( - "/virtual-districts/detail", - summary="获取虚拟分区信息", - description="获取指定ID的虚拟分区详细信息" -) -async def fastapi_get_virtual_district( - network: str = Query(..., description="管网名称(或数据库名称)"), - id: str = Query(..., description="虚拟分区ID") -) -> dict[str, Any]: - """获取虚拟分区的详细信息。""" - return get_virtual_district(network, id) - -@router.patch( - "/virtual-districts", - response_model=None, - summary="设置虚拟分区属性", - description="修改指定虚拟分区的属性信息" -) -async def fastapi_set_virtual_district( - network: str = Query(..., description="管网名称(或数据库名称)"), - req: Request = None -) -> ChangeSet: - """设置虚拟分区属性。""" - props = await req.json() - return set_virtual_district(network, ChangeSet(props)) - -@router.post( - "/virtual-districts", - response_model=None, - summary="添加新虚拟分区", - description="向水网添加一个新的虚拟分区" -) -async def fastapi_add_virtual_district( - network: str = Query(..., description="管网名称(或数据库名称)"), - req: Request = None -) -> ChangeSet: - """添加新的虚拟分区。""" - props = await req.json() - return add_virtual_district(network, ChangeSet(props)) - -@router.delete( - "/virtual-districts", - response_model=None, - summary="删除虚拟分区", - description="删除指定的虚拟分区" -) -async def fastapi_delete_virtual_district( - network: str = Query(..., description="管网名称(或数据库名称)"), - req: Request = None -) -> ChangeSet: - """删除虚拟分区。""" - props = await req.json() - return delete_virtual_district(network, ChangeSet(props)) - -@router.get( - "/virtual-districts", - summary="获取所有虚拟分区", - description="获取指定水网中的所有虚拟分区信息" -) -async def fastapi_get_all_virtual_district( - network: str = Query(..., description="管网名称(或数据库名称)") -) -> list[dict[str, Any]]: - """获取所有虚拟分区的信息列表。""" - return get_all_virtual_districts(network) - -@router.post( - "/virtual-district-generation-runs", - response_model=None, - summary="生成虚拟分区", - description="根据参数自动生成虚拟分区方案" -) -async def fastapi_generate_virtual_district( - network: str = Query(..., description="管网名称(或数据库名称)"), - inflate_delta: float = Query(..., description="膨胀参数"), - req: Request = None -) -> ChangeSet: - """生成虚拟分区。""" - props = await req.json() - return generate_virtual_district(network, props["centers"], inflate_delta) - -@router.post( - "/district-metering-areas/for-nodes", - summary="计算节点DMA分区", - description="为指定节点集计算区域计量(DMA)分区方案" -) -async def fastapi_calculate_district_metering_area_for_nodes( - network: str = Query(..., description="管网名称(或数据库名称)"), - req: Request = None -) -> list[list[str]]: - """ - 计算节点DMA分区。 - - 请求体格式: - { - "nodes": 节点ID列表(list[str]), - "part_count": 分区数量(int), - "part_type": 分区类型(int) - } - """ - props = await req.json() - nodes = props["nodes"] - part_count = props["part_count"] - part_type = props["part_type"] - return calculate_district_metering_area_for_nodes( - network, nodes, part_count, part_type - ) + return delete_region(network, ChangeSet(await request.json())) diff --git a/app/api/v1/endpoints/network/reservoirs.py b/app/api/v1/endpoints/network/reservoirs.py index c2e29c0..2b1d018 100644 --- a/app/api/v1/endpoints/network/reservoirs.py +++ b/app/api/v1/endpoints/network/reservoirs.py @@ -1,7 +1,6 @@ from fastapi import APIRouter, Request, Query, Path, Body from typing import Any, List, Dict, Union from app.services.tjnetwork import ( - Any, ChangeSet, add_reservoir, delete_reservoir, diff --git a/app/api/v1/endpoints/network/tags.py b/app/api/v1/endpoints/network/tags.py index 6a0964e..83be913 100644 --- a/app/api/v1/endpoints/network/tags.py +++ b/app/api/v1/endpoints/network/tags.py @@ -1,7 +1,6 @@ from fastapi import APIRouter, Request, Query, Path, Body from typing import Any, List, Dict, Union from app.services.tjnetwork import ( - Any, ChangeSet, get_tag, get_tag_schema, diff --git a/app/api/v1/endpoints/network/tanks.py b/app/api/v1/endpoints/network/tanks.py index 1d5e84d..bc08fc6 100644 --- a/app/api/v1/endpoints/network/tanks.py +++ b/app/api/v1/endpoints/network/tanks.py @@ -1,7 +1,6 @@ from fastapi import APIRouter, Request, Query, Path, Body from typing import Any, List, Dict, Union from app.services.tjnetwork import ( - Any, ChangeSet, add_tank, delete_tank, diff --git a/app/api/v1/endpoints/network/valves.py b/app/api/v1/endpoints/network/valves.py index d9244e8..550b46a 100644 --- a/app/api/v1/endpoints/network/valves.py +++ b/app/api/v1/endpoints/network/valves.py @@ -1,7 +1,6 @@ from fastapi import APIRouter, Request, Query, Path, Body from typing import Any, List, Dict, Union from app.services.tjnetwork import ( - Any, ChangeSet, VALVES_TYPE_PRV, add_valve, diff --git a/app/api/v1/endpoints/project.py b/app/api/v1/endpoints/project.py index aeeebfb..4e5ce40 100644 --- a/app/api/v1/endpoints/project.py +++ b/app/api/v1/endpoints/project.py @@ -10,8 +10,6 @@ from app.auth.permissions import ( ) from app.domain.schemas.metadata import ProjectMetaResponse import app.services.project_info as project_info -from app.infra.db.postgresql.database import get_database_instance as get_pg_db -from app.infra.db.timescaledb.database import get_database_instance as get_ts_db from app.services.tjnetwork import ( ChangeSet, list_project, @@ -26,11 +24,7 @@ from app.services.tjnetwork import ( read_inp, dump_inp, get_all_vertices, - get_all_scada_elements, - get_all_district_metering_areas, - get_all_service_areas, - get_all_virtual_districts, - get_extension_data, + get_all_scada_info, convert_inp_v3_to_v2, ) @@ -137,27 +131,6 @@ async def open_project_endpoint( """ open_project(network) - # 尝试连接指定数据库 - try: - # 初始化 PostgreSQL 连接池 - pg_instance = await get_pg_db(network) - async with pg_instance.get_connection() as conn: - async with conn.cursor() as cur: - await cur.execute("SELECT 1") - - # 初始化 TimescaleDB 连接池 - ts_instance = await get_ts_db(network) - async with ts_instance.get_connection() as conn: - async with conn.cursor() as cur: - await cur.execute("SELECT 1") - - except Exception as e: - # 记录错误但不阻断项目打开,或者根据需求决定是否阻断 - # 这里选择打印错误,因为 open_project 原本只负责原生部分 - print(f"Failed to connect to databases for {network}: {str(e)}") - # 如果数据库连接是必须的,可以抛出异常: - # raise HTTPException(status_code=500, detail=f"Database connection failed: {str(e)}") - return network @router.delete("/projects/current", summary="关闭项目", description="将指定项目从内存中卸载,释放资源。") @@ -202,24 +175,11 @@ async def export_inp_endpoint( op = cs.operations[0] open_project(network) op["vertex"] = json.dumps(get_all_vertices(network)) - op["scada"] = json.dumps(get_all_scada_elements(network)) - op["dma"] = json.dumps(get_all_district_metering_areas(network)) - op["sa"] = json.dumps(get_all_service_areas(network)) - op["vd"] = json.dumps(get_all_virtual_districts(network)) - op["legend"] = get_extension_data(network, "legend") - - db = get_extension_data(network, "scada_db") - print(db) - scada_db = "" - if db: - scada_db = db - print(scada_db) - op["scada_db"] = scada_db + 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="管网名称(或数据库名称)"), @@ -353,19 +313,7 @@ async def fastapi_convert_v3_to_v2( op = cs.operations[0] open_project(network) op["vertex"] = json.dumps(get_all_vertices(network)) - op["scada"] = json.dumps(get_all_scada_elements(network)) - op["dma"] = json.dumps(get_all_district_metering_areas(network)) - op["sa"] = json.dumps(get_all_service_areas(network)) - op["vd"] = json.dumps(get_all_virtual_districts(network)) - op["legend"] = get_extension_data(network, "legend") - - db = get_extension_data(network, "scada_db") - print(db) - scada_db = "" - if db: - scada_db = db - print(scada_db) - op["scada_db"] = scada_db + op["scada"] = json.dumps(get_all_scada_info(network)) close_project(network) @@ -496,19 +444,7 @@ async def fastapi_convert_v3_to_v2( op = cs.operations[0] open_project(network) op["vertex"] = json.dumps(get_all_vertices(network)) - op["scada"] = json.dumps(get_all_scada_elements(network)) - op["dma"] = json.dumps(get_all_district_metering_areas(network)) - op["sa"] = json.dumps(get_all_service_areas(network)) - op["vd"] = json.dumps(get_all_virtual_districts(network)) - op["legend"] = get_extension_data(network, "legend") - - db = get_extension_data(network, "scada_db") - print(db) - scada_db = "" - if db: - scada_db = db - print(scada_db) - op["scada_db"] = scada_db + op["scada"] = json.dumps(get_all_scada_info(network)) close_project(network) diff --git a/app/api/v1/endpoints/project_data.py b/app/api/v1/endpoints/project_data.py index dcf333a..d4706d3 100644 --- a/app/api/v1/endpoints/project_data.py +++ b/app/api/v1/endpoints/project_data.py @@ -1,8 +1,10 @@ -from fastapi import APIRouter, Depends, HTTPException, Path, Query +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query from psycopg import AsyncConnection from app.infra.db.postgresql.scada import ScadaInfoRepository -from app.infra.db.postgresql.scheme import SchemeRepository +from app.infra.db.postgresql.analysis import AnalysisRepository from app.auth.project_dependencies import get_project_pg_connection router = APIRouter() @@ -33,8 +35,8 @@ async def get_scada_info_with_connection( ) -@router.get("/schemes/list-with-connection", summary="获取方案列表", description="使用连接池查询所有方案信息") -async def get_scheme_list_with_connection( +@router.get("/analysis/runs", summary="获取分析运行列表") +async def get_analysis_runs( conn: AsyncConnection = Depends(get_database_connection), ): """ @@ -43,14 +45,15 @@ async def get_scheme_list_with_connection( 返回项目中所有方案的详细信息 """ try: - scheme_data = await SchemeRepository.get_schemes(conn) - return {"success": True, "data": scheme_data, "count": len(scheme_data)} + runs = await AnalysisRepository.list_runs(conn) + return {"success": True, "data": runs, "count": len(runs)} except Exception as e: - raise HTTPException(status_code=500, detail=f"查询方案信息时发生错误: {str(e)}") + raise HTTPException(status_code=500, detail=f"查询分析运行时发生错误: {str(e)}") -@router.get("/burst-locations/database-view", summary="获取爆管定位结果", description="使用连接池查询所有爆管定位结果") -async def get_burst_locate_result_with_connection( +@router.get("/analysis/runs/{run_id}", summary="获取分析运行") +async def get_analysis_run( + run_id: UUID, conn: AsyncConnection = Depends(get_database_connection), ): """ @@ -59,17 +62,22 @@ async def get_burst_locate_result_with_connection( 返回项目中所有的爆管定位分析结果 """ try: - burst_data = await SchemeRepository.get_burst_locate_results(conn) - return {"success": True, "data": burst_data, "count": len(burst_data)} + run = await AnalysisRepository.get_run(conn, run_id) + if run is None: + raise HTTPException(status_code=404, detail="分析运行不存在") + return run + except HTTPException: + raise except Exception as e: raise HTTPException( - status_code=500, detail=f"查询爆管定位结果时发生错误: {str(e)}" + status_code=500, detail=f"查询分析运行时发生错误: {str(e)}" ) -@router.get("/burst-locations/{burst_incident}", summary="按事件查询爆管定位结果", description="根据爆管事件ID查询对应的爆管定位结果") -async def get_burst_locate_result_by_incident( - burst_incident: str = Path(..., description="爆管事件ID"), +@router.get("/analysis/runs/{run_id}/results", summary="获取分析结果") +async def get_analysis_results( + run_id: UUID, + result_type: str | None = Query(default=None, description="结果类型"), conn: AsyncConnection = Depends(get_database_connection), ): """ @@ -79,11 +87,9 @@ async def get_burst_locate_result_by_incident( burst_incident: 爆管事件的唯一标识符 """ try: - return await SchemeRepository.get_burst_locate_result_by_incident( - conn, burst_incident - ) + return await AnalysisRepository.list_results(conn, run_id, result_type) except Exception as e: raise HTTPException( status_code=500, - detail=f"根据 burst_incident 查询爆管定位结果时发生错误: {str(e)}", + detail=f"查询分析结果时发生错误: {str(e)}", ) diff --git a/app/api/v1/endpoints/risk.py b/app/api/v1/endpoints/risk.py deleted file mode 100644 index 58a2b02..0000000 --- a/app/api/v1/endpoints/risk.py +++ /dev/null @@ -1,127 +0,0 @@ -from typing import Any, List, Dict -from fastapi import APIRouter, Query, Path -from app.services.tjnetwork import ( - get_pipe_risk_probability_now, - get_pipe_risk_probability, - get_pipes_risk_probability, - get_network_pipe_risk_probability_now, - get_pipe_risk_probability_geometries, -) - -router = APIRouter() - -@router.get( - "/pipes/risk-probability-now", - summary="获取管道当前风险概率", - description="获取指定管道当前时刻的风险概率值" -) -async def fastapi_get_pipe_risk_probability_now( - network: str = Query(..., description="管网名称(或数据库名称)"), - pipe_id: str = Query(..., description="管道ID") -) -> dict[str, Any]: - """ - 获取管道当前风险概率。 - - 查询指定管道在当前时刻的风险概率值。 - - Args: - network: 管网名称(或数据库名称) - pipe_id: 管道ID - - Returns: - 包含风险概率信息的字典 - """ - return get_pipe_risk_probability_now(network, pipe_id) - - -@router.get( - "/pipes/risk-probability", - summary="获取管道风险概率历史", - description="获取指定管道的风险概率历史数据" -) -async def fastapi_get_pipe_risk_probability( - network: str = Query(..., description="管网名称(或数据库名称)"), - pipe_id: str = Query(..., description="管道ID") -) -> dict[str, Any]: - """ - 获取管道风险概率历史。 - - 查询指定管道的历史风险概率数据。 - - Args: - network: 管网名称(或数据库名称) - pipe_id: 管道ID - - Returns: - 包含风险概率历史的字典 - """ - return get_pipe_risk_probability(network, pipe_id) - - -@router.get( - "/pipes-risk-probabilities", - summary="批量获取多条管道风险概率", - description="批量获取多条管道的风险概率值" -) -async def fastapi_get_pipes_risk_probability( - network: str = Query(..., description="管网名称(或数据库名称)"), - pipe_ids: str = Query(..., description="逗号分隔的管道ID列表") -) -> list[dict[str, Any]]: - """ - 批量获取多条管道风险概率。 - - 查询多条指定管道的风险概率值。 - - Args: - network: 管网名称(或数据库名称) - pipe_ids: 逗号分隔的管道ID列表(例如:pipe1,pipe2,pipe3) - - Returns: - 包含多条管道风险概率的列表 - """ - pipeids = pipe_ids.split(",") - return get_pipes_risk_probability(network, pipeids) - - -@router.get( - "/network-pipe-risk-probability-nows", - summary="获取整个网络的管道风险概率", - description="获取指定网络中所有管道的当前风险概率值" -) -async def fastapi_get_network_pipe_risk_probability_now( - network: str = Query(..., description="管网名称(或数据库名称)"), -) -> list[dict[str, Any]]: - """ - 获取整个网络的管道风险概率。 - - 查询指定网络中所有管道在当前时刻的风险概率值。 - - Args: - network: 管网名称(或数据库名称) - - Returns: - 包含网络内所有管道风险概率的列表 - """ - return get_network_pipe_risk_probability_now(network) - - -@router.get( - "/pipes/risk-probability-geometries", - summary="获取管道风险几何信息", - description="获取指定网络中管道的风险相关几何数据" -) -async def fastapi_get_pipe_risk_probability_geometries( - network: str = Query(..., description="管网名称(或数据库名称)") -) -> dict[str, Any]: - """ - 获取管道风险几何信息。 - - 查询指定网络中管道的地理和风险相关的几何数据。 - - Args: - network: 管网名称(或数据库名称) - - Returns: - 包含几何信息和风险数据的字典 - """ - return get_pipe_risk_probability_geometries(network) diff --git a/app/api/v1/endpoints/scada.py b/app/api/v1/endpoints/scada.py index 48f822c..4c2f3db 100644 --- a/app/api/v1/endpoints/scada.py +++ b/app/api/v1/endpoints/scada.py @@ -1,525 +1,34 @@ from typing import Any -from fastapi import APIRouter, Request, Query + +from fastapi import APIRouter, Query + from app.services.tjnetwork import ( - ChangeSet, - get_scada_info, get_all_scada_info, - get_scada_device_schema, - get_scada_device, - set_scada_device, - add_scada_device, - delete_scada_device, - clean_scada_device, - get_all_scada_device_ids, - get_all_scada_devices, - get_scada_device_data_schema, - get_scada_device_data, - set_scada_device_data, - add_scada_device_data, - delete_scada_device_data, - clean_scada_device_data, - get_scada_element_schema, - get_scada_element, - set_scada_element, - add_scada_element, - delete_scada_element, - clean_scada_element, - get_all_scada_elements, - get_scada_element_schema, + get_scada_info, get_scada_info_schema, ) + router = APIRouter() -async def fast_get_scada_properties( - network: str = Query(..., description="管网名称(或数据库名称)"), - scada: str = Query(..., description="SCADA设备ID") -) -> dict[str, Any]: - """ - 获取单个SCADA设备的属性信息 - - 根据管网名称和SCADA设备ID获取该设备的完整属性。 - - Args: - network: 管网名称(或数据库名称) - scada: SCADA设备ID - - Returns: - SCADA设备的属性字典 - """ - return get_scada_info(network, scada) -async def fast_get_all_scada_properties( - network: str = Query(..., description="管网名称(或数据库名称)") -) -> list[dict[str, Any]]: - """ - 获取指定管网所有SCADA设备的属性信息 - - 查询该管网下所有已配置的SCADA设备的属性列表。 - - Args: - network: 管网名称(或数据库名称) - - Returns: - SCADA设备属性列表 - """ - return get_all_scada_info(network) - - -############################################################ -# scada_device 设备管理 -############################################################ - -@router.get("/network-schemas/scada-device", summary="获取SCADA设备架构", tags=["SCADA设备"]) -async def fastapi_get_scada_device_schema( - network: str = Query(..., description="管网名称(或数据库名称)") -) -> dict[str, dict[str, Any]]: - """ - 获取SCADA设备的数据架构 - - 返回SCADA设备表的字段定义和类型信息。 - - Args: - network: 管网名称(或数据库名称) - - Returns: - SCADA设备的字段架构信息 - """ - return get_scada_device_schema(network) - -@router.get("/scada-devices/detail", summary="获取SCADA设备", tags=["SCADA设备"]) -async def fastapi_get_scada_device( - network: str = Query(..., description="管网名称(或数据库名称)"), - id: str = Query(..., description="SCADA设备ID") -) -> dict[str, Any]: - """ - 获取单个SCADA设备的信息 - - 根据设备ID查询该设备的详细信息。 - - Args: - network: 管网名称(或数据库名称) - id: SCADA设备ID - - Returns: - SCADA设备信息 - """ - return get_scada_device(network, id) - -@router.patch("/scada-devices", response_model=None, summary="更新SCADA设备", tags=["SCADA设备"]) -async def fastapi_set_scada_device( - network: str = Query(..., description="管网名称(或数据库名称)"), - req: Request = None -) -> ChangeSet: - """ - 更新SCADA设备信息 - - 修改指定SCADA设备的属性。 - - Args: - network: 管网名称(或数据库名称) - req: 请求体,包含要更新的设备属性 - - Returns: - 变更集合信息 - """ - props = await req.json() - return set_scada_device(network, ChangeSet(props)) - -@router.post("/scada-devices", response_model=None, summary="添加SCADA设备", tags=["SCADA设备"]) -async def fastapi_add_scada_device( - network: str = Query(..., description="管网名称(或数据库名称)"), - req: Request = None -) -> ChangeSet: - """ - 添加新的SCADA设备 - - 在指定管网中添加一个新的SCADA设备。 - - Args: - network: 管网名称(或数据库名称) - req: 请求体,包含新设备的属性 - - Returns: - 变更集合信息 - """ - props = await req.json() - return add_scada_device(network, ChangeSet(props)) - -@router.delete("/scada-devices", response_model=None, summary="删除SCADA设备", tags=["SCADA设备"]) -async def fastapi_delete_scada_device( - network: str = Query(..., description="管网名称(或数据库名称)"), - req: Request = None -) -> ChangeSet: - """ - 删除SCADA设备 - - 从指定管网中删除一个SCADA设备。 - - Args: - network: 管网名称(或数据库名称) - req: 请求体,包含要删除的设备ID - - Returns: - 变更集合信息 - """ - props = await req.json() - return delete_scada_device(network, ChangeSet(props)) - -@router.post("/scada-device-cleaning-runs", response_model=None, summary="清空SCADA设备表", tags=["SCADA设备"]) -async def fastapi_clean_scada_device( - network: str = Query(..., description="管网名称(或数据库名称)") -) -> ChangeSet: - """ - 清空SCADA设备表 - - 删除指定管网中所有的SCADA设备。 - - Args: - network: 管网名称(或数据库名称) - - Returns: - 变更集合信息 - """ - return clean_scada_device(network) - -@router.get("/scada-devices/ids", summary="获取所有SCADA设备ID", tags=["SCADA设备"]) -async def fastapi_get_all_scada_device_ids( - network: str = Query(..., description="管网名称(或数据库名称)") -) -> list[str]: - """ - 获取指定管网所有SCADA设备的ID列表 - - Args: - network: 管网名称(或数据库名称) - - Returns: - SCADA设备ID列表 - """ - return get_all_scada_device_ids(network) - -@router.get("/scada-devices", summary="获取所有SCADA设备", tags=["SCADA设备"]) -async def fastapi_get_all_scada_devices( - network: str = Query(..., description="管网名称(或数据库名称)") -) -> list[dict[str, Any]]: - """ - 获取指定管网所有SCADA设备的完整信息 - - Args: - network: 管网名称(或数据库名称) - - Returns: - SCADA设备信息列表 - """ - return get_all_scada_devices(network) - - -############################################################ -# scada_device_data 设备数据管理 -############################################################ - -@router.get("/network-schemas/scada-device-data", summary="获取SCADA设备数据架构", tags=["SCADA设备数据"]) -async def fastapi_get_scada_device_data_schema( +@router.get("/network-schemas/scada-device", summary="获取 SCADA 设备结构") +async def get_scada_device_schema( network: str = Query(..., description="管网名称(或数据库名称)"), ) -> dict[str, dict[str, Any]]: - """ - 获取SCADA设备数据的表结构 - - 返回SCADA设备数据表的字段定义和类型信息。 - - Args: - network: 管网名称(或数据库名称) - - Returns: - SCADA设备数据的字段架构信息 - """ - return get_scada_device_data_schema(network) - -@router.get("/scada-device-datas/detail", summary="获取SCADA设备数据", tags=["SCADA设备数据"]) -async def fastapi_get_scada_device_data( - network: str = Query(..., description="管网名称(或数据库名称)"), - device_id: str = Query(..., description="SCADA设备ID") -) -> dict[str, Any]: - """ - 获取单个SCADA设备的数据 - - 查询指定设备的监测数据或配置数据。 - - Args: - network: 管网名称(或数据库名称) - device_id: SCADA设备ID - - Returns: - SCADA设备数据 - """ - return get_scada_device_data(network, device_id) - -@router.patch("/scada-device-datas", response_model=None, summary="更新SCADA设备数据", tags=["SCADA设备数据"]) -async def fastapi_set_scada_device_data( - network: str = Query(..., description="管网名称(或数据库名称)"), - req: Request = None -) -> ChangeSet: - """ - 更新SCADA设备数据 - - 修改指定SCADA设备的数据。 - - Args: - network: 管网名称(或数据库名称) - req: 请求体,包含要更新的数据 - - Returns: - 变更集合信息 - """ - props = await req.json() - return set_scada_device_data(network, ChangeSet(props)) - -@router.post("/scada-device-datas", response_model=None, summary="添加SCADA设备数据", tags=["SCADA设备数据"]) -async def fastapi_add_scada_device_data( - network: str = Query(..., description="管网名称(或数据库名称)"), - req: Request = None -) -> ChangeSet: - """ - 添加新的SCADA设备数据 - - 为指定SCADA设备添加新的数据记录。 - - Args: - network: 管网名称(或数据库名称) - req: 请求体,包含新数据的内容 - - Returns: - 变更集合信息 - """ - props = await req.json() - return add_scada_device_data(network, ChangeSet(props)) - -@router.delete("/scada-device-datas", response_model=None, summary="删除SCADA设备数据", tags=["SCADA设备数据"]) -async def fastapi_delete_scada_device_data( - network: str = Query(..., description="管网名称(或数据库名称)"), - req: Request = None -) -> ChangeSet: - """ - 删除SCADA设备数据 - - 删除指定SCADA设备的数据记录。 - - Args: - network: 管网名称(或数据库名称) - req: 请求体,包含要删除的数据ID - - Returns: - 变更集合信息 - """ - props = await req.json() - return delete_scada_device_data(network, ChangeSet(props)) - -@router.post("/scada-device-data-cleaning-runs", response_model=None, summary="清空SCADA设备数据表", tags=["SCADA设备数据"]) -async def fastapi_clean_scada_device_data( - network: str = Query(..., description="管网名称(或数据库名称)") -) -> ChangeSet: - """ - 清空SCADA设备数据表 - - 删除指定管网中所有SCADA设备的数据。 - - Args: - network: 管网名称(或数据库名称) - - Returns: - 变更集合信息 - """ - return clean_scada_device_data(network) - - -############################################################ -# scada_element SCADA元素映射 -############################################################ - -@router.get("/network-schemas/scada-element", summary="获取SCADA元素架构", tags=["SCADA元素映射"]) -async def fastapi_get_scada_element_schema( - network: str = Query(..., description="管网名称(或数据库名称)"), -) -> dict[str, dict[str, Any]]: - """ - 获取SCADA元素映射的表结构 - - 返回SCADA元素映射表的字段定义和类型信息。 - - Args: - network: 管网名称(或数据库名称) - - Returns: - SCADA元素映射的字段架构信息 - """ - return get_scada_element_schema(network) - -@router.get("/scada-elements", summary="获取所有SCADA元素映射", tags=["SCADA元素映射"]) -async def fastapi_get_scada_elements( - network: str = Query(..., description="管网名称(或数据库名称)") -) -> list[dict[str, Any]]: - """ - 获取指定管网所有SCADA元素映射 - - 查询所有SCADA设备与管网元素(节点/管道)的映射关系。 - - Args: - network: 管网名称(或数据库名称) - - Returns: - SCADA元素映射列表 - """ - return get_all_scada_elements(network) - -@router.get("/scada-elements/detail", summary="获取单个SCADA元素映射", tags=["SCADA元素映射"]) -async def fastapi_get_scada_element( - network: str = Query(..., description="管网名称(或数据库名称)"), - id: str = Query(..., description="SCADA元素映射ID") -) -> dict[str, Any]: - """ - 获取单个SCADA元素映射的信息 - - 根据ID查询特定的SCADA设备与管网元素的映射关系。 - - Args: - network: 管网名称(或数据库名称) - id: SCADA元素映射ID - - Returns: - SCADA元素映射信息 - """ - return get_scada_element(network, id) - -@router.patch("/scada-elements", response_model=None, summary="更新SCADA元素映射", tags=["SCADA元素映射"]) -async def fastapi_set_scada_element( - network: str = Query(..., description="管网名称(或数据库名称)"), - req: Request = None -) -> ChangeSet: - """ - 更新SCADA元素映射 - - 修改SCADA设备与管网元素的映射关系。 - - Args: - network: 管网名称(或数据库名称) - req: 请求体,包含要更新的映射信息 - - Returns: - 变更集合信息 - """ - props = await req.json() - return set_scada_element(network, ChangeSet(props)) - -@router.post("/scada-elements", response_model=None, summary="添加SCADA元素映射", tags=["SCADA元素映射"]) -async def fastapi_add_scada_element( - network: str = Query(..., description="管网名称(或数据库名称)"), - req: Request = None -) -> ChangeSet: - """ - 添加新的SCADA元素映射 - - 创建SCADA设备与管网元素的新映射关系。 - - Args: - network: 管网名称(或数据库名称) - req: 请求体,包含新映射的信息 - - Returns: - 变更集合信息 - """ - props = await req.json() - return add_scada_element(network, ChangeSet(props)) - -@router.delete("/scada-elements", response_model=None, summary="删除SCADA元素映射", tags=["SCADA元素映射"]) -async def fastapi_delete_scada_element( - network: str = Query(..., description="管网名称(或数据库名称)"), - req: Request = None -) -> ChangeSet: - """ - 删除SCADA元素映射 - - 移除SCADA设备与管网元素的映射关系。 - - Args: - network: 管网名称(或数据库名称) - req: 请求体,包含要删除的映射ID - - Returns: - 变更集合信息 - """ - props = await req.json() - return delete_scada_element(network, ChangeSet(props)) - -@router.post("/scada-element-cleaning-runs", response_model=None, summary="清空SCADA元素映射表", tags=["SCADA元素映射"]) -async def fastapi_clean_scada_element( - network: str = Query(..., description="管网名称(或数据库名称)") -) -> ChangeSet: - """ - 清空SCADA元素映射表 - - 删除指定管网中所有的SCADA元素映射。 - - Args: - network: 管网名称(或数据库名称) - - Returns: - 变更集合信息 - """ - return clean_scada_element(network) - - -############################################################ -# scada_info SCADA信息 -############################################################ - -@router.get("/scada-info-schemas", summary="获取SCADA信息架构", tags=["SCADA信息"]) -async def fastapi_get_scada_info_schema( - network: str = Query(..., description="管网名称(或数据库名称)") -) -> dict[str, dict[str, Any]]: - """ - 获取SCADA信息表的结构 - - 返回SCADA信息表的字段定义和类型信息。 - - Args: - network: 管网名称(或数据库名称) - - Returns: - SCADA信息的字段架构信息 - """ return get_scada_info_schema(network) -@router.get("/scada-info/detail", summary="获取SCADA信息", tags=["SCADA信息"]) -async def fastapi_get_scada_info( - network: str = Query(..., description="管网名称(或数据库名称)"), - id: str = Query(..., description="SCADA信息ID") -) -> dict[str, Any]: - """ - 获取单个SCADA信息 - - 根据ID查询SCADA的详细配置信息。 - - Args: - network: 管网名称(或数据库名称) - id: SCADA信息ID - - Returns: - SCADA信息详情 - """ - return get_scada_info(network, id) -@router.get("/scada-info", summary="获取所有SCADA信息", tags=["SCADA信息"]) -async def fastapi_get_all_scada_info( - network: str = Query(..., description="管网名称(或数据库名称)") +@router.get("/scada-devices", summary="获取 SCADA 设备列表") +async def get_scada_devices( + network: str = Query(..., description="管网名称(或数据库名称)"), ) -> list[dict[str, Any]]: - """ - 获取指定管网所有SCADA的信息 - - 查询该管网下所有已配置的SCADA的完整信息。 - - Args: - network: 管网名称(或数据库名称) - - Returns: - SCADA信息列表 - """ return get_all_scada_info(network) + + +@router.get("/scada-devices/detail", summary="获取 SCADA 设备") +async def get_scada_device( + network: str = Query(..., description="管网名称(或数据库名称)"), + device_id: str = Query(..., description="SCADA 设备 ID"), +) -> dict[str, Any]: + return get_scada_info(network, device_id) diff --git a/app/api/v1/endpoints/schemes.py b/app/api/v1/endpoints/schemes.py deleted file mode 100644 index ff365cc..0000000 --- a/app/api/v1/endpoints/schemes.py +++ /dev/null @@ -1,60 +0,0 @@ -from datetime import datetime -from fastapi import APIRouter, HTTPException, Path, Query -from typing import Any -from app.services.tjnetwork import get_scheme_schema, get_scheme, get_all_schemes -from app.services.scheme_management import query_scheme_detail -from app.services.time_api import extract_date - -router = APIRouter() - -@router.get("/network-schemas/scheme", summary="获取方案模式", description="获取指定网络的方案模式定义") -async def fastapi_get_scheme_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[Any, Any]]: - """ - 获取方案模式定义 - - 返回指定网络的方案模式结构定义 - """ - return get_scheme_schema(network) - -@router.get("/schemes/detail", summary="获取单个方案", description="根据名称获取指定的方案信息") -async def fastapi_get_scheme(network: str = Query(..., description="管网名称(或数据库名称)"), schema_name: str = Query(..., description="方案名称")) -> dict[Any, Any]: - """ - 获取单个方案详情 - - 返回指定网络中指定名称的方案详细信息 - """ - return get_scheme(network, schema_name) - -@router.get("/schemes", summary="获取所有方案", description="获取指定网络的所有方案信息") -async def fastapi_get_all_schemes( - network: str = Query(..., description="管网名称(或数据库名称)"), - scheme_type: str | None = Query(None, description="方案类型;为空时返回全部类型"), - query_date: datetime | None = Query(None, description="查询日期(可选)"), -) -> list[dict[Any, Any]]: - """ - 获取所有方案列表 - - 返回指定网络中所有可用的方案 - """ - parsed_date = ( - extract_date(query_date, field_name="query_date") - if query_date is not None - else None - ) - return get_all_schemes(network, scheme_type=scheme_type, query_date=parsed_date) - - -@router.get("/schemes/{scheme_name}", summary="获取方案详情", description="按方案类型获取指定方案详情") -async def fastapi_get_scheme_detail( - scheme_name: str = Path(..., description="方案名称"), - network: str = Query(..., description="管网名称(或数据库名称)"), - scheme_type: str | None = Query(None, description="方案类型;为空时返回通用方案详情"), -) -> dict[Any, Any]: - result = query_scheme_detail( - name=network, - scheme_name=scheme_name, - scheme_type=scheme_type, - ) - if not result: - raise HTTPException(status_code=404, detail=f"Scheme {scheme_name} not found") - return result diff --git a/app/api/v1/endpoints/sensor_placement.py b/app/api/v1/endpoints/sensor_placement.py index 5ebdd25..ba3bf16 100644 --- a/app/api/v1/endpoints/sensor_placement.py +++ b/app/api/v1/endpoints/sensor_placement.py @@ -1,6 +1,7 @@ import logging from typing import Any from urllib.parse import quote +from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Path, Query, status from fastapi.responses import StreamingResponse @@ -26,8 +27,9 @@ from app.services.sensor_placement import ( build_sensor_placement_workbook, can_edit_sensor_placement, get_sensor_placement_candidate, - get_sensor_placement_scheme, - update_sensor_placement_scheme, + get_sensor_placement_run, + list_sensor_placement_runs, + update_sensor_placement_run, ) router = APIRouter() @@ -74,19 +76,19 @@ def _service_http_error(exc: Exception) -> HTTPException: ) -def _get_scheme_response( +def _get_run_response( network: str, - scheme_id: int, + run_id: UUID, current_user: Any, project_context: ProjectContext, ) -> dict[str, Any]: try: - scheme = get_sensor_placement_scheme(network, scheme_id) + run = get_sensor_placement_run(network, run_id) return { - **scheme, + **run, "can_edit": ( _can_modify_project(project_context) - and can_edit_sensor_placement(current_user, scheme) + and can_edit_sensor_placement(current_user, run) ), } except ( @@ -116,7 +118,7 @@ async def get_sensor_placement_candidate_detail( @router.post( - "/sensor-placement-optimization-runs", + "/sensor-placement-runs", response_model=SensorPlacementSchemeResponse, summary="创建并返回监测点优化方案", ) @@ -136,13 +138,13 @@ async def optimize_sensor_placement_scheme( created = await run_in_threadpool( optimizer, name=network, - scheme_name=payload.scheme_name, + scheme_name=payload.run_name, sensor_number=payload.sensor_count, min_diameter=payload.min_diameter, username=current_user.username, ) - scheme = get_sensor_placement_scheme(network, int(created["id"])) - return {**scheme, "can_edit": True} + run = get_sensor_placement_run(network, created["run_id"]) + return {**run, "can_edit": True} except ( SensorPlacementConflictError, SensorPlacementValidationError, @@ -158,31 +160,45 @@ async def optimize_sensor_placement_scheme( @router.get( - "/sensor-placement-schemes/{scheme_id}", + "/sensor-placement-runs", + response_model=list[SensorPlacementSchemeResponse], + summary="获取监测点优化运行", +) +async def get_sensor_placement_runs( + project_context: ProjectContext = Depends(get_project_context), +) -> list[dict[str, Any]]: + return await run_in_threadpool( + list_sensor_placement_runs, + project_context.project_code, + ) + + +@router.get( + "/sensor-placement-runs/{run_id}", response_model=SensorPlacementSchemeResponse, summary="获取监测点方案详情", ) -async def get_sensor_placement_scheme_detail( - scheme_id: int, +async def get_sensor_placement_run_detail( + run_id: UUID, network: str = Query(..., min_length=1), project_context: ProjectContext = Depends(get_project_context), current_user=Depends(get_current_metadata_user), ) -> dict[str, Any]: - return _get_scheme_response( + return _get_run_response( _project_network(network, project_context), - scheme_id, + run_id, current_user, project_context, ) @router.put( - "/sensor-placement-schemes/{scheme_id}", + "/sensor-placement-runs/{run_id}", response_model=SensorPlacementSchemeResponse, summary="覆盖保存监测点方案", ) -async def overwrite_sensor_placement_scheme( - scheme_id: int, +async def overwrite_sensor_placement_run( + run_id: UUID, payload: SensorPlacementUpdateRequest, network: str = Query(..., min_length=1), project_context: ProjectContext = Depends(get_project_context), @@ -190,21 +206,21 @@ async def overwrite_sensor_placement_scheme( ) -> dict[str, Any]: network = _project_network(network, project_context) _require_project_write(project_context) - scheme = _get_scheme_response( + run = _get_run_response( network, - scheme_id, + run_id, current_user, project_context, ) - if not scheme["can_edit"]: - raise HTTPException(status_code=403, detail="无权修改该监测点方案") + if not run["can_edit"]: + raise HTTPException(status_code=403, detail="无权修改该监测点优化运行") try: - updated = update_sensor_placement_scheme( + updated = update_sensor_placement_run( network, - scheme_id, - expected_sensor_location=payload.expected_sensor_location, - sensor_location=payload.sensor_location, + run_id, + expected_sensor_locations=payload.expected_sensor_locations, + sensor_locations=payload.sensor_locations, ) return {**updated, "can_edit": True} except ( @@ -216,26 +232,26 @@ async def overwrite_sensor_placement_scheme( @router.post( - "/sensor-placement-schemes/{scheme_id}/exports/excel", + "/sensor-placement-runs/{run_id}/exports/excel", summary="导出监测点工程清单", ) async def export_sensor_placement_excel( - scheme_id: int, + run_id: UUID, payload: SensorPlacementExportRequest, network: str = Query(..., min_length=1), project_context: ProjectContext = Depends(get_project_context), current_user=Depends(get_current_metadata_user), ) -> StreamingResponse: network = _project_network(network, project_context) - scheme = _get_scheme_response( + run = _get_run_response( network, - scheme_id, + run_id, current_user, project_context, ) if ( - payload.sensor_location != scheme["sensor_location"] - and not scheme["can_edit"] + payload.sensor_locations != run["sensor_locations"] + and not run["can_edit"] ): raise HTTPException(status_code=403, detail="无权导出该方案的未保存草稿") @@ -243,14 +259,14 @@ async def export_sensor_placement_excel( workbook = await run_in_threadpool( build_sensor_placement_workbook, network=network, - scheme=scheme, - sensor_location=payload.sensor_location, + scheme=run, + sensor_location=payload.sensor_locations, adjustment_status=payload.adjustment_status, ) except SensorPlacementValidationError as exc: raise _service_http_error(exc) from exc - filename = f"{scheme['scheme_name']}_监测点清单.xlsx" + filename = f"{run['name']}_监测点清单.xlsx" encoded_filename = quote(filename) return StreamingResponse( workbook, diff --git a/app/api/v1/endpoints/simulation.py b/app/api/v1/endpoints/simulation.py index b561359..17da470 100644 --- a/app/api/v1/endpoints/simulation.py +++ b/app/api/v1/endpoints/simulation.py @@ -22,11 +22,6 @@ from app.algorithms.simulation.scenarios import ( # scheduling_analysis, pressure_regulation, ) -from app.algorithms.sensor import ( - pressure_sensor_placement_sensitivity, - pressure_sensor_placement_kmeans, -) - from app.services.simulation_ops import ( project_management, scheduling_simulation, @@ -108,14 +103,6 @@ class PumpFailureState(BaseModel): pump_status: dict = Field(..., description="泵状态字典") -class PressureSensorPlacement(BaseModel): - name: str = Field(..., description="管网名称(或数据库名称)") - scheme_name: str = Field(..., description="方案名称") - sensor_number: int = Field(..., description="传感器数量") - min_diameter: int = Field(0, description="最小管径限制") - username: str = Field(..., description="用户名") - - def run_simulation_manually_by_date( network_name: str, start_time: datetime, duration: int ) -> None: @@ -663,154 +650,6 @@ async def fastapi_pump_failure(data: PumpFailureState = Body(..., description=" return json.dumps("SUCCESS") -@router.post("/pressure-sensor-placement-sensitivity-calculations", summary="压力传感器放置-灵敏度分析(基础)", description="基于灵敏度分析方法,为指定管网项目确定最优的压力传感器放置位置。此为基础版本。") -async def pressure_sensor_placement_sensitivity_endpoint( - name: str = Query(..., description="管网名称(或数据库名称)"), - scheme_name: str = Query(..., description="放置方案名称"), - sensor_number: int = Query(..., description="传感器数量"), - min_diameter: int = Query(..., description="最小管径限制(毫米)"), - username: str = Query(..., description="用户名"), -): - """ - 压力传感器放置-灵敏度分析(基础版本) - - - **name**: 管网名称(或数据库名称) - - **scheme_name**: 放置方案名称 - - **sensor_number**: 传感器数量 - - **min_diameter**: 最小管径限制(毫米) - - **username**: 用户名 - - 基于灵敏度分析方法确定传感器放置位置。 - """ - return pressure_sensor_placement_sensitivity( - name, scheme_name, sensor_number, min_diameter, username - ) - - -@router.post("/pressure-sensor-placement-sensitivities", summary="压力传感器放置-灵敏度分析(高级)", description="高级版本的压力传感器放置分析,通过JSON请求体提供详细参数。基于灵敏度分析方法确定最优放置位置。") -async def fastapi_pressure_sensor_placement_sensitivity( - data: PressureSensorPlacement = Body(..., description="传感器放置分析参数"), -) -> None: - """ - 压力传感器放置-灵敏度分析(高级版本) - - 请求体参数: - - **name**: 管网名称(或数据库名称) - - **scheme_name**: 放置方案名称 - - **sensor_number**: 传感器数量 - - **min_diameter**: 最小管径限制(毫米) - - **username**: 用户名 - - 基于灵敏度分析方法确定压力传感器的最优放置位置。 - """ - item = data.dict() - pressure_sensor_placement_sensitivity( - name=item["name"], - scheme_name=item["scheme_name"], - sensor_number=item["sensor_number"], - min_diameter=item["min_diameter"], - username=item["username"], - ) - - -@router.post("/pressure-sensor-placement-kmeans-calculations", summary="压力传感器放置-KMeans聚类分析(基础)", description="基于KMeans聚类算法,为指定管网项目确定压力传感器的最优放置位置。此为基础版本。") -async def pressure_sensor_placement_kmeans_endpoint( - name: str = Query(..., description="管网名称(或数据库名称)"), - scheme_name: str = Query(..., description="放置方案名称"), - sensor_number: int = Query(..., description="传感器数量"), - min_diameter: int = Query(..., description="最小管径限制(毫米)"), - username: str = Query(..., description="用户名"), -): - """ - 压力传感器放置-KMeans聚类分析(基础版本) - - - **name**: 管网名称(或数据库名称) - - **scheme_name**: 放置方案名称 - - **sensor_number**: 传感器数量 - - **min_diameter**: 最小管径限制(毫米) - - **username**: 用户名 - - 基于KMeans聚类算法确定传感器放置位置。 - """ - return pressure_sensor_placement_kmeans( - name, scheme_name, sensor_number, min_diameter, username - ) - - -@router.post("/pressure-sensor-placement-kmeans", summary="压力传感器放置-KMeans聚类分析(高级)", description="高级版本的压力传感器放置分析,通过JSON请求体提供详细参数。基于KMeans聚类算法确定最优放置位置。") -async def fastapi_pressure_sensor_placement_kmeans( - data: PressureSensorPlacement = Body(..., description="传感器放置分析参数"), -) -> None: - """ - 压力传感器放置-KMeans聚类分析(高级版本) - - 请求体参数: - - **name**: 管网名称(或数据库名称) - - **scheme_name**: 放置方案名称 - - **sensor_number**: 传感器数量 - - **min_diameter**: 最小管径限制(毫米) - - **username**: 用户名 - - 基于KMeans聚类算法确定压力传感器的最优放置位置。 - """ - item = data.dict() - pressure_sensor_placement_kmeans( - name=item["name"], - scheme_name=item["scheme_name"], - sensor_number=item["sensor_number"], - min_diameter=item["min_diameter"], - username=item["username"], - ) - - -@router.post("/sensor-placement-schemes", summary="传感器放置方案创建", description="创建新的传感器放置方案,支持灵敏度分析和KMeans聚类两种方法。根据指定的方法自动计算最优的传感器放置位置。") -async def fastapi_pressure_sensor_placement( - network: str = Query(..., description="管网名称(或数据库名称)"), - scheme_name: str = Query(..., description="放置方案名称"), - sensor_type: str = Query(..., description="传感器类型"), - method: str = Query(..., description="放置方法('sensitivity'或'kmeans')"), - sensor_count: int = Query(..., description="传感器数量"), - min_diameter: int = Query(0, description="最小管径限制(毫米),默认0"), - user_name: str = Query(..., description="用户名"), -) -> str: - """ - 传感器放置方案创建 - - - **network**: 管网名称(或数据库名称) - - **scheme_name**: 放置方案名称 - - **sensor_type**: 传感器类型 - - **method**: 放置方法('sensitivity'或'kmeans') - - **sensor_count**: 传感器数量 - - **min_diameter**: 最小管径限制(毫米,默认0) - - **user_name**: 用户名 - - 支持两种放置方法: - - sensitivity: 基于灵敏度分析 - - kmeans: 基于KMeans聚类 - """ - if method not in ["sensitivity", "kmeans"]: - raise HTTPException( - status_code=400, detail="Invalid method. Must be 'sensitivity' or 'kmeans'" - ) - if method == "sensitivity": - pressure_sensor_placement_sensitivity( - name=network, - scheme_name=scheme_name, - sensor_number=sensor_count, - min_diameter=min_diameter, - username=user_name, - ) - elif method == "kmeans": - pressure_sensor_placement_kmeans( - name=network, - scheme_name=scheme_name, - sensor_number=sensor_count, - min_diameter=min_diameter, - username=user_name, - ) - return "success" - - @router.post("/simulation-runs", summary="手动运行日期指定模拟", description="根据指定的开始时间和持续时间,手动运行水力模拟。开始时间必须是显式带时区的 ISO 8601 / RFC3339 时间。") async def fastapi_run_simulation_manually_by_date( data: RunSimulationManuallyByDate = Body(..., description="模拟运行参数"), @@ -829,30 +668,6 @@ async def fastapi_run_simulation_manually_by_date( item = data.model_dump() try: simulation.query_corresponding_element_id_and_query_id(item["name"]) - simulation.query_corresponding_pattern_id_and_query_id(item["name"]) - region_result = simulation.query_non_realtime_region(item["name"]) - globals.source_outflow_region_id = simulation.get_source_outflow_region_id( - item["name"], region_result - ) - globals.realtime_region_pipe_flow_and_demand_id = ( - simulation.query_realtime_region_pipe_flow_and_demand_id( - item["name"], region_result - ) - ) - globals.pipe_flow_region_patterns = simulation.query_pipe_flow_region_patterns( - item["name"] - ) - globals.non_realtime_region_patterns = ( - simulation.query_non_realtime_region_patterns(item["name"], region_result) - ) - ( - globals.source_outflow_region_patterns, - globals.realtime_region_pipe_flow_and_demand_patterns, - ) = simulation.get_realtime_region_patterns( - item["name"], - globals.source_outflow_region_id, - globals.realtime_region_pipe_flow_and_demand_id, - ) start_time = parse_utc_time(item["start_time"], field_name="start_time") run_simulation_manually_by_date( item["name"], start_time, item["duration"] diff --git a/app/api/v1/endpoints/snapshots.py b/app/api/v1/endpoints/snapshots.py deleted file mode 100644 index 2d6e245..0000000 --- a/app/api/v1/endpoints/snapshots.py +++ /dev/null @@ -1,199 +0,0 @@ -from fastapi import APIRouter, Depends, Request, Query -from app.auth.permissions import SIMULATION_RUN, require_permission -from app.services.tjnetwork import ( - ChangeSet, - get_current_operation, - execute_undo, - execute_redo, - list_snapshot, - have_snapshot, - have_snapshot_for_operation, - have_snapshot_for_current_operation, - take_snapshot_for_operation, - take_snapshot_for_current_operation, - take_snapshot, - pick_snapshot, - pick_operation, - sync_with_server, - execute_batch_commands, - execute_batch_command, - get_restore_operation, - set_restore_operation, -) - -router = APIRouter() - -@router.get("/current-operation-ids", summary="获取当前操作ID", description="获取网络当前的操作ID") -async def get_current_operation_id_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")) -> int: - """ - 获取当前操作ID - - 返回网络当前正在执行的操作ID - """ - return get_current_operation(network) - -@router.post("/undos", summary="撤销操作", description="撤销网络上最后的一个操作") -async def undo_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")): - """ - 撤销操作 - - 撤销网络上最近执行的一个操作 - """ - return execute_undo(network) - -@router.post("/redos", summary="重做操作", description="重做网络上被撤销的操作") -async def redo_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")): - """ - 重做操作 - - 重做网络上被撤销的操作 - """ - return execute_redo(network) - -@router.get("/snapshots", summary="获取快照列表", description="获取网络中的所有快照") -async def list_snapshot_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[tuple[int, str]]: - """ - 获取快照列表 - - 返回网络中所有可用的快照及其信息 - """ - return list_snapshot(network) - -@router.get("/snapshots/existence", summary="检查快照是否存在", description="检查指定标签的快照是否存在") -async def have_snapshot_endpoint(network: str = Query(..., description="管网名称(或数据库名称)"), tag: str = Query(..., description="快照标签")) -> bool: - """ - 检查快照是否存在 - - 返回指定标签的快照是否存在 - """ - return have_snapshot(network, tag) - -@router.get("/snapshot-for-operations", summary="检查操作快照是否存在", description="检查指定操作ID的快照是否存在") -async def have_snapshot_for_operation_endpoint(network: str = Query(..., description="管网名称(或数据库名称)"), operation: int = Query(..., description="操作ID")) -> bool: - """ - 检查操作快照是否存在 - - 返回指定操作ID的快照是否存在 - """ - return have_snapshot_for_operation(network, operation) - -@router.get("/snapshot-for-current-operations", summary="检查当前操作快照是否存在", description="检查当前操作的快照是否存在") -async def have_snapshot_for_current_operation_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")) -> bool: - """ - 检查当前操作快照是否存在 - - 返回当前操作的快照是否存在 - """ - return have_snapshot_for_current_operation(network) - -@router.post("/snapshot-for-operations", summary="为操作创建快照", description="为指定的操作创建快照") -async def take_snapshot_for_operation_endpoint( - network: str = Query(..., description="管网名称(或数据库名称)"), - operation: int = Query(..., description="操作ID"), - tag: str = Query(..., description="快照标签") -) -> None: - """ - 为操作创建快照 - - 为指定操作创建一个带标签的快照 - """ - return take_snapshot_for_operation(network, operation, tag) - -@router.post("/snapshot-for-current-operations", summary="为当前操作创建快照", description="为当前操作创建快照") -async def take_snapshot_for_current_operation_endpoint(network: str = Query(..., description="管网名称(或数据库名称)"), tag: str = Query(..., description="快照标签")) -> None: - """ - 为当前操作创建快照 - - 为网络当前操作创建一个快照 - """ - return take_snapshot_for_current_operation(network, tag) - -@router.post("/snapshots", summary="创建快照", description="为网络创建一个快照") -async def take_snapshot_endpoint(network: str = Query(..., description="管网名称(或数据库名称)"), tag: str = Query(..., description="快照标签")) -> None: - """ - 创建快照 - - 为网络创建一个带标签的快照 - """ - return take_snapshot(network, tag) - -@router.patch("/snapshots", summary="选择快照", description="选择并恢复到指定的快照", response_model=None) -async def pick_snapshot_endpoint(network: str = Query(..., description="管网名称(或数据库名称)"), tag: str = Query(..., description="快照标签"), discard: bool = Query(False, description="是否丢弃当前更改")) -> ChangeSet: - """ - 选择快照 - - 选择并恢复到指定的快照 - """ - return pick_snapshot(network, tag, discard) - -@router.patch("/operations", summary="选择操作", description="选择并恢复到指定的操作", response_model=None) -async def pick_operation_endpoint( - network: str = Query(..., description="管网名称(或数据库名称)"), - operation: int = Query(..., description="操作ID"), - discard: bool = Query(False, description="是否丢弃当前更改") -) -> ChangeSet: - """ - 选择操作 - - 选择并恢复到指定的操作 - """ - return pick_operation(network, operation, discard) - -@router.post("/with-servers", summary="与服务器同步", description="将网络与服务器同步到指定操作", response_model=None) -async def sync_with_server_endpoint( - network: str = Query(..., description="管网名称(或数据库名称)"), - operation: int = Query(..., description="目标操作ID"), - _=Depends(require_permission(SIMULATION_RUN)), -) -> ChangeSet: - """ - 与服务器同步 - - 将网络与服务器同步到指定的操作 - """ - return sync_with_server(network, operation) - -@router.post("/network-command-batches", summary="执行批量命令", description="执行多个网络操作命令", response_model=None) -async def execute_batch_commands_endpoint(network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None) -> ChangeSet: - """ - 执行批量命令 - - 在网络上执行多个操作命令 - """ - jo_root = await req.json() - cs: ChangeSet = ChangeSet() - cs.operations = jo_root["operations"] - rcs = execute_batch_commands(network, cs) - return rcs - -@router.post("/network-command-batches/compressed", summary="执行压缩批量命令", description="执行压缩的批量命令", response_model=None) -async def execute_compressed_batch_commands_endpoint( - network: str = Query(..., description="管网名称(或数据库名称)"), - req: Request = None -) -> ChangeSet: - """ - 执行压缩批量命令 - - 执行压缩格式的批量命令 - """ - jo_root = await req.json() - cs: ChangeSet = ChangeSet() - cs.operations = jo_root["operations"] - return execute_batch_command(network, cs) - -@router.get("/restore-operations", summary="获取恢复操作ID", description="获取网络的恢复操作ID") -async def get_restore_operation_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")) -> int: - """ - 获取恢复操作ID - - 返回网络的恢复操作ID - """ - return get_restore_operation(network) - -@router.patch("/restore-operations", summary="设置恢复操作ID", description="设置网络的恢复操作ID") -async def set_restore_operation_endpoint(network: str = Query(..., description="管网名称(或数据库名称)"), operation: int = Query(..., description="操作ID")) -> None: - """ - 设置恢复操作ID - - 设置网络的恢复操作ID - """ - return set_restore_operation(network, operation) diff --git a/app/api/v1/endpoints/timeseries/analysis.py b/app/api/v1/endpoints/timeseries/analysis.py new file mode 100644 index 0000000..763146c --- /dev/null +++ b/app/api/v1/endpoints/timeseries/analysis.py @@ -0,0 +1,82 @@ +from datetime import datetime +from uuid import UUID + +from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query +from psycopg import AsyncConnection + +from app.infra.db.timescaledb.repositories.analysis import AnalysisResultsRepository + +from .dependencies import get_timescale_connection + +router = APIRouter() + + +@router.post("/timeseries/analysis/runs/{run_id}/results", status_code=201) +async def store_analysis_results( + run_id: UUID = Path(..., description="分析运行 ID"), + payload: dict = Body(...), + conn: AsyncConnection = Depends(get_timescale_connection), +): + try: + node_rows = payload.get("node_results", []) + link_rows = payload.get("link_results", []) + await AnalysisResultsRepository.store_results( + conn, run_id, node_rows, link_rows + ) + return { + "run_id": run_id, + "node_count": len(node_rows), + "link_count": len(link_rows), + } + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + +@router.get("/timeseries/analysis/runs/{run_id}/nodes/{node_id}") +async def get_analysis_node_series( + run_id: UUID, + node_id: str, + start_time: datetime = Query(...), + end_time: datetime = Query(...), + field: str = Query(...), + conn: AsyncConnection = Depends(get_timescale_connection), +): + try: + return await AnalysisResultsRepository.get_node_series( + conn, run_id, node_id, start_time, end_time, field + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.get("/timeseries/analysis/runs/{run_id}/links/{link_id}") +async def get_analysis_link_series( + run_id: UUID, + link_id: str, + start_time: datetime = Query(...), + end_time: datetime = Query(...), + field: str = Query(...), + conn: AsyncConnection = Depends(get_timescale_connection), +): + try: + return await AnalysisResultsRepository.get_link_series( + conn, run_id, link_id, start_time, end_time, field + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.get("/timeseries/analysis/runs/{run_id}/values") +async def get_analysis_values_at_time( + run_id: UUID, + result_time: datetime = Query(...), + element_type: str = Query(..., pattern="^(node|link)$"), + field: str = Query(...), + conn: AsyncConnection = Depends(get_timescale_connection), +): + try: + return await AnalysisResultsRepository.get_values_at_time( + conn, run_id, element_type, result_time, field + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc diff --git a/app/api/v1/endpoints/timeseries/composite.py b/app/api/v1/endpoints/timeseries/composite.py index 7ac740d..06ea8dd 100644 --- a/app/api/v1/endpoints/timeseries/composite.py +++ b/app/api/v1/endpoints/timeseries/composite.py @@ -1,6 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query from datetime import datetime from psycopg import AsyncConnection +from uuid import UUID from app.infra.db.timescaledb.composite_queries import CompositeQueries from .dependencies import get_timescale_connection, get_postgres_connection @@ -13,8 +14,7 @@ async def get_scada_associated_simulation_data( start_time: datetime = Query(..., description="查询开始时间"), end_time: datetime = Query(..., description="查询结束时间"), device_ids: str = Query(..., description="SCADA设备ID列表,逗号分隔"), - scheme_type: str = Query(None, description="方案类型,若为空则查询实时数据"), - scheme_name: str = Query(None, description="方案名称,若为空则查询实时数据"), + run_id: UUID | None = Query(None, description="分析运行 ID;为空时查询实时数据"), timescale_conn: AsyncConnection = Depends(get_timescale_connection), postgres_conn: AsyncConnection = Depends(get_postgres_connection), ): @@ -22,14 +22,13 @@ async def get_scada_associated_simulation_data( 获取SCADA关联的link/node模拟值 根据传入的SCADA device_ids,找到关联的link/node, - 并根据对应的type,查询对应的模拟数据。支持查询实时或方案数据。 + 并根据对应的type,查询对应的模拟数据。支持查询实时或分析运行数据。 Args: start_time: 查询开始时间 end_time: 查询结束时间 device_ids: SCADA设备ID列表,用逗号分隔 - scheme_type: 方案类型,若为空则查询实时数据 - scheme_name: 方案名称,若为空则查询实时数据 + run_id: 分析运行 ID,若为空则查询实时数据 timescale_conn: TimescaleDB连接 postgres_conn: PostgreSQL连接 @@ -46,15 +45,14 @@ async def get_scada_associated_simulation_data( else [] ) - if scheme_type and scheme_name: - result = await CompositeQueries.get_scada_associated_scheme_simulation_data( + if run_id is not None: + result = await CompositeQueries.get_scada_associated_analysis_simulation_data( timescale_conn, postgres_conn, device_ids_list, start_time, end_time, - scheme_type, - scheme_name, + run_id, ) else: result = ( @@ -80,23 +78,21 @@ async def get_feature_simulation_data( feature_infos: str = Query( ..., description="特征信息,格式: id1:type1,id2:type2,type为pipe(管道)或junction(节点)" ), - scheme_type: str = Query(None, description="方案类型,若为空则查询实时数据"), - scheme_name: str = Query(None, description="方案名称,若为空则查询实时数据"), + run_id: UUID | None = Query(None, description="分析运行 ID;为空时查询实时数据"), timescale_conn: AsyncConnection = Depends(get_timescale_connection), ): """ 获取link/node模拟值 根据传入的featureInfos,找到关联的link/node, - 并根据对应的type,查询对应的模拟数据。支持查询实时或方案数据。 + 并根据对应的type,查询对应的模拟数据。支持查询实时或分析运行数据。 Args: start_time: 查询开始时间 end_time: 查询结束时间 feature_infos: 格式为 "element_id1:type1,element_id2:type2" 例如: "P1:pipe,J1:junction" - scheme_type: 方案类型,若为空则查询实时数据 - scheme_name: 方案名称,若为空则查询实时数据 + run_id: 分析运行 ID,若为空则查询实时数据 timescale_conn: TimescaleDB连接 Returns: @@ -119,14 +115,13 @@ async def get_feature_simulation_data( if not feature_infos_list: raise HTTPException(status_code=400, detail="feature_infos cannot be empty") - if scheme_type and scheme_name: - result = await CompositeQueries.get_scheme_simulation_data( + if run_id is not None: + result = await CompositeQueries.get_analysis_simulation_data( timescale_conn, feature_infos_list, start_time, end_time, - scheme_type, - scheme_name, + run_id, ) else: result = await CompositeQueries.get_realtime_simulation_data( diff --git a/app/api/v1/endpoints/timeseries/scheme.py b/app/api/v1/endpoints/timeseries/scheme.py deleted file mode 100644 index 0c56a75..0000000 --- a/app/api/v1/endpoints/timeseries/scheme.py +++ /dev/null @@ -1,391 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException, Query, Path, Body -from typing import List -from datetime import datetime -from psycopg import AsyncConnection - -from app.infra.db.timescaledb.repositories.scheme import SchemeRepository -from .dependencies import get_timescale_connection - -router = APIRouter() - - -@router.post("/timeseries/schemes/links/batches", status_code=201, summary="批量插入方案管道数据") -async def insert_scheme_links( - data: List[dict] = Body(..., description="方案管道数据列表"), - conn: AsyncConnection = Depends(get_timescale_connection), -): - """ - 批量插入方案管道数据 - - 将特定方案的管道模拟数据批量插入时间序列数据库。 - - Args: - data: 方案管道数据列表 - - Returns: - 插入成功的记录数 - """ - await SchemeRepository.insert_links_batch(conn, data) - return {"message": f"Inserted {len(data)} records"} - - -@router.get("/timeseries/schemes/links", summary="查询方案管道数据") -async def get_scheme_links( - scheme_type: str = Query(..., description="方案类型"), - scheme_name: str = Query(..., description="方案名称"), - start_time: datetime = Query(..., description="查询开始时间"), - end_time: datetime = Query(..., description="查询结束时间"), - conn: AsyncConnection = Depends(get_timescale_connection), -): - """ - 查询指定方案和时间范围内的管道数据 - - 根据方案和时间范围查询管道的模拟值。 - - Args: - scheme_type: 方案类型 - scheme_name: 方案名称 - start_time: 查询开始时间 - end_time: 查询结束时间 - - Returns: - 方案管道数据列表 - """ - return await SchemeRepository.get_links_by_scheme_and_time_range( - conn, scheme_type, scheme_name, start_time, end_time - ) - - -@router.get("/timeseries/schemes/links/{link_id}/field", summary="查询方案管道字段数据") -async def get_scheme_link_field( - link_id: str = Path(..., description="管道ID"), - scheme_type: str = Query(..., description="方案类型"), - scheme_name: str = Query(..., description="方案名称"), - start_time: datetime = Query(..., description="查询开始时间"), - end_time: datetime = Query(..., description="查询结束时间"), - field: str = Query(..., description="要查询的字段名称"), - conn: AsyncConnection = Depends(get_timescale_connection), -): - """ - 查询指定方案管道的特定字段数据 - - 查询特定方案中指定管道在时间范围内的特定字段值。 - - Args: - link_id: 管道ID - scheme_type: 方案类型 - scheme_name: 方案名称 - start_time: 查询开始时间 - end_time: 查询结束时间 - field: 字段名称 - - Returns: - 字段数据列表 - - Raises: - HTTPException: 当查询参数无效时返回400错误 - """ - try: - return await SchemeRepository.get_link_field_by_scheme_and_time_range( - conn, scheme_type, scheme_name, start_time, end_time, link_id, field - ) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - -@router.patch("/timeseries/schemes/links/{link_id}/field", summary="更新方案管道字段") -async def update_scheme_link_field( - link_id: str = Path(..., description="管道ID"), - scheme_type: str = Query(..., description="方案类型"), - scheme_name: str = Query(..., description="方案名称"), - time: datetime = Query(..., description="更新数据的时间戳"), - field: str = Query(..., description="要更新的字段名称"), - value: float = Query(..., description="更新的字段值"), - conn: AsyncConnection = Depends(get_timescale_connection), -): - """ - 更新指定方案管道的字段值 - - 更新特定方案中指定管道在某个时间的字段数据。 - - Args: - link_id: 管道ID - scheme_type: 方案类型 - scheme_name: 方案名称 - time: 数据时间戳 - field: 字段名称 - value: 字段新值 - - Returns: - 更新结果信息 - - Raises: - HTTPException: 当字段不存在或更新失败时返回400错误 - """ - try: - await SchemeRepository.update_link_field( - conn, time, scheme_type, scheme_name, link_id, field, value - ) - return {"message": "Updated successfully"} - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - -@router.delete("/timeseries/schemes/links", summary="删除方案管道数据") -async def delete_scheme_links( - scheme_type: str = Query(..., description="方案类型"), - scheme_name: str = Query(..., description="方案名称"), - start_time: datetime = Query(..., description="删除开始时间"), - end_time: datetime = Query(..., description="删除结束时间"), - conn: AsyncConnection = Depends(get_timescale_connection), -): - """ - 删除指定方案和时间范围内的管道数据 - - 删除在指定方案和时间范围内的所有管道模拟数据。 - - Args: - scheme_type: 方案类型 - scheme_name: 方案名称 - start_time: 删除开始时间 - end_time: 删除结束时间 - - Returns: - 删除结果信息 - """ - await SchemeRepository.delete_links_by_scheme_and_time_range( - conn, scheme_type, scheme_name, start_time, end_time - ) - return {"message": "Deleted successfully"} - - -@router.post("/timeseries/schemes/nodes/batches", status_code=201, summary="批量插入方案节点数据") -async def insert_scheme_nodes( - data: List[dict] = Body(..., description="方案节点数据列表"), - conn: AsyncConnection = Depends(get_timescale_connection), -): - """ - 批量插入方案节点数据 - - 将特定方案的节点模拟数据批量插入时间序列数据库。 - - Args: - data: 方案节点数据列表 - - Returns: - 插入成功的记录数 - """ - await SchemeRepository.insert_nodes_batch(conn, data) - return {"message": f"Inserted {len(data)} records"} - - -@router.get("/timeseries/schemes/nodes/{node_id}/field", summary="查询方案节点字段数据") -async def get_scheme_node_field( - node_id: str = Path(..., description="节点ID"), - scheme_type: str = Query(..., description="方案类型"), - scheme_name: str = Query(..., description="方案名称"), - start_time: datetime = Query(..., description="查询开始时间"), - end_time: datetime = Query(..., description="查询结束时间"), - field: str = Query(..., description="要查询的字段名称"), - conn: AsyncConnection = Depends(get_timescale_connection), -): - """ - 查询指定方案节点的特定字段数据 - - 查询特定方案中指定节点在时间范围内的特定字段值。 - - Args: - node_id: 节点ID - scheme_type: 方案类型 - scheme_name: 方案名称 - start_time: 查询开始时间 - end_time: 查询结束时间 - field: 字段名称 - - Returns: - 字段数据列表 - - Raises: - HTTPException: 当查询参数无效时返回400错误 - """ - try: - return await SchemeRepository.get_node_field_by_scheme_and_time_range( - conn, scheme_type, scheme_name, start_time, end_time, node_id, field - ) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - -@router.patch("/timeseries/schemes/nodes/{node_id}/field", summary="更新方案节点字段") -async def update_scheme_node_field( - node_id: str = Path(..., description="节点ID"), - scheme_type: str = Query(..., description="方案类型"), - scheme_name: str = Query(..., description="方案名称"), - time: datetime = Query(..., description="更新数据的时间戳"), - field: str = Query(..., description="要更新的字段名称"), - value: float = Query(..., description="更新的字段值"), - conn: AsyncConnection = Depends(get_timescale_connection), -): - """ - 更新指定方案节点的字段值 - - 更新特定方案中指定节点在某个时间的字段数据。 - - Args: - node_id: 节点ID - scheme_type: 方案类型 - scheme_name: 方案名称 - time: 数据时间戳 - field: 字段名称 - value: 字段新值 - - Returns: - 更新结果信息 - - Raises: - HTTPException: 当字段不存在或更新失败时返回400错误 - """ - try: - await SchemeRepository.update_node_field( - conn, time, scheme_type, scheme_name, node_id, field, value - ) - return {"message": "Updated successfully"} - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - -@router.delete("/timeseries/schemes/nodes", summary="删除方案节点数据") -async def delete_scheme_nodes( - scheme_type: str = Query(..., description="方案类型"), - scheme_name: str = Query(..., description="方案名称"), - start_time: datetime = Query(..., description="删除开始时间"), - end_time: datetime = Query(..., description="删除结束时间"), - conn: AsyncConnection = Depends(get_timescale_connection), -): - """ - 删除指定方案和时间范围内的节点数据 - - 删除在指定方案和时间范围内的所有节点模拟数据。 - - Args: - scheme_type: 方案类型 - scheme_name: 方案名称 - start_time: 删除开始时间 - end_time: 删除结束时间 - - Returns: - 删除结果信息 - """ - await SchemeRepository.delete_nodes_by_scheme_and_time_range( - conn, scheme_type, scheme_name, start_time, end_time - ) - return {"message": "Deleted successfully"} - - -@router.post("/timeseries/schemes/simulation-results", status_code=201, summary="存储方案模拟结果") -async def store_scheme_simulation_result( - scheme_type: str = Query(..., description="方案类型"), - scheme_name: str = Query(..., description="方案名称"), - node_result_list: List[dict] = Body(..., description="节点模拟结果列表"), - link_result_list: List[dict] = Body(..., description="管道模拟结果列表"), - result_start_time: str = Query(..., description="模拟结果开始时间"), - conn: AsyncConnection = Depends(get_timescale_connection), -): - """ - 存储方案模拟结果到时间序列数据库 - - 将特定方案的节点和管道模拟计算结果批量存储到TimescaleDB数据库。 - - Args: - scheme_type: 方案类型 - scheme_name: 方案名称 - node_result_list: 节点模拟结果列表 - link_result_list: 管道模拟结果列表 - result_start_time: 模拟结果对应的起始时间 - - Returns: - 存储结果信息 - """ - await SchemeRepository.store_scheme_simulation_result( - conn, - scheme_type, - scheme_name, - node_result_list, - link_result_list, - result_start_time, - ) - return {"message": "Scheme simulation results stored successfully"} - - -@router.get( - "/timeseries/schemes/records", summary="按方案、时间和属性查询数据" -) -async def query_scheme_records_by_scheme_time_property( - scheme_type: str = Query(..., description="方案类型"), - scheme_name: str = Query(..., description="方案名称"), - query_time: str = Query(..., description="查询时间"), - type: str = Query(..., description="元素类型,pipe(管道)或 junction(节点)"), - property: str = Query(..., description="要查询的属性名称"), - conn: AsyncConnection = Depends(get_timescale_connection), -): - """ - 按指定方案、时间和属性查询所有方案数据 - - 查询在特定方案和时间点,所有指定类型元素的特定属性值。 - - Args: - scheme_type: 方案类型 - scheme_name: 方案名称 - query_time: 查询时间 - type: 元素类型(pipe或junction) - property: 属性名称 - - Returns: - 查询结果列表 - - Raises: - HTTPException: 当查询参数无效时返回400错误 - """ - try: - results = await SchemeRepository.query_all_record_by_scheme_time_property( - conn, scheme_type, scheme_name, query_time, type, property - ) - return {"results": results} - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - -@router.get("/timeseries/schemes/simulation-results", summary="按ID和时间查询方案模拟数据") -async def query_scheme_simulation_by_id_time( - scheme_type: str = Query(..., description="方案类型"), - scheme_name: str = Query(..., description="方案名称"), - id: str = Query(..., description="元素ID(管道ID或节点ID)"), - type: str = Query(..., description="元素类型,pipe(管道)或 junction(节点)"), - query_time: str = Query(..., description="查询时间"), - conn: AsyncConnection = Depends(get_timescale_connection), -): - """ - 按指定ID和时间查询方案模拟结果 - - 查询特定方案中的元素在某一时间点的模拟数据。 - - Args: - scheme_type: 方案类型 - scheme_name: 方案名称 - id: 元素ID - type: 元素类型(pipe或junction) - query_time: 查询时间 - - Returns: - 模拟结果数据 - - Raises: - HTTPException: 当查询参数无效时返回400错误 - """ - try: - result = await SchemeRepository.query_scheme_simulation_result_by_id_time( - conn, scheme_type, scheme_name, id, type, query_time - ) - return {"result": result} - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) diff --git a/app/api/v1/router.py b/app/api/v1/router.py index 89133fe..1cd7a6d 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -7,20 +7,15 @@ from app.api.v1.endpoints import ( audit, burst_detection, burst_location, - extension, geocoding, leakage, meta, - misc, model_import, project, project_data, - risk, scada, - schemes, sensor_placement, simulation, - snapshots, web_search, ) from app.api.v1.endpoints.components import ( @@ -45,15 +40,14 @@ from app.api.v1.endpoints.network import ( valves, ) from app.api.v1.endpoints.timeseries import ( + analysis as ts_analysis, composite as ts_composite, realtime as ts_realtime, scada as ts_scada, - scheme as ts_scheme, ) from app.auth.permissions import ( BURST_RUN, OPTIMIZATION_RUN, - RISK_RUN, SCADA_CLEAN, SCADA_VIEW, SIMULATION_RUN, @@ -88,7 +82,6 @@ simulation_access = Depends( webgis_view_access = Depends(require_permission(WEBGIS_VIEW)) simulation_run_access = Depends(require_permission(SIMULATION_RUN)) burst_run_access = Depends(require_permission(BURST_RUN)) -risk_run_access = Depends(require_permission(RISK_RUN)) optimization_run_access = Depends(require_permission(OPTIMIZATION_RUN)) # Core services @@ -139,32 +132,16 @@ api_router.include_router( tags=["Simulation Control"], dependencies=[simulation_run_access], ) -api_router.include_router(scada.router, dependencies=[scada_access]) +api_router.include_router( + scada.router, + tags=["SCADA Metadata"], + dependencies=[scada_access], +) api_router.include_router( sensor_placement.router, tags=["Sensor Placement"], dependencies=[optimization_run_access], ) -api_router.include_router( - snapshots.router, - tags=["Snapshots"], - dependencies=[simulation_access], -) -api_router.include_router( - schemes.router, - tags=["Schemes"], - dependencies=[simulation_access], -) -api_router.include_router( - misc.router, - tags=["Misc"], - dependencies=[webgis_view_access], -) -api_router.include_router( - risk.router, - tags=["Risk"], - dependencies=[risk_run_access], -) api_router.include_router( web_search.router, tags=["Web Search"], @@ -194,7 +171,7 @@ api_router.include_router( # TimescaleDB data for endpoint_router, tag in ( (ts_realtime.router, "TimescaleDB - Realtime"), - (ts_scheme.router, "TimescaleDB - Scheme"), + (ts_analysis.router, "TimescaleDB - Analysis"), ): api_router.include_router( endpoint_router, @@ -217,8 +194,3 @@ api_router.include_router( tags=["Project Data"], dependencies=[webgis_view_access], ) -api_router.include_router( - extension.router, - tags=["Extension"], - dependencies=[webgis_access], -) diff --git a/app/core/config.py b/app/core/config.py index abb8d14..c8ea03d 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -38,9 +38,10 @@ class Settings(BaseSettings): PROJECT_PG_CACHE_SIZE: int = 50 PROJECT_TS_CACHE_SIZE: int = 50 + PROJECT_PG_POOL_MIN_SIZE: int = 0 PROJECT_PG_POOL_SIZE: int = 5 PROJECT_PG_MAX_OVERFLOW: int = 10 - PROJECT_TS_POOL_MIN_SIZE: int = 1 + PROJECT_TS_POOL_MIN_SIZE: int = 0 PROJECT_TS_POOL_MAX_SIZE: int = 10 # Keycloak access token verification diff --git a/app/domain/schemas/sensor_placement.py b/app/domain/schemas/sensor_placement.py index a8f503b..3852811 100644 --- a/app/domain/schemas/sensor_placement.py +++ b/app/domain/schemas/sensor_placement.py @@ -1,5 +1,6 @@ from datetime import datetime from typing import Literal +from uuid import UUID from pydantic import BaseModel, Field, field_validator @@ -23,7 +24,7 @@ class SensorPlacementOptimizeRequest(BaseModel): max_length=63, pattern=r"^[^/\\\x00]+$", ) - scheme_name: str = Field(..., min_length=1, max_length=32) + run_name: str = Field(..., min_length=1, max_length=64) sensor_type: Literal["pressure"] method: Literal["sensitivity", "kmeans"] sensor_count: int = Field(..., gt=0, le=200) @@ -39,27 +40,27 @@ class SensorPlacementOptimizeRequest(BaseModel): class SensorPlacementUpdateRequest(BaseModel): - expected_sensor_location: list[str] = Field( + expected_sensor_locations: list[str] = Field( ..., min_length=1, max_length=200, ) - sensor_location: list[str] = Field(..., min_length=1, max_length=200) + sensor_locations: list[str] = Field(..., min_length=1, max_length=200) - @field_validator("expected_sensor_location", "sensor_location") + @field_validator("expected_sensor_locations", "sensor_locations") @classmethod def validate_locations(cls, value: list[str]) -> list[str]: return _normalize_location_ids(value) class SensorPlacementExportRequest(BaseModel): - sensor_location: list[str] = Field(..., min_length=1, max_length=200) + sensor_locations: list[str] = Field(..., min_length=1, max_length=200) adjustment_status: dict[str, AdjustmentStatus] = Field( default_factory=dict, max_length=200, ) - @field_validator("sensor_location") + @field_validator("sensor_locations") @classmethod def validate_locations(cls, value: list[str]) -> list[str]: return _normalize_location_ids(value) @@ -81,12 +82,13 @@ class SensorPointResponse(BaseModel): class SensorPlacementSchemeResponse(BaseModel): - id: int - scheme_name: str - sensor_number: int + run_id: UUID + name: str + sensor_count: int min_diameter: int - username: str - create_time: datetime - sensor_location: list[str] + created_by: str + created_at: datetime + status: str + sensor_locations: list[str] sensor_points: list[SensorPointResponse] can_edit: bool = False diff --git a/app/infra/db/postgresql/analysis.py b/app/infra/db/postgresql/analysis.py new file mode 100644 index 0000000..7dd405a --- /dev/null +++ b/app/infra/db/postgresql/analysis.py @@ -0,0 +1,52 @@ +from uuid import UUID + +from psycopg import AsyncConnection + + +class AnalysisRepository: + @staticmethod + async def list_runs(conn: AsyncConnection) -> list[dict]: + async with conn.cursor() as cur: + await cur.execute( + """ + SELECT run_id, name, run_type, created_by, created_at, + started_at, status, parameters + FROM analysis.runs + ORDER BY created_at DESC, run_id + """ + ) + return await cur.fetchall() + + @staticmethod + async def get_run(conn: AsyncConnection, run_id: UUID) -> dict | None: + async with conn.cursor() as cur: + await cur.execute( + """ + SELECT run_id, name, run_type, created_by, created_at, + started_at, status, parameters + FROM analysis.runs + WHERE run_id = %s + """, + (run_id,), + ) + return await cur.fetchone() + + @staticmethod + async def list_results( + conn: AsyncConnection, run_id: UUID, result_type: str | None = None + ) -> list[dict]: + query = """ + SELECT result_id, run_id, result_type, node_id, link_id, + payload, created_at + FROM analysis.results + WHERE run_id = %s + """ + params: tuple[UUID] | tuple[UUID, str] = (run_id,) + if result_type is not None: + query += " AND result_type = %s" + params = (run_id, result_type) + query += " ORDER BY created_at, result_id" + + async with conn.cursor() as cur: + await cur.execute(query, params) + return await cur.fetchall() diff --git a/app/infra/db/postgresql/database.py b/app/infra/db/postgresql/database.py deleted file mode 100644 index ad19f15..0000000 --- a/app/infra/db/postgresql/database.py +++ /dev/null @@ -1,123 +0,0 @@ -import logging -from contextlib import asynccontextmanager -from typing import AsyncGenerator, Dict, Optional -import psycopg_pool -from psycopg.rows import dict_row -from app.infra.db.project_routing import get_project_pgconn_string - -# Configure logging -logger = logging.getLogger(__name__) - - -class Database: - def __init__(self, db_name=None): - self.pool = None - self.db_name = db_name - self.conninfo = None - - def init_pool(self, db_name=None): - """Initialize the connection pool.""" - # Use provided db_name, or the one from constructor, or default from config - target_db_name = db_name or self.db_name - - # Get connection string, handling default case where target_db_name might be None - if target_db_name: - conn_string = get_project_pgconn_string(db_name=target_db_name) - else: - conn_string = get_project_pgconn_string() - self.conninfo = conn_string - - try: - self.pool = psycopg_pool.AsyncConnectionPool( - conninfo=conn_string, - min_size=5, - max_size=20, - open=False, # Don't open immediately, wait for startup - kwargs={"row_factory": dict_row}, # Return rows as dictionaries - ) - logger.info(f"PostgreSQL connection pool initialized for database: {target_db_name or 'default'}") - except Exception as e: - logger.error(f"Failed to initialize postgresql connection pool: {e}") - raise - - async def open(self): - if self.pool: - await self.pool.open() - - async def close(self): - """Close the connection pool.""" - if self.pool: - await self.pool.close() - logger.info("PostgreSQL connection pool closed.") - - @asynccontextmanager - async def get_connection(self) -> AsyncGenerator: - """Get a connection from the pool.""" - if not self.pool: - raise Exception("Database pool is not initialized.") - - async with self.pool.connection() as conn: - yield conn - - -# 默认数据库实例 -db = Database() - -# 缓存不同数据库的实例 - 避免重复创建连接池 -_database_instances: Dict[str, Database] = {} - - -def create_database_instance(db_name): - """Create a new Database instance for a specific database.""" - return Database(db_name=db_name) - - -async def get_database_instance(db_name: Optional[str] = None) -> Database: - """Get or create a database instance for the specified database name.""" - if not db_name: - return db # 返回默认数据库实例 - - expected_conninfo = get_project_pgconn_string(db_name=db_name) - existing = _database_instances.get(db_name) - if existing is not None and existing.conninfo != expected_conninfo: - await existing.close() - del _database_instances[db_name] - - if db_name not in _database_instances: - # 创建新的数据库实例 - instance = create_database_instance(db_name) - instance.init_pool() - await instance.open() - _database_instances[db_name] = instance - logger.info(f"Created new database instance for: {db_name}") - - return _database_instances[db_name] - - -async def get_db_connection(): - """Dependency for FastAPI to get a database connection.""" - async with db.get_connection() as conn: - yield conn - - -async def get_database_connection(db_name: Optional[str] = None): - """ - FastAPI dependency to get database connection with optional database name. - 使用方法: conn: AsyncConnection = Depends(lambda: get_database_connection("your_db_name")) - 或在路由函数中: conn: AsyncConnection = Depends(get_database_connection) - """ - instance = await get_database_instance(db_name) - async with instance.get_connection() as conn: - yield conn - - -async def cleanup_database_instances(): - """Clean up all database instances (call this on application shutdown).""" - for db_name, instance in _database_instances.items(): - await instance.close() - logger.info(f"Closed database instance for: {db_name}") - _database_instances.clear() - - # 关闭默认数据库 - await db.close() - logger.info("All database instances cleaned up.") diff --git a/app/infra/db/postgresql/scada.py b/app/infra/db/postgresql/scada.py index ef33852..6248c81 100644 --- a/app/infra/db/postgresql/scada.py +++ b/app/infra/db/postgresql/scada.py @@ -11,6 +11,10 @@ def _optional_float(value: Any) -> float | None: return float(value) if value is not None else None +def _optional_int(value: Any) -> int | None: + return int(value) if value is not None else None + + class ScadaInfoRepository: """Read SCADA metadata from the current project's business database.""" @@ -19,33 +23,34 @@ class ScadaInfoRepository: async with conn.cursor() as cur: await cur.execute( """ - SELECT id, - type, - associated_element_id, + SELECT id AS device_id, + device_type, + node_id, + link_id, api_query_id, transmission_mode, transmission_frequency, reliability, - x_coor, - y_coor - FROM public.scada_info + x, + y + FROM gis.scada_devices + ORDER BY id """ ) records = await cur.fetchall() return [ { - "id": str(record["id"]).strip(), - "type": str(record["type"]).strip().lower(), - "associated_element_id": _optional_text( - record["associated_element_id"] - ), - "api_query_id": record["api_query_id"], + "device_id": str(record["device_id"]).strip(), + "device_type": str(record["device_type"]).strip().lower(), + "node_id": _optional_text(record["node_id"]), + "link_id": _optional_text(record["link_id"]), + "api_query_id": _optional_text(record["api_query_id"]), "transmission_mode": record["transmission_mode"], "transmission_frequency": record["transmission_frequency"], - "reliability": _optional_float(record["reliability"]), - "x": _optional_float(record["x_coor"]), - "y": _optional_float(record["y_coor"]), + "reliability": _optional_int(record["reliability"]), + "x": _optional_float(record["x"]), + "y": _optional_float(record["y"]), } for record in records ] diff --git a/app/infra/db/postgresql/scada_assets.py b/app/infra/db/postgresql/scada_assets.py new file mode 100644 index 0000000..4e501e8 --- /dev/null +++ b/app/infra/db/postgresql/scada_assets.py @@ -0,0 +1,66 @@ +from typing import Any + +from app.native.wndb.core.database import read_all, try_read + + +def get_scada_info_schema(name: str) -> dict[str, dict[str, Any]]: + return { + "device_id": {"type": "str", "optional": False, "readonly": True}, + "device_type": {"type": "str", "optional": False, "readonly": True}, + "node_id": {"type": "str", "optional": True, "readonly": True}, + "link_id": {"type": "str", "optional": True, "readonly": True}, + "api_query_id": {"type": "str", "optional": True, "readonly": True}, + "transmission_mode": {"type": "str", "optional": False, "readonly": True}, + "transmission_frequency": {"type": "str", "optional": False, "readonly": True}, + "reliability": {"type": "int", "optional": False, "readonly": True}, + "x": {"type": "float", "optional": True, "readonly": True}, + "y": {"type": "float", "optional": True, "readonly": True}, + } + + +_SELECT = """ + SELECT device_id, device_type, node_id, link_id, api_query_id, + transmission_mode, transmission_frequency, reliability, + x, y + FROM asset.scada_devices +""" + +_SELECT_MATERIALIZED = """ + SELECT id AS device_id, device_type, node_id, link_id, api_query_id, + transmission_mode, transmission_frequency, reliability, + x, y + FROM gis.scada_devices +""" + + +def _device(row: dict[str, Any]) -> dict[str, Any]: + return { + "device_id": str(row["device_id"]), + "device_type": str(row["device_type"]), + "node_id": str(row["node_id"]) if row["node_id"] is not None else None, + "link_id": str(row["link_id"]) if row["link_id"] is not None else None, + "api_query_id": ( + str(row["api_query_id"]) if row["api_query_id"] is not None else None + ), + "transmission_mode": str(row["transmission_mode"]), + "transmission_frequency": str(row["transmission_frequency"]), + "reliability": int(row["reliability"]), + "x": float(row["x"]) if row["x"] is not None else None, + "y": float(row["y"]) if row["y"] is not None else None, + } + + +def get_scada_info(name: str, device_id: str) -> dict[str, Any]: + row = try_read( + name, + _SELECT + " WHERE device_id = %s", + (device_id,), + ) + return _device(row) if row else {} + + +def get_all_scada_info(name: str) -> list[dict[str, Any]]: + return [ + _device(row) + for row in read_all(name, _SELECT_MATERIALIZED + " ORDER BY device_id") + ] diff --git a/app/infra/db/postgresql/scheme.py b/app/infra/db/postgresql/scheme.py deleted file mode 100644 index ed9c240..0000000 --- a/app/infra/db/postgresql/scheme.py +++ /dev/null @@ -1,104 +0,0 @@ -from typing import List, Optional, Any -from psycopg import AsyncConnection - - -class SchemeRepository: - - @staticmethod - async def get_schemes(conn: AsyncConnection) -> List[dict]: - """ - 查询pg数据库中, scheme_list 的所有记录 - :param conn: 异步数据库连接 - :return: 包含所有记录的列表, 每条记录为一个字典 - """ - async with conn.cursor() as cur: - await cur.execute( - """ - SELECT scheme_id, scheme_name, scheme_type, username, create_time, scheme_start_time, scheme_detail - FROM public.scheme_list - """ - ) - records = await cur.fetchall() - - scheme_list = [] - for record in records: - scheme_list.append( - { - "scheme_id": record["scheme_id"], - "scheme_name": record["scheme_name"], - "scheme_type": record["scheme_type"], - "username": record["username"], - "create_time": record["create_time"], - "scheme_start_time": record["scheme_start_time"], - "scheme_detail": record["scheme_detail"], - } - ) - - return scheme_list - - @staticmethod - async def get_burst_locate_results(conn: AsyncConnection) -> List[dict]: - """ - 查询pg数据库中, burst_locate_result 的所有记录 - :param conn: 异步数据库连接 - :return: 包含所有记录的列表, 每条记录为一个字典 - """ - async with conn.cursor() as cur: - await cur.execute( - """ - SELECT id, type, burst_incident, leakage, detect_time, locate_result - FROM public.burst_locate_result - """ - ) - records = await cur.fetchall() - - results = [] - for record in records: - results.append( - { - "id": record["id"], - "type": record["type"], - "burst_incident": record["burst_incident"], - "leakage": record["leakage"], - "detect_time": record["detect_time"], - "locate_result": record["locate_result"], - } - ) - - return results - - @staticmethod - async def get_burst_locate_result_by_incident( - conn: AsyncConnection, burst_incident: str - ) -> List[dict]: - """ - 根据 burst_incident 查询爆管定位结果 - :param conn: 异步数据库连接 - :param burst_incident: 爆管事件标识 - :return: 包含匹配记录的列表 - """ - async with conn.cursor() as cur: - await cur.execute( - """ - SELECT id, type, burst_incident, leakage, detect_time, locate_result - FROM public.burst_locate_result - WHERE burst_incident = %s - """, - (burst_incident,), - ) - records = await cur.fetchall() - - results = [] - for record in records: - results.append( - { - "id": record["id"], - "type": record["type"], - "burst_incident": record["burst_incident"], - "leakage": record["leakage"], - "detect_time": record["detect_time"], - "locate_result": record["locate_result"], - } - ) - - return results diff --git a/app/infra/db/postgresql/sensor_placement.py b/app/infra/db/postgresql/sensor_placement.py new file mode 100644 index 0000000..3ce1267 --- /dev/null +++ b/app/infra/db/postgresql/sensor_placement.py @@ -0,0 +1,189 @@ +from typing import Any +from uuid import UUID, uuid4 + +from psycopg.rows import dict_row +from psycopg.types.json import Jsonb + +from app.native.wndb.core.connection import project_connection + + +RUN_TYPE = "sensor_placement" +RESULT_TYPE = "sensor_placement" + + +def _placement_row(row: dict[str, Any]) -> dict[str, Any]: + payload = row.get("payload") if isinstance(row.get("payload"), dict) else {} + locations = [str(item) for item in payload.get("sensor_locations", [])] + return { + "run_id": row["run_id"], + "name": row["name"], + "sensor_count": len(locations), + "min_diameter": int(payload.get("minimum_diameter", 0)), + "created_by": row["created_by"], + "created_at": row["created_at"], + "status": row["status"], + "sensor_locations": locations, + } + + +def get_all_sensor_placements(name: str) -> list[dict[str, Any]]: + with project_connection(name) as conn, conn.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT r.run_id, r.name, r.created_by, r.created_at, r.status, + result.payload + FROM analysis.runs AS r + JOIN LATERAL ( + SELECT payload + FROM analysis.results + WHERE run_id = r.run_id AND result_type = %s + ORDER BY created_at DESC, result_id DESC + LIMIT 1 + ) AS result ON true + WHERE r.run_type = %s + ORDER BY r.created_at DESC, r.run_id + """, + (RESULT_TYPE, RUN_TYPE), + ) + return [_placement_row(row) for row in cur.fetchall()] + + +def create_sensor_placement( + name: str, + *, + run_name: str, + min_diameter: int, + created_by: str, + sensor_locations: list[str], +) -> dict[str, Any]: + run_id = uuid4() + payload = { + "sensor_number": len(sensor_locations), + "minimum_diameter": min_diameter, + "sensor_locations": sensor_locations, + } + with project_connection(name) as conn, conn.transaction(): + with conn.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + INSERT INTO analysis.runs + (run_id, name, run_type, created_by, started_at, status, parameters) + VALUES (%s, %s, %s, %s, now(), 'completed', '{}'::jsonb) + RETURNING run_id, name, created_by, created_at, status + """, + (run_id, run_name, RUN_TYPE, created_by), + ) + created = cur.fetchone() + cur.execute( + """ + INSERT INTO analysis.results (run_id, result_type, payload) + VALUES (%s, %s, %s) + """, + (run_id, RESULT_TYPE, Jsonb(payload)), + ) + if created is None: + raise RuntimeError("监测点优化运行写入失败") + return _placement_row(dict(created) | {"payload": payload}) + + +def get_sensor_placement(name: str, run_id: UUID) -> dict[str, Any] | None: + with project_connection(name) as conn, conn.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT r.run_id, r.name, r.created_by, r.created_at, r.status, + result.payload + FROM analysis.runs AS r + JOIN LATERAL ( + SELECT payload + FROM analysis.results + WHERE run_id = r.run_id AND result_type = %s + ORDER BY created_at DESC, result_id DESC + LIMIT 1 + ) AS result ON true + WHERE r.run_id = %s AND r.run_type = %s + """, + (RESULT_TYPE, run_id, RUN_TYPE), + ) + row = cur.fetchone() + return _placement_row(row) if row else None + + +def get_sensor_placement_nodes( + name: str, + node_ids: list[str], +) -> list[dict[str, Any]]: + if not node_ids: + return [] + with project_connection(name) as conn, conn.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + WITH incident_pipe_diameters AS ( + SELECT node_id, MAX(diameter) AS max_pipe_diameter + FROM ( + SELECT l.start_node_id AS node_id, p.diameter + FROM network.pipes AS p + JOIN network.links AS l ON l.id = p.link_id + WHERE l.start_node_id = ANY(%s) + UNION ALL + SELECT l.end_node_id AS node_id, p.diameter + FROM network.pipes AS p + JOIN network.links AS l ON l.id = p.link_id + WHERE l.end_node_id = ANY(%s) + ) AS incident_pipes + GROUP BY node_id + ) + SELECT j.node_id, + ipd.max_pipe_diameter, + j.elevation, + ST_X(g.geom) AS project_x, + ST_Y(g.geom) AS project_y, + ST_X(ST_Transform(g.geom, 3857)) AS map_x, + ST_Y(ST_Transform(g.geom, 3857)) AS map_y + FROM network.junctions AS j + JOIN gis.node_geometries AS g ON g.node_id = j.node_id + LEFT JOIN incident_pipe_diameters AS ipd ON ipd.node_id = j.node_id + WHERE j.node_id = ANY(%s) + ORDER BY j.node_id + """, + (node_ids, node_ids, node_ids), + ) + return list(cur.fetchall()) + + +def update_sensor_placement( + name: str, + run_id: UUID, + *, + expected_sensor_locations: list[str], + sensor_locations: list[str], +) -> dict[str, Any] | None: + with project_connection(name) as conn, conn.transaction(): + with conn.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT result_id, payload + FROM analysis.results + WHERE run_id = %s AND result_type = %s + ORDER BY created_at DESC, result_id DESC + LIMIT 1 + FOR UPDATE + """, + (run_id, RESULT_TYPE), + ) + result = cur.fetchone() + if result is None: + return None + payload = result["payload"] if isinstance(result["payload"], dict) else {} + current = [str(item) for item in payload.get("sensor_locations", [])] + if current != expected_sensor_locations: + return None + payload = { + **payload, + "sensor_number": len(sensor_locations), + "sensor_locations": sensor_locations, + } + cur.execute( + "UPDATE analysis.results SET payload = %s WHERE result_id = %s", + (Jsonb(payload), result["result_id"]), + ) + return get_sensor_placement(name, run_id) diff --git a/app/infra/db/timescaledb/__init__.py b/app/infra/db/timescaledb/__init__.py index 1ab85a9..3261953 100644 --- a/app/infra/db/timescaledb/__init__.py +++ b/app/infra/db/timescaledb/__init__.py @@ -1,2 +1 @@ -from .database import * -from .composite_queries import CompositeQueries \ No newline at end of file +from .composite_queries import CompositeQueries diff --git a/app/infra/db/timescaledb/composite_queries.py b/app/infra/db/timescaledb/composite_queries.py index 6baf715..9b83074 100644 --- a/app/infra/db/timescaledb/composite_queries.py +++ b/app/infra/db/timescaledb/composite_queries.py @@ -5,15 +5,16 @@ from typing import Any, Dict, List, Optional, Tuple import numpy as np import pandas as pd from psycopg import AsyncConnection +from uuid import UUID -import app.native.wndb as wndb from app.algorithms.cleaning.flow import clean_flow_data_df_kf from app.algorithms.cleaning.pressure import clean_pressure_data_df_km from app.algorithms.health.analyzer import PipelineHealthAnalyzer from app.infra.db.postgresql.scada import ScadaInfoRepository from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository -from app.infra.db.timescaledb.repositories.scheme import SchemeRepository +from app.infra.db.timescaledb.repositories.analysis import AnalysisResultsRepository from app.infra.db.timescaledb.repositories.scada import ScadaRepository +from app.native.wndb.model.pipes import get_pipes_by_property class CompositeQueries: @@ -26,7 +27,7 @@ class CompositeQueries: postgres_conn: AsyncConnection, ) -> Dict[str, Dict[str, Any]]: scadas = await ScadaInfoRepository.get_scadas(postgres_conn) - return {scada["id"]: scada for scada in scadas} + return {scada["device_id"]: scada for scada in scadas} @staticmethod async def get_scada_associated_realtime_simulation_data( @@ -63,8 +64,12 @@ class CompositeQueries: if not target_scada: raise ValueError(f"SCADA device {device_id} not found") - element_id = target_scada["associated_element_id"] - scada_type = target_scada["type"] + 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 模拟数据 @@ -85,17 +90,16 @@ class CompositeQueries: return result @staticmethod - async def get_scada_associated_scheme_simulation_data( + async def get_scada_associated_analysis_simulation_data( timescale_conn: AsyncConnection, postgres_conn: AsyncConnection, device_ids: List[str], start_time: datetime, end_time: datetime, - scheme_type: str, - scheme_name: str, + run_id: UUID, ) -> Dict[str, List[Dict[str, Any]]]: """ - 获取 SCADA 关联的 link/node scheme 模拟值 + 获取 SCADA 关联的 link/node 分析模拟值 根据传入的 SCADA device_ids,找到关联的 link/node, 并根据对应的 type,查询对应的模拟数据 @@ -121,30 +125,22 @@ class CompositeQueries: if not target_scada: raise ValueError(f"SCADA device {device_id} not found") - element_id = target_scada["associated_element_id"] - scada_type = target_scada["type"] + 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 SchemeRepository.get_link_field_by_scheme_and_time_range( - timescale_conn, - scheme_type, - scheme_name, - start_time, - end_time, - element_id, - "flow", + res = await AnalysisResultsRepository.get_link_series( + timescale_conn, run_id, element_id, start_time, end_time, "flow" ) elif scada_type == "pressure": # 查询 node 模拟数据 - res = await SchemeRepository.get_node_field_by_scheme_and_time_range( - timescale_conn, - scheme_type, - scheme_name, - start_time, - end_time, - element_id, - "pressure", + res = await AnalysisResultsRepository.get_node_series( + timescale_conn, run_id, element_id, start_time, end_time, "pressure" ) else: raise ValueError(f"Unknown SCADA type: {scada_type}") @@ -201,16 +197,15 @@ class CompositeQueries: return result @staticmethod - async def get_scheme_simulation_data( + async def get_analysis_simulation_data( timescale_conn: AsyncConnection, feature_infos: List[Tuple[str, str]], start_time: datetime, end_time: datetime, - scheme_type: str, - scheme_name: str, + run_id: UUID, ) -> Dict[str, List[Dict[str, Any]]]: """ - 获取 link/node scheme 模拟值 + 获取 link/node 分析模拟值 根据传入的 feature_infos,找到关联的 link/node, 并根据对应的 type,查询对应的模拟数据 @@ -220,8 +215,7 @@ class CompositeQueries: feature_infos: 传入的 feature 信息列表,包含 (element_id, type) start_time: 开始时间 end_time: 结束时间 - scheme_type: 工况类型 - scheme_name: 工况名称 + run_id: 分析运行 ID Returns: 模拟数据字典,以 feature_id 为键,值为数据列表,每个数据包含 time, value 和 feature_id @@ -233,25 +227,13 @@ class CompositeQueries: for feature_id, feature_type in feature_infos: if feature_type.lower() == "pipe": # 查询 link 模拟数据 - res = await SchemeRepository.get_link_field_by_scheme_and_time_range( - timescale_conn, - scheme_type, - scheme_name, - start_time, - end_time, - feature_id, - "flow", + res = await AnalysisResultsRepository.get_link_series( + timescale_conn, run_id, feature_id, start_time, end_time, "flow" ) elif feature_type.lower() == "junction": # 查询 node 模拟数据 - res = await SchemeRepository.get_node_field_by_scheme_and_time_range( - timescale_conn, - scheme_type, - scheme_name, - start_time, - end_time, - feature_id, - "pressure", + res = await AnalysisResultsRepository.get_node_series( + timescale_conn, run_id, feature_id, start_time, end_time, "pressure" ) else: raise ValueError(f"Unknown type: {feature_type}") @@ -296,7 +278,7 @@ class CompositeQueries: ( scada for scada in scada_by_id.values() - if scada["associated_element_id"] == element_id + if (scada.get("node_id") or scada.get("link_id")) == element_id ), None, ) @@ -304,7 +286,7 @@ class CompositeQueries: if not associated_scada: return None - device_id = associated_scada["id"] + device_id = associated_scada["device_id"] data_field = "cleaned_value" if use_cleaned else "monitored_value" @@ -358,7 +340,7 @@ class CompositeQueries: unsupported_ids = [ device_id for device_id in device_ids - if scada_by_id[device_id]["type"] not in supported_types + if scada_by_id[device_id]["device_type"] not in supported_types ] if unsupported_ids: raise ValueError( @@ -368,7 +350,7 @@ class CompositeQueries: device_ids = [ device_id for device_id, info in scada_by_id.items() - if info["type"] in supported_types + if info["device_type"] in supported_types ] if not device_ids: @@ -409,12 +391,12 @@ class CompositeQueries: pressure_ids = [ device_id for device_id in df.columns - if scada_by_id[device_id]["type"] == "pressure" + if scada_by_id[device_id]["device_type"] == "pressure" ] flow_ids = [ device_id for device_id in df.columns - if scada_by_id[device_id]["type"] in {"pipe_flow", "flow"} + if scada_by_id[device_id]["device_type"] in {"pipe_flow", "flow"} ] updated_rows = 0 @@ -497,7 +479,7 @@ class CompositeQueries: # 批量查询这些管道的详细信息 fields = ["id", "diameter", "node1", "node2"] - all_links = wndb.get_pipes_by_property(network_name, fields=fields) + all_links = get_pipes_by_property(network_name, fields=fields) # 转换为字典以快速查找 links_dict = {link["id"]: link for link in all_links} diff --git a/app/infra/db/timescaledb/database.py b/app/infra/db/timescaledb/database.py deleted file mode 100644 index fd2bcd4..0000000 --- a/app/infra/db/timescaledb/database.py +++ /dev/null @@ -1,132 +0,0 @@ -import logging -from contextlib import asynccontextmanager -from typing import AsyncGenerator, Dict, Optional -import psycopg_pool -from psycopg.rows import dict_row -from app.infra.db.project_routing import get_project_timescale_pgconn_string - -# Configure logging -logger = logging.getLogger(__name__) - - -class Database: - def __init__(self, db_name=None): - self.pool = None - self.db_name = db_name - self.conninfo = None - - def init_pool(self, db_name=None): - """Initialize the connection pool.""" - # Use provided db_name, or the one from constructor, or default from config - target_db_name = db_name or self.db_name - - # Get connection string, handling default case where target_db_name might be None - if target_db_name: - conn_string = get_project_timescale_pgconn_string(db_name=target_db_name) - else: - conn_string = get_project_timescale_pgconn_string() - self.conninfo = conn_string - - try: - self.pool = psycopg_pool.AsyncConnectionPool( - conninfo=conn_string, - min_size=5, - max_size=20, - open=False, # Don't open immediately, wait for startup - kwargs={"row_factory": dict_row}, # Return rows as dictionaries - ) - logger.info( - f"TimescaleDB connection pool initialized for database: {target_db_name or 'default'}" - ) - except Exception as e: - logger.error(f"Failed to initialize TimescaleDB connection pool: {e}") - raise - - async def open(self): - if self.pool: - await self.pool.open() - - async def close(self): - """Close the connection pool.""" - if self.pool: - await self.pool.close() - logger.info("TimescaleDB connection pool closed.") - - def get_pgconn_string(self, db_name=None): - """Get the TimescaleDB connection string.""" - target_db_name = db_name or self.db_name - if target_db_name: - return get_project_timescale_pgconn_string(db_name=target_db_name) - return get_project_timescale_pgconn_string() - - @asynccontextmanager - async def get_connection(self) -> AsyncGenerator: - """Get a connection from the pool.""" - if not self.pool: - raise Exception("Database pool is not initialized.") - - async with self.pool.connection() as conn: - yield conn - - -# 默认数据库实例 -db = Database() - -# 缓存不同数据库的实例 - 避免重复创建连接池 -_database_instances: Dict[str, Database] = {} - - -def create_database_instance(db_name): - """Create a new Database instance for a specific database.""" - return Database(db_name=db_name) - - -async def get_database_instance(db_name: Optional[str] = None) -> Database: - """Get or create a database instance for the specified database name.""" - if not db_name: - return db # 返回默认数据库实例 - - expected_conninfo = get_project_timescale_pgconn_string(db_name=db_name) - existing = _database_instances.get(db_name) - if existing is not None and existing.conninfo != expected_conninfo: - await existing.close() - del _database_instances[db_name] - - if db_name not in _database_instances: - # 创建新的数据库实例 - instance = create_database_instance(db_name) - instance.init_pool() - await instance.open() - _database_instances[db_name] = instance - logger.info(f"Created new database instance for: {db_name}") - - return _database_instances[db_name] - - -async def get_db_connection(): - """Dependency for FastAPI to get a database connection.""" - async with db.get_connection() as conn: - yield conn - - -async def get_database_connection(db_name: Optional[str] = None): - """ - FastAPI dependency to get database connection with optional database name. - 使用方法: conn: AsyncConnection = Depends(lambda: get_database_connection("your_db_name")) - 或在路由函数中: conn: AsyncConnection = Depends(get_database_connection) - """ - instance = await get_database_instance(db_name) - async with instance.get_connection() as conn: - yield conn - - -async def cleanup_database_instances(): - """Clean up all database instances (call this on application shutdown).""" - for db_name, instance in _database_instances.items(): - await instance.close() - logger.info(f"Closed database instance for: {db_name}") - _database_instances.clear() - - # 关闭默认数据库 - await db.close() - logger.info("All database instances cleaned up.") diff --git a/app/infra/db/timescaledb/internal_queries.py b/app/infra/db/timescaledb/internal_queries.py index b6d4cbb..2c69db7 100644 --- a/app/infra/db/timescaledb/internal_queries.py +++ b/app/infra/db/timescaledb/internal_queries.py @@ -2,12 +2,12 @@ from typing import List from fastapi.logger import logger from datetime import datetime, timedelta -import psycopg from psycopg import sql from psycopg.rows import dict_row import time from app.infra.db.project_routing import get_project_timescale_pgconn_string -from app.infra.db.timescaledb.repositories.scheme import SchemeRepository +from app.infra.db.timescaledb.sync_pool import timescale_connection +from app.infra.db.timescaledb.repositories.analysis import AnalysisResultsRepository from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository from app.infra.db.timescaledb.repositories.scada import ScadaRepository from app.services.time_api import parse_utc_time @@ -25,12 +25,7 @@ class InternalStorage: """存储实时模拟结果""" for attempt in range(max_retries): try: - conn_string = ( - get_project_timescale_pgconn_string(db_name=db_name) - if db_name - else get_project_timescale_pgconn_string() - ) - with psycopg.Connection.connect(conn_string) as conn: + with timescale_connection(db_name) as conn: RealtimeRepository.store_realtime_simulation_result_sync( conn, node_result_list, link_result_list, result_start_time ) @@ -43,9 +38,8 @@ class InternalStorage: raise # 达到最大重试次数后抛出异常 @staticmethod - def store_scheme_simulation( - scheme_type: str, - scheme_name: str, + def store_analysis_simulation( + run_id, node_result_list: List[dict], link_result_list: List[dict], result_start_time: str, @@ -54,24 +48,16 @@ class InternalStorage: db_name: str = None, max_retries: int = 3, ): - """存储方案模拟结果""" + """Store immutable simulation results for one analysis run.""" for attempt in range(max_retries): try: - conn_string = ( - get_project_timescale_pgconn_string(db_name=db_name) - if db_name - else get_project_timescale_pgconn_string() - ) - with psycopg.Connection.connect(conn_string) as conn: - SchemeRepository.store_scheme_simulation_result_sync( - conn, - scheme_type, - scheme_name, - node_result_list, - link_result_list, - result_start_time, - num_periods, - result_timestep_seconds, + with timescale_connection(db_name) as conn: + node_rows, link_rows = AnalysisResultsRepository.prepare_simulation_rows( + node_result_list, link_result_list, result_start_time, + num_periods, result_timestep_seconds or 3600, + ) + AnalysisResultsRepository.store_results_sync( + conn, run_id, node_rows, link_rows ) break # 成功 except Exception as e: @@ -98,12 +84,7 @@ class InternalQueries: for attempt in range(max_retries): try: - conn_string = ( - get_project_timescale_pgconn_string(db_name=db_name) - if db_name - else get_project_timescale_pgconn_string() - ) - with psycopg.Connection.connect(conn_string) as conn: + with timescale_connection(db_name) as conn: rows = ScadaRepository.get_scada_by_ids_time_range_sync( conn, device_ids, start_time, end_time ) @@ -139,12 +120,7 @@ class InternalQueries: for attempt in range(max_retries): try: - conn_string = ( - get_project_timescale_pgconn_string(db_name=db_name) - if db_name - else get_project_timescale_pgconn_string() - ) - with psycopg.Connection.connect(conn_string) as conn: + with timescale_connection(db_name) as conn: rows = ScadaRepository.get_scada_by_ids_time_range_sync( conn, device_ids, start_dt, end_dt ) @@ -184,12 +160,7 @@ class InternalQueries: ) for attempt in range(max_retries): try: - conn_string = ( - get_project_timescale_pgconn_string(db_name=db_name) - if db_name - else get_project_timescale_pgconn_string() - ) - with psycopg.Connection.connect(conn_string) as conn: + with timescale_connection(db_name) as conn: return ScadaRepository.get_latest_scada_time_sync( conn, device_ids, @@ -224,20 +195,19 @@ class InternalQueries: ) @staticmethod - def query_scheme_simulation_by_ids_timerange( + def query_analysis_simulation_by_ids_timerange( element_ids: List[str], start_time: str | datetime, end_time: str | datetime, element_type: str, field: str, - scheme_type: str, - scheme_name: str, + run_id, db_name: str = None, max_retries: int = 3, ) -> dict[str, list[dict]]: - """查询方案模拟结果,返回 {id: [{time, value}, ...]}。""" + """Query one analysis run, returning {id: [{time, value}, ...]}.""" return InternalQueries._query_simulation_by_ids_timerange( - schema_name="scheme", + schema_name="analysis", element_ids=element_ids, start_time=start_time, end_time=end_time, @@ -245,8 +215,7 @@ class InternalQueries: field=field, db_name=db_name, max_retries=max_retries, - scheme_type=scheme_type, - scheme_name=scheme_name, + run_id=run_id, ) @staticmethod @@ -260,8 +229,7 @@ class InternalQueries: field: str, db_name: str = None, max_retries: int = 3, - scheme_type: str | None = None, - scheme_name: str | None = None, + run_id=None, ) -> dict[str, list[dict]]: normalized_element_ids = list( dict.fromkeys( @@ -275,51 +243,44 @@ class InternalQueries: start_dt = parse_utc_time(start_time, field_name="start_time") end_dt = parse_utc_time(end_time, field_name="end_time") - table_name, valid_fields = InternalQueries._resolve_simulation_table(element_type) + table_name, id_column, valid_fields = InternalQueries._resolve_simulation_table(element_type) if field not in valid_fields: raise ValueError(f"Invalid field for {element_type}: {field}") - if schema_name not in {"realtime", "scheme"}: + if schema_name not in {"realtime", "analysis"}: raise ValueError(f"Unsupported schema_name: {schema_name}") - if schema_name == "scheme" and (not scheme_type or not scheme_name): - raise ValueError("scheme 查询必须提供 scheme_type 和 scheme_name。") + if schema_name == "analysis" and run_id is None: + raise ValueError("analysis query requires run_id") for attempt in range(max_retries): try: - conn_string = ( - get_project_timescale_pgconn_string(db_name=db_name) - if db_name - else get_project_timescale_pgconn_string() - ) - with psycopg.Connection.connect(conn_string) as conn: + with timescale_connection(db_name) as conn: with conn.cursor(row_factory=dict_row) as cur: - if schema_name == "scheme": + if schema_name == "analysis": query = sql.SQL( - "SELECT btrim(id::text) AS id, time, {} FROM {}.{} " - "WHERE scheme_type = %s AND scheme_name = %s " - "AND time >= %s AND time <= %s AND btrim(id::text) = ANY(%s)" + "SELECT btrim({}::text) AS id, time, {} FROM {}.{} " + "WHERE run_id = %s AND time >= %s AND time <= %s " + "AND btrim({}::text) = ANY(%s)" ).format( + sql.Identifier(id_column), sql.Identifier(field), sql.Identifier(schema_name), sql.Identifier(table_name), + sql.Identifier(id_column), ) cur.execute( query, - ( - scheme_type, - scheme_name, - start_dt, - end_dt, - normalized_element_ids, - ), + (run_id, start_dt, end_dt, normalized_element_ids), ) else: query = sql.SQL( - "SELECT btrim(id::text) AS id, time, {} FROM {}.{} " - "WHERE time >= %s AND time <= %s AND btrim(id::text) = ANY(%s)" + "SELECT btrim({}::text) AS id, time, {} FROM {}.{} " + "WHERE time >= %s AND time <= %s AND btrim({}::text) = ANY(%s)" ).format( + sql.Identifier(id_column), sql.Identifier(field), sql.Identifier(schema_name), sql.Identifier(table_name), + sql.Identifier(id_column), ) cur.execute(query, (start_dt, end_dt, normalized_element_ids)) rows = cur.fetchall() @@ -342,12 +303,12 @@ class InternalQueries: raise @staticmethod - def _resolve_simulation_table(element_type: str) -> tuple[str, set[str]]: + def _resolve_simulation_table(element_type: str) -> tuple[str, str, set[str]]: normalized_type = element_type.lower() if normalized_type == "node": - return "node_simulation", {"actual_demand", "total_head", "pressure", "quality"} + return "node_results", "node_id", {"actual_demand", "total_head", "pressure", "quality"} if normalized_type == "link": - return "link_simulation", { + return "link_results", "link_id", { "flow", "friction", "headloss", diff --git a/app/infra/db/timescaledb/repositories/analysis.py b/app/infra/db/timescaledb/repositories/analysis.py new file mode 100644 index 0000000..b2799d5 --- /dev/null +++ b/app/infra/db/timescaledb/repositories/analysis.py @@ -0,0 +1,268 @@ +from datetime import datetime, timedelta +from typing import Any +from uuid import UUID + +from psycopg import AsyncConnection, Connection, sql + +from app.services.time_api import parse_utc_time + + +class AnalysisResultsRepository: + NODE_FIELDS = {"actual_demand", "total_head", "pressure", "quality"} + LINK_FIELDS = { + "flow", + "friction", + "headloss", + "quality", + "reaction", + "setting", + "status", + "velocity", + } + + @staticmethod + def prepare_simulation_rows( + node_results: list[dict[str, Any]], + link_results: list[dict[str, Any]], + result_start_time: str, + num_periods: int, + result_timestep_seconds: int, + ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + start_time = parse_utc_time( + result_start_time, field_name="result_start_time" + ) + timestep = timedelta(seconds=result_timestep_seconds) + node_rows: list[dict[str, Any]] = [] + for node_result in node_results: + for period_index, values in enumerate( + node_result.get("result", [])[:num_periods] + ): + node_rows.append( + { + "time": start_time + timestep * period_index, + "node_id": node_result["node"], + "actual_demand": values.get("demand"), + "total_head": values.get("head"), + "pressure": values.get("pressure"), + "quality": values.get("quality"), + } + ) + link_rows: list[dict[str, Any]] = [] + for link_result in link_results: + for period_index, values in enumerate( + link_result.get("result", [])[:num_periods] + ): + link_rows.append( + { + "time": start_time + timestep * period_index, + "link_id": link_result["link"], + **{field: values.get(field) for field in AnalysisResultsRepository.LINK_FIELDS}, + } + ) + return node_rows, link_rows + + @staticmethod + async def store_results( + conn: AsyncConnection, + run_id: UUID, + node_rows: list[dict[str, Any]], + link_rows: list[dict[str, Any]], + ) -> None: + async with conn.transaction(), conn.cursor() as cur: + await AnalysisResultsRepository._lock_run(cur, run_id) + await AnalysisResultsRepository._assert_run_is_empty(cur, run_id) + if node_rows: + async with cur.copy( + "COPY analysis.node_results " + "(time, run_id, node_id, actual_demand, total_head, pressure, quality) " + "FROM STDIN" + ) as copy: + for row in node_rows: + await copy.write_row( + ( + row["time"], + run_id, + row["node_id"], + row.get("actual_demand"), + row.get("total_head"), + row.get("pressure"), + row.get("quality"), + ) + ) + if link_rows: + async with cur.copy( + "COPY analysis.link_results " + "(time, run_id, link_id, flow, friction, headloss, quality, " + "reaction, setting, status, velocity) FROM STDIN" + ) as copy: + for row in link_rows: + await copy.write_row( + ( + row["time"], + run_id, + row["link_id"], + row.get("flow"), + row.get("friction"), + row.get("headloss"), + row.get("quality"), + row.get("reaction"), + row.get("setting"), + row.get("status"), + row.get("velocity"), + ) + ) + + @staticmethod + async def _assert_run_is_empty(cur, run_id: UUID) -> None: + await cur.execute( + """ + SELECT EXISTS ( + SELECT 1 FROM analysis.node_results WHERE run_id = %s + UNION ALL + SELECT 1 FROM analysis.link_results WHERE run_id = %s + ) AS exists + """, + (run_id, run_id), + ) + row = await cur.fetchone() + if row and row["exists"]: + raise ValueError(f"analysis results already exist for run {run_id}") + + @staticmethod + async def _lock_run(cur, run_id: UUID) -> None: + await cur.execute( + "SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 0))", + (run_id,), + ) + + @staticmethod + async def get_node_series( + conn: AsyncConnection, + run_id: UUID, + node_id: str, + start_time: datetime, + end_time: datetime, + field: str, + ) -> list[dict[str, Any]]: + if field not in AnalysisResultsRepository.NODE_FIELDS: + raise ValueError(f"invalid node result field: {field}") + query = sql.SQL( + "SELECT time, {} AS value FROM analysis.node_results " + "WHERE run_id = %s AND node_id = %s AND time BETWEEN %s AND %s " + "ORDER BY time" + ).format(sql.Identifier(field)) + async with conn.cursor() as cur: + await cur.execute(query, (run_id, node_id, start_time, end_time)) + return await cur.fetchall() + + @staticmethod + async def get_link_series( + conn: AsyncConnection, + run_id: UUID, + link_id: str, + start_time: datetime, + end_time: datetime, + field: str, + ) -> list[dict[str, Any]]: + if field not in AnalysisResultsRepository.LINK_FIELDS: + raise ValueError(f"invalid link result field: {field}") + query = sql.SQL( + "SELECT time, {} AS value FROM analysis.link_results " + "WHERE run_id = %s AND link_id = %s AND time BETWEEN %s AND %s " + "ORDER BY time" + ).format(sql.Identifier(field)) + async with conn.cursor() as cur: + await cur.execute(query, (run_id, link_id, start_time, end_time)) + return await cur.fetchall() + + @staticmethod + async def get_values_at_time( + conn: AsyncConnection, + run_id: UUID, + element_type: str, + result_time: datetime, + field: str, + ) -> dict[str, Any]: + if element_type == "node": + table, id_column, fields = ( + "node_results", + "node_id", + AnalysisResultsRepository.NODE_FIELDS, + ) + elif element_type == "link": + table, id_column, fields = ( + "link_results", + "link_id", + AnalysisResultsRepository.LINK_FIELDS, + ) + else: + raise ValueError("element_type must be node or link") + if field not in fields: + raise ValueError(f"invalid {element_type} result field: {field}") + query = sql.SQL( + "SELECT {id_column}, {field} AS value FROM analysis.{table} " + "WHERE run_id = %s AND time = %s ORDER BY {id_column}" + ).format( + id_column=sql.Identifier(id_column), + field=sql.Identifier(field), + table=sql.Identifier(table), + ) + async with conn.cursor() as cur: + await cur.execute(query, (run_id, result_time)) + return {row[id_column]: row["value"] for row in await cur.fetchall()} + + @staticmethod + def store_results_sync( + conn: Connection, + run_id: UUID, + node_rows: list[dict[str, Any]], + link_rows: list[dict[str, Any]], + ) -> None: + with conn.transaction(), conn.cursor() as cur: + cur.execute( + "SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 0))", + (run_id,), + ) + cur.execute( + """ + SELECT EXISTS ( + SELECT 1 FROM analysis.node_results WHERE run_id = %s + UNION ALL + SELECT 1 FROM analysis.link_results WHERE run_id = %s + ) AS exists + """, + (run_id, run_id), + ) + row = cur.fetchone() + if row and row["exists"]: + raise ValueError(f"analysis results already exist for run {run_id}") + if node_rows: + with cur.copy( + "COPY analysis.node_results " + "(time, run_id, node_id, actual_demand, total_head, pressure, quality) " + "FROM STDIN" + ) as copy: + for item in node_rows: + copy.write_row( + ( + item["time"], run_id, item["node_id"], + item.get("actual_demand"), item.get("total_head"), + item.get("pressure"), item.get("quality"), + ) + ) + if link_rows: + with cur.copy( + "COPY analysis.link_results " + "(time, run_id, link_id, flow, friction, headloss, quality, " + "reaction, setting, status, velocity) FROM STDIN" + ) as copy: + for item in link_rows: + copy.write_row( + ( + item["time"], run_id, item["link_id"], + item.get("flow"), item.get("friction"), + item.get("headloss"), item.get("quality"), + item.get("reaction"), item.get("setting"), + item.get("status"), item.get("velocity"), + ) + ) diff --git a/app/infra/db/timescaledb/repositories/realtime.py b/app/infra/db/timescaledb/repositories/realtime.py index 6b26fa4..10fe5bc 100644 --- a/app/infra/db/timescaledb/repositories/realtime.py +++ b/app/infra/db/timescaledb/repositories/realtime.py @@ -11,7 +11,7 @@ class RealtimeRepository: @staticmethod async def insert_links_batch(conn: AsyncConnection, data: List[dict]): - """Batch insert for realtime.link_simulation using DELETE then COPY for performance.""" + """Batch insert for realtime.link_results using DELETE then COPY.""" if not data: return @@ -21,15 +21,19 @@ class RealtimeRepository: # 使用事务确保原子性 async with conn.transaction(): async with conn.cursor() as cur: + await cur.execute( + "SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))", + (target_time,), + ) # 1. 先删除该时间点的旧数据 await cur.execute( - "DELETE FROM realtime.link_simulation WHERE time = %s", + "DELETE FROM realtime.link_results WHERE time = %s", (target_time,), ) # 2. 使用 COPY 快速写入新数据 async with cur.copy( - "COPY realtime.link_simulation (time, id, flow, friction, headloss, quality, reaction, setting, status, velocity) FROM STDIN" + "COPY realtime.link_results (time, link_id, flow, friction, headloss, quality, reaction, setting, status, velocity) FROM STDIN" ) as copy: for item in data: await copy.write_row( @@ -49,7 +53,7 @@ class RealtimeRepository: @staticmethod def insert_links_batch_sync(conn: Connection, data: List[dict]): - """Batch insert for realtime.link_simulation using DELETE then COPY for performance (sync version).""" + """Synchronous batch insert for realtime.link_results.""" if not data: return @@ -59,15 +63,19 @@ class RealtimeRepository: # 使用事务确保原子性 with conn.transaction(): with conn.cursor() as cur: + cur.execute( + "SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))", + (target_time,), + ) # 1. 先删除该时间点的旧数据 cur.execute( - "DELETE FROM realtime.link_simulation WHERE time = %s", + "DELETE FROM realtime.link_results WHERE time = %s", (target_time,), ) # 2. 使用 COPY 快速写入新数据 with cur.copy( - "COPY realtime.link_simulation (time, id, flow, friction, headloss, quality, reaction, setting, status, velocity) FROM STDIN" + "COPY realtime.link_results (time, link_id, flow, friction, headloss, quality, reaction, setting, status, velocity) FROM STDIN" ) as copy: for item in data: copy.write_row( @@ -91,7 +99,7 @@ class RealtimeRepository: ) -> List[dict]: async with conn.cursor() as cur: await cur.execute( - "SELECT * FROM realtime.link_simulation WHERE time >= %s AND time <= %s AND id = %s", + "SELECT * FROM realtime.link_results WHERE time >= %s AND time <= %s AND link_id = %s", (start_time, end_time, link_id), ) return await cur.fetchall() @@ -104,7 +112,7 @@ 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_simulation WHERE time >= %s AND time <= %s", + "SELECT * FROM realtime.link_results WHERE time >= %s AND time <= %s", (normalized_start_time, normalized_end_time), ) return await cur.fetchall() @@ -132,7 +140,7 @@ class RealtimeRepository: raise ValueError(f"Invalid field: {field}") query = sql.SQL( - "SELECT time, {} FROM realtime.link_simulation WHERE time >= %s AND time <= %s AND id = %s" + "SELECT time, {} FROM realtime.link_results WHERE time >= %s AND time <= %s AND link_id = %s" ).format(sql.Identifier(field)) async with conn.cursor() as cur: @@ -164,7 +172,7 @@ class RealtimeRepository: raise ValueError(f"Invalid field: {field}") query = sql.SQL( - "SELECT id, time, {} FROM realtime.link_simulation WHERE time >= %s AND time <= %s" + "SELECT link_id, time, {} FROM realtime.link_results WHERE time >= %s AND time <= %s" ).format(sql.Identifier(field)) async with conn.cursor() as cur: @@ -172,7 +180,7 @@ class RealtimeRepository: rows = await cur.fetchall() result = defaultdict(list) for row in rows: - result[row["id"]].append( + result[row["link_id"]].append( {"time": row["time"].isoformat(), "value": row[field]} ) return dict(result) @@ -199,7 +207,7 @@ class RealtimeRepository: raise ValueError(f"Invalid field: {field}") query = sql.SQL( - "UPDATE realtime.link_simulation SET {} = %s WHERE time = %s AND id = %s" + "UPDATE realtime.link_results SET {} = %s WHERE time = %s AND link_id = %s" ).format(sql.Identifier(field)) async with conn.cursor() as cur: @@ -211,7 +219,7 @@ class RealtimeRepository: ): async with conn.cursor() as cur: await cur.execute( - "DELETE FROM realtime.link_simulation WHERE time >= %s AND time <= %s", + "DELETE FROM realtime.link_results WHERE time >= %s AND time <= %s", (start_time, end_time), ) @@ -228,15 +236,19 @@ class RealtimeRepository: # 使用事务确保原子性 async with conn.transaction(): async with conn.cursor() as cur: + await cur.execute( + "SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))", + (target_time,), + ) # 1. 先删除该时间点的旧数据 await cur.execute( - "DELETE FROM realtime.node_simulation WHERE time = %s", + "DELETE FROM realtime.node_results WHERE time = %s", (target_time,), ) # 2. 使用 COPY 快速写入新数据 async with cur.copy( - "COPY realtime.node_simulation (time, id, actual_demand, total_head, pressure, quality) FROM STDIN" + "COPY realtime.node_results (time, node_id, actual_demand, total_head, pressure, quality) FROM STDIN" ) as copy: for item in data: await copy.write_row( @@ -261,15 +273,19 @@ class RealtimeRepository: # 使用事务确保原子性 with conn.transaction(): with conn.cursor() as cur: + cur.execute( + "SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))", + (target_time,), + ) # 1. 先删除该时间点的旧数据 cur.execute( - "DELETE FROM realtime.node_simulation WHERE time = %s", + "DELETE FROM realtime.node_results WHERE time = %s", (target_time,), ) # 2. 使用 COPY 快速写入新数据 with cur.copy( - "COPY realtime.node_simulation (time, id, actual_demand, total_head, pressure, quality) FROM STDIN" + "COPY realtime.node_results (time, node_id, actual_demand, total_head, pressure, quality) FROM STDIN" ) as copy: for item in data: copy.write_row( @@ -289,7 +305,7 @@ class RealtimeRepository: ) -> List[dict]: async with conn.cursor() as cur: await cur.execute( - "SELECT * FROM realtime.node_simulation WHERE time >= %s AND time <= %s AND id = %s", + "SELECT * FROM realtime.node_results WHERE time >= %s AND time <= %s AND node_id = %s", (start_time, end_time, node_id), ) return await cur.fetchall() @@ -302,7 +318,7 @@ 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_simulation WHERE time >= %s AND time <= %s", + "SELECT * FROM realtime.node_results WHERE time >= %s AND time <= %s", (normalized_start_time, normalized_end_time), ) return await cur.fetchall() @@ -320,7 +336,7 @@ class RealtimeRepository: raise ValueError(f"Invalid field: {field}") query = sql.SQL( - "SELECT time, {} FROM realtime.node_simulation WHERE time >= %s AND time <= %s AND id = %s" + "SELECT time, {} FROM realtime.node_results WHERE time >= %s AND time <= %s AND node_id = %s" ).format(sql.Identifier(field)) async with conn.cursor() as cur: @@ -339,7 +355,7 @@ class RealtimeRepository: raise ValueError(f"Invalid field: {field}") query = sql.SQL( - "SELECT id, time, {} FROM realtime.node_simulation WHERE time >= %s AND time <= %s" + "SELECT node_id, time, {} FROM realtime.node_results WHERE time >= %s AND time <= %s" ).format(sql.Identifier(field)) async with conn.cursor() as cur: @@ -347,7 +363,7 @@ class RealtimeRepository: rows = await cur.fetchall() result = defaultdict(list) for row in rows: - result[row["id"]].append( + result[row["node_id"]].append( {"time": row["time"].isoformat(), "value": row[field]} ) return dict(result) @@ -365,7 +381,7 @@ class RealtimeRepository: raise ValueError(f"Invalid field: {field}") query = sql.SQL( - "UPDATE realtime.node_simulation SET {} = %s WHERE time = %s AND id = %s" + "UPDATE realtime.node_results SET {} = %s WHERE time = %s AND node_id = %s" ).format(sql.Identifier(field)) async with conn.cursor() as cur: @@ -377,7 +393,7 @@ class RealtimeRepository: ): async with conn.cursor() as cur: await cur.execute( - "DELETE FROM realtime.node_simulation WHERE time >= %s AND time <= %s", + "DELETE FROM realtime.node_results WHERE time >= %s AND time <= %s", (start_time, end_time), ) @@ -439,12 +455,15 @@ class RealtimeRepository: } ) - # Insert data using batch methods - if node_data: - await RealtimeRepository.insert_nodes_batch(conn, node_data) + # Keep node and link replacement atomic. The batch helpers use nested + # transactions (savepoints), while this outer transaction guarantees + # that a link write failure also rolls back the node replacement. + async with conn.transaction(): + if node_data: + await RealtimeRepository.insert_nodes_batch(conn, node_data) - if link_data: - await RealtimeRepository.insert_links_batch(conn, link_data) + if link_data: + await RealtimeRepository.insert_links_batch(conn, link_data) @staticmethod def store_realtime_simulation_result_sync( @@ -502,12 +521,15 @@ class RealtimeRepository: } ) - # Insert data using batch methods - if node_data: - RealtimeRepository.insert_nodes_batch_sync(conn, node_data) + # Keep node and link replacement atomic. The batch helpers use nested + # transactions (savepoints), while this outer transaction guarantees + # that a link write failure also rolls back the node replacement. + with conn.transaction(): + if node_data: + RealtimeRepository.insert_nodes_batch_sync(conn, node_data) - if link_data: - RealtimeRepository.insert_links_batch_sync(conn, link_data) + if link_data: + RealtimeRepository.insert_links_batch_sync(conn, link_data) @staticmethod async def query_all_record_by_time_property( diff --git a/app/infra/db/timescaledb/repositories/scada.py b/app/infra/db/timescaledb/repositories/scada.py index b28dfea..09ef7e5 100644 --- a/app/infra/db/timescaledb/repositories/scada.py +++ b/app/infra/db/timescaledb/repositories/scada.py @@ -14,7 +14,7 @@ class ScadaRepository: async with conn.cursor() as cur: async with cur.copy( - "COPY scada.scada_data (time, device_id, monitored_value, cleaned_value) FROM STDIN" + "COPY scada.measurements (time, device_id, monitored_value, cleaned_value) FROM STDIN" ) as copy: for item in data: await copy.write_row( @@ -35,7 +35,7 @@ class ScadaRepository: ) -> List[dict]: async with conn.cursor() as cur: await cur.execute( - "SELECT * FROM scada.scada_data 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", (device_ids, start_time, end_time), ) return await cur.fetchall() @@ -49,7 +49,7 @@ class ScadaRepository: ) -> List[dict]: with conn.cursor(row_factory=dict_row) as cur: cur.execute( - "SELECT * FROM scada.scada_data 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", (device_ids, start_time, end_time), ) return cur.fetchall() @@ -63,12 +63,12 @@ class ScadaRepository: with conn.cursor(row_factory=dict_row) as cur: if before_time is None: cur.execute( - "SELECT max(time) AS time FROM scada.scada_data WHERE device_id = ANY(%s)", + "SELECT max(time) AS time FROM scada.measurements WHERE device_id = ANY(%s)", (device_ids,), ) else: cur.execute( - "SELECT max(time) AS time FROM scada.scada_data " + "SELECT max(time) AS time FROM scada.measurements " "WHERE device_id = ANY(%s) AND time <= %s", (device_ids, before_time), ) @@ -88,7 +88,7 @@ class ScadaRepository: raise ValueError(f"Invalid field: {field}") query = sql.SQL( - "SELECT device_id, time, {} FROM scada.scada_data 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)" ).format(sql.Identifier(field)) async with conn.cursor() as cur: @@ -111,10 +111,10 @@ class ScadaRepository: raise ValueError(f"Invalid field: {field}") update_query = sql.SQL( - "UPDATE scada.scada_data SET {} = %s WHERE time = %s AND device_id = %s" + "UPDATE scada.measurements SET {} = %s WHERE time = %s AND device_id = %s" ).format(sql.Identifier(field)) insert_query = sql.SQL( - "INSERT INTO scada.scada_data (time, device_id, {}) VALUES (%s, %s, %s)" + "INSERT INTO scada.measurements (time, device_id, {}) VALUES (%s, %s, %s)" ).format(sql.Identifier(field)) async with conn.cursor() as cur: @@ -128,6 +128,6 @@ class ScadaRepository: ): async with conn.cursor() as cur: await cur.execute( - "DELETE FROM scada.scada_data WHERE device_id = %s AND time >= %s AND time <= %s", + "DELETE FROM scada.measurements WHERE device_id = %s AND time >= %s AND time <= %s", (device_id, start_time, end_time), ) diff --git a/app/infra/db/timescaledb/repositories/scheme.py b/app/infra/db/timescaledb/repositories/scheme.py deleted file mode 100644 index 3903fc0..0000000 --- a/app/infra/db/timescaledb/repositories/scheme.py +++ /dev/null @@ -1,710 +0,0 @@ -from typing import List, Any, Dict -from datetime import datetime, timedelta -from collections import defaultdict -from psycopg import AsyncConnection, Connection, sql -import app.services.globals as globals -from app.services.time_api import parse_clock_duration_seconds, parse_utc_time - - -class SchemeRepository: - @staticmethod - def _get_result_timestep(result_timestep_seconds: int | None) -> timedelta: - if result_timestep_seconds is not None: - if result_timestep_seconds <= 0: - raise ValueError("result_timestep_seconds must be greater than 0.") - return timedelta(seconds=result_timestep_seconds) - - timestep_seconds = parse_clock_duration_seconds( - globals.hydraulic_timestep, - field_name="HYDRAULIC TIMESTEP", - ) - if timestep_seconds <= 0: - raise ValueError("HYDRAULIC TIMESTEP must be greater than 0.") - return timedelta(seconds=timestep_seconds) - - # --- Link Simulation --- - - @staticmethod - async def insert_links_batch(conn: AsyncConnection, data: List[dict]): - """Batch insert for scheme.link_simulation using DELETE then COPY for performance.""" - if not data: - return - - # 获取批次中所有不同的时间点 - all_times = list(set(item["time"] for item in data)) - target_scheme_type = data[0]["scheme_type"] - target_scheme_name = data[0]["scheme_name"] - - # 使用事务确保原子性 - async with conn.transaction(): - async with conn.cursor() as cur: - # 1. 删除该批次涉及的所有时间点、scheme_type、scheme_name 的旧数据 - await cur.execute( - "DELETE FROM scheme.link_simulation WHERE time = ANY(%s) AND scheme_type = %s AND scheme_name = %s", - (all_times, target_scheme_type, target_scheme_name), - ) - - # 2. 使用 COPY 快速写入新数据 - async with cur.copy( - "COPY scheme.link_simulation (time, scheme_type, scheme_name, id, flow, friction, headloss, quality, reaction, setting, status, velocity) FROM STDIN" - ) as copy: - for item in data: - await copy.write_row( - ( - item["time"], - item["scheme_type"], - item["scheme_name"], - item["id"], - item.get("flow"), - item.get("friction"), - item.get("headloss"), - item.get("quality"), - item.get("reaction"), - item.get("setting"), - item.get("status"), - item.get("velocity"), - ) - ) - - @staticmethod - def insert_links_batch_sync(conn: Connection, data: List[dict]): - """Batch insert for scheme.link_simulation using DELETE then COPY for performance (sync version).""" - if not data: - return - - # 获取批次中所有不同的时间点 - all_times = list(set(item["time"] for item in data)) - target_scheme_type = data[0]["scheme_type"] - target_scheme_name = data[0]["scheme_name"] - - # 使用事务确保原子性 - with conn.transaction(): - with conn.cursor() as cur: - # 1. 删除该批次涉及的所有时间点、scheme_type、scheme_name 的旧数据 - cur.execute( - "DELETE FROM scheme.link_simulation WHERE time = ANY(%s) AND scheme_type = %s AND scheme_name = %s", - (all_times, target_scheme_type, target_scheme_name), - ) - - # 2. 使用 COPY 快速写入新数据 - with cur.copy( - "COPY scheme.link_simulation (time, scheme_type, scheme_name, id, flow, friction, headloss, quality, reaction, setting, status, velocity) FROM STDIN" - ) as copy: - for item in data: - copy.write_row( - ( - item["time"], - item["scheme_type"], - item["scheme_name"], - item["id"], - item.get("flow"), - item.get("friction"), - item.get("headloss"), - item.get("quality"), - item.get("reaction"), - item.get("setting"), - item.get("status"), - item.get("velocity"), - ) - ) - - @staticmethod - async def get_link_by_scheme_and_time_range( - conn: AsyncConnection, - scheme_type: str, - scheme_name: str, - start_time: datetime, - end_time: datetime, - link_id: str, - ) -> List[dict]: - async with conn.cursor() as cur: - await cur.execute( - "SELECT * FROM scheme.link_simulation WHERE scheme_type = %s AND scheme_name = %s AND time >= %s AND time <= %s AND id = %s", - (scheme_type, scheme_name, start_time, end_time, link_id), - ) - return await cur.fetchall() - - @staticmethod - async def get_links_by_scheme_and_time_range( - conn: AsyncConnection, - scheme_type: str, - scheme_name: str, - start_time: datetime, - end_time: datetime, - ) -> List[dict]: - async with conn.cursor() as cur: - await cur.execute( - "SELECT * FROM scheme.link_simulation WHERE scheme_type = %s AND scheme_name = %s AND time >= %s AND time <= %s", - (scheme_type, scheme_name, start_time, end_time), - ) - return await cur.fetchall() - - @staticmethod - async def get_link_field_by_scheme_and_time_range( - conn: AsyncConnection, - scheme_type: str, - scheme_name: str, - start_time: datetime, - end_time: datetime, - link_id: str, - field: str, - ) -> List[Dict[str, Any]]: - # Validate field name to prevent SQL injection - valid_fields = { - "flow", - "friction", - "headloss", - "quality", - "reaction", - "setting", - "status", - "velocity", - } - if field not in valid_fields: - raise ValueError(f"Invalid field: {field}") - - query = sql.SQL( - "SELECT time, {} FROM scheme.link_simulation WHERE scheme_type = %s AND scheme_name = %s AND time >= %s AND time <= %s AND id = %s" - ).format(sql.Identifier(field)) - - async with conn.cursor() as cur: - await cur.execute( - query, (scheme_type, scheme_name, start_time, end_time, link_id) - ) - rows = await cur.fetchall() - return [ - {"time": row["time"].isoformat(), "value": row[field]} for row in rows - ] - - @staticmethod - async def get_links_field_by_scheme_and_time_range( - conn: AsyncConnection, - scheme_type: str, - scheme_name: str, - start_time: datetime, - end_time: datetime, - field: str, - ) -> dict: - # Validate field name to prevent SQL injection - valid_fields = { - "flow", - "friction", - "headloss", - "quality", - "reaction", - "setting", - "status", - "velocity", - } - if field not in valid_fields: - raise ValueError(f"Invalid field: {field}") - - query = sql.SQL( - "SELECT id, time, {} FROM scheme.link_simulation WHERE scheme_type = %s AND scheme_name = %s AND time >= %s AND time <= %s" - ).format(sql.Identifier(field)) - - async with conn.cursor() as cur: - await cur.execute(query, (scheme_type, scheme_name, start_time, end_time)) - rows = await cur.fetchall() - result = defaultdict(list) - for row in rows: - result[row["id"]].append( - {"time": row["time"].isoformat(), "value": row[field]} - ) - return dict(result) - - @staticmethod - async def update_link_field( - conn: AsyncConnection, - time: datetime, - scheme_type: str, - scheme_name: str, - link_id: str, - field: str, - value: Any, - ): - valid_fields = { - "flow", - "friction", - "headloss", - "quality", - "reaction", - "setting", - "status", - "velocity", - } - if field not in valid_fields: - raise ValueError(f"Invalid field: {field}") - - query = sql.SQL( - "UPDATE scheme.link_simulation SET {} = %s WHERE time = %s AND scheme_type = %s AND scheme_name = %s AND id = %s" - ).format(sql.Identifier(field)) - - async with conn.cursor() as cur: - await cur.execute(query, (value, time, scheme_type, scheme_name, link_id)) - - @staticmethod - async def delete_links_by_scheme_and_time_range( - conn: AsyncConnection, - scheme_type: str, - scheme_name: str, - start_time: datetime, - end_time: datetime, - ): - async with conn.cursor() as cur: - await cur.execute( - "DELETE FROM scheme.link_simulation WHERE scheme_type = %s AND scheme_name = %s AND time >= %s AND time <= %s", - (scheme_type, scheme_name, start_time, end_time), - ) - - # --- Node Simulation --- - - @staticmethod - async def insert_nodes_batch(conn: AsyncConnection, data: List[dict]): - if not data: - return - - # 获取批次中所有不同的时间点 - all_times = list(set(item["time"] for item in data)) - target_scheme_type = data[0]["scheme_type"] - target_scheme_name = data[0]["scheme_name"] - - # 使用事务确保原子性 - async with conn.transaction(): - async with conn.cursor() as cur: - # 1. 删除该批次涉及的所有时间点、scheme_type、scheme_name 的旧数据 - await cur.execute( - "DELETE FROM scheme.node_simulation WHERE time = ANY(%s) AND scheme_type = %s AND scheme_name = %s", - (all_times, target_scheme_type, target_scheme_name), - ) - - # 2. 使用 COPY 快速写入新数据 - async with cur.copy( - "COPY scheme.node_simulation (time, scheme_type, scheme_name, id, actual_demand, total_head, pressure, quality) FROM STDIN" - ) as copy: - for item in data: - await copy.write_row( - ( - item["time"], - item["scheme_type"], - item["scheme_name"], - item["id"], - item.get("actual_demand"), - item.get("total_head"), - item.get("pressure"), - item.get("quality"), - ) - ) - - @staticmethod - def insert_nodes_batch_sync(conn: Connection, data: List[dict]): - if not data: - return - - # 获取批次中所有不同的时间点 - all_times = list(set(item["time"] for item in data)) - target_scheme_type = data[0]["scheme_type"] - target_scheme_name = data[0]["scheme_name"] - - # 使用事务确保原子性 - with conn.transaction(): - with conn.cursor() as cur: - # 1. 删除该批次涉及的所有时间点、scheme_type、scheme_name 的旧数据 - cur.execute( - "DELETE FROM scheme.node_simulation WHERE time = ANY(%s) AND scheme_type = %s AND scheme_name = %s", - (all_times, target_scheme_type, target_scheme_name), - ) - - # 2. 使用 COPY 快速写入新数据 - with cur.copy( - "COPY scheme.node_simulation (time, scheme_type, scheme_name, id, actual_demand, total_head, pressure, quality) FROM STDIN" - ) as copy: - for item in data: - copy.write_row( - ( - item["time"], - item["scheme_type"], - item["scheme_name"], - item["id"], - item.get("actual_demand"), - item.get("total_head"), - item.get("pressure"), - item.get("quality"), - ) - ) - - @staticmethod - async def get_node_by_scheme_and_time_range( - conn: AsyncConnection, - scheme_type: str, - scheme_name: str, - start_time: datetime, - end_time: datetime, - node_id: str, - ) -> List[dict]: - async with conn.cursor() as cur: - await cur.execute( - "SELECT * FROM scheme.node_simulation WHERE scheme_type = %s AND scheme_name = %s AND time >= %s AND time <= %s AND id = %s", - (scheme_type, scheme_name, start_time, end_time, node_id), - ) - return await cur.fetchall() - - @staticmethod - async def get_nodes_by_scheme_and_time_range( - conn: AsyncConnection, - scheme_type: str, - scheme_name: str, - start_time: datetime, - end_time: datetime, - ) -> List[dict]: - async with conn.cursor() as cur: - await cur.execute( - "SELECT * FROM scheme.node_simulation WHERE scheme_type = %s AND scheme_name = %s AND time >= %s AND time <= %s", - (scheme_type, scheme_name, start_time, end_time), - ) - return await cur.fetchall() - - @staticmethod - async def get_node_field_by_scheme_and_time_range( - conn: AsyncConnection, - scheme_type: str, - scheme_name: str, - start_time: datetime, - end_time: datetime, - node_id: str, - field: str, - ) -> List[Dict[str, Any]]: - # Validate field name to prevent SQL injection - valid_fields = {"actual_demand", "total_head", "pressure", "quality"} - if field not in valid_fields: - raise ValueError(f"Invalid field: {field}") - - query = sql.SQL( - "SELECT time, {} FROM scheme.node_simulation WHERE scheme_type = %s AND scheme_name = %s AND time >= %s AND time <= %s AND id = %s" - ).format(sql.Identifier(field)) - - async with conn.cursor() as cur: - await cur.execute( - query, (scheme_type, scheme_name, start_time, end_time, node_id) - ) - rows = await cur.fetchall() - return [ - {"time": row["time"].isoformat(), "value": row[field]} for row in rows - ] - - @staticmethod - async def get_nodes_field_by_scheme_and_time_range( - conn: AsyncConnection, - scheme_type: str, - scheme_name: str, - start_time: datetime, - end_time: datetime, - field: str, - ) -> dict: - # Validate field name to prevent SQL injection - valid_fields = {"actual_demand", "total_head", "pressure", "quality"} - if field not in valid_fields: - raise ValueError(f"Invalid field: {field}") - - query = sql.SQL( - "SELECT id, time, {} FROM scheme.node_simulation WHERE scheme_type = %s AND scheme_name = %s AND time >= %s AND time <= %s" - ).format(sql.Identifier(field)) - - async with conn.cursor() as cur: - await cur.execute(query, (scheme_type, scheme_name, start_time, end_time)) - rows = await cur.fetchall() - result = defaultdict(list) - for row in rows: - result[row["id"]].append( - {"time": row["time"].isoformat(), "value": row[field]} - ) - return dict(result) - - @staticmethod - async def update_node_field( - conn: AsyncConnection, - time: datetime, - scheme_type: str, - scheme_name: str, - node_id: str, - field: str, - value: Any, - ): - valid_fields = {"actual_demand", "total_head", "pressure", "quality"} - if field not in valid_fields: - raise ValueError(f"Invalid field: {field}") - - query = sql.SQL( - "UPDATE scheme.node_simulation SET {} = %s WHERE time = %s AND scheme_type = %s AND scheme_name = %s AND id = %s" - ).format(sql.Identifier(field)) - - async with conn.cursor() as cur: - await cur.execute(query, (value, time, scheme_type, scheme_name, node_id)) - - @staticmethod - async def delete_nodes_by_scheme_and_time_range( - conn: AsyncConnection, - scheme_type: str, - scheme_name: str, - start_time: datetime, - end_time: datetime, - ): - async with conn.cursor() as cur: - await cur.execute( - "DELETE FROM scheme.node_simulation WHERE scheme_type = %s AND scheme_name = %s AND time >= %s AND time <= %s", - (scheme_type, scheme_name, start_time, end_time), - ) - - # --- 复合查询 --- - - @staticmethod - async def store_scheme_simulation_result( - conn: AsyncConnection, - scheme_type: str, - scheme_name: str, - node_result_list: List[Dict[str, any]], - link_result_list: List[Dict[str, any]], - result_start_time: str, - num_periods: int = 1, - result_timestep_seconds: int | None = None, - ): - """ - Store scheme simulation results to TimescaleDB. - - Args: - conn: Database connection - scheme_type: Scheme type - scheme_name: Scheme name - node_result_list: List of node simulation results - link_result_list: List of link simulation results - result_start_time: Start time for the results (ISO format string) - """ - simulation_time = parse_utc_time( - result_start_time, field_name="result_start_time" - ) - - timestep = SchemeRepository._get_result_timestep(result_timestep_seconds) - - # Prepare node data for batch insert - node_data = [] - for node_result in node_result_list: - node_id = node_result.get("node") - result_rows = node_result.get("result", []) - for period_index in range(min(num_periods, len(result_rows))): - current_time = simulation_time + (timestep * period_index) - data = result_rows[period_index] - node_data.append( - { - "time": current_time, - "scheme_type": scheme_type, - "scheme_name": scheme_name, - "id": node_id, - "actual_demand": data.get("demand"), - "total_head": data.get("head"), - "pressure": data.get("pressure"), - "quality": data.get("quality"), - } - ) - - # Prepare link data for batch insert - link_data = [] - for link_result in link_result_list: - link_id = link_result.get("link") - result_rows = link_result.get("result", []) - for period_index in range(min(num_periods, len(result_rows))): - current_time = simulation_time + (timestep * period_index) - data = result_rows[period_index] - link_data.append( - { - "time": current_time, - "scheme_type": scheme_type, - "scheme_name": scheme_name, - "id": link_id, - "flow": data.get("flow"), - "friction": data.get("friction"), - "headloss": data.get("headloss"), - "quality": data.get("quality"), - "reaction": data.get("reaction"), - "setting": data.get("setting"), - "status": data.get("status"), - "velocity": data.get("velocity"), - } - ) - - # Insert data using batch methods - if node_data: - await SchemeRepository.insert_nodes_batch(conn, node_data) - - if link_data: - await SchemeRepository.insert_links_batch(conn, link_data) - - @staticmethod - def store_scheme_simulation_result_sync( - conn: Connection, - scheme_type: str, - scheme_name: str, - node_result_list: List[Dict[str, any]], - link_result_list: List[Dict[str, any]], - result_start_time: str, - num_periods: int = 1, - result_timestep_seconds: int | None = None, - ): - """ - Store scheme simulation results to TimescaleDB (sync version). - - Args: - conn: Database connection - scheme_type: Scheme type - scheme_name: Scheme name - node_result_list: List of node simulation results - link_result_list: List of link simulation results - result_start_time: Start time for the results (ISO format string) - """ - simulation_time = parse_utc_time( - result_start_time, field_name="result_start_time" - ) - - timestep = SchemeRepository._get_result_timestep(result_timestep_seconds) - - # Prepare node data for batch insert - node_data = [] - for node_result in node_result_list: - node_id = node_result.get("node") - result_rows = node_result.get("result", []) - for period_index in range(min(num_periods, len(result_rows))): - current_time = simulation_time + (timestep * period_index) - data = result_rows[period_index] - node_data.append( - { - "time": current_time, - "scheme_type": scheme_type, - "scheme_name": scheme_name, - "id": node_id, - "actual_demand": data.get("demand"), - "total_head": data.get("head"), - "pressure": data.get("pressure"), - "quality": data.get("quality"), - } - ) - - # Prepare link data for batch insert - link_data = [] - for link_result in link_result_list: - link_id = link_result.get("link") - result_rows = link_result.get("result", []) - for period_index in range(min(num_periods, len(result_rows))): - current_time = simulation_time + (timestep * period_index) - data = result_rows[period_index] - link_data.append( - { - "time": current_time, - "scheme_type": scheme_type, - "scheme_name": scheme_name, - "id": link_id, - "flow": data.get("flow"), - "friction": data.get("friction"), - "headloss": data.get("headloss"), - "quality": data.get("quality"), - "reaction": data.get("reaction"), - "setting": data.get("setting"), - "status": data.get("status"), - "velocity": data.get("velocity"), - } - ) - - # Insert data using batch methods - if node_data: - SchemeRepository.insert_nodes_batch_sync(conn, node_data) - - if link_data: - SchemeRepository.insert_links_batch_sync(conn, link_data) - - @staticmethod - async def query_all_record_by_scheme_time_property( - conn: AsyncConnection, - scheme_type: str, - scheme_name: str, - query_time: str, - type: str, - property: str, - ) -> list: - """ - Query all records by scheme, time and property from TimescaleDB. - - Args: - conn: Database connection - scheme_type: Scheme type - scheme_name: Scheme name - query_time: Time to query (ISO format string) - type: Type of data ("node" or "link") - property: Property/field to query - - Returns: - List of records matching the criteria - """ - target_time = parse_utc_time(query_time, field_name="query_time") - - # Create time range: query_time ± 1 second - start_time = target_time - timedelta(seconds=1) - end_time = target_time + timedelta(seconds=1) - - # Query based on type - if type.lower() == "node": - data = await SchemeRepository.get_nodes_field_by_scheme_and_time_range( - conn, scheme_type, scheme_name, start_time, end_time, property - ) - elif type.lower() == "link": - data = await SchemeRepository.get_links_field_by_scheme_and_time_range( - conn, scheme_type, scheme_name, start_time, end_time, property - ) - else: - raise ValueError(f"Invalid type: {type}. Must be 'node' or 'link'") - - # Format the results - # Format the results - result = [] - for id, items in data.items(): - for item in items: - result.append({"ID": id, "value": item["value"]}) - return result - - @staticmethod - async def query_scheme_simulation_result_by_id_time( - conn: AsyncConnection, - scheme_type: str, - scheme_name: str, - id: str, - type: str, - query_time: str, - ) -> list[dict]: - """ - Query scheme simulation results by id and time from TimescaleDB. - - Args: - conn: Database connection - scheme_type: Scheme type - scheme_name: Scheme name - id: The id of the node or link - type: Type of data ("node" or "link") - query_time: Time to query (ISO format string) - - Returns: - List of records matching the criteria - """ - target_time = parse_utc_time(query_time, field_name="query_time") - - # Create time range: query_time ± 1 second - start_time = target_time - timedelta(seconds=1) - end_time = target_time + timedelta(seconds=1) - - # Query based on type - if type.lower() == "node": - return await SchemeRepository.get_node_by_scheme_and_time_range( - conn, scheme_type, scheme_name, start_time, end_time, id - ) - elif type.lower() == "link": - return await SchemeRepository.get_link_by_scheme_and_time_range( - conn, scheme_type, scheme_name, start_time, end_time, id - ) - else: - raise ValueError(f"Invalid type: {type}. Must be 'node' or 'link'") diff --git a/app/infra/db/timescaledb/sync_pool.py b/app/infra/db/timescaledb/sync_pool.py new file mode 100644 index 0000000..6d703d7 --- /dev/null +++ b/app/infra/db/timescaledb/sync_pool.py @@ -0,0 +1,95 @@ +from collections import OrderedDict +from collections.abc import Iterator +from contextlib import contextmanager +from threading import RLock + +from psycopg import Connection +from psycopg.rows import dict_row +from psycopg_pool import ConnectionPool + +from app.core.config import settings +from app.infra.db.project_routing import get_project_timescale_pgconn_string + + +_pools: OrderedDict[str, ConnectionPool] = OrderedDict() +_pool_conninfo: dict[str, str] = {} +_pool_borrows: dict[str, int] = {} +_lock = RLock() + + +def _evict_idle_pools(*, protected: str | None = None) -> None: + limit = max(1, settings.PROJECT_TS_CACHE_SIZE) + while len(_pools) > limit: + candidate = next( + (key for key in _pools if key != protected and _pool_borrows.get(key, 0) == 0), + None, + ) + if candidate is None: + return + pool = _pools.pop(candidate) + _pool_conninfo.pop(candidate, None) + _pool_borrows.pop(candidate, None) + if not pool.closed: + pool.close() + + +def get_timescale_pool(db_name: str) -> ConnectionPool: + conninfo = get_project_timescale_pgconn_string(db_name=db_name) + with _lock: + pool = _pools.get(db_name) + if pool is not None and _pool_conninfo.get(db_name) == conninfo and not pool.closed: + _pools.move_to_end(db_name) + return pool + if pool is not None and not pool.closed: + if _pool_borrows.get(db_name, 0): + raise RuntimeError(f"Cannot replace active TimescaleDB pool {db_name!r}") + pool.close() + pool = ConnectionPool( + conninfo=conninfo, + min_size=settings.PROJECT_TS_POOL_MIN_SIZE, + max_size=settings.PROJECT_TS_POOL_MAX_SIZE, + kwargs={"row_factory": dict_row}, + open=True, + ) + _pools[db_name] = pool + _pool_conninfo[db_name] = conninfo + _pool_borrows.setdefault(db_name, 0) + _evict_idle_pools(protected=db_name) + return pool + + +@contextmanager +def timescale_connection(db_name: str) -> Iterator[Connection]: + with _lock: + pool = get_timescale_pool(db_name) + _pool_borrows[db_name] = _pool_borrows.get(db_name, 0) + 1 + try: + with pool.connection() as conn: + yield conn + finally: + with _lock: + _pool_borrows[db_name] -= 1 + _evict_idle_pools() + + +def close_timescale_pool(db_name: str) -> None: + with _lock: + if _pool_borrows.get(db_name, 0): + raise RuntimeError(f"Cannot close active TimescaleDB pool {db_name!r}") + pool = _pools.pop(db_name, None) + _pool_conninfo.pop(db_name, None) + _pool_borrows.pop(db_name, None) + if pool is not None and not pool.closed: + pool.close() + + +def close_all_timescale_pools() -> None: + """Close every synchronous TimescaleDB pool.""" + with _lock: + pools = list(_pools.values()) + _pools.clear() + _pool_conninfo.clear() + _pool_borrows.clear() + for pool in pools: + if not pool.closed: + pool.close() diff --git a/app/infra/epanet/epanet.py b/app/infra/epanet/epanet.py index a2e490d..cc86302 100644 --- a/app/infra/epanet/epanet.py +++ b/app/infra/epanet/epanet.py @@ -11,8 +11,8 @@ from typing import Any import uuid sys.path.append("..") -from app.native.wndb import project -from app.native.wndb import inp_out +from app.native.wndb.core import projects +from app.native.wndb.inp import exporter def _verify_platform(): @@ -326,13 +326,13 @@ def _make_isolated_run_paths(base_name: str, cwd: str) -> tuple[str, str, str]: # DingZQ, 2025-02-04, 返回dict[str, Any] def run_project_return_dict(name: str, readable_output: bool = True) -> dict[str, Any]: - if not project.have_project(name): + if not projects.have_project(name): raise Exception(f"Not found project [{name}]") cwd = os.path.abspath(os.getcwd()) inp, rpt, opt = _make_isolated_run_paths(name, cwd) - inp_out.dump_inp(name, inp, "2") + exporter.dump_inp(name, inp, "2") if platform.system() == "Windows": exe = os.path.join(os.path.dirname(__file__), "windows", "runepanet.exe") @@ -389,13 +389,13 @@ def run_project_return_dict(name: str, readable_output: bool = True) -> dict[str # original code def run_project(name: str, readable_output: bool = True) -> str: - if not project.have_project(name): + if not projects.have_project(name): raise Exception(f"Not found project [{name}]") cwd = os.path.abspath(os.getcwd()) inp, rpt, opt = _make_isolated_run_paths(name, cwd) - inp_out.dump_inp(name, inp, "2") + exporter.dump_inp(name, inp, "2") if platform.system() == "Windows": exe = os.path.join(os.path.dirname(__file__), "windows", "runepanet.exe") diff --git a/app/main.py b/app/main.py index d3fe175..5896c3e 100644 --- a/app/main.py +++ b/app/main.py @@ -8,10 +8,10 @@ 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.timescaledb.database import db as tsdb -from app.infra.db.postgresql.database import db as pgdb 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,26 +30,15 @@ async def lifespan(app: FastAPI): logger.info("TJWater CloudService is starting...") logger.info("**********************************************************") - # 初始化数据库连接池 - tsdb.init_pool() - pgdb.init_pool() - - await tsdb.open() - await pgdb.open() - - # 将数据库实例存储到 app.state,供依赖项使用 - app.state.db = pgdb - logger.info("Database connection pool initialized and stored in app.state") - if project_info.name: print(project_info.name) open_project(project_info.name) yield # 清理资源 - await tsdb.close() - await pgdb.close() await project_connection_manager.close_all() + close_all_timescale_pools() + close_all_project_pools() await close_metadata_engine() logger.info("Database connections closed") diff --git a/app/native/wndb/__init__.py b/app/native/wndb/__init__.py index 35b9c20..915a3c4 100644 --- a/app/native/wndb/__init__.py +++ b/app/native/wndb/__init__.py @@ -1,480 +1,6 @@ -"""`app.native.wndb` 的公共 API 门面。 +"""Water-network database package. -调用建议: -- 推荐使用模块方式导入,保持调用点清晰: - `import app.native.wndb as wndb` -- 典型流程: - 1) 项目生命周期:`open_project(...)` / `close_project(...)` - 2) 模型数据读写:`get_*`, `set_*`, `add_*`, `delete_*` - 3) 持久化与恢复:`take_snapshot(...)`, `execute_undo()`, `restore(...)` - -该文件刻意保持平铺导出,以兼容历史调用。 +Import from the responsibility-specific modules under ``core``, ``model``, +``gis``, ``inp``, and ``commands``. This package intentionally does not expose +the former flat compatibility API. """ - -# ----------------------------------------------------------------------------- -# 项目生命周期与 INP 导入导出 -# ----------------------------------------------------------------------------- -from .project import ( - list_project, - have_project, - create_project, - delete_project, - clean_project, -) -from .project import is_project_open, open_project, close_project -from .project import copy_project - -# DingZQ, 2024-12-28: 将 INP v3 转换为 v2 -from .inp_in import read_inp, import_inp, convert_inp_v3_to_v2 -from .inp_out import dump_inp, export_inp - -# ----------------------------------------------------------------------------- -# 数据库操作、快照与撤销重做 -# ----------------------------------------------------------------------------- -from .database import API_ADD, API_UPDATE, API_DELETE -from .database import ChangeSet -from .database import get_current_operation -from .database import execute_undo, execute_redo -from .database import list_snapshot -from .database import ( - have_snapshot, - have_snapshot_for_operation, - have_snapshot_for_current_operation, -) -from .database import ( - take_snapshot_for_operation, - take_snapshot_for_current_operation, - take_snapshot, -) -from .database import update_snapshot, update_snapshot_for_current_operation -from .database import delete_snapshot, delete_snapshot_by_operation -from .database import get_operation_by_snapshot, get_snapshot_by_operation -from .database import pick_snapshot -from .database import pick_operation, sync_with_server -from .database import ( - get_restore_operation, - set_restore_operation, - set_restore_operation_to_current, - restore, -) -from .database import read, try_read, read_all, write - -# ----------------------------------------------------------------------------- -# 批处理执行与扩展数据 -# ----------------------------------------------------------------------------- -from .batch_exe import execute_batch_commands, execute_batch_command - -from .extension_data import ( - get_all_extension_data_keys, - get_all_extension_data, - get_extension_data, - set_extension_data, -) - -# ----------------------------------------------------------------------------- -# 核心网络模型基础类型与辅助方法 -# ----------------------------------------------------------------------------- -from .s0_base import JUNCTION, RESERVOIR, TANK, PIPE, PUMP, VALVE, PATTERN, CURVE -from .s0_base import is_node, is_junction, is_reservoir, is_tank -from .s0_base import is_link, is_pipe, is_pump, is_valve -from .s0_base import is_curve -from .s0_base import is_pattern -from .s0_base import ( - get_nodes, - get_nodes_id_and_type, - get_junctions, - get_reservoirs, - get_tanks, - get_links, - get_links_id_and_type, - get_pipes, - get_pumps, - get_valves, - get_curves, - get_patterns, -) -from .s0_base import ( - get_node_type, - get_link_type, - get_element_type, - get_element_type_value, -) -from .s0_base import get_node_links, get_link_nodes -from .s0_base import get_major_nodes, get_major_pipes - -# ----------------------------------------------------------------------------- -# EPANET 基础分段(S1-S27) -# ----------------------------------------------------------------------------- -from .s1_title import get_title_schema, get_title, set_title - -from .s2_junctions import ( - get_junction_schema, - add_junction, - get_junction, - set_junction, - get_all_junctions, -) -from .batch_api import delete_junction_cascade - -from .s3_reservoirs import ( - get_reservoir_schema, - add_reservoir, - get_reservoir, - set_reservoir, - get_all_reservoirs, -) -from .batch_api import delete_reservoir_cascade - -from .s4_tanks import OVERFLOW_YES, OVERFLOW_NO -from .s4_tanks import get_tank_schema, add_tank, get_tank, set_tank, get_all_tanks -from .batch_api import delete_tank_cascade - -from .s5_pipes import PIPE_STATUS_OPEN, PIPE_STATUS_CLOSED, PIPE_STATUS_CV -from .s5_pipes import ( - get_pipe_schema, - add_pipe, - get_pipe, - set_pipe, - get_all_pipes, - get_pipes_by_property, -) -from .batch_api import delete_pipe_cascade - -from .s6_pumps import get_pump_schema, add_pump, get_pump, set_pump, get_all_pumps -from .batch_api import delete_pump_cascade - -from .s7_valves import ( - VALVES_TYPE_PRV, - VALVES_TYPE_PSV, - VALVES_TYPE_PBV, - VALVES_TYPE_FCV, - VALVES_TYPE_TCV, - VALVES_TYPE_GPV, -) -from .s7_valves import get_valve_schema, add_valve, get_valve, set_valve, get_all_valves -from .batch_api import delete_valve_cascade - -from .s8_tags import TAG_TYPE_NODE, TAG_TYPE_LINK -from .s8_tags import get_tag_schema, get_tags, get_tag, set_tag - -from .s9_demands import get_demand_schema, get_demand, set_demand - -from .s10_status import LINK_STATUS_OPEN, LINK_STATUS_CLOSED, LINK_STATUS_ACTIVE -from .s10_status import get_status_schema, get_status, set_status - -from .s11_patterns import get_pattern_schema, get_pattern, set_pattern, add_pattern -from .batch_api import delete_pattern_cascade - -from .s12_curves import ( - CURVE_TYPE_PUMP, - CURVE_TYPE_EFFICIENCY, - CURVE_TYPE_VOLUME, - CURVE_TYPE_HEADLOSS, -) -from .s12_curves import get_curve_schema, get_curve, set_curve, add_curve -from .batch_api import delete_curve_cascade - -from .s13_controls import get_control_schema, get_control, set_control - -from .s14_rules import get_rule_schema, get_rule, set_rule - -from .s15_energy import get_energy_schema, get_energy, set_energy -from .s15_energy import get_pump_energy_schema, get_pump_energy, set_pump_energy - -from .s16_emitters import get_emitter_schema, get_emitter, set_emitter - -from .s17_quality import get_quality_schema, get_quality, set_quality - -from .s18_sources import ( - SOURCE_TYPE_CONCEN, - SOURCE_TYPE_MASS, - SOURCE_TYPE_FLOWPACED, - SOURCE_TYPE_SETPOINT, -) -from .s18_sources import ( - get_source_schema, - get_source, - set_source, - add_source, - delete_source, -) - -from .s19_reactions import get_reaction_schema, get_reaction, set_reaction -from .s19_reactions import ( - get_pipe_reaction_schema, - get_pipe_reaction, - set_pipe_reaction, -) -from .s19_reactions import ( - get_tank_reaction_schema, - get_tank_reaction, - set_tank_reaction, -) - -from .s20_mixing import ( - MIXING_MODEL_MIXED, - MIXING_MODEL_2COMP, - MIXING_MODEL_FIFO, - MIXING_MODEL_LIFO, -) -from .s20_mixing import ( - get_mixing_schema, - get_mixing, - set_mixing, - add_mixing, - delete_mixing, -) - -from .s21_times import ( - TIME_STATISTIC_NONE, - TIME_STATISTIC_AVERAGED, - TIME_STATISTIC_MINIMUM, - TIME_STATISTIC_MAXIMUM, - TIME_STATISTIC_RANGE, -) -from .s21_times import get_time_schema, get_time, set_time - -from .s23_options_util import ( - OPTION_UNITS_CFS, - OPTION_UNITS_GPM, - OPTION_UNITS_MGD, - OPTION_UNITS_IMGD, - OPTION_UNITS_AFD, - OPTION_UNITS_LPS, - OPTION_UNITS_LPM, - OPTION_UNITS_MLD, - OPTION_UNITS_CMH, - OPTION_UNITS_CMD, -) -from .s23_options_util import ( - OPTION_PRESSURE_PSI, - OPTION_PRESSURE_KPA, - OPTION_PRESSURE_METERS, -) -from .s23_options_util import OPTION_HEADLOSS_HW, OPTION_HEADLOSS_DW, OPTION_HEADLOSS_CM -from .s23_options_util import OPTION_UNBALANCED_STOP, OPTION_UNBALANCED_CONTINUE -from .s23_options_util import OPTION_DEMAND_MODEL_DDA, OPTION_DEMAND_MODEL_PDA -from .s23_options_util import ( - OPTION_QUALITY_NONE, - OPTION_QUALITY_CHEMICAL, - OPTION_QUALITY_AGE, - OPTION_QUALITY_TRACE, -) -from .s23_options_util import get_option_schema, get_option -from .batch_api import set_option_ex - -from .s23_options_util import ( - OPTION_V3_FLOW_UNITS_CFS, - OPTION_V3_FLOW_UNITS_GPM, - OPTION_V3_FLOW_UNITS_MGD, - OPTION_V3_FLOW_UNITS_IMGD, - OPTION_V3_FLOW_UNITS_AFD, - OPTION_V3_FLOW_UNITS_LPS, - OPTION_V3_FLOW_UNITS_LPM, - OPTION_V3_FLOW_UNITS_MLD, - OPTION_V3_FLOW_UNITS_CMH, - OPTION_V3_FLOW_UNITS_CMD, -) -from .s23_options_util import ( - OPTION_V3_PRESSURE_UNITS_PSI, - OPTION_V3_PRESSURE_UNITS_KPA, - OPTION_V3_PRESSURE_UNITS_METERS, -) -from .s23_options_util import ( - OPTION_V3_HEADLOSS_MODEL_HW, - OPTION_V3_HEADLOSS_MODEL_DW, - OPTION_V3_HEADLOSS_MODEL_CM, -) -from .s23_options_util import ( - OPTION_V3_STEP_SIZING_FULL, - OPTION_V3_STEP_SIZING_RELAXATION, - OPTION_V3_STEP_SIZING_LINESEARCH, -) -from .s23_options_util import ( - OPTION_V3_IF_UNBALANCED_STOP, - OPTION_V3_IF_UNBALANCED_CONTINUE, -) -from .s23_options_util import ( - OPTION_V3_DEMAND_MODEL_FIXED, - OPTION_V3_DEMAND_MODEL_CONSTRAINED, - OPTION_V3_DEMAND_MODEL_POWER, - OPTION_V3_DEMAND_MODEL_LOGISTIC, -) -from .s23_options_util import ( - OPTION_V3_LEAKAGE_MODEL_NONE, - OPTION_V3_LEAKAGE_MODEL_POWER, - OPTION_V3_LEAKAGE_MODEL_FAVAD, -) -from .s23_options_util import ( - OPTION_V3_QUALITY_MODEL_NONE, - OPTION_V3_QUALITY_MODEL_CHEMICAL, - OPTION_V3_QUALITY_MODEL_AGE, - OPTION_V3_QUALITY_MODEL_TRACE, -) -from .s23_options_util import ( - OPTION_V3_QUALITY_UNITS_HRS, - OPTION_V3_QUALITY_UNITS_PCNT, - OPTION_V3_QUALITY_UNITS_MGL, - OPTION_V3_QUALITY_UNITS_UGL, -) -from .s23_options_util import get_option_v3_schema, get_option_v3 -from .batch_api import set_option_v3_ex - -from .s24_coordinates import ( - get_links_in_extent, - get_node_coord, - get_nodes_in_extent, -) - -from .s25_vertices import ( - get_vertex_schema, - get_vertex, - set_vertex, - add_vertex, - delete_vertex, -) -from .s25_vertices import get_all_vertex_links, get_all_vertices - -from .s26_labels import get_label_schema, get_label, set_label, add_label, delete_label - -from .s27_backdrop import get_backdrop_schema, get_backdrop, set_backdrop - -# ----------------------------------------------------------------------------- -# SCADA 映射与遥测实体 -# ----------------------------------------------------------------------------- -from .s29_scada_device import ( - SCADA_DEVICE_TYPE_PRESSURE, - SCADA_DEVICE_TYPE_DEMAND, - SCADA_DEVICE_TYPE_QUALITY, - SCADA_DEVICE_TYPE_LEVEL, - SCADA_DEVICE_TYPE_FLOW, - SCADA_DEVICE_TYPE_UNKNOWN, -) -from .s29_scada_device import ( - get_scada_device_schema, - get_scada_device, - set_scada_device, - add_scada_device, - delete_scada_device, -) -from .s29_scada_device import get_all_scada_device_ids, get_all_scada_devices -from .clean_api import clean_scada_device - -from .s30_scada_device_data import ( - get_scada_device_data_schema, - get_scada_device_data, - set_scada_device_data, - add_scada_device_data, - delete_scada_device_data, -) -from .clean_api import clean_scada_device_data - -from .s31_scada_element import ( - SCADA_MODEL_TYPE_JUNCTION, - SCADA_MODEL_TYPE_RESERVOIR, - SCADA_MODEL_TYPE_TANK, - SCADA_MODEL_TYPE_PIPE, - SCADA_MODEL_TYPE_PUMP, - SCADA_MODEL_TYPE_VALVE, -) -from .s31_scada_element import SCADA_ELEMENT_STATUS_OFFLINE, SCADA_ELEMENT_STATUS_ONLINE -from .s31_scada_element import ( - get_scada_element_schema, - get_scada_element, - set_scada_element, - add_scada_element, - delete_scada_element, -) -from .s31_scada_element import get_all_scada_element_ids, get_all_scada_elements -from .clean_api import clean_scada_element - -# ----------------------------------------------------------------------------- -# 区域、DMA、服务分区、虚拟分区与需水分配 -# ----------------------------------------------------------------------------- -from .s32_region_util import ( - get_nodes_in_boundary, - get_nodes_in_region, - get_links_on_region_boundary, - calculate_convex_hull, - calculate_boundary, - inflate_boundary, - inflate_region, -) -from .s32_region import ( - get_region_schema, - get_region, - set_region, - add_region, - delete_region, -) - -from .s33_dma_cal import PARTITION_TYPE_RB, PARTITION_TYPE_KWAY -from .s33_dma_cal import ( - calculate_district_metering_area_for_nodes, - calculate_district_metering_area_for_region, - calculate_district_metering_area_for_network, -) -from .s33_dma import ( - get_district_metering_area_schema, - get_district_metering_area, - set_district_metering_area, - add_district_metering_area, - delete_district_metering_area, -) -from .s33_dma import get_all_district_metering_area_ids, get_all_district_metering_areas -from .s33_dma_gen import ( - generate_district_metering_area, - generate_sub_district_metering_area, -) - -from .s34_sa_cal import calculate_service_area -from .s34_sa import ( - get_service_area_schema, - get_service_area, - set_service_area, - add_service_area, - delete_service_area, -) -from .s34_sa import get_all_service_area_ids, get_all_service_areas -from .s34_sa_gen import generate_service_area - -from .s35_vd_cal import calculate_virtual_district -from .s35_vd import ( - get_virtual_district_schema, - get_virtual_district, - set_virtual_district, - add_virtual_district, - delete_virtual_district, -) -from .s35_vd import get_all_virtual_district_ids, get_all_virtual_districts -from .s35_vd_gen import generate_virtual_district - -from .s36_wda_cal import ( - calculate_demand_to_nodes, - calculate_demand_to_region, - calculate_demand_to_network, -) - -# ----------------------------------------------------------------------------- -# 元数据与高级分析 -# ----------------------------------------------------------------------------- -from .s38_scada_info import get_scada_info_schema, get_scada_info, get_all_scada_info - -from .s40_schema import get_scheme_schema, get_scheme, get_all_schemes - -from .s41_pipe_risk_probability import ( - get_pipe_risk_probability_now, - get_pipe_risk_probability, - get_network_pipe_risk_probability_now, - get_pipes_risk_probability, - get_pipe_risk_probability_geometries, -) - -from .s42_sensor_placement import ( - get_all_sensor_placements, - get_sensor_placement, - get_sensor_placement_nodes, - update_sensor_placement, -) - -from .s43_burst_locate_result import get_all_burst_locate_results diff --git a/app/native/wndb/batch_api.py b/app/native/wndb/batch_api.py deleted file mode 100644 index 1c47d54..0000000 --- a/app/native/wndb/batch_api.py +++ /dev/null @@ -1,53 +0,0 @@ -from .sections import * -from .database import ChangeSet, API_DELETE, API_UPDATE -from .batch_exe import execute_batch_command - - -def delete_junction_cascade(name: str, cs: ChangeSet) -> ChangeSet: - cs.operations[0] |= { 'operation' : API_DELETE, 'type' : s2_junction } - return execute_batch_command(name, cs) - - -def delete_reservoir_cascade(name: str, cs: ChangeSet) -> ChangeSet: - cs.operations[0] |= { 'operation' : API_DELETE, 'type' : s3_reservoir } - return execute_batch_command(name, cs) - - -def delete_tank_cascade(name: str, cs: ChangeSet) -> ChangeSet: - cs.operations[0] |= { 'operation' : API_DELETE, 'type' : s4_tank } - return execute_batch_command(name, cs) - - -def delete_pipe_cascade(name: str, cs: ChangeSet) -> ChangeSet: - cs.operations[0] |= { 'operation' : API_DELETE, 'type' : s5_pipe } - return execute_batch_command(name, cs) - - -def delete_pump_cascade(name: str, cs: ChangeSet) -> ChangeSet: - cs.operations[0] |= { 'operation' : API_DELETE, 'type' : s6_pump } - return execute_batch_command(name, cs) - - -def delete_valve_cascade(name: str, cs: ChangeSet) -> ChangeSet: - cs.operations[0] |= { 'operation' : API_DELETE, 'type' : s7_valve } - return execute_batch_command(name, cs) - - -def delete_pattern_cascade(name: str, cs: ChangeSet) -> ChangeSet: - cs.operations[0] |= { 'operation' : API_DELETE, 'type' : s11_pattern } - return execute_batch_command(name, cs) - - -def delete_curve_cascade(name: str, cs: ChangeSet) -> ChangeSet: - cs.operations[0] |= { 'operation' : API_DELETE, 'type' : s12_curve } - return execute_batch_command(name, cs) - - -def set_option_ex(name: str, cs: ChangeSet) -> ChangeSet: - cs.operations[0] |= { 'operation' : API_UPDATE, 'type' : s23_option } - return execute_batch_command(name, cs) - - -def set_option_v3_ex(name: str, cs: ChangeSet) -> ChangeSet: - cs.operations[0] |= { 'operation' : API_UPDATE, 'type' : s23_option_v3 } - return execute_batch_command(name, cs) diff --git a/app/native/wndb/batch_api_cs.py b/app/native/wndb/batch_api_cs.py deleted file mode 100644 index ec94f53..0000000 --- a/app/native/wndb/batch_api_cs.py +++ /dev/null @@ -1,238 +0,0 @@ -from .database import ChangeSet, g_delete_prefix, API_DELETE, API_UPDATE, try_read -from .sections import * - -from .s0_base import * - -from .s3_reservoirs import unset_reservoir_by_pattern -from .s4_tanks import unset_tank_by_curve -from .s6_pumps import unset_pump_by_curve, unset_pump_by_pattern -from .s8_tags import delete_tag_by_node, delete_tag_by_link -from .s9_demands import delete_demand_by_junction, unset_demand_by_pattern -from .s10_status import delete_status_by_link -from .s15_energy import delete_pump_energy_by_pump, unset_pump_energy_by_pattern, unset_pump_energy_by_curve -from .s16_emitters import delete_emitter_by_junction -from .s17_quality import delete_quality_by_node -from .s18_sources import delete_source_by_node, unset_source_by_pattern -from .s19_reactions import delete_pipe_reaction_by_pipe, delete_tank_reaction_by_tank -from .s20_mixing import delete_mixing_by_tank -from .s25_vertices import delete_vertex_by_link -from .s26_labels import unset_label_by_node - -from .s23_options_util import generate_v2, generate_v3 - - -def delete_junction_cascade_batch_cs(name: str, cs: ChangeSet) -> ChangeSet: - result = ChangeSet() - - id = cs.operations[0]['id'] - row = try_read(name, f"select * from junctions where id = '{id}'") - if row == None: - return result - - links = get_node_links(name, id) - - for link in links: - if is_pipe(name, link): - result.merge(delete_pipe_cascade_batch_cs(name, ChangeSet(g_delete_prefix | {'type': 'pipe', 'id': link}))) - if is_pump(name, link): - result.merge(delete_pump_cascade_batch_cs(name, ChangeSet(g_delete_prefix | {'type': 'pump', 'id': link}))) - if is_valve(name, link): - result.merge(delete_valve_cascade_batch_cs(name, ChangeSet(g_delete_prefix | {'type': 'valve', 'id': link}))) - - result.merge(delete_tag_by_node(name, id)) - result.merge(delete_demand_by_junction(name, id)) - result.merge(delete_emitter_by_junction(name, id)) - result.merge(delete_quality_by_node(name, id)) - result.merge(delete_source_by_node(name, id)) - result.merge(unset_label_by_node(name, id)) - result.merge(cs) - - return result - - -def delete_reservoir_cascade_batch_cs(name: str, cs: ChangeSet) -> ChangeSet: - result = ChangeSet() - - id = cs.operations[0]['id'] - row = try_read(name, f"select * from reservoirs where id = '{id}'") - if row == None: - return result - - links = get_node_links(name, id) - - for link in links: - if is_pipe(name, link): - result.merge(delete_pipe_cascade_batch_cs(name, ChangeSet(g_delete_prefix | {'type': 'pipe', 'id': link}))) - if is_pump(name, link): - result.merge(delete_pump_cascade_batch_cs(name, ChangeSet(g_delete_prefix | {'type': 'pump', 'id': link}))) - if is_valve(name, link): - result.merge(delete_valve_cascade_batch_cs(name, ChangeSet(g_delete_prefix | {'type': 'valve', 'id': link}))) - - result.merge(delete_tag_by_node(name, id)) - result.merge(delete_quality_by_node(name, id)) - result.merge(delete_source_by_node(name, id)) - result.merge(unset_label_by_node(name, id)) - result.merge(cs) - - return result - - -def delete_tank_cascade_batch_cs(name: str, cs: ChangeSet) -> ChangeSet: - result = ChangeSet() - - id = cs.operations[0]['id'] - row = try_read(name, f"select * from tanks where id = '{id}'") - if row == None: - return result - - links = get_node_links(name, id) - - for link in links: - if is_pipe(name, link): - result.merge(delete_pipe_cascade_batch_cs(name, ChangeSet(g_delete_prefix | {'type': 'pipe', 'id': link}))) - if is_pump(name, link): - result.merge(delete_pump_cascade_batch_cs(name, ChangeSet(g_delete_prefix | {'type': 'pump', 'id': link}))) - if is_valve(name, link): - result.merge(delete_valve_cascade_batch_cs(name, ChangeSet(g_delete_prefix | {'type': 'valve', 'id': link}))) - - result.merge(delete_tag_by_node(name, id)) - result.merge(delete_quality_by_node(name, id)) - result.merge(delete_source_by_node(name, id)) - result.merge(delete_tank_reaction_by_tank(name, id)) - result.merge(delete_mixing_by_tank(name, id)) - result.merge(unset_label_by_node(name, id)) - result.merge(cs) - - return result - - -def delete_pipe_cascade_batch_cs(name: str, cs: ChangeSet) -> ChangeSet: - result = ChangeSet() - - id = cs.operations[0]['id'] - row = try_read(name, f"select * from pipes where id = '{id}'") - if row == None: - return result - - result.merge(delete_tag_by_link(name, id)) - result.merge(delete_status_by_link(name, id)) - result.merge(delete_pipe_reaction_by_pipe(name, id)) - result.merge(delete_vertex_by_link(name, id)) - result.merge(cs) - - return result - - -def delete_pump_cascade_batch_cs(name: str, cs: ChangeSet) -> ChangeSet: - result = ChangeSet() - - id = cs.operations[0]['id'] - row = try_read(name, f"select * from pumps where id = '{id}'") - if row == None: - return result - - result.merge(delete_tag_by_link(name, id)) - result.merge(delete_status_by_link(name, id)) - result.merge(delete_pump_energy_by_pump(name, id)) - result.merge(delete_vertex_by_link(name, id)) - result.merge(cs) - - return result - - -def delete_valve_cascade_batch_cs(name: str, cs: ChangeSet) -> ChangeSet: - result = ChangeSet() - - id = cs.operations[0]['id'] - row = try_read(name, f"select * from valves where id = '{id}'") - if row == None: - return result - - result.merge(delete_tag_by_link(name, id)) - result.merge(delete_status_by_link(name, id)) - result.merge(delete_vertex_by_link(name, id)) - - result.merge(cs) - - return result - - -def delete_pattern_cascade_batch_cs(name: str, cs: ChangeSet) -> ChangeSet: - result = ChangeSet() - - id = cs.operations[0]['id'] - row = try_read(name, f"select * from _pattern where id = '{id}'") - if row == None: - return result - - 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) - - return result - - -def delete_curve_cascade_batch_cs(name: str, cs: ChangeSet) -> ChangeSet: - result = ChangeSet() - - id = cs.operations[0]['id'] - row = try_read(name, f"select * from _curve where id = '{id}'") - if row == None: - return result - - result.merge(unset_tank_by_curve(name, id)) - result.merge(unset_pump_by_curve(name, id)) - result.merge(unset_pump_energy_by_curve(name, id)) - result.merge(cs) - - return result - - -def set_option_cs(cs: ChangeSet) -> ChangeSet: - cs.operations[0]['operation'] = API_UPDATE - cs.operations[0]['type'] = 'option' - new_cs = cs - new_cs.merge(generate_v3(cs)) - return new_cs - - -def set_option_v3_cs(cs: ChangeSet) -> ChangeSet: - cs.operations[0]['operation'] = API_UPDATE - cs.operations[0]['type'] = 'option_v3' - new_cs = cs - new_cs.merge(generate_v2(cs)) - return new_cs - - -def rewrite_batch_api(name: str, cs: ChangeSet) -> ChangeSet: - op = cs.operations[0] - api = op['operation'] - type = op['type'] - - if api == API_DELETE: - if type == s2_junction: - return delete_junction_cascade_batch_cs(name, cs) - elif type == s3_reservoir: - return delete_reservoir_cascade_batch_cs(name, cs) - elif type == s4_tank: - return delete_tank_cascade_batch_cs(name, cs) - elif type == s5_pipe: - return delete_pipe_cascade_batch_cs(name, cs) - elif type == s6_pump: - return delete_pump_cascade_batch_cs(name, cs) - elif type == s7_valve: - return delete_valve_cascade_batch_cs(name, cs) - elif type == s11_pattern: - return delete_pattern_cascade_batch_cs(name, cs) - elif type == s12_curve: - return delete_curve_cascade_batch_cs(name, cs) - elif api == API_UPDATE: - if type == s23_option: - return set_option_cs(cs) - elif type == s23_option_v3: - return set_option_v3_cs(cs) - - return cs diff --git a/app/native/wndb/batch_exe.py b/app/native/wndb/batch_exe.py deleted file mode 100644 index 080688e..0000000 --- a/app/native/wndb/batch_exe.py +++ /dev/null @@ -1,380 +0,0 @@ -from typing import Any -from .sections import * -from .database import API_ADD, API_UPDATE, API_DELETE, ChangeSet, write, read, read_all, get_current_operation -from .extension_data import set_extension_data -from .s1_title import set_title -from .s2_junctions import set_junction, add_junction, delete_junction -from .s3_reservoirs import set_reservoir, add_reservoir, delete_reservoir -from .s4_tanks import set_tank, add_tank, delete_tank -from .s5_pipes import set_pipe, add_pipe, delete_pipe -from .s6_pumps import set_pump, add_pump, delete_pump -from .s7_valves import set_valve, add_valve, delete_valve -from .s8_tags import set_tag -from .s9_demands import set_demand -from .s10_status import set_status -from .s11_patterns import set_pattern, add_pattern, delete_pattern -from .s12_curves import set_curve, add_curve, delete_curve -from .s13_controls import set_control -from .s14_rules import set_rule -from .s15_energy import set_energy, set_pump_energy -from .s16_emitters import set_emitter -from .s17_quality import set_quality -from .s18_sources import set_source, add_source, delete_source -from .s19_reactions import set_reaction, set_pipe_reaction, set_tank_reaction -from .s20_mixing import set_mixing, add_mixing, delete_mixing -from .s21_times import set_time -from .s23_options_util import set_option, set_option_v3 -from .s25_vertices import set_vertex, add_vertex, delete_vertex -from .s26_labels import set_label, add_label, delete_label -from .s27_backdrop import set_backdrop -from .s29_scada_device import set_scada_device, add_scada_device, delete_scada_device -from .s30_scada_device_data import set_scada_device_data, add_scada_device_data, delete_scada_device_data -from .s31_scada_element import set_scada_element, add_scada_element, delete_scada_element -from .s32_region import set_region, add_region, delete_region -from .s33_dma import set_district_metering_area, add_district_metering_area, delete_district_metering_area -from .s34_sa import set_service_area, add_service_area, delete_service_area -from .s35_vd import set_virtual_district, add_virtual_district, delete_virtual_district -from .batch_api_cs import rewrite_batch_api - - -def _execute_add_command(name: str, cs: ChangeSet) -> ChangeSet: - type = cs.operations[0]['type'] - - if type == s1_title: - return ChangeSet() - if type == s2_junction: - return add_junction(name, cs) - elif type == s3_reservoir: - return add_reservoir(name, cs) - elif type == s4_tank: - return add_tank(name, cs) - elif type == s5_pipe: - return add_pipe(name, cs) - elif type == s6_pump: - return add_pump(name, cs) - elif type == s7_valve: - return add_valve(name, cs) - elif type == s8_tag: - return ChangeSet() - elif type == s9_demand: - return ChangeSet() - elif type == s10_status: - return ChangeSet() - elif type == s11_pattern: - return add_pattern(name, cs) - elif type == s12_curve: - return add_curve(name, cs) - elif type == s13_control: - return ChangeSet() - elif type == s14_rule: - return ChangeSet() - elif type == s15_energy: - return ChangeSet() - elif type == s15_pump_energy: - return ChangeSet() - elif type == s16_emitter: - return ChangeSet() - elif type == s17_quality: - return ChangeSet() - elif type == s18_source: - return add_source(name, cs) - elif type == s19_reaction: - return ChangeSet() - elif type == s19_pipe_reaction: - return ChangeSet() - elif type == s19_tank_reaction: - return ChangeSet() - elif type == s20_mixing: - return add_mixing(name, cs) - elif type == s21_time: - return ChangeSet() - elif type == s22_report: - return ChangeSet() - elif type == s23_option: - return ChangeSet() - elif type == s23_option_v3: - return ChangeSet() - elif type == s24_coordinate: - return ChangeSet() - elif type == s25_vertex: - return add_vertex(name, cs) - elif type == s26_label: - return add_label(name, cs) - elif type == s27_backdrop: - return ChangeSet() - elif type == s28_end: - return ChangeSet() - elif type == s29_scada_device: - return add_scada_device(name, cs) - elif type == s30_scada_device_data: - return add_scada_device_data(name, cs) - elif type == s31_scada_element: - return add_scada_element(name, cs) - elif type == s32_region: - return add_region(name, cs) - elif type == s33_dma: - return add_district_metering_area(name, cs) - elif type == s34_sa: - return add_service_area(name, cs) - elif type == s35_vd: - return add_virtual_district(name, cs) - - return ChangeSet() - - -def _execute_update_command(name: str, cs: ChangeSet) -> ChangeSet: - type = cs.operations[0]['type'] - - if type == 'extension_data': - return set_extension_data(name, cs) - if type == s1_title: - return set_title(name, cs) - if type == s2_junction: - return set_junction(name, cs) - elif type == s3_reservoir: - return set_reservoir(name, cs) - elif type == s4_tank: - return set_tank(name, cs) - elif type == s5_pipe: - return set_pipe(name, cs) - elif type == s6_pump: - return set_pump(name, cs) - elif type == s7_valve: - return set_valve(name, cs) - elif type == s8_tag: - return set_tag(name, cs) - elif type == s9_demand: - return set_demand(name, cs) - elif type == s10_status: - return set_status(name, cs) - elif type == s11_pattern: - return set_pattern(name, cs) - elif type == s12_curve: - return set_curve(name, cs) - elif type == s13_control: - return set_control(name, cs) - elif type == s14_rule: - return set_rule(name, cs) - elif type == s15_energy: - return set_energy(name, cs) - elif type == s15_pump_energy: - return set_pump_energy(name, cs) - elif type == s16_emitter: - return set_emitter(name, cs) - elif type == s17_quality: - return set_quality(name, cs) - elif type == s18_source: - return set_source(name, cs) - elif type == s19_reaction: - return set_reaction(name, cs) - elif type == s19_pipe_reaction: - return set_pipe_reaction(name, cs) - elif type == s19_tank_reaction: - return set_tank_reaction(name, cs) - elif type == s20_mixing: - return set_mixing(name, cs) - elif type == s21_time: - return set_time(name, cs) - elif type == s22_report: # no api now - return ChangeSet() - elif type == s23_option: - return set_option(name, cs) - elif type == s23_option_v3: - return set_option_v3(name, cs) - elif type == s24_coordinate: # do not support update here - return ChangeSet() - elif type == s25_vertex: - return set_vertex(name, cs) - elif type == s26_label: - return set_label(name, cs) - elif type == s27_backdrop: - return set_backdrop(name, cs) - elif type == s28_end: # end - return ChangeSet() - elif type == s29_scada_device: - return set_scada_device(name, cs) - elif type == s30_scada_device_data: - return set_scada_device_data(name, cs) - elif type == s31_scada_element: - return set_scada_element(name, cs) - elif type == s32_region: - return set_region(name, cs) - elif type == s33_dma: - return set_district_metering_area(name, cs) - elif type == s34_sa: - return set_service_area(name, cs) - elif type == s35_vd: - return set_virtual_district(name, cs) - - return ChangeSet() - - -def _execute_delete_command(name: str, cs: ChangeSet) -> ChangeSet: - type = cs.operations[0]['type'] - - if type == s1_title: - return ChangeSet() - if type == s2_junction: - return delete_junction(name, cs) - elif type == s3_reservoir: - return delete_reservoir(name, cs) - elif type == s4_tank: - return delete_tank(name, cs) - elif type == s5_pipe: - return delete_pipe(name, cs) - elif type == s6_pump: - return delete_pump(name, cs) - elif type == s7_valve: - return delete_valve(name, cs) - elif type == s8_tag: - return ChangeSet() - elif type == s9_demand: - return ChangeSet() - elif type == s10_status: - return ChangeSet() - elif type == s11_pattern: - return delete_pattern(name, cs) - elif type == s12_curve: - return delete_curve(name, cs) - elif type == s13_control: - return ChangeSet() - elif type == s14_rule: - return ChangeSet() - elif type == s15_energy: - return ChangeSet() - elif type == s15_pump_energy: - return ChangeSet() - elif type == s16_emitter: - return ChangeSet() - elif type == s17_quality: - return ChangeSet() - elif type == s18_source: - return delete_source(name, cs) - elif type == s19_reaction: - return ChangeSet() - elif type == s19_pipe_reaction: - return ChangeSet() - elif type == s19_tank_reaction: - return ChangeSet() - elif type == s20_mixing: - return delete_mixing(name, cs) - elif type == s21_time: - return ChangeSet() - elif type == s22_report: - return ChangeSet() - elif type == s23_option: - return ChangeSet() - elif type == s23_option_v3: - return ChangeSet() - elif type == s24_coordinate: - return ChangeSet() - elif type == s25_vertex: - return delete_vertex(name, cs) - elif type == s26_label: - return delete_label(name, cs) - elif type == s27_backdrop: - return ChangeSet() - elif type == s28_end: - return ChangeSet() - elif type == s29_scada_device: - return delete_scada_device(name, cs) - elif type == s30_scada_device_data: - return delete_scada_device_data(name, cs) - elif type == s31_scada_element: - return delete_scada_element(name, cs) - elif type == s32_region: - return delete_region(name, cs) - elif type == s33_dma: - return delete_district_metering_area(name, cs) - elif type == s34_sa: - return delete_service_area(name, cs) - elif type == s35_vd: - return delete_virtual_district(name, cs) - - return ChangeSet() - - -def execute_batch_commands(name: str, cs: ChangeSet) -> ChangeSet: - new_cs = ChangeSet() - for op in cs.operations: - new_cs.merge(rewrite_batch_api(name, ChangeSet(op))) - - result = ChangeSet() - - todo = {} - - try: - for op in new_cs.operations: - todo = op - operation = op['operation'] - if operation == API_ADD: - result.merge(_execute_add_command(name, ChangeSet(op))) - elif operation == API_UPDATE: - result.merge(_execute_update_command(name, ChangeSet(op))) - elif operation == API_DELETE: - result.merge(_execute_delete_command(name, ChangeSet(op))) - except: - print(f'ERROR: Fail to execute {todo}') - - return result - - -def execute_batch_command(name: str, cs: ChangeSet) -> ChangeSet: - write(name, 'delete from batch_operation where id > 0') - write(name, "update operation_table set option = 'batch_operation' where option = 'operation'") - - new_cs = ChangeSet() - for op in cs.operations: - new_cs.merge(rewrite_batch_api(name, ChangeSet(op))) - - result = ChangeSet() - - todo = {} - - try: - for op in new_cs.operations: - todo = op - operation = op['operation'] - if operation == API_ADD: - result.merge(_execute_add_command(name, ChangeSet(op))) - elif operation == API_UPDATE: - result.merge(_execute_update_command(name, ChangeSet(op))) - elif operation == API_DELETE: - result.merge(_execute_delete_command(name, ChangeSet(op))) - except: - print(f'ERROR: Fail to execute {todo}') - - count = read(name, 'select count(*) as count from batch_operation')['count'] - if count == 1: - write(name, 'delete from batch_operation where id > 0') - write(name, "update operation_table set option = 'operation' where option = 'batch_operation'") - return ChangeSet() - - redo_list: list[str] = [] - redo_cs_list: list[dict[str, Any]] = [] - redo_rows = read_all(name, 'select redo, redo_cs from batch_operation where id > 0 order by id asc') - for row in redo_rows: - redo_list.append(row['redo']) - redo_cs_list += eval(row['redo_cs']) - - undo_list: list[str] = [] - undo_cs_list: list[dict[str, Any]] = [] - undo_rows = read_all(name, 'select undo, undo_cs from batch_operation where id > 0 order by id desc') - for row in undo_rows: - undo_list.append(row['undo']) - undo_cs_list += eval(row['undo_cs']) - - redo = '\n'.join(redo_list).replace("'", "''") - redo_cs = str(redo_cs_list).replace("'", "''") - undo = '\n'.join(undo_list).replace("'", "''") - undo_cs = str(undo_cs_list).replace("'", "''") - - parent = get_current_operation(name) - write(name, f"insert into operation (id, redo, undo, parent, redo_cs, undo_cs) values (default, '{redo}', '{undo}', {parent}, '{redo_cs}', '{undo_cs}')") - current = read(name, 'select max(id) as id from operation')['id'] - write(name, f"update current_operation set id = {current}") - - write(name, 'delete from batch_operation where id > 0') - write(name, "update operation_table set option = 'operation' where option = 'batch_operation'") - - return result diff --git a/app/native/wndb/clean_api.py b/app/native/wndb/clean_api.py deleted file mode 100644 index ba3d7f8..0000000 --- a/app/native/wndb/clean_api.py +++ /dev/null @@ -1,45 +0,0 @@ -from .database import ChangeSet, read_all -from .batch_exe import execute_batch_command - -# TODO: merge to batch_api - -def clean_scada_device_cs(name: str) -> ChangeSet: - cs = ChangeSet() - - rows = read_all(name, 'select id from scada_device acs') - for row in rows: - cs.delete({ 'type': 'scada_device', 'id': row['id'] }) - - return cs - - -def clean_scada_device_data_cs(name: str) -> ChangeSet: - cs = ChangeSet() - - rows = read_all(name, 'select distinct device_id from scada_device_data acs') - for row in rows: - cs.update({ 'type': 'scada_device_data', 'device_id': row['device_id'], 'data': [] }) - - return cs - - -def clean_scada_element_cs(name: str) -> ChangeSet: - cs = ChangeSet() - - rows = read_all(name, 'select id from scada_element acs') - for row in rows: - cs.delete({ 'type': 'scada_element', 'id': row['id'] }) - - return cs - - -def clean_scada_device(name: str) -> ChangeSet: - return execute_batch_command(name, clean_scada_device_cs(name)) - - -def clean_scada_device_data(name: str) -> ChangeSet: - return execute_batch_command(name, clean_scada_device_data_cs(name)) - - -def clean_scada_element(name: str) -> ChangeSet: - return execute_batch_command(name, clean_scada_element_cs(name)) diff --git a/app/native/wndb/commands/__init__.py b/app/native/wndb/commands/__init__.py new file mode 100644 index 0000000..a1a7f7f --- /dev/null +++ b/app/native/wndb/commands/__init__.py @@ -0,0 +1 @@ +"""Model command rewriting, cascade handling, and execution.""" diff --git a/app/native/wndb/commands/api.py b/app/native/wndb/commands/api.py new file mode 100644 index 0000000..f5e99b6 --- /dev/null +++ b/app/native/wndb/commands/api.py @@ -0,0 +1,65 @@ +"""Convenience entry points for cascade deletes and option synchronization.""" + +from ..core.database import API_DELETE, API_UPDATE, ChangeSet +from .executor import execute_batch_command + + +def _execute( + name: str, + change_set: ChangeSet, + *, + operation: str, + element_type: str, +) -> ChangeSet: + change_set.operations[0].update( + {"operation": operation, "type": element_type} + ) + return execute_batch_command(name, change_set) + + +def delete_junction_cascade(name: str, change_set: ChangeSet) -> ChangeSet: + return _execute( + name, change_set, operation=API_DELETE, element_type="junction" + ) + + +def delete_reservoir_cascade(name: str, change_set: ChangeSet) -> ChangeSet: + return _execute( + name, change_set, operation=API_DELETE, element_type="reservoir" + ) + + +def delete_tank_cascade(name: str, change_set: ChangeSet) -> ChangeSet: + return _execute(name, change_set, operation=API_DELETE, element_type="tank") + + +def delete_pipe_cascade(name: str, change_set: ChangeSet) -> ChangeSet: + return _execute(name, change_set, operation=API_DELETE, element_type="pipe") + + +def delete_pump_cascade(name: str, change_set: ChangeSet) -> ChangeSet: + return _execute(name, change_set, operation=API_DELETE, element_type="pump") + + +def delete_valve_cascade(name: str, change_set: ChangeSet) -> ChangeSet: + return _execute(name, change_set, operation=API_DELETE, element_type="valve") + + +def delete_pattern_cascade(name: str, change_set: ChangeSet) -> ChangeSet: + return _execute( + name, change_set, operation=API_DELETE, element_type="pattern" + ) + + +def delete_curve_cascade(name: str, change_set: ChangeSet) -> ChangeSet: + return _execute(name, change_set, operation=API_DELETE, element_type="curve") + + +def set_option_ex(name: str, change_set: ChangeSet) -> ChangeSet: + return _execute(name, change_set, operation=API_UPDATE, element_type="option") + + +def set_option_v3_ex(name: str, change_set: ChangeSet) -> ChangeSet: + return _execute( + name, change_set, operation=API_UPDATE, element_type="option_v3" + ) diff --git a/app/native/wndb/commands/cascade.py b/app/native/wndb/commands/cascade.py new file mode 100644 index 0000000..b0669a9 --- /dev/null +++ b/app/native/wndb/commands/cascade.py @@ -0,0 +1,244 @@ +from collections.abc import Callable + +from ..core.database import API_DELETE, API_UPDATE, ChangeSet, g_delete_prefix, try_read +from ..model.elements import get_node_links, is_pipe, is_pump, is_valve + +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.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 +from ..model.quality import delete_quality_by_node +from ..model.sources import delete_source_by_node, unset_source_by_pattern +from ..model.reactions import delete_pipe_reaction_by_pipe, delete_tank_reaction_by_tank +from ..model.mixing import delete_mixing_by_tank +from ..gis.vertices import delete_vertex_by_link +from ..gis.labels import unset_label_by_node + +from ..model.options import generate_v2, generate_v3 + + +def expand_junction_delete(name: str, cs: ChangeSet) -> ChangeSet: + result = ChangeSet() + + id = cs.operations[0]['id'] + row = try_read(name, "select 1 from network.junctions where node_id = %s", (id,)) + if row == None: + return result + + links = get_node_links(name, id) + + for link in links: + if is_pipe(name, link): + result.merge(expand_pipe_delete(name, ChangeSet(g_delete_prefix | {'type': 'pipe', 'id': link}))) + if is_pump(name, link): + result.merge(expand_pump_delete(name, ChangeSet(g_delete_prefix | {'type': 'pump', 'id': link}))) + if is_valve(name, link): + result.merge(expand_valve_delete(name, ChangeSet(g_delete_prefix | {'type': 'valve', 'id': link}))) + + result.merge(delete_tag_by_node(name, id)) + result.merge(delete_demand_by_junction(name, id)) + result.merge(delete_emitter_by_junction(name, id)) + result.merge(delete_quality_by_node(name, id)) + result.merge(delete_source_by_node(name, id)) + result.merge(unset_label_by_node(name, id)) + result.merge(cs) + + return result + + +def expand_reservoir_delete(name: str, cs: ChangeSet) -> ChangeSet: + result = ChangeSet() + + id = cs.operations[0]['id'] + row = try_read(name, "select 1 from network.reservoirs where node_id = %s", (id,)) + if row == None: + return result + + links = get_node_links(name, id) + + for link in links: + if is_pipe(name, link): + result.merge(expand_pipe_delete(name, ChangeSet(g_delete_prefix | {'type': 'pipe', 'id': link}))) + if is_pump(name, link): + result.merge(expand_pump_delete(name, ChangeSet(g_delete_prefix | {'type': 'pump', 'id': link}))) + if is_valve(name, link): + result.merge(expand_valve_delete(name, ChangeSet(g_delete_prefix | {'type': 'valve', 'id': link}))) + + result.merge(delete_tag_by_node(name, id)) + result.merge(delete_quality_by_node(name, id)) + result.merge(delete_source_by_node(name, id)) + result.merge(unset_label_by_node(name, id)) + result.merge(cs) + + return result + + +def expand_tank_delete(name: str, cs: ChangeSet) -> ChangeSet: + result = ChangeSet() + + id = cs.operations[0]['id'] + row = try_read(name, "select 1 from network.tanks where node_id = %s", (id,)) + if row == None: + return result + + links = get_node_links(name, id) + + for link in links: + if is_pipe(name, link): + result.merge(expand_pipe_delete(name, ChangeSet(g_delete_prefix | {'type': 'pipe', 'id': link}))) + if is_pump(name, link): + result.merge(expand_pump_delete(name, ChangeSet(g_delete_prefix | {'type': 'pump', 'id': link}))) + if is_valve(name, link): + result.merge(expand_valve_delete(name, ChangeSet(g_delete_prefix | {'type': 'valve', 'id': link}))) + + result.merge(delete_tag_by_node(name, id)) + result.merge(delete_quality_by_node(name, id)) + result.merge(delete_source_by_node(name, id)) + result.merge(delete_tank_reaction_by_tank(name, id)) + result.merge(delete_mixing_by_tank(name, id)) + result.merge(unset_label_by_node(name, id)) + result.merge(cs) + + return result + + +def expand_pipe_delete(name: str, cs: ChangeSet) -> ChangeSet: + result = ChangeSet() + + id = cs.operations[0]['id'] + row = try_read(name, "select 1 from network.pipes where link_id = %s", (id,)) + if row == None: + return result + + result.merge(delete_tag_by_link(name, id)) + result.merge(delete_status_by_link(name, id)) + result.merge(delete_pipe_reaction_by_pipe(name, id)) + result.merge(delete_vertex_by_link(name, id)) + result.merge(cs) + + return result + + +def expand_pump_delete(name: str, cs: ChangeSet) -> ChangeSet: + result = ChangeSet() + + id = cs.operations[0]['id'] + row = try_read(name, "select 1 from network.pumps where link_id = %s", (id,)) + if row == None: + return result + + result.merge(delete_tag_by_link(name, id)) + result.merge(delete_status_by_link(name, id)) + result.merge(delete_pump_energy_by_pump(name, id)) + result.merge(delete_vertex_by_link(name, id)) + result.merge(cs) + + return result + + +def expand_valve_delete(name: str, cs: ChangeSet) -> ChangeSet: + result = ChangeSet() + + id = cs.operations[0]['id'] + row = try_read(name, "select 1 from network.valves where link_id = %s", (id,)) + if row == None: + return result + + result.merge(delete_tag_by_link(name, id)) + result.merge(delete_status_by_link(name, id)) + result.merge(delete_vertex_by_link(name, id)) + + result.merge(cs) + + return result + + +def expand_pattern_delete(name: str, cs: ChangeSet) -> ChangeSet: + result = ChangeSet() + + id = cs.operations[0]['id'] + row = try_read(name, "select 1 from network.patterns where id = %s", (id,)) + if row == None: + return result + + 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) + + return result + + +def expand_curve_delete(name: str, cs: ChangeSet) -> ChangeSet: + result = ChangeSet() + + id = cs.operations[0]['id'] + row = try_read(name, "select 1 from network.curves where id = %s", (id,)) + if row == None: + return result + + result.merge(unset_tank_by_curve(name, id)) + result.merge(unset_pump_by_curve(name, id)) + result.merge(unset_pump_energy_by_curve(name, id)) + result.merge(cs) + + return result + + +def expand_legacy_options_update(cs: ChangeSet) -> ChangeSet: + cs.operations[0]['operation'] = API_UPDATE + cs.operations[0]['type'] = 'option' + new_cs = cs + new_cs.merge(generate_v3(cs)) + return new_cs + + +def expand_v3_options_update(cs: ChangeSet) -> ChangeSet: + cs.operations[0]['operation'] = API_UPDATE + cs.operations[0]['type'] = 'option_v3' + new_cs = cs + new_cs.merge(generate_v2(cs)) + return new_cs + + +def expand_command(name: str, cs: ChangeSet) -> ChangeSet: + op = cs.operations[0] + operation = op['operation'] + element_type = op['type'] + + if operation == API_DELETE: + handler = _DELETE_REWRITERS.get(element_type) + if handler: + return handler(name, cs) + elif operation == API_UPDATE: + handler = _UPDATE_REWRITERS.get(element_type) + if handler: + return handler(cs) + + return cs + + +DeleteRewriter = Callable[[str, ChangeSet], ChangeSet] +UpdateRewriter = Callable[[ChangeSet], ChangeSet] + +_DELETE_REWRITERS: dict[str, DeleteRewriter] = { + "junction": expand_junction_delete, + "reservoir": expand_reservoir_delete, + "tank": expand_tank_delete, + "pipe": expand_pipe_delete, + "pump": expand_pump_delete, + "valve": expand_valve_delete, + "pattern": expand_pattern_delete, + "curve": expand_curve_delete, +} + +_UPDATE_REWRITERS: dict[str, UpdateRewriter] = { + "option": expand_legacy_options_update, + "option_v3": expand_v3_options_update, +} diff --git a/app/native/wndb/commands/executor.py b/app/native/wndb/commands/executor.py new file mode 100644 index 0000000..7eea13e --- /dev/null +++ b/app/native/wndb/commands/executor.py @@ -0,0 +1,155 @@ +"""Transactional command dispatch for WNDB model mutations.""" + +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, +) +from ..gis.backdrop import set_backdrop +from ..gis.labels import add_label, delete_label, set_label +from ..gis.regions import add_region, delete_region, set_region +from ..gis.vertices import add_vertex, delete_vertex, set_vertex +from ..model.controls import set_control +from ..model.curves import add_curve, delete_curve, set_curve +from ..model.demands import set_demand +from ..model.emitters import set_emitter +from ..model.energy import set_energy, set_pump_energy +from ..model.junctions import add_junction, delete_junction, set_junction +from ..model.mixing import add_mixing, delete_mixing, set_mixing +from ..model.options import set_option, set_option_v3 +from ..model.patterns import add_pattern, delete_pattern, set_pattern +from ..model.pipes import add_pipe, delete_pipe, set_pipe +from ..model.pumps import add_pump, delete_pump, set_pump +from ..model.quality import set_quality +from ..model.reactions import ( + set_pipe_reaction, + set_reaction, + set_tank_reaction, +) +from ..model.reservoirs import add_reservoir, delete_reservoir, set_reservoir +from ..model.rules import set_rule +from ..model.sources import add_source, delete_source, set_source +from ..model.status import set_status +from ..model.tags import set_tag +from ..model.tanks import add_tank, delete_tank, set_tank +from ..model.times import set_time +from ..model.title import set_title +from ..model.valves import add_valve, delete_valve, set_valve +from .cascade import expand_command + +CommandHandler = Callable[[str, ChangeSet], ChangeSet] + +_ADD_HANDLERS: dict[str, CommandHandler] = { + "junction": add_junction, + "reservoir": add_reservoir, + "tank": add_tank, + "pipe": add_pipe, + "pump": add_pump, + "valve": add_valve, + "pattern": add_pattern, + "curve": add_curve, + "source": add_source, + "mixing": add_mixing, + "vertex": add_vertex, + "label": add_label, + "region": add_region, +} + +_UPDATE_HANDLERS: dict[str, CommandHandler] = { + "title": set_title, + "junction": set_junction, + "reservoir": set_reservoir, + "tank": set_tank, + "pipe": set_pipe, + "pump": set_pump, + "valve": set_valve, + "tag": set_tag, + "demand": set_demand, + "status": set_status, + "pattern": set_pattern, + "curve": set_curve, + "control": set_control, + "rule": set_rule, + "energy": set_energy, + "pump_energy": set_pump_energy, + "emitter": set_emitter, + "quality": set_quality, + "source": set_source, + "reaction": set_reaction, + "pipe_reaction": set_pipe_reaction, + "tank_reaction": set_tank_reaction, + "mixing": set_mixing, + "time": set_time, + "option": set_option, + "option_v3": set_option_v3, + "vertex": set_vertex, + "label": set_label, + "backdrop": set_backdrop, + "region": set_region, +} + +_DELETE_HANDLERS: dict[str, CommandHandler] = { + "junction": delete_junction, + "reservoir": delete_reservoir, + "tank": delete_tank, + "pipe": delete_pipe, + "pump": delete_pump, + "valve": delete_valve, + "pattern": delete_pattern, + "curve": delete_curve, + "source": delete_source, + "mixing": delete_mixing, + "vertex": delete_vertex, + "label": delete_label, + "region": delete_region, +} + + +def _dispatch( + handlers: dict[str, CommandHandler], name: str, change_set: ChangeSet +) -> ChangeSet: + element_type = change_set.operations[0]["type"] + handler = handlers.get(element_type) + return handler(name, change_set) if handler else ChangeSet() + + +def _execute_add_command(name: str, change_set: ChangeSet) -> ChangeSet: + return _dispatch(_ADD_HANDLERS, name, change_set) + + +def _execute_update_command(name: str, change_set: ChangeSet) -> ChangeSet: + return _dispatch(_UPDATE_HANDLERS, name, change_set) + + +def _execute_delete_command(name: str, change_set: ChangeSet) -> ChangeSet: + return _dispatch(_DELETE_HANDLERS, name, change_set) + + +def execute_batch_commands(name: str, change_set: ChangeSet) -> ChangeSet: + with project_transaction(name): + rewritten = ChangeSet() + for operation in change_set.operations: + rewritten.merge(expand_command(name, ChangeSet(operation))) + + result = ChangeSet() + for operation in rewritten.operations: + operation_type = operation["operation"] + if operation_type == API_ADD: + result.merge(_execute_add_command(name, ChangeSet(operation))) + elif operation_type == API_UPDATE: + result.merge(_execute_update_command(name, ChangeSet(operation))) + elif operation_type == API_DELETE: + result.merge(_execute_delete_command(name, ChangeSet(operation))) + + if rewritten.operations: + refresh_materialized_views(name) + return result + + +def execute_batch_command(name: str, change_set: ChangeSet) -> ChangeSet: + return execute_batch_commands(name, change_set) diff --git a/app/native/wndb/connection.py b/app/native/wndb/connection.py deleted file mode 100644 index db4cce1..0000000 --- a/app/native/wndb/connection.py +++ /dev/null @@ -1,90 +0,0 @@ -from collections.abc import Iterator -from contextlib import contextmanager -from threading import RLock - -import psycopg as pg - -from app.infra.db.project_routing import get_project_pgconn_string - -g_conn_dict: dict[str, pg.Connection] = {} -g_conninfo_dict: dict[str, str] = {} -_registry_lock = RLock() -_project_locks: dict[str, RLock] = {} - - -def _is_closed(connection: pg.Connection) -> bool: - return bool(getattr(connection, "closed", False)) - - -def _close_connection(connection: pg.Connection) -> None: - if not _is_closed(connection): - connection.close() - - -def _is_healthy(connection: pg.Connection) -> bool: - if _is_closed(connection): - return False - try: - with connection.cursor() as cur: - cur.execute("SELECT 1") - except pg.Error: - return False - return True - - -def _get_project_lock(name: str) -> RLock: - with _registry_lock: - lock = _project_locks.get(name) - if lock is None: - lock = RLock() - _project_locks[name] = lock - return lock - - -def open_connection(name: str) -> pg.Connection: - with _get_project_lock(name): - conninfo = get_project_pgconn_string(db_name=name) - connection = g_conn_dict.get(name) - if ( - connection is None - or g_conninfo_dict.get(name) != conninfo - or not _is_healthy(connection) - ): - if connection is not None: - _close_connection(connection) - connection = pg.connect(conninfo=conninfo, autocommit=True) - g_conn_dict[name] = connection - g_conninfo_dict[name] = conninfo - return connection - - -def is_connection_open(name: str) -> bool: - with _get_project_lock(name): - connection = g_conn_dict.get(name) - if connection is None: - return False - if not _is_healthy(connection): - del g_conn_dict[name] - g_conninfo_dict.pop(name, None) - _close_connection(connection) - return False - if g_conninfo_dict.get(name) != get_project_pgconn_string(db_name=name): - del g_conn_dict[name] - g_conninfo_dict.pop(name, None) - _close_connection(connection) - return False - return True - - -def close_connection(name: str) -> None: - with _get_project_lock(name): - connection = g_conn_dict.pop(name, None) - g_conninfo_dict.pop(name, None) - if connection is not None: - _close_connection(connection) - - -@contextmanager -def project_connection(name: str) -> Iterator[pg.Connection]: - with _get_project_lock(name): - yield open_connection(name) diff --git a/app/native/wndb/core/__init__.py b/app/native/wndb/core/__init__.py new file mode 100644 index 0000000..abacd0c --- /dev/null +++ b/app/native/wndb/core/__init__.py @@ -0,0 +1 @@ +"""WNDB connection, transaction, and project lifecycle infrastructure.""" diff --git a/app/native/wndb/core/connection.py b/app/native/wndb/core/connection.py new file mode 100644 index 0000000..d3e6d82 --- /dev/null +++ b/app/native/wndb/core/connection.py @@ -0,0 +1,224 @@ +from collections import OrderedDict +from collections.abc import Iterator +from contextlib import contextmanager +from contextvars import ContextVar +from threading import RLock + +from psycopg import Connection +from psycopg.rows import dict_row +from psycopg_pool import ConnectionPool + +from app.core.config import settings +from app.infra.db.project_routing import get_project_pgconn_string + +_pools: OrderedDict[str, ConnectionPool] = OrderedDict() +_pool_conninfo: dict[str, str] = {} +_pool_borrows: dict[str, int] = {} +_admin_pools: OrderedDict[str, ConnectionPool] = OrderedDict() +_admin_pool_borrows: dict[str, int] = {} +_registry_lock = RLock() +_active_project_connection: ContextVar[tuple[str, Connection] | None] = ContextVar( + "wndb_active_project_connection", + default=None, +) + + +def _close_pool(pool: ConnectionPool) -> None: + if not pool.closed: + pool.close() + + +def _evict_idle_project_pools(*, protected: str | None = None) -> None: + limit = max(1, settings.PROJECT_PG_CACHE_SIZE) + while len(_pools) > limit: + candidate = next( + (key for key in _pools if key != protected and _pool_borrows.get(key, 0) == 0), + None, + ) + if candidate is None: + return + pool = _pools.pop(candidate) + _pool_conninfo.pop(candidate, None) + _pool_borrows.pop(candidate, None) + _close_pool(pool) + + +def _evict_idle_admin_pools(*, protected: str | None = None) -> None: + limit = max(1, settings.PROJECT_PG_CACHE_SIZE) + while len(_admin_pools) > limit: + candidate = next( + ( + key + for key in _admin_pools + if key != protected and _admin_pool_borrows.get(key, 0) == 0 + ), + None, + ) + if candidate is None: + return + pool = _admin_pools.pop(candidate) + _admin_pool_borrows.pop(candidate, None) + _close_pool(pool) + + +def get_project_pool(name: str) -> ConnectionPool: + """Return the routed synchronous pool used by native WNDB operations.""" + conninfo = get_project_pgconn_string(db_name=name) + with _registry_lock: + pool = _pools.get(name) + if pool is not None and _pool_conninfo.get(name) == conninfo and not pool.closed: + _pools.move_to_end(name) + return pool + if pool is not None: + if _pool_borrows.get(name, 0): + raise RuntimeError(f"Cannot replace active project pool {name!r}") + _close_pool(pool) + pool = ConnectionPool( + conninfo=conninfo, + 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}, + open=True, + ) + _pools[name] = pool + _pool_conninfo[name] = conninfo + _pool_borrows.setdefault(name, 0) + _evict_idle_project_pools(protected=name) + return pool + + +def is_project_pool_open(name: str) -> bool: + with _registry_lock: + pool = _pools.get(name) + if pool is None or pool.closed: + return False + if _pool_conninfo.get(name) != get_project_pgconn_string(db_name=name): + if _pool_borrows.get(name, 0): + return False + _close_pool(pool) + _pools.pop(name, None) + _pool_conninfo.pop(name, None) + _pool_borrows.pop(name, None) + return False + return True + + +def close_project_pool(name: str) -> None: + with _registry_lock: + if _pool_borrows.get(name, 0): + raise RuntimeError(f"Cannot close active project pool {name!r}") + pool = _pools.pop(name, None) + _pool_conninfo.pop(name, None) + _pool_borrows.pop(name, None) + if pool is not None: + _close_pool(pool) + + +def close_all_project_pools() -> None: + """Close every WNDB project pool and the administration pool.""" + with _registry_lock: + pools = [*_pools.values(), *_admin_pools.values()] + _pools.clear() + _pool_conninfo.clear() + _pool_borrows.clear() + _admin_pools.clear() + _admin_pool_borrows.clear() + for pool in pools: + _close_pool(pool) + + +def get_admin_pool() -> ConnectionPool: + """Return the administration pool for the current routed PostgreSQL host.""" + conninfo = get_project_pgconn_string(db_name="postgres") + with _registry_lock: + pool = _admin_pools.get(conninfo) + if pool is not None and not pool.closed: + _admin_pools.move_to_end(conninfo) + return pool + pool = ConnectionPool( + conninfo=conninfo, + min_size=settings.PROJECT_PG_POOL_MIN_SIZE, + max_size=settings.PROJECT_PG_POOL_SIZE, + kwargs={"autocommit": True, "row_factory": dict_row}, + open=True, + ) + _admin_pools[conninfo] = pool + _admin_pool_borrows.setdefault(conninfo, 0) + _evict_idle_admin_pools(protected=conninfo) + return pool + + +@contextmanager +def project_connection(name: str) -> Iterator[Connection]: + """Borrow one routed WNDB connection and return it to its pool on exit.""" + active = _active_project_connection.get() + if active is not None: + active_name, conn = active + if active_name != name: + raise RuntimeError( + f"Cannot access project {name!r} inside transaction for {active_name!r}" + ) + yield conn + return + with _registry_lock: + pool = get_project_pool(name) + _pool_borrows[name] = _pool_borrows.get(name, 0) + 1 + try: + with pool.connection() as conn: + yield conn + finally: + with _registry_lock: + _pool_borrows[name] -= 1 + _evict_idle_project_pools() + + +@contextmanager +def project_transaction(name: str) -> Iterator[Connection]: + """Run all nested WNDB operations on one pooled connection and transaction.""" + active = _active_project_connection.get() + if active is not None: + active_name, conn = active + if active_name != name: + raise RuntimeError( + f"Cannot nest project {name!r} inside transaction for {active_name!r}" + ) + with conn.transaction(): + yield conn + return + + with _registry_lock: + pool = get_project_pool(name) + _pool_borrows[name] = _pool_borrows.get(name, 0) + 1 + try: + with pool.connection() as conn: + token = _active_project_connection.set((name, conn)) + try: + with conn.transaction(): + yield conn + finally: + _active_project_connection.reset(token) + finally: + with _registry_lock: + _pool_borrows[name] -= 1 + _evict_idle_project_pools() + + +def is_project_transaction_active(name: str) -> bool: + active = _active_project_connection.get() + return active is not None and active[0] == name + + +@contextmanager +def admin_connection() -> Iterator[Connection]: + """Borrow a PostgreSQL administration connection from its pool.""" + with _registry_lock: + pool = get_admin_pool() + conninfo = pool.conninfo + _admin_pool_borrows[conninfo] = _admin_pool_borrows.get(conninfo, 0) + 1 + try: + with pool.connection() as conn: + yield conn + finally: + with _registry_lock: + _admin_pool_borrows[conninfo] -= 1 + _evict_idle_admin_pools() diff --git a/app/native/wndb/core/database.py b/app/native/wndb/core/database.py new file mode 100644 index 0000000..b71d7fb --- /dev/null +++ b/app/native/wndb/core/database.py @@ -0,0 +1,143 @@ +from collections.abc import Mapping, Sequence +from typing import Any + +from psycopg import sql +from psycopg.rows import Row, dict_row + +from .connection import is_project_transaction_active, project_connection + +API_ADD = "add" +API_UPDATE = "update" +API_DELETE = "delete" + +g_add_prefix = {"operation": API_ADD} +g_update_prefix = {"operation": API_UPDATE} +g_delete_prefix = {"operation": API_DELETE} + + +class ChangeSet: + def __init__(self, ps: dict[str, Any] | None = None): + self.operations: list[dict[str, Any]] = [] + if ps is not None: + self.append(ps) + + @staticmethod + def from_list(ps: list[dict[str, Any]]): + change_set = ChangeSet() + for item in ps: + change_set.append(item) + return change_set + + def add(self, ps: dict[str, Any]): + self.operations.append(g_add_prefix | ps) + return self + + def update(self, ps: dict[str, Any]): + self.operations.append(g_update_prefix | ps) + return self + + def delete(self, ps: dict[str, Any]): + self.operations.append(g_delete_prefix | ps) + return self + + def append(self, ps: dict[str, Any]): + self.operations.append(ps) + return self + + def merge(self, change_set): + self.operations.extend(change_set.operations) + return self + + def dump(self): + for operation in self.operations: + print(operation) + + def compress(self): + return self + + +class DatabaseCommand: + def __init__(self, statement: str, changes: list[dict[str, Any]]) -> None: + self.sql = statement + self.changes = changes + +QueryParams = Sequence[Any] | Mapping[str, Any] + + +def sql_literal(value: Any) -> str: + """Render one PostgreSQL literal for legacy WNDB SQL batch builders. + + WNDB still assembles multi-statement model changes before executing them as + one transaction. Every interpolated value must pass through this helper; + identifiers remain static strings owned by the backend. + """ + return sql.Literal(value).as_string() + + +def _execute(cur, query: str, params: QueryParams | None = None): + return cur.execute(query, params) if params is not None else cur.execute(query) + + +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) + row = cur.fetchone() + if row is None: + raise LookupError(query) + return row + + +def read_all( + name: str, query: str, params: QueryParams | None = None +) -> list[Row]: + with project_connection(name) as conn, conn.cursor(row_factory=dict_row) as cur: + _execute(cur, query, params) + return cur.fetchall() + + +def try_read( + name: str, query: str, params: QueryParams | None = None +) -> Row | None: + with project_connection(name) as conn, conn.cursor(row_factory=dict_row) as cur: + _execute(cur, query, params) + return cur.fetchone() + + +def write(name: str, query: str, params: QueryParams | None = None) -> None: + with project_connection(name) as conn, conn.cursor() as cur: + _execute(cur, query, params) + + +def refresh_materialized_views(name: str, *, concurrently: bool = True) -> None: + """Refresh the GIS query layer after committed model or asset changes.""" + with project_connection(name) as conn, conn.cursor() as cur: + cur.execute("CALL gis.refresh_all_materialized_views(%s)", (concurrently,)) + + +_MATERIALIZED_VIEW_SOURCES = ( + "network.nodes", + "network.junctions", + "network.reservoirs", + "network.tanks", + "network.links", + "network.pipes", + "network.pumps", + "network.valves", + "network.demands", + "gis.node_geometries", + "gis.link_vertices", + "asset.scada_devices", +) + + +def _affects_materialized_views(command: DatabaseCommand) -> bool: + statement = command.sql.lower() + return any(source in statement for source in _MATERIALIZED_VIEW_SOURCES) + + +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) + return ChangeSet.from_list(command.changes) diff --git a/app/native/wndb/core/projects.py b/app/native/wndb/core/projects.py new file mode 100644 index 0000000..cd2ea0c --- /dev/null +++ b/app/native/wndb/core/projects.py @@ -0,0 +1,107 @@ +from psycopg import sql +from psycopg.rows import dict_row +from .connection import ( + admin_connection, + close_project_pool, + get_project_pool, + is_project_pool_open, +) + +_server_databases = ["template0", "template1", "postgres", "project"] + + +def list_project() -> list[str]: + ps = [] + with admin_connection() as conn: + 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,), + ): + ps.append(p["datname"]) + return ps + + +def have_project(name: str) -> bool: + with admin_connection() as conn: + with conn.cursor() as cur: + cur.execute("select 1 from pg_database where datname = %s", (name,)) + return cur.fetchone() is not None + + +def copy_project(source: str, new: str) -> None: + close_project_pool(source) + + with admin_connection() as admin_conn: + with admin_conn.cursor() as cur: + cur.execute( + "update pg_database set datallowconn = false where datname = %s", + (source,), + ) + try: + cur.execute( + "select pg_terminate_backend(pid) from pg_stat_activity where datname = %s and pid <> pg_backend_pid()", + (source,), + ) + cur.execute( + sql.SQL("create database {} with template = {}").format( + sql.Identifier(new), sql.Identifier(source) + ) + ) + finally: + cur.execute( + "update pg_database set datallowconn = true where datname = %s", + (source,), + ) + + +def create_project(name: str) -> None: + return copy_project("project", name) + + +def delete_project(name: str) -> None: + close_project_pool(name) + with admin_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "select pg_terminate_backend(pid) from pg_stat_activity " + "where datname = %s and pid <> pg_backend_pid()", + (name,), + ) + cur.execute( + sql.SQL("drop database {}").format(sql.Identifier(name)) + ) + + +def clean_project(excluded: list[str] = []) -> None: + projects = list_project() + 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)) + ) + + +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) diff --git a/app/native/wndb/database.py b/app/native/wndb/database.py deleted file mode 100644 index 5009418..0000000 --- a/app/native/wndb/database.py +++ /dev/null @@ -1,368 +0,0 @@ -from collections.abc import Mapping, Sequence -from typing import Any -from psycopg.rows import dict_row, Row -from .connection import project_connection - -API_ADD = 'add' -API_UPDATE = 'update' -API_DELETE = 'delete' - -g_add_prefix = { 'operation': API_ADD } -g_update_prefix = { 'operation': API_UPDATE } -g_delete_prefix = { 'operation': API_DELETE } - - -class ChangeSet: - def __init__(self, ps: dict[str, Any] | None = None): - self.operations : list[dict[str, Any]] = [] - if ps != None: - self.append(ps) - - @staticmethod - def from_list(ps: list[dict[str, Any]]): - cs = ChangeSet() - for _cs in ps: - cs.append(_cs) - return cs - - def add(self, ps: dict[str, Any]): - self.operations.append(g_add_prefix | ps) - return self - - def update(self, ps: dict[str, Any]): - self.operations.append(g_update_prefix | ps) - return self - - def delete(self, ps: dict[str, Any]): - self.operations.append(g_delete_prefix | ps) - return self - - def append(self, ps: dict[str, Any]): - self.operations.append(ps) - return self - - def merge(self, cs): - if len(cs.operations) > 0: - self.operations += cs.operations - return self - - def dump(self): - for op in self.operations: - print(op) - - def compress(self): - return self - - -class DbChangeSet: - def __init__(self, redo_sql: str, undo_sql: str, redo_cs: list[dict[str, Any]], undo_cs: list[dict[str, Any]]) -> None: - self.redo_sql = redo_sql - self.undo_sql = undo_sql - self.redo_cs = redo_cs - self.undo_cs = undo_cs - - @staticmethod - def from_list(css): - redo_sql_s : list[str] = [] - undo_sql_s : list[str] = [] - redo_cs_s : list[dict[str, Any]] = [] - undo_cs_s : list[dict[str, Any]] = [] - - for r in css: - redo_sql_s.append(r.redo_sql) - undo_sql_s.append(r.undo_sql) - redo_cs_s += r.redo_cs - r.undo_cs.reverse() # reverse again... - undo_cs_s += r.undo_cs - - redo_sql = '\n'.join(redo_sql_s) - undo_sql_s.reverse() - undo_sql = '\n'.join(undo_sql_s) - undo_cs_s.reverse() - - return DbChangeSet(redo_sql, undo_sql, redo_cs_s, undo_cs_s) - - -QueryParams = Sequence[Any] | Mapping[str, Any] - - -def _execute(cur, sql: str, params: QueryParams | None = None): - return cur.execute(sql, params) if params is not None else cur.execute(sql) - - -def read(name: str, sql: str, params: QueryParams | None = None) -> Row: - with project_connection(name) as conn: - with conn.cursor(row_factory=dict_row) as cur: - _execute(cur, sql, params) - row = cur.fetchone() - if row == None: - raise Exception(sql) - return row - - -def read_all( - name: str, sql: str, params: QueryParams | None = None -) -> list[Row]: - with project_connection(name) as conn: - with conn.cursor(row_factory=dict_row) as cur: - _execute(cur, sql, params) - return cur.fetchall() - - -def try_read( - name: str, sql: str, params: QueryParams | None = None -) -> Row | None: - with project_connection(name) as conn: - with conn.cursor(row_factory=dict_row) as cur: - _execute(cur, sql, params) - return cur.fetchone() - - -def write(name: str, sql: str) -> None: - with project_connection(name) as conn: - with conn.cursor() as cur: - cur.execute(sql) - - -def get_current_operation(name: str) -> int: - return int(read(name, 'select id from current_operation')['id']) - - -def execute_command(name: str, command: DbChangeSet, undo_redo: bool = True) -> ChangeSet: - write(name, command.redo_sql) - - if undo_redo: - op_table = read(name, "select * from operation_table")['option'] - parent = get_current_operation(name) - redo_sql = command.redo_sql.replace("'", "''") - undo_sql = command.undo_sql.replace("'", "''") - redo_cs_str = str(command.redo_cs).replace("'", "''") - undo_cs_str = str(command.undo_cs).replace("'", "''") - write(name, f"insert into {op_table} (id, redo, undo, parent, redo_cs, undo_cs) values (default, '{redo_sql}', '{undo_sql}', {parent}, '{redo_cs_str}', '{undo_cs_str}')") - - if op_table == 'operation': - current = read(name, 'select max(id) as id from operation')['id'] - write(name, f"update current_operation set id = {current}") - - return ChangeSet.from_list(command.redo_cs) - - -def execute_undo(name: str, discard: bool = False) -> ChangeSet: - row = read(name, f'select * from operation where id = {get_current_operation(name)}') - - write(name, row['undo']) - - parent = row['parent'] if row['parent'] != None else 0 - - # update foreign key - write(name, f"update current_operation set id = {parent} where id = {row['id']}") - - if discard: - # update foreign key - write(name, f"update operation set redo_child = null where id = {parent}") - # on delete cascade => child & snapshot - write(name, f"delete from operation where id = {row['id']}") - else: - write(name, f"update operation set redo_child = {row['id']} where id = {parent}") - - e = eval(row['undo_cs']) if row['undo_cs'] not in [None, ''] else [] - return ChangeSet.from_list(e) - - -def execute_redo(name: str) -> ChangeSet: - row = read(name, f'select * from operation where id = {get_current_operation(name)}') - if row['redo_child'] == None: - return ChangeSet() - - row = read(name, f"select * from operation where id = {row['redo_child']}") - write(name, row['redo']) - - parent = row['parent'] if row['parent'] != None else 0 - write(name, f"update current_operation set id = {row['id']} where id = {parent}") - - e = eval(row['redo_cs']) if row['redo_cs'] not in [None, ''] else [] - return ChangeSet.from_list(e) - - -def list_snapshot(name: str) -> list[tuple[int, str]]: - rows = read_all(name, f'select * from snapshot_operation order by id') - result = [] - for row in rows: - result.append((int(row['id']), str(row['tag']))) - return result - - -def have_snapshot(name: str, tag: str) -> bool: - return try_read(name, f"select id from snapshot_operation where tag = '{tag}'") != None - - -def have_snapshot_for_operation(name: str, operation: int) -> bool: - return try_read(name, f"select id from snapshot_operation where id = {operation}") != None - - -def have_snapshot_for_current_operation(name: str) -> bool: - return have_snapshot_for_operation(name, get_current_operation(name)) - - -def take_snapshot_for_operation(name: str, operation: int, tag: str) -> None: - if tag == None or tag == '': - return None - write(name, f"insert into snapshot_operation (id, tag) values ({operation}, '{tag}')") - - -def take_snapshot_for_current_operation(name: str, tag: str) -> None: - take_snapshot_for_operation(name, get_current_operation(name), tag) - - -# deprecated ! use take_snapshot_for_current_operation instead -def take_snapshot(name: str, tag: str) -> None: - take_snapshot_for_current_operation(name, tag) - - -def update_snapshot(name: str, operation: int, tag: str) -> None: - if tag == None or tag == '': - return None - if have_snapshot_for_operation(name, operation): - write(name, f"update snapshot_operation set tag = '{tag}' where id = {operation}") - else: - take_snapshot_for_operation(name, operation, tag) - - -def update_snapshot_for_current_operation(name: str, tag: str) -> None: - return update_snapshot(name, get_current_operation(name), tag) - - -def delete_snapshot(name: str, tag: str) -> None: - write(name, f"delete from snapshot_operation where tag = '{tag}'") - - -def delete_snapshot_by_operation(name: str, operation: int) -> None: - write(name, f"delete from snapshot_operation where id = {operation}") - - -def get_operation_by_snapshot(name: str, tag: str) -> int | None: - row = try_read(name, f"select id from snapshot_operation where tag = '{tag}'") - return int(row['id']) if row != None else None - - -def get_snapshot_by_operation(name: str, operation: int) -> str | None: - row = try_read(name, f"select tag from snapshot_operation where id = {operation}") - return str(row['tag']) if row != None else None - - -def _get_parents(name: str, id: int) -> list[int]: - ids = [id] - while ids[-1] != 0: - row = read(name, f'select parent from operation where id = {ids[-1]}') - ids.append(int(row['parent'])) - return ids - - -def pick_operation(name: str, operation: int, discard: bool) -> ChangeSet: - target = operation - curr = get_current_operation(name) - - curr_parents = _get_parents(name, curr) - target_parents = _get_parents(name, target) - - change = ChangeSet() - - if target in curr_parents: - for _ in range(curr_parents.index(target)): - change.merge(execute_undo(name, discard)) - - elif curr in target_parents: - target_parents.reverse() - curr_index = target_parents.index(curr) - for i in range(curr_index, len(target_parents) - 1): - write(name, f"update operation set redo_child = '{target_parents[i + 1]}' where id = '{target_parents[i]}'") - change.merge(execute_redo(name)) - - else: - ancestor_index = -1 - while curr_parents[ancestor_index] == target_parents[ancestor_index]: - ancestor_index -= 1 - ancestor = curr_parents[ancestor_index + 1] - - for _ in range(curr_parents.index(ancestor)): - change.merge(execute_undo(name, discard)) - - target_parents.reverse() - curr_index = target_parents.index(ancestor) - for i in range(curr_index, len(target_parents) - 1): - write(name, f"update operation set redo_child = '{target_parents[i + 1]}' where id = '{target_parents[i]}'") - change.merge(execute_redo(name)) - - return change.compress() - - -def pick_snapshot(name: str, tag: str, discard: bool) -> ChangeSet: - if not have_snapshot(name, tag): - return ChangeSet() - - target = int(read(name, f"select id from snapshot_operation where tag = '{tag}'")['id']) - return pick_operation(name, target, discard) - - -def _get_change_set(name: str, operation: int, undo: bool) -> ChangeSet: - row = read(name, f'select * from operation where id = {operation}') - field= 'undo_cs' if undo else 'redo_cs' - return ChangeSet.from_list(eval(row[field])) - - -def sync_with_server(name: str, operation: int) -> ChangeSet: - fr = operation - to = get_current_operation(name) - - fr_parents = _get_parents(name, fr) - to_parents = _get_parents(name, to) - - change = ChangeSet() - - if fr in to_parents: - index = to_parents.index(fr) - 1 - while index >= 0: - change.merge(_get_change_set(name, to_parents[index], False)) #redo - index -= 1 - - elif to in fr_parents: - index = 0 - while index <= fr_parents.index(to) - 1: - change.merge(_get_change_set(name, fr_parents[index], True)) - index += 1 - - else: - ancestor_index = -1 - while fr_parents[ancestor_index] == to_parents[ancestor_index]: - ancestor_index -= 1 - - ancestor = fr_parents[ancestor_index + 1] - - index = 0 - while index <= fr_parents.index(ancestor) - 1: - change.merge(_get_change_set(name, fr_parents[index], True)) - index += 1 - - index = to_parents.index(ancestor) - 1 - while index >= 0: - change.merge(_get_change_set(name, to_parents[index], False)) - index -= 1 - - return change.compress() - - -def get_restore_operation(name: str) -> int: - return read(name, f'select * from restore_operation')['id'] - - -def set_restore_operation(name: str, operation: int) -> None: - write(name, f'update restore_operation set id = {operation}') - - -def set_restore_operation_to_current(name: str) -> None: - return set_restore_operation(name, get_current_operation(name)) - - -def restore(name: str, discard: bool) -> ChangeSet: - op = get_restore_operation(name) - return pick_operation(name, op, discard) diff --git a/app/native/wndb/extension_data.py b/app/native/wndb/extension_data.py deleted file mode 100644 index cfce4ea..0000000 --- a/app/native/wndb/extension_data.py +++ /dev/null @@ -1,62 +0,0 @@ -from .database import * - - -def get_all_extension_data_keys(name: str) -> list[str]: - result: list[str] = [] - for row in read_all(name, 'select key from extension_data'): - result.append(row['key']) - return result - - -def get_all_extension_data(name: str) -> dict[str, Any]: - result: dict[str, Any] = {} - for row in read_all(name, 'select key, value from extension_data'): - result[row['key']] = row['value'] - return result - - -def get_extension_data(name: str, key: str) -> str | None: - if key == None or key == '': - return None - row = try_read(name, f"select value from extension_data where key = '{key}'") - if row == None: - return None - return row['value'] - - -def _set_extension_data(name: str, cs: ChangeSet) -> DbChangeSet: - op = cs.operations[0] - key, new_val = op['key'], op['value'] - - f_new_val = f"'{new_val}'" if new_val != None else 'null' - - old_val = get_extension_data(name, key) - f_old_val = f"'{old_val}'" if old_val != None else 'null' - - redo_sql = f"delete from extension_data where key = '{key}';" - if new_val != None: - redo_sql += f"insert into extension_data (key, value) values ('{key}', {f_new_val});" - - undo_sql = f"delete from extension_data where key = '{key}';" - if old_val != None: - undo_sql += f"insert into extension_data (key, value) values ('{key}', {f_old_val});" - - redo_cs = g_update_prefix | { 'type': 'extension_data', 'key': key, 'value': new_val } - undo_cs = g_update_prefix | { 'type': 'extension_data', 'key': key, 'value': old_val } - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) - - -def set_extension_data(name: str, cs: ChangeSet) -> ChangeSet: - if len(cs.operations) != 1: - return ChangeSet() - - op = cs.operations[0] - if 'key' not in op or 'value' not in op: - return ChangeSet() - - key = op['key'] - if key == None or key == '': - return ChangeSet() - - return execute_command(name, _set_extension_data(name, cs)) diff --git a/app/native/wndb/gis/__init__.py b/app/native/wndb/gis/__init__.py new file mode 100644 index 0000000..a73bf8e --- /dev/null +++ b/app/native/wndb/gis/__init__.py @@ -0,0 +1 @@ +"""GIS persistence and network geometry operations.""" diff --git a/app/native/wndb/gis/backdrop.py b/app/native/wndb/gis/backdrop.py new file mode 100644 index 0000000..4834ab9 --- /dev/null +++ b/app/native/wndb/gis/backdrop.py @@ -0,0 +1,44 @@ +from typing import Any + +from ..core.database import ( + ChangeSet, + DatabaseCommand, + execute_command, + g_update_prefix, + read, + sql_literal, +) + + +def get_backdrop_schema(name: str) -> dict[str, dict[str, Any]]: + return { 'content' : {'type': 'str' , 'optional': False , 'readonly': False} } + + +def get_backdrop(name: str) -> dict[str, Any]: + e = read(name, "select content from gis.backdrops where id = true") + return { 'content': e['content'] } + + +def _set_backdrop(name: str, cs: ChangeSet) -> DatabaseCommand: + statement = f"update gis.backdrops set content = {sql_literal(cs.operations[0]['content'])} where id = true;" + + change = g_update_prefix | { 'type': 'backdrop', 'content': cs.operations[0]['content'] } + + return DatabaseCommand(statement, [change]) + + +def set_backdrop(name: str, cs: ChangeSet) -> ChangeSet: + return execute_command(name, _set_backdrop(name, cs)) + + +def inp_in_backdrop(section: list[str]) -> str: + if section == []: + return str('') + + content = '\n'.join(section) + return str(f"update gis.backdrops set content = {sql_literal(content)} where id = true;") + + +def inp_out_backdrop(name: str) -> list[str]: + obj = str(get_backdrop(name)['content']) + return obj.split('\n') diff --git a/app/native/wndb/s24_coordinates.py b/app/native/wndb/gis/coordinates.py similarity index 64% rename from app/native/wndb/s24_coordinates.py rename to app/native/wndb/gis/coordinates.py index 46f2bf5..1c53780 100644 --- a/app/native/wndb/s24_coordinates.py +++ b/app/native/wndb/gis/coordinates.py @@ -1,20 +1,23 @@ -from .database import * -from .connection import project_connection -from .s0_base import get_link_nodes +from psycopg.rows import dict_row + +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: - coord = f"st_geomfromtext('point({x} {y})')" - return str(f"update coordinates set coord = {coord} where node = '{node}';") + geom = f"st_setsrid(st_makepoint({sql_literal(x)}, {sql_literal(y)}), 900914)" + return f"update gis.node_geometries set geom = {geom} where node_id = {sql_literal(node)};" def sql_insert_coord(node: str, x: float, y: float) -> str: - coord = f"st_geomfromtext('point({x} {y})')" - return str(f"insert into coordinates (node, coord) values ('{node}', {coord});") + geom = f"st_setsrid(st_makepoint({sql_literal(x)}, {sql_literal(y)}), 900914)" + return f"insert into gis.node_geometries (node_id, geom) values ({sql_literal(node)}, {geom});" def sql_delete_coord(node: str) -> str: - return str(f"delete from coordinates where node = '{node}';") + return f"delete from gis.node_geometries where node_id = {sql_literal(node)};" def from_postgis_point(coord: str) -> dict[str, float]: @@ -25,7 +28,7 @@ def from_postgis_point(coord: str) -> dict[str, float]: def get_node_coord(name: str, node: str) -> dict[str, float]: row = try_read( name, - "select st_astext(coord) as coord_geom from coordinates where node = %s", + "select st_astext(geom) as coord_geom from gis.node_geometries where node_id = %s", (node,), ) if row == None: @@ -38,9 +41,9 @@ def get_node_coord(name: str, node: str) -> dict[str, float]: # 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, st_astext(coord) as coord_geom from coordinates') + 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'] + node_id = obj['node_id'] coord = from_postgis_point(obj['coord_geom']) x = coord['x'] y = coord['y'] @@ -57,9 +60,9 @@ def get_links_in_extent(name: str, x1: float, y1: float, x2: float, y2: float) - all_link_ids = [] with project_connection(name) as conn: with conn.cursor(row_factory=dict_row) as cur: - cur.execute(f"select id from pipes") + cur.execute("select link_id from network.pipes") for record in cur: - all_link_ids.append(record['id']) + all_link_ids.append(record['link_id']) links = [] for link_id in all_link_ids: @@ -71,7 +74,7 @@ def get_links_in_extent(name: str, x1: float, y1: float, x2: float, y2: float) - def node_has_coord(name: str, node: str) -> bool: return try_read( - name, "select node from coordinates where node = %s", (node,) + name, "select node_id from gis.node_geometries where node_id = %s", (node,) ) != None @@ -85,15 +88,15 @@ def node_has_coord(name: str, node: str) -> bool: def inp_in_coord(line: str) -> str: tokens = line.split() node = tokens[0] - coord = f"st_geomfromtext('point({tokens[1]} {tokens[2]})')" - return str(f"insert into coordinates (node, coord) values ('{node}', {coord});") + x, y = float(tokens[1]), float(tokens[2]) + return sql_insert_coord(node, x, y) def inp_out_coord(name: str) -> list[str]: lines = [] - objs = read_all(name, 'select node, st_astext(coord) as coord_geom from coordinates') + objs = read_all(name, 'select node_id, st_astext(geom) as coord_geom from gis.node_geometries') for obj in objs: - node = obj['node'] + node = obj['node_id'] coord = from_postgis_point(obj['coord_geom']) x = coord['x'] y = coord['y'] diff --git a/app/native/wndb/s26_labels.py b/app/native/wndb/gis/labels.py similarity index 55% rename from app/native/wndb/s26_labels.py rename to app/native/wndb/gis/labels.py index 3bb0596..4eba2a4 100644 --- a/app/native/wndb/s26_labels.py +++ b/app/native/wndb/gis/labels.py @@ -1,4 +1,16 @@ -from .database import * +from typing import Any + +from ..core.database import ( + ChangeSet, + DatabaseCommand, + execute_command, + g_add_prefix, + g_delete_prefix, + g_update_prefix, + read_all, + sql_literal, + try_read, +) def get_label_schema(name: str) -> dict[str, dict[str, Any]]: @@ -12,7 +24,7 @@ def get_label(name: str, x: float, y: float) -> dict[str, Any]: d = {} d['x'] = x d['y'] = y - l = try_read(name, f'select * from labels where x = {x} and y = {y}') + l = try_read(name, "select label, node_id as node from gis.labels where geom = st_setsrid(st_makepoint(%s, %s), 900914)", (x, y)) if l == None: d['label'] = None d['node'] = None @@ -30,21 +42,16 @@ class Label(object): self.label = str(input['label']) self.node = str(input['node']) if 'node' in input and input['node'] != None else None - self.f_type = f"'{self.type}'" - self.f_x = self.x - self.f_y = self.y - self.f_label = f"'{self.label}'" - self.f_node = f"'{self.node}'" if self.node != None else 'null' + self.f_type = sql_literal(self.type) + self.f_x = sql_literal(self.x) + self.f_y = sql_literal(self.y) + self.f_label = sql_literal(self.label) + self.f_node = sql_literal(self.node) def as_dict(self) -> dict[str, Any]: return { 'type': self.type, 'x': self.x, 'y': self.y, 'label': self.label, 'node': self.node } - def as_xy_dict(self) -> dict[str, Any]: - return { 'type': self.type, 'x': self.x, 'y': self.y } - - -def _set_label(name: str, cs: ChangeSet) -> DbChangeSet: - old = Label(get_label(name, cs.operations[0]['x'], cs.operations[0]['y'])) +def _set_label(name: str, cs: ChangeSet) -> DatabaseCommand: raw_new = get_label(name, cs.operations[0]['x'], cs.operations[0]['y']) new_dict = cs.operations[0] @@ -54,45 +61,42 @@ def _set_label(name: str, cs: ChangeSet) -> DbChangeSet: raw_new[key] = new_dict[key] new = Label(raw_new) - redo_sql = f"update labels set label = {new.f_label}, node = {new.f_node} where x = {new.f_x} and y = {new.f_y};" - undo_sql = f"update labels set label = {old.f_label}, node = {old.f_node} where x = {old.f_x} and y = {old.f_y};" + statement = f"update gis.labels set label = {new.f_label}, node_id = {new.f_node} where geom = st_setsrid(st_makepoint({new.f_x}, {new.f_y}), 900914);" - redo_cs = g_update_prefix | new.as_dict() - undo_cs = g_update_prefix | old.as_dict() + change = g_update_prefix | new.as_dict() - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def set_label(name: str, cs: ChangeSet) -> ChangeSet: return execute_command(name, _set_label(name, cs)) -def _add_label(name: str, cs: ChangeSet) -> DbChangeSet: +def _add_label(name: str, cs: ChangeSet) -> DatabaseCommand: new = Label(cs.operations[0]) - redo_sql = f"insert into labels (x, y, label, node) values ({new.f_x}, {new.f_y}, {new.f_label}, {new.f_node});" - undo_sql = f"delete from labels where x = {new.f_x} and y = {new.f_y};" + statement = f"insert into gis.labels (id, node_id, label, geom) values ((select coalesce(max(id) + 1, 1) from gis.labels), {new.f_node}, {new.f_label}, st_setsrid(st_makepoint({new.f_x}, {new.f_y}), 900914));" - redo_cs = g_add_prefix | new.as_dict() - undo_cs = g_delete_prefix | new.as_xy_dict() + change = g_add_prefix | new.as_dict() - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def add_label(name: str, cs: ChangeSet) -> ChangeSet: return execute_command(name, _add_label(name, cs)) -def _delete_label(name: str, cs: ChangeSet) -> DbChangeSet: - old = Label(get_label(name, cs.operations[0]['x'], cs.operations[0]['y'])) +def _delete_label(name: str, cs: ChangeSet) -> DatabaseCommand: + x = float(cs.operations[0]['x']) + y = float(cs.operations[0]['y']) + f_x = sql_literal(x) + f_y = sql_literal(y) - redo_sql = f"delete from labels where x = {old.f_x} and y = {old.f_y};" - undo_sql = f"insert into labels (x, y, label, node) values ({old.f_x}, {old.f_y}, {old.f_label}, {old.f_node});" + statement = f"delete from gis.labels where geom = st_setsrid(st_makepoint({f_x}, {f_y}), 900914);" - redo_cs = g_delete_prefix | old.as_xy_dict() - undo_cs = g_add_prefix | old.as_dict() + change = g_delete_prefix | {'type': 'label', 'x': x, 'y': y} - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def delete_label(name: str, cs: ChangeSet) -> ChangeSet: @@ -110,14 +114,12 @@ def inp_in_label(line: str) -> str: y = float(tokens[1]) label = str(tokens[2]) node = str(tokens[3]) if num >= 4 else None - node = f"'{node}'" if node != None else 'null' - - return str(f"insert into labels (x, y, label, node) values ({x}, {y}, '{label}', {node});") + return str(f"insert into gis.labels (id, node_id, label, geom) values ((select coalesce(max(id) + 1, 1) from gis.labels), {sql_literal(node)}, {sql_literal(label)}, st_setsrid(st_makepoint({sql_literal(x)}, {sql_literal(y)}), 900914));") def inp_out_label(name: str) -> list[str]: lines = [] - objs = read_all(name, 'select * from labels') + objs = read_all(name, 'select st_x(geom) as x, st_y(geom) as y, label, node_id as node from gis.labels order by id') for obj in objs: x = obj['x'] y = obj['y'] @@ -130,7 +132,7 @@ def inp_out_label(name: str) -> list[str]: def unset_label_by_node(name: str, node: str) -> ChangeSet: cs = ChangeSet() - rows = read_all(name, f"select x, y from labels where node = '{node}'") + rows = read_all(name, "select st_x(geom) as x, st_y(geom) as y from gis.labels where node_id = %s", (node,)) for row in rows: cs.append(g_update_prefix | {'type': 'label', 'x': row['x'], 'y': row['y'], 'node': None}) diff --git a/app/native/wndb/s32_region_util.py b/app/native/wndb/gis/region_geometry.py similarity index 56% rename from app/native/wndb/s32_region_util.py rename to app/native/wndb/gis/region_geometry.py index f9c5e88..ac0e51c 100644 --- a/app/native/wndb/s32_region_util.py +++ b/app/native/wndb/gis/region_geometry.py @@ -1,12 +1,11 @@ import platform -import os import math from typing import Any import pyclipper -from .s0_base import get_node_links, get_link_nodes, is_pipe -from .s5_pipes import get_pipe -from .database import read, try_read, read_all, write -from .s24_coordinates import node_has_coord, get_node_coord +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 def from_postgis_polygon(polygon: str) -> list[tuple[float, float]]: @@ -33,17 +32,13 @@ def to_postgis_linestring(boundary: list[tuple[float, float]]) -> str: def get_nodes_in_boundary(name: str, boundary: list[tuple[float, float]]) -> list[str]: - api = 'get_nodes_in_boundary' - write(name, f"delete from temp_region where id = '{api}'") - write(name, f"insert into temp_region (id, boundary) values ('{api}', '{to_postgis_polygon(boundary)}')") - - nodes: list[str] = [] - for row in read_all(name, f"select c.node from coordinates as c, temp_region as r where ST_Intersects(c.coord, r.boundary) and r.id = '{api}'"): - nodes.append(row['node']) - - write(name, f"delete from temp_region where id = '{api}'") - - return nodes + rows = read_all( + name, + "select node_id from gis.node_geometries " + "where st_intersects(geom, st_geomfromtext(%s, 900914)) order by node_id", + (to_postgis_polygon(boundary),), + ) + return [str(row["node_id"]) for row in rows] def _get_links_on_boundary(name: str, nodes: list[str]) -> list[str]: @@ -64,54 +59,36 @@ def _get_links_on_boundary(name: str, nodes: list[str]) -> list[str]: return links -# if region is general or wda => get_nodes_in_boundary -# if region is dma, sa or vd => get stored nodes in table def get_nodes_in_region(name: str, region_id: str) -> list[str]: - nodes: list[str] = [] - - row = try_read(name, f"select r_type from region where id = '{region_id}'") - if row == None: - return nodes - - r_type = str(row['r_type']) - - if r_type == 'DMA' or r_type == 'SA' or r_type == 'VD': - table = '' - if r_type == 'DMA': - table = 'region_dma' - elif r_type == 'SA': - table = 'region_sa' - elif r_type == 'VD': - table = 'region_vd' - - if table != '': - row = try_read(name, f"select nodes from {table} where id = '{region_id}'") - if row != None: - nodes = eval(str(row['nodes'])) - - if nodes == []: - for row in read_all(name, f"select c.node from coordinates as c, region as r where ST_Intersects(c.coord, r.boundary) and r.id = '{region_id}'"): - nodes.append(row['node']) - - return nodes + stored = read_all( + name, + "select node_id from gis.region_nodes where region_id = %s order by node_id", + (region_id,), + ) + if stored: + return [str(row["node_id"]) for row in stored] + rows = read_all( + name, + "select n.node_id from gis.node_geometries n join gis.regions r " + "on st_intersects(n.geom, r.boundary) where r.id = %s order by n.node_id", + (region_id,), + ) + return [str(row["node_id"]) for row in rows] def get_links_on_region_boundary(name: str, region_id: str) -> list[str]: nodes = get_nodes_in_region(name, region_id) - print(nodes) return _get_links_on_boundary(name, nodes) def calculate_convex_hull(name: str, nodes: list[str]) -> list[tuple[float, float]]: - write(name, f'delete from temp_node') - for node in nodes: - write(name, f"insert into temp_node values ('{node}')") - - # TODO: check none - polygon = read(name, f'select st_astext(st_convexhull(st_collect(array(select coord from coordinates where node in (select * from temp_node))))) as boundary' )['boundary'] - write(name, f'delete from temp_node') - - return from_postgis_polygon(polygon) + row = read( + name, + "select st_astext(st_convexhull(st_collect(geom))) as boundary " + "from gis.node_geometries where node_id = any(%s)", + (nodes,), + ) + return from_postgis_polygon(str(row["boundary"])) def _verify_platform(): @@ -292,115 +269,7 @@ def calculate_boundary(name: str, nodes: list[str], accurate = False) -> list[tu topology = Topology(name, nodes) t_nodes = topology.nodes() t_links = topology.links() - - vertices, path, boundary = _calculate_boundary(topology.max_x_node(), t_nodes, t_links) - - if not accurate: - return boundary - - api = 'calculate_boundary' - write(name, f"delete from temp_region where id = '{api}'") - # use linestring instead of polygon to reduce strict limitation - # TODO: linestring can not work well - write(name, f"insert into temp_region (id, boundary) values ('{api}', '{to_postgis_polygon(boundary)}')") - - write(name, f'delete from temp_node') - for node in nodes: - write(name, f"insert into temp_node values ('{node}')") - - for row in read_all(name, f"select n.node from coordinates as c, temp_node as n, temp_region as r where c.node = n.node and ST_Intersects(c.coord, r.boundary) and r.id = '{api}'"): - node = row['node'] - write(name, f"delete from temp_node where node = '{node}'") - - outside_nodes: list[str] = [] - for row in read_all(name, "select node from temp_node"): - outside_nodes.append(row['node']) - - # no outside nodes, return - if len(outside_nodes) == 0: - write(name, f'delete from temp_node') - write(name, f"delete from temp_region where id = '{api}'") - return boundary - - new_nodes: dict[str, Any] = {} - new_links: dict[str, Any] = {} - - boundary_links: dict[str, list[str]] = {} - write(name, "delete from temp_link_2") - for node in outside_nodes: - for link in t_nodes[node]['links']: - node1 = t_links[link]['node1'] - node2 = t_links[link]['node2'] - if node1 in outside_nodes and node2 not in outside_nodes and node2 not in vertices and link: - if link not in boundary: - boundary_links[link] = [] - line = f"LINESTRING({t_nodes[node1]['x']} {t_nodes[node1]['y']}, {t_nodes[node2]['x']} {t_nodes[node2]['y']})" - write(name, f"insert into temp_link_2 values ('{link}', '{line}')") - if node2 in outside_nodes and node1 not in outside_nodes and node1 not in vertices: - if link not in boundary: - boundary_links[link] = [] - line = f"LINESTRING({t_nodes[node1]['x']} {t_nodes[node1]['y']}, {t_nodes[node2]['x']} {t_nodes[node2]['y']})" - write(name, f"insert into temp_link_2 values ('{link}', '{line}')") - if node1 in outside_nodes and node2 in outside_nodes: - x1, x2 = t_nodes[node1]['x'], t_nodes[node2]['x'] - y1, y2 = t_nodes[node1]['y'], t_nodes[node2]['y'] - if node1 not in new_nodes: - new_nodes[node1] = { 'x': x1, 'y': y1, 'links': [] } - if node2 not in new_nodes: - new_nodes[node2] = { 'x': x2, 'y': y2, 'links': [] } - if link not in new_links: - new_links[link] = t_links[link] - - # no boundary links, return - if len(boundary_links) == 0: - write(name, "delete from temp_link_2") - write(name, f'delete from temp_node') - write(name, f"delete from temp_region where id = '{api}'") - return boundary - - write(name, "delete from temp_link_1") - for link, _ in path.items(): - node1 = t_links[link]['node1'] - node2 = t_links[link]['node2'] - line = f"LINESTRING({t_nodes[node1]['x']} {t_nodes[node1]['y']}, {t_nodes[node2]['x']} {t_nodes[node2]['y']})" - write(name, f"insert into temp_link_1 (link, geom) values ('{link}', '{line}')") - - has_intersection = False - for row in read_all(name, f"select l1.link as l, l2.link as r, st_astext(st_intersection(l1.geom, l2.geom)) as p from temp_link_1 as l1, temp_link_2 as l2 where st_intersects(l1.geom, l2.geom)"): - has_intersection = True - - link1, link2, pt = str(row['l']), str(row['r']), str(row['p']) - pts = pt.lower().removeprefix('point(').removesuffix(')').split(' ') - xy = (float(pts[0]), float(pts[1])) - - new_node = f'NODE_[{link1}]_[{link2}]' - new_nodes[new_node] = { 'x': xy[0], 'y': xy[1], 'links': [] } - - path[link1].append(new_node) - boundary_links[link2].append(new_node) - - # no intersection, return - if not has_intersection: - write(name, "delete from temp_link_1") - write(name, "delete from temp_link_2") - write(name, 'delete from temp_node') - write(name, f"delete from temp_region where id = '{api}'") - return boundary - - new_nodes, new_links = _collect_new_links(path, t_nodes, t_links, new_nodes, new_links) - new_nodes, new_links = _collect_new_links(boundary_links, t_nodes, t_links, new_nodes, new_links) - - for link, values in new_links.items(): - new_nodes[values['node1']]['links'].append(link) - new_nodes[values['node2']]['links'].append(link) - - _, _, boundary = _calculate_boundary(topology.max_x_node(), new_nodes, new_links) - - write(name, "delete from temp_link_1") - write(name, "delete from temp_link_2") - write(name, 'delete from temp_node') - write(name, f"delete from temp_region where id = '{api}'") - + _, _, boundary = _calculate_boundary(topology.max_x_node(), t_nodes, t_links) return boundary @@ -432,7 +301,7 @@ def inflate_boundary(name: str, boundary: list[tuple[float, float]], delta: floa def inflate_region(name: str, region_id: str, delta: float = 0.5) -> list[tuple[float, float]]: - r = try_read(name, f"select id, st_astext(boundary) as boundary_geom from region where id = '{region_id}'") + r = try_read(name, "select id, st_astext(boundary) as boundary_geom from gis.regions where id = %s", (region_id,)) if r == None: return [] boundary = from_postgis_polygon(str(r['boundary_geom'])) diff --git a/app/native/wndb/gis/regions.py b/app/native/wndb/gis/regions.py new file mode 100644 index 0000000..0dbe18b --- /dev/null +++ b/app/native/wndb/gis/regions.py @@ -0,0 +1,127 @@ +from typing import Any + +from ..core.database import ( + ChangeSet, + DatabaseCommand, + execute_command, + g_add_prefix, + g_delete_prefix, + g_update_prefix, + sql_literal, + try_read, +) +from .region_geometry import from_postgis_polygon, to_postgis_polygon + + +def get_region_schema(name: str) -> dict[str, dict[str, Any]]: + return { + "id": {"type": "str", "optional": False, "readonly": True}, + "region_type": {"type": "str", "optional": False, "readonly": False}, + "boundary": {"type": "tuple_list", "optional": False, "readonly": False}, + } + + +def get_region(name: str, id: str) -> dict[str, Any]: + row = try_read( + name, + "select id, region_type, st_astext(boundary) as boundary_geom " + "from gis.regions where id = %s", + (id,), + ) + if row is None: + return {} + return { + "id": str(row["id"]), + "region_type": str(row["region_type"]), + "boundary": from_postgis_polygon(str(row["boundary_geom"])), + } + + +def _valid_boundary(boundary: list[Any]) -> bool: + return len(boundary) >= 4 and boundary[0] == boundary[-1] + + +def _set_region(name: str, cs: ChangeSet) -> DatabaseCommand: + region_id = cs.operations[0]["id"] + old = get_region(name, region_id) + new = old | { + key: cs.operations[0][key] + for key in ("region_type", "boundary") + if key in cs.operations[0] + } + statement = ( + "update gis.regions set " + f"region_type = {sql_literal(new['region_type'])}, " + f"boundary = st_geomfromtext({sql_literal(to_postgis_polygon(new['boundary']))}, 900914) " + f"where id = {sql_literal(region_id)};" + ) + return DatabaseCommand( + statement, + [g_update_prefix | {"type": "region"} | new], + ) + + +def set_region(name: str, cs: ChangeSet) -> ChangeSet: + operation = cs.operations[0] + if "id" not in operation or get_region(name, operation["id"]) == {}: + return ChangeSet() + if "boundary" in operation and not _valid_boundary(operation["boundary"]): + return ChangeSet() + return execute_command(name, _set_region(name, cs)) + + +def _add_region(name: str, cs: ChangeSet) -> DatabaseCommand: + operation = cs.operations[0] + region_id = operation["id"] + region_type = str(operation.get("region_type", "none")) + boundary = operation["boundary"] + statement = ( + "insert into gis.regions (id, region_type, boundary) values " + f"({sql_literal(region_id)}, {sql_literal(region_type)}, " + f"st_geomfromtext({sql_literal(to_postgis_polygon(boundary))}, 900914));" + ) + value = {"type": "region", "id": region_id, "region_type": region_type, "boundary": boundary} + return DatabaseCommand( + statement, + [g_add_prefix | value], + ) + + +def add_region(name: str, cs: ChangeSet) -> ChangeSet: + operation = cs.operations[0] + if "id" not in operation or "boundary" not in operation: + return ChangeSet() + if not _valid_boundary(operation["boundary"]): + return ChangeSet() + if get_region(name, operation["id"]) != {}: + return ChangeSet() + return execute_command(name, _add_region(name, cs)) + + +def _delete_region(name: str, cs: ChangeSet) -> DatabaseCommand: + region_id = cs.operations[0]["id"] + statement = f"delete from gis.regions where id = {sql_literal(region_id)};" + return DatabaseCommand( + statement, + [g_delete_prefix | {"type": "region", "id": region_id}], + ) + + +def delete_region(name: str, cs: ChangeSet) -> ChangeSet: + if "id" not in cs.operations[0] or get_region(name, cs.operations[0]["id"]) == {}: + return ChangeSet() + return execute_command(name, _delete_region(name, cs)) + + +def inp_in_region(line: str) -> str: + tokens = line.split() + return f"insert into gis.regions (id, region_type) values ({sql_literal(tokens[0])}, {sql_literal(tokens[1])});" + + +def inp_in_bound(line: str) -> str: + return line.split()[0] + + +def inp_in_regionnodes(line: str) -> str: + tokens = line.split() + return f"insert into gis.region_nodes (region_id, node_id) values ({sql_literal(tokens[0])}, {sql_literal(tokens[1])});" diff --git a/app/native/wndb/s25_vertices.py b/app/native/wndb/gis/vertices.py similarity index 53% rename from app/native/wndb/s25_vertices.py rename to app/native/wndb/gis/vertices.py index 0bd7647..50e1e71 100644 --- a/app/native/wndb/s25_vertices.py +++ b/app/native/wndb/gis/vertices.py @@ -1,4 +1,16 @@ -from .database import * +from typing import Any + +from ..core.database import ( + ChangeSet, + DatabaseCommand, + execute_command, + g_add_prefix, + g_delete_prefix, + g_update_prefix, + read_all, + sql_literal, + try_read, +) def get_vertex_schema(name: str) -> dict[str, dict[str, Any]]: @@ -9,59 +21,48 @@ def get_vertex_schema(name: str) -> dict[str, dict[str, Any]]: def get_vertex(name: str, link: str) -> dict[str, Any]: - cus = read_all(name, f"select * from vertices where link = '{link}' order by _order") + cus = read_all(name, "select st_x(geom) as x, st_y(geom) as y from gis.link_vertices where link_id = %s order by sequence_no", (link,)) cs = [] for r in cus: cs.append({ 'x': float(r['x']), 'y': float(r['y']) }) return { 'link': link, 'coords': cs } -def _set_vertex(name: str, cs: ChangeSet) -> DbChangeSet: +def _set_vertex(name: str, cs: ChangeSet) -> DatabaseCommand: link = cs.operations[0]['link'] - old = get_vertex(name, link) new = { 'link': link, 'coords': [] } - f_link = f"'{link}'" + f_link = sql_literal(link) - # TODO: transaction ? - redo_sql = f"delete from vertices where link = {f_link};" - for xy in cs.operations[0]['coords']: + statement = f"delete from gis.link_vertices where link_id = {f_link};" + for sequence_no, xy in enumerate(cs.operations[0]['coords']): x, y = float(xy['x']), float(xy['y']) - f_x, f_y = x, y - redo_sql += f"\ninsert into vertices (link, x, y) values ({f_link}, {f_x}, {f_y});" + f_x, f_y = sql_literal(x), sql_literal(y) + statement += f"\ninsert into gis.link_vertices (link_id, sequence_no, geom) values ({f_link}, {sequence_no}, st_setsrid(st_makepoint({f_x}, {f_y}), 900914));" new['coords'].append({ 'x': x, 'y': y }) - undo_sql = f"delete from vertices where link = {f_link};" - for xy in old['coords']: - f_x, f_y = xy['x'], xy['y'] - undo_sql += f"\ninsert into vertices (link, x, y) values ({f_link}, {f_x}, {f_y});" + change = { 'type': 'vertex' } | new - redo_cs = { 'type': 'vertex' } | new - undo_cs = { 'type': 'vertex' } | old - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def set_vertex(name: str, cs: ChangeSet) -> ChangeSet: result = _set_vertex(name, cs) - result.redo_cs[0] |= g_update_prefix - result.undo_cs[0] |= g_update_prefix + result.changes[0] |= g_update_prefix return execute_command(name, result) -def _add_vertex(name: str, cs: ChangeSet) -> DbChangeSet: +def _add_vertex(name: str, cs: ChangeSet) -> DatabaseCommand: result = _set_vertex(name, cs) - result.redo_cs[0] |= g_add_prefix - result.undo_cs[0] |= g_delete_prefix + result.changes[0] |= g_add_prefix return result -def _delete_vertex(name: str, cs: ChangeSet) -> DbChangeSet: +def _delete_vertex(name: str, cs: ChangeSet) -> DatabaseCommand: cs.operations[0]['coords'] = [] result = _set_vertex(name, cs) - result.redo_cs[0] |= g_delete_prefix - result.undo_cs[0] |= g_add_prefix + result.changes[0] |= g_delete_prefix return result @@ -77,14 +78,14 @@ def delete_vertex(name: str, cs: ChangeSet) -> ChangeSet: def get_all_vertex_links(name: str) -> list[str]: result : list[str] = [] - rows = read_all(name, 'select link from vertices order by link') + rows = read_all(name, 'select distinct link_id from gis.link_vertices order by link_id') for row in rows: - result.append(str(row['link'])) + result.append(str(row['link_id'])) return result def get_all_vertices(name: str) -> list[dict[str, Any]]: - return read_all(name, 'select * from vertices order by link') + return read_all(name, 'select link_id, sequence_no, st_x(geom) as x, st_y(geom) as y from gis.link_vertices order by link_id, sequence_no') #-------------------------------------------------------------- @@ -99,14 +100,15 @@ def inp_in_vertex(line: str) -> str: link = tokens[0] x = float(tokens[1]) y = float(tokens[2]) - return str(f"insert into vertices (link, x, y) values ('{link}', {x}, {y});") + link_sql = sql_literal(link) + return f"insert into gis.link_vertices (link_id, sequence_no, geom) values ({link_sql}, (select coalesce(max(sequence_no) + 1, 0) from gis.link_vertices where link_id = {link_sql}), st_setsrid(st_makepoint({sql_literal(x)}, {sql_literal(y)}), 900914));" def inp_out_vertex(name: str) -> list[str]: lines = [] - objs = read_all(name, f"select * from vertices order by _order") + objs = read_all(name, "select link_id, st_x(geom) as x, st_y(geom) as y from gis.link_vertices order by link_id, sequence_no") for obj in objs: - link = obj['link'] + link = obj['link_id'] x = obj['x'] y = obj['y'] lines.append(f"{link} {x} {y}") @@ -114,7 +116,7 @@ def inp_out_vertex(name: str) -> list[str]: def delete_vertex_by_link(name: str, link: str) -> ChangeSet: - row = try_read(name, f"select * from vertices where link = '{link}'") + row = try_read(name, "select * from gis.link_vertices where link_id = %s", (link,)) if row == None: return ChangeSet() return ChangeSet(g_delete_prefix | {'type': 'vertex', 'link' : link}) diff --git a/app/native/wndb/inp/__init__.py b/app/native/wndb/inp/__init__.py new file mode 100644 index 0000000..a6b4fd5 --- /dev/null +++ b/app/native/wndb/inp/__init__.py @@ -0,0 +1 @@ +"""EPANET INP import, export, and section mapping.""" diff --git a/app/native/wndb/inp_out.py b/app/native/wndb/inp/exporter.py similarity index 79% rename from app/native/wndb/inp_out.py rename to app/native/wndb/inp/exporter.py index aa7de7e..4638f88 100644 --- a/app/native/wndb/inp_out.py +++ b/app/native/wndb/inp/exporter.py @@ -1,35 +1,67 @@ import os -from .project import * -from .database import ChangeSet -from .sections import * -from .s1_title import inp_out_title -from .s2_junctions import inp_out_junction -from .s3_reservoirs import inp_out_reservoir -from .s4_tanks import inp_out_tank -from .s5_pipes import inp_out_pipe -from .s6_pumps import inp_out_pump -from .s7_valves import inp_out_valve -from .s8_tags import inp_out_tag -from .s9_demands import inp_out_demand -from .s10_status import inp_out_status -from .s11_patterns import inp_out_pattern, inp_out_pattern_v3 -from .s12_curves import inp_out_curve, inp_out_curve_v3 -from .s13_controls import inp_out_control -from .s14_rules import inp_out_rule -from .s15_energy import inp_out_energy -from .s16_emitters import inp_out_emitter -from .s17_quality import inp_out_quality -from .s18_sources import inp_out_source -from .s19_reactions import inp_out_reaction -from .s20_mixing import inp_out_mixing -from .s21_times import inp_out_time -from .s22_report import inp_out_report -from .s23_options import inp_out_option -from .s23_options_v3 import inp_out_option_v3 -from .s24_coordinates import inp_out_coord -from .s25_vertices import inp_out_vertex -from .s26_labels import inp_out_label -from .s27_backdrop import inp_out_backdrop + +from ..core.projects import close_project, have_project, is_project_open, open_project +from ..core.database import ChangeSet +from .sections import ( + BACKDROP, + CONTROLS, + COORDINATES, + CURVES, + DEMANDS, + EMITTERS, + END, + ENERGY, + JUNCTIONS, + LABELS, + MIXING, + OPTIONS, + PATTERNS, + PIPES, + PUMPS, + QUALITY, + REACTIONS, + REPORT, + RESERVOIRS, + RULES, + SOURCES, + STATUS, + TAGS, + TANKS, + TIMES, + TITLE, + VALVES, + VERTICES, + section_name, + section_names_for_epanetv2, +) +from ..model.title import inp_out_title +from ..model.junctions import inp_out_junction +from ..model.reservoirs import inp_out_reservoir +from ..model.tanks import inp_out_tank +from ..model.pipes import inp_out_pipe +from ..model.pumps import inp_out_pump +from ..model.valves import inp_out_valve +from ..model.tags import inp_out_tag +from ..model.demands import inp_out_demand +from ..model.status import inp_out_status +from ..model.patterns import inp_out_pattern, inp_out_pattern_v3 +from ..model.curves import inp_out_curve, inp_out_curve_v3 +from ..model.controls import inp_out_control +from ..model.rules import inp_out_rule +from ..model.energy import inp_out_energy +from ..model.emitters import inp_out_emitter +from ..model.quality import inp_out_quality +from ..model.sources import inp_out_source +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_v3 import inp_out_option_v3 +from ..gis.coordinates import inp_out_coord +from ..gis.vertices import inp_out_vertex +from ..gis.labels import inp_out_label +from ..gis.backdrop import inp_out_backdrop #from .s28_end import * diff --git a/app/native/wndb/inp_in.py b/app/native/wndb/inp/importer.py similarity index 70% rename from app/native/wndb/inp_in.py rename to app/native/wndb/inp/importer.py index a283f93..03a6d73 100644 --- a/app/native/wndb/inp_in.py +++ b/app/native/wndb/inp/importer.py @@ -1,42 +1,84 @@ import datetime import os -from .project import * -from .database import ChangeSet, write -from .sections import * -from .s0_base import get_region_type -from .s1_title import inp_in_title -from .s2_junctions import inp_in_junction -from .s3_reservoirs import inp_in_reservoir -from .s4_tanks import inp_in_tank -from .s5_pipes import inp_in_pipe -from .s6_pumps import inp_in_pump -from .s7_valves import inp_in_valve -from .s8_tags import inp_in_tag -from .s9_demands import inp_in_demand -from .s10_status import inp_in_status -from .s11_patterns import pattern_v3_types, inp_in_pattern -from .s12_curves import curve_types, inp_in_curve -from .s13_controls import inp_in_control -from .s14_rules import inp_in_rule -from .s15_energy import inp_in_energy -from .s16_emitters import inp_in_emitter -from .s17_quality import inp_in_quality -from .s18_sources import inp_in_source -from .s19_reactions import inp_in_reaction -from .s20_mixing import inp_in_mixing -from .s21_times import inp_in_time -from .s22_report import inp_in_report -from .s23_options import inp_in_option -from .s23_options_v3 import inp_in_option_v3 -from .s24_coordinates import inp_in_coord -from .s25_vertices import inp_in_vertex -from .s26_labels import inp_in_label -from .s27_backdrop import inp_in_backdrop -from .s32_region import inp_in_region, inp_in_bound, inp_in_regionnodes -from .s32_region_util import from_postgis_polygon, to_postgis_polygon + +from psycopg import sql + +from ..core.projects import ( + close_project, + create_project, + delete_project, + have_project, + is_project_open, + open_project, +) +from ..core.connection import project_transaction +from ..core.database import ChangeSet, refresh_materialized_views, sql_literal, write +from .sections import ( + BACKDROP, + BOUND, + CONTROLS, + COORDINATES, + CURVES, + DEMANDS, + EMITTERS, + ENERGY, + JUNCTIONS, + LABELS, + MIXING, + OPTIONS, + PATTERNS, + PIPES, + PUMPS, + QUALITY, + REACTIONS, + REGION, + REGION_NODES, + REPORT, + RESERVOIRS, + RULES, + SOURCES, + STATUS, + TAGS, + TANKS, + TIMES, + TITLE, + VALVES, + VERTICES, + section_name, +) +from ..model.title import inp_in_title +from ..model.junctions import inp_in_junction +from ..model.reservoirs import inp_in_reservoir +from ..model.tanks import inp_in_tank +from ..model.pipes import inp_in_pipe +from ..model.pumps import inp_in_pump +from ..model.valves import inp_in_valve +from ..model.tags import inp_in_tag +from ..model.demands import inp_in_demand +from ..model.status import inp_in_status +from ..model.patterns import pattern_v3_types, inp_in_pattern +from ..model.curves import curve_types, inp_in_curve +from ..model.controls import inp_in_control +from ..model.rules import inp_in_rule +from ..model.energy import inp_in_energy +from ..model.emitters import inp_in_emitter +from ..model.quality import inp_in_quality +from ..model.sources import inp_in_source +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_v3 import inp_in_option_v3 +from ..gis.coordinates import inp_in_coord +from ..gis.vertices import inp_in_vertex +from ..gis.labels import inp_in_label +from ..gis.backdrop import inp_in_backdrop +from ..gis.regions import inp_in_region, inp_in_bound, inp_in_regionnodes +from ..gis.region_geometry import to_postgis_polygon # DingZQ, 2024-12-28, export inp -from .inp_out import export_inp +from .exporter import export_inp _S = "S" _L = "L" @@ -205,8 +247,6 @@ def parse_file(project: str, inp: str, version: str = "3") -> None: current_bound = [] current_bound.clear() region_list = {} - current_region_nodes = [] - current_region_nodes.clear() sql_batch = SQLBatch(project) _print_time("Second scan...") @@ -254,7 +294,7 @@ def parse_file(project: str, inp: str, version: str = "3") -> None: if tokens[1].upper() in pattern_v3_types: # v3 sql_batch.add( - f"insert into _pattern (id) values ('{tokens[0]}');" + f"insert into network.patterns (id) values ({sql_literal(tokens[0])});" ) current_pattern = tokens[0] if tokens[1].upper() == "VARIABLE": @@ -263,7 +303,7 @@ def parse_file(project: str, inp: str, version: str = "3") -> None: if current_pattern != tokens[0]: sql_batch.add( - f"insert into _pattern (id) values ('{tokens[0]}');" + f"insert into network.patterns (id) values ({sql_literal(tokens[0])});" ) current_pattern = tokens[0] @@ -272,7 +312,7 @@ def parse_file(project: str, inp: str, version: str = "3") -> None: if tokens[1].upper() in curve_types: # v3 sql_batch.add( - f"insert into _curve (id, type) values ('{tokens[0]}', '{tokens[1].upper()}');" + f"insert into network.curves (id, curve_type) values ({sql_literal(tokens[0])}, {sql_literal(tokens[1].upper())});" ) current_curve = tokens[0] continue @@ -282,51 +322,39 @@ def parse_file(project: str, inp: str, version: str = "3") -> None: if curve_type_desc_line != None: type = curve_type_desc_line.split(":")[0].strip() sql_batch.add( - f"insert into _curve (id, type) values ('{tokens[0]}', '{type}');" + f"insert into network.curves (id, curve_type) values ({sql_literal(tokens[0])}, {sql_literal(type)});" ) current_curve = tokens[0] curve_type_desc_line = None elif s == REGION: tokens = line.split() region_list[tokens[0]] = tokens[1] + continue elif s == BOUND: tokens = line.split() if tokens[0] != current_region and len(current_bound) > 0: - # insert the previous region after get all the vertex of the attatched geometry current_bound.append(current_bound[0]) current_geometry = to_postgis_polygon(current_bound) - region_type = map_regiontype[region_list[tokens[0]]] + region_type = map_regiontype.get( + region_list[current_region], + region_list[current_region], + ) sql_batch.add( - f"insert into region(id, boundary,r_type) values ('{current_region}', '{current_geometry}','{region_type}');" + "insert into gis.regions(id, region_type, boundary) " + f"values ({sql_literal(current_region)}, {sql_literal(region_type)}, " + f"st_geomfromtext({sql_literal(current_geometry)}, 900914));" ) - # start the new region current_bound.clear() vertex_point = (float(tokens[1]), float(tokens[2])) current_bound.append(vertex_point) current_region = tokens[0] - elif s == REGION_NODES: - tokens = line.split() - if ( - tokens[0] != current_region - and len(current_region_nodes) > 0 - ): - # insert the previous region after get all the vertex of the attatched geometry - sql_batch.add( - get_insert_into_region_sql( - current_region, current_region_nodes - ) - ) - # start the new region - current_region_nodes.clear() - current_region_nodes.append(tokens[1]) - current_region = tokens[0] if s == JUNCTIONS: sql_batch.add(handler(line, demand_outside)) elif s == PATTERNS: sql_batch.add( handler(line, current_pattern not in variable_patterns) ) - elif s == BOUND or s == REGION_NODES: + elif s == BOUND: continue else: sql_batch.add(handler(line)) @@ -342,42 +370,21 @@ def parse_file(project: str, inp: str, version: str = "3") -> None: if len(current_bound) > 0: current_bound.append(current_bound[0]) current_geometry = to_postgis_polygon(current_bound) - region_type = map_regiontype[region_list[current_region]] - sql_batch.add( - f"insert into region(id, boundary,r_type) values ('{current_region}', '{current_geometry}','{region_type}');" + region_type = map_regiontype.get( + region_list[current_region], + region_list[current_region], ) - # reset the current region to none for the [REGION_NODES] session reading - # current_region=None - # need to insert the last region_nodes into database - if len(current_region_nodes) > 0: sql_batch.add( - get_insert_into_region_sql(current_region, current_region_nodes) + "insert into gis.regions(id, region_type, boundary) " + f"values ({sql_literal(current_region)}, {sql_literal(region_type)}, " + f"st_geomfromtext({sql_literal(current_geometry)}, 900914));" ) - # current_region=None sql_batch.flush() end = _print_time(f'End reading file "{inp}"') print(f"Total (in second): {(end-start).seconds}(s)") -def get_insert_into_region_sql(region: str, nodes: list[str]) -> str: - str_sql = "" - str_nodes = str(nodes).replace("'", "''") - r_type = region[0 : region.index("_")] - if r_type == "DMA" or r_type == "SA" or r_type == "VD": - table = "" - if r_type == "DMA": - table = "region_dma" - elif r_type == "SA": - table = "region_sa" - source = region[region.index("_") + 1 :] - str_sql = f"insert into region_sa(id,time_index,source,nodes) values ('{region}', 0,'{source}','{str_nodes}');" - elif r_type == "VD": - table = "region_vd" - - return str_sql - - def read_inp(project: str, inp: str, version: str = "3") -> bool: if version != "3" and version != "2": version = "2" @@ -391,7 +398,9 @@ def read_inp(project: str, inp: str, version: str = "3") -> bool: create_project(project) open_project(project) - parse_file(project, inp, version) + with project_transaction(project): + parse_file(project, inp, version) + refresh_materialized_views(project) """try: parse_file(project, inp, version) diff --git a/app/native/wndb/sections.py b/app/native/wndb/inp/sections.py similarity index 63% rename from app/native/wndb/sections.py rename to app/native/wndb/inp/sections.py index 8c48a0d..9f774ad 100644 --- a/app/native/wndb/sections.py +++ b/app/native/wndb/inp/sections.py @@ -1,43 +1,3 @@ -s1_title = 'title' -s2_junction = 'junction' -s3_reservoir = 'reservoir' -s4_tank = 'tank' -s5_pipe = 'pipe' -s6_pump = 'pump' -s7_valve = 'valve' -s8_tag = 'tag' -s9_demand = 'demand' -s10_status = 'status' -s11_pattern = 'pattern' -s12_curve = 'curve' -s13_control = 'control' -s14_rule = 'rule' -s15_energy = 'energy' -s15_pump_energy = 'pump_energy' -s16_emitter = 'emitter' -s17_quality = 'quality' -s18_source = 'source' -s19_reaction = 'reaction' -s19_pipe_reaction = 'pipe_reaction' -s19_tank_reaction = 'tank_reaction' -s20_mixing = 'mixing' -s21_time = 'time' -s22_report = 'report' -s23_option = 'option' -s23_option_v3 = 'option_v3' -s24_coordinate = 'coordinate' -s25_vertex = 'vertex' -s26_label = 'label' -s27_backdrop = 'backdrop' -s28_end = 'end' -s29_scada_device = 'scada_device' -s30_scada_device_data = 'scada_device_data' -s31_scada_element = 'scada_element' -s32_region = 'region' -s33_dma = 'district_metering_area' -s34_sa = 'service_area' -s35_vd = 'virtual_district' - TITLE = 'TITLE' JUNCTIONS = 'JUNCTIONS' RESERVOIRS = 'RESERVOIRS' @@ -87,4 +47,4 @@ section_names_for_epanetv2 = [TITLE, JUNCTIONS, RESERVOIRS, TANKS, PIPE PATTERNS, CURVES, CONTROLS, RULES, ENERGY, EMITTERS, QUALITY, SOURCES, REACTIONS, MIXING, TIMES, REPORT, OPTIONS, COORDINATES, VERTICES, - LABELS, BACKDROP, END] \ No newline at end of file + LABELS, BACKDROP, END] diff --git a/app/native/wndb/model/__init__.py b/app/native/wndb/model/__init__.py new file mode 100644 index 0000000..a037248 --- /dev/null +++ b/app/native/wndb/model/__init__.py @@ -0,0 +1 @@ +"""Water-network model persistence grouped by domain entity.""" diff --git a/app/native/wndb/s13_controls.py b/app/native/wndb/model/controls.py similarity index 52% rename from app/native/wndb/s13_controls.py rename to app/native/wndb/model/controls.py index 7b43f2b..b3c5f17 100644 --- a/app/native/wndb/s13_controls.py +++ b/app/native/wndb/model/controls.py @@ -1,4 +1,13 @@ -from .database import * +from typing import Any + +from ..core.database import ( + ChangeSet, + DatabaseCommand, + execute_command, + g_update_prefix, + read_all, + sql_literal, +) def get_control_schema(name: str) -> dict[str, dict[str, Any]]: @@ -6,28 +15,21 @@ def get_control_schema(name: str) -> dict[str, dict[str, Any]]: def get_control(name: str) -> dict[str, Any]: - cs = read_all(name, f"select * from controls") + cs = read_all(name, "select line from network.controls order by sequence_no") ds = [] for c in cs: ds.append(c['line']) return { 'controls': ds } -def _set_control(name: str, cs: ChangeSet) -> DbChangeSet: - old = get_control(name) +def _set_control(name: str, cs: ChangeSet) -> DatabaseCommand: + statement = 'delete from network.controls;' + for sequence_no, line in enumerate(cs.operations[0]['controls']): + statement += f"\ninsert into network.controls (sequence_no, line) values ({sequence_no}, {sql_literal(line)});" - redo_sql = 'delete from controls;' - for line in cs.operations[0]['controls']: - redo_sql += f"\ninsert into controls (line) values ('{line}');" + change = g_update_prefix | { 'type': 'control', 'controls': cs.operations[0]['controls'] } - undo_sql = 'delete from controls;' - for line in old['controls']: - undo_sql += f"\ninsert into controls (line) values ('{line}');" - - redo_cs = g_update_prefix | { 'type': 'control', 'controls': cs.operations[0]['controls'] } - undo_cs = g_update_prefix | { 'type': 'control', 'controls': old['controls'] } - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def set_control(name: str, cs: ChangeSet) -> ChangeSet: @@ -45,7 +47,7 @@ def set_control(name: str, cs: ChangeSet) -> ChangeSet: def inp_in_control(line: str) -> str: - return str(f"insert into controls (line) values ('{line}');") + return str(f"insert into network.controls (sequence_no, line) values ((select coalesce(max(sequence_no) + 1, 0) from network.controls), {sql_literal(line)});") def inp_out_control(name: str) -> list[str]: diff --git a/app/native/wndb/s12_curves.py b/app/native/wndb/model/curves.py similarity index 54% rename from app/native/wndb/s12_curves.py rename to app/native/wndb/model/curves.py index eb37982..7fe841e 100644 --- a/app/native/wndb/s12_curves.py +++ b/app/native/wndb/model/curves.py @@ -1,4 +1,16 @@ -from .database import * +from typing import Any + +from ..core.database import ( + ChangeSet, + DatabaseCommand, + execute_command, + g_add_prefix, + g_delete_prefix, + g_update_prefix, + read_all, + sql_literal, + try_read, +) CURVE_TYPE_PUMP = 'PUMP' CURVE_TYPE_EFFICIENCY = 'EFFICIENCY' @@ -16,26 +28,25 @@ def get_curve_schema(name: str) -> dict[str, dict[str, Any]]: def get_curve(name: str, id: str) -> dict[str, Any]: - c_one = try_read(name, f"select * from _curve where id = '{id}'") + c_one = try_read(name, "select id, curve_type from network.curves where id = %s", (id,)) if c_one == None: return {} - cus = read_all(name, f"select * from curves where id = '{id}' order by _order") + cus = read_all(name, "select x, y from network.curve_points where curve_id = %s order by sequence_no", (id,)) cs = [] for r in cus: cs.append({ 'x': float(r['x']), 'y': float(r['y']) }) d = {} d['id'] = id - d['c_type'] = c_one['type'] + d['c_type'] = c_one['curve_type'] d['coords'] = cs return d -def _set_curve(name: str, cs: ChangeSet) -> DbChangeSet: +def _set_curve(name: str, cs: ChangeSet) -> DatabaseCommand: id = cs.operations[0]['id'] - f_id = f"'{id}'" + f_id = sql_literal(id) old = get_curve(name, id) - old_f_type = f"'{old['c_type']}'" new = { 'id': id } if 'coords' in cs.operations[0]: @@ -46,25 +57,17 @@ def _set_curve(name: str, cs: ChangeSet) -> DbChangeSet: new['c_type'] = cs.operations[0]['c_type'] else: new['c_type'] = old['c_type'] - new_f_type = f"'{new['c_type']}'" + new_f_type = sql_literal(new['c_type']) - # TODO: transaction ? - redo_sql = f"delete from curves where id = {f_id};" - redo_sql += f"\nupdate _curve set type = {new_f_type} where id = {f_id};" - for xy in new['coords']: - f_x, f_y = xy['x'], xy['y'] - redo_sql += f"\ninsert into curves (id, x, y) values ({f_id}, {f_x}, {f_y});" + statement = f"delete from network.curve_points where curve_id = {f_id};" + statement += f"\nupdate network.curves set curve_type = {new_f_type} where id = {f_id};" + for sequence_no, xy in enumerate(new['coords']): + f_x, f_y = sql_literal(xy['x']), sql_literal(xy['y']) + statement += f"\ninsert into network.curve_points (curve_id, sequence_no, x, y) values ({f_id}, {sequence_no}, {f_x}, {f_y});" - undo_sql = f"delete from curves where id = {f_id};" - undo_sql += f"\nupdate _curve set type = {old_f_type} where id = {f_id};" - for xy in old['coords']: - f_x, f_y = xy['x'], xy['y'] - undo_sql += f"\ninsert into curves (id, x, y) values ({f_id}, {f_x}, {f_y});" + change = g_update_prefix | { 'type': 'curve' } | new - redo_cs = g_update_prefix | { 'type': 'curve' } | new - undo_cs = g_update_prefix | { 'type': 'curve' } | old - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def set_curve(name: str, cs: ChangeSet) -> ChangeSet: @@ -75,28 +78,23 @@ def set_curve(name: str, cs: ChangeSet) -> ChangeSet: return execute_command(name, _set_curve(name, cs)) -def _add_curve(name: str, cs: ChangeSet) -> DbChangeSet: +def _add_curve(name: str, cs: ChangeSet) -> DatabaseCommand: id = cs.operations[0]['id'] - f_id = f"'{id}'" + f_id = sql_literal(id) new = { 'id': id, 'c_type': cs.operations[0]['c_type'], 'coords': [] } - new_f_type = f"'{new['c_type']}'" + new_f_type = sql_literal(new['c_type']) - # TODO: transaction ? - redo_sql = f"insert into _curve (id, type) values ({f_id}, {new_f_type});" - for xy in cs.operations[0]['coords']: + statement = f"insert into network.curves (id, curve_type) values ({f_id}, {new_f_type});" + for sequence_no, xy in enumerate(cs.operations[0]['coords']): x, y = float(xy['x']), float(xy['y']) - f_x, f_y = x, y - redo_sql += f"\ninsert into curves (id, x, y) values ({f_id}, {f_x}, {f_y});" + f_x, f_y = sql_literal(x), sql_literal(y) + statement += f"\ninsert into network.curve_points (curve_id, sequence_no, x, y) values ({f_id}, {sequence_no}, {f_x}, {f_y});" new['coords'].append({ 'x': x, 'y': y }) - undo_sql = f"delete from curves where id = {f_id};" - undo_sql += f"\ndelete from _curve where id = {f_id};" + change = g_add_prefix | { 'type': 'curve' } | new - redo_cs = g_add_prefix | { 'type': 'curve' } | new - undo_cs = g_delete_prefix | { 'type': 'curve' } | { 'id' : id } - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def add_curve(name: str, cs: ChangeSet) -> ChangeSet: @@ -107,26 +105,15 @@ def add_curve(name: str, cs: ChangeSet) -> ChangeSet: return execute_command(name, _add_curve(name, cs)) -def _delete_curve(name: str, cs: ChangeSet) -> DbChangeSet: +def _delete_curve(name: str, cs: ChangeSet) -> DatabaseCommand: id = cs.operations[0]['id'] - f_id = f"'{id}'" + f_id = sql_literal(id) - old = get_curve(name, id) - old_f_type = f"'{old['c_type']}'" + statement = f"delete from network.curves where id = {f_id};" - redo_sql = f"delete from curves where id = {f_id};" - redo_sql += f"\ndelete from _curve where id = {f_id};" + change = g_delete_prefix | { 'type': 'curve' } | { 'id' : id } - # TODO: transaction ? - undo_sql = f"insert into _curve (id, type) values ({f_id}, {old_f_type});" - for xy in old['coords']: - f_x, f_y = xy['x'], xy['y'] - undo_sql += f"\ninsert into curves (id, x, y) values ({f_id}, {f_x}, {f_y});" - - redo_cs = g_delete_prefix | { 'type': 'curve' } | { 'id' : id } - undo_cs = g_add_prefix | { 'type': 'curve' } | old - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def delete_curve(name: str, cs: ChangeSet) -> ChangeSet: @@ -151,17 +138,18 @@ def delete_curve(name: str, cs: ChangeSet) -> ChangeSet: def inp_in_curve(line: str) -> str: tokens = line.split() - return str(f"insert into curves (id, x, y) values ('{tokens[0]}', {float(tokens[1])}, {float(tokens[2])});") + curve_id = sql_literal(tokens[0]) + return str(f"insert into network.curve_points (curve_id, sequence_no, x, y) values ({curve_id}, (select coalesce(max(sequence_no) + 1, 0) from network.curve_points where curve_id = {curve_id}), {sql_literal(float(tokens[1]))}, {sql_literal(float(tokens[2]))});") def inp_out_curve(name: str) -> list[str]: lines = [] - types = read_all(name, f"select * from _curve") + types = read_all(name, "select id, curve_type as type from network.curves order by id") for type in types: id = type['id'] # ;type: desc lines.append(f";{type['type']}:") - objs = read_all(name, f"select * from curves where id = '{id}' order by _order") + objs = read_all(name, "select curve_id as id, x, y from network.curve_points where curve_id = %s order by sequence_no", (id,)) for obj in objs: id = obj['id'] x = obj['x'] @@ -172,12 +160,12 @@ def inp_out_curve(name: str) -> list[str]: def inp_out_curve_v3(name: str) -> list[str]: lines = [] - types = read_all(name, f"select * from _curve") + types = read_all(name, "select id, curve_type as type from network.curves order by id") for type in types: id = type['id'] # id type lines.append(f"{id} {type['type']}") - objs = read_all(name, f"select * from curves where id = '{id}' order by _order") + objs = read_all(name, "select curve_id as id, x, y from network.curve_points where curve_id = %s order by sequence_no", (id,)) for obj in objs: id = obj['id'] x = obj['x'] diff --git a/app/native/wndb/s9_demands.py b/app/native/wndb/model/demands.py similarity index 58% rename from app/native/wndb/s9_demands.py rename to app/native/wndb/model/demands.py index e1b8126..f0a1228 100644 --- a/app/native/wndb/s9_demands.py +++ b/app/native/wndb/model/demands.py @@ -1,4 +1,12 @@ -from .database import read_all, ChangeSet, DbChangeSet, g_update_prefix, execute_command, try_read +from ..core.database import ( + ChangeSet, + DatabaseCommand, + execute_command, + g_update_prefix, + read_all, + sql_literal, + try_read, +) from typing import Any def get_demand_schema(name: str) -> dict[str, dict[str, Any]]: @@ -10,7 +18,7 @@ def get_demand_schema(name: str) -> dict[str, dict[str, Any]]: def get_demand(name: str, junction: str) -> dict[str, Any]: - des = read_all(name, f"select * from demands where junction = '{junction}' order by _order") + des = read_all(name, "select base_demand as demand, pattern_id as pattern, category from network.demands where junction_id = %s order by sequence_no", (junction,)) ds = [] for r in des: d = {} @@ -21,39 +29,26 @@ def get_demand(name: str, junction: str) -> dict[str, Any]: return { 'junction': junction, 'demands': ds } -def _set_demand(name: str, cs: ChangeSet) -> DbChangeSet: +def _set_demand(name: str, cs: ChangeSet) -> DatabaseCommand: junction = cs.operations[0]['junction'] - old = get_demand(name, junction) new = { 'junction': junction, 'demands': [] } - f_junction = f"'{junction}'" + f_junction = sql_literal(junction) - # TODO: transaction ? - redo_sql = f"delete from demands where junction = {f_junction};" - for r in cs.operations[0]['demands']: + statement = f"delete from network.demands where junction_id = {f_junction};" + for sequence_no, r in enumerate(cs.operations[0]['demands']): demand = float(r['demand']) pattern = str(r['pattern']) if 'pattern' in r and r['pattern'] != None else None category = str(r['category']) if 'category' in r and r['category'] != None else None - f_demand = demand - f_pattern = f"'{pattern}'" if pattern is not None else 'null' - f_category = f"'{category}'" if category is not None else 'null' - redo_sql += f"\ninsert into demands (junction, demand, pattern, category) values ({f_junction}, {f_demand}, {f_pattern}, {f_category});" + f_demand = sql_literal(demand) + f_pattern = sql_literal(pattern) + f_category = sql_literal(category) + statement += f"\ninsert into network.demands (junction_id, sequence_no, base_demand, pattern_id, category) values ({f_junction}, {sequence_no}, {f_demand}, {f_pattern}, {f_category});" new['demands'].append({ 'demand': demand, 'pattern': pattern, 'category': category }) - undo_sql = f"delete from demands where junction = {f_junction};" - for r in old['demands']: - demand = float(r['demand']) - pattern = str(r['pattern']) if 'pattern' in r and r['pattern'] != None else None - category = str(r['category']) if 'category' in r and r['category'] != None else None - f_demand = demand - f_pattern = f"'{pattern}'" if pattern is not None else 'null' - f_category = f"'{category}'" if category is not None else 'null' - undo_sql += f"\ninsert into demands (junction, demand, pattern, category) values ({f_junction}, {f_demand}, {f_pattern}, {f_category});" + change = g_update_prefix | { 'type': 'demand' } | new - redo_cs = g_update_prefix | { 'type': 'demand' } | new - undo_cs = g_update_prefix | { 'type': 'demand' } | old - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def set_demand(name: str, cs: ChangeSet) -> ChangeSet: @@ -76,16 +71,15 @@ def inp_in_demand(line: str) -> str: junction = str(tokens[0]) demand = float(tokens[1]) pattern = str(tokens[2]) if num_without_desc >= 3 else None - pattern = f"'{pattern}'" if pattern is not None else 'null' category = str(tokens[3]) if num_without_desc >= 4 else None - category = f"'{category}'" if category is not None else 'null' - return str(f"insert into demands (junction, demand, pattern, category) values ('{junction}', {demand}, {pattern}, {category});") + junction_sql = sql_literal(junction) + return str(f"insert into network.demands (junction_id, sequence_no, base_demand, pattern_id, category) values ({junction_sql}, (select coalesce(max(sequence_no) + 1, 0) from network.demands where junction_id = {junction_sql}), {sql_literal(demand)}, {sql_literal(pattern)}, {sql_literal(category)});") def inp_out_demand(name: str) -> list[str]: lines = [] - objs = read_all(name, "select * from demands order by _order") + objs = read_all(name, "select junction_id as junction, base_demand as demand, pattern_id as pattern, category from network.demands order by junction_id, sequence_no") for obj in objs: junction = obj['junction'] demand = obj['demand'] @@ -96,7 +90,7 @@ def inp_out_demand(name: str) -> list[str]: def delete_demand_by_junction(name: str, junction: str) -> ChangeSet: - row = try_read(name, f"select * from demands where junction = '{junction}'") + row = try_read(name, "select 1 from network.demands where junction_id = %s", (junction,)) if row is None: return ChangeSet() return ChangeSet(g_update_prefix | {'type': 'demand', 'junction': junction, 'demands': []}) @@ -105,7 +99,7 @@ def delete_demand_by_junction(name: str, junction: str) -> ChangeSet: def unset_demand_by_pattern(name: str, pattern: str) -> ChangeSet: cs = ChangeSet() - rows = read_all(name, f"select distinct junction from demands where pattern = '{pattern}'") + 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']: diff --git a/app/native/wndb/model/elements.py b/app/native/wndb/model/elements.py new file mode 100644 index 0000000..fe8cb93 --- /dev/null +++ b/app/native/wndb/model/elements.py @@ -0,0 +1,282 @@ +from typing import Any + +from psycopg import sql +from psycopg.rows import Row, dict_row + +from ..core.connection import project_connection +from ..core.database import read + +_NODE = "network.nodes" +_LINK = "network.links" +_CURVE = "network.curves" +_PATTERN = "network.patterns" +_REGION = "gis.regions" + +JUNCTION = "junction" +RESERVOIR = "reservoir" +TANK = "tank" +PIPE = "pipe" +PUMP = "pump" +VALVE = "valve" +PATTERN = "pattern" +CURVE = "curve" +REGION = "region" + +ELEMENT_TYPES: dict[str, int] = { + RESERVOIR: 0, + TANK: 1, + JUNCTION: 2, + PIPE: 3, + PUMP: 4, + VALVE: 5, +} + + +def _table_identifier(table: str): + return sql.Identifier(*table.split(".")) + + +def _get_from(name: str, element_id: str, table: str) -> Row | None: + query = sql.SQL("SELECT * FROM {} WHERE id = %s").format( + _table_identifier(table) + ) + with project_connection(name) as conn, conn.cursor(row_factory=dict_row) as cur: + cur.execute(query, (element_id,)) + return cur.fetchone() + + +def is_node(name: str, element_id: str) -> bool: + return _get_from(name, element_id, _NODE) is not None + + +def is_junction(name: str, element_id: str) -> bool: + row = _get_from(name, element_id, _NODE) + return row is not None and row["node_type"] == JUNCTION + + +def is_reservoir(name: str, element_id: str) -> bool: + row = _get_from(name, element_id, _NODE) + return row is not None and row["node_type"] == RESERVOIR + + +def is_tank(name: str, element_id: str) -> bool: + row = _get_from(name, element_id, _NODE) + return row is not None and row["node_type"] == TANK + + +def is_link(name: str, element_id: str) -> bool: + return _get_from(name, element_id, _LINK) is not None + + +def is_pipe(name: str, element_id: str) -> bool: + row = _get_from(name, element_id, _LINK) + return row is not None and row["link_type"] == PIPE + + +def is_pump(name: str, element_id: str) -> bool: + row = _get_from(name, element_id, _LINK) + return row is not None and row["link_type"] == PUMP + + +def is_valve(name: str, element_id: str) -> bool: + row = _get_from(name, element_id, _LINK) + return row is not None and row["link_type"] == VALVE + + +def get_node_type(name: str, node_id: str) -> str: + row = _get_from(name, node_id, _NODE) + if row is None: + raise LookupError(node_id) + return row["node_type"] + + +def get_link_type(name: str, link_id: str) -> str: + row = _get_from(name, link_id, _LINK) + if row is None: + raise LookupError(link_id) + return row["link_type"] + + +def get_element_type(name: str, element_id: str) -> str | None: + if is_node(name, element_id): + return get_node_type(name, element_id) + if is_link(name, element_id): + return get_link_type(name, element_id) + return None + + +def get_element_type_value(name: str, element_id: str) -> int: + element_type = get_element_type(name, element_id) + if element_type is None: + raise LookupError(element_id) + return ELEMENT_TYPES[element_type] + + +def is_curve(name: str, element_id: str) -> bool: + return _get_from(name, element_id, _CURVE) is not None + + +def is_pattern(name: str, element_id: str) -> bool: + return _get_from(name, element_id, _PATTERN) is not None + + +def is_region(name: str, element_id: str) -> bool: + return _get_from(name, element_id, _REGION) is not None + + +def _get_all(name: str, table: str) -> list[str]: + query = sql.SQL("SELECT id FROM {} ORDER BY id").format(_table_identifier(table)) + with project_connection(name) as conn, conn.cursor(row_factory=dict_row) as cur: + cur.execute(query) + return [row["id"] for row in cur] + + +def _get_nodes_by_type(name: str, node_type: str) -> list[str]: + rows = read_all_typed( + name, + "SELECT id FROM network.nodes WHERE node_type = %s ORDER BY id", + (node_type,), + ) + return [row["id"] for row in rows] + + +def _get_links_by_type(name: str, link_type: str) -> list[str]: + rows = read_all_typed( + name, + "SELECT id FROM network.links WHERE link_type = %s ORDER BY id", + (link_type,), + ) + return [row["id"] for row in rows] + + +def read_all_typed(name: str, query: str, params: tuple[Any, ...]) -> list[Row]: + with project_connection(name) as conn, conn.cursor(row_factory=dict_row) as cur: + cur.execute(query, params) + return cur.fetchall() + + +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) + + +def get_reservoirs(name: str) -> list[str]: + return _get_nodes_by_type(name, RESERVOIR) + + +def get_tanks(name: str) -> list[str]: + return _get_nodes_by_type(name, TANK) + + +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) + + +def get_pumps(name: str) -> list[str]: + return _get_links_by_type(name, PUMP) + + +def get_valves(name: str) -> list[str]: + return _get_links_by_type(name, VALVE) + + +def get_curves(name: str) -> list[str]: + return _get_all(name, _CURVE) + + +def get_patterns(name: str) -> list[str]: + return _get_all(name, _PATTERN) + + +def get_regions(name: str) -> list[str]: + return _get_all(name, _REGION) + + +def get_node_links(name: str, node_id: str) -> list[str]: + rows = read_all_typed( + name, + """ + SELECT id FROM network.links + WHERE start_node_id = %s OR end_node_id = %s + ORDER BY id + """, + (node_id, node_id), + ) + return [row["id"] for row in rows] + + +def get_all_node_links(name: str) -> dict[str, list[str]]: + """Build the node adjacency map with one scan of the link table.""" + rows = read_all_typed( + name, + "SELECT id, start_node_id, end_node_id FROM network.links ORDER BY id", + (), + ) + result: dict[str, list[str]] = {} + for row in rows: + link_id = str(row["id"]) + result.setdefault(str(row["start_node_id"]), []).append(link_id) + result.setdefault(str(row["end_node_id"]), []).append(link_id) + return result + + +def get_link_nodes(name: str, link_id: str) -> list[str]: + row = read( + name, + """ + SELECT start_node_id, end_node_id + FROM network.links WHERE id = %s + """, + (link_id,), + ) + return [str(row["start_node_id"]), str(row["end_node_id"])] + + +def get_region_type(name: str, region_id: str) -> str: + row = read( + name, + "SELECT region_type FROM gis.regions WHERE id = %s", + (region_id,), + ) + return row["region_type"] diff --git a/app/native/wndb/s16_emitters.py b/app/native/wndb/model/emitters.py similarity index 66% rename from app/native/wndb/s16_emitters.py rename to app/native/wndb/model/emitters.py index 8f510a9..591ede3 100644 --- a/app/native/wndb/s16_emitters.py +++ b/app/native/wndb/model/emitters.py @@ -1,4 +1,14 @@ -from .database import * +from typing import Any + +from ..core.database import ( + ChangeSet, + DatabaseCommand, + execute_command, + g_update_prefix, + read_all, + sql_literal, + try_read, +) def get_emitter_schema(name: str) -> dict[str, dict[str, Any]]: @@ -7,7 +17,7 @@ def get_emitter_schema(name: str) -> dict[str, dict[str, Any]]: def get_emitter(name: str, junction: str) -> dict[str, Any]: - e = try_read(name, f"select * from emitters where junction = '{junction}'") + e = try_read(name, "select junction_id as junction, coefficient from network.emitters where junction_id = %s", (junction,)) if e == None: return { 'junction': junction, 'coefficient': None } d = {} @@ -22,16 +32,15 @@ class Emitter(object): self.junction = str(input['junction']) self.coefficient = float(input['coefficient']) if 'coefficient' in input and input['coefficient'] != None else None - self.f_type = f"'{self.type}'" - self.f_junction = f"'{self.junction}'" - self.f_coefficient = self.coefficient if self.coefficient != None else 'null' + self.f_type = sql_literal(self.type) + self.f_junction = sql_literal(self.junction) + self.f_coefficient = sql_literal(self.coefficient) def as_dict(self) -> dict[str, Any]: return { 'type': self.type, 'junction': self.junction, 'coefficient': self.coefficient } -def _set_emitter(name: str, cs: ChangeSet) -> DbChangeSet: - old = Emitter(get_emitter(name, cs.operations[0]['junction'])) +def _set_emitter(name: str, cs: ChangeSet) -> DatabaseCommand: raw_new = get_emitter(name, cs.operations[0]['junction']) new_dict = cs.operations[0] @@ -41,18 +50,13 @@ def _set_emitter(name: str, cs: ChangeSet) -> DbChangeSet: raw_new[key] = new_dict[key] new = Emitter(raw_new) - redo_sql = f"delete from emitters where junction = {new.f_junction};" + statement = f"delete from network.emitters where junction_id = {new.f_junction};" if new.coefficient != None: - redo_sql += f"\ninsert into emitters (junction, coefficient) values ({new.f_junction}, {new.f_coefficient});" + statement += f"\ninsert into network.emitters (junction_id, coefficient) values ({new.f_junction}, {new.f_coefficient});" - undo_sql = f"delete from emitters where junction = {old.f_junction};" - if old.coefficient != None: - undo_sql += f"\ninsert into emitters (junction, coefficient) values ({old.f_junction}, {old.f_coefficient});" + change = g_update_prefix | new.as_dict() - redo_cs = g_update_prefix | new.as_dict() - undo_cs = g_update_prefix | old.as_dict() - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def set_emitter(name: str, cs: ChangeSet) -> ChangeSet: @@ -78,12 +82,12 @@ def inp_in_emitter(line: str) -> str: junction = str(tokens[0]) coefficient = float(tokens[1]) - return str(f"insert into emitters (junction, coefficient) values ('{junction}', {coefficient});") + return str(f"insert into network.emitters (junction_id, coefficient) values ({sql_literal(junction)}, {sql_literal(coefficient)});") def inp_out_emitter(name: str) -> list[str]: lines = [] - objs = read_all(name, 'select * from emitters') + objs = read_all(name, 'select junction_id as junction, coefficient from network.emitters order by junction_id') for obj in objs: junction = obj['junction'] coefficient = obj['coefficient'] @@ -92,7 +96,7 @@ def inp_out_emitter(name: str) -> list[str]: def delete_emitter_by_junction(name: str, junction: str) -> ChangeSet: - row = try_read(name, f"select * from emitters where junction = '{junction}'") + row = try_read(name, "select 1 from network.emitters where junction_id = %s", (junction,)) if row == None: return ChangeSet() return ChangeSet(g_update_prefix | {'type' : 'emitter', 'junction': junction, 'coefficient': None}) diff --git a/app/native/wndb/model/energy.py b/app/native/wndb/model/energy.py new file mode 100644 index 0000000..7f60051 --- /dev/null +++ b/app/native/wndb/model/energy.py @@ -0,0 +1,220 @@ +from typing import Any + +from ..core.database import ( + ChangeSet, + DatabaseCommand, + execute_command, + g_update_prefix, + read_all, + sql_literal, + try_read, +) + + +element_schema = {'type': 'str' , 'optional': True , 'readonly': False} + + +def get_energy_schema(name: str) -> dict[str, dict[str, Any]]: + return { 'GLOBAL PRICE' : element_schema, + 'GLOBAL PATTERN' : element_schema, + 'GLOBAL EFFIC' : element_schema, + 'DEMAND CHARGE' : element_schema } + + +def get_energy(name: str) -> dict[str, Any]: + ts = read_all(name, "select key, value from network.energy_settings") + d = {} + for e in ts: + d[e['key']] = str(e['value']) + return d + + +def _set_energy(name: str, cs: ChangeSet) -> DatabaseCommand: + new = {} + + new_dict = cs.operations[0] + schema = get_energy_schema(name) + for key in schema.keys(): + if key in new_dict: + new[key] = str(new_dict[key]) + + change = g_update_prefix | { 'type' : 'energy' } + + statement = '' + for key, value in new.items(): + if statement != '': + statement += '\n' + statement += f"update network.energy_settings set value = {sql_literal(value)} where key = {sql_literal(key)};" + change |= { key: value } + + return DatabaseCommand(statement, [change]) + + +def set_energy(name: str, cs: ChangeSet) -> ChangeSet: + return execute_command(name, _set_energy(name, cs)) + + +def get_pump_energy_schema(name: str) -> dict[str, dict[str, Any]]: + return { 'pump' : {'type': 'str' , 'optional': False , 'readonly': True }, + 'price' : {'type': 'float' , 'optional': True , 'readonly': False}, + 'pattern' : {'type': 'str' , 'optional': True , 'readonly': False}, + 'effic' : {'type': 'str' , 'optional': True , 'readonly': False} } + + +def get_pump_energy(name: str, pump: str) -> dict[str, Any]: + d = {} + d['pump'] = pump + pe = try_read(name, "select price, pattern_id as pattern, efficiency_curve_id as effic from network.pump_energy_settings where pump_id = %s", (pump,)) + d['price'] = float(pe['price']) if pe is not None and pe['price'] is not None else None + d['pattern'] = str(pe['pattern']) if pe is not None and pe['pattern'] is not None else None + d['effic'] = str(pe['effic']) if pe is not None and pe['effic'] is not None else None + return d + + +class PumpEnergy(object): + def __init__(self, input: dict[str, Any]) -> None: + self.type = 'pump_energy' + self.pump = str(input['pump']) + self.price = float(input['price']) if 'price' in input and input['price'] != None else None + self.pattern = str(input['pattern']) if 'pattern' in input and input['pattern'] != None else None + self.effic = str(input['effic']) if 'effic' in input and input['effic'] != None else None + + self.f_type = sql_literal(self.type) + self.f_pump = sql_literal(self.pump) + self.f_price = sql_literal(self.price) + self.f_pattern = sql_literal(self.pattern) + self.f_effic = sql_literal(self.effic) + + def as_dict(self) -> dict[str, Any]: + return { 'type': self.type, 'pump': self.pump, 'price': self.price, 'pattern': self.pattern, 'effic': self.effic } + + +def _set_pump_energy(name: str, cs: ChangeSet) -> DatabaseCommand: + raw_new = get_pump_energy(name, cs.operations[0]['pump']) + + new_dict = cs.operations[0] + schema = get_pump_energy_schema(name) + for key, value in schema.items(): + if key in new_dict and not value['readonly']: + raw_new[key] = new_dict[key] + new = PumpEnergy(raw_new) + + statement = f"delete from network.pump_energy_settings where pump_id = {new.f_pump};" + if new.price is not None or new.pattern is not None or new.effic is not None: + statement += f"\ninsert into network.pump_energy_settings (pump_id, efficiency_curve_id, pattern_id, price) values ({new.f_pump}, {new.f_effic}, {new.f_pattern}, {new.f_price});" + + change = g_update_prefix | new.as_dict() + + return DatabaseCommand(statement, [change]) + + +def set_pump_energy(name: str, cs: ChangeSet) -> ChangeSet: + return execute_command(name, _set_pump_energy(name, cs)) + + +#-------------------------------------------------------------- +# [EPA2][EPA3][IN][OUT] +# GLOBAL {PRICE/PATTERN/EFFIC} value +# PUMP id {PRICE/PATTERN/EFFIC} value +# DEMAND CHARGE value +#-------------------------------------------------------------- + + +def inp_in_energy(line: str) -> str: + tokens = line.split() + + if tokens[0].upper() == 'PUMP': + pump = tokens[1] + key = tokens[2].lower() + value = tokens[3] + if key == 'price': + value = float(value) + if key == 'efficiency': + key = 'effic' + + column = {'price': 'price', 'pattern': 'pattern_id', 'effic': 'efficiency_curve_id'}[key] + return str(f"insert into network.pump_energy_settings (pump_id, {column}) values ({sql_literal(pump)}, {sql_literal(value)}) on conflict (pump_id) do update set {column} = excluded.{column};") + + else: + line = line.upper().strip() + for key in get_energy_schema('').keys(): + if line.startswith(key): + value = line.removeprefix(key).strip() + + # exception here + if line.startswith('GLOBAL EFFICIENCY'): + value = line.removeprefix('GLOBAL EFFICIENCY').strip() + + return str(f"update network.energy_settings set value = {sql_literal(value)} where key = {sql_literal(key)};") + + return str('') + + +def inp_out_energy(name: str) -> list[str]: + lines = [] + + objs = read_all(name, "select key, value from network.energy_settings order by key") + for obj in objs: + key = obj['key'] + value = obj['value'] + if value.strip() != '': + lines.append(f'{key} {value}') + + objs = read_all(name, "select pump_id as pump, price, pattern_id as pattern, efficiency_curve_id as effic from network.pump_energy_settings order by pump_id") + for obj in objs: + pump = obj['pump'] + if obj['price'] is not None: + lines.append(f"PUMP {pump} PRICE {obj['price']}") + if obj['pattern'] is not None: + lines.append(f"PUMP {pump} PATTERN {obj['pattern']}") + if obj['effic'] is not None: + lines.append(f"PUMP {pump} EFFIC {obj['effic']}") + + return lines + + +def delete_pump_energy_by_pump(name: str, pump: str) -> ChangeSet: + row = try_read( + name, + "select pump_id from network.pump_energy_settings where pump_id = %s", + (pump,), + ) + if row is None: + return ChangeSet() + return ChangeSet(g_update_prefix | {'type': 'pump_energy', 'pump' : pump, 'price': None, 'pattern': None, 'effic': None}) + + +def unset_pump_energy_by_pattern(name: str, pattern: str) -> ChangeSet: + cs = ChangeSet() + + rows = read_all( + name, + "select pump_id as pump, price, efficiency_curve_id as effic " + "from network.pump_energy_settings where pattern_id = %s", + (pattern,), + ) + for row in rows: + pump = row['pump'] + price = float(row['price']) if row['price'] is not None else None + effic = str(row['effic']) if row['effic'] is not None else None + cs.append(g_update_prefix | {'type': 'pump_energy', 'pump' : pump, 'price': price, 'pattern': None, 'effic': effic}) + + return cs + + +def unset_pump_energy_by_curve(name: str, curve: str) -> ChangeSet: + cs = ChangeSet() + + rows = read_all( + name, + "select pump_id as pump, price, pattern_id as pattern " + "from network.pump_energy_settings where efficiency_curve_id = %s", + (curve,), + ) + for row in rows: + pump = row['pump'] + price = float(row['price']) if row['price'] is not None else None + pattern = str(row['pattern']) if row['pattern'] is not None else None + cs.append(g_update_prefix | {'type': 'pump_energy', 'pump' : pump, 'price': price, 'pattern': pattern, 'effic': None}) + + return cs diff --git a/app/native/wndb/s2_junctions.py b/app/native/wndb/model/junctions.py similarity index 54% rename from app/native/wndb/s2_junctions.py rename to app/native/wndb/model/junctions.py index ac59a8e..e7af019 100644 --- a/app/native/wndb/s2_junctions.py +++ b/app/native/wndb/model/junctions.py @@ -1,6 +1,20 @@ -from .database import * -from .s0_base import * -from .s24_coordinates import * +from typing import Any + +from psycopg import sql + +from ..core.database import ( + ChangeSet, + DatabaseCommand, + execute_command, + g_add_prefix, + g_delete_prefix, + g_update_prefix, + read_all, + sql_literal, + try_read, +) +from ..gis.coordinates import sql_delete_coord, sql_insert_coord, sql_update_coord +from .elements import get_all_node_links def get_junction_schema(name: str) -> dict[str, dict[str, Any]]: @@ -12,34 +26,55 @@ def get_junction_schema(name: str) -> dict[str, dict[str, Any]]: def get_junction(name: str, id: str) -> dict[str, Any]: - j = try_read(name, "select * from junctions where id = %s", (id,)) + j = try_read( + name, + """ + SELECT n.id, j.elevation, ST_X(g.geom) AS x, ST_Y(g.geom) AS y, + COALESCE(array_agg(l.id ORDER BY l.id) + FILTER (WHERE l.id IS NOT NULL), '{}') AS links + FROM network.nodes AS n + JOIN network.junctions AS j ON j.node_id = n.id + LEFT JOIN gis.node_geometries AS g ON g.node_id = n.id + LEFT JOIN network.links AS l + ON l.start_node_id = n.id OR l.end_node_id = n.id + WHERE n.id = %s + GROUP BY n.id, j.elevation, g.geom + """, + (id,), + ) if j == None: return {} - xy = get_node_coord(name, id) d = {} d['id'] = str(j['id']) - d['x'] = float(xy['x']) - d['y'] = float(xy['y']) + d['x'] = float(j['x'] or 0.0) + d['y'] = float(j['y'] or 0.0) d['elevation'] = float(j['elevation']) - d['links'] = get_node_links(name, id) + d['links'] = list(j['links']) return d # DingZQ, 2025-03-29 def get_all_junctions(name: str) -> list[dict[str, Any]]: - rows = read_all(name, f"select * from junctions") + rows = read_all( + name, + """ + SELECT id, elevation, x, y + FROM gis.junctions + ORDER BY id + """, + ) if rows == None: return [] + links_by_node = get_all_node_links(name) result = [] for row in rows: d = {} id = str(row['id']) - xy = get_node_coord(name, id) d['id'] = id - d['x'] = float(xy['x']) - d['y'] = float(xy['y']) + d['x'] = float(row['x'] or 0.0) + d['y'] = float(row['y'] or 0.0) d['elevation'] = float(row['elevation']) - d['links'] = get_node_links(name, id) + d['links'] = links_by_node.get(id, []) result.append(d) return result @@ -52,19 +87,14 @@ class Junction(object): self.y = float(input['y']) self.elevation = float(input['elevation']) - self.f_type = f"'{self.type}'" - self.f_id = f"'{self.id}'" - self.f_elevation = self.elevation + self.f_type = sql_literal(self.type) + self.f_id = sql_literal(self.id) + self.f_elevation = sql_literal(self.elevation) def as_dict(self) -> dict[str, Any]: return { 'type': self.type, 'id': self.id, 'x': self.x, 'y': self.y, 'elevation': self.elevation } - def as_id_dict(self) -> dict[str, Any]: - return { 'type': self.type, 'id': self.id } - - -def _set_junction(name: str, cs: ChangeSet) -> DbChangeSet: - old = Junction(get_junction(name, cs.operations[0]['id'])) +def _set_junction(name: str, cs: ChangeSet) -> DatabaseCommand: raw_new = get_junction(name, cs.operations[0]['id']) new_dict = cs.operations[0] @@ -74,16 +104,12 @@ def _set_junction(name: str, cs: ChangeSet) -> DbChangeSet: raw_new[key] = new_dict[key] new = Junction(raw_new) - redo_sql = f"update junctions set elevation = {new.f_elevation} where id = {new.f_id};" - redo_sql += f"\n{sql_update_coord(new.id, new.x, new.y)}" + statement = f"update network.junctions set elevation = {new.f_elevation} where node_id = {new.f_id};" + statement += f"\n{sql_update_coord(new.id, new.x, new.y)}" - undo_sql = sql_update_coord(old.id, old.x, old.y) - undo_sql += f"\nupdate junctions set elevation = {old.f_elevation} where id = {old.f_id};" + change = g_update_prefix | new.as_dict() - redo_cs = g_update_prefix | new.as_dict() - undo_cs = g_update_prefix | old.as_dict() - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def set_junction(name: str, cs: ChangeSet) -> ChangeSet: @@ -94,21 +120,16 @@ def set_junction(name: str, cs: ChangeSet) -> ChangeSet: return execute_command(name, _set_junction(name, cs)) -def _add_junction(name: str, cs: ChangeSet) -> DbChangeSet: +def _add_junction(name: str, cs: ChangeSet) -> DatabaseCommand: new = Junction(cs.operations[0]) - redo_sql = f"insert into _node (id, type) values ({new.f_id}, {new.f_type});" - redo_sql += f"\ninsert into junctions (id, elevation) values ({new.f_id}, {new.f_elevation});" - redo_sql += f"\n{sql_insert_coord(new.id, new.x, new.y)}" + statement = f"insert into network.nodes (id, node_type) values ({new.f_id}, {new.f_type});" + statement += f"\ninsert into network.junctions (node_id, elevation) values ({new.f_id}, {new.f_elevation});" + statement += f"\n{sql_insert_coord(new.id, new.x, new.y)}" - undo_sql = sql_delete_coord(new.id) - undo_sql += f"\ndelete from junctions where id = {new.f_id};" - undo_sql += f"\ndelete from _node where id = {new.f_id};" + change = g_add_prefix | new.as_dict() - redo_cs = g_add_prefix | new.as_dict() - undo_cs = g_delete_prefix | new.as_id_dict() - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def add_junction(name: str, cs: ChangeSet) -> ChangeSet: @@ -119,21 +140,16 @@ def add_junction(name: str, cs: ChangeSet) -> ChangeSet: return execute_command(name, _add_junction(name, cs)) -def _delete_junction(name: str, cs: ChangeSet) -> DbChangeSet: - old = Junction(get_junction(name, cs.operations[0]['id'])) +def _delete_junction(name: str, cs: ChangeSet) -> DatabaseCommand: + element_id = str(cs.operations[0]['id']) + f_id = sql_literal(element_id) - redo_sql = sql_delete_coord(old.id) - redo_sql += f"\ndelete from junctions where id = {old.f_id};" - redo_sql += f"\ndelete from _node where id = {old.f_id};" + statement = sql_delete_coord(element_id) + statement += f"\ndelete from network.nodes where id = {f_id};" - undo_sql = f"insert into _node (id, type) values ({old.f_id}, {old.f_type});" - undo_sql += f"\ninsert into junctions (id, elevation) values ({old.f_id}, {old.f_elevation});" - undo_sql += f"\n{sql_insert_coord(old.id, old.x, old.y)}" + change = g_delete_prefix | {'type': 'junction', 'id': element_id} - redo_cs = g_delete_prefix | old.as_id_dict() - undo_cs = g_add_prefix | old.as_dict() - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def delete_junction(name: str, cs: ChangeSet) -> ChangeSet: @@ -170,19 +186,18 @@ def inp_in_junction(line: str, demand_outside: bool) -> str: elevation = float(tokens[1]) demand = float(tokens[2]) if num_without_desc >= 3 and tokens[2] != '*' else None pattern = str(tokens[3]) if num_without_desc >= 4 and tokens[3] != '*' else None - pattern = f"'{pattern}'" if pattern != None else 'null' desc = str(tokens[-1]) if has_desc else None - sql = f"insert into _node (id, type) values ('{id}', 'junction');insert into junctions (id, elevation) values ('{id}', {elevation});" + sql = f"insert into network.nodes (id, node_type) values ({sql_literal(id)}, 'junction');insert into network.junctions (node_id, elevation) values ({sql_literal(id)}, {sql_literal(elevation)});" if demand != None and demand_outside == False: - sql += f"insert into demands (junction, demand, pattern) values ('{id}', {demand}, {pattern});" + sql += f"insert into network.demands (junction_id, sequence_no, base_demand, pattern_id) values ({sql_literal(id)}, 0, {sql_literal(demand)}, {sql_literal(pattern)});" return str(sql) def inp_out_junction(name: str) -> list[str]: lines = [] - objs = read_all(name, 'select * from junctions') + objs = read_all(name, 'select node_id as id, elevation from network.junctions order by node_id') for obj in objs: id = obj['id'] elev = obj['elevation'] diff --git a/app/native/wndb/s20_mixing.py b/app/native/wndb/model/mixing.py similarity index 61% rename from app/native/wndb/s20_mixing.py rename to app/native/wndb/model/mixing.py index 5bef359..de1b113 100644 --- a/app/native/wndb/s20_mixing.py +++ b/app/native/wndb/model/mixing.py @@ -1,5 +1,16 @@ -from .database import * -from .s0_base import * +from typing import Any + +from ..core.database import ( + ChangeSet, + DatabaseCommand, + execute_command, + g_add_prefix, + g_delete_prefix, + g_update_prefix, + read_all, + sql_literal, + try_read, +) MIXING_MODEL_MIXED = 'MIXED' MIXING_MODEL_2COMP = '2COMP' @@ -13,7 +24,7 @@ def get_mixing_schema(name: str) -> dict[str, dict[str, Any]]: def get_mixing(name: str, tank: str) -> dict[str, Any]: - m = try_read(name, f"select * from mixing where tank = '{tank}'") + m = try_read(name, "select tank_id as tank, model, value from network.tank_mixing where tank_id = %s", (tank,)) if m == None: return {} d = {} @@ -30,20 +41,15 @@ class Mixing(object): self.model = str(input['model']) self.value = float(input['value']) if 'value' in input and input['value'] != None else None - self.f_type = f"'{self.type}'" - self.f_tank = f"'{self.tank}'" - self.f_model = f"'{self.model}'" - self.f_value = self.value if self.value != None else 'null' + self.f_type = sql_literal(self.type) + self.f_tank = sql_literal(self.tank) + self.f_model = sql_literal(self.model) + self.f_value = sql_literal(self.value) def as_dict(self) -> dict[str, Any]: return { 'type': self.type, 'tank': self.tank, 'model': self.model, 'value': self.value } - def as_id_dict(self) -> dict[str, Any]: - return { 'type': self.type, 'tank': self.tank } - - -def _set_mixing(name: str, cs: ChangeSet) -> DbChangeSet: - old = Mixing(get_mixing(name, cs.operations[0]['tank'])) +def _set_mixing(name: str, cs: ChangeSet) -> DatabaseCommand: raw_new = get_mixing(name, cs.operations[0]['tank']) new_dict = cs.operations[0] @@ -53,13 +59,11 @@ def _set_mixing(name: str, cs: ChangeSet) -> DbChangeSet: raw_new[key] = new_dict[key] new = Mixing(raw_new) - redo_sql = f"update mixing set model = {new.f_model}, value = {new.f_value} where tank = {new.f_tank};" - undo_sql = f"update mixing set model = {old.f_model}, value = {old.f_value} where tank = {old.f_tank};" + statement = f"update network.tank_mixing set model = {new.f_model}, value = {new.f_value} where tank_id = {new.f_tank};" - redo_cs = g_update_prefix | new.as_dict() - undo_cs = g_update_prefix | old.as_dict() + change = g_update_prefix | new.as_dict() - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def set_mixing(name: str, cs: ChangeSet) -> ChangeSet: @@ -70,16 +74,14 @@ def set_mixing(name: str, cs: ChangeSet) -> ChangeSet: return execute_command(name, _set_mixing(name, cs)) -def _add_mixing(name: str, cs: ChangeSet) -> DbChangeSet: +def _add_mixing(name: str, cs: ChangeSet) -> DatabaseCommand: new = Mixing(cs.operations[0]) - redo_sql = f"insert into mixing (tank, model, value) values ({new.f_tank}, {new.f_model}, {new.f_value});" - undo_sql = f"delete from mixing where tank = {new.f_tank};" + statement = f"insert into network.tank_mixing (tank_id, model, value) values ({new.f_tank}, {new.f_model}, {new.f_value});" - redo_cs = g_add_prefix | new.as_dict() - undo_cs = g_delete_prefix | new.as_id_dict() + change = g_add_prefix | new.as_dict() - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def add_mixing(name: str, cs: ChangeSet) -> ChangeSet: @@ -90,16 +92,15 @@ def add_mixing(name: str, cs: ChangeSet) -> ChangeSet: return execute_command(name, _add_mixing(name, cs)) -def _delete_mixing(name: str, cs: ChangeSet) -> DbChangeSet: - old = Mixing(get_mixing(name, cs.operations[0]['tank'])) +def _delete_mixing(name: str, cs: ChangeSet) -> DatabaseCommand: + tank = str(cs.operations[0]['tank']) + f_tank = sql_literal(tank) - redo_sql = f"delete from mixing where tank = {old.f_tank};" - undo_sql = f"insert into mixing (tank, model, value) values ({old.f_tank}, {old.f_model}, {old.f_value});" + statement = f"delete from network.tank_mixing where tank_id = {f_tank};" - redo_cs = g_delete_prefix | old.as_id_dict() - undo_cs = g_add_prefix | old.as_dict() + change = g_delete_prefix | {'type': 'mixing', 'tank': tank} - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def delete_mixing(name: str, cs: ChangeSet) -> ChangeSet: @@ -127,14 +128,12 @@ def inp_in_mixing(line: str) -> str: tank = str(tokens[0]) model = str(tokens[1].upper()) value = float(tokens[3]) if num_without_desc >= 4 else None - value = value if value != None else 'null' - - return str(f"insert into mixing (tank, model, value) values ('{tank}', '{model}', {value});") + return str(f"insert into network.tank_mixing (tank_id, model, value) values ({sql_literal(tank)}, {sql_literal(model)}, {sql_literal(value)});") def inp_out_mixing(name: str) -> list[str]: lines = [] - objs = read_all(name, 'select * from mixing') + objs = read_all(name, 'select tank_id as tank, model, value from network.tank_mixing order by tank_id') for obj in objs: tank = obj['tank'] model = obj['model'] @@ -144,7 +143,7 @@ def inp_out_mixing(name: str) -> list[str]: def delete_mixing_by_tank(name: str, tank: str) -> ChangeSet: - row = try_read(name, f"select * from mixing where tank = '{tank}'") + row = try_read(name, "select 1 from network.tank_mixing where tank_id = %s", (tank,)) if row == None: return ChangeSet() return ChangeSet(g_delete_prefix | {'type' : 'mixing', 'tank': tank}) diff --git a/app/native/wndb/s23_options_util.py b/app/native/wndb/model/options.py similarity index 88% rename from app/native/wndb/s23_options_util.py rename to app/native/wndb/model/options.py index f273125..b08306f 100644 --- a/app/native/wndb/s23_options_util.py +++ b/app/native/wndb/model/options.py @@ -1,4 +1,13 @@ -from .database import * +from typing import Any + +from ..core.database import ( + ChangeSet, + DatabaseCommand, + execute_command, + g_update_prefix, + read_all, + sql_literal, +) #-------------------------------------------------------------- @@ -99,45 +108,32 @@ def get_option_schema(name: str) -> dict[str, dict[str, Any]]: def get_option(name: str) -> dict[str, Any]: - ts = read_all(name, f"select * from options") + ts = read_all(name, "select key, value from network.simulation_settings where engine_version = 'legacy'") d = {} for e in ts: d[e['key']] = str(e['value']) return d -def _set_option(name: str, cs: ChangeSet) -> DbChangeSet: - raw_old = get_option(name) - - old = {} +def _set_option(name: str, cs: ChangeSet) -> DatabaseCommand: new = {} new_dict = cs.operations[0] schema = get_option_schema(name) for key in schema.keys(): if key in new_dict: - old[key] = str(raw_old[key]) new[key] = str(new_dict[key]) - redo_cs = g_update_prefix | { 'type' : 'option' } + change = g_update_prefix | { 'type' : 'option' } - redo_sql = '' + statement = '' for key, value in new.items(): - if redo_sql != '': - redo_sql += '\n' - redo_sql += f"update options set value = '{value}' where key = '{key}';" - redo_cs |= { key: value } + if statement != '': + statement += '\n' + statement += f"update network.simulation_settings set value = {sql_literal(value)} where engine_version = 'legacy' and key = {sql_literal(key)};" + change |= { key: value } - undo_cs = g_update_prefix | { 'type' : 'option' } - - undo_sql = '' - for key, value in old.items(): - if undo_sql != '': - undo_sql += '\n' - undo_sql += f"update options set value = '{value}' where key = '{key}';" - undo_cs |= { key: value } - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def set_option(name: str, cs: ChangeSet) -> ChangeSet: @@ -223,45 +219,32 @@ def get_option_v3_schema(name: str) -> dict[str, dict[str, Any]]: def get_option_v3(name: str) -> dict[str, Any]: - ts = read_all(name, f"select * from options_v3") + ts = read_all(name, "select key, value from network.simulation_settings where engine_version = 'v3'") d = {} for e in ts: d[e['key']] = str(e['value']) return d -def _set_option_v3(name: str, cs: ChangeSet) -> DbChangeSet: - raw_old = get_option_v3(name) - - old = {} +def _set_option_v3(name: str, cs: ChangeSet) -> DatabaseCommand: new = {} new_dict = cs.operations[0] schema = get_option_v3_schema(name) for key in schema.keys(): if key in new_dict: - old[key] = str(raw_old[key]) new[key] = str(new_dict[key]) - redo_cs = g_update_prefix | { 'type' : 'option_v3' } + change = g_update_prefix | { 'type' : 'option_v3' } - redo_sql = '' + statement = '' for key, value in new.items(): - if redo_sql != '': - redo_sql += '\n' - redo_sql += f"update options_v3 set value = '{value}' where key = '{key}';" - redo_cs |= { key: value } + if statement != '': + statement += '\n' + statement += f"update network.simulation_settings set value = {sql_literal(value)} where engine_version = 'v3' and key = {sql_literal(key)};" + change |= { key: value } - undo_cs = g_update_prefix | { 'type' : 'option_v3' } - - undo_sql = '' - for key, value in old.items(): - if undo_sql != '': - undo_sql += '\n' - undo_sql += f"update options_v3 set value = '{value}' where key = '{key}';" - undo_cs |= { key: value } - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def set_option_v3(name: str, cs: ChangeSet) -> ChangeSet: @@ -398,4 +381,3 @@ def generate_v3(cs: ChangeSet) -> ChangeSet: return ChangeSet(cs_v3) return ChangeSet() - diff --git a/app/native/wndb/s23_options.py b/app/native/wndb/model/options_legacy.py similarity index 79% rename from app/native/wndb/s23_options.py rename to app/native/wndb/model/options_legacy.py index f1a372c..253c416 100644 --- a/app/native/wndb/s23_options.py +++ b/app/native/wndb/model/options_legacy.py @@ -1,81 +1,83 @@ -from .database import * -from .s23_options_util import get_option_schema, generate_v3 - - -def _inp_in_option(section: list[str]) -> ChangeSet: - if len(section) <= 0: - return ChangeSet() - - cs = g_update_prefix | { 'type' : 'option' } - for s in section: - if s.startswith(';'): - continue - - tokens = s.strip().split() - if tokens[0].upper() == 'PATTERN': # can not upper id - value = tokens[1] if len(tokens) > 1 else '' - cs |= { 'PATTERN' : value } - elif tokens[0].upper() == 'QUALITY': # can not upper trace node - value = tokens[1] if len(tokens) > 1 else '' - if len(tokens) > 2: - value += f' {tokens[2]}' - cs |= { 'QUALITY' : value } - else: - line = s.upper().strip() - for key in get_option_schema('').keys(): - if line.startswith(key): - value = line.removeprefix(key).strip() - cs |= { key : value } - - result = ChangeSet(cs) - result.merge(generate_v3(result)) - return result - - -def inp_in_option(section: list[str]) -> str: - sql = '' - result = _inp_in_option(section) - for op in result.operations: - for key in op.keys(): - if key == 'operation' or key == 'type': - continue - if op['type'] == 'option': - sql += f"update options set value = '{op[key]}' where key = '{key}';" - else: - sql += f"update options_v3 set value = '{op[key]}' where key = '{key}';" - return sql - - -def inp_out_option(name: str) -> list[str]: - lines = [] - objs = read_all(name, f"select * from options") - - is_dda = False - - for obj in objs: - if obj['key'] == 'DEMAND MODEL': - is_dda = obj['value'] == 'DDA' - - dda_ignore = [ - 'HEADERROR', # TODO: default is 0 which is conflict with PDA - 'FLOWCHANGE', # TODO: default is 0 which is conflict with PDA - 'MINIMUM PRESSURE', - 'REQUIRED PRESSURE', - 'PRESSURE EXPONENT' - ] - - for obj in objs: - key = obj['key'] - # why write this ? - if key == 'PRESSURE': - continue - # release version does not support new keys and has error message - if key == 'HTOL' or key == 'QTOL' or key == 'RQTOL': - continue - # ignore some weird settings for DDA - if is_dda and key in dda_ignore: - continue - value = obj['value'] - if str(value).strip() != '': - lines.append(f'{key} {value}') - return lines +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: + if len(section) <= 0: + return ChangeSet() + + cs = g_update_prefix | { 'type' : 'option' } + for s in section: + if s.startswith(';'): + continue + + tokens = s.strip().split() + if tokens[0].upper() == 'PATTERN': # can not upper id + value = tokens[1] if len(tokens) > 1 else '' + cs |= { 'PATTERN' : value } + elif tokens[0].upper() == 'QUALITY': # can not upper trace node + value = tokens[1] if len(tokens) > 1 else '' + if len(tokens) > 2: + value += f' {tokens[2]}' + cs |= { 'QUALITY' : value } + else: + line = s.upper().strip() + for key in get_option_schema('').keys(): + if line.startswith(key): + value = line.removeprefix(key).strip() + cs |= { key : value } + + result = ChangeSet(cs) + result.merge(generate_v3(result)) + return result + + +def inp_in_option(section: list[str]) -> str: + sql = '' + result = _inp_in_option(section) + for op in result.operations: + for key in op.keys(): + if key == 'operation' or key == 'type': + continue + if op['type'] == 'option': + sql += f"update network.simulation_settings set value = {sql_literal(op[key])} where engine_version = 'legacy' and key = {sql_literal(key)};" + else: + sql += f"update network.simulation_settings set value = {sql_literal(op[key])} where engine_version = 'v3' and key = {sql_literal(key)};" + return sql + + +def inp_out_option(name: str) -> list[str]: + lines = [] + objs = read_all(name, "select key, value from network.simulation_settings where engine_version = 'legacy' order by key") + + is_dda = False + + for obj in objs: + if obj['key'] == 'DEMAND MODEL': + is_dda = obj['value'] == 'DDA' + + dda_ignore = [ + 'HEADERROR', # TODO: default is 0 which is conflict with PDA + 'FLOWCHANGE', # TODO: default is 0 which is conflict with PDA + 'MINIMUM PRESSURE', + 'REQUIRED PRESSURE', + 'PRESSURE EXPONENT' + ] + + for obj in objs: + key = obj['key'] + # why write this ? + if key == 'PRESSURE': + continue + # release version does not support new keys and has error message + if key == 'HTOL' or key == 'QTOL' or key == 'RQTOL': + continue + # ignore some weird settings for DDA + if is_dda and key in dda_ignore: + continue + value = obj['value'] + if str(value).strip() != '': + lines.append(f'{key} {value}') + return lines diff --git a/app/native/wndb/s23_options_v3.py b/app/native/wndb/model/options_v3.py similarity index 77% rename from app/native/wndb/s23_options_v3.py rename to app/native/wndb/model/options_v3.py index 0a7738b..ecf84b7 100644 --- a/app/native/wndb/s23_options_v3.py +++ b/app/native/wndb/model/options_v3.py @@ -1,5 +1,7 @@ -from .database import * -from .s23_options_util import get_option_schema, get_option_v3_schema, generate_v2, generate_v3 +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 def _parse_v2(v2_lines: list[str]) -> dict[str, str]: @@ -62,15 +64,15 @@ def inp_in_option_v3(section: list[str]) -> str: if key == 'operation' or key == 'type': continue if op['type'] == 'option_v3': - sql += f"update options_v3 set value = '{op[key]}' where key = '{key}';" + sql += f"update network.simulation_settings set value = {sql_literal(op[key])} where engine_version = 'v3' and key = {sql_literal(key)};" else: - sql += f"update options set value = '{op[key]}' where key = '{key}';" + sql += f"update network.simulation_settings set value = {sql_literal(op[key])} where engine_version = 'legacy' and key = {sql_literal(key)};" return sql def inp_out_option_v3(name: str) -> list[str]: lines = [] - objs = read_all(name, f"select * from options_v3") + objs = read_all(name, "select key, value from network.simulation_settings where engine_version = 'v3' order by key") for obj in objs: key = obj['key'] value = obj['value'] diff --git a/app/native/wndb/s11_patterns.py b/app/native/wndb/model/patterns.py similarity index 53% rename from app/native/wndb/s11_patterns.py rename to app/native/wndb/model/patterns.py index 382d554..ffe8f70 100644 --- a/app/native/wndb/s11_patterns.py +++ b/app/native/wndb/model/patterns.py @@ -1,4 +1,18 @@ -from .database import * +from typing import Any + +from psycopg import sql + +from ..core.database import ( + ChangeSet, + DatabaseCommand, + execute_command, + g_add_prefix, + g_delete_prefix, + g_update_prefix, + read_all, + sql_literal, + try_read, +) PATTERN_V3_TYPE_FIXED = 'FIXED' PATTERN_V3_TYPE_VARIABLE = 'VARIABLE' @@ -11,19 +25,19 @@ def get_pattern_schema(name: str) -> dict[str, dict[str, Any]]: def get_pattern(name: str, id: str) -> dict[str, Any]: - p_one = try_read(name, f"select * from _pattern where id = '{id}'") + p_one = try_read(name, "select id from network.patterns where id = %s", (id,)) if p_one == None: return {} - pas = read_all(name, f"select * from patterns where id = '{id}' order by _order") + pas = read_all(name, "select factor from network.pattern_values where pattern_id = %s order by sequence_no", (id,)) ps = [] for r in pas: ps.append(float(r['factor'])) return { 'id': id, 'factors': ps } -def _set_pattern(name: str, cs: ChangeSet) -> DbChangeSet: +def _set_pattern(name: str, cs: ChangeSet) -> DatabaseCommand: id = cs.operations[0]['id'] - f_id = f"'{id}'" + f_id = sql_literal(id) old = get_pattern(name, id) @@ -33,19 +47,14 @@ def _set_pattern(name: str, cs: ChangeSet) -> DbChangeSet: else: new['factors'] = old['factors'] - # TODO: transaction ? - redo_sql = f"delete from patterns where id = {f_id};" - for f_factor in new['factors']: - redo_sql += f"\ninsert into patterns (id, factor) values ({f_id}, {f_factor});" + statement = f"delete from network.pattern_values where pattern_id = {f_id};" + for sequence_no, factor in enumerate(new['factors']): + f_factor = sql_literal(factor) + statement += f"\ninsert into network.pattern_values (pattern_id, sequence_no, factor) values ({f_id}, {sequence_no}, {f_factor});" - undo_sql = f"delete from patterns where id = {f_id};" - for f_factor in old['factors']: - undo_sql += f"\ninsert into patterns (id, factor) values ({f_id}, {f_factor});" + change = g_update_prefix | { 'type': 'pattern' } | new - redo_cs = g_update_prefix | { 'type': 'pattern' } | new - undo_cs = g_update_prefix | { 'type': 'pattern' } | old - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def set_pattern(name: str, cs: ChangeSet) -> ChangeSet: @@ -56,24 +65,20 @@ def set_pattern(name: str, cs: ChangeSet) -> ChangeSet: return execute_command(name, _set_pattern(name, cs)) -def _add_pattern(name: str, cs: ChangeSet) -> DbChangeSet: +def _add_pattern(name: str, cs: ChangeSet) -> DatabaseCommand: id = cs.operations[0]['id'] - f_id = f"'{id}'" + f_id = sql_literal(id) new = { 'id': id, 'factors': cs.operations[0]['factors'] } - # TODO: transaction ? - redo_sql = f"insert into _pattern (id) values ({f_id});" - for f_factor in new['factors']: - redo_sql += f"\ninsert into patterns (id, factor) values ({f_id}, {f_factor});" + statement = f"insert into network.patterns (id) values ({f_id});" + for sequence_no, factor in enumerate(new['factors']): + f_factor = sql_literal(factor) + statement += f"\ninsert into network.pattern_values (pattern_id, sequence_no, factor) values ({f_id}, {sequence_no}, {f_factor});" - undo_sql = f"delete from patterns where id = {f_id};" - undo_sql += f"\ndelete from _pattern where id = {f_id};" + change = g_add_prefix | { 'type': 'pattern' } | new - redo_cs = g_add_prefix | { 'type': 'pattern' } | new - undo_cs = g_delete_prefix | { 'type': 'pattern' } | { 'id': id } - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def add_pattern(name: str, cs: ChangeSet) -> ChangeSet: @@ -84,24 +89,15 @@ def add_pattern(name: str, cs: ChangeSet) -> ChangeSet: return execute_command(name, _add_pattern(name, cs)) -def _delete_pattern(name: str, cs: ChangeSet) -> DbChangeSet: +def _delete_pattern(name: str, cs: ChangeSet) -> DatabaseCommand: id = cs.operations[0]['id'] - f_id = f"'{id}'" + f_id = sql_literal(id) - old = get_pattern(name, id) + statement = f"delete from network.patterns where id = {f_id};" - redo_sql = f"delete from patterns where id = {f_id};" - redo_sql += f"\ndelete from _pattern where id = {f_id};" + change = g_delete_prefix | { 'type': 'pattern' } | { 'id': id } - # TODO: transaction ? - undo_sql = f"insert into _pattern (id) values ({f_id});" - for f_factor in old['factors']: - undo_sql += f"\ninsert into patterns (id, factor) values ({f_id}, {f_factor});" - - redo_cs = g_delete_prefix | { 'type': 'pattern' } | { 'id': id } - undo_cs = g_add_prefix | { 'type': 'pattern' } | old - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def delete_pattern(name: str, cs: ChangeSet) -> ChangeSet: @@ -129,18 +125,21 @@ def delete_pattern(name: str, cs: ChangeSet) -> ChangeSet: def inp_in_pattern(line: str, fixed: bool = True) -> str: tokens = line.split() sql = '' + pattern_id = sql_literal(tokens[0]) if fixed: for token in tokens[1:]: - sql += f"insert into patterns (id, factor) values ('{tokens[0]}', {float(token)});" + factor = sql_literal(float(token)) + sql += f"insert into network.pattern_values (pattern_id, sequence_no, factor) values ({pattern_id}, (select coalesce(max(sequence_no) + 1, 0) from network.pattern_values where pattern_id = {pattern_id}), {factor});" else: for token in tokens[1::2]: - sql += f"insert into patterns (id, factor) values ('{tokens[0]}', {float(token)});" + factor = sql_literal(float(token)) + sql += f"insert into network.pattern_values (pattern_id, sequence_no, factor) values ({pattern_id}, (select coalesce(max(sequence_no) + 1, 0) from network.pattern_values where pattern_id = {pattern_id}), {factor});" return sql def inp_out_pattern(name: str) -> list[str]: lines = [] - objs = read_all(name, f"select * from patterns order by _order") + objs = read_all(name, "select pattern_id as id, factor from network.pattern_values order by pattern_id, sequence_no") for obj in objs: id = obj['id'] factor = obj['factor'] @@ -150,7 +149,7 @@ def inp_out_pattern(name: str) -> list[str]: def inp_out_pattern_v3(name: str) -> list[str]: lines = [] - objs = read_all(name, f"select * from patterns order by _order") + objs = read_all(name, "select pattern_id as id, factor from network.pattern_values order by pattern_id, sequence_no") ids = [] for obj in objs: id = obj['id'] diff --git a/app/native/wndb/s5_pipes.py b/app/native/wndb/model/pipes.py similarity index 64% rename from app/native/wndb/s5_pipes.py rename to app/native/wndb/model/pipes.py index 91b50cc..ae45e30 100644 --- a/app/native/wndb/s5_pipes.py +++ b/app/native/wndb/model/pipes.py @@ -1,5 +1,16 @@ -from .database import * -from .s0_base import * +from typing import Any + +from ..core.database import ( + ChangeSet, + DatabaseCommand, + execute_command, + g_add_prefix, + g_delete_prefix, + g_update_prefix, + read_all, + sql_literal, + try_read, +) PIPE_STATUS_OPEN = 'OPEN' @@ -19,7 +30,7 @@ def get_pipe_schema(name: str) -> dict[str, dict[str, Any]]: def get_pipe(name: str, id: str) -> dict[str, Any]: - p = try_read(name, f"select * from pipes where id = '{id}'") + p = try_read(name, "select l.id, l.start_node_id as node1, l.end_node_id as node2, p.length, p.diameter, p.roughness, p.minor_loss, p.status from network.links l join network.pipes p on p.link_id = l.id where l.id = %s", (id,)) if p == None: return {} d = {} @@ -35,7 +46,11 @@ def get_pipe(name: str, id: str) -> dict[str, Any]: # DingZQ, 2025-03-29 def get_all_pipes(name: str) -> list[dict[str, Any]]: - rows = read_all(name, f"select * from pipes") + rows = read_all( + name, + "SELECT id, start_node_id AS node1, end_node_id AS node2, length, " + "diameter, roughness, minor_loss, status FROM gis.pipes ORDER BY id", + ) if rows == None: return [] @@ -72,7 +87,11 @@ def get_pipes_by_property( 'status', ] - rows = read_all(name, "select * from pipes") + rows = read_all( + name, + "SELECT id, start_node_id AS node1, end_node_id AS node2, length, " + "diameter, roughness, minor_loss, status FROM gis.pipes ORDER BY id", + ) if rows == None: return [] @@ -112,25 +131,20 @@ class Pipe(object): self.minor_loss = float(input['minor_loss']) self.status = str(input['status']) - self.f_type = f"'{self.type}'" - self.f_id = f"'{self.id}'" - self.f_node1 = f"'{self.node1}'" - self.f_node2 = f"'{self.node2}'" - self.f_length = self.length - self.f_diameter = self.diameter - self.f_roughness = self.roughness - self.f_minor_loss = self.minor_loss - self.f_status = f"'{self.status}'" + self.f_type = sql_literal(self.type) + self.f_id = sql_literal(self.id) + self.f_node1 = sql_literal(self.node1) + self.f_node2 = sql_literal(self.node2) + self.f_length = sql_literal(self.length) + self.f_diameter = sql_literal(self.diameter) + self.f_roughness = sql_literal(self.roughness) + self.f_minor_loss = sql_literal(self.minor_loss) + self.f_status = sql_literal(self.status) 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 as_id_dict(self) -> dict[str, Any]: - return { 'type': self.type, 'id': self.id } - - -def _set_pipe(name: str, cs: ChangeSet) -> DbChangeSet: - old = Pipe(get_pipe(name, cs.operations[0]['id'])) +def _set_pipe(name: str, cs: ChangeSet) -> DatabaseCommand: raw_new = get_pipe(name, cs.operations[0]['id']) new_dict = cs.operations[0] @@ -140,13 +154,11 @@ def _set_pipe(name: str, cs: ChangeSet) -> DbChangeSet: raw_new[key] = new_dict[key] new = Pipe(raw_new) - redo_sql = f"update pipes set node1 = {new.f_node1}, node2 = {new.f_node2}, length = {new.f_length}, diameter = {new.f_diameter}, roughness = {new.f_roughness}, minor_loss = {new.f_minor_loss}, status = {new.f_status} where id = {new.f_id};" - undo_sql = f"update pipes set node1 = {old.f_node1}, node2 = {old.f_node2}, length = {old.f_length}, diameter = {old.f_diameter}, roughness = {old.f_roughness}, minor_loss = {old.f_minor_loss}, status = {old.f_status} where id = {old.f_id};" + 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};" + change = g_update_prefix | new.as_dict() - redo_cs = g_update_prefix | new.as_dict() - undo_cs = g_update_prefix | old.as_dict() - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def set_pipe(name: str, cs: ChangeSet) -> ChangeSet: @@ -157,19 +169,15 @@ def set_pipe(name: str, cs: ChangeSet) -> ChangeSet: return execute_command(name, _set_pipe(name, cs)) -def _add_pipe(name: str, cs: ChangeSet) -> DbChangeSet: +def _add_pipe(name: str, cs: ChangeSet) -> DatabaseCommand: new = Pipe(cs.operations[0]) - redo_sql = f"insert into _link (id, type) values ({new.f_id}, {new.f_type});" - redo_sql += f"\ninsert into pipes (id, node1, node2, length, diameter, roughness, minor_loss, status) values ({new.f_id}, {new.f_node1}, {new.f_node2}, {new.f_length}, {new.f_diameter}, {new.f_roughness}, {new.f_minor_loss}, {new.f_status});" + statement = f"insert into network.links (id, link_type, start_node_id, end_node_id) values ({new.f_id}, {new.f_type}, {new.f_node1}, {new.f_node2});" + statement += f"\ninsert into network.pipes (link_id, length, diameter, roughness, minor_loss, status) values ({new.f_id}, {new.f_length}, {new.f_diameter}, {new.f_roughness}, {new.f_minor_loss}, {new.f_status});" - undo_sql = f"delete from pipes where id = {new.f_id};" - undo_sql += f"\ndelete from _link where id = {new.f_id};" + change = g_add_prefix | new.as_dict() - redo_cs = g_add_prefix | new.as_dict() - undo_cs = g_delete_prefix | new.as_id_dict() - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def add_pipe(name: str, cs: ChangeSet) -> ChangeSet: @@ -180,19 +188,15 @@ def add_pipe(name: str, cs: ChangeSet) -> ChangeSet: return execute_command(name, _add_pipe(name, cs)) -def _delete_pipe(name: str, cs: ChangeSet) -> DbChangeSet: - old = Pipe(get_pipe(name, cs.operations[0]['id'])) +def _delete_pipe(name: str, cs: ChangeSet) -> DatabaseCommand: + element_id = str(cs.operations[0]['id']) + f_id = sql_literal(element_id) - redo_sql = f"delete from pipes where id = {old.f_id};" - redo_sql += f"\ndelete from _link where id = {old.f_id};" + statement = f"delete from network.links where id = {f_id};" - undo_sql = f"insert into _link (id, type) values ({old.f_id}, {old.f_type});" - undo_sql += f"\ninsert into pipes (id, node1, node2, length, diameter, roughness, minor_loss, status) values ({old.f_id}, {old.f_node1}, {old.f_node2}, {old.f_length}, {old.f_diameter}, {old.f_roughness}, {old.f_minor_loss}, {old.f_status});" + change = g_delete_prefix | {'type': 'pipe', 'id': element_id} - redo_cs = g_delete_prefix | old.as_id_dict() - undo_cs = g_add_prefix | old.as_dict() - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def delete_pipe(name: str, cs: ChangeSet) -> ChangeSet: @@ -230,12 +234,12 @@ def inp_in_pipe(line: str) -> str: status = str(tokens[7].upper()) if num_without_desc >= 8 else PIPE_STATUS_OPEN desc = str(tokens[-1]) if has_desc else None - return str(f"insert into _link (id, type) values ('{id}', 'pipe');insert into pipes (id, node1, node2, length, diameter, roughness, minor_loss, status) values ('{id}', '{node1}', '{node2}', {length}, {diameter}, {roughness}, {minor_loss}, '{status}');") + return str(f"insert into network.links (id, link_type, start_node_id, end_node_id) values ({sql_literal(id)}, 'pipe', {sql_literal(node1)}, {sql_literal(node2)});insert into network.pipes (link_id, length, diameter, roughness, minor_loss, status) values ({sql_literal(id)}, {sql_literal(length)}, {sql_literal(diameter)}, {sql_literal(roughness)}, {sql_literal(minor_loss)}, {sql_literal(status)});") def inp_out_pipe(name: str) -> list[str]: lines = [] - objs = read_all(name, 'select * from pipes') + objs = read_all(name, 'select l.id, l.start_node_id as node1, l.end_node_id as node2, p.length, p.diameter, p.roughness, p.minor_loss, p.status from network.links l join network.pipes p on p.link_id = l.id order by l.id') for obj in objs: id = obj['id'] node1 = obj['node1'] @@ -248,13 +252,3 @@ def inp_out_pipe(name: str) -> list[str]: desc = ';' lines.append(f'{id} {node1} {node2} {length} {diameter} {roughness} {minor_loss} {status} {desc}') return lines - - -'''def delete_pipe_by_node(name: str, node: str) -> ChangeSet: - cs = ChangeSet() - - rows = read_all(name, f"select id from pipes where node1 = '{node}' or node2 = '{node}'") - for row in rows: - cs.append(g_delete_prefix | {'type': 'pipe', 'id': row['id']}) - - return cs''' diff --git a/app/native/wndb/s6_pumps.py b/app/native/wndb/model/pumps.py similarity index 62% rename from app/native/wndb/s6_pumps.py rename to app/native/wndb/model/pumps.py index 8ac4622..fb2657c 100644 --- a/app/native/wndb/s6_pumps.py +++ b/app/native/wndb/model/pumps.py @@ -1,5 +1,16 @@ -from .database import * -from .s0_base import * +from typing import Any + +from ..core.database import ( + ChangeSet, + DatabaseCommand, + execute_command, + g_add_prefix, + g_delete_prefix, + g_update_prefix, + read_all, + sql_literal, + try_read, +) def get_pump_schema(name: str) -> dict[str, dict[str, Any]]: @@ -13,7 +24,7 @@ def get_pump_schema(name: str) -> dict[str, dict[str, Any]]: def get_pump(name: str, id: str) -> dict[str, Any]: - p = try_read(name, f"select * from pumps where id = '{id}'") + p = try_read(name, "select l.id, l.start_node_id as node1, l.end_node_id as node2, p.power, p.head_curve_id as head, p.speed, p.pattern_id as pattern from network.links l join network.pumps p on p.link_id = l.id where l.id = %s", (id,)) if p == None: return {} d = {} @@ -28,7 +39,12 @@ def get_pump(name: str, id: str) -> dict[str, Any]: # DingZQ, 2025-03-29 def get_all_pumps(name: str) -> list[dict[str, Any]]: - rows = read_all(name, f"select * from pumps") + rows = read_all( + name, + "SELECT id, start_node_id AS node1, end_node_id AS node2, power, " + "head_curve_id AS head, speed, pattern_id AS pattern " + "FROM gis.pumps ORDER BY id", + ) if rows == None: return [] @@ -59,24 +75,19 @@ class Pump(object): self.speed = float(input['speed']) if 'speed' in input and input['speed'] != None else None self.pattern = str(input['pattern']) if 'pattern' in input and input['pattern'] != None else None - self.f_type = f"'{self.type}'" - self.f_id = f"'{self.id}'" - self.f_node1 = f"'{self.node1}'" - self.f_node2 = f"'{self.node2}'" - self.f_power = self.power if self.power != None else 'null' - self.f_head = f"'{self.head}'" if self.head != None else 'null' - self.f_speed = self.speed if self.speed != None else 'null' - self.f_pattern = f"'{self.pattern}'" if self.pattern != None else 'null' + self.f_type = sql_literal(self.type) + self.f_id = sql_literal(self.id) + self.f_node1 = sql_literal(self.node1) + self.f_node2 = sql_literal(self.node2) + self.f_power = sql_literal(self.power) + self.f_head = sql_literal(self.head) + self.f_speed = sql_literal(self.speed) + self.f_pattern = sql_literal(self.pattern) 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 as_id_dict(self) -> dict[str, Any]: - return { 'type': self.type, 'id': self.id } - - -def _set_pump(name: str, cs: ChangeSet) -> DbChangeSet: - old = Pump(get_pump(name, cs.operations[0]['id'])) +def _set_pump(name: str, cs: ChangeSet) -> DatabaseCommand: raw_new = get_pump(name, cs.operations[0]['id']) new_dict = cs.operations[0] @@ -86,13 +97,11 @@ def _set_pump(name: str, cs: ChangeSet) -> DbChangeSet: raw_new[key] = new_dict[key] new = Pump(raw_new) - redo_sql = f"update pumps set node1 = {new.f_node1}, node2 = {new.f_node2}, power = {new.f_power}, head = {new.f_head}, speed = {new.f_speed}, pattern = {new.f_pattern} where id = {new.f_id};" - undo_sql = f"update pumps set node1 = {old.f_node1}, node2 = {old.f_node2}, power = {old.f_power}, head = {old.f_head}, speed = {old.f_speed}, pattern = {old.f_pattern} where id = {old.f_id};" + 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.pumps set power = {new.f_power}, head_curve_id = {new.f_head}, speed = {new.f_speed}, pattern_id = {new.f_pattern} where link_id = {new.f_id};" + change = g_update_prefix | new.as_dict() - redo_cs = g_update_prefix | new.as_dict() - undo_cs = g_update_prefix | old.as_dict() - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def set_pump(name: str, cs: ChangeSet) -> ChangeSet: @@ -103,19 +112,15 @@ def set_pump(name: str, cs: ChangeSet) -> ChangeSet: return execute_command(name, _set_pump(name, cs)) -def _add_pump(name: str, cs: ChangeSet) -> DbChangeSet: +def _add_pump(name: str, cs: ChangeSet) -> DatabaseCommand: new = Pump(cs.operations[0]) - redo_sql = f"insert into _link (id, type) values ({new.f_id}, {new.f_type});" - redo_sql += f"\ninsert into pumps (id, node1, node2, power, head, speed, pattern) values ({new.f_id}, {new.f_node1}, {new.f_node2}, {new.f_power}, {new.f_head}, {new.f_speed}, {new.f_pattern});" + statement = f"insert into network.links (id, link_type, start_node_id, end_node_id) values ({new.f_id}, {new.f_type}, {new.f_node1}, {new.f_node2});" + statement += f"\ninsert into network.pumps (link_id, power, head_curve_id, speed, pattern_id) values ({new.f_id}, {new.f_power}, {new.f_head}, {new.f_speed}, {new.f_pattern});" - undo_sql = f"delete from pumps where id = {new.f_id};" - undo_sql += f"\ndelete from _link where id = {new.f_id};" + change = g_add_prefix | new.as_dict() - redo_cs = g_add_prefix | new.as_dict() - undo_cs = g_delete_prefix | new.as_id_dict() - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def add_pump(name: str, cs: ChangeSet) -> ChangeSet: @@ -126,19 +131,15 @@ def add_pump(name: str, cs: ChangeSet) -> ChangeSet: return execute_command(name, _add_pump(name, cs)) -def _delete_pump(name: str, cs: ChangeSet) -> DbChangeSet: - old = Pump(get_pump(name, cs.operations[0]['id'])) +def _delete_pump(name: str, cs: ChangeSet) -> DatabaseCommand: + element_id = str(cs.operations[0]['id']) + f_id = sql_literal(element_id) - redo_sql = f"delete from pumps where id = {old.f_id};" - redo_sql += f"\ndelete from _link where id = {old.f_id};" + statement = f"delete from network.links where id = {f_id};" - undo_sql = f"insert into _link (id, type) values ({old.f_id}, {old.f_type});" - undo_sql += f"\ninsert into pumps (id, node1, node2, power, head, speed, pattern) values ({old.f_id}, {old.f_node1}, {old.f_node2}, {old.f_power}, {old.f_head}, {old.f_speed}, {old.f_pattern});" + change = g_delete_prefix | {'type': 'pump', 'id': element_id} - redo_cs = g_delete_prefix | old.as_id_dict() - undo_cs = g_add_prefix | old.as_dict() - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def delete_pump(name: str, cs: ChangeSet) -> ChangeSet: @@ -170,21 +171,17 @@ def inp_in_pump(line: str) -> str: for i in range(3, num_without_desc, 2): props |= { tokens[i].lower(): tokens[i + 1] } power = float(props['power']) if 'power' in props else None - power = power if power != None else 'null' head = str(props['head']) if 'head' in props else None - head = f"'{head}'" if head != None else 'null' speed = float(props['speed']) if 'speed' in props else None - speed = speed if speed != None else 'null' pattern = str(props['pattern']) if 'pattern' in props else None - pattern = f"'{pattern}'" if pattern != None else 'null' desc = str(tokens[-1]) if has_desc else None - return str(f"insert into _link (id, type) values ('{id}', 'pump');insert into pumps (id, node1, node2, power, head, speed, pattern) values ('{id}', '{node1}', '{node2}', {power}, {head}, {speed}, {pattern});") + return str(f"insert into network.links (id, link_type, start_node_id, end_node_id) values ({sql_literal(id)}, 'pump', {sql_literal(node1)}, {sql_literal(node2)});insert into network.pumps (link_id, power, head_curve_id, speed, pattern_id) values ({sql_literal(id)}, {sql_literal(power)}, {sql_literal(head)}, {sql_literal(speed)}, {sql_literal(pattern)});") def inp_out_pump(name: str) -> list[str]: lines = [] - objs = read_all(name, 'select * from pumps') + objs = read_all(name, 'select l.id, l.start_node_id as node1, l.end_node_id as node2, p.power, p.head_curve_id as head, p.speed, p.pattern_id as pattern from network.links l join network.pumps p on p.link_id = l.id order by l.id') for obj in objs: id = obj['id'] node1 = obj['node1'] @@ -198,20 +195,10 @@ def inp_out_pump(name: str) -> list[str]: return lines -'''def delete_pump_by_node(name: str, node: str) -> ChangeSet: - cs = ChangeSet() - - rows = read_all(name, f"select id from pumps where node1 = '{node}' or node2 = '{node}'") - for row in rows: - cs.append(g_delete_prefix | {'type': 'pump', 'id': row['id']}) - - return cs''' - - def unset_pump_by_curve(name: str, curve: str) -> ChangeSet: cs = ChangeSet() - rows = read_all(name, f"select * from pumps where head = '{curve}'") + rows = read_all(name, "select link_id as id, power from network.pumps where head_curve_id = %s", (curve,)) for row in rows: if row['power'] != None: cs.append(g_update_prefix | {'type': 'pump', 'id': row['id'], 'head': None}) @@ -224,7 +211,7 @@ def unset_pump_by_curve(name: str, curve: str) -> ChangeSet: def unset_pump_by_pattern(name: str, pattern: str) -> ChangeSet: cs = ChangeSet() - rows = read_all(name, f"select id from pumps where pattern = '{pattern}'") + rows = read_all(name, "select link_id as id from network.pumps where pattern_id = %s", (pattern,)) for row in rows: cs.append(g_update_prefix | {'type': 'pump', 'id': row['id'], 'pattern': None}) diff --git a/app/native/wndb/s17_quality.py b/app/native/wndb/model/quality.py similarity index 65% rename from app/native/wndb/s17_quality.py rename to app/native/wndb/model/quality.py index 539b205..e8d6c8d 100644 --- a/app/native/wndb/s17_quality.py +++ b/app/native/wndb/model/quality.py @@ -1,4 +1,14 @@ -from .database import * +from typing import Any + +from ..core.database import ( + ChangeSet, + DatabaseCommand, + execute_command, + g_update_prefix, + read_all, + sql_literal, + try_read, +) def get_quality_schema(name: str) -> dict[str, dict[str, Any]]: @@ -7,7 +17,7 @@ def get_quality_schema(name: str) -> dict[str, dict[str, Any]]: def get_quality(name: str, node: str) -> dict[str, Any]: - e = try_read(name, f"select * from quality where node = '{node}'") + e = try_read(name, "select node_id as node, value as quality from network.initial_quality where node_id = %s", (node,)) if e == None: return { 'node': node, 'quality': None } d = {} @@ -22,16 +32,15 @@ class Quality(object): self.node = str(input['node']) self.quality = float(input['quality']) if 'quality' in input and input['quality'] != None else None - self.f_type = f"'{self.type}'" - self.f_node = f"'{self.node}'" - self.f_quality = self.quality if self.quality != None else 'null' + self.f_type = sql_literal(self.type) + self.f_node = sql_literal(self.node) + self.f_quality = sql_literal(self.quality) def as_dict(self) -> dict[str, Any]: return { 'type': self.type, 'node': self.node, 'quality': self.quality } -def _set_quality(name: str, cs: ChangeSet) -> DbChangeSet: - old = Quality(get_quality(name, cs.operations[0]['node'])) +def _set_quality(name: str, cs: ChangeSet) -> DatabaseCommand: raw_new = get_quality(name, cs.operations[0]['node']) new_dict = cs.operations[0] @@ -41,18 +50,13 @@ def _set_quality(name: str, cs: ChangeSet) -> DbChangeSet: raw_new[key] = new_dict[key] new = Quality(raw_new) - redo_sql = f"delete from quality where node = {new.f_node};" + statement = f"delete from network.initial_quality where node_id = {new.f_node};" if new.quality != None: - redo_sql += f"\ninsert into quality (node, quality) values ({new.f_node}, {new.f_quality});" + statement += f"\ninsert into network.initial_quality (node_id, value) values ({new.f_node}, {new.f_quality});" - undo_sql = f"delete from quality where node = {old.f_node};" - if old.quality != None: - undo_sql += f"\ninsert into quality (node, quality) values ({old.f_node}, {old.f_quality});" + change = g_update_prefix | new.as_dict() - redo_cs = g_update_prefix | new.as_dict() - undo_cs = g_update_prefix | old.as_dict() - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def set_quality(name: str, cs: ChangeSet) -> ChangeSet: @@ -75,12 +79,12 @@ def inp_in_quality(line: str) -> str: node = str(tokens[0]) quality = float(tokens[1]) - return str(f"insert into quality (node, quality) values ('{node}', {quality});") + return str(f"insert into network.initial_quality (node_id, value) values ({sql_literal(node)}, {sql_literal(quality)});") def inp_out_quality(name: str) -> list[str]: lines = [] - objs = read_all(name, 'select * from quality') + objs = read_all(name, 'select node_id as node, value as quality from network.initial_quality order by node_id') for obj in objs: node = obj['node'] quality = obj['quality'] @@ -89,7 +93,7 @@ def inp_out_quality(name: str) -> list[str]: def delete_quality_by_node(name: str, node: str) -> ChangeSet: - row = try_read(name, f"select * from quality where node = '{node}'") + row = try_read(name, "select 1 from network.initial_quality where node_id = %s", (node,)) if row == None: return ChangeSet() return ChangeSet(g_update_prefix | {'type' : 'quality', 'node': node, 'quality': None}) diff --git a/app/native/wndb/s19_reactions.py b/app/native/wndb/model/reactions.py similarity index 56% rename from app/native/wndb/s19_reactions.py rename to app/native/wndb/model/reactions.py index bf0faf5..1f675fd 100644 --- a/app/native/wndb/s19_reactions.py +++ b/app/native/wndb/model/reactions.py @@ -1,4 +1,14 @@ -from .database import * +from typing import Any + +from ..core.database import ( + ChangeSet, + DatabaseCommand, + execute_command, + g_update_prefix, + read_all, + sql_literal, + try_read, +) element_schema = {'type': 'str' , 'optional': True , 'readonly': False} @@ -15,45 +25,32 @@ def get_reaction_schema(name: str) -> dict[str, dict[str, Any]]: def get_reaction(name: str) -> dict[str, Any]: - ts = read_all(name, f"select * from reactions") + ts = read_all(name, "select key, value from network.reaction_settings") d = {} for e in ts: d[e['key']] = str(e['value']) return d -def _set_reaction(name: str, cs: ChangeSet) -> DbChangeSet: - raw_old = get_reaction(name) - - old = {} +def _set_reaction(name: str, cs: ChangeSet) -> DatabaseCommand: new = {} new_dict = cs.operations[0] schema = get_reaction_schema(name) for key in schema.keys(): if key in new_dict: - old[key] = str(raw_old[key]) new[key] = str(new_dict[key]) - redo_cs = g_update_prefix | { 'type' : 'reaction' } + change = g_update_prefix | { 'type' : 'reaction' } - redo_sql = '' + statement = '' for key, value in new.items(): - if redo_sql != '': - redo_sql += '\n' - redo_sql += f"update reactions set value = '{value}' where key = '{key}';" - redo_cs |= { key: value } + if statement != '': + statement += '\n' + statement += f"update network.reaction_settings set value = {sql_literal(value)} where key = {sql_literal(key)};" + change |= { key: value } - undo_cs = g_update_prefix | { 'type' : 'reaction' } - - undo_sql = '' - for key, value in old.items(): - if undo_sql != '': - undo_sql += '\n' - undo_sql += f"update reactions set value = '{value}' where key = '{key}';" - undo_cs |= { key: value } - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def set_reaction(name: str, cs: ChangeSet) -> ChangeSet: @@ -69,10 +66,9 @@ def get_pipe_reaction_schema(name: str) -> dict[str, dict[str, Any]]: def get_pipe_reaction(name: str, pipe: str) -> dict[str, Any]: d = {} d['pipe'] = pipe - pr = try_read(name, f"select * from reactions_pipe_bulk where pipe = '{pipe}'") - d['bulk'] = float(pr['value']) if pr != None else None - pr = try_read(name, f"select * from reactions_pipe_wall where pipe = '{pipe}'") - d['wall'] = float(pr['value']) if pr != None else None + pr = try_read(name, "select bulk_coefficient as bulk, wall_coefficient as wall from network.pipe_reaction_coefficients where pipe_id = %s", (pipe,)) + d['bulk'] = float(pr['bulk']) if pr is not None and pr['bulk'] is not None else None + d['wall'] = float(pr['wall']) if pr is not None and pr['wall'] is not None else None return d @@ -83,17 +79,16 @@ class PipeReaction(object): self.bulk = float(input['bulk']) if 'bulk' in input and input['bulk'] != None else None self.wall = float(input['wall']) if 'wall' in input and input['wall'] != None else None - self.f_type = f"'{self.type}'" - self.f_pipe = f"'{self.pipe}'" - self.f_bulk = self.bulk if self.bulk != None else 'null' - self.f_wall = self.wall if self.wall != None else 'null' + self.f_type = sql_literal(self.type) + self.f_pipe = sql_literal(self.pipe) + self.f_bulk = sql_literal(self.bulk) + self.f_wall = sql_literal(self.wall) def as_dict(self) -> dict[str, Any]: return { 'type': self.type, 'pipe': self.pipe, 'bulk': self.bulk, 'wall': self.wall } -def _set_pipe_reaction(name: str, cs: ChangeSet) -> DbChangeSet: - old = PipeReaction(get_pipe_reaction(name, cs.operations[0]['pipe'])) +def _set_pipe_reaction(name: str, cs: ChangeSet) -> DatabaseCommand: raw_new = get_pipe_reaction(name, cs.operations[0]['pipe']) new_dict = cs.operations[0] @@ -103,22 +98,13 @@ def _set_pipe_reaction(name: str, cs: ChangeSet) -> DbChangeSet: raw_new[key] = new_dict[key] new = PipeReaction(raw_new) - redo_sql = f"delete from reactions_pipe_bulk where pipe = {new.f_pipe};\ndelete from reactions_pipe_wall where pipe = {new.f_pipe};" - if new.bulk != None: - redo_sql += f"\ninsert into reactions_pipe_bulk (pipe, value) values ({new.f_pipe}, {new.f_bulk});" - if new.wall != None: - redo_sql += f"\ninsert into reactions_pipe_wall (pipe, value) values ({new.f_pipe}, {new.f_wall});" + statement = f"delete from network.pipe_reaction_coefficients where pipe_id = {new.f_pipe};" + if new.bulk is not None or new.wall is not None: + statement += f"\ninsert into network.pipe_reaction_coefficients (pipe_id, bulk_coefficient, wall_coefficient) values ({new.f_pipe}, {new.f_bulk}, {new.f_wall});" - undo_sql = f"delete from reactions_pipe_bulk where pipe = {old.f_pipe};\ndelete from reactions_pipe_wall where pipe = {old.f_pipe};" - if old.bulk != None: - undo_sql += f"\ninsert into reactions_pipe_bulk (pipe, value) values ({old.f_pipe}, {old.f_bulk});" - if old.wall != None: - undo_sql += f"\ninsert into reactions_pipe_wall (pipe, value) values ({old.f_pipe}, {old.f_wall});" + change = g_update_prefix | new.as_dict() - redo_cs = g_update_prefix | new.as_dict() - undo_cs = g_update_prefix | old.as_dict() - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def set_pipe_reaction(name: str, cs: ChangeSet) -> ChangeSet: @@ -133,8 +119,8 @@ def get_tank_reaction_schema(name: str) -> dict[str, dict[str, Any]]: def get_tank_reaction(name: str, tank: str) -> dict[str, Any]: d = {} d['tank'] = tank - pr = try_read(name, f"select * from reactions_tank where tank = '{tank}'") - d['value'] = float(pr['value']) if pr != None else None + pr = try_read(name, "select coefficient as value from network.tank_reaction_coefficients where tank_id = %s", (tank,)) + d['value'] = float(pr['value']) if pr is not None else None return d @@ -144,16 +130,15 @@ class TankReaction(object): self.tank = str(input['tank']) self.value = float(input['value']) if 'value' in input and input['value'] != None else None - self.f_type = f"'{self.type}'" - self.f_tank = f"'{self.tank}'" - self.f_value = self.value if self.value != None else 'null' + self.f_type = sql_literal(self.type) + self.f_tank = sql_literal(self.tank) + self.f_value = sql_literal(self.value) def as_dict(self) -> dict[str, Any]: return { 'type': self.type, 'tank': self.tank, 'value': self.value } -def _set_tank_reaction(name: str, cs: ChangeSet) -> DbChangeSet: - old = TankReaction(get_tank_reaction(name, cs.operations[0]['tank'])) +def _set_tank_reaction(name: str, cs: ChangeSet) -> DatabaseCommand: raw_new = get_tank_reaction(name, cs.operations[0]['tank']) new_dict = cs.operations[0] @@ -163,18 +148,13 @@ def _set_tank_reaction(name: str, cs: ChangeSet) -> DbChangeSet: raw_new[key] = new_dict[key] new = TankReaction(raw_new) - redo_sql = f"delete from reactions_tank where tank = {new.f_tank};" + statement = f"delete from network.tank_reaction_coefficients where tank_id = {new.f_tank};" if new.value != None: - redo_sql += f"\ninsert into reactions_tank (tank, value) values ({new.f_tank}, {new.f_value});" + statement += f"\ninsert into network.tank_reaction_coefficients (tank_id, coefficient) values ({new.f_tank}, {new.f_value});" - undo_sql = f"delete from reactions_tank where tank = {old.f_tank};" - if old.value != None: - undo_sql += f"\ninsert into reactions_tank (tank, value) values ({old.f_tank}, {old.f_value});" + change = g_update_prefix | new.as_dict() - redo_cs = g_update_prefix | new.as_dict() - undo_cs = g_update_prefix | old.as_dict() - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def set_tank_reaction(name: str, cs: ChangeSet) -> ChangeSet: @@ -200,20 +180,21 @@ def inp_in_reaction(line: str) -> str: if token0 == 'BULK' or token0 == 'WALL': pipe = tokens[1] key = token0.lower() - value = tokens[2] - return str(f"insert into reactions_pipe_{key} (pipe, value) values ('{pipe}', {value});") + value = float(tokens[2]) + column = 'bulk_coefficient' if key == 'bulk' else 'wall_coefficient' + return str(f"insert into network.pipe_reaction_coefficients (pipe_id, {column}) values ({sql_literal(pipe)}, {sql_literal(value)}) on conflict (pipe_id) do update set {column} = excluded.{column};") elif token0 == 'TANK': tank = tokens[1] - value = tokens[2] - return str(f"insert into reactions_tank (tank, value) values ('{tank}', {value});") + value = float(tokens[2]) + return str(f"insert into network.tank_reaction_coefficients (tank_id, coefficient) values ({sql_literal(tank)}, {sql_literal(value)});") else: line = line.upper().strip() for key in get_reaction_schema('').keys(): if line.startswith(key): value = line.removeprefix(key).strip() - return str(f"update reactions set value = '{value}' where key = '{key}';") + return str(f"update network.reaction_settings set value = {sql_literal(value)} where key = {sql_literal(key)};") return str('') @@ -221,25 +202,25 @@ def inp_in_reaction(line: str) -> str: def inp_out_reaction(name: str) -> list[str]: lines = [] - objs = read_all(name, f"select * from reactions") + objs = read_all(name, "select key, value from network.reaction_settings order by key") for obj in objs: key = obj['key'] value = obj['value'] lines.append(f'{key} {value}') - objs = read_all(name, f"select * from reactions_pipe_bulk") + objs = read_all(name, "select pipe_id as pipe, bulk_coefficient as value from network.pipe_reaction_coefficients where bulk_coefficient is not null order by pipe_id") for obj in objs: pipe = obj['pipe'] value = obj['value'] lines.append(f'BULK {pipe} {value}') - objs = read_all(name, f"select * from reactions_pipe_wall") + objs = read_all(name, "select pipe_id as pipe, wall_coefficient as value from network.pipe_reaction_coefficients where wall_coefficient is not null order by pipe_id") for obj in objs: pipe = obj['pipe'] value = obj['value'] lines.append(f'WALL {pipe} {value}') - objs = read_all(name, f"select * from reactions_tank") + objs = read_all(name, "select tank_id as tank, coefficient as value from network.tank_reaction_coefficients order by tank_id") for obj in objs: tank = obj['tank'] value = obj['value'] @@ -249,15 +230,14 @@ def inp_out_reaction(name: str) -> list[str]: def delete_pipe_reaction_by_pipe(name: str, pipe: str) -> ChangeSet: - row1 = try_read(name, f"select * from reactions_pipe_bulk where pipe = '{pipe}'") - row2 = try_read(name, f"select * from reactions_pipe_wall where pipe = '{pipe}'") - if row1 == None and row2 == None: + row = try_read(name, "select 1 from network.pipe_reaction_coefficients where pipe_id = %s", (pipe,)) + if row is None: return ChangeSet() return ChangeSet(g_update_prefix | {'type': 'pipe_reaction', 'pipe': pipe, 'bulk': None, 'wall': None}) def delete_tank_reaction_by_tank(name: str, tank: str) -> ChangeSet: - row = try_read(name, f"select * from reactions_tank where tank = '{tank}'") + row = try_read(name, "select 1 from network.tank_reaction_coefficients where tank_id = %s", (tank,)) if row == None: return ChangeSet() return ChangeSet(g_update_prefix | {'type': 'tank_reaction', 'tank': tank, 'value': None}) diff --git a/app/native/wndb/s22_report.py b/app/native/wndb/model/reports.py similarity index 85% rename from app/native/wndb/s22_report.py rename to app/native/wndb/model/reports.py index 02b675b..75bfadd 100644 --- a/app/native/wndb/s22_report.py +++ b/app/native/wndb/model/reports.py @@ -1,4 +1,4 @@ -from .database import * +from ..core.database import read_all #-------------------------------------------------------------- @@ -26,7 +26,7 @@ def inp_in_report(section: list[str]) -> str: def inp_out_report(name: str) -> list[str]: lines = [] - objs = read_all(name, f"select * from report") + objs = read_all(name, "select key, value from network.report_settings order by key") for obj in objs: key = obj['key'] value = obj['value'] diff --git a/app/native/wndb/s3_reservoirs.py b/app/native/wndb/model/reservoirs.py similarity index 58% rename from app/native/wndb/s3_reservoirs.py rename to app/native/wndb/model/reservoirs.py index 181b707..1e0bceb 100644 --- a/app/native/wndb/s3_reservoirs.py +++ b/app/native/wndb/model/reservoirs.py @@ -1,6 +1,23 @@ -from .database import * -from .s0_base import * -from .s24_coordinates import * +from typing import Any + +from ..core.database import ( + ChangeSet, + DatabaseCommand, + execute_command, + g_add_prefix, + g_delete_prefix, + g_update_prefix, + read_all, + sql_literal, + try_read, +) +from ..gis.coordinates import ( + get_node_coord, + sql_delete_coord, + sql_insert_coord, + sql_update_coord, +) +from .elements import get_all_node_links, get_node_links def get_reservoir_schema(name: str) -> dict[str, dict[str, Any]]: @@ -13,7 +30,7 @@ def get_reservoir_schema(name: str) -> dict[str, dict[str, Any]]: def get_reservoir(name: str, id: str) -> dict[str, Any]: - r = try_read(name, f"select * from reservoirs where id = '{id}'") + r = try_read(name, "select node_id as id, head, pattern_id as pattern from network.reservoirs where node_id = %s", (id,)) if r == None: return {} xy = get_node_coord(name, id) @@ -28,21 +45,25 @@ def get_reservoir(name: str, id: str) -> dict[str, Any]: # DingZQ, 2025-03-29 def get_all_reservoirs(name: str) -> list[dict[str, Any]]: - rows = read_all(name, f"select * from reservoirs") + rows = read_all( + name, + "SELECT id, head, pattern_id AS pattern, x, y " + "FROM gis.reservoirs ORDER BY id", + ) if rows == None: return [] + links_by_node = get_all_node_links(name) result = [] for row in rows: d = {} id = str(row['id']) - xy = get_node_coord(name, id) d['id'] = id - d['x'] = float(xy['x']) - d['y'] = float(xy['y']) + d['x'] = float(row['x'] or 0.0) + d['y'] = float(row['y'] or 0.0) d['head'] = float(row['head']) if row['head'] != None else None d['pattern'] = str(row['pattern']) if row['pattern'] != None else None - d['links'] = get_node_links(name, id) + d['links'] = links_by_node.get(id, []) result.append(d) return result @@ -56,20 +77,15 @@ class Reservoir(object): self.head = float(input['head']) self.pattern = str(input['pattern']) if 'pattern' in input and input['pattern'] != None else None - self.f_type = f"'{self.type}'" - self.f_id = f"'{self.id}'" - self.f_head = self.head - self.f_pattern = f"'{self.pattern}'" if self.pattern != None else 'null' + self.f_type = sql_literal(self.type) + self.f_id = sql_literal(self.id) + self.f_head = sql_literal(self.head) + self.f_pattern = sql_literal(self.pattern) 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 as_id_dict(self) -> dict[str, Any]: - return { 'type': self.type, 'id': self.id } - - -def _set_reservoir(name: str, cs: ChangeSet) -> DbChangeSet: - old = Reservoir(get_reservoir(name, cs.operations[0]['id'])) +def _set_reservoir(name: str, cs: ChangeSet) -> DatabaseCommand: raw_new = get_reservoir(name, cs.operations[0]['id']) new_dict = cs.operations[0] @@ -79,16 +95,12 @@ def _set_reservoir(name: str, cs: ChangeSet) -> DbChangeSet: raw_new[key] = new_dict[key] new = Reservoir(raw_new) - redo_sql = f"update reservoirs set head = {new.f_head}, pattern = {new.f_pattern} where id = {new.f_id};" - redo_sql += f"\n{sql_update_coord(new.id, new.x, new.y)}" + statement = f"update network.reservoirs set head = {new.f_head}, pattern_id = {new.f_pattern} where node_id = {new.f_id};" + statement += f"\n{sql_update_coord(new.id, new.x, new.y)}" - undo_sql = sql_update_coord(old.id, old.x, old.y) - undo_sql += f"\nupdate reservoirs set head = {old.f_head}, pattern = {old.f_pattern} where id = {old.f_id};" + change = g_update_prefix | new.as_dict() - redo_cs = g_update_prefix | new.as_dict() - undo_cs = g_update_prefix | old.as_dict() - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def set_reservoir(name: str, cs: ChangeSet) -> ChangeSet: @@ -99,21 +111,16 @@ def set_reservoir(name: str, cs: ChangeSet) -> ChangeSet: return execute_command(name, _set_reservoir(name, cs)) -def _add_reservoir(name: str, cs: ChangeSet) -> DbChangeSet: +def _add_reservoir(name: str, cs: ChangeSet) -> DatabaseCommand: new = Reservoir(cs.operations[0]) - redo_sql = f"insert into _node (id, type) values ({new.f_id}, {new.f_type});" - redo_sql += f"\ninsert into reservoirs (id, head, pattern) values ({new.f_id}, {new.f_head}, {new.f_pattern});" - redo_sql += f"\n{sql_insert_coord(new.id, new.x, new.y)}" + statement = f"insert into network.nodes (id, node_type) values ({new.f_id}, {new.f_type});" + statement += f"\ninsert into network.reservoirs (node_id, head, pattern_id) values ({new.f_id}, {new.f_head}, {new.f_pattern});" + statement += f"\n{sql_insert_coord(new.id, new.x, new.y)}" - undo_sql = sql_delete_coord(new.id) - undo_sql += f"\ndelete from reservoirs where id = {new.f_id};" - undo_sql += f"\ndelete from _node where id = {new.f_id};" + change = g_add_prefix | new.as_dict() - redo_cs = g_add_prefix | new.as_dict() - undo_cs = g_delete_prefix | new.as_id_dict() - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def add_reservoir(name: str, cs: ChangeSet) -> ChangeSet: @@ -124,21 +131,16 @@ def add_reservoir(name: str, cs: ChangeSet) -> ChangeSet: return execute_command(name, _add_reservoir(name, cs)) -def _delete_reservoir(name: str, cs: ChangeSet) -> DbChangeSet: - old = Reservoir(get_reservoir(name, cs.operations[0]['id'])) +def _delete_reservoir(name: str, cs: ChangeSet) -> DatabaseCommand: + element_id = str(cs.operations[0]['id']) + f_id = sql_literal(element_id) - redo_sql = sql_delete_coord(old.id) - redo_sql += f"\ndelete from reservoirs where id = {old.f_id};" - redo_sql += f"\ndelete from _node where id = {old.f_id};" + statement = sql_delete_coord(element_id) + statement += f"\ndelete from network.nodes where id = {f_id};" - undo_sql = f"insert into _node (id, type) values ({old.f_id}, {old.f_type});" - undo_sql += f"\ninsert into reservoirs (id, head, pattern) values ({old.f_id}, {old.f_head}, {old.f_pattern});" - undo_sql += f"\n{sql_insert_coord(old.id, old.x, old.y)}" + change = g_delete_prefix | {'type': 'reservoir', 'id': element_id} - redo_cs = g_delete_prefix | old.as_id_dict() - undo_cs = g_add_prefix | old.as_dict() - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def delete_reservoir(name: str, cs: ChangeSet) -> ChangeSet: @@ -165,15 +167,14 @@ def inp_in_reservoir(line: str) -> str: id = str(tokens[0]) head = float(tokens[1]) pattern = str(tokens[2]) if num_without_desc >= 3 else None - pattern = f"'{pattern}'" if pattern != None else 'null' desc = str(tokens[-1]) if has_desc else None - return str(f"insert into _node (id, type) values ('{id}', 'reservoir');insert into reservoirs (id, head, pattern) values ('{id}', {head}, {pattern});") + return str(f"insert into network.nodes (id, node_type) values ({sql_literal(id)}, 'reservoir');insert into network.reservoirs (node_id, head, pattern_id) values ({sql_literal(id)}, {sql_literal(head)}, {sql_literal(pattern)});") def inp_out_reservoir(name: str) -> list[str]: lines = [] - objs = read_all(name, 'select * from reservoirs') + objs = read_all(name, 'select node_id as id, head, pattern_id as pattern from network.reservoirs order by node_id') for obj in objs: id = obj['id'] head = obj['head'] @@ -186,7 +187,7 @@ def inp_out_reservoir(name: str) -> list[str]: def unset_reservoir_by_pattern(name: str, pattern: str) -> ChangeSet: cs = ChangeSet() - rows = read_all(name, f"select id from reservoirs where pattern = '{pattern}'") + rows = read_all(name, "select node_id as id from network.reservoirs where pattern_id = %s", (pattern,)) for row in rows: cs.append(g_update_prefix | {'type': 'reservoir', 'id': row['id'], 'pattern': None}) diff --git a/app/native/wndb/model/rules.py b/app/native/wndb/model/rules.py new file mode 100644 index 0000000..3dd4c25 --- /dev/null +++ b/app/native/wndb/model/rules.py @@ -0,0 +1,50 @@ +from typing import Any + +from ..core.database import ( + ChangeSet, + DatabaseCommand, + execute_command, + g_update_prefix, + read_all, + sql_literal, +) + + +def get_rule_schema(name: str) -> dict[str, dict[str, Any]]: + return { 'rules' : {'type': 'str_list' , 'optional': False , 'readonly': False} } + + +def get_rule(name: str) -> dict[str, Any]: + cs = read_all(name, "select line from network.rules order by sequence_no") + ds = [] + for c in cs: + ds.append(c['line']) + return { 'rules': ds } + + +def _set_rule(name: str, cs: ChangeSet) -> DatabaseCommand: + statement = 'delete from network.rules;' + for sequence_no, line in enumerate(cs.operations[0]['rules']): + statement += f"\ninsert into network.rules (sequence_no, line) values ({sequence_no}, {sql_literal(line)});" + + change = g_update_prefix | { 'type': 'rule', 'rules': cs.operations[0]['rules'] } + + return DatabaseCommand(statement, [change]) + + +def set_rule(name: str, cs: ChangeSet) -> ChangeSet: + return execute_command(name, _set_rule(name, cs)) + + +#-------------------------------------------------------------- +# [EPA2][EPA3] +# TODO... +#-------------------------------------------------------------- + + +def inp_in_rule(line: str) -> str: + return str(f"insert into network.rules (sequence_no, line) values ((select coalesce(max(sequence_no) + 1, 0) from network.rules), {sql_literal(line)});") + + +def inp_out_rule(name: str) -> list[str]: + return get_rule(name)['rules'] diff --git a/app/native/wndb/s18_sources.py b/app/native/wndb/model/sources.py similarity index 58% rename from app/native/wndb/s18_sources.py rename to app/native/wndb/model/sources.py index 0abaeda..327cee5 100644 --- a/app/native/wndb/s18_sources.py +++ b/app/native/wndb/model/sources.py @@ -1,5 +1,16 @@ -from .database import * -from .s0_base import * +from typing import Any + +from ..core.database import ( + ChangeSet, + DatabaseCommand, + execute_command, + g_add_prefix, + g_delete_prefix, + g_update_prefix, + read_all, + sql_literal, + try_read, +) SOURCE_TYPE_CONCEN = 'CONCEN' SOURCE_TYPE_MASS = 'MASS' @@ -14,7 +25,7 @@ def get_source_schema(name: str) -> dict[str, dict[str, Any]]: def get_source(name: str, node: str) -> dict[str, Any]: - s = try_read(name, f"select * from sources where node = '{node}'") + s = try_read(name, "select node_id as node, source_type as s_type, strength, pattern_id as pattern from network.sources where node_id = %s", (node,)) if s == None: return {} d = {} @@ -33,21 +44,16 @@ class Source(object): self.strength = float(input['strength']) self.pattern = str(input['pattern']) if 'pattern' in input and input['pattern'] != None else None - self.f_type = f"'{self.type}'" - self.f_node = f"'{self.node}'" - self.f_s_type = f"'{self.s_type}'" - self.f_strength = self.strength - self.f_pattern = f"'{self.pattern}'" if self.pattern != None else 'null' + self.f_type = sql_literal(self.type) + self.f_node = sql_literal(self.node) + self.f_s_type = sql_literal(self.s_type) + self.f_strength = sql_literal(self.strength) + self.f_pattern = sql_literal(self.pattern) def as_dict(self) -> dict[str, Any]: return { 'type': self.type, 'node': self.node, 's_type': self.s_type, 'strength': self.strength, 'pattern': self.pattern } - def as_id_dict(self) -> dict[str, Any]: - return { 'type': self.type, 'node': self.node } - - -def _set_source(name: str, cs: ChangeSet) -> DbChangeSet: - old = Source(get_source(name, cs.operations[0]['node'])) +def _set_source(name: str, cs: ChangeSet) -> DatabaseCommand: raw_new = get_source(name, cs.operations[0]['node']) new_dict = cs.operations[0] @@ -57,45 +63,40 @@ def _set_source(name: str, cs: ChangeSet) -> DbChangeSet: raw_new[key] = new_dict[key] new = Source(raw_new) - redo_sql = f"update sources set s_type = {new.f_s_type}, strength = {new.f_strength}, pattern = {new.f_pattern} where node = {new.f_node};" - undo_sql = f"update sources set s_type = {old.f_s_type}, strength = {old.f_strength}, pattern = {old.f_pattern} where node = {old.f_node};" + statement = f"update network.sources set source_type = {new.f_s_type}, strength = {new.f_strength}, pattern_id = {new.f_pattern} where node_id = {new.f_node};" - redo_cs = g_update_prefix | new.as_dict() - undo_cs = g_update_prefix | old.as_dict() + change = g_update_prefix | new.as_dict() - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def set_source(name: str, cs: ChangeSet) -> ChangeSet: return execute_command(name, _set_source(name, cs)) -def _add_source(name: str, cs: ChangeSet) -> DbChangeSet: +def _add_source(name: str, cs: ChangeSet) -> DatabaseCommand: new = Source(cs.operations[0]) - redo_sql = f"insert into sources (node, s_type, strength, pattern) values ({new.f_node}, {new.f_s_type}, {new.f_strength}, {new.f_pattern});" - undo_sql = f"delete from sources where node = {new.f_node};" + statement = f"insert into network.sources (node_id, source_type, strength, pattern_id) values ({new.f_node}, {new.f_s_type}, {new.f_strength}, {new.f_pattern});" - redo_cs = g_add_prefix | new.as_dict() - undo_cs = g_delete_prefix | new.as_id_dict() + change = g_add_prefix | new.as_dict() - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def add_source(name: str, cs: ChangeSet) -> ChangeSet: return execute_command(name, _add_source(name, cs)) -def _delete_source(name: str, cs: ChangeSet) -> DbChangeSet: - old = Source(get_source(name, cs.operations[0]['node'])) +def _delete_source(name: str, cs: ChangeSet) -> DatabaseCommand: + node = str(cs.operations[0]['node']) + f_node = sql_literal(node) - redo_sql = f"delete from sources where node = {old.f_node};" - undo_sql = f"insert into sources (node, s_type, strength, pattern) values ({old.f_node}, {old.f_s_type}, {old.f_strength}, {old.f_pattern});" + statement = f"delete from network.sources where node_id = {f_node};" - redo_cs = g_delete_prefix | old.as_id_dict() - undo_cs = g_add_prefix | old.as_dict() + change = g_delete_prefix | {'type': 'source', 'node': node} - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def delete_source(name: str, cs: ChangeSet) -> ChangeSet: @@ -119,14 +120,12 @@ def inp_in_source(line: str) -> str: s_type = str(tokens[1].upper()) strength = float(tokens[2]) pattern = str(tokens[3]) if num_without_desc >= 4 else None - pattern = f"'{pattern}'" if pattern != None else 'null' - - return str(f"insert into sources (node, s_type, strength, pattern) values ('{node}', '{s_type}', {strength}, {pattern});") + return str(f"insert into network.sources (node_id, source_type, strength, pattern_id) values ({sql_literal(node)}, {sql_literal(s_type)}, {sql_literal(strength)}, {sql_literal(pattern)});") def inp_out_source(name: str) -> list[str]: lines = [] - objs = read_all(name, 'select * from sources') + objs = read_all(name, 'select node_id as node, source_type as s_type, strength, pattern_id as pattern from network.sources order by node_id') for obj in objs: node = obj['node'] s_type = obj['s_type'] @@ -137,7 +136,7 @@ def inp_out_source(name: str) -> list[str]: def delete_source_by_node(name: str, node: str) -> ChangeSet: - row = try_read(name, f"select * from sources where node = '{node}'") + row = try_read(name, "select 1 from network.sources where node_id = %s", (node,)) if row == None: return ChangeSet() return ChangeSet(g_delete_prefix | {'type' : 'source', 'node': node}) @@ -146,7 +145,7 @@ def delete_source_by_node(name: str, node: str) -> ChangeSet: def unset_source_by_pattern(name: str, pattern: str) -> ChangeSet: cs = ChangeSet() - rows = read_all(name, f"select node from sources where pattern = '{pattern}'") + rows = read_all(name, "select node_id as node from network.sources where pattern_id = %s", (pattern,)) for row in rows: cs.append(g_update_prefix | {'type': 'source', 'node': row['node'], 'pattern': None}) diff --git a/app/native/wndb/s10_status.py b/app/native/wndb/model/status.py similarity index 66% rename from app/native/wndb/s10_status.py rename to app/native/wndb/model/status.py index 33b2a7d..b056c11 100644 --- a/app/native/wndb/s10_status.py +++ b/app/native/wndb/model/status.py @@ -1,4 +1,14 @@ -from .database import * +from typing import Any + +from ..core.database import ( + ChangeSet, + DatabaseCommand, + execute_command, + g_update_prefix, + read_all, + sql_literal, + try_read, +) LINK_STATUS_OPEN = 'OPEN' @@ -13,7 +23,7 @@ def get_status_schema(name: str) -> dict[str, dict[str, Any]]: def get_status(name: str, link: str) -> dict[str, Any]: - s = try_read(name, f"select * from status where link = '{link}'") + s = try_read(name, "select link_id as link, status, setting from network.link_initial_settings where link_id = %s", (link,)) if s == None: return { 'link': link, 'status': None, 'setting': None } d = {} @@ -30,17 +40,16 @@ class Status(object): self.status = str(input['status']) if 'status' in input and input['status'] != None else None self.setting = float(input['setting']) if 'setting' in input and input['setting'] != None else None - self.f_type = f"'{self.type}'" - self.f_link = f"'{self.link}'" - self.f_status = f"'{self.status}'" if self.status != None else 'null' - self.f_setting = self.setting if self.setting != None else 'null' + self.f_type = sql_literal(self.type) + self.f_link = sql_literal(self.link) + self.f_status = sql_literal(self.status) + self.f_setting = sql_literal(self.setting) def as_dict(self) -> dict[str, Any]: return { 'type': self.type, 'link': self.link, 'status': self.status, 'setting': self.setting } -def _set_status(name: str, cs: ChangeSet) -> DbChangeSet: - old = Status(get_status(name, cs.operations[0]['link'])) +def _set_status(name: str, cs: ChangeSet) -> DatabaseCommand: raw_new = get_status(name, cs.operations[0]['link']) new_dict = cs.operations[0] @@ -50,18 +59,13 @@ def _set_status(name: str, cs: ChangeSet) -> DbChangeSet: raw_new[key] = new_dict[key] new = Status(raw_new) - redo_sql = f"delete from status where link = {new.f_link};" + statement = f"delete from network.link_initial_settings where link_id = {new.f_link};" if new.status != None or new.setting != None: - redo_sql += f"\ninsert into status (link, status, setting) values ({new.f_link}, {new.f_status}, {new.f_setting});" + statement += f"\ninsert into network.link_initial_settings (link_id, status, setting) values ({new.f_link}, {new.f_status}, {new.f_setting});" - undo_sql = f"delete from status where link = {old.f_link};" - if old.status != None or old.setting != None: - undo_sql += f"\ninsert into status (link, status, setting) values ({old.f_link}, {old.f_status}, {old.f_setting});" + change = g_update_prefix | new.as_dict() - redo_cs = g_update_prefix | new.as_dict() - undo_cs = g_update_prefix | old.as_dict() - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def set_status(name: str, cs: ChangeSet) -> ChangeSet: @@ -84,14 +88,14 @@ def inp_in_status(line: str) -> str: link = str(tokens[0]) value = tokens[1].upper() if value == LINK_STATUS_OPEN or value == LINK_STATUS_CLOSED or value == LINK_STATUS_ACTIVE: - return str(f"insert into status (link, status, setting) values ('{link}', '{value}', null);") + return str(f"insert into network.link_initial_settings (link_id, status, setting) values ({sql_literal(link)}, {sql_literal(value)}, null);") else: - return str(f"insert into status (link, status, setting) values ('{link}', null, {float(value)});") + return str(f"insert into network.link_initial_settings (link_id, status, setting) values ({sql_literal(link)}, null, {sql_literal(float(value))});") def inp_out_status(name: str) -> list[str]: lines = [] - objs = read_all(name, 'select * from status') + objs = read_all(name, 'select link_id as link, status, setting from network.link_initial_settings order by link_id') for obj in objs: link = obj['link'] status = obj['status'] if obj['status'] != None else '' @@ -104,7 +108,7 @@ def inp_out_status(name: str) -> list[str]: def delete_status_by_link(name: str, link: str) -> ChangeSet: - row = try_read(name, f"select * from status where link = '{link}'") + row = try_read(name, "select 1 from network.link_initial_settings where link_id = %s", (link,)) if row == None: return ChangeSet() return ChangeSet(g_update_prefix | {'type': 'status', 'link': link, 'status': None, 'setting': None}) diff --git a/app/native/wndb/model/tags.py b/app/native/wndb/model/tags.py new file mode 100644 index 0000000..c22d6a8 --- /dev/null +++ b/app/native/wndb/model/tags.py @@ -0,0 +1,123 @@ +from typing import Any + +from ..core.database import ( + ChangeSet, + DatabaseCommand, + execute_command, + g_update_prefix, + read_all, + sql_literal, + try_read, +) + + +TAG_TYPE_NODE = "NODE" +TAG_TYPE_LINK = "LINK" + + +def get_tag_schema(name: str) -> dict[str, dict[str, Any]]: + return { + "t_type": {"type": "str", "optional": False, "readonly": False}, + "id": {"type": "str", "optional": False, "readonly": False}, + "tag": {"type": "str", "optional": True, "readonly": False}, + } + + +def get_tags(name: str) -> list[dict[str, Any]]: + rows = read_all( + name, + """ + select 'NODE' as t_type, node_id as id, tag from network.node_tags + union all + select 'LINK' as t_type, link_id as id, tag from network.link_tags + order by t_type, id + """, + ) + return [ + { + "t_type": str(row["t_type"]), + "id": str(row["id"]), + "tag": str(row["tag"]) if row["tag"] is not None else None, + } + for row in rows + ] + + +def _tag_table(t_type: str) -> tuple[str, str]: + if t_type == TAG_TYPE_NODE: + return "network.node_tags", "node_id" + if t_type == TAG_TYPE_LINK: + return "network.link_tags", "link_id" + raise ValueError("Only NODE and LINK tags are supported") + + +def get_tag(name: str, t_type: str, id: str) -> dict[str, Any]: + table, id_column = _tag_table(t_type) + row = try_read( + name, + f"select {id_column} as id, tag from {table} where {id_column} = %s", + (id,), + ) + return { + "t_type": t_type, + "id": id, + "tag": str(row["tag"]) if row and row["tag"] is not None else None, + } + + +def _replace_tag_sql(t_type: str, element_id: str, tag: str | None) -> str: + table, id_column = _tag_table(t_type) + element_sql = sql_literal(element_id) + statements = [f"delete from {table} where {id_column} = {element_sql};"] + if tag is not None: + statements.append( + f"insert into {table} ({id_column}, tag) " + f"values ({element_sql}, {sql_literal(tag)});" + ) + return "\n".join(statements) + + +def _set_tag(name: str, cs: ChangeSet) -> DatabaseCommand: + operation = cs.operations[0] + t_type = str(operation["t_type"]).upper() + element_id = str(operation["id"]) + new = {"t_type": t_type, "id": element_id, "tag": operation.get("tag")} + return DatabaseCommand( + _replace_tag_sql(t_type, element_id, new["tag"]), + [g_update_prefix | {"type": "tag"} | new], + ) + + +def set_tag(name: str, cs: ChangeSet) -> ChangeSet: + if not {"t_type", "id", "tag"} <= cs.operations[0].keys(): + return ChangeSet() + return execute_command(name, _set_tag(name, cs)) + + +def inp_in_tag(line: str) -> str: + tokens = line.split() + if len(tokens) < 3: + return "" + return _replace_tag_sql(tokens[0].upper(), tokens[1], tokens[2]) + + +def inp_out_tag(name: str) -> list[str]: + return [f"{row['t_type']} {row['id']} {row['tag']}" for row in get_tags(name)] + + +def delete_tag_by_node(name: str, node: str) -> ChangeSet: + row = get_tag(name, TAG_TYPE_NODE, node) + return ( + ChangeSet(g_update_prefix | {"type": "tag"} | row | {"tag": None}) + if row["tag"] is not None + else ChangeSet() + ) + + +def delete_tag_by_link(name: str, link: str) -> ChangeSet: + row = get_tag(name, TAG_TYPE_LINK, link) + return ( + ChangeSet(g_update_prefix | {"type": "tag"} | row | {"tag": None}) + if row["tag"] is not None + else ChangeSet() + ) diff --git a/app/native/wndb/s4_tanks.py b/app/native/wndb/model/tanks.py similarity index 62% rename from app/native/wndb/s4_tanks.py rename to app/native/wndb/model/tanks.py index 306c9d7..d610df7 100644 --- a/app/native/wndb/s4_tanks.py +++ b/app/native/wndb/model/tanks.py @@ -1,6 +1,23 @@ -from .database import * -from .s0_base import * -from .s24_coordinates import * +from typing import Any + +from ..core.database import ( + ChangeSet, + DatabaseCommand, + execute_command, + g_add_prefix, + g_delete_prefix, + g_update_prefix, + read_all, + sql_literal, + try_read, +) +from ..gis.coordinates import ( + get_node_coord, + sql_delete_coord, + sql_insert_coord, + sql_update_coord, +) +from .elements import get_all_node_links, get_node_links OVERFLOW_YES = 'YES' @@ -23,7 +40,7 @@ def get_tank_schema(name: str) -> dict[str, dict[str, Any]]: def get_tank(name: str, id: str) -> dict[str, Any]: - t = try_read(name, f"select * from tanks where id = '{id}'") + t = try_read(name, "select node_id as id, elevation, initial_level as init_level, minimum_level as min_level, maximum_level as max_level, diameter, minimum_volume as min_vol, volume_curve_id as vol_curve, overflow from network.tanks where node_id = %s", (id,)) if t == None: return {} xy = get_node_coord(name, id) @@ -44,18 +61,24 @@ def get_tank(name: str, id: str) -> dict[str, Any]: # DingZQ, 2025-03-29 def get_all_tanks(name: str) -> list[dict[str, Any]]: - rows = read_all(name, f"select * from tanks") + rows = read_all( + name, + "SELECT id, elevation, initial_level AS init_level, " + "minimum_level AS min_level, maximum_level AS max_level, diameter, " + "minimum_volume AS min_vol, volume_curve_id AS vol_curve, overflow, " + "x, y FROM gis.tanks ORDER BY id", + ) if rows == None: return [] + links_by_node = get_all_node_links(name) result = [] for row in rows: d = {} id = str(row['id']) - xy = get_node_coord(name, id) d['id'] = id - d['x'] = float(xy['x']) - d['y'] = float(xy['y']) + d['x'] = float(row['x'] or 0.0) + d['y'] = float(row['y'] or 0.0) d['elevation'] = float(row['elevation']) d['init_level'] = float(row['init_level']) d['min_level'] = float(row['min_level']) @@ -64,7 +87,7 @@ def get_all_tanks(name: str) -> list[dict[str, Any]]: d['min_vol'] = float(row['min_vol']) d['vol_curve'] = str(row['vol_curve']) if row['vol_curve'] != None else None d['overflow'] = str(row['overflow']) if row['overflow'] != None else None - d['links'] = get_node_links(name, id) + d['links'] = links_by_node.get(id, []) result.append(d) return result @@ -84,26 +107,21 @@ class Tank(object): self.vol_curve = str(input['vol_curve']) if 'vol_curve' in input and input['vol_curve'] != None else None self.overflow = str(input['overflow']) if 'overflow' in input and input['overflow'] != None else None - self.f_type = f"'{self.type}'" - self.f_id = f"'{self.id}'" - self.f_elevation = self.elevation - self.f_init_level = self.init_level - self.f_min_level = self.min_level - self.f_max_level = self.max_level - self.f_diameter = self.diameter - self.f_min_vol = self.min_vol - self.f_vol_curve = f"'{self.vol_curve}'" if self.vol_curve != None else 'null' - self.f_overflow = f"'{self.overflow}'" if self.overflow != None else 'null' + self.f_type = sql_literal(self.type) + self.f_id = sql_literal(self.id) + self.f_elevation = sql_literal(self.elevation) + self.f_init_level = sql_literal(self.init_level) + self.f_min_level = sql_literal(self.min_level) + self.f_max_level = sql_literal(self.max_level) + self.f_diameter = sql_literal(self.diameter) + self.f_min_vol = sql_literal(self.min_vol) + self.f_vol_curve = sql_literal(self.vol_curve) + self.f_overflow = sql_literal(self.overflow) 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 as_id_dict(self) -> dict[str, Any]: - return { 'type': self.type, 'id': self.id } - - -def _set_tank(name: str, cs: ChangeSet) -> DbChangeSet: - old = Tank(get_tank(name, cs.operations[0]['id'])) +def _set_tank(name: str, cs: ChangeSet) -> DatabaseCommand: raw_new = get_tank(name, cs.operations[0]['id']) new_dict = cs.operations[0] @@ -113,16 +131,12 @@ def _set_tank(name: str, cs: ChangeSet) -> DbChangeSet: raw_new[key] = new_dict[key] new = Tank(raw_new) - redo_sql = f"update tanks set elevation = {new.f_elevation}, init_level = {new.f_init_level}, min_level = {new.f_min_level}, max_level = {new.f_max_level}, diameter = {new.f_diameter}, min_vol = {new.f_min_vol}, vol_curve = {new.f_vol_curve}, overflow = {new.f_overflow} where id = {new.f_id};" - redo_sql += f"\n{sql_update_coord(new.id, new.x, new.y)}" + statement = f"update network.tanks set elevation = {new.f_elevation}, initial_level = {new.f_init_level}, minimum_level = {new.f_min_level}, maximum_level = {new.f_max_level}, diameter = {new.f_diameter}, minimum_volume = {new.f_min_vol}, volume_curve_id = {new.f_vol_curve}, overflow = {new.f_overflow} where node_id = {new.f_id};" + statement += f"\n{sql_update_coord(new.id, new.x, new.y)}" - undo_sql = sql_update_coord(old.id, old.x, old.y) - undo_sql += f"\nupdate tanks set elevation = {old.f_elevation}, init_level = {old.f_init_level}, min_level = {old.f_min_level}, max_level = {old.f_max_level}, diameter = {old.f_diameter}, min_vol = {old.f_min_vol}, vol_curve = {old.f_vol_curve}, overflow = {old.f_overflow} where id = {old.f_id};" + change = g_update_prefix | new.as_dict() - redo_cs = g_update_prefix | new.as_dict() - undo_cs = g_update_prefix | old.as_dict() - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def set_tank(name: str, cs: ChangeSet) -> ChangeSet: @@ -133,21 +147,16 @@ def set_tank(name: str, cs: ChangeSet) -> ChangeSet: return execute_command(name, _set_tank(name, cs)) -def _add_tank(name: str, cs: ChangeSet) -> DbChangeSet: +def _add_tank(name: str, cs: ChangeSet) -> DatabaseCommand: new = Tank(cs.operations[0]) - redo_sql = f"insert into _node (id, type) values ({new.f_id}, {new.f_type});" - redo_sql += f"\ninsert into tanks (id, elevation, init_level, min_level, max_level, diameter, min_vol, vol_curve, overflow) values ({new.f_id}, {new.f_elevation}, {new.f_init_level}, {new.f_min_level}, {new.f_max_level}, {new.f_diameter}, {new.f_min_vol}, {new.f_vol_curve}, {new.f_overflow});" - redo_sql += f"\n{sql_insert_coord(new.id, new.x, new.y)}" + statement = f"insert into network.nodes (id, node_type) values ({new.f_id}, {new.f_type});" + statement += f"\ninsert into network.tanks (node_id, elevation, initial_level, minimum_level, maximum_level, diameter, minimum_volume, volume_curve_id, overflow) values ({new.f_id}, {new.f_elevation}, {new.f_init_level}, {new.f_min_level}, {new.f_max_level}, {new.f_diameter}, {new.f_min_vol}, {new.f_vol_curve}, {new.f_overflow});" + statement += f"\n{sql_insert_coord(new.id, new.x, new.y)}" - undo_sql = sql_delete_coord(new.id) - undo_sql += f"\ndelete from tanks where id = {new.f_id};" - undo_sql += f"\ndelete from _node where id = {new.f_id};" + change = g_add_prefix | new.as_dict() - redo_cs = g_add_prefix | new.as_dict() - undo_cs = g_delete_prefix | new.as_id_dict() - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def add_tank(name: str, cs: ChangeSet) -> ChangeSet: @@ -158,21 +167,16 @@ def add_tank(name: str, cs: ChangeSet) -> ChangeSet: return execute_command(name, _add_tank(name, cs)) -def _delete_tank(name: str, cs: ChangeSet) -> DbChangeSet: - old = Tank(get_tank(name, cs.operations[0]['id'])) +def _delete_tank(name: str, cs: ChangeSet) -> DatabaseCommand: + element_id = str(cs.operations[0]['id']) + f_id = sql_literal(element_id) - redo_sql = sql_delete_coord(old.id) - redo_sql += f"\ndelete from tanks where id = {old.f_id};" - redo_sql += f"\ndelete from _node where id = {old.f_id};" + statement = sql_delete_coord(element_id) + statement += f"\ndelete from network.nodes where id = {f_id};" - undo_sql = f"insert into _node (id, type) values ({old.f_id}, {old.f_type});" - undo_sql += f"\ninsert into tanks (id, elevation, init_level, min_level, max_level, diameter, min_vol, vol_curve, overflow) values ({old.f_id}, {old.f_elevation}, {old.f_init_level}, {old.f_min_level}, {old.f_max_level}, {old.f_diameter}, {old.f_min_vol}, {old.f_vol_curve}, {old.f_overflow});" - undo_sql += f"\n{sql_insert_coord(old.id, old.x, old.y)}" + change = g_delete_prefix | {'type': 'tank', 'id': element_id} - redo_cs = g_delete_prefix | old.as_id_dict() - undo_cs = g_add_prefix | old.as_dict() - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def delete_tank(name: str, cs: ChangeSet) -> ChangeSet: @@ -212,17 +216,15 @@ def inp_in_tank(line: str) -> str: diameter = float(tokens[5]) min_vol = float(tokens[6]) if num_without_desc >= 7 else 0.0 vol_curve = str(tokens[7]) if num_without_desc >= 8 and tokens[7] != '*' else None - vol_curve = f"'{vol_curve}'" if vol_curve != None else 'null' overflow = str(tokens[8].upper()) if num_without_desc >= 9 else None - overflow = f"'{overflow}'" if overflow != None else 'null' desc = str(tokens[-1]) if has_desc else None - return str(f"insert into _node (id, type) values ('{id}', 'tank');insert into tanks (id, elevation, init_level, min_level, max_level, diameter, min_vol, vol_curve, overflow) values ('{id}', {elevation}, {init_level}, {min_level}, {max_level}, {diameter}, {min_vol}, {vol_curve}, {overflow});") + return str(f"insert into network.nodes (id, node_type) values ({sql_literal(id)}, 'tank');insert into network.tanks (node_id, elevation, initial_level, minimum_level, maximum_level, diameter, minimum_volume, volume_curve_id, overflow) values ({sql_literal(id)}, {sql_literal(elevation)}, {sql_literal(init_level)}, {sql_literal(min_level)}, {sql_literal(max_level)}, {sql_literal(diameter)}, {sql_literal(min_vol)}, {sql_literal(vol_curve)}, {sql_literal(overflow)});") def inp_out_tank(name: str) -> list[str]: lines = [] - objs = read_all(name, 'select * from tanks') + objs = read_all(name, 'select node_id as id, elevation, initial_level as init_level, minimum_level as min_level, maximum_level as max_level, diameter, minimum_volume as min_vol, volume_curve_id as vol_curve, overflow from network.tanks order by node_id') for obj in objs: id = obj['id'] elevation = obj['elevation'] @@ -243,7 +245,7 @@ def inp_out_tank(name: str) -> list[str]: def unset_tank_by_curve(name: str, curve: str) -> ChangeSet: cs = ChangeSet() - rows = read_all(name, f"select id from tanks where vol_curve = '{curve}'") + rows = read_all(name, "select node_id as id from network.tanks where volume_curve_id = %s", (curve,)) for row in rows: cs.append(g_update_prefix | {'type': 'tank', 'id': row['id'], 'vol_curve': None}) diff --git a/app/native/wndb/s21_times.py b/app/native/wndb/model/times.py similarity index 71% rename from app/native/wndb/s21_times.py rename to app/native/wndb/model/times.py index 44520d8..1777fec 100644 --- a/app/native/wndb/s21_times.py +++ b/app/native/wndb/model/times.py @@ -1,112 +1,110 @@ -from .database import * - -TIME_STATISTIC_NONE = 'NONE' -TIME_STATISTIC_AVERAGED = 'AVERAGED' -TIME_STATISTIC_MINIMUM = 'MINIMUM' -TIME_STATISTIC_MAXIMUM = 'MAXIMUM' -TIME_STATISTIC_RANGE = 'RANGE' - -element_schema = {'type': 'str' , 'optional': True , 'readonly': False} - -def get_time_schema(name: str) -> dict[str, dict[str, Any]]: - return { 'DURATION' : element_schema, - 'HYDRAULIC TIMESTEP' : element_schema, - 'QUALITY TIMESTEP' : element_schema, - 'RULE TIMESTEP' : element_schema, - 'PATTERN TIMESTEP' : element_schema, - 'PATTERN START' : element_schema, - 'REPORT TIMESTEP' : element_schema, - 'REPORT START' : element_schema, - 'START CLOCKTIME' : element_schema, - 'STATISTIC' : element_schema} - - -def get_time(name: str) -> dict[str, Any]: - ts = read_all(name, f"select * from times") - d = {} - for e in ts: - d[e['key']] = str(e['value']) - return d - - -def _set_time(name: str, cs: ChangeSet) -> DbChangeSet: - raw_old = get_time(name) - - old = {} - new = {} - - new_dict = cs.operations[0] - schema = get_time_schema(name) - for key in schema.keys(): - if key in new_dict: - old[key] = str(raw_old[key]) - new[key] = str(new_dict[key]) - - redo_cs = g_update_prefix | { 'type' : 'time' } - - redo_sql = '' - for key, value in new.items(): - if redo_sql != '': - redo_sql += '\n' - redo_sql += f"update times set value = '{value}' where key = '{key}';" - redo_cs |= { key: value } - - undo_cs = g_update_prefix | { 'type' : 'time' } - - undo_sql = '' - for key, value in old.items(): - if undo_sql != '': - undo_sql += '\n' - undo_sql += f"update times set value = '{value}' where key = '{key}';" - undo_cs |= { key: value } - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) - - -def set_time(name: str, cs: ChangeSet) -> ChangeSet: - return execute_command(name, _set_time(name, cs)) - - -#-------------------------------------------------------------- -# [EPA2][EPA3] -# STATISTIC {NONE/AVERAGE/MIN/MAX/RANGE} -# DURATION value (units) -# HYDRAULIC TIMESTEP value (units) -# QUALITY TIMESTEP value (units) -# RULE TIMESTEP value (units) -# PATTERN TIMESTEP value (units) -# PATTERN START value (units) -# REPORT TIMESTEP value (units) -# REPORT START value (units) -# START CLOCKTIME value (AM PM) -# [EPA3] supports [EPA2] keyword -#-------------------------------------------------------------- - - -def inp_in_time(section: list[str]) -> str: - sql = '' - for s in section: - if s.startswith(';'): - continue - - line = s.upper().strip() - - # TOTAL DURATION => DURATION - if line.startswith('TOTAL DURATION'): - line = line.replace('TOTAL DURATION', 'DURATION') - - for key in get_time_schema('').keys(): - if line.startswith(key): - value = line.removeprefix(key).strip() - sql += f"update times set value = '{value}' where key = '{key}';" - return sql - - -def inp_out_time(name: str) -> list[str]: - lines = [] - objs = read_all(name, f"select * from times") - for obj in objs: - key = obj['key'] - value = obj['value'] - lines.append(f'{key} {value}') - return lines +from typing import Any + +from psycopg import sql + +from ..core.database import ( + ChangeSet, + DatabaseCommand, + execute_command, + g_update_prefix, + read_all, + sql_literal, +) + +TIME_STATISTIC_NONE = 'NONE' +TIME_STATISTIC_AVERAGED = 'AVERAGED' +TIME_STATISTIC_MINIMUM = 'MINIMUM' +TIME_STATISTIC_MAXIMUM = 'MAXIMUM' +TIME_STATISTIC_RANGE = 'RANGE' + +element_schema = {'type': 'str' , 'optional': True , 'readonly': False} + +def get_time_schema(name: str) -> dict[str, dict[str, Any]]: + return { 'DURATION' : element_schema, + 'HYDRAULIC TIMESTEP' : element_schema, + 'QUALITY TIMESTEP' : element_schema, + 'RULE TIMESTEP' : element_schema, + 'PATTERN TIMESTEP' : element_schema, + 'PATTERN START' : element_schema, + 'REPORT TIMESTEP' : element_schema, + 'REPORT START' : element_schema, + 'START CLOCKTIME' : element_schema, + 'STATISTIC' : element_schema} + + +def get_time(name: str) -> dict[str, Any]: + ts = read_all(name, "select key, value from network.time_settings") + d = {} + for e in ts: + d[e['key']] = str(e['value']) + return d + + +def _set_time(name: str, cs: ChangeSet) -> DatabaseCommand: + new = {} + + new_dict = cs.operations[0] + schema = get_time_schema(name) + for key in schema.keys(): + if key in new_dict: + new[key] = str(new_dict[key]) + + change = g_update_prefix | { 'type' : 'time' } + + statement = '' + for key, value in new.items(): + if statement != '': + statement += '\n' + statement += f"update network.time_settings set value = {sql_literal(value)} where key = {sql_literal(key)};" + change |= { key: value } + + return DatabaseCommand(statement, [change]) + + +def set_time(name: str, cs: ChangeSet) -> ChangeSet: + return execute_command(name, _set_time(name, cs)) + + +#-------------------------------------------------------------- +# [EPA2][EPA3] +# STATISTIC {NONE/AVERAGE/MIN/MAX/RANGE} +# DURATION value (units) +# HYDRAULIC TIMESTEP value (units) +# QUALITY TIMESTEP value (units) +# RULE TIMESTEP value (units) +# PATTERN TIMESTEP value (units) +# PATTERN START value (units) +# REPORT TIMESTEP value (units) +# REPORT START value (units) +# START CLOCKTIME value (AM PM) +# [EPA3] supports [EPA2] keyword +#-------------------------------------------------------------- + + +def inp_in_time(section: list[str]) -> str: + sql = '' + for s in section: + if s.startswith(';'): + continue + + line = s.upper().strip() + + # TOTAL DURATION => DURATION + if line.startswith('TOTAL DURATION'): + line = line.replace('TOTAL DURATION', 'DURATION') + + for key in get_time_schema('').keys(): + if line.startswith(key): + value = line.removeprefix(key).strip() + sql += f"update network.time_settings set value = {sql_literal(value)} where key = {sql_literal(key)};" + return sql + + +def inp_out_time(name: str) -> list[str]: + lines = [] + objs = read_all(name, "select key, value from network.time_settings order by key") + for obj in objs: + key = obj['key'] + value = obj['value'] + lines.append(f'{key} {value}') + return lines diff --git a/app/native/wndb/model/title.py b/app/native/wndb/model/title.py new file mode 100644 index 0000000..93652c5 --- /dev/null +++ b/app/native/wndb/model/title.py @@ -0,0 +1,52 @@ +from typing import Any + +from ..core.database import ( + ChangeSet, + DatabaseCommand, + execute_command, + g_update_prefix, + read_all, + sql_literal, +) + + +def get_title_schema(name: str) -> dict[str, dict[str, Any]]: + return {"value": {"type": "str", "optional": False, "readonly": False}} + + +def get_title(name: str) -> dict[str, Any]: + rows = read_all( + name, + "select value from network.model_titles order by sequence_no", + ) + return {"value": "\n".join(str(row["value"]) for row in rows)} + + +def _replace_title_sql(value: str) -> str: + statements = ["delete from network.model_titles;"] + for sequence_no, line in enumerate(value.split("\n")): + statements.append( + "insert into network.model_titles (sequence_no, value) " + f"values ({sequence_no}, {sql_literal(line)});" + ) + return "\n".join(statements) + + +def _set_title(name: str, cs: ChangeSet) -> DatabaseCommand: + new = str(cs.operations[0]["value"]) + return DatabaseCommand( + _replace_title_sql(new), + [g_update_prefix | {"type": "title", "value": new}], + ) + + +def set_title(name: str, cs: ChangeSet) -> ChangeSet: + return execute_command(name, _set_title(name, cs)) + + +def inp_in_title(section: list[str]) -> str: + return _replace_title_sql("\n".join(section)) + + +def inp_out_title(name: str) -> list[str]: + return str(get_title(name)["value"]).split("\n") diff --git a/app/native/wndb/s7_valves.py b/app/native/wndb/model/valves.py similarity index 59% rename from app/native/wndb/s7_valves.py rename to app/native/wndb/model/valves.py index 3192088..687af97 100644 --- a/app/native/wndb/s7_valves.py +++ b/app/native/wndb/model/valves.py @@ -1,5 +1,16 @@ -from .database import * -from .s0_base import * +from typing import Any + +from ..core.database import ( + ChangeSet, + DatabaseCommand, + execute_command, + g_add_prefix, + g_delete_prefix, + g_update_prefix, + read_all, + sql_literal, + try_read, +) VALVES_TYPE_PRV = 'PRV' @@ -21,7 +32,7 @@ def get_valve_schema(name: str) -> dict[str, dict[str, Any]]: def get_valve(name: str, id: str) -> dict[str, Any]: - p = try_read(name, f"select * from valves where id = '{id}'") + p = try_read(name, "select l.id, l.start_node_id as node1, l.end_node_id as node2, v.diameter, v.valve_type as v_type, v.setting, v.minor_loss from network.links l join network.valves v on v.link_id = l.id where l.id = %s", (id,)) if p == None: return {} d = {} @@ -35,7 +46,11 @@ def get_valve(name: str, id: str) -> dict[str, Any]: return d def get_all_valves(name: str) -> list[dict[str, Any]]: - rows = read_all(name, f"select * from valves") + rows = read_all( + name, + "SELECT id, start_node_id AS node1, end_node_id AS node2, diameter, " + "valve_type AS v_type, setting, minor_loss FROM gis.valves ORDER BY id", + ) if rows == None: return [] @@ -67,24 +82,19 @@ class Valve(object): self.setting = str(input['setting']) self.minor_loss = float(input['minor_loss']) - self.f_type = f"'{self.type}'" - self.f_id = f"'{self.id}'" - self.f_node1 = f"'{self.node1}'" - self.f_node2 = f"'{self.node2}'" - self.f_diameter = self.diameter - self.f_v_type = f"'{self.v_type}'" - self.f_setting = f"'{self.setting}'" - self.f_minor_loss = self.minor_loss + self.f_type = sql_literal(self.type) + self.f_id = sql_literal(self.id) + self.f_node1 = sql_literal(self.node1) + self.f_node2 = sql_literal(self.node2) + self.f_diameter = sql_literal(self.diameter) + self.f_v_type = sql_literal(self.v_type) + self.f_setting = sql_literal(self.setting) + self.f_minor_loss = sql_literal(self.minor_loss) 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 as_id_dict(self) -> dict[str, Any]: - return { 'type': self.type, 'id': self.id } - - -def _set_valve(name: str, cs: ChangeSet) -> DbChangeSet: - old = Valve(get_valve(name, cs.operations[0]['id'])) +def _set_valve(name: str, cs: ChangeSet) -> DatabaseCommand: raw_new = get_valve(name, cs.operations[0]['id']) new_dict = cs.operations[0] @@ -94,13 +104,11 @@ def _set_valve(name: str, cs: ChangeSet) -> DbChangeSet: raw_new[key] = new_dict[key] new = Valve(raw_new) - redo_sql = f"update valves set node1 = {new.f_node1}, node2 = {new.f_node2}, diameter = {new.f_diameter}, v_type = {new.f_v_type}, setting = {new.f_setting}, minor_loss = {new.f_minor_loss} where id = {new.f_id};" - undo_sql = f"update valves set node1 = {old.f_node1}, node2 = {old.f_node2}, diameter = {old.f_diameter}, v_type = {old.f_v_type}, setting = {old.f_setting}, minor_loss = {old.f_minor_loss} where id = {old.f_id};" + 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.valves set diameter = {new.f_diameter}, valve_type = {new.f_v_type}, setting = {new.f_setting}, minor_loss = {new.f_minor_loss} where link_id = {new.f_id};" + change = g_update_prefix | new.as_dict() - redo_cs = g_update_prefix | new.as_dict() - undo_cs = g_update_prefix | old.as_dict() - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def set_valve(name: str, cs: ChangeSet) -> ChangeSet: @@ -111,19 +119,15 @@ def set_valve(name: str, cs: ChangeSet) -> ChangeSet: return execute_command(name, _set_valve(name, cs)) -def _add_valve(name: str, cs: ChangeSet) -> DbChangeSet: +def _add_valve(name: str, cs: ChangeSet) -> DatabaseCommand: new = Valve(cs.operations[0]) - redo_sql = f"insert into _link (id, type) values ({new.f_id}, {new.f_type});" - redo_sql += f"\ninsert into valves (id, node1, node2, diameter, v_type, setting, minor_loss) values ({new.f_id}, {new.f_node1}, {new.f_node2}, {new.f_diameter}, {new.f_v_type}, {new.f_setting}, {new.f_minor_loss});" + statement = f"insert into network.links (id, link_type, start_node_id, end_node_id) values ({new.f_id}, {new.f_type}, {new.f_node1}, {new.f_node2});" + statement += f"\ninsert into network.valves (link_id, diameter, valve_type, setting, minor_loss) values ({new.f_id}, {new.f_diameter}, {new.f_v_type}, {new.f_setting}, {new.f_minor_loss});" - undo_sql = f"delete from valves where id = {new.f_id};" - undo_sql += f"\ndelete from _link where id = {new.f_id};" + change = g_add_prefix | new.as_dict() - redo_cs = g_add_prefix | new.as_dict() - undo_cs = g_delete_prefix | new.as_id_dict() - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def add_valve(name: str, cs: ChangeSet) -> ChangeSet: @@ -134,19 +138,15 @@ def add_valve(name: str, cs: ChangeSet) -> ChangeSet: return execute_command(name, _add_valve(name, cs)) -def _delete_valve(name: str, cs: ChangeSet) -> DbChangeSet: - old = Valve(get_valve(name, cs.operations[0]['id'])) +def _delete_valve(name: str, cs: ChangeSet) -> DatabaseCommand: + element_id = str(cs.operations[0]['id']) + f_id = sql_literal(element_id) - redo_sql = f"delete from valves where id = {old.f_id};" - redo_sql += f"\ndelete from _link where id = {old.f_id};" + statement = f"delete from network.links where id = {f_id};" - undo_sql = f"insert into _link (id, type) values ({old.f_id}, {old.f_type});" - undo_sql += f"\ninsert into valves (id, node1, node2, diameter, v_type, setting, minor_loss) values ({old.f_id}, {old.f_node1}, {old.f_node2}, {old.f_diameter}, {old.f_v_type}, {old.f_setting}, {old.f_minor_loss});" + change = g_delete_prefix | {'type': 'valve', 'id': element_id} - redo_cs = g_delete_prefix | old.as_id_dict() - undo_cs = g_add_prefix | old.as_dict() - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) + return DatabaseCommand(statement, [change]) def delete_valve(name: str, cs: ChangeSet) -> ChangeSet: @@ -181,12 +181,12 @@ def inp_in_valve(line: str) -> str: minor_loss = float(tokens[6]) if len(tokens) >= 7 else 0.0 desc = str(tokens[-1]) if has_desc else None - return str(f"insert into _link (id, type) values ('{id}', 'valve');insert into valves (id, node1, node2, diameter, v_type, setting, minor_loss) values ('{id}', '{node1}', '{node2}', {diameter}, '{v_type}', '{setting}', {minor_loss});") + return str(f"insert into network.links (id, link_type, start_node_id, end_node_id) values ({sql_literal(id)}, 'valve', {sql_literal(node1)}, {sql_literal(node2)});insert into network.valves (link_id, diameter, valve_type, setting, minor_loss) values ({sql_literal(id)}, {sql_literal(diameter)}, {sql_literal(v_type)}, {sql_literal(setting)}, {sql_literal(minor_loss)});") def inp_out_valve(name: str) -> list[str]: lines = [] - objs = read_all(name, 'select * from valves') + objs = read_all(name, 'select l.id, l.start_node_id as node1, l.end_node_id as node2, v.diameter, v.valve_type as v_type, v.setting, v.minor_loss from network.links l join network.valves v on v.link_id = l.id order by l.id') for obj in objs: id = obj['id'] node1 = obj['node1'] @@ -198,13 +198,3 @@ def inp_out_valve(name: str) -> list[str]: desc = ';' lines.append(f'{id} {node1} {node2} {diameter} {v_type} {setting} {minor_loss} {desc}') return lines - - -'''def delete_valve_by_node(name: str, node: str) -> ChangeSet: - cs = ChangeSet() - - rows = read_all(name, f"select id from valves where node1 = '{node}' or node2 = '{node}'") - for row in rows: - cs.append(g_delete_prefix | {'type': 'valve', 'id': row['id']}) - - return cs''' diff --git a/app/native/wndb/project.py b/app/native/wndb/project.py deleted file mode 100644 index 2a7bca6..0000000 --- a/app/native/wndb/project.py +++ /dev/null @@ -1,192 +0,0 @@ -import os -import psycopg as pg -from psycopg import sql -from psycopg.rows import dict_row -from .connection import ( - close_connection, - is_connection_open, - open_connection, -) -from app.core.config import get_pg_config, get_pg_password -from app.infra.db.project_routing import get_project_pgconn_string - -# no undo/redo - -_server_databases = ["template0", "template1", "postgres", "project"] - - -def list_project() -> list[str]: - ps = [] - with pg.connect(conninfo=get_project_pgconn_string(), autocommit=True) as conn: - with conn.cursor(row_factory=dict_row) as cur: - for p in cur.execute( - f"select datname from pg_database where datname <> 'postgres' and datname <> 'template0' and datname <> 'template1' and datname <> 'project'" - ): - ps.append(p["datname"]) - return ps - - -def have_project(name: str) -> bool: - with pg.connect( - conninfo=get_project_pgconn_string(db_name="postgres"), autocommit=True - ) as conn: - with conn.cursor() as cur: - cur.execute("select 1 from pg_database where datname = %s", (name,)) - return cur.fetchone() is not None - - -def copy_project(source: str, new: str) -> None: - close_connection(source) - - with pg.connect( - conninfo=get_project_pgconn_string(db_name="postgres"), autocommit=True - ) as admin_conn: - with admin_conn.cursor() as cur: - cur.execute( - "update pg_database set datallowconn = false where datname = %s", - (source,), - ) - try: - cur.execute( - "select pg_terminate_backend(pid) from pg_stat_activity where datname = %s and pid <> pg_backend_pid()", - (source,), - ) - cur.execute( - sql.SQL("create database {} with template = {}").format( - sql.Identifier(new), sql.Identifier(source) - ) - ) - finally: - cur.execute( - "update pg_database set datallowconn = true where datname = %s", - (source,), - ) - - -# 2025-02-07, WMH -# copyproject会把pg中operation这个表的全部内容也加进去,我们实际项目运行一周后operation这个表会变得特别大,导致CopyProject花费的时间很长,CopyProjectEx把operation的在复制时没有一块复制过去,节省时间 -class CopyProjectEx: - @staticmethod - def create_database(connection, new_db): - with connection.cursor() as cursor: - cursor.execute(f'create database "{new_db}"') - connection.commit() - - @staticmethod - def execute_pg_dump(source_db, exclude_table_list): - - os.environ["PGPASSWORD"] = get_pg_password() # 设置密码环境变量 - pg_config = get_pg_config() - host = pg_config["host"] - port = pg_config["port"] - user = pg_config["user"] - dump_command_structure = f"pg_dump -h {host} -p {port} -U {user} -F c -s -f source_db_structure.dump {source_db}" - os.system(dump_command_structure) - - if exclude_table_list is not None: - exclude_table = " ".join(["-T {}".format(i) for i in exclude_table_list]) - dump_command_db = f"pg_dump -h {host} -p {port} -U {user} -F c -a {exclude_table} -f source_db.dump {source_db}" - else: - dump_command_db = f"pg_dump -h {host} -p {port} -U {user} -F c -a -f source_db.dump {source_db}" - os.system(dump_command_db) - - @staticmethod - def execute_pg_restore(new_db): - os.environ["PGPASSWORD"] = get_pg_password() # 设置密码环境变量 - pg_config = get_pg_config() - host = pg_config["host"] - port = pg_config["port"] - user = pg_config["user"] - restore_command_structure = f"pg_restore -h {host} -p {port} -U {user} -d {new_db} source_db_structure.dump" - os.system(restore_command_structure) - - restore_command_db = ( - f"pg_restore -h {host} -p {port} -U {user} -d {new_db} source_db.dump" - ) - os.system(restore_command_db) - - @staticmethod - def init_operation_table(connection, excluded_table): - with connection.cursor() as cursor: - if "operation" in excluded_table: - insert_query = "insert into operation (id, redo, undo, redo_cs, undo_cs) values (0, '', '', '', '')" - cursor.execute(insert_query) - - if "current_operation" in excluded_table: - insert_query = "insert into current_operation (id) values (0)" - cursor.execute(insert_query) - - if "restore_operation" in excluded_table: - insert_query = "insert into restore_operation (id) values (0)" - cursor.execute(insert_query) - - if "batch_operation" in excluded_table: - insert_query = "insert into batch_operation (id, redo, undo, redo_cs, undo_cs) values (0, '', '', '', '')" - cursor.execute(insert_query) - - if "operation_table" in excluded_table: - insert_query = ( - "insert into operation_table (option) values ('operation')" - ) - cursor.execute(insert_query) - connection.commit() - - def __call__(self, source: str, new_db: str, excluded_tables: [str] = None) -> None: - source_connection = pg.connect( - conninfo=get_project_pgconn_string(), autocommit=True - ) - - self.create_database(source_connection, new_db) - - self.execute_pg_dump(source, excluded_tables) - self.execute_pg_restore(new_db) - source_connection.close() - - new_db_connection = pg.connect( - conninfo=get_project_pgconn_string(db_name=new_db), autocommit=True - ) - self.init_operation_table(new_db_connection, excluded_tables) - new_db_connection.close() - - -def create_project(name: str) -> None: - return copy_project("project", name) - - -def delete_project(name: str) -> None: - with pg.connect(conninfo=get_project_pgconn_string(), autocommit=True) as conn: - with conn.cursor() as cur: - cur.execute( - f"select pg_terminate_backend(pid) from pg_stat_activity where datname = '{name}'" - ) - cur.execute(f'drop database "{name}"') - - -def clean_project(excluded: list[str] = []) -> None: - projects = list_project() - with pg.connect(conninfo=get_project_pgconn_string(), autocommit=True) as conn: - with conn.cursor(row_factory=dict_row) as cur: - row = cur.execute(f"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( - f"select pg_terminate_backend(pid) from pg_stat_activity where datname = '{project}'" - ) - cur.execute(f'drop database "{project}"') - - -def open_project(name: str) -> None: - open_connection(name) - - -def is_project_open(name: str) -> bool: - return is_connection_open(name) - - -def close_project(name: str) -> None: - close_connection(name) diff --git a/app/native/wndb/s0_base.py b/app/native/wndb/s0_base.py deleted file mode 100644 index 02ee733..0000000 --- a/app/native/wndb/s0_base.py +++ /dev/null @@ -1,281 +0,0 @@ -from psycopg import sql -from psycopg.rows import dict_row, Row -from .connection import project_connection -from .database import read -from typing import Any - -_NODE = '_node' -_LINK = '_link' -_CURVE = '_curve' -_PATTERN = '_pattern' -_REGION = '_region' - -JUNCTION = 'junction' -RESERVOIR = 'reservoir' -TANK = 'tank' -PIPE = 'pipe' -PUMP = 'pump' -VALVE = 'valve' - -PATTERN = 'pattern' -CURVE = 'curve' - -REGION = 'region' - -# DingZQ, 2025-02-05 -''' - C++ 代码里已经定义了这些 enum 值 -{ - kNothing = -1, - - //Node - kReservoir = 0, - kTank, - kJunction, - - //Link - kPipe, - kPump, - kValve, -''' -ELEMENT_TYPES : dict[str, int] = { - RESERVOIR : 0, - TANK : 1, - JUNCTION : 2, - PIPE : 3, - PUMP : 4, - VALVE : 5, -} - -def _get_from(name: str, id: str, base_type: str) -> Row | None: - with project_connection(name) as conn: - with conn.cursor(row_factory=dict_row) as cur: - cur.execute( - sql.SQL("select * from {} where id = %s").format( - sql.Identifier(base_type) - ), - (id,), - ) - return cur.fetchone() - - -def is_node(name: str, id: str) -> bool: - return _get_from(name, id, _NODE) != None - - -def is_junction(name: str, id: str) -> bool: - row = _get_from(name, id, _NODE) - return row != None and row['type'] == JUNCTION - - -def is_reservoir(name: str, id: str) -> bool: - row = _get_from(name, id, _NODE) - return row != None and row['type'] == RESERVOIR - - -def is_tank(name: str, id: str) -> bool: - row = _get_from(name, id, _NODE) - return row != None and row['type'] == TANK - - -def is_link(name: str, id: str) -> bool: - return _get_from(name, id, _LINK) != None - - -def is_pipe(name: str, id: str) -> bool: - row = _get_from(name, id, _LINK) - return row != None and row['type'] == PIPE - - -def is_pump(name: str, id: str) -> bool: - row = _get_from(name, id, _LINK) - return row != None and row['type'] == PUMP - - -def is_valve(name: str, id: str) -> bool: - row = _get_from(name, id, _LINK) - return row != None and row['type'] == VALVE - -# DingZQ, 2025-02-05 -def get_node_type(name: str, node_id: str) -> str: - row = _get_from(name, node_id, _NODE) - return row['type'] - - -def get_link_type(name: str, link_id: str) -> str: - row = _get_from(name, link_id, _LINK) - return row['type'] - -def get_element_type(name: str, element_id: str) -> str: - if is_node(name, element_id): - return get_node_type(name, element_id) - elif is_link(name, element_id): - return get_link_type(name, element_id) - else: - return None - -def get_element_type_value(name: str, element_id: str) -> int: - return ELEMENT_TYPES[get_element_type(name, element_id)] - -def is_curve(name: str, id: str) -> bool: - - return _get_from(name, id, _CURVE) != None - - -def is_pattern(name: str, id: str) -> bool: - return _get_from(name, id, _PATTERN) != None - - -def is_region(name: str, id: str) -> bool: - return _get_from(name, id, _REGION) != None - - -def _get_all(name: str, base_type: str) -> list[str]: - ids : list[str] = [] - with project_connection(name) as conn: - with conn.cursor(row_factory=dict_row) as cur: - cur.execute(f"select id from {base_type} order by id") - for record in cur: - ids.append(record['id']) - return ids - - -def get_nodes(name: str) -> list[str]: - return _get_all(name, _NODE) - -# DingZQ -def _get_nodes_by_type(name: str, type: str) -> list[str]: - ids : list[str] = [] - with project_connection(name) as conn: - with conn.cursor(row_factory=dict_row) as cur: - cur.execute(f"select id from {_NODE} where type = '{type}' order by id") - for record in cur: - ids.append(record['id']) - return ids - -# DingZQ -def get_nodes_id_and_type(name: str) -> dict[str, str]: - nodes_id_and_type: dict[str, str] = {} - with project_connection(name) as conn: - with conn.cursor(row_factory=dict_row) as cur: - cur.execute(f"select id, type from {_NODE} order by id") - for record in cur: - nodes_id_and_type[record['id']] = record['type'] - return nodes_id_and_type - -# DingZQ 2024-12-31 -def get_major_nodes(name: str, diameter: int) -> list[str]: - major_nodes_set = set() - with project_connection(name) as conn: - with conn.cursor(row_factory=dict_row) as cur: - cur.execute(f"select node1, node2 from pipes where diameter > {diameter}") - for record in cur: - major_nodes_set.add(record['node1']) - major_nodes_set.add(record['node2']) - - return list(major_nodes_set) - -# DingZQs -def get_junctions(name: str) -> list[str]: - return _get_nodes_by_type(name, JUNCTION) - -# DingZQ -def get_reservoirs(name: str) -> list[str]: - return _get_nodes_by_type(name, RESERVOIR) - -# DingZQ -def get_tanks(name: str) -> list[str]: - return _get_nodes_by_type(name, TANK) - -# DingZQ -def get_links(name: str) -> list[str]: - return _get_all(name, _LINK) - -# DingZQ -def _get_links_by_type(name: str, type: str) -> list[str]: - ids : list[str] = [] - with project_connection(name) as conn: - with conn.cursor(row_factory=dict_row) as cur: - cur.execute(f"select id from {_LINK} where type = '{type}' order by id") - for record in cur: - ids.append(record['id']) - return ids - -# DingZQ -def get_links_id_and_type(name: str) -> dict[str, str]: - links_id_and_type: dict[str, str] = {} - with project_connection(name) as conn: - with conn.cursor(row_factory=dict_row) as cur: - cur.execute(f"select id, type from {_LINK} order by id") - for record in cur: - links_id_and_type[record['id']] = record['type'] - return links_id_and_type - -# DingZQ 2024-12-31 -# 获取直径大于800的管道 -def get_major_pipes(name: str, diameter: int) -> list[str]: - major_pipe_ids: list[str] = [] - with project_connection(name) as conn: - with conn.cursor(row_factory=dict_row) as cur: - cur.execute(f"select id from pipes where diameter > {diameter} order by id") - for record in cur: - major_pipe_ids.append(record['id']) - return major_pipe_ids - -# DingZQ -def get_pipes(name: str) -> list[str]: - return _get_links_by_type(name, PIPE) - -# DingZQ -def get_pumps(name: str) -> list[str]: - return _get_links_by_type(name, PUMP) - -# DingZQ -def get_valves(name: str) -> list[str]: - return _get_links_by_type(name, VALVE) - - -def get_curves(name: str) -> list[str]: - return _get_all(name, _CURVE) - - -def get_patterns(name: str) -> list[str]: - return _get_all(name, _PATTERN) - -def get_regions(name: str) -> list[str]: - return _get_all(name, _REGION) - -def get_node_links(name: str, id: str) -> list[str]: - with project_connection(name) as conn: - with conn.cursor(row_factory=dict_row) as cur: - links: list[str] = [] - for p in cur.execute( - "select id from pipes where node1 = %s or node2 = %s", (id, id) - ).fetchall(): - links.append(p['id']) - for p in cur.execute( - "select id from pumps where node1 = %s or node2 = %s", (id, id) - ).fetchall(): - links.append(p['id']) - for p in cur.execute( - "select id from valves where node1 = %s or node2 = %s", (id, id) - ).fetchall(): - links.append(p['id']) - return links - - -def get_link_nodes(name: str, id: str) -> list[str]: - row = {} - if is_pipe(name, id): - row = read(name, "select node1, node2 from pipes where id = %s", (id,)) - elif is_pump(name, id): - row = read(name, "select node1, node2 from pumps where id = %s", (id,)) - elif is_valve(name, id): - row = read(name, "select node1, node2 from valves where id = %s", (id,)) - return [str(row['node1']), str(row['node2'])] - -def get_region_type(name: str, id: str)->str: - if(is_region(name,id)): - type = read(name, "select type from _region where id = %s", (id,)) - return type - diff --git a/app/native/wndb/s14_rules.py b/app/native/wndb/s14_rules.py deleted file mode 100644 index b210f71..0000000 --- a/app/native/wndb/s14_rules.py +++ /dev/null @@ -1,48 +0,0 @@ -from .database import * - - -def get_rule_schema(name: str) -> dict[str, dict[str, Any]]: - return { 'rules' : {'type': 'str_list' , 'optional': False , 'readonly': False} } - - -def get_rule(name: str) -> dict[str, Any]: - cs = read_all(name, f"select * from rules") - ds = [] - for c in cs: - ds.append(c['line']) - return { 'rules': ds } - - -def _set_rule(name: str, cs: ChangeSet) -> DbChangeSet: - old = get_rule(name) - - redo_sql = 'delete from rules;' - for line in cs.operations[0]['rules']: - redo_sql += f"\ninsert into rules (line) values ('{line}');" - - undo_sql = 'delete from rules;' - for line in old['rules']: - undo_sql += f"\ninsert into rules (line) values ('{line}');" - - redo_cs = g_update_prefix | { 'type': 'rule', 'rules': cs.operations[0]['rules'] } - undo_cs = g_update_prefix | { 'type': 'rule', 'rules': old['rules'] } - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) - - -def set_rule(name: str, cs: ChangeSet) -> ChangeSet: - return execute_command(name, _set_rule(name, cs)) - - -#-------------------------------------------------------------- -# [EPA2][EPA3] -# TODO... -#-------------------------------------------------------------- - - -def inp_in_rule(line: str) -> str: - return str(f"insert into rules (line) values ('{line}');") - - -def inp_out_rule(name: str) -> list[str]: - return get_rule(name)['rules'] \ No newline at end of file diff --git a/app/native/wndb/s15_energy.py b/app/native/wndb/s15_energy.py deleted file mode 100644 index 3abb998..0000000 --- a/app/native/wndb/s15_energy.py +++ /dev/null @@ -1,240 +0,0 @@ -from .database import * - - -element_schema = {'type': 'str' , 'optional': True , 'readonly': False} - - -def get_energy_schema(name: str) -> dict[str, dict[str, Any]]: - return { 'GLOBAL PRICE' : element_schema, - 'GLOBAL PATTERN' : element_schema, - 'GLOBAL EFFIC' : element_schema, - 'DEMAND CHARGE' : element_schema } - - -def get_energy(name: str) -> dict[str, Any]: - ts = read_all(name, f"select * from energy") - d = {} - for e in ts: - d[e['key']] = str(e['value']) - return d - - -def _set_energy(name: str, cs: ChangeSet) -> DbChangeSet: - raw_old = get_energy(name) - - old = {} - new = {} - - new_dict = cs.operations[0] - schema = get_energy_schema(name) - for key in schema.keys(): - if key in new_dict: - old[key] = str(raw_old[key]) - new[key] = str(new_dict[key]) - - redo_cs = g_update_prefix | { 'type' : 'energy' } - - redo_sql = '' - for key, value in new.items(): - if redo_sql != '': - redo_sql += '\n' - redo_sql += f"update energy set value = '{value}' where key = '{key}';" - redo_cs |= { key: value } - - undo_cs = g_update_prefix | { 'type' : 'energy' } - - undo_sql = '' - for key, value in old.items(): - if undo_sql != '': - undo_sql += '\n' - undo_sql += f"update energy set value = '{value}' where key = '{key}';" - undo_cs |= { key: value } - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) - - -def set_energy(name: str, cs: ChangeSet) -> ChangeSet: - return execute_command(name, _set_energy(name, cs)) - - -def get_pump_energy_schema(name: str) -> dict[str, dict[str, Any]]: - return { 'pump' : {'type': 'str' , 'optional': False , 'readonly': True }, - 'price' : {'type': 'float' , 'optional': True , 'readonly': False}, - 'pattern' : {'type': 'str' , 'optional': True , 'readonly': False}, - 'effic' : {'type': 'str' , 'optional': True , 'readonly': False} } - - -def get_pump_energy(name: str, pump: str) -> dict[str, Any]: - d = {} - d['pump'] = pump - pe = try_read(name, f"select * from energy_pump_price where pump = '{pump}'") - d['price'] = float(pe['price']) if pe != None else None - pe = try_read(name, f"select * from energy_pump_pattern where pump = '{pump}'") - d['pattern'] = str(pe['pattern']) if pe != None else None - pe = try_read(name, f"select * from energy_pump_effic where pump = '{pump}'") - d['effic'] = str(pe['effic']) if pe != None else None - return d - - -class PumpEnergy(object): - def __init__(self, input: dict[str, Any]) -> None: - self.type = 'pump_energy' - self.pump = str(input['pump']) - self.price = float(input['price']) if 'price' in input and input['price'] != None else None - self.pattern = str(input['pattern']) if 'pattern' in input and input['pattern'] != None else None - self.effic = str(input['effic']) if 'effic' in input and input['effic'] != None else None - - self.f_type = f"'{self.type}'" - self.f_pump = f"'{self.pump}'" - self.f_price = self.price if self.price != None else 'null' - self.f_pattern = f"'{self.pattern}'" if self.pattern != None else 'null' - self.f_effic = f"'{self.effic}'" if self.effic != None else 'null' - - def as_dict(self) -> dict[str, Any]: - return { 'type': self.type, 'pump': self.pump, 'price': self.price, 'pattern': self.pattern, 'effic': self.effic } - - -def _set_pump_energy(name: str, cs: ChangeSet) -> DbChangeSet: - old = PumpEnergy(get_pump_energy(name, cs.operations[0]['pump'])) - raw_new = get_pump_energy(name, cs.operations[0]['pump']) - - new_dict = cs.operations[0] - schema = get_pump_energy_schema(name) - for key, value in schema.items(): - if key in new_dict and not value['readonly']: - raw_new[key] = new_dict[key] - new = PumpEnergy(raw_new) - - redo_sql = f"delete from energy_pump_price where pump = {new.f_pump};\ndelete from energy_pump_pattern where pump = {new.f_pump};\ndelete from energy_pump_effic where pump = {new.f_pump};" - if new.price != None: - redo_sql += f"\ninsert into energy_pump_price (pump, price) values ({new.f_pump}, {new.f_price});" - if new.pattern != None: - redo_sql += f"\ninsert into energy_pump_pattern (pump, pattern) values ({new.f_pump}, {new.f_pattern});" - if new.effic != None: - redo_sql += f"\ninsert into energy_pump_effic (pump, effic) values ({new.f_pump}, {new.f_effic});" - - undo_sql = f"delete from energy_pump_price where pump = {old.f_pump};\ndelete from energy_pump_pattern where pump = {old.f_pump};\ndelete from energy_pump_effic where pump = {old.f_pump};" - if old.price != None: - undo_sql += f"\ninsert into energy_pump_price (pump, price) values ({old.f_pump}, {old.f_price});" - if old.pattern != None: - undo_sql += f"\ninsert into energy_pump_pattern (pump, pattern) values ({old.f_pump}, {old.f_pattern});" - if old.effic != None: - undo_sql += f"\ninsert into energy_pump_effic (pump, effic) values ({old.f_pump}, {old.f_effic});" - - redo_cs = g_update_prefix | new.as_dict() - undo_cs = g_update_prefix | old.as_dict() - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) - - -def set_pump_energy(name: str, cs: ChangeSet) -> ChangeSet: - return execute_command(name, _set_pump_energy(name, cs)) - - -#-------------------------------------------------------------- -# [EPA2][EPA3][IN][OUT] -# GLOBAL {PRICE/PATTERN/EFFIC} value -# PUMP id {PRICE/PATTERN/EFFIC} value -# DEMAND CHARGE value -#-------------------------------------------------------------- - - -def inp_in_energy(line: str) -> str: - tokens = line.split() - - if tokens[0].upper() == 'PUMP': - pump = tokens[1] - key = tokens[2].lower() - value = tokens[3] - if key == 'price': - value = float(value) - else: - value = f"'{value}'" - if key == 'efficiency': - key = 'effic' - - return str(f"insert into energy_pump_{key} (pump, {key}) values ('{pump}', {value});") - - else: - line = line.upper().strip() - for key in get_energy_schema('').keys(): - if line.startswith(key): - value = line.removeprefix(key).strip() - - # exception here - if line.startswith('GLOBAL EFFICIENCY'): - value = line.removeprefix('GLOBAL EFFICIENCY').strip() - - return str(f"update energy set value = '{value}' where key = '{key}';") - - return str('') - - -def inp_out_energy(name: str) -> list[str]: - lines = [] - - objs = read_all(name, f"select * from energy") - for obj in objs: - key = obj['key'] - value = obj['value'] - if value.strip() != '': - lines.append(f'{key} {value}') - - objs = read_all(name, f"select * from energy_pump_price") - for obj in objs: - pump = obj['pump'] - value = obj['price'] - lines.append(f'PUMP {pump} PRICE {value}') - - objs = read_all(name, f"select * from energy_pump_pattern") - for obj in objs: - pump = obj['pump'] - value = obj['pattern'] - lines.append(f'PUMP {pump} PATTERN {value}') - - objs = read_all(name, f"select * from energy_pump_effic") - for obj in objs: - pump = obj['pump'] - value = obj['effic'] - lines.append(f'PUMP {pump} EFFIC {value}') - - return lines - - -def delete_pump_energy_by_pump(name: str, pump: str) -> ChangeSet: - row1 = try_read(name, f"select * from energy_pump_price where pump = '{pump}'") - row2 = try_read(name, f"select * from energy_pump_pattern where pump = '{pump}'") - row3 = try_read(name, f"select * from energy_pump_effic where pump = '{pump}'") - if row1 == None and row2 == None and row3 == None: - return ChangeSet() - return ChangeSet(g_update_prefix | {'type': 'pump_energy', 'pump' : pump, 'price': None, 'pattern': None, 'effic': None}) - - -def unset_pump_energy_by_pattern(name: str, pattern: str) -> ChangeSet: - cs = ChangeSet() - - rows = read_all(name, f"select * from energy_pump_pattern where pattern = '{pattern}'") - for row in rows: - pump = row['pump'] - row1 = try_read(name, f"select * from energy_pump_price where pump = '{pump}'") - price = float(row1['price']) if row1 != None else None - row2 = try_read(name, f"select * from energy_pump_effic where pump = '{pump}'") - effic = str(row2['effic']) if row2 != None else None - cs.append(g_update_prefix | {'type': 'pump_energy', 'pump' : pump, 'price': price, 'pattern': None, 'effic': effic}) - - return cs - - -def unset_pump_energy_by_curve(name: str, curve: str) -> ChangeSet: - cs = ChangeSet() - - rows = read_all(name, f"select * from energy_pump_effic where effic = '{curve}'") - for row in rows: - pump = row['pump'] - row1 = try_read(name, f"select * from energy_pump_price where pump = '{pump}'") - price = float(row1['price']) if row1 != None else None - row2 = try_read(name, f"select * from energy_pump_pattern where pump = '{pump}'") - pattern = str(row2['pattern']) if row2 != None else None - cs.append(g_update_prefix | {'type': 'pump_energy', 'pump' : pump, 'price': price, 'pattern': pattern, 'effic': None}) - - return cs diff --git a/app/native/wndb/s1_title.py b/app/native/wndb/s1_title.py deleted file mode 100644 index 3ec5d6a..0000000 --- a/app/native/wndb/s1_title.py +++ /dev/null @@ -1,40 +0,0 @@ -from .database import * - - -def get_title_schema(name: str) -> dict[str, dict[str, Any]]: - return {'value': {'type': 'float', 'optional': False, 'readonly': False}} - - -def get_title(name: str) -> dict[str, Any]: - title = read(name, 'select * from title') - return { 'value': title['value'] } - - -def _set_title(name: str, cs: ChangeSet) -> DbChangeSet: - new = cs.operations[0]['value'] - old = get_title(name)['value'] - - redo_sql = f"update title set value = '{new}';" - undo_sql = f"update title set value = '{old}';" - - redo_cs = g_update_prefix | { 'type': 'title', 'value': new } - undo_cs = g_update_prefix | { 'type': 'title', 'value': old } - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) - - -def set_title(name: str, cs: ChangeSet) -> ChangeSet: - return execute_command(name, _set_title(name ,cs)) - - -def inp_in_title(section: list[str]) -> str: - if section == []: - return str('') - - title = '\n'.join(section) - return str(f"update title set value = '{title}';") - - -def inp_out_title(name: str) -> list[str]: - obj = str(get_title(name)['value']) - return obj.split('\n') diff --git a/app/native/wndb/s27_backdrop.py b/app/native/wndb/s27_backdrop.py deleted file mode 100644 index bedc8fa..0000000 --- a/app/native/wndb/s27_backdrop.py +++ /dev/null @@ -1,39 +0,0 @@ -from .database import * - - -def get_backdrop_schema(name: str) -> dict[str, dict[str, Any]]: - return { 'content' : {'type': 'str' , 'optional': False , 'readonly': False} } - - -def get_backdrop(name: str) -> dict[str, Any]: - e = read(name, f"select * from backdrop") - return { 'content': e['content'] } - - -def _set_backdrop(name: str, cs: ChangeSet) -> DbChangeSet: - old = get_backdrop(name) - - redo_sql = f"update backdrop set content = '{cs.operations[0]['content']}' where content = '{old['content']}';" - undo_sql = f"update backdrop set content = '{old['content']}' where content = '{cs.operations[0]['content']}';" - - redo_cs = g_update_prefix | { 'type': 'backdrop', 'content': cs.operations[0]['content'] } - undo_cs = g_update_prefix | { 'type': 'backdrop', 'content': old['content'] } - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) - - -def set_backdrop(name: str, cs: ChangeSet) -> ChangeSet: - return execute_command(name, _set_backdrop(name, cs)) - - -def inp_in_backdrop(section: list[str]) -> str: - if section == []: - return str('') - - content = '\n'.join(section) - return str(f"update backdrop set content = '{content}';") - - -def inp_out_backdrop(name: str) -> list[str]: - obj = str(get_backdrop(name)['content']) - return obj.split('\n') \ No newline at end of file diff --git a/app/native/wndb/s28_end.py b/app/native/wndb/s28_end.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/native/wndb/s29_scada_device.py b/app/native/wndb/s29_scada_device.py deleted file mode 100644 index ec2f139..0000000 --- a/app/native/wndb/s29_scada_device.py +++ /dev/null @@ -1,123 +0,0 @@ -from .database import * - - -SCADA_DEVICE_TYPE_PRESSURE = 'PRESSURE' -SCADA_DEVICE_TYPE_DEMAND = 'DEMAND' -SCADA_DEVICE_TYPE_QUALITY = 'QUALITY' -SCADA_DEVICE_TYPE_LEVEL = 'LEVEL' -SCADA_DEVICE_TYPE_FLOW = 'FLOW' -SCADA_DEVICE_TYPE_UNKNOWN = 'UNKNOWN' - - -def get_scada_device_schema(name: str) -> dict[str, dict[str, Any]]: - return { 'id' : {'type': 'str', 'optional': False, 'readonly': True }, - 'name' : {'type': 'str', 'optional': True , 'readonly': False}, - 'address': {'type': 'str', 'optional': True , 'readonly': False}, - 'sd_type': {'type': 'str', 'optional': True , 'readonly': False}} - - -def get_scada_device(name: str, id: str) -> dict[str, Any]: - sm = try_read(name, f"select * from scada_device where id = '{id}'") - if sm == None: - return {} - d = {} - d['id'] = str(sm['id']) - d['name'] = str(sm['name']) if sm['name'] != None else None - d['address'] = str(sm['address']) if sm['address'] != None else None - d['sd_type'] = str(sm['sd_type']) if sm['sd_type'] != None else None - return d - - -class ScadaDevice(object): - def __init__(self, input: dict[str, Any]) -> None: - self.type = 'scada_device' - self.id = str(input['id']) - self.name = str(input['name']) if 'name' in input and input['name'] != None else None - self.address = str(input['address']) if 'address' in input and input['address'] != None else None - self.sd_type = str(input['sd_type']) if 'sd_type' in input and input['sd_type'] != None else None - - self.f_type = f"'{self.type}'" - self.f_id = f"'{self.id}'" - self.f_name = f"'{self.name}'" if self.name != None else 'null' - self.f_address = f"'{self.address}'" if self.address != None else 'null' - self.f_sd_type = f"'{self.sd_type}'" if self.sd_type != None else 'null' - - def as_dict(self) -> dict[str, Any]: - return { 'type': self.type, 'id': self.id, 'name': self.name, 'address': self.address, 'sd_type': self.sd_type } - - def as_id_dict(self) -> dict[str, Any]: - return { 'type': self.type, 'id': self.id } - - -def _set_scada_device(name: str, cs: ChangeSet) -> DbChangeSet: - old = ScadaDevice(get_scada_device(name, cs.operations[0]['id'])) - raw_new = get_scada_device(name, cs.operations[0]['id']) - - new_dict = cs.operations[0] - schema = get_scada_device_schema(name) - for key, value in schema.items(): - if key in new_dict and not value['readonly']: - raw_new[key] = new_dict[key] - new = ScadaDevice(raw_new) - - redo_sql = f"update scada_device set name = {new.f_name}, address = {new.f_address}, sd_type = {new.f_sd_type} where id = {new.f_id};" - undo_sql = f"update scada_device set name = {old.f_name}, address = {old.f_address}, sd_type = {old.f_sd_type} where id = {old.f_id};" - - redo_cs = g_update_prefix | new.as_dict() - undo_cs = g_update_prefix | old.as_dict() - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) - - -def set_scada_device(name: str, cs: ChangeSet) -> ChangeSet: - if get_scada_device(name, cs.operations[0]['id']) == {}: - return ChangeSet() - return execute_command(name, _set_scada_device(name, cs)) - - -def _add_scada_device(name: str, cs: ChangeSet) -> DbChangeSet: - new = ScadaDevice(cs.operations[0]) - - redo_sql = f"insert into scada_device (id, name, address, sd_type) values ({new.f_id}, {new.f_name}, {new.f_address}, {new.f_sd_type});" - undo_sql = f"delete from scada_device where id = {new.f_id};" - - redo_cs = g_add_prefix | new.as_dict() - undo_cs = g_delete_prefix | new.as_id_dict() - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) - - -def add_scada_device(name: str, cs: ChangeSet) -> ChangeSet: - if get_scada_device(name, cs.operations[0]['id']) != {}: - return ChangeSet() - return execute_command(name, _add_scada_device(name, cs)) - - -def _delete_scada_device(name: str, cs: ChangeSet) -> DbChangeSet: - old = ScadaDevice(get_scada_device(name, cs.operations[0]['id'])) - - redo_sql = f"delete from scada_device where id = {old.f_id};" - undo_sql = f"insert into scada_device (id, name, address, sd_type) values ({old.f_id}, {old.f_name}, {old.f_address}, {old.f_sd_type});" - - redo_cs = g_delete_prefix | old.as_id_dict() - undo_cs = g_add_prefix | old.as_dict() - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) - - -def delete_scada_device(name: str, cs: ChangeSet) -> ChangeSet: - if get_scada_device(name, cs.operations[0]['id']) == {}: - return ChangeSet() - return execute_command(name, _delete_scada_device(name, cs)) - - -def get_all_scada_device_ids(name: str) -> list[str]: - result : list[str] = [] - rows = read_all(name, 'select id from scada_device order by id') - for row in rows: - result.append(str(row['id'])) - return result - - -def get_all_scada_devices(name: str) -> list[dict[str, Any]]: - return read_all(name, 'select * from scada_device order by id') diff --git a/app/native/wndb/s30_scada_device_data.py b/app/native/wndb/s30_scada_device_data.py deleted file mode 100644 index 5f23800..0000000 --- a/app/native/wndb/s30_scada_device_data.py +++ /dev/null @@ -1,90 +0,0 @@ -from .database import * - - -def get_scada_device_data_schema(name: str) -> dict[str, dict[str, Any]]: - return { 'device_id' : {'type': 'str' , 'optional': False , 'readonly': True }, - 'data' : {'type': 'list' , 'optional': False , 'readonly': False, - 'element': { 'time' : {'type': 'str' , 'optional': False , 'readonly': False }, - 'value' : {'type': 'float' , 'optional': False , 'readonly': False } }}} - - -def get_scada_device_data(name: str, device_id: str) -> dict[str, Any]: - sds = read_all(name, f"select * from scada_device_data where device_id = '{device_id}' order by time") - ds = [] - for r in sds: - ds.append({ 'time': str(r['time']), 'value': float(r['value']) }) - return { 'device_id': device_id, 'data': ds } - - -def _set_scada_device_data(name: str, cs: ChangeSet) -> DbChangeSet: - device_id = cs.operations[0]['device_id'] - - old = get_scada_device_data(name, device_id) - new = { 'device_id': device_id, 'data': [] } - - f_device_id = f"'{device_id}'" - - # TODO: transaction ? - redo_sql = f"delete from scada_device_data where device_id = {f_device_id};" - for tv in cs.operations[0]['data']: - time, value = str(tv['time']), float(tv['value']) - f_time, f_value = f"'{time}'", value - redo_sql += f"\ninsert into scada_device_data (device_id, time, value) values ({f_device_id}, {f_time}, {f_value});" - new['data'].append({ 'time': time, 'value': value }) - - undo_sql = f"delete from scada_device_data where device_id = {f_device_id};" - for tv in old['data']: - time, value = str(tv['time']), float(tv['value']) - f_time, f_value = f"'{time}'", value - undo_sql += f"\ninsert into scada_device_data (device_id, time, value) values ({f_device_id}, {f_time}, {f_value});" - - redo_cs = g_update_prefix | { 'type': 'scada_device_data' } | new - undo_cs = g_update_prefix | { 'type': 'scada_device_data' } | old - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) - - -def set_scada_device_data(name: str, cs: ChangeSet) -> ChangeSet: - return execute_command(name, _set_scada_device_data(name, cs)) - - -def _add_scada_device_data(name: str, cs: ChangeSet) -> DbChangeSet: - values = cs.operations[0] - device_id = values['device_id'] - time = values['time'] - value = float(values['value']) - - redo_sql = f"insert into scada_device_data (device_id, time, value) values ('{device_id}', '{time}', {value});" - undo_sql = f"delete from scada_device_data where device_id = '{device_id}' and time = '{time}';" - redo_cs = g_add_prefix | { 'type': 'scada_device_data', 'device_id': device_id, 'time': time, 'value': value } - undo_cs = g_delete_prefix | { 'type': 'scada_device_data', 'device_id': device_id, 'time': time } - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) - - -def add_scada_device_data(name: str, cs: ChangeSet) -> ChangeSet: - row = try_read(name, f"select * from scada_device_data where device_id = '{cs.operations[0]['device_id']}' and time = '{cs.operations[0]['time']}'") - if row != None: - return ChangeSet() - return execute_command(name, _add_scada_device_data(name, cs)) - - -def _delete_scada_device_data(name: str, cs: ChangeSet) -> DbChangeSet: - values = cs.operations[0] - device_id = values['device_id'] - time = values['time'] - value = float(read(name, f"select * from scada_device_data where device_id = '{device_id}' and time = '{time}'")['value']) - - redo_sql = f"delete from scada_device_data where device_id = '{device_id}' and time = '{time}';" - undo_sql = f"insert into scada_device_data (device_id, time, value) values ('{device_id}', '{time}', {value});" - redo_cs = g_delete_prefix | { 'type': 'scada_device_data', 'device_id': device_id, 'time': time } - undo_cs = g_add_prefix | { 'type': 'scada_device_data', 'device_id': device_id, 'time': time, 'value': value } - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) - - -def delete_scada_device_data(name: str, cs: ChangeSet) -> ChangeSet: - row = try_read(name, f"select * from scada_device_data where device_id = '{cs.operations[0]['device_id']}' and time = '{cs.operations[0]['time']}'") - if row == None: - return ChangeSet() - return execute_command(name, _delete_scada_device_data(name, cs)) diff --git a/app/native/wndb/s31_scada_element.py b/app/native/wndb/s31_scada_element.py deleted file mode 100644 index 457def3..0000000 --- a/app/native/wndb/s31_scada_element.py +++ /dev/null @@ -1,197 +0,0 @@ -from .database import * -from .s0_base import * - - -SCADA_TYPE_PRESSURE = 'PRESSURE' -SCADA_TYPE_DEMAND = 'DEMAND' -SCADA_TYPE_QUALITY = 'QUALITY' -SCADA_TYPE_LEVEL = 'LEVEL' -SCADA_TYPE_FLOW = 'FLOW' - - -SCADA_MODEL_TYPE_JUNCTION = 'JUNCTION' -SCADA_MODEL_TYPE_RESERVOIR = 'RESERVOIR' -SCADA_MODEL_TYPE_TANK = 'TANK' -SCADA_MODEL_TYPE_PIPE = 'PIPE' -SCADA_MODEL_TYPE_PUMP = 'PUMP' -SCADA_MODEL_TYPE_VALVE = 'VALVE' - - -SCADA_ELEMENT_STATUS_OFFLINE = 'OFF' -SCADA_ELEMENT_STATUS_ONLINE = 'ON' - - -_scada_model_types = [SCADA_MODEL_TYPE_JUNCTION, SCADA_MODEL_TYPE_RESERVOIR, SCADA_MODEL_TYPE_TANK, SCADA_MODEL_TYPE_PIPE, SCADA_MODEL_TYPE_PUMP, SCADA_MODEL_TYPE_VALVE] - - -def _check_model(name: str, cs: ChangeSet) -> bool: - has_model_id = 'model_id' in cs.operations[0] - has_model_type = 'model_type' in cs.operations[0] - - if has_model_id and has_model_type: - pass - elif has_model_id and not has_model_type: - return False - elif not has_model_id and has_model_type: - return False - elif not has_model_id and not has_model_type: - return True - - _model_id = cs.operations[0]['model_id'] - _model_type = cs.operations[0]['model_type'] - if _model_type == SCADA_MODEL_TYPE_JUNCTION: - return is_junction(name, _model_id) - elif _model_type == SCADA_MODEL_TYPE_RESERVOIR: - return is_reservoir(name, _model_id) - elif _model_type == SCADA_MODEL_TYPE_TANK: - return is_tank(name, _model_id) - elif _model_type == SCADA_MODEL_TYPE_PIPE: - return is_pipe(name, _model_id) - elif _model_type == SCADA_MODEL_TYPE_PUMP: - return is_pump(name, _model_id) - elif _model_type == SCADA_MODEL_TYPE_VALVE: - return is_valve(name, _model_id) - return False - - -def get_scada_element_schema(name: str) -> dict[str, dict[str, Any]]: - return { 'id' : {'type': 'str' , 'optional': False , 'readonly': True }, - 'x' : {'type': 'float' , 'optional': False , 'readonly': False}, - 'y' : {'type': 'float' , 'optional': False , 'readonly': False}, - 'device_id' : {'type': 'str' , 'optional': True , 'readonly': False}, - 'model_id' : {'type': 'str' , 'optional': True , 'readonly': False}, - 'model_type' : {'type': 'str' , 'optional': True , 'readonly': False}, - 'status' : {'type': 'str' , 'optional': True , 'readonly': False} } - - -def get_scada_element(name: str, id: str) -> dict[str, Any]: - sm = try_read(name, f"select * from scada_element where id = '{id}'") - if sm == None: - return {} - d = {} - d['id'] = str(sm['id']) - d['x'] = float(sm['x']) - d['y'] = float(sm['y']) - d['device_id'] = str(sm['device_id']) if sm['device_id'] != None else None - d['model_id'] = str(sm['model_id']) if sm['model_id'] != None else None - d['model_type'] = str(sm['model_type']) if sm['model_type'] != None else None - d['status'] = str(sm['status']) - return d - - -class ScadaModel(object): - def __init__(self, input: dict[str, Any]) -> None: - self.type = 'scada_element' - self.id = str(input['id']) - self.x = float(input['x']) - self.y = float(input['y']) - self.device_id = str(input['device_id']) if 'device_id' in input and input['device_id'] != None else None - self.model_id = str(input['model_id']) if 'model_id' in input and input['model_id'] != None else None - self.model_type = str(input['model_type']) if 'model_type' in input and input['model_type'] != None else None - self.status = str(input['status']) if 'status' in input and input['status'] != None else SCADA_ELEMENT_STATUS_OFFLINE - - self.f_type = f"'{self.type}'" - self.f_id = f"'{self.id}'" - self.f_x = self.x - self.f_y = self.y - self.f_device_id = f"'{self.device_id}'" if self.device_id != None else 'null' - self.f_model_id = f"'{self.model_id}'" if self.model_id != None else 'null' - self.f_model_type = f"'{self.model_type}'" if self.model_type != None else 'null' - self.f_status = f"'{self.status}'" - - def as_dict(self) -> dict[str, Any]: - return { 'type': self.type, 'id': self.id, 'x': self.x, 'y': self.y, 'device_id': self.device_id, 'model_id': self.model_id, 'model_type': self.model_type, 'status': self.status } - - def as_id_dict(self) -> dict[str, Any]: - return { 'type': self.type, 'id': self.id } - - -def _set_scada_element(name: str, cs: ChangeSet) -> DbChangeSet: - old = ScadaModel(get_scada_element(name, cs.operations[0]['id'])) - raw_new = get_scada_element(name, cs.operations[0]['id']) - - new_dict = cs.operations[0] - schema = get_scada_element_schema(name) - for key, value in schema.items(): - if key in new_dict and not value['readonly']: - raw_new[key] = new_dict[key] - new = ScadaModel(raw_new) - - redo_sql = f"update scada_element set x = {new.f_x}, y = {new.f_y}, device_id = {new.f_device_id}, model_id = {new.f_model_id}, model_type = {new.f_model_type}, status = {new.f_status} where id = {new.f_id};" - undo_sql = f"update scada_element set x = {old.f_x}, y = {old.f_y}, device_id = {old.f_device_id}, model_id = {old.f_model_id}, model_type = {old.f_model_type}, status = {old.f_status} where id = {old.f_id};" - - redo_cs = g_update_prefix | new.as_dict() - undo_cs = g_update_prefix | old.as_dict() - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) - - -def set_scada_element(name: str, cs: ChangeSet) -> ChangeSet: - if get_scada_element(name, cs.operations[0]['id']) == {}: - return ChangeSet() - if _check_model(name, cs) == False: - return ChangeSet() - return execute_command(name, _set_scada_element(name, cs)) - - -def _add_scada_element(name: str, cs: ChangeSet) -> DbChangeSet: - new = ScadaModel(cs.operations[0]) - - redo_sql = f"insert into scada_element (id, x, y, device_id, model_id, model_type, status) values ({new.f_id}, {new.f_x}, {new.f_y}, {new.f_device_id}, {new.f_model_id}, {new.f_model_type}, {new.f_status});" - undo_sql = f"delete from scada_element where id = {new.f_id};" - - redo_cs = g_add_prefix | new.as_dict() - undo_cs = g_delete_prefix | new.as_id_dict() - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) - - -def add_scada_element(name: str, cs: ChangeSet) -> ChangeSet: - if get_scada_element(name, cs.operations[0]['id']) != {}: - return ChangeSet() - if _check_model(name, cs) == False: - return ChangeSet() - return execute_command(name, _add_scada_element(name, cs)) - - -def _delete_scada_element(name: str, cs: ChangeSet) -> DbChangeSet: - old = ScadaModel(get_scada_element(name, cs.operations[0]['id'])) - - redo_sql = f"delete from scada_element where id = {old.f_id};" - undo_sql = f"insert into scada_element (id, x, y, device_id, model_id, model_type, status) values ({old.f_id}, {old.f_x}, {old.f_y}, {old.f_device_id}, {old.f_model_id}, {old.f_model_type}, {old.f_status});" - - redo_cs = g_delete_prefix | old.as_id_dict() - undo_cs = g_add_prefix | old.as_dict() - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) - - -def delete_scada_element(name: str, cs: ChangeSet) -> ChangeSet: - if get_scada_element(name, cs.operations[0]['id']) == {}: - return ChangeSet() - return execute_command(name, _delete_scada_element(name, cs)) - - -def get_all_scada_element_ids(name: str) -> list[str]: - result : list[str] = [] - rows = read_all(name, 'select id from scada_element order by id') - for row in rows: - result.append(str(row['id'])) - return result - -# -# create table scada_element -# ( -# id text primary key -# , x float8 not null -# , y float8 not null -# , device_id text references scada_device(id) -# , model_id varchar(32) -- add constraint in API -# , model_type scada_model_type -# , status scada_element_status not null default 'OFF' -# ); -# -# 返回list,list里每个item是dict,内容是 'id':'abc' 这样 -# scada_model type 是类似pressure,flow之类的,是由Device 决定 的 -def get_all_scada_elements(name: str) -> list[dict[str, Any]]: - return read_all(name, 'select * from scada_element order by id') diff --git a/app/native/wndb/s32_region.py b/app/native/wndb/s32_region.py deleted file mode 100644 index 95fd67f..0000000 --- a/app/native/wndb/s32_region.py +++ /dev/null @@ -1,95 +0,0 @@ -from .database import * -from .s32_region_util import from_postgis_polygon, to_postgis_polygon - -def get_region_schema(name: str) -> dict[str, dict[str, Any]]: - return { 'id' : {'type': 'str' , 'optional': False , 'readonly': True }, - 'boundary' : {'type': 'tuple_list' , 'optional': False , 'readonly': False} } - - -def get_region(name: str, id: str) -> dict[str, Any]: - r = try_read(name, f"select id, st_astext(boundary) as boundary_geom from region where id = '{id}'") - if r == None: - return {} - d = {} - d['id'] = str(r['id']) - d['boundary'] = from_postgis_polygon(str(r['boundary_geom'])) - return d - - -def _set_region(name: str, cs: ChangeSet) -> DbChangeSet: - id = cs.operations[0]['id'] - new = cs.operations[0]['boundary'] - old = get_region(name, id)['boundary'] - - redo_sql = f"update region set boundary = st_geomfromtext('{to_postgis_polygon(new)}') where id = '{id}';" - undo_sql = f"update region set boundary = st_geomfromtext('{to_postgis_polygon(old)}') where id = '{id}';" - redo_cs = g_update_prefix | { 'type': 'region', 'id': id, 'boundary': new } - undo_cs = g_update_prefix | { 'type': 'region', 'id': id, 'boundary': old } - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) - - -def set_region(name: str, cs: ChangeSet) -> ChangeSet: - if 'id' not in cs.operations[0] or 'boundary' not in cs.operations[0]: - return ChangeSet() - b = cs.operations[0]['boundary'] - if len(b) < 4 or b[0] != b[-1]: - return ChangeSet() - if get_region(name, cs.operations[0]['id']) == {}: - return ChangeSet() - return execute_command(name, _set_region(name, cs)) - - -def _add_region(name: str, cs: ChangeSet) -> DbChangeSet: - id = cs.operations[0]['id'] - new = cs.operations[0]['boundary'] - - redo_sql = f"insert into region (id, boundary) values ('{id}', '{to_postgis_polygon(new)}');" - undo_sql = f"delete from region where id = '{id}';" - redo_cs = g_add_prefix | { 'type': 'region', 'id': id, 'boundary': new } - undo_cs = g_delete_prefix | { 'type': 'region', 'id': id } - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) - - -def add_region(name: str, cs: ChangeSet) -> ChangeSet: - if 'id' not in cs.operations[0] or 'boundary' not in cs.operations[0]: - return ChangeSet() - b = cs.operations[0]['boundary'] - if len(b) < 4 or b[0] != b[-1]: - return ChangeSet() - if get_region(name, cs.operations[0]['id']) != {}: - return ChangeSet() - return execute_command(name, _add_region(name, cs)) - - -def _delete_region(name: str, cs: ChangeSet) -> DbChangeSet: - id = cs.operations[0]['id'] - old = get_region(name, id)['boundary'] - - redo_sql = f"delete from region where id = '{id}';" - undo_sql = f"insert into region (id, boundary) values ('{id}', '{to_postgis_polygon(old)}');" - redo_cs = g_delete_prefix | { 'type': 'region', 'id': id } - undo_cs = g_add_prefix | { 'type': 'region', 'id': id, 'boundary': old } - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) - - -def delete_region(name: str, cs: ChangeSet) -> ChangeSet: - if 'id' not in cs.operations[0]: - return ChangeSet() - if get_region(name, cs.operations[0]['id']) == {}: - return ChangeSet() - return execute_command(name, _delete_region(name, cs)) - -def inp_in_region(line: str) -> str: - tokens = line.split() - return str(f"insert into _region (id, type) values ('{tokens[0]}', '{tokens[1]}');") - -def inp_in_bound(line: str) -> str: - tokens = line.split() - return tokens[0] - -def inp_in_regionnodes(line: str)->str: - tokens = line.split() - return tokens[0] \ No newline at end of file diff --git a/app/native/wndb/s33_dma.py b/app/native/wndb/s33_dma.py deleted file mode 100644 index 49198c9..0000000 --- a/app/native/wndb/s33_dma.py +++ /dev/null @@ -1,230 +0,0 @@ -from .database import * -from .s0_base import is_node -from .s32_region_util import to_postgis_polygon -from .s32_region import get_region - -def get_district_metering_area_schema(name: str) -> dict[str, dict[str, Any]]: - return { 'id' : {'type': 'str' , 'optional': False , 'readonly': True }, - 'boundary' : {'type': 'tuple_list' , 'optional': False , 'readonly': False }, - 'parent' : {'type': 'str' , 'optional': True , 'readonly': False }, - 'level' : {'type': 'int' , 'optional': False , 'readonly': True } } - - -def get_district_metering_area(name: str, id: str) -> dict[str, Any]: - dma = get_region(name, id) - if dma == {}: - return {} - r = try_read(name, f"select * from region_dma where id = '{id}'") - if r == None: - return {} - dma['parent'] = r['parent'] - dma['nodes'] = list(eval(r['nodes'])) - dma['level'] = 1 - - if dma['parent'] != None: - parent = dma['parent'] - while parent != None: - parent = read(name, f"select parent from region_dma where id = '{parent}'")['parent'] - dma['level'] += 1 - - return dma - - -def _set_district_metering_area(name: str, cs: ChangeSet) -> DbChangeSet: - id = cs.operations[0]['id'] - - new_boundary = cs.operations[0]['boundary'] - old_boundary = get_region(name, id)['boundary'] - - new_parent = cs.operations[0]['parent'] - f_new_parent = f"'{new_parent}'" if new_parent != None else 'null' - - new_nodes = cs.operations[0]['nodes'] - str_new_nodes = str(new_nodes).replace("'", "''") - - old = get_district_metering_area(name, id) - old_parent = old['parent'] - f_old_parent = f"'{old_parent}'" if old_parent != None else 'null' - - old_nodes = old['nodes'] - str_old_nodes = str(old_nodes).replace("'", "''") - - redo_sql = f"update region set boundary = st_geomfromtext('{to_postgis_polygon(new_boundary)}') where id = '{id}';" - redo_sql += f"update region_dma set parent = {f_new_parent}, nodes = '{str_new_nodes}' where id = '{id}';" - - undo_sql = f"update region_dma set parent = {f_old_parent}, nodes = '{str_old_nodes}' where id = '{id}';" - undo_sql += f"update region set boundary = st_geomfromtext('{to_postgis_polygon(old_boundary)}') where id = '{id}';" - - redo_cs = g_update_prefix | { 'type': 'district_metering_area', 'id': id, 'boundary': new_boundary, 'parent': new_parent, 'nodes': new_nodes } - undo_cs = g_update_prefix | { 'type': 'district_metering_area', 'id': id, 'boundary': old_boundary, 'parent': old_parent, 'nodes': old_nodes } - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) - - -def set_district_metering_area(name: str, cs: ChangeSet) -> ChangeSet: - ops = cs.operations - - if len(cs.operations) == 0: - return ChangeSet() - - op = ops[0] - - if 'id' not in op: - return ChangeSet() - - dma = get_district_metering_area(name, op['id']) - if dma == {}: - return ChangeSet() - - if 'boundary' not in op: - op['boundary'] = dma['boundary'] - else: - b = op['boundary'] - if len(b) < 4 or b[0] != b[-1]: - return ChangeSet() - - if 'parent' not in op: - op['parent'] = dma['parent'] - - if op['parent'] != None and get_district_metering_area(name, op['parent']) == {}: - return ChangeSet() - - if 'nodes' not in op: - op['nodes'] = dma['nodes'] - else: - for node in op['nodes']: - if not is_node(name, node): - return ChangeSet() - - return execute_command(name, _set_district_metering_area(name, cs)) - - -def _add_district_metering_area(name: str, cs: ChangeSet) -> DbChangeSet: - id = cs.operations[0]['id'] - - boundary = cs.operations[0]['boundary'] - - parent = cs.operations[0]['parent'] - f_parent = f"'{parent}'" if parent != None else 'null' - - nodes = cs.operations[0]['nodes'] - str_nodes = str(nodes).replace("'", "''") - - redo_sql = f"insert into region (id, boundary, r_type) values ('{id}', '{to_postgis_polygon(boundary)}', 'DMA');" - redo_sql += f"insert into region_dma (id, parent, nodes) values ('{id}', {f_parent}, '{str_nodes}');" - - undo_sql = f"delete from region_dma where id = '{id}';" - undo_sql += f"delete from region where id = '{id}';" - - redo_cs = g_add_prefix | { 'type': 'district_metering_area', 'id': id, 'boundary': boundary, 'parent': parent, 'nodes': nodes } - undo_cs = g_delete_prefix | { 'type': 'district_metering_area', 'id': id } - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) - - -def add_district_metering_area(name: str, cs: ChangeSet) -> ChangeSet: - ops = cs.operations - - if len(cs.operations) == 0: - return ChangeSet() - - op = ops[0] - - if 'id' not in op: - return ChangeSet() - - dma = get_district_metering_area(name, op['id']) - if dma != {}: - return ChangeSet() - - if 'boundary' not in op: - return ChangeSet() - else: - b = op['boundary'] - if len(b) < 4 or b[0] != b[-1]: - return ChangeSet() - - if 'parent' not in op: - op['parent'] = None - - if op['parent'] != None and get_district_metering_area(name, op['parent']) == {}: - return ChangeSet() - - if 'nodes' not in op: - op['nodes'] = [] - else: - for node in op['nodes']: - if not is_node(name, node): - return ChangeSet() - - return execute_command(name, _add_district_metering_area(name, cs)) - - -def _delete_district_metering_area(name: str, cs: ChangeSet) -> DbChangeSet: - id = cs.operations[0]['id'] - dma = get_district_metering_area(name, id) - boundary = dma['boundary'] - parent = dma['parent'] - f_parent = f"'{parent}'" if parent != None else 'null' - nodes = dma['nodes'] - str_nodes = str(nodes).replace("'", "''") - - redo_sql = f"delete from region_dma where id = '{id}';" - redo_sql += f"delete from region where id = '{id}';" - - undo_sql = f"insert into region (id, boundary, r_type) values ('{id}', '{to_postgis_polygon(boundary)}', 'DMA');" - undo_sql += f"insert into region_dma (id, parent, nodes) values ('{id}', {f_parent}, '{str_nodes}');" - - redo_cs = g_delete_prefix | { 'type': 'district_metering_area', 'id': id } - undo_cs = g_add_prefix | { 'type': 'district_metering_area', 'id': id, 'boundary': boundary, 'parent': parent, 'nodes': nodes } - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) - - -def _has_child(name: str, parent: str) -> bool: - return try_read(name, f"select * from region_dma where parent = '{parent}'") != None - - -def is_descendant_of(name: str, descendant: str, ancestor: str) -> bool: - parent = descendant - while parent != None: - parent = read(name, f"select parent from region_dma where id = '{parent}'")['parent'] - if parent == ancestor: - return True - return False - - -def delete_district_metering_area(name: str, cs: ChangeSet) -> ChangeSet: - ops = cs.operations - - if len(cs.operations) == 0: - return ChangeSet() - - op = ops[0] - - if 'id' not in op: - return ChangeSet() - - dma = get_district_metering_area(name, op['id']) - if dma == {}: - return ChangeSet() - - #TODO: cascade ? - if _has_child(name, dma['id']): - return ChangeSet() - - return execute_command(name, _delete_district_metering_area(name, cs)) - - -def get_all_district_metering_area_ids(name: str) -> list[str]: - ids = [] - for row in read_all(name, f"select id from region_dma"): - ids.append(row['id']) - return ids - - -def get_all_district_metering_areas(name: str) -> list[dict[str, Any]]: - result = [] - for id in get_all_district_metering_area_ids(name): - result.append(get_district_metering_area(name, id)) - return result diff --git a/app/native/wndb/s33_dma_cal.py b/app/native/wndb/s33_dma_cal.py deleted file mode 100644 index 5da2ea6..0000000 --- a/app/native/wndb/s33_dma_cal.py +++ /dev/null @@ -1,168 +0,0 @@ -import ctypes -import os -import numpy as np -import pymetis -from .database import * -from .s0_base import get_nodes -from .s32_region_util import get_nodes_in_region -from .s32_region_util import Topology - - -PARTITION_TYPE_RB = 0 -PARTITION_TYPE_KWAY = 1 - -''' -adjacency_list = [np.array([4, 2, 1]), - np.array([0, 2, 3]), - np.array([4, 3, 1, 0]), - np.array([1, 2, 5, 6]), - np.array([0, 2, 5]), - np.array([4, 3, 6]), - np.array([5, 3])] -n_cuts, membership = pymetis.part_graph(2, adjacency=adjacency_list) -# n_cuts = 3 -# membership = [1, 1, 1, 0, 1, 0, 0] - -nodes_part_0 = np.argwhere(np.array(membership) == 0).ravel() # [3, 5, 6] -nodes_part_1 = np.argwhere(np.array(membership) == 1).ravel() # [0, 1, 2, 4] - -print(nodes_part_0) -print(nodes_part_1) -''' - - -def calculate_district_metering_area_for_nodes(name: str, nodes: list[str], part_count: int = 1, part_type: int = PARTITION_TYPE_RB) -> list[list[str]]: - if part_type != PARTITION_TYPE_RB and part_type != PARTITION_TYPE_KWAY: - return [] - if part_count <= 0: - return [] - elif part_count == 1: - return [nodes] - - topology = Topology(name, nodes) - t_nodes = topology.nodes() - t_links = topology.links() - t_node_list = topology.node_list() - - adjacency_list = [] - - for node in t_node_list: - links: list[str] = t_nodes[node]['links'] - a_nodes: list[int] = [] - for link in links: - if t_links[link]['node1'] == node: - i = t_node_list.index(t_links[link]['node2']) - a_nodes.append(i) - elif t_links[link]['node2'] == node: - i = t_node_list.index(t_links[link]['node1']) - a_nodes.append(i) - adjacency_list.append(np.array(a_nodes)) - - recursive = part_type == PARTITION_TYPE_RB - options = pymetis.Options() - options.set_defaults() - options._set(pymetis.OptionKey.CONTIG, 1) - options._set(pymetis.OptionKey.SEED, 0) - n_cuts, membership = pymetis.part_graph( - nparts=part_count, - adjacency=adjacency_list, - recursive=recursive, - options=options, - ) - - result: list[list[str]] = [] - for i in range(0, part_count): - indices: list[int] = list(np.argwhere(np.array(membership) == i).ravel()) - index_strs: list[str] = [] - for index in indices: - index_strs.append(t_node_list[index]) - result.append(index_strs) - - return result - - -def _calculate_district_metering_area_for_nodes(name: str, nodes: list[str], part_count: int = 1, part_type: int = PARTITION_TYPE_RB) -> list[list[str]]: - if part_type != PARTITION_TYPE_RB and part_type != PARTITION_TYPE_KWAY: - return [] - if part_count <= 0: - return [] - elif part_count == 1: - return [nodes] - - lib = ctypes.CDLL(os.path.join(os.getcwd(), 'api', 'CMetis.dll')) - - METIS_NOPTIONS = 40 - c_options = (ctypes.c_int64 * METIS_NOPTIONS)() - - METIS_OK = 1 - result = lib.set_default_options(c_options) - if result != METIS_OK: - return [] - - METIS_OPTION_PTYPE , METIS_OPTION_CONTIG = 0, 13 - c_options[METIS_OPTION_PTYPE] = part_type - c_options[METIS_OPTION_CONTIG] = 1 - - topology = Topology(name, nodes) - t_nodes = topology.nodes() - t_links = topology.links() - t_node_list = topology.node_list() - t_link_list = topology.link_list() - - nedges = len(t_link_list) * 2 - - c_nvtxs = ctypes.c_int64(len(t_node_list)) - c_ncon = ctypes.c_int64(1) - c_xadj = (ctypes.c_int64 * (c_nvtxs.value + 1))() - c_adjncy = (ctypes.c_int64 * nedges)() - c_vwgt = (ctypes.c_int64 * (c_ncon.value * c_nvtxs.value))() - c_adjwgt = (ctypes.c_int64 * nedges)() - c_vsize = (ctypes.c_int64 * c_nvtxs.value)() - - c_xadj[0] = 0 - - l, n = 0, 0 - c_xadj_i = 1 - for node in t_node_list: - links = t_nodes[node]['links'] - for link in links: - node1 = t_links[link]['node1'] - node2 = t_links[link]['node2'] - c_adjncy[l] = t_node_list.index(node2) if node2 != node else t_node_list.index(node1) - c_adjwgt[l] = 1 - l += 1 - if len(links) > 0: - c_xadj[c_xadj_i] = l # adjncy.size() - c_xadj_i += 1 - c_vwgt[n] = 1 - c_vsize[n] = 1 - n += 1 - - part_func = lib.part_graph_recursive if part_type == PARTITION_TYPE_RB else lib.part_graph_kway - - c_nparts = ctypes.c_int64(part_count) - c_tpwgts = ctypes.POINTER(ctypes.c_double)() - c_ubvec = ctypes.POINTER(ctypes.c_double)() - c_out_edgecut = ctypes.c_int64(0) - c_out_part = (ctypes.c_int64 * c_nvtxs.value)() - result = part_func(ctypes.byref(c_nvtxs), ctypes.byref(c_ncon), c_xadj, c_adjncy, c_vwgt, c_vsize, c_adjwgt, ctypes.byref(c_nparts), c_tpwgts, c_ubvec, c_options, ctypes.byref(c_out_edgecut), c_out_part) - if result != METIS_OK: - return [] - - dmas : list[list[str]]= [] - for i in range(part_count): - dmas.append([]) - for i in range(c_nvtxs.value): - dmas[c_out_part[i]].append(t_node_list[i]) - - return dmas - - -def calculate_district_metering_area_for_region(name: str, region: str, part_count: int = 1, part_type: int = PARTITION_TYPE_RB) -> list[list[str]]: - nodes = get_nodes_in_region(name, region) - return calculate_district_metering_area_for_nodes(name, nodes, part_count, part_type) - - -def calculate_district_metering_area_for_network(name: str, part_count: int = 1, part_type: int = PARTITION_TYPE_RB) -> list[list[str]]: - nodes = get_nodes(name) - return calculate_district_metering_area_for_nodes(name, nodes, part_count, part_type) diff --git a/app/native/wndb/s33_dma_gen.py b/app/native/wndb/s33_dma_gen.py deleted file mode 100644 index 4a88fdc..0000000 --- a/app/native/wndb/s33_dma_gen.py +++ /dev/null @@ -1,47 +0,0 @@ -from .s32_region_util import calculate_boundary, inflate_boundary -from .s33_dma_cal import * -from .s33_dma import get_all_district_metering_area_ids, get_all_district_metering_areas, get_district_metering_area, is_descendant_of -from .batch_exe import execute_batch_command - - -def generate_district_metering_area(name: str, part_count: int = 1, part_type: int = PARTITION_TYPE_RB, inflate_delta: float = 0.5) -> ChangeSet: - cs = ChangeSet() - - dmas = get_all_district_metering_areas(name) - max_level = 0 - for dma in dmas: - if dma['level'] > max_level: - max_level = dma['level'] - while max_level > 0: - for dma in dmas: - if dma['level'] == max_level: - cs.delete({ 'type': 'district_metering_area', 'id': dma['id'] }) - max_level -= 1 - - i = 1 - for nodes in calculate_district_metering_area_for_network(name, part_count, part_type): - boundary = calculate_boundary(name, nodes) - boundary = inflate_boundary(name, boundary, inflate_delta) - cs.add({ 'type': 'district_metering_area', 'id': f"DMA_1_{i}", 'boundary': boundary, 'parent': None, 'nodes': nodes }) - i += 1 - - return execute_batch_command(name, cs) - - -def generate_sub_district_metering_area(name: str, dma: str, part_count: int = 1, part_type: int = PARTITION_TYPE_RB, inflate_delta: float = 0.5) -> ChangeSet: - cs = ChangeSet() - - for id in get_all_district_metering_area_ids(name): - if is_descendant_of(name, id, dma): - cs.delete({ 'type': 'district_metering_area', 'id': id }) - - level = get_district_metering_area(name, dma)['level'] + 1 - - i = 1 - for nodes in calculate_district_metering_area_for_region(name, dma, part_count, part_type): - boundary = calculate_boundary(name, nodes) - boundary = inflate_boundary(name, boundary, inflate_delta) - cs.add({ 'type': 'district_metering_area', 'id': f"DMA_[{dma}]_{level}_{i}", 'boundary': boundary, 'parent': dma, 'nodes': nodes }) - i += 1 - - return execute_batch_command(name, cs) diff --git a/app/native/wndb/s34_sa.py b/app/native/wndb/s34_sa.py deleted file mode 100644 index ec54c2c..0000000 --- a/app/native/wndb/s34_sa.py +++ /dev/null @@ -1,217 +0,0 @@ -from .database import * -from .s0_base import is_node -from .s32_region_util import to_postgis_polygon -from .s32_region import get_region - -def get_service_area_schema(name: str) -> dict[str, dict[str, Any]]: - return { 'id' : {'type': 'str' , 'optional': False , 'readonly': True }, - 'boundary' : {'type': 'tuple_list' , 'optional': False , 'readonly': False }, - 'source' : {'type': 'str' , 'optional': False , 'readonly': False }, - 'time_index' : {'type': 'int' , 'optional': False , 'readonly': False } } - -def get_service_area(name: str, id: str) -> dict[str, Any]: - sa = get_region(name, id) - if sa == {}: - return {} - r = try_read(name, f"select * from region_sa where id = '{id}'") - if r == None: - return {} - sa['source'] = r['source'] - sa['nodes'] = list(eval(r['nodes'])) - sa['time_index'] = r['time_index'] - return sa - -def _set_service_area(name: str, cs: ChangeSet) -> DbChangeSet: - id = cs.operations[0]['id'] - - new_boundary = cs.operations[0]['boundary'] - old_boundary = get_region(name, id)['boundary'] - - new_source = cs.operations[0]['source'] - f_new_source = f"'{new_source}'" - - new_nodes = cs.operations[0]['nodes'] - str_new_nodes = str(new_nodes).replace("'", "''") - - new_time_index = cs.operations[0]['time_index'] - - old = get_service_area(name, id) - old_source = old['source'] - f_old_source = f"'{old_source}'" - - old_nodes = old['nodes'] - str_old_nodes = str(old_nodes).replace("'", "''") - - old_time_index = old['time_index'] - - redo_sql = f"update region set boundary = st_geomfromtext('{to_postgis_polygon(new_boundary)}') where id = '{id}';" - redo_sql += f"update region_sa set time_index = {new_time_index}, source = {f_new_source}, nodes = '{str_new_nodes}' where id = '{id}';" - - undo_sql = f"update region_sa set time_index = {old_time_index}, source = {f_old_source}, nodes = '{str_old_nodes}' where id = '{id}';" - undo_sql += f"update region set boundary = st_geomfromtext('{to_postgis_polygon(old_boundary)}') where id = '{id}';" - - redo_cs = g_update_prefix | { 'type': 'service_area', 'id': id, 'boundary': new_boundary, 'time_index': new_time_index, 'source': new_source, 'nodes': new_nodes } - undo_cs = g_update_prefix | { 'type': 'service_area', 'id': id, 'boundary': old_boundary, 'time_index': old_time_index, 'source': old_source, 'nodes': old_nodes } - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) - - -def set_service_area(name: str, cs: ChangeSet) -> ChangeSet: - ops = cs.operations - - if len(cs.operations) == 0: - return ChangeSet() - - op = ops[0] - - if 'id' not in op: - return ChangeSet() - - sa = get_service_area(name, op['id']) - if sa == {}: - return ChangeSet() - - if 'boundary' not in op: - op['boundary'] = sa['boundary'] - else: - b = op['boundary'] - if len(b) < 4 or b[0] != b[-1]: - return ChangeSet() - - if 'time_index' not in op: - op['time_index'] = sa['time_index'] - - if 'source' not in op: - op['source'] = sa['source'] - - if not is_node(name, op['source']): - return ChangeSet() - - if 'nodes' not in op: - op['nodes'] = sa['nodes'] - else: - for node in op['nodes']: - if not is_node(name, node): - return ChangeSet() - - return execute_command(name, _set_service_area(name, cs)) - - -def _add_service_area(name: str, cs: ChangeSet) -> DbChangeSet: - id = cs.operations[0]['id'] - - boundary = cs.operations[0]['boundary'] - - time_index = cs.operations[0]['time_index'] - - source = cs.operations[0]['source'] - f_source = f"'{source}'" - - nodes = cs.operations[0]['nodes'] - str_nodes = str(nodes).replace("'", "''") - - redo_sql = f"insert into region (id, boundary, r_type) values ('{id}', '{to_postgis_polygon(boundary)}', 'SA');" - redo_sql += f"insert into region_sa (id, time_index, source, nodes) values ('{id}', {time_index}, {f_source}, '{str_nodes}');" - - undo_sql = f"delete from region_sa where id = '{id}';" - undo_sql += f"delete from region where id = '{id}';" - - redo_cs = g_add_prefix | { 'type': 'service_area', 'id': id, 'boundary': boundary, 'time_index': time_index, 'source': source, 'nodes': nodes } - undo_cs = g_delete_prefix | { 'type': 'service_area', 'id': id } - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) - - -def add_service_area(name: str, cs: ChangeSet) -> ChangeSet: - ops = cs.operations - - if len(cs.operations) == 0: - return ChangeSet() - - op = ops[0] - - if 'id' not in op: - return ChangeSet() - - sa = get_service_area(name, op['id']) - if sa != {}: - return ChangeSet() - - if 'boundary' not in op: - return ChangeSet() - else: - b = op['boundary'] - if len(b) < 4 or b[0] != b[-1]: - return ChangeSet() - - if 'time_index' not in op: - return ChangeSet() - - if 'source' not in op: - return ChangeSet() - - if not is_node(name, op['source']): - return ChangeSet() - - if 'nodes' not in op: - op['nodes'] = [] - else: - for node in op['nodes']: - if not is_node(name, node): - return ChangeSet() - - return execute_command(name, _add_service_area(name, cs)) - - -def _delete_service_area(name: str, cs: ChangeSet) -> DbChangeSet: - id = cs.operations[0]['id'] - sa = get_service_area(name, id) - boundary = sa['boundary'] - time_index = sa['time_index'] - source = sa['source'] - f_source = f"'{source}'" - nodes = sa['nodes'] - str_nodes = str(nodes).replace("'", "''") - - redo_sql = f"delete from region_sa where id = '{id}';" - redo_sql += f"delete from region where id = '{id}';" - - undo_sql = f"insert into region (id, boundary, r_type) values ('{id}', '{to_postgis_polygon(boundary)}', 'SA');" - undo_sql += f"insert into region_sa (id, time_index, source, nodes) values ('{id}', {time_index}, {f_source}, '{str_nodes}');" - - redo_cs = g_delete_prefix | { 'type': 'service_area', 'id': id } - undo_cs = g_add_prefix | { 'type': 'service_area', 'id': id, 'boundary': boundary, 'time_index': time_index, 'source': source, 'nodes': nodes } - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) - - -def delete_service_area(name: str, cs: ChangeSet) -> ChangeSet: - ops = cs.operations - - if len(cs.operations) == 0: - return ChangeSet() - - op = ops[0] - - if 'id' not in op: - return ChangeSet() - - sa = get_service_area(name, op['id']) - if sa == {}: - return ChangeSet() - - return execute_command(name, _delete_service_area(name, cs)) - - -def get_all_service_area_ids(name: str) -> list[str]: - ids = [] - for row in read_all(name, f"select id from region_sa"): - ids.append(row['id']) - return ids - - -def get_all_service_areas(name: str) -> list[dict[str, Any]]: - result = [] - for id in get_all_service_area_ids(name): - result.append(get_service_area(name, id)) - return result diff --git a/app/native/wndb/s34_sa_cal.py b/app/native/wndb/s34_sa_cal.py deleted file mode 100644 index 158a350..0000000 --- a/app/native/wndb/s34_sa_cal.py +++ /dev/null @@ -1,210 +0,0 @@ -import os -import platform -import subprocess -import uuid -from queue import Queue -from typing import Any - -from app.infra.epanet.epanet import Output - -from .inp_out import dump_inp -from .project import have_project -from .s0_base import get_link_nodes, get_node_links -from .s23_options_util import get_option_v3 - - -def _update_section(lines: list[str], section: str, transform) -> list[str]: - result: list[str] = [] - i = 0 - while i < len(lines): - line = lines[i] - if line.strip() == f'[{section}]': - result.append(line) - i += 1 - section_lines: list[str] = [] - while i < len(lines) and not lines[i].startswith('['): - section_lines.append(lines[i]) - i += 1 - result.extend(transform(section_lines)) - continue - result.append(line) - i += 1 - return result - - -def _build_service_area_input(name: str, inp_path: str) -> None: - dump_inp(name, inp_path, '2') - - with open(inp_path, encoding='utf-8') as file: - lines = file.read().splitlines() - - unbalanced = get_option_v3(name).get('IF_UNBALANCED', '').strip() - if unbalanced != '': - lines = _update_section( - lines, - 'OPTIONS', - lambda option_lines: [ - f'UNBALANCED {unbalanced}' if line.startswith('UNBALANCED ') else line - for line in option_lines - ], - ) - - with open(inp_path, mode='w', encoding='utf-8') as file: - file.write('\n'.join(lines) + '\n') - - -def _run_epanet_output(inp_path: str, rpt_path: str, out_path: str) -> dict[str, Any]: - epanet_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', 'infra', 'epanet')) - if platform.system() == 'Windows': - exe = os.path.join(epanet_dir, 'windows', 'runepanet.exe') - else: - exe = os.path.join(epanet_dir, 'linux', 'runepanet') - if not os.access(exe, os.X_OK): - os.chmod(exe, 0o755) - - env = os.environ.copy() - if platform.system() == 'Linux': - lib_dir = os.path.dirname(exe) - env['LD_LIBRARY_PATH'] = f"{lib_dir}:{env.get('LD_LIBRARY_PATH', '')}" - - process = subprocess.run([exe, inp_path, rpt_path, out_path], env=env, capture_output=True, text=True) - if process.returncode != 0: - raise RuntimeError( - f'EPANET failed for [{inp_path}] with code {process.returncode}: ' - f'stdout={process.stdout} stderr={process.stderr}' - ) - - return Output(out_path).dump() - - -def _calculate_service_area(name: str, output: dict[str, Any], time_index: int) -> dict[str, list[str]]: - sources: dict[str, list[str]] = {} - for node_result in output['node_results']: - result = node_result['result'][time_index] - if result['demand'] < 0: - sources[node_result['node']] = [] - - link_flows: dict[str, float] = {} - for link_result in output['link_results']: - result = link_result['result'][time_index] - link_flows[link_result['link']] = float(result['flow']) - - for source in sources: - queue: Queue[str] = Queue() - queue.put(source) - - while not queue.empty(): - cursor = queue.get() - if cursor not in sources[source]: - sources[source].append(cursor) - - links = get_node_links(name, cursor) - for link in links: - node1, node2 = get_link_nodes(name, link) - if node1 == cursor and link_flows[link] > 0: - queue.put(node2) - elif node2 == cursor and link_flows[link] < 0: - queue.put(node1) - - concentration_map: dict[str, dict[str, float]] = {} - node_wip: list[str] = [] - for source, nodes in sources.items(): - for node in nodes: - if node not in concentration_map: - concentration_map[node] = {} - concentration_map[node][source] = 0.0 - if node not in node_wip: - node_wip.append(node) - - for node, concentrations in concentration_map.items(): - if len(concentrations) == 1: - node_wip.remove(node) - for source in concentrations.keys(): - concentration_map[node][source] = 1.0 - - node_upstream: dict[str, list[tuple[str, str]]] = {} - for node in node_wip: - node_upstream[node] = [] - - links = get_node_links(name, node) - for link in links: - node1, node2 = get_link_nodes(name, link) - if node2 == node and link_flows[link] > 0: - node_upstream[node].append((link, node1)) - elif node1 == node and link_flows[link] < 0: - node_upstream[node].append((link, node2)) - - while len(node_wip) != 0: - done: list[str] = [] - for node in node_wip: - up_link_nodes = node_upstream[node] - ready = True - for link_node in up_link_nodes: - if link_node[1] in node_wip: - ready = False - break - if not ready: - continue - - for link, upstream_node in up_link_nodes: - if upstream_node not in concentration_map: - continue - for source, concentration in concentration_map[upstream_node].items(): - concentration_map[node][source] += concentration * abs(link_flows[link]) - - total_concentration = sum(concentration_map[node].values()) - if total_concentration == 0: - raise RuntimeError(f'Failed to normalize service area concentration for node [{node}] at time [{time_index}]') - - for source in concentration_map[node].keys(): - concentration_map[node][source] /= total_concentration - - done.append(node) - - if len(done) == 0: - raise RuntimeError(f'Failed to resolve service area graph for time [{time_index}]') - - for node in done: - node_wip.remove(node) - - source_to_main_node: dict[str, list[str]] = {} - for node, concentrations in concentration_map.items(): - max_source = '' - max_concentration = 0.0 - for source, concentration in concentrations.items(): - if concentration > max_concentration: - max_concentration = concentration - max_source = source - if max_source not in source_to_main_node: - source_to_main_node[max_source] = [] - source_to_main_node[max_source].append(node) - - return source_to_main_node - - -def calculate_service_area(name: str) -> list[dict[str, list[str]]]: - if not have_project(name): - raise Exception(f'Not found project [{name}]') - - root = os.path.abspath(os.getcwd()) - token = f'{os.getpid()}_{uuid.uuid4().hex}' - inp_path = os.path.join(root, 'db_inp', f'{name}.service_area.{token}.inp') - rpt_path = os.path.join(root, 'temp', f'{name}.service_area.{token}.rpt') - out_path = os.path.join(root, 'temp', f'{name}.service_area.{token}.opt') - - os.makedirs(os.path.dirname(inp_path), exist_ok=True) - os.makedirs(os.path.dirname(rpt_path), exist_ok=True) - - try: - _build_service_area_input(name, inp_path) - output = _run_epanet_output(inp_path, rpt_path, out_path) - - results: list[dict[str, list[str]]] = [] - time_count = len(output['node_results'][0]['result']) - for time_index in range(time_count): - results.append(_calculate_service_area(name, output, time_index)) - return results - finally: - for path in (inp_path, rpt_path, out_path): - if os.path.exists(path): - os.remove(path) diff --git a/app/native/wndb/s34_sa_gen.py b/app/native/wndb/s34_sa_gen.py deleted file mode 100644 index 7eb7ff8..0000000 --- a/app/native/wndb/s34_sa_gen.py +++ /dev/null @@ -1,23 +0,0 @@ -from .s32_region_util import calculate_boundary, inflate_boundary -from .s34_sa_cal import * -from .s34_sa import get_all_service_area_ids -from .batch_exe import execute_batch_command -from .database import ChangeSet - -def generate_service_area(name: str, inflate_delta: float = 0.5) -> ChangeSet: - cs = ChangeSet() - - for id in get_all_service_area_ids(name): - cs.delete({'type': 'service_area', 'id': id}) - - sass = calculate_service_area(name) - - time_index = 0 - for sas in sass: - for source, nodes in sas.items(): - boundary = calculate_boundary(name, nodes) - boundary = inflate_boundary(name, boundary, inflate_delta) - cs.add({ 'type': 'service_area', 'id': f"SA_{source}_{time_index}", 'boundary': boundary, 'time_index': time_index, 'source': source, 'nodes': nodes }) - time_index += 1 - - return execute_batch_command(name, cs) diff --git a/app/native/wndb/s35_vd.py b/app/native/wndb/s35_vd.py deleted file mode 100644 index feddd1d..0000000 --- a/app/native/wndb/s35_vd.py +++ /dev/null @@ -1,202 +0,0 @@ -from .database import * -from .s0_base import is_node -from .s32_region_util import to_postgis_polygon -from .s32_region import get_region - -def get_virtual_district_schema(name: str) -> dict[str, dict[str, Any]]: - return { 'id' : {'type': 'str' , 'optional': False , 'readonly': True }, - 'boundary' : {'type': 'tuple_list' , 'optional': False , 'readonly': False }, - 'center' : {'type': 'str' , 'optional': False , 'readonly': False } } - -def get_virtual_district(name: str, id: str) -> dict[str, Any]: - vd = get_region(name, id) - if vd == {}: - return {} - r = try_read(name, f"select * from region_vd where id = '{id}'") - if r == None: - return {} - vd['center'] = r['center'] - vd['nodes'] = list(eval(r['nodes'])) - return vd - -def _set_virtual_district(name: str, cs: ChangeSet) -> DbChangeSet: - id = cs.operations[0]['id'] - - new_boundary = cs.operations[0]['boundary'] - old_boundary = get_region(name, id)['boundary'] - - new_center = cs.operations[0]['center'] - f_new_center = f"'{new_center}'" - - new_nodes = cs.operations[0]['nodes'] - str_new_nodes = str(new_nodes).replace("'", "''") - - old = get_virtual_district(name, id) - old_center = old['center'] - f_old_center = f"'{old_center}'" - - old_nodes = old['nodes'] - str_old_nodes = str(old_nodes).replace("'", "''") - - redo_sql = f"update region set boundary = st_geomfromtext('{to_postgis_polygon(new_boundary)}') where id = '{id}';" - redo_sql += f"update region_vd set center = {f_new_center}, nodes = '{str_new_nodes}' where id = '{id}';" - - undo_sql = f"update region_vd set center = {f_old_center}, nodes = '{str_old_nodes}' where id = '{id}';" - undo_sql += f"update region set boundary = st_geomfromtext('{to_postgis_polygon(old_boundary)}') where id = '{id}';" - - redo_cs = g_update_prefix | { 'type': 'virtual_district', 'id': id, 'boundary': new_boundary, 'center': new_center, 'nodes': new_nodes } - undo_cs = g_update_prefix | { 'type': 'virtual_district', 'id': id, 'boundary': old_boundary, 'center': old_center, 'nodes': old_nodes } - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) - - -def set_virtual_district(name: str, cs: ChangeSet) -> ChangeSet: - ops = cs.operations - - if len(cs.operations) == 0: - return ChangeSet() - - op = ops[0] - - if 'id' not in op: - return ChangeSet() - - vd = get_virtual_district(name, op['id']) - if vd == {}: - return ChangeSet() - - if 'boundary' not in op: - op['boundary'] = vd['boundary'] - else: - b = op['boundary'] - if len(b) < 4 or b[0] != b[-1]: - return ChangeSet() - - if 'center' not in op: - op['center'] = vd['center'] - - if not is_node(name, op['center']): - return ChangeSet() - - if 'nodes' not in op: - op['nodes'] = vd['nodes'] - else: - for node in op['nodes']: - if not is_node(name, node): - return ChangeSet() - - return execute_command(name, _set_virtual_district(name, cs)) - - -def _add_virtual_district(name: str, cs: ChangeSet) -> DbChangeSet: - id = cs.operations[0]['id'] - - boundary = cs.operations[0]['boundary'] - - center = cs.operations[0]['center'] - f_center = f"'{center}'" - - nodes = cs.operations[0]['nodes'] - str_nodes = str(nodes).replace("'", "''") - - redo_sql = f"insert into region (id, boundary, r_type) values ('{id}', '{to_postgis_polygon(boundary)}', 'VD');" - redo_sql += f"insert into region_vd (id, center, nodes) values ('{id}', {f_center}, '{str_nodes}');" - - undo_sql = f"delete from region_vd where id = '{id}';" - undo_sql += f"delete from region where id = '{id}';" - - redo_cs = g_add_prefix | { 'type': 'virtual_district', 'id': id, 'boundary': boundary, 'center': center, 'nodes': nodes } - undo_cs = g_delete_prefix | { 'type': 'virtual_district', 'id': id } - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) - - -def add_virtual_district(name: str, cs: ChangeSet) -> ChangeSet: - ops = cs.operations - - if len(cs.operations) == 0: - return ChangeSet() - - op = ops[0] - - if 'id' not in op: - return ChangeSet() - - vd = get_virtual_district(name, op['id']) - if vd != {}: - return ChangeSet() - - if 'boundary' not in op: - return ChangeSet() - else: - b = op['boundary'] - if len(b) < 4 or b[0] != b[-1]: - return ChangeSet() - - if 'center' not in op: - return ChangeSet() - - if not is_node(name, op['center']): - return ChangeSet() - - if 'nodes' not in op: - op['nodes'] = [] - else: - for node in op['nodes']: - if not is_node(name, node): - return ChangeSet() - - return execute_command(name, _add_virtual_district(name, cs)) - - -def _delete_virtual_district(name: str, cs: ChangeSet) -> DbChangeSet: - id = cs.operations[0]['id'] - vd = get_virtual_district(name, id) - boundary = vd['boundary'] - center = vd['center'] - f_center = f"'{center}'" - nodes = vd['nodes'] - str_nodes = str(nodes).replace("'", "''") - - redo_sql = f"delete from region_vd where id = '{id}';" - redo_sql += f"delete from region where id = '{id}';" - - undo_sql = f"insert into region (id, boundary, r_type) values ('{id}', '{to_postgis_polygon(boundary)}', 'VD');" - undo_sql += f"insert into region_vd (id, center, nodes) values ('{id}', {f_center}, '{str_nodes}');" - - redo_cs = g_delete_prefix | { 'type': 'virtual_district', 'id': id } - undo_cs = g_add_prefix | { 'type': 'virtual_district', 'id': id, 'boundary': boundary, 'center': center, 'nodes': nodes } - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) - - -def delete_virtual_district(name: str, cs: ChangeSet) -> ChangeSet: - ops = cs.operations - - if len(cs.operations) == 0: - return ChangeSet() - - op = ops[0] - - if 'id' not in op: - return ChangeSet() - - vd = get_virtual_district(name, op['id']) - if vd == {}: - return ChangeSet() - - return execute_command(name, _delete_virtual_district(name, cs)) - - -def get_all_virtual_district_ids(name: str) -> list[str]: - ids = [] - for row in read_all(name, f"select id from region_vd"): - ids.append(row['id']) - return ids - - -def get_all_virtual_districts(name: str) -> list[dict[str, Any]]: - result = [] - for id in get_all_virtual_district_ids(name): - result.append(get_virtual_district(name, id)) - return result diff --git a/app/native/wndb/s35_vd_cal.py b/app/native/wndb/s35_vd_cal.py deleted file mode 100644 index b115814..0000000 --- a/app/native/wndb/s35_vd_cal.py +++ /dev/null @@ -1,66 +0,0 @@ -from .database import * -from .s0_base import get_node_links - - -def calculate_virtual_district(name: str, centers: list[str]) -> dict[str, list[Any]]: - write(name, 'delete from temp_vd_topology') - - # map node name to index - i = 0 - isolated_nodes = [] - node_index: dict[str, int] = {} - for row in read_all(name, 'select id from _node'): - node = str(row['id']) - if get_node_links(name, node) == []: - isolated_nodes.append(node) - continue - i += 1 - node_index[node] = i - - # build topology graph - pipes = read_all(name, 'select node1, node2, length from pipes') - for pipe in pipes: - source = node_index[str(pipe['node1'])] - target = node_index[str(pipe['node2'])] - cost = float(pipe['length']) - write(name, f"insert into temp_vd_topology (source, target, cost) values ({source}, {target}, {cost})") - pumps = read_all(name, 'select node1, node2 from pumps') - for pump in pumps: - source = node_index[str(pump['node1'])] - target = node_index[str(pump['node2'])] - write(name, f"insert into temp_vd_topology (source, target, cost) values ({source}, {target}, 0.0)") - valves = read_all(name, 'select node1, node2 from valves') - for valve in valves: - source = node_index[str(valve['node1'])] - target = node_index[str(valve['node2'])] - write(name, f"insert into temp_vd_topology (source, target, cost) values ({source}, {target}, 0.0)") - - # dijkstra distance - node_distance: dict[str, dict[str, Any]] = {} - for center in centers: - for node, index in node_index.items(): - if node == center: - node_distance[node] = { 'center': center, 'distance' : 0.0 } - continue - # TODO: check none - distance = float(read(name, f"select max(agg_cost) as distance from pgr_dijkstraCost('select id, source, target, cost from temp_vd_topology', {index}, {node_index[center]}, false)")['distance']) - if node not in node_distance: - node_distance[node] = { 'center': center, 'distance' : distance } - elif distance < node_distance[node]['distance']: - node_distance[node] = { 'center': center, 'distance' : distance } - - write(name, 'delete from temp_vd_topology') - - # reorganize the distance result - center_node: dict[str, list[str]] = {} - for node, value in node_distance.items(): - if value['center'] not in center_node: - center_node[value['center']] = [] - center_node[value['center']].append(node) - - vds: list[dict[str, Any]] = [] - - for center, value in center_node.items(): - vds.append({ 'center': center, 'nodes': value }) - - return { 'virtual_districts': vds, 'isolated_nodes': isolated_nodes } diff --git a/app/native/wndb/s35_vd_gen.py b/app/native/wndb/s35_vd_gen.py deleted file mode 100644 index 6a3bc72..0000000 --- a/app/native/wndb/s35_vd_gen.py +++ /dev/null @@ -1,21 +0,0 @@ -from .s32_region_util import calculate_boundary, inflate_boundary -from .s35_vd_cal import * -from .s35_vd import get_all_virtual_district_ids -from .batch_exe import execute_batch_command - -def generate_virtual_district(name: str, centers: list[str], inflate_delta: float = 0.5) -> ChangeSet: - cs = ChangeSet() - - for id in get_all_virtual_district_ids(name): - cs.delete({'type': 'virtual_district', 'id': id}) - - vds = calculate_virtual_district(name, centers)['virtual_districts'] - - for vd in vds: - center = vd['center'] - nodes = vd['nodes'] - boundary = calculate_boundary(name, nodes) - boundary = inflate_boundary(name, boundary, inflate_delta) - cs.add({ 'type': 'virtual_district', 'id': f"VD_{center}", 'boundary': boundary, 'center': center, 'nodes': nodes }) - - return execute_batch_command(name, cs) diff --git a/app/native/wndb/s36_wda.py b/app/native/wndb/s36_wda.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/native/wndb/s38_scada_info.py b/app/native/wndb/s38_scada_info.py deleted file mode 100644 index c7b9001..0000000 --- a/app/native/wndb/s38_scada_info.py +++ /dev/null @@ -1,58 +0,0 @@ -from .database import * - - -def get_scada_info_schema(name: str) -> dict[str, dict[str, Any]]: - return { - "id": {"type": "str", "optional": False, "readonly": True}, - "type": {"type": "str", "optional": False, "readonly": True}, - "x": {"type": "float", "optional": False, "readonly": False}, - "y": {"type": "float", "optional": False, "readonly": False}, - "query_api_id": {"type": "str", "optional": False, "readonly": False}, - "transmission_mode": {"type": "str", "optional": True, "readonly": True}, - "transmission_frequency": {"type": "float", "optional": True, "readonly": True}, - "reliability": {"type": "float", "optional": True, "readonly": True}, - "associated_element_id": {"type": "str", "optional": False, "readonly": True}, - } - - -def get_scada_info(name: str, id: str) -> dict[str, Any]: - si = try_read(name, f"select * from scada_info where id = '{id}'") - if si is None: - return {} - - d = {} - d["id"] = si["id"] - d["type"] = si["type"] - d["x"] = float(si["x_coor"]) - d["y"] = float(si["y_coor"]) - d["api_query_id"] = si["api_query_id"] - d["transmission_mode"] = si.get("transmission_mode") - d["transmission_frequency"] = si.get("transmission_frequency") - d["reliability"] = si.get("reliability") - d["associated_element_id"] = si["associated_element_id"] - - return d - - -def get_all_scada_info(name: str) -> list[dict[str, Any]]: - sis = read_all(name, f"select * from scada_info") - if sis is None: - return [] - - d = [] - for si in sis: - d.append( - { - "id": si["id"], - "type": si["type"], - "x": float(si["x_coor"]), - "y": float(si["y_coor"]), - "api_query_id": si["api_query_id"], - "transmission_mode": si.get("transmission_mode"), - "transmission_frequency": si.get("transmission_frequency"), - "reliability": si.get("reliability"), - "associated_element_id": si["associated_element_id"], - } - ) - - return d diff --git a/app/native/wndb/s40_schema.py b/app/native/wndb/s40_schema.py deleted file mode 100644 index 4a646ee..0000000 --- a/app/native/wndb/s40_schema.py +++ /dev/null @@ -1,33 +0,0 @@ -from .database import * -from .s0_base import * - - -def get_scheme_schema(name: str) -> dict[str, dict[Any, Any]]: - return { - "id": {"type": "str", "optional": False, "readonly": True}, - "name": {"type": "str", "optional": False, "readonly": False}, - "type": {"type": "str", "optional": False, "readonly": False}, - "create_time": {"type": "str", "optional": False, "readonly": True}, - "start_time": {"type": "str", "optional": False, "readonly": True}, - "detail": {"type": "str", "optional": False, "readonly": True}, - } - - -def get_scheme(name: str, schema_name: str) -> dict[Any, Any]: - t = try_read(name, f"select * from scheme_list where scheme_name = '{schema_name}'") - if t == None: - return {} - - d = {} - d["id"] = str(t["scheme_id"]) - d["name"] = str(t["scheme_name"]) - d["type"] = str(t["scheme_type"]) - d["create_time"] = str(t["create_time"]) - d["start_time"] = str(t["start_time"]) - d["detail"] = str(t["detail"]) - - return d - - -def get_all_schemes(name: str) -> list[dict[Any, Any]]: - return read_all(name, "select * from scheme_list") diff --git a/app/native/wndb/s41_pipe_risk_probability.py b/app/native/wndb/s41_pipe_risk_probability.py deleted file mode 100644 index b441305..0000000 --- a/app/native/wndb/s41_pipe_risk_probability.py +++ /dev/null @@ -1,92 +0,0 @@ -from .database import * -from .connection import project_connection -from .s0_base import * -from psycopg.rows import dict_row -import json - -def get_pipe_risk_probability_now(name: str, pipe_id: str) -> dict[str, Any]: - t = try_read(name, f"select * from pipe_risk_probability where pipeid = '{pipe_id}'") - if t == None: - return {} - - d = {} - d['pipeid'] = str(t['pipeid']) - d['pipeage'] = t['pipeage'] - d['risk_probability_now'] = t['risk_probability_now'] - - return d - -def get_pipe_risk_probability(name: str, pipe_id: str) -> dict[str, Any]: - t = try_read(name, f"select * from pipe_risk_probability where pipeid = '{pipe_id}'") - if t == None: - return {} - - d = {} - d['pipeid'] = t['pipeid'] - d['x'] = t['x'] - d['y'] = t['y'] - - return d - -def get_network_pipe_risk_probability_now(name: str) -> list[dict[str, Any]]: - pipe_risk_probability_list = [] - with project_connection(name) as conn: - with conn.cursor(row_factory=dict_row) as cur: - cur.execute(f"select * from pipe_risk_probability") - for record in cur: - #pipe_risk_probability_list.append(record) - t = {} - t['pipeid'] = record['pipeid'] - t['pipeage'] = record['pipeage'] - t['risk_probability_now'] = record['risk_probability_now'] - pipe_risk_probability_list.append(t) - - return pipe_risk_probability_list - -def get_pipes_risk_probability(name: str, pipe_ids: list[str]) -> list[dict[str, Any]]: - pipe_risk_probability_list = [] - with project_connection(name) as conn: - with conn.cursor(row_factory=dict_row) as cur: - cur.execute(f"select * from pipe_risk_probability") - for record in cur: - if record['pipeid'] in pipe_ids: - t = {} - t['pipeid'] = record['pipeid'] - t['x'] = record['x'] - t['y'] = record['y'] - pipe_risk_probability_list.append(t) - - return pipe_risk_probability_list - -def get_pipe_risk_probability_geometries(name: str) -> dict[str, Any]: - ''' - 获取管道的几何信息 - 返回一个字典,key 是管道的 id,value 是管道的几何信息 - 几何信息是一个字典,包含 start 和 end 两个 key,value 是管道的起点和终点的坐标 - ''' - pipe_risk_probability_geometries = {} - - key_pipeId = '编码' - # key_startnode = '上游节点' - # key_endnode = '下游节点' - key_geometry = 'geometry' - - with project_connection(name) as conn: - with conn.cursor(row_factory=dict_row) as cur: - cur.execute(f"select *, ST_AsGeoJSON(geometry) AS {key_geometry} from gis_pipe") - - for record in cur: - id = record[key_pipeId] - geom = json.loads(record[key_geometry]) - - pipe_risk_probability_geometries[id] = { - 'points': geom['coordinates'] - } - - for col in record: - if col != key_geometry: - pipe_risk_probability_geometries[id][col] = record[col] - - # print(len(pipe_risk_probability_geometries)) - - return pipe_risk_probability_geometries diff --git a/app/native/wndb/s42_sensor_placement.py b/app/native/wndb/s42_sensor_placement.py deleted file mode 100644 index 38eb57a..0000000 --- a/app/native/wndb/s42_sensor_placement.py +++ /dev/null @@ -1,124 +0,0 @@ -from typing import Any - -from psycopg.rows import dict_row - -from .connection import project_connection -from .database import read_all - - -def get_all_sensor_placements(name: str) -> list[dict[str, Any]]: - return read_all(name, "select * from sensor_placement") - - -def create_sensor_placement( - name: str, - *, - scheme_name: str, - min_diameter: int, - username: str, - sensor_location: list[str], -) -> dict[str, Any]: - with project_connection(name) as conn: - with conn.cursor(row_factory=dict_row) as cur: - cur.execute( - """ - INSERT INTO sensor_placement ( - scheme_name, - sensor_number, - min_diameter, - username, - sensor_location - ) - VALUES (%s, %s, %s, %s, %s) - RETURNING * - """, - ( - scheme_name, - len(sensor_location), - min_diameter, - username, - sensor_location, - ), - ) - created = cur.fetchone() - if created is None: - raise RuntimeError("监测点方案写入失败") - return dict(created) - - -def get_sensor_placement(name: str, scheme_id: int) -> dict[str, Any] | None: - with project_connection(name) as conn: - with conn.cursor(row_factory=dict_row) as cur: - cur.execute( - "SELECT * FROM sensor_placement WHERE id = %s", - (scheme_id,), - ) - return cur.fetchone() - - -def get_sensor_placement_nodes( - name: str, - node_ids: list[str], -) -> list[dict[str, Any]]: - if not node_ids: - return [] - with project_connection(name) as conn: - with conn.cursor(row_factory=dict_row) as cur: - cur.execute( - """ - WITH incident_pipe_diameters AS ( - SELECT node_id, MAX(diameter) AS max_pipe_diameter - FROM ( - SELECT node1 AS node_id, diameter - FROM pipes - WHERE node1 = ANY(%s) - UNION ALL - SELECT node2 AS node_id, diameter - FROM pipes - WHERE node2 = ANY(%s) - ) AS incident_pipes - GROUP BY node_id - ) - SELECT DISTINCT ON (gj.id) - gj.id AS node_id, - ipd.max_pipe_diameter, - gj.elevation, - ST_X(c.coord) AS project_x, - ST_Y(c.coord) AS project_y, - ST_X(gj.geom) AS map_x, - ST_Y(gj.geom) AS map_y - FROM geo_junctions_mat AS gj - JOIN coordinates AS c ON c.node = gj.id - LEFT JOIN incident_pipe_diameters AS ipd ON ipd.node_id = gj.id - WHERE gj.id = ANY(%s) - ORDER BY gj.id - """, - (node_ids, node_ids, node_ids), - ) - return list(cur.fetchall()) - - -def update_sensor_placement( - name: str, - scheme_id: int, - *, - expected_sensor_location: list[str], - sensor_location: list[str], -) -> dict[str, Any] | None: - with project_connection(name) as conn: - with conn.cursor(row_factory=dict_row) as cur: - cur.execute( - """ - UPDATE sensor_placement - SET sensor_location = %s, sensor_number = %s - WHERE id = %s AND sensor_location = %s - RETURNING * - """, - ( - sensor_location, - len(sensor_location), - scheme_id, - expected_sensor_location, - ), - ) - return cur.fetchone() diff --git a/app/native/wndb/s43_burst_locate_result.py b/app/native/wndb/s43_burst_locate_result.py deleted file mode 100644 index ac26463..0000000 --- a/app/native/wndb/s43_burst_locate_result.py +++ /dev/null @@ -1,6 +0,0 @@ -from .database import * -from .s0_base import * -import json - -def get_all_burst_locate_results(name: str) -> list[dict[Any, Any]]: - return read_all(name, "select * from burst_locate_result") \ No newline at end of file diff --git a/app/native/wndb/s8_tags.py b/app/native/wndb/s8_tags.py deleted file mode 100644 index 3a77576..0000000 --- a/app/native/wndb/s8_tags.py +++ /dev/null @@ -1,142 +0,0 @@ -from typing import Any -from .database import ChangeSet, execute_command, try_read, read_all, DbChangeSet, g_update_prefix - -TAG_TYPE_NODE = 'NODE' -TAG_TYPE_LINK = 'LINK' - -def get_tag_schema(name: str) -> dict[str, dict[str, Any]]: - return { 't_type' : {'type': 'str' , 'optional': False , 'readonly': False}, - 'id' : {'type': 'str' , 'optional': False , 'readonly': False}, - 'tag' : {'type': 'str' , 'optional': True , 'readonly': False},} - - -def get_tags(name: str) -> list[dict[str, Any]]: - results: list[dict[str, Any]] = [] - rows = read_all(name, "select * from tags_node") - for row in rows: - tag = str(row['tag']) if row['tag'] != None else None - results.append({ 't_type': TAG_TYPE_NODE, 'id': str(row['id']), 'tag': tag }) - rows = read_all(name, "select * from tags_link") - for row in rows: - tag = str(row['tag']) if row['tag'] != None else None - results.append({ 't_type': TAG_TYPE_LINK, 'id': str(row['id']), 'tag': tag }) - return results - - -def get_tag(name: str, t_type: str, id: str) -> dict[str, Any]: - t = None - if t_type == TAG_TYPE_NODE: - t = try_read(name, f"select * from tags_node where id = '{id}'") - elif t_type == TAG_TYPE_LINK: - t = try_read(name, f"select * from tags_link where id = '{id}'") - if t is None: - return { 't_type': t_type, 'id': id, 'tag': None } - d = {} - d['t_type'] = t_type - d['id'] = str(t['id']) - d['tag'] = str(t['tag']) if t['tag'] is not None else None - return d - - -class Tag(object): - def __init__(self, input: dict[str, Any]) -> None: - self.type = 'tag' - self.t_type = str(input['t_type']) - self.id = str(input['id']) - self.tag = str(input['tag']) if 'tag' in input and input['tag'] != None else None - - self.f_type = f"'{self.type}'" - self.f_t_type = f"'{self.t_type}'" - self.f_id = f"'{self.id}'" - self.f_tag = f"'{self.tag}'" if self.tag != None else 'null' - - def as_dict(self) -> dict[str, Any]: - return { 'type': self.type, 't_type': self.t_type, 'id': self.id, 'tag': self.tag } - - -def _set_tag(name: str, cs: ChangeSet) -> DbChangeSet: - old = Tag(get_tag(name, cs.operations[0]['t_type'], cs.operations[0]['id'])) - raw_new = get_tag(name, cs.operations[0]['t_type'], cs.operations[0]['id']) - - new_dict = cs.operations[0] - schema = get_tag_schema(name) - for key, value in schema.items(): - if key in new_dict and not value['readonly']: - raw_new[key] = new_dict[key] - new = Tag(raw_new) - - table = '' - if cs.operations[0]['t_type'] == TAG_TYPE_NODE: - table = 'tags_node' - elif cs.operations[0]['t_type'] == TAG_TYPE_LINK: - table = 'tags_link' - else: - raise Exception('Only support NODE and Link') - - redo_sql = f"delete from {table} where id = {new.f_id};" - if new.tag is not None: - redo_sql += f"\ninsert into {table} (id, tag) values ({new.f_id}, {new.f_tag});" - - undo_sql = f"delete from {table} where id = {old.f_id};" - if old.tag is not None: - undo_sql += f"\ninsert into {table} (id, tag) values ({old.f_id}, {old.f_tag});" - - redo_cs = g_update_prefix | new.as_dict() - undo_cs = g_update_prefix | old.as_dict() - - return DbChangeSet(redo_sql, undo_sql, [redo_cs], [undo_cs]) - - -def set_tag(name: str, cs: ChangeSet) -> ChangeSet: - if 't_type' not in cs.operations[0] or 'id' not in cs.operations[0] or 'tag' not in cs.operations[0]: - return ChangeSet() - return execute_command(name, _set_tag(name, cs)) - - -def inp_in_tag(line: str) -> str: - tokens = line.split() - - num = len(tokens) - has_desc = tokens[-1].startswith(';') - num_without_desc = (num - 1) if has_desc else num - - t_type = str(tokens[0].upper()) - id = str(tokens[1]) - tag = str(tokens[2]) - - if t_type == TAG_TYPE_NODE: - return str(f"insert into tags_node (id, tag) values ('{id}', '{tag}');") - elif t_type == TAG_TYPE_LINK: - return str(f"insert into tags_link (id, tag) values ('{id}', '{tag}');") - return str('') - - -def inp_out_tag(name: str) -> list[str]: - lines = [] - objs = read_all(name, 'select * from tags_node') - for obj in objs: - t_type = TAG_TYPE_NODE - id = obj['id'] - tag = obj['tag'] - lines.append(f'{t_type} {id} {tag}') - objs = read_all(name, 'select * from tags_link') - for obj in objs: - t_type = TAG_TYPE_LINK - id = obj['id'] - tag = obj['tag'] - lines.append(f'{t_type} {id} {tag}') - return lines - - -def delete_tag_by_node(name: str, node: str) -> ChangeSet: - row = try_read(name, f"select * from tags_node where id = '{node}'") - if row is None: - return ChangeSet() - return ChangeSet(g_update_prefix | {'type': 'tag', 't_type': TAG_TYPE_NODE, 'id': node, 'tag': None }) - - -def delete_tag_by_link(name: str, link: str) -> ChangeSet: - row = try_read(name, f"select * from tags_link where id = '{link}'") - if row is None: - return ChangeSet() - return ChangeSet(g_update_prefix | {'type': 'tag', 't_type': TAG_TYPE_LINK, 'id': link, 'tag': None }) diff --git a/app/services/burst_detection.py b/app/services/burst_detection.py index e9d35a4..9f45acb 100644 --- a/app/services/burst_detection.py +++ b/app/services/burst_detection.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections import Counter from datetime import datetime, timedelta from typing import Any +from uuid import UUID import numpy as np import pandas as pd @@ -49,8 +50,7 @@ def run_burst_detection( sensor_nodes: list[str] | None = None, scheme_name: str | None = None, data_source: str = "monitoring", - simulation_scheme_name: str | None = None, - simulation_scheme_type: str | None = None, + simulation_run_id: UUID | None = None, ) -> dict[str, Any]: """ 运行爆管侦测服务入口。 @@ -153,18 +153,17 @@ def run_burst_detection( else _get_pressure_sensor_nodes(network) ) if data_source == "simulation": - if not simulation_scheme_name: - raise ValueError("模拟方案模式必须提供 simulation_scheme_name。") + if not simulation_run_id: + raise ValueError("模拟数据模式必须提供 simulation_run_id。") observed_df = _build_observed_pressure_from_simulation( network=network, sensor_nodes=scada_sensor_nodes, scada_start=scada_start, scada_end=scada_end, - simulation_scheme_name=simulation_scheme_name, - simulation_scheme_type=simulation_scheme_type, + simulation_run_id=simulation_run_id, ) observed_input = observed_df - observed_source = "simulation_scheme_timerange" + observed_source = "analysis_run_timerange" else: observed_df = _build_observed_pressure_from_scada( network=network, @@ -224,9 +223,8 @@ def run_burst_detection( } if data_source == "simulation": payload["data_source"] = "simulation" - payload["simulation_scheme"] = { - "name": simulation_scheme_name, - "type": simulation_scheme_type, + payload["simulation_run"] = { + "run_id": str(simulation_run_id), } else: payload["data_source"] = "monitoring" @@ -299,11 +297,10 @@ def _build_observed_pressure_from_simulation( sensor_nodes: list[str], scada_start: datetime | str | None, scada_end: datetime | str | None, - simulation_scheme_name: str | None, - simulation_scheme_type: str | None = None, + simulation_run_id: UUID, ) -> pd.DataFrame: if scada_start is None or scada_end is None: - raise ValueError("使用模拟方案查询时必须同时提供 scada_start 与 scada_end。") + raise ValueError("使用分析模拟数据时必须同时提供 scada_start 与 scada_end。") start_dt = _to_datetime(scada_start) end_dt = _to_datetime(scada_end) @@ -316,12 +313,9 @@ def _build_observed_pressure_from_simulation( # Check for missing nodes in simulation result if needed, but InternalQueries handles some of it. # We assume sensor_nodes are valid pressure nodes. - scheme_type = simulation_scheme_type or "burst_analysis" - - simulation_data = InternalQueries.query_scheme_simulation_by_ids_timerange( + simulation_data = InternalQueries.query_analysis_simulation_by_ids_timerange( db_name=network, - scheme_type=scheme_type, - scheme_name=simulation_scheme_name, + run_id=simulation_run_id, element_ids=sensor_nodes, start_time=start_dt.isoformat(), end_time=end_dt.isoformat(), @@ -646,8 +640,8 @@ def _resolve_sampling_interval_minutes( inferred_intervals = [ parsed for item in get_all_scada_info(network) - if str(item.get("type", "")).lower() == "pressure" - and str(item.get("associated_element_id", "")) in selected_nodes + if str(item.get("device_type", "")).lower() == "pressure" + and str(item.get("node_id", "")) in selected_nodes and ( parsed := _parse_sampling_interval_minutes( item.get("transmission_frequency") @@ -685,9 +679,9 @@ def _parse_sampling_interval_minutes(value: Any) -> int | None: def _get_pressure_sensor_mapping(network: str) -> dict[str, str]: node_query_id: dict[str, str] = {} for item in get_all_scada_info(network): - if str(item.get("type", "")).lower() != "pressure": + if str(item.get("device_type", "")).lower() != "pressure": continue - node_id = item.get("associated_element_id") + node_id = item.get("node_id") query_id = item.get("api_query_id") if node_id and query_id is not None: node_query_id[str(node_id)] = str(query_id) @@ -697,14 +691,16 @@ def _get_pressure_sensor_mapping(network: str) -> dict[str, str]: def _get_pressure_sensor_nodes(network: str) -> list[str]: sensor_nodes: list[str] = [] for item in get_all_scada_info(network): - if str(item.get("type", "")).lower() != "pressure": + if str(item.get("device_type", "")).lower() != "pressure": continue - node_id = item.get("associated_element_id") + node_id = item.get("node_id") if isinstance(node_id, str) and node_id: sensor_nodes.append(node_id) sensor_nodes = list(dict.fromkeys(sensor_nodes)) if not sensor_nodes: - raise ValueError("未找到压力传感器对应节点(scada_info.type=pressure)。") + raise ValueError( + "未找到压力传感器对应节点(asset.scada_devices.device_type=pressure)。" + ) return sensor_nodes diff --git a/app/services/burst_location.py b/app/services/burst_location.py index 589d122..517b95d 100644 --- a/app/services/burst_location.py +++ b/app/services/burst_location.py @@ -3,6 +3,7 @@ from __future__ import annotations import os from datetime import datetime, timedelta from typing import Any +from uuid import UUID import pandas as pd @@ -11,7 +12,7 @@ from app.infra.db.timescaledb.internal_queries import InternalQueries from app.services.scheme_management import ( query_burst_location_scheme_detail, query_burst_location_schemes, - query_scheme_list, + get_analysis_run, scheme_name_exists, store_scheme_info, ) @@ -21,7 +22,6 @@ from app.services.time_api import extract_date, parse_utc_time, utc_now SeriesInput = pd.Series | dict[str, Any] | list[dict[str, Any]] FLOW_SCADA_TYPES = {"pipe_flow", "flow", "demand"} SIMULATION_DATA_SOURCES = {"monitoring", "simulation"} -DEFAULT_SIMULATION_SCHEME_TYPE = "burst_analysis" def _normalize_series(data: SeriesInput, field_name: str) -> pd.Series: @@ -65,16 +65,12 @@ def run_burst_location_by_network( scada_normal_end: datetime | str | None = None, use_scada_flow: bool = False, scheme_name: str | None = None, - simulation_scheme_name: str | None = None, - simulation_scheme_type: str | None = None, + simulation_run_id: UUID | None = None, ) -> dict[str, Any]: if not network: raise ValueError("network is required.") normalized_data_source = _normalize_data_source( - data_source, simulation_scheme_name=simulation_scheme_name - ) - resolved_simulation_scheme_type = ( - simulation_scheme_type or DEFAULT_SIMULATION_SCHEME_TYPE + data_source, simulation_run_id=simulation_run_id ) selected_pressure_ids = ( @@ -123,8 +119,8 @@ def run_burst_location_by_network( else None ) if normalized_data_source == "simulation": - if not simulation_scheme_name: - raise ValueError("模拟方案模式必须提供 simulation_scheme_name。") + if not simulation_run_id: + raise ValueError("模拟数据模式必须提供 simulation_run_id。") normal_start_dt = burst_start_dt normal_end_dt = burst_end_dt ( @@ -137,9 +133,8 @@ def run_burst_location_by_network( end_dt=burst_end_dt, data_type="pressure", series_name="burst_pressure", - simulation_source="scheme", - simulation_scheme_name=simulation_scheme_name, - simulation_scheme_type=resolved_simulation_scheme_type, + simulation_source="analysis", + simulation_run_id=simulation_run_id, ) ( normal_pressure_series, @@ -152,10 +147,9 @@ def run_burst_location_by_network( data_type="pressure", series_name="normal_pressure", simulation_source="realtime", - simulation_scheme_name=None, - simulation_scheme_type=resolved_simulation_scheme_type, + simulation_run_id=None, ) - observed_source = "simulation_scheme_burst_realtime_normal_timerange" + observed_source = "analysis_run_burst_realtime_normal_timerange" else: if normal_pressure_from_payload is None and ( normal_start_dt is None or normal_end_dt is None @@ -230,8 +224,8 @@ def run_burst_location_by_network( else None ) if normalized_data_source == "simulation": - if not simulation_scheme_name: - raise ValueError("模拟方案模式必须提供 simulation_scheme_name。") + if not simulation_run_id: + raise ValueError("模拟数据模式必须提供 simulation_run_id。") burst_flow_series, burst_flow_samples = ( _build_observed_series_from_simulation( network=network, @@ -240,9 +234,8 @@ def run_burst_location_by_network( end_dt=burst_end_dt, data_type="flow", series_name="burst_flow", - simulation_source="scheme", - simulation_scheme_name=simulation_scheme_name, - simulation_scheme_type=resolved_simulation_scheme_type, + simulation_source="analysis", + simulation_run_id=simulation_run_id, ) ) normal_flow_series, normal_flow_samples = ( @@ -254,8 +247,7 @@ def run_burst_location_by_network( data_type="flow", series_name="normal_flow", simulation_source="realtime", - simulation_scheme_name=None, - simulation_scheme_type=resolved_simulation_scheme_type, + simulation_run_id=None, ) ) else: @@ -354,14 +346,12 @@ def run_burst_location_by_network( } ) if normalized_data_source == "simulation": - simulation_burst_ids = _get_simulation_scheme_burst_ids( + simulation_burst_ids = _get_simulation_run_burst_ids( network=network, - scheme_name=simulation_scheme_name, - scheme_type=resolved_simulation_scheme_type, + run_id=simulation_run_id, ) - payload["simulation_scheme"] = { - "name": simulation_scheme_name, - "type": resolved_simulation_scheme_type, + payload["simulation_run"] = { + "run_id": str(simulation_run_id), "burst_ids": simulation_burst_ids, } if scheme_name: @@ -471,20 +461,15 @@ def _validate_time_window( return start_dt, end_dt -def _get_simulation_scheme_burst_ids( - *, network: str, scheme_name: str | None, scheme_type: str +def _get_simulation_run_burst_ids( + *, network: str, run_id: UUID | None ) -> list[str]: - if not scheme_name: + if run_id is None: return [] - rows = query_scheme_list(network, scheme_type=scheme_type) or [] - for row in rows: - if len(row) < 7: - continue - if row[1] != scheme_name or row[2] != scheme_type: - continue - detail = row[6] if isinstance(row[6], dict) else {} - return _normalize_burst_ids(detail.get("burst_ID")) - return [] + run = get_analysis_run(network, run_id) + if not run: + raise ValueError(f"未找到模拟运行: {run_id}") + return _normalize_burst_ids(run["parameters"].get("burst_ID")) def _normalize_burst_ids(value: Any) -> list[str]: @@ -566,8 +551,7 @@ def _build_observed_series_from_simulation( data_type: str, series_name: str, simulation_source: str, - simulation_scheme_name: str | None, - simulation_scheme_type: str, + simulation_run_id: UUID | None, ) -> tuple[pd.Series, int]: sensor_ids = _dedupe_ids(sensor_ids) sensor_metadata = _build_sensor_metadata(network=network, data_type=data_type) @@ -586,8 +570,7 @@ def _build_observed_series_from_simulation( end_dt=end_dt, data_type=data_type, simulation_source=simulation_source, - simulation_scheme_name=simulation_scheme_name, - simulation_scheme_type=simulation_scheme_type, + simulation_run_id=simulation_run_id, ) simulation_data = _normalize_timeseries_by_id(simulation_data) values: dict[str, float] = {} @@ -625,10 +608,9 @@ def _query_simulation_data_by_sensor_ids( end_dt: datetime, data_type: str, simulation_source: str, - simulation_scheme_name: str | None, - simulation_scheme_type: str, + simulation_run_id: UUID | None, ) -> dict[str, list[dict[str, Any]]]: - if simulation_source not in {"scheme", "realtime"}: + if simulation_source not in {"analysis", "realtime"}: raise ValueError(f"Unsupported simulation_source: {simulation_source}") sensor_ids = _dedupe_ids(sensor_ids) @@ -645,8 +627,7 @@ def _query_simulation_data_by_sensor_ids( start_dt=start_dt, end_dt=end_dt, simulation_source=simulation_source, - simulation_scheme_name=simulation_scheme_name, - simulation_scheme_type=simulation_scheme_type, + simulation_run_id=simulation_run_id, ) ) return result @@ -679,8 +660,7 @@ def _query_simulation_data_by_sensor_ids( start_dt=start_dt, end_dt=end_dt, simulation_source=simulation_source, - simulation_scheme_name=simulation_scheme_name, - simulation_scheme_type=simulation_scheme_type, + simulation_run_id=simulation_run_id, ) ) if demand_ids: @@ -693,8 +673,7 @@ def _query_simulation_data_by_sensor_ids( start_dt=start_dt, end_dt=end_dt, simulation_source=simulation_source, - simulation_scheme_name=simulation_scheme_name, - simulation_scheme_type=simulation_scheme_type, + simulation_run_id=simulation_run_id, ) ) return result @@ -709,19 +688,17 @@ def _query_simulation_values( start_dt: datetime, end_dt: datetime, simulation_source: str, - simulation_scheme_name: str | None, - simulation_scheme_type: str, + simulation_run_id: UUID | None, ) -> dict[str, list[dict[str, Any]]]: element_ids = _dedupe_ids(element_ids) if not element_ids: return {} - if simulation_source == "scheme": - if not simulation_scheme_name: - raise ValueError("读取方案模拟数据时必须提供 simulation_scheme_name。") - return InternalQueries.query_scheme_simulation_by_ids_timerange( + if simulation_source == "analysis": + if not simulation_run_id: + raise ValueError("读取分析模拟数据时必须提供 simulation_run_id。") + return InternalQueries.query_analysis_simulation_by_ids_timerange( db_name=network, - scheme_type=simulation_scheme_type, - scheme_name=simulation_scheme_name, + run_id=simulation_run_id, element_ids=element_ids, start_time=start_dt.isoformat(), end_time=end_dt.isoformat(), @@ -743,7 +720,7 @@ def _query_simulation_values( def _build_sensor_metadata(network: str, data_type: str) -> dict[str, dict[str, str]]: metadata: dict[str, dict[str, str]] = {} for item in get_all_scada_info(network): - scada_type = str(item.get("type", "")).lower() + scada_type = str(item.get("device_type", "")).lower() if data_type == "pressure": if scada_type != "pressure": continue @@ -752,7 +729,7 @@ def _build_sensor_metadata(network: str, data_type: str) -> dict[str, dict[str, continue else: raise ValueError(f"Unsupported data_type: {data_type}") - element_id = _normalize_identifier(item.get("associated_element_id")) + element_id = _normalize_identifier(item.get("node_id") or item.get("link_id")) query_id = _normalize_identifier(item.get("api_query_id")) if element_id and query_id: metadata[element_id] = {"query_id": query_id, "scada_type": scada_type} @@ -765,11 +742,11 @@ def _build_scada_mapping(network: str, data_type: str) -> dict[str, str]: def _normalize_data_source( - data_source: str | None, simulation_scheme_name: str | None = None + data_source: str | None, simulation_run_id: UUID | None = None ) -> str: normalized = str(data_source or "").strip().lower() if not normalized: - return "simulation" if simulation_scheme_name else "monitoring" + return "simulation" if simulation_run_id else "monitoring" if normalized not in SIMULATION_DATA_SOURCES: allowed_sources = ", ".join(sorted(SIMULATION_DATA_SOURCES)) raise ValueError( @@ -783,7 +760,7 @@ def _get_sensor_nodes(network: str, data_type: str) -> list[str]: sensor_ids = sorted(mapping.keys()) if not sensor_ids: type_name = "压力" if data_type == "pressure" else "流量" - raise ValueError(f"未找到{type_name}传感器对应节点(scada_info.type)。") + raise ValueError(f"未找到{type_name}传感器对应节点(asset.scada_devices.device_type)。") return sensor_ids diff --git a/app/services/globals.py b/app/services/globals.py index 681ed05..9df7d0a 100644 --- a/app/services/globals.py +++ b/app/services/globals.py @@ -1,54 +1,14 @@ -# simulation.py中的全局变量 -# reservoir basic height -RESERVOIR_BASIC_HEIGHT = float(250.35) -PATTERN_TIME_STEP = None # 浮点数 -# 实时数据类:element_id和api_query_id对应 -reservoirs_id = {} -tanks_id = {} -fixed_pumps_id ={} -variable_pumps_id = {} -pressure_id = {} -demand_id = {} -quality_id = {} -# 实时数据类:pattern_id和api_query_id对应 -source_outflow_pattern_id = {} -realtime_pipe_flow_pattern_id = {} -pipe_flow_region_patterns = {} # 根据realtime的pipe_flow,对non_realtime的demand进行分区 -# 分区查询 -source_outflow_region = {} # 以绑定的管段作为value -source_outflow_region_id = {} # 以api_query_id作为value -source_outflow_region_patterns = {} # 以associated_pattern作为value -# 非实时数据的pattern -non_realtime_region_patterns = {} # 基于source_outflow_region进行区分 -realtime_region_pipe_flow_and_demand_id = {} # 基于source_outflow_region搜索该分区中的实时pipe_flow和demand的api_query_id,后续用region的流量 - 实时流量计的流量 -realtime_region_pipe_flow_and_demand_patterns = {} # 基于source_outflow_region搜索该分区中的实时pipe_flow和demand的associated_pattern,后续用region的流量 - 实时流量计的流量 -# --------------------------------------------------------- -# 全局变量,用于存储不同类型的realtime api_query_id -reservoir_liquid_level_realtime_ids = [] -tank_liquid_level_realtime_ids = [] -fixed_pump_realtime_ids = [] -variable_pump_realtime_ids = [] -source_outflow_realtime_ids = [] -pipe_flow_realtime_ids = [] -pressure_realtime_ids = [] -demand_realtime_ids = [] -quality_realtime_ids = [] -# transmission_frequency的最大值 -transmission_frequency = None -hydraulic_timestep = None # 时间字符串 -reservoir_liquid_level_non_realtime_ids = [] -tank_liquid_level_non_realtime_ids = [] -fixed_pump_non_realtime_ids = [] -variable_pump_non_realtime_ids = [] -source_outflow_non_realtime_ids = [] -pipe_flow_non_realtime_ids = [] -pressure_non_realtime_ids = [] -demand_non_realtime_ids = [] -quality_non_realtime_ids = [] +"""Mutable state used by the legacy synchronous simulation runner.""" -# api_query_id和associated_element_id对应,不包含液位和泵 -scheme_source_outflow_ids = {} -scheme_pipe_flow_ids = {} -scheme_pressure_ids = {} -scheme_demand_ids = {} -scheme_quality_ids = {} +RESERVOIR_BASIC_HEIGHT = 250.35 +PATTERN_TIME_STEP: float | None = None +hydraulic_timestep: str | None = None + +# Element ID -> SCADA api_query_id, loaded per project before simulation. +reservoirs_id: dict[str, str] = {} +tanks_id: dict[str, str] = {} +fixed_pumps_id: dict[str, str] = {} +variable_pumps_id: dict[str, str] = {} +pressure_id: dict[str, str] = {} +demand_id: dict[str, str] = {} +quality_id: dict[str, str] = {} diff --git a/app/services/leakage_identifier.py b/app/services/leakage_identifier.py index a85d653..beacc04 100644 --- a/app/services/leakage_identifier.py +++ b/app/services/leakage_identifier.py @@ -194,18 +194,18 @@ def get_leakage_identify_scheme_detail( def _get_pressure_sensor_nodes(network: str) -> list[str]: - scada_info = get_all_scada_info(network) + scada_devices = get_all_scada_info(network) sensor_nodes: list[str] = [] - for item in scada_info: - scada_type = str(item.get("type", "")).lower() + for item in scada_devices: + scada_type = str(item.get("device_type", "")).lower() if scada_type != "pressure": continue - node_id = item.get("associated_element_id") + node_id = item.get("node_id") if isinstance(node_id, str) and node_id: sensor_nodes.append(node_id) sensor_nodes = list(dict.fromkeys(sensor_nodes)) if not sensor_nodes: - raise ValueError("未找到压力传感器对应节点(scada_info.type=pressure)。") + raise ValueError("未找到关联节点的压力 SCADA 设备。") return sensor_nodes @@ -462,9 +462,9 @@ def _build_observed_pressure_from_scada( node_query_id: dict[str, str] = {} for item in get_all_scada_info(network): - if str(item.get("type", "")).lower() != "pressure": + if str(item.get("device_type", "")).lower() != "pressure": continue - node_id = item.get("associated_element_id") + node_id = item.get("node_id") query_id = item.get("api_query_id") if ( isinstance(node_id, str) diff --git a/app/services/network_import.py b/app/services/network_import.py index 378ffa2..7a98c0a 100644 --- a/app/services/network_import.py +++ b/app/services/network_import.py @@ -1,196 +1,6 @@ -import csv -import os - -import chardet -import psycopg -from psycopg import sql - -from app.infra.db.project_routing import get_project_pgconn_string from app.services.tjnetwork import read_inp -############################################################ -# network_update 10 -############################################################ - - def network_update(file_path: str, project_code: str) -> None: - """ - 更新pg数据库中的inp文件 - :param file_path: inp文件 - :param project_code: 元数据项目代码 - :return: - """ + """Replace one project's hydraulic model from an EPANET INP file.""" read_inp(project_code, file_path) - - csv_path = "./history_pattern_flow.csv" - - # # 检查文件是否存在 - # if os.path.exists(csv_path): - # print(f"history_patterns_flows文件存在,开始处理...") - # - # # 读取 CSV 文件 - # df = pd.read_csv(csv_path) - # - # # 连接到 PostgreSQL 数据库(这里是数据库 "bb") - # with psycopg.connect("dbname=bb host=127.0.0.1") as conn: - # with conn.cursor() as cur: - # for index, row in df.iterrows(): - # # 直接将数据插入,不进行唯一性检查 - # insert_sql = sql.SQL(""" - # INSERT INTO history_patterns_flows (id, factor, flow) - # VALUES (%s, %s, %s); - # """) - # # 将数据插入数据库 - # cur.execute(insert_sql, (row['id'], row['factor'], row['flow'])) - # conn.commit() - # print("数据成功导入到 'history_patterns_flows' 表格。") - # else: - # print(f"history_patterns_flows文件不存在。") - # 检查文件是否存在 - if os.path.exists(csv_path): - print(f"history_patterns_flows文件存在,开始处理...") - - with psycopg.connect(get_project_pgconn_string(project_code)) as conn: - with conn.cursor() as cur: - with open(csv_path, newline="", encoding="utf-8-sig") as csvfile: - reader = csv.DictReader(csvfile) - for row in reader: - # 直接将数据插入,不进行唯一性检查 - insert_sql = sql.SQL( - """ - INSERT INTO history_patterns_flows (id, factor, flow) - VALUES (%s, %s, %s); - """ - ) - # 将数据插入数据库 - cur.execute(insert_sql, (row["id"], row["factor"], row["flow"])) - conn.commit() - print("数据成功导入到 'history_patterns_flows' 表格。") - else: - print(f"history_patterns_flows文件不存在。") - - -def submit_scada_info(name: str, coord_id: str) -> None: - """ - 将scada信息表导入pg数据库 - :param name: 项目名称(数据库名称) - :param coord_id: 坐标系的id,如4326,根据原始坐标信息输入 - :return: - """ - scada_info_path = "./scada_info.csv" - # 检查文件是否存在 - if os.path.exists(scada_info_path): - print(f"scada_info文件存在,开始处理...") - - # 自动检测文件编码 - with open(scada_info_path, "rb") as file: - raw_data = file.read() - detected = chardet.detect(raw_data) - file_encoding = detected["encoding"] - print(f"检测到的文件编码:{file_encoding}") - try: - # 动态替换数据库名称 - conn_string = get_project_pgconn_string(db_name=name) - - # 连接到 PostgreSQL 数据库(这里是数据库 "bb") - with psycopg.connect(conn_string) as conn: - with conn.cursor() as cur: - # 检查 scada_info 表是否为空 - cur.execute("SELECT COUNT(*) FROM scada_info;") - count = cur.fetchone()[0] - - if count > 0: - print("scada_info表中已有数据,正在清空记录...") - cur.execute("DELETE FROM scada_info;") - print("表记录已清空。") - - with open( - scada_info_path, newline="", encoding=file_encoding - ) as csvfile: - reader = csv.DictReader(csvfile) - for row in reader: - # 将CSV单元格值为空的字段转换为 None - cleaned_row = { - key: (value if value.strip() else None) - for key, value in row.items() - } - - # 处理 associated_source_outflow_id 列动态变化 - associated_columns = [ - f"associated_source_outflow_id{i}" for i in range(1, 21) - ] - associated_values = [ - ( - cleaned_row.get(col).strip() - if cleaned_row.get(col) - and cleaned_row.get(col).strip() - else None - ) - for col in associated_columns - ] - - # 将 X_coor 和 Y_coor 转换为 geometry 类型 - x_coor = ( - float(cleaned_row["X_coor"]) - if cleaned_row["X_coor"] - else None - ) - y_coor = ( - float(cleaned_row["Y_coor"]) - if cleaned_row["Y_coor"] - else None - ) - coord = ( - f"SRID={coord_id};POINT({x_coor} {y_coor})" - if x_coor and y_coor - else None - ) - - # 准备插入 SQL 语句 - insert_sql = sql.SQL( - """ - INSERT INTO scada_info ( - id, type, associated_element_id, associated_pattern, - associated_pipe_flow_id, {associated_columns}, - API_query_id, transmission_mode, transmission_frequency, - reliability, X_coor, Y_coor, coord - ) - VALUES ( - %s, %s, %s, %s, %s, {associated_placeholders}, - %s, %s, %s, %s, %s, %s, %s - ); - """ - ).format( - associated_columns=sql.SQL(", ").join( - sql.Identifier(col) for col in associated_columns - ), - associated_placeholders=sql.SQL(", ").join( - sql.Placeholder() for _ in associated_columns - ), - ) - # 将数据插入数据库 - cur.execute( - insert_sql, - ( - cleaned_row["id"], - cleaned_row["type"], - cleaned_row["associated_element_id"], - cleaned_row.get("associated_pattern"), - cleaned_row.get("associated_pipe_flow_id"), - *associated_values, - cleaned_row.get("API_query_id"), - cleaned_row["transmission_mode"], - cleaned_row["transmission_frequency"], - cleaned_row["reliability"], - x_coor, - y_coor, - coord, - ), - ) - conn.commit() - print("数据成功导入到 'scada_info' 表格。") - except Exception as e: - print(f"导入时出错:{e}") - else: - print(f"scada_info文件不存在。") diff --git a/app/services/scheme_management.py b/app/services/scheme_management.py index b0bfe06..516c5f1 100644 --- a/app/services/scheme_management.py +++ b/app/services/scheme_management.py @@ -1,43 +1,23 @@ -import ast -import json from datetime import date, datetime +from typing import Any +from uuid import UUID, uuid4 -import geopandas as gpd -import pandas as pd -import psycopg -from sqlalchemy import create_engine +from psycopg.types.json import Jsonb -from app.infra.db.project_routing import get_project_pgconn_string +from app.native.wndb.core.connection import project_connection from app.services.time_api import parse_utc_time -# 2025/03/23 def scheme_name_exists(name: str, scheme_name: str) -> bool: - """ - 判断传入的 scheme_name 是否已存在于 scheme_list 表中,用于输入框判断 - :param name: 数据库名称 - :param scheme_name: 需要判断的方案名称 - :return: 如果存在返回 True,否则返回 False - """ - try: - conn_string = get_project_pgconn_string(db_name=name) - with psycopg.connect(conn_string) as conn: - with conn.cursor() as cur: - cur.execute( - "SELECT COUNT(*) FROM scheme_list WHERE scheme_name = %s", - (scheme_name,), - ) - result = cur.fetchone() - if result is not None and result[0] > 0: - return True - else: - return False - except Exception as e: - print(f"查询 scheme_name 时出错:{e}") - return False + with project_connection(name) as conn, conn.cursor() as cur: + cur.execute( + "select exists(select 1 from analysis.runs where name = %s)", + (scheme_name,), + ) + row = cur.fetchone() + return bool(row and row[0]) -# 2025/03/23 def store_scheme_info( name: str, scheme_name: str, @@ -45,202 +25,165 @@ def store_scheme_info( username: str, scheme_start_time: datetime | str, scheme_detail: dict, -): - """ - 将一条方案记录插入 scheme_list 表中 - :param name: 数据库名称 - :param scheme_name: 方案名称 - :param scheme_type: 方案类型 - :param username: MetaDB 中的用户名快照 - :param scheme_start_time: 带时区的方案起始时间;写入前统一转换为 UTC - :param scheme_detail: 方案详情(字典,会转换为 JSON) - :return: - """ - try: - conn_string = get_project_pgconn_string(db_name=name) - with psycopg.connect(conn_string) as conn: - with conn.cursor() as cur: - sql = """ - INSERT INTO scheme_list (scheme_name, scheme_type, username, scheme_start_time, scheme_detail) - VALUES (%s, %s, %s, %s, %s) - """ - # 将字典转换为 JSON 字符串 - scheme_detail_json = json.dumps(scheme_detail) - normalized_scheme_start_time = parse_utc_time( - scheme_start_time, field_name="scheme_start_time" - ) - cur.execute( - sql, - ( - scheme_name, - scheme_type, - username, - normalized_scheme_start_time, - scheme_detail_json, - ), - ) - conn.commit() - print("方案信息存储成功!") - except Exception as e: - print(f"存储方案信息时出错:{e}") +) -> UUID: + """Create one completed, immutable analysis run.""" + return create_analysis_run( + name=name, + scheme_name=scheme_name, + scheme_type=scheme_type, + username=username, + scheme_start_time=scheme_start_time, + scheme_detail=scheme_detail, + status="completed", + ) -# 2025/03/23 -def delete_scheme_info(name: str, scheme_name: str) -> None: - """ - 从 scheme_list 表中删除指定的方案 - :param name: 数据库名称 - :param scheme_name: 要删除的方案名称 - """ - try: - conn_string = get_project_pgconn_string(db_name=name) - with psycopg.connect(conn_string) as conn: - with conn.cursor() as cur: - # 使用参数化查询删除方案记录 - cur.execute( - "DELETE FROM scheme_list WHERE scheme_name = %s", (scheme_name,) - ) - conn.commit() - print(f"方案 {scheme_name} 删除成功!") - except Exception as e: - print(f"删除方案时出错:{e}") +def create_analysis_run( + name: str, + scheme_name: str, + scheme_type: str, + username: str, + scheme_start_time: datetime | str, + scheme_detail: dict, + *, + status: str = "running", +) -> UUID: + """Create a distinct execution record; names are labels, not identities.""" + started_at = parse_utc_time(scheme_start_time, field_name="scheme_start_time") + run_id = uuid4() + with project_connection(name) as conn, conn.cursor() as cur: + cur.execute( + """ + insert into analysis.runs + (run_id, name, run_type, created_by, created_at, started_at, status, parameters) + values (%s, %s, %s, %s, now(), %s, %s, %s) + """, + ( + run_id, + scheme_name, + scheme_type, + username, + started_at, + status, + Jsonb(scheme_detail), + ), + ) + return run_id + + +def update_analysis_run( + name: str, + run_id: UUID, + *, + status: str, + username: str, + scheme_detail: dict, +) -> None: + """Update lifecycle state and metadata for one execution identity.""" + with project_connection(name) as conn, conn.cursor() as cur: + cur.execute( + """ + update analysis.runs + set created_by = %s, status = %s, parameters = %s + where run_id = %s + """, + (username, status, Jsonb(scheme_detail), run_id), + ) + if cur.rowcount != 1: + raise LookupError(f"analysis run {run_id} does not exist") + + +def _run_row(row: dict[str, Any]) -> dict[str, Any]: + parameters = row.get("parameters") if isinstance(row.get("parameters"), dict) else {} + return { + "run_id": row["run_id"], + "name": row["name"], + "run_type": row["run_type"], + "created_by": row["created_by"], + "created_at": row["created_at"], + "started_at": row["started_at"], + "status": row["status"], + "parameters": parameters, + } + + +def _list_runs( + name: str, + run_type: str | None = None, + query_date: date | None = None, +) -> list[dict[str, Any]]: + clauses: list[str] = [] + params: list[Any] = [] + if run_type: + clauses.append("run_type = %s") + params.append(run_type) + if query_date is not None: + clauses.append("created_at::date = %s") + params.append(query_date) + where = f"where {' and '.join(clauses)}" if clauses else "" + with project_connection(name) as conn, conn.cursor() as cur: + cur.execute( + f"select run_id, name, run_type, created_by, created_at, started_at, status, parameters from analysis.runs {where} order by created_at desc", + params, + ) + return [_run_row(row) for row in cur.fetchall()] -# 2025/03/23 def query_scheme_list( name: str, scheme_type: str | None = None, query_date: date | None = None, -) -> list: - """ - 查询pg数据库中的scheme_list,按照 create_time 降序排列,离现在时间最近的记录排在最前面 - :param name: 项目名称(数据库名称) - :param scheme_type: 方案类型;为空时返回全部类型 - :param query_date: 查询日期;为空时不按日期过滤 - :return: 返回查询结果的所有行 - """ - try: - # 动态替换数据库名称 - conn_string = get_project_pgconn_string(db_name=name) - # 连接到 PostgreSQL 数据库(这里是数据库 "bb") - with psycopg.connect(conn_string) as conn: - with conn.cursor() as cur: - if scheme_type and query_date is not None: - cur.execute( - """ - SELECT * - FROM scheme_list - WHERE scheme_type = %s AND DATE(create_time) = %s - ORDER BY create_time DESC - """, - (scheme_type, query_date), - ) - elif scheme_type: - cur.execute( - """ - SELECT * - FROM scheme_list - WHERE scheme_type = %s - ORDER BY create_time DESC - """, - (scheme_type,), - ) - elif query_date is not None: - cur.execute( - """ - SELECT * - FROM scheme_list - WHERE DATE(create_time) = %s - ORDER BY create_time DESC - """, - (query_date,), - ) - else: - cur.execute("SELECT * FROM scheme_list ORDER BY create_time DESC") - rows = cur.fetchall() - return rows - - except Exception as e: - print(f"查询错误:{e}") +) -> list[dict[str, Any]]: + return _list_runs(name, scheme_type, query_date) -def _filter_scheme_detail_scope( - result: dict, +def _get_run_by_name( name: str, - scheme_type: str | None = None, -) -> dict: - if not result: - return {} - if scheme_type and result.get("scheme_type") != scheme_type: - return {} - network = result.get("network") - if network not in (None, name): - return {} - return result + run_name: str, + run_type: str | None = None, +) -> dict[str, Any]: + params: list[Any] = [run_name] + type_clause = "" + if run_type: + type_clause = "and run_type = %s" + params.append(run_type) + with project_connection(name) as conn, conn.cursor() as cur: + cur.execute( + f""" + select run_id, name, run_type, created_by, created_at, started_at, + status, parameters + from analysis.runs + where name = %s {type_clause} + order by created_at desc + limit 1 + """, + params, + ) + row = cur.fetchone() + return _run_row(row) if row else {} + + +def get_analysis_run(name: str, run_id: UUID) -> dict[str, Any]: + with project_connection(name) as conn, conn.cursor() as cur: + cur.execute( + """ + select run_id, name, run_type, created_by, created_at, started_at, + status, parameters + from analysis.runs + where run_id = %s + """, + (run_id,), + ) + row = cur.fetchone() + return _run_row(row) if row else {} def query_scheme_detail( name: str, scheme_name: str, scheme_type: str | None = None, -) -> dict: - if scheme_type == "dma_leak_identification": - return _filter_scheme_detail_scope( - query_leakage_identify_scheme_detail(name, scheme_name), - name, - scheme_type, - ) - if scheme_type == "burst_detection": - return _filter_scheme_detail_scope( - query_burst_detection_scheme_detail(name, scheme_name), - name, - scheme_type, - ) - if scheme_type == "burst_location": - return _filter_scheme_detail_scope( - query_burst_location_scheme_detail(name, scheme_name), - name, - scheme_type, - ) - - conn_string = get_project_pgconn_string(db_name=name) - with psycopg.connect(conn_string) as conn: - with conn.cursor() as cur: - if scheme_type: - cur.execute( - """ - SELECT scheme_id, scheme_name, scheme_type, username, create_time, scheme_start_time, scheme_detail - FROM public.scheme_list - WHERE scheme_name = %s AND scheme_type = %s - LIMIT 1 - """, - (scheme_name, scheme_type), - ) - else: - cur.execute( - """ - SELECT scheme_id, scheme_name, scheme_type, username, create_time, scheme_start_time, scheme_detail - FROM public.scheme_list - WHERE scheme_name = %s - LIMIT 1 - """, - (scheme_name,), - ) - row = cur.fetchone() - if row is None: - return {} - detail = row[6] if isinstance(row[6], dict) else {} - return _filter_scheme_detail_scope({ - "scheme_id": row[0], - "scheme_name": row[1], - "scheme_type": row[2], - "username": row[3], - "create_time": row[4], - "scheme_start_time": row[5], - "scheme_detail": detail, - "network": detail.get("network"), - "result_payload": detail.get("result_payload", {}), - }, name, scheme_type) +) -> dict[str, Any]: + return _get_run_by_name(name, scheme_name, scheme_type) def store_leakage_identify_result( @@ -255,42 +198,51 @@ def store_leakage_identify_result( run_status: str = "completed", error_message: str | None = None, ) -> None: - conn_string = get_project_pgconn_string(db_name=name) - with psycopg.connect(conn_string) as conn: - with conn.cursor() as cur: - cur.execute( - """ - INSERT INTO public.leakage_identify_result - ( - scheme_name, network, run_status, error_message, - sensor_nodes, result_rows, node_area_map, areas, drawing_payload - ) - VALUES (%s, %s, %s, %s, %s::jsonb, %s::jsonb, %s::jsonb, %s::jsonb, %s::jsonb) - ON CONFLICT (scheme_name) - DO UPDATE SET - network = EXCLUDED.network, - run_status = EXCLUDED.run_status, - error_message = EXCLUDED.error_message, - sensor_nodes = EXCLUDED.sensor_nodes, - result_rows = EXCLUDED.result_rows, - node_area_map = EXCLUDED.node_area_map, - areas = EXCLUDED.areas, - drawing_payload = EXCLUDED.drawing_payload, - created_at = NOW(); - """, - ( - scheme_name, - network, - run_status, - error_message, - json.dumps(sensor_nodes), - json.dumps(result_rows), - json.dumps(node_area_map), - json.dumps(areas), - json.dumps(drawing_payload or {}), - ), - ) - conn.commit() + run = _get_run_by_name(name, scheme_name, "dma_leak_identification") + if not run: + raise LookupError(f"analysis run {scheme_name!r} does not exist") + payload = { + "network": network, + "run_status": run_status, + "error_message": error_message, + "sensor_nodes": sensor_nodes, + "rows": result_rows, + "node_area_map": node_area_map, + "areas": areas, + "drawing_payload": drawing_payload or {}, + } + with project_connection(name) as conn, conn.cursor() as cur: + cur.execute( + "insert into analysis.results (run_id, result_type, payload) values (%s, 'leakage_identification', %s)", + (run["run_id"], Jsonb(payload)), + ) + + +def _list_typed_runs( + name: str, + network: str, + run_type: str, + query_date: date | None, +) -> list[dict[str, Any]]: + rows = _list_runs(name, run_type, query_date) + return [ + row + for row in rows + if not network or row["parameters"].get("network") in (None, network) + ] + + +def _typed_run_detail(name: str, run_name: str, run_type: str) -> dict[str, Any]: + run = _get_run_by_name(name, run_name, run_type) + if not run: + return {} + with project_connection(name) as conn, conn.cursor() as cur: + cur.execute( + "select result_type, payload, created_at from analysis.results where run_id = %s order by created_at, result_id", + (run["run_id"],), + ) + results = [dict(row) for row in cur.fetchall()] + return run | {"results": results} def query_leakage_identify_schemes( @@ -298,100 +250,12 @@ def query_leakage_identify_schemes( network: str, scheme_type: str = "dma_leak_identification", query_date: date | None = None, -) -> list[dict]: - conn_string = get_project_pgconn_string(db_name=name) - with psycopg.connect(conn_string) as conn: - with conn.cursor() as cur: - if query_date is None: - cur.execute( - """ - SELECT scheme_id, scheme_name, scheme_type, username, create_time, scheme_start_time, scheme_detail - FROM public.scheme_list - WHERE scheme_type = %s - ORDER BY create_time DESC - """, - (scheme_type,), - ) - else: - cur.execute( - """ - SELECT scheme_id, scheme_name, scheme_type, username, create_time, scheme_start_time, scheme_detail - FROM public.scheme_list - WHERE scheme_type = %s AND DATE(create_time) = %s - ORDER BY create_time DESC - """, - (scheme_type, query_date), - ) - rows = cur.fetchall() - result = [] - for row in rows: - detail = row[6] if isinstance(row[6], dict) else {} - if network and detail.get("network") not in (None, network): - continue - result.append( - { - "scheme_id": row[0], - "scheme_name": row[1], - "scheme_type": row[2], - "username": row[3], - "create_time": row[4], - "scheme_start_time": row[5], - "scheme_detail": detail, - } - ) - return result +) -> list[dict[str, Any]]: + return _list_typed_runs(name, network, scheme_type, query_date) -def query_leakage_identify_scheme_detail(name: str, scheme_name: str) -> dict: - conn_string = get_project_pgconn_string(db_name=name) - with psycopg.connect(conn_string) as conn: - with conn.cursor() as cur: - cur.execute( - """ - SELECT scheme_id, scheme_name, scheme_type, username, create_time, scheme_start_time, scheme_detail - FROM public.scheme_list - WHERE scheme_name = %s - LIMIT 1 - """, - (scheme_name,), - ) - base_row = cur.fetchone() - if base_row is None: - return {} - cur.execute( - """ - SELECT network, created_at, run_status, error_message, sensor_nodes, result_rows, node_area_map, areas, drawing_payload - FROM public.leakage_identify_result - WHERE scheme_name = %s - LIMIT 1 - """, - (scheme_name,), - ) - result_row = cur.fetchone() - if result_row is None: - return {} - return { - "scheme_id": base_row[0], - "scheme_name": base_row[1], - "scheme_type": base_row[2], - "username": base_row[3], - "create_time": base_row[4], - "scheme_start_time": base_row[5], - "scheme_detail": base_row[6] if isinstance(base_row[6], dict) else {}, - "network": result_row[0], - "result_created_at": result_row[1], - "run_status": result_row[2], - "error_message": result_row[3], - "sensor_nodes": result_row[4] if isinstance(result_row[4], list) else [], - "rows": result_row[5] if isinstance(result_row[5], list) else [], - "node_area_map": result_row[6] if isinstance(result_row[6], dict) else {}, - "areas": result_row[7] if isinstance(result_row[7], list) else [], - "drawing_payload": ( - result_row[8] - if isinstance(result_row[8], dict) - else {"type": "FeatureCollection", "features": []} - ), - } +def query_leakage_identify_scheme_detail(name: str, scheme_name: str) -> dict[str, Any]: + return _typed_run_detail(name, scheme_name, "dma_leak_identification") def query_burst_location_schemes( @@ -399,78 +263,12 @@ def query_burst_location_schemes( network: str, scheme_type: str = "burst_location", query_date: date | None = None, -) -> list[dict]: - conn_string = get_project_pgconn_string(db_name=name) - with psycopg.connect(conn_string) as conn: - with conn.cursor() as cur: - if query_date is None: - cur.execute( - """ - SELECT scheme_id, scheme_name, scheme_type, username, create_time, scheme_start_time, scheme_detail - FROM public.scheme_list - WHERE scheme_type = %s - ORDER BY create_time DESC - """, - (scheme_type,), - ) - else: - cur.execute( - """ - SELECT scheme_id, scheme_name, scheme_type, username, create_time, scheme_start_time, scheme_detail - FROM public.scheme_list - WHERE scheme_type = %s AND DATE(create_time) = %s - ORDER BY create_time DESC - """, - (scheme_type, query_date), - ) - rows = cur.fetchall() - result = [] - for row in rows: - detail = row[6] if isinstance(row[6], dict) else {} - if network and detail.get("network") not in (None, network): - continue - result.append( - { - "scheme_id": row[0], - "scheme_name": row[1], - "scheme_type": row[2], - "username": row[3], - "create_time": row[4], - "scheme_start_time": row[5], - "scheme_detail": detail, - } - ) - return result +) -> list[dict[str, Any]]: + return _list_typed_runs(name, network, scheme_type, query_date) -def query_burst_location_scheme_detail(name: str, scheme_name: str) -> dict: - conn_string = get_project_pgconn_string(db_name=name) - with psycopg.connect(conn_string) as conn: - with conn.cursor() as cur: - cur.execute( - """ - SELECT scheme_id, scheme_name, scheme_type, username, create_time, scheme_start_time, scheme_detail - FROM public.scheme_list - WHERE scheme_name = %s - LIMIT 1 - """, - (scheme_name,), - ) - base_row = cur.fetchone() - if base_row is None: - return {} - detail = base_row[6] if isinstance(base_row[6], dict) else {} - return { - "scheme_id": base_row[0], - "scheme_name": base_row[1], - "scheme_type": base_row[2], - "username": base_row[3], - "create_time": base_row[4], - "scheme_start_time": base_row[5], - "scheme_detail": detail, - "network": detail.get("network"), - "result_payload": detail.get("result_payload", {}), - } +def query_burst_location_scheme_detail(name: str, scheme_name: str) -> dict[str, Any]: + return _typed_run_detail(name, scheme_name, "burst_location") def query_burst_detection_schemes( @@ -478,171 +276,9 @@ def query_burst_detection_schemes( network: str, scheme_type: str = "burst_detection", query_date: date | None = None, -) -> list[dict]: - conn_string = get_project_pgconn_string(db_name=name) - with psycopg.connect(conn_string) as conn: - with conn.cursor() as cur: - if query_date is None: - cur.execute( - """ - SELECT scheme_id, scheme_name, scheme_type, username, create_time, scheme_start_time, scheme_detail - FROM public.scheme_list - WHERE scheme_type = %s - ORDER BY create_time DESC - """, - (scheme_type,), - ) - else: - cur.execute( - """ - SELECT scheme_id, scheme_name, scheme_type, username, create_time, scheme_start_time, scheme_detail - FROM public.scheme_list - WHERE scheme_type = %s AND DATE(create_time) = %s - ORDER BY create_time DESC - """, - (scheme_type, query_date), - ) - rows = cur.fetchall() - result = [] - for row in rows: - detail = row[6] if isinstance(row[6], dict) else {} - if network and detail.get("network") not in (None, network): - continue - result.append( - { - "scheme_id": row[0], - "scheme_name": row[1], - "scheme_type": row[2], - "username": row[3], - "create_time": row[4], - "scheme_start_time": row[5], - "scheme_detail": detail, - } - ) - return result +) -> list[dict[str, Any]]: + return _list_typed_runs(name, network, scheme_type, query_date) -def query_burst_detection_scheme_detail(name: str, scheme_name: str) -> dict: - conn_string = get_project_pgconn_string(db_name=name) - with psycopg.connect(conn_string) as conn: - with conn.cursor() as cur: - cur.execute( - """ - SELECT scheme_id, scheme_name, scheme_type, username, create_time, scheme_start_time, scheme_detail - FROM public.scheme_list - WHERE scheme_name = %s - LIMIT 1 - """, - (scheme_name,), - ) - base_row = cur.fetchone() - if base_row is None: - return {} - detail = base_row[6] if isinstance(base_row[6], dict) else {} - return { - "scheme_id": base_row[0], - "scheme_name": base_row[1], - "scheme_type": base_row[2], - "username": base_row[3], - "create_time": base_row[4], - "scheme_start_time": base_row[5], - "scheme_detail": detail, - "network": detail.get("network"), - "result_payload": detail.get("result_payload", {}), - } - - -# 2025/03/23 -def upload_shp_to_pg(name: str, table_name: str, role: str, shp_file_path: str): - """ - 将 Shapefile 文件上传到 PostgreSQL 数据库 - :param name: 项目名称(数据库名称) - :param table_name: 创建表的名字 - :param role: 数据库角色名,位于c盘user中查看 - :param shp_file_path: shp文件的路径 - :return: - """ - try: - # 动态连接到指定的数据库 - conn_string = get_project_pgconn_string(db_name=name) - with psycopg.connect(conn_string) as conn: - # 读取 Shapefile 文件 - gdf = gpd.read_file(shp_file_path) - - # 检查投影坐标系(CRS),并确保是 EPSG:4326 - if gdf.crs.to_string() != "EPSG:4490": - gdf = gdf.to_crs(epsg=4490) - - # 使用 GeoDataFrame 的 .to_postgis 方法将数据写入 PostgreSQL - # 需要在数据库中提前安装 PostGIS 扩展 - engine = create_engine(f"postgresql+psycopg2://{role}:@127.0.0.1/{name}") - gdf.to_postgis( - table_name, engine, if_exists="replace", index=True, index_label="id" - ) - - print( - f"Shapefile 文件成功上传到 PostgreSQL 数据库 '{name}' 的表 '{table_name}'." - ) - - except Exception as e: - print(f"上传 Shapefile 到 PostgreSQL 时出错:{e}") - - -def submit_risk_probability_result(name: str, result_file_path: str) -> None: - """ - 将管网风险评估结果导入pg数据库 - :param name: 项目名称(数据库名称) - :param result_file_path: 结果文件路径 - :return: - """ - # 自动检测文件编码 - # with open({result_file_path}, 'rb') as file: - # raw_data = file.read() - # detected = chardet.detect(raw_data) - # file_encoding = detected['encoding'] - # print(f"检测到的文件编码:{file_encoding}") - - try: - # 动态替换数据库名称 - conn_string = get_project_pgconn_string(db_name=name) - - # 连接到 PostgreSQL 数据库 - with psycopg.connect(conn_string) as conn: - with conn.cursor() as cur: - # 检查 scada_info 表是否为空 - cur.execute("SELECT COUNT(*) FROM pipe_risk_probability;") - count = cur.fetchone()[0] - - if count > 0: - print("pipe_risk_probability表中已有数据,正在清空记录...") - cur.execute("DELETE FROM pipe_risk_probability;") - print("表记录已清空。") - - # 读取Excel并转换x/y列为列表 - df = pd.read_excel(result_file_path, sheet_name="Sheet1") - df["x"] = df["x"].apply(ast.literal_eval) - df["y"] = df["y"].apply(ast.literal_eval) - - # 批量插入数据 - for index, row in df.iterrows(): - insert_query = """ - INSERT INTO pipe_risk_probability - (pipeID, pipeage, risk_probability_now, x, y) - VALUES (%s, %s, %s, %s, %s) - """ - cur.execute( - insert_query, - ( - row["pipeID"], - row["pipeage"], - row["risk_probability_now"], - row["x"], # 直接传递列表 - row["y"], # 同上 - ), - ) - - conn.commit() - print("风险评估结果导入成功") - - except Exception as e: - print(f"导入时出错:{e}") +def query_burst_detection_scheme_detail(name: str, scheme_name: str) -> dict[str, Any]: + return _typed_run_detail(name, scheme_name, "burst_detection") diff --git a/app/services/sensor_placement.py b/app/services/sensor_placement.py index 8d4f4f7..b1dddc7 100644 --- a/app/services/sensor_placement.py +++ b/app/services/sensor_placement.py @@ -1,6 +1,7 @@ from datetime import datetime from io import BytesIO from typing import Any +from uuid import UUID from openpyxl import Workbook from openpyxl.styles import Alignment, Font, PatternFill @@ -8,7 +9,7 @@ from openpyxl.worksheet.worksheet import Worksheet from openpyxl.utils import get_column_letter from pyproj import Transformer -from app.native import wndb +from app.infra.db.postgresql import sensor_placement as sensor_placement_repository class SensorPlacementNotFoundError(LookupError): @@ -61,7 +62,9 @@ def _sensor_points( network: str, sensor_location: list[str], ) -> list[dict[str, Any]]: - nodes = wndb.get_sensor_placement_nodes(network, sensor_location) + nodes = sensor_placement_repository.get_sensor_placement_nodes( + network, sensor_location + ) by_id = {str(node["node_id"]): node for node in nodes} missing = [node_id for node_id in sensor_location if node_id not in by_id] if missing: @@ -113,49 +116,59 @@ def validate_sensor_placement_nodes( _sensor_points(network, _normalize_locations(sensor_location)) -def get_sensor_placement_scheme(network: str, scheme_id: int) -> dict[str, Any]: - scheme = wndb.get_sensor_placement(network, scheme_id) - if scheme is None: - raise SensorPlacementNotFoundError("监测点方案不存在") +def get_sensor_placement_run(network: str, run_id: UUID) -> dict[str, Any]: + run = sensor_placement_repository.get_sensor_placement(network, run_id) + if run is None: + raise SensorPlacementNotFoundError("监测点优化运行不存在") - locations = [str(item) for item in (scheme.get("sensor_location") or [])] + locations = [str(item) for item in (run.get("sensor_locations") or [])] return { - **scheme, - "sensor_number": len(locations), - "sensor_location": locations, + **run, + "sensor_count": len(locations), + "sensor_locations": locations, "sensor_points": _sensor_points(network, locations), } -def update_sensor_placement_scheme( +def list_sensor_placement_runs(network: str) -> list[dict[str, Any]]: + return [ + { + **run, + "sensor_points": _sensor_points(network, run["sensor_locations"]), + } + for run in sensor_placement_repository.get_all_sensor_placements(network) + ] + + +def update_sensor_placement_run( network: str, - scheme_id: int, + run_id: UUID, *, - expected_sensor_location: list[str], - sensor_location: list[str], + expected_sensor_locations: list[str], + sensor_locations: list[str], ) -> dict[str, Any]: - expected = _normalize_locations(expected_sensor_location) - next_locations = _normalize_locations(sensor_location) + expected = _normalize_locations(expected_sensor_locations) + next_locations = _normalize_locations(sensor_locations) _sensor_points(network, next_locations) - updated = wndb.update_sensor_placement( + updated = sensor_placement_repository.update_sensor_placement( network, - scheme_id, - expected_sensor_location=expected, - sensor_location=next_locations, + run_id, + expected_sensor_locations=expected, + sensor_locations=next_locations, ) if updated is None: - if wndb.get_sensor_placement(network, scheme_id) is None: - raise SensorPlacementNotFoundError("监测点方案不存在") - raise SensorPlacementConflictError("方案已被其他用户修改,请重新加载") - return get_sensor_placement_scheme(network, scheme_id) + if sensor_placement_repository.get_sensor_placement(network, run_id) is None: + raise SensorPlacementNotFoundError("监测点优化运行不存在") + raise SensorPlacementConflictError("运行结果已被其他用户修改,请重新加载") + return get_sensor_placement_run(network, run_id) -def can_edit_sensor_placement(user: Any, scheme: dict[str, Any]) -> bool: +def can_edit_sensor_placement(user: Any, run: dict[str, Any]) -> bool: return bool( getattr(user, "is_superuser", False) or getattr(user, "role", None) == "admin" - or getattr(user, "username", None) == scheme.get("username") + or getattr(user, "username", None) == run.get("created_by") ) @@ -174,16 +187,16 @@ def _populate_info_sheet( location_count: int, is_draft: bool, ) -> None: - created_at = scheme["create_time"] + created_at = scheme["created_at"] if isinstance(created_at, datetime): created_at = created_at.isoformat(timespec="minutes") rows = [ ("项目", network), - ("方案名称", scheme["scheme_name"]), + ("运行名称", scheme["name"]), ("监测点数量", location_count), ("最小管径", scheme["min_diameter"]), - ("创建人", scheme["username"]), + ("创建人", scheme["created_by"]), ("创建时间", created_at), ("导出时间", datetime.now().astimezone().isoformat(timespec="minutes")), ("文档状态", "未保存草稿" if is_draft else "当前方案"), @@ -245,7 +258,7 @@ def build_sensor_placement_workbook( ) -> BytesIO: locations = _normalize_locations(sensor_location) points = _sensor_points(network, locations) - is_draft = locations != list(scheme["sensor_location"]) + is_draft = locations != list(scheme["sensor_locations"]) workbook = Workbook() info_sheet = workbook.active diff --git a/app/services/simulation.py b/app/services/simulation.py index 0ee8b4b..6ea495c 100644 --- a/app/services/simulation.py +++ b/app/services/simulation.py @@ -28,16 +28,17 @@ import pytz import requests import time from typing import Optional, Tuple +from uuid import UUID import typing -import psycopg 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.infra.db.project_routing import get_project_pgconn_string +from app.native.wndb.core.connection import project_connection from app.infra.db.timescaledb.internal_queries import ( InternalQueries as TimescaleInternalQueries, ) +from app.services.scheme_management import create_analysis_run, update_analysis_run from app.infra.db.timescaledb.internal_queries import ( InternalStorage as TimescaleInternalStorage, ) @@ -48,569 +49,35 @@ logging.basicConfig( def query_corresponding_element_id_and_query_id(name: str) -> None: - """ - 查询scada_info这张表中,realtime类型的记录中,associated_element_id与api_query_id的对应关系 - :param name: 数据库名称 - :return: - """ - # 连接数据库 - conn_string = get_project_pgconn_string(db_name=name) - try: - with psycopg.connect(conn_string) as conn: - with conn.cursor() as cur: - # 查询 transmission_mode 为 'realtime' 的记录 - cur.execute( - """ - SELECT type, associated_element_id, api_query_id - FROM scada_info - WHERE transmission_mode = 'realtime'; - """ - ) - records = cur.fetchall() - # 遍历查询结果,并根据 type 将数据存储到相应的字典中 - for record in records: - type_, associated_element_id, api_query_id = record - if type_ == "reservoir_liquid_level": - globals.reservoirs_id[associated_element_id] = api_query_id - elif type_ == "tank_liquid_level": - globals.tanks_id[associated_element_id] = api_query_id - elif type_ == "fixed_pump": - globals.fixed_pumps_id[associated_element_id] = api_query_id - elif type_ == "variable_pump": - globals.variable_pumps_id[associated_element_id] = api_query_id - elif type_ == "pressure": - globals.pressure_id[associated_element_id] = api_query_id - elif type_ == "demand": - globals.demand_id[associated_element_id] = api_query_id - elif type_ == "quality": - globals.quality_id[associated_element_id] = api_query_id - else: - # 如果遇到未定义的类型,可以选择记录日志或忽略 - print(f"未处理的类型: {type_}") - except psycopg.Error as e: - print(f"数据库连接或查询出错: {e}") - - -def query_corresponding_pattern_id_and_query_id(name: str) -> None: - """ - 查询 scada_info 表中 transmission_mode 为 'realtime',且 type 为 'source_outflow' 或 'pipe_flow' 的记录, - 提取 associated_pattern 和 api_query_id 的对应关系,并分别存储到对应的字典中。 - :param name: 数据库名称 - :return: - """ - # 连接数据库 - conn_string = get_project_pgconn_string(db_name=name) - try: - with psycopg.connect(conn_string) as conn: - with conn.cursor() as cur: - # 查询 transmission_mode 为 'realtime' 且 type 为 'source_outflow' 或 'pipe_flow' 的记录 - cur.execute( - """ - SELECT type, associated_pattern, api_query_id - FROM scada_info - WHERE transmission_mode = 'realtime' - AND type IN ('source_outflow', 'pipe_flow'); - """ - ) - records = cur.fetchall() - # 遍历查询结果,并根据 type 将数据存储到相应的字典中 - for record in records: - type_, associated_pattern, api_query_id = record - if type_ == "source_outflow": - globals.source_outflow_pattern_id[associated_pattern] = ( - api_query_id - ) - elif type_ == "pipe_flow": - globals.realtime_pipe_flow_pattern_id[associated_pattern] = ( - api_query_id - ) - except psycopg.Error as e: - print(f"数据库连接或查询出错: {e}") - - -# 2025/01/11 -def query_non_realtime_region(name: str) -> dict: - """ - 查询 scada_info 表中 transmission_mode 为 'non_realtime',且 type 为 'pipe_flow' 的记录, - 提取所有以 'associated_source_outflow_id' 开头的列的值,并将每条记录的这些值作为一个 region(region1, region2, ...), - 最后去掉重复的 region,并存储到 source_outflow_region 字典中。 - :param name: 数据库名字 - :return: 包含区域与对应 associated_source_outflow_id 的字典 - """ - source_outflow_regions = [] # 用于存储所有 region(包含重复的) - # 构建连接字符串 - conn_string = get_project_pgconn_string(db_name=name) - try: - # 连接到数据库 - with psycopg.connect(conn_string) as conn: - with conn.cursor() as cur: - # 执行查询,筛选出 transmission_mode 为 'non_realtime' 且 type 为 'pipe_flow' 的记录 - cur.execute( - """ - SELECT * - FROM scada_info - WHERE transmission_mode = 'non_realtime' - AND type = 'pipe_flow'; - """ - ) - records = cur.fetchall() - col_names = [desc.name for desc in cur.description] - # 找出所有以 'associated_source_outflow_id' 开头的列 - source_outflow_cols = [ - col - for col in col_names - if col.startswith("associated_source_outflow_id") - ] - logging.info( - f"Identified source_outflow columns: {source_outflow_cols}" - ) - for record in records: - # 提取所有以 'associated_source_outflow_id' 开头的列的值,排除 None - values = [ - record[col_names.index(col)] - for col in source_outflow_cols - if record[col_names.index(col)] is not None - ] - # 如果该记录有相关的值,则将其作为一个 region - if values: - # 将值排序以确保相同的组合顺序一致(如果顺序不重要) - # 如果顺序重要,请删除排序步骤 - region_tuple = tuple(sorted(values)) - source_outflow_regions.append(region_tuple) - # 移除重复的 regions - unique_regions = [] - seen = set() - for region in source_outflow_regions: - if region not in seen: - seen.add(region) - unique_regions.append(region) - # 为每个唯一的 region 分配一个 region 键 - for idx, region in enumerate(unique_regions, 1): - region_key = f"region{idx}" - globals.source_outflow_region[region_key] = list(region) - logging.info("查询并处理数据成功。") - except psycopg.Error as e: - logging.error(f"数据库连接或查询出错: {e}") - except Exception as ex: - logging.error(f"处理数据时出错: {ex}") - return globals.source_outflow_region - - -# 2025/01/18 -def query_non_realtime_region_patterns( - name: str, - source_outflow_region: dict, - column_prefix: str = "associated_source_outflow_id", -) -> dict: - """ - 根据 source_outflow_region,对 scada_info 表中 transmission_mode 为 'non_realtime'的记录进行分组, - 将匹配的记录的 associated_pattern 存入 non_realtime_region_patterns 字典中,同时把用 realtime pipe_flow修正的 non_realtime demand 去掉 - :param name: 数据库名称 - :param source_outflow_region: 包含区域与对应 associated_source_outflow_id 的字典 - :param column_prefix: 需要提取的列的前缀 - :return: 包含区域与对应 associated_pattern 的字典 - """ - globals.non_realtime_region_patterns = { - region: [] for region in globals.source_outflow_region.keys() + """Load realtime device-to-element mappings from the new asset schema.""" + target_maps = { + "reservoir_liquid_level": globals.reservoirs_id, + "tank_liquid_level": globals.tanks_id, + "fixed_pump": globals.fixed_pumps_id, + "variable_pump": globals.variable_pumps_id, + "pressure": globals.pressure_id, + "demand": globals.demand_id, + "quality": globals.quality_id, } - region_tuple_to_key = { - frozenset(ids): region for region, ids in globals.source_outflow_region.items() - } - conn_string = get_project_pgconn_string(db_name=name) - try: - with psycopg.connect(conn_string) as conn: - with conn.cursor() as cur: - # 执行查询,筛选出 transmission_mode 为 'non_realtime' - cur.execute( - """ - SELECT * - FROM scada_info - WHERE transmission_mode = 'non_realtime' - """ - ) - records = cur.fetchall() - col_names = [desc.name for desc in cur.description] - # 找出所有以指定前缀开头的列 - source_outflow_cols = [ - col for col in col_names if col.startswith(column_prefix) - ] - logging.info( - f"Identified source_outflow columns: {source_outflow_cols}" - ) - # 确保 'associated_pattern' 列存在 - if "associated_pattern" not in col_names: - logging.error( - "'associated_pattern' column not found in scada_info table." - ) - return globals.non_realtime_region_patterns - # 获取 'associated_pattern' 列的索引 - pattern_idx = col_names.index("associated_pattern") - for record in records: - # 提取所有以 'associated_source_outflow_id' 开头的列的值,排除 None - values = [ - record[col_names.index(col)] - for col in source_outflow_cols - if record[col_names.index(col)] is not None - ] - if values: - # 将值转换为 frozenset 以便与 region_tuple_to_key 进行匹配 - region_frozenset = frozenset(values) - # 检查是否存在匹配的 region - region_key = region_tuple_to_key.get(region_frozenset) - if region_key: - # 获取 'associated_pattern' 的值 - associated_pattern = record[pattern_idx] - if associated_pattern is not None: - globals.non_realtime_region_patterns[region_key].append( - associated_pattern - ) - logging.info("生成 regions_patterns 成功。") - except psycopg.Error as e: - logging.error(f"数据库连接或查询出错: {e}") - except Exception as ex: - logging.error(f"处理数据时出错: {ex}") - # 获取pipe_flow_region_patterns中的所有区域 - exclude_regions = set( - region - for regions in globals.pipe_flow_region_patterns.values() - for region in regions - ) - # 从non_realtime_region_patterns中去除这些区域 - for region_key, regions in globals.non_realtime_region_patterns.items(): - globals.non_realtime_region_patterns[region_key] = [ - region for region in regions if region not in exclude_regions - ] - return globals.non_realtime_region_patterns - - -# 2025/01/18 -def query_realtime_region_pipe_flow_and_demand_id( - name: str, - source_outflow_region: dict, - column_prefix: str = "associated_source_outflow_id", -) -> dict: - """ - 根据 source_outflow_region,对 scada_info 表中 transmission_mode 为 'realtime', - 且 type 为 'pipe_flow' 或 ‘demand’ 的记录进行分组,将匹配的记录的 api_query_id 存入 realtime_region_pipe_flow_and_demand_id 字典中。 - :param name: 数据库名称 - :param source_outflow_region: 包含区域与对应 associated_source_outflow_id 的字典 - :param column_prefix: 需要提取的列的前缀 - :return: 包含区域与对应 api_query_id 的字典 - """ - globals.realtime_region_pipe_flow_and_demand_id = { - region: [] for region in globals.source_outflow_region.keys() - } - # 创建一个映射,从 frozenset(ids) 到 region_key - region_tuple_to_key = { - frozenset(ids): region for region, ids in globals.source_outflow_region.items() - } - conn_string = get_project_pgconn_string(db_name=name) - try: - with psycopg.connect(conn_string) as conn: - with conn.cursor() as cur: - # 执行查询,筛选出 transmission_mode 为 'realtime' 且 type 为 'pipe_flow' 或 'demand' 的记录 - cur.execute( - """ - SELECT * - FROM scada_info - WHERE transmission_mode = 'realtime' - AND type IN ('pipe_flow', 'demand'); - """ - ) - records = cur.fetchall() - col_names = [desc.name for desc in cur.description] - # 找出所有以指定前缀开头的列 - source_outflow_cols = [ - col for col in col_names if col.startswith(column_prefix) - ] - logging.info( - f"Identified source_outflow columns: {source_outflow_cols}" - ) - # 确保 'api_query_id' 列存在 - if "api_query_id" not in col_names: - logging.error( - "'api_query_id' column not found in scada_info table." - ) - return globals.realtime_region_pipe_flow_and_demand_id - # 获取 'api_query_id' 列的索引 - api_query_id_idx = col_names.index("api_query_id") - for record in records: - # 提取所有以 'associated_source_outflow_id' 开头的列的值,排除 None - values = [ - record[col_names.index(col)] - for col in source_outflow_cols - if record[col_names.index(col)] is not None - ] - if values: - # 将值转换为 frozenset 以便与 region_tuple_to_key 进行匹配 - region_frozenset = frozenset(values) - # 检查是否存在匹配的 region - region_key = region_tuple_to_key.get(region_frozenset) - if region_key: - # 获取 'api_query_id' 的值 - api_query_id = record[api_query_id_idx] - if api_query_id is not None: - globals.realtime_region_pipe_flow_and_demand_id[ - region_key - ].append(api_query_id) - logging.info("生成 realtime_region_pipe_flow_and_demand_id 成功。") - except psycopg.Error as e: - logging.error(f"数据库连接或查询出错: {e}") - except Exception as ex: - logging.error(f"处理数据时出错: {ex}") - return globals.realtime_region_pipe_flow_and_demand_id - - -# 2025/01/17 -def query_pipe_flow_region_patterns( - name: str, column_prefix: str = "associated_pipe_flow_id" -) -> dict: - """ - 查询 scada_info 表中 type 为 'demand' 且 transmission_mode 为 'non_realtime' 的记录, - 记录该记录的 associated_pattern。 - 如果该记录的 associated_pipe_flow_id 存在, - 且根据 associated_pipe_flow_id 查询的 associated_element_id 对应的记录的 transmission_mode 为 'realtime', - 则将该记录的 associated_pattern 作为值记录到字典中,字典的 key 为 pipe_flow 类的 associated_pattern。 - 字典样式为:{'region1': ['P17021', 'ZBBGXSZW000377'], 'region2': ['P16504']} - :param name: 数据库名称 - :param column_prefix: 需要提取的列的前缀 - :return: pipe_flow_region_patterns 字典 - """ - conn_string = get_project_pgconn_string(db_name=name) - try: - with psycopg.connect(conn_string) as conn: - with conn.cursor() as cur: - # 查询 type 为 'demand' 且 transmission_mode 为 'non_realtime' 的记录 - cur.execute( - """ - SELECT associated_pattern, associated_pipe_flow_id - FROM scada_info - WHERE type = 'demand' - AND transmission_mode = 'non_realtime'; - """ - ) - records = cur.fetchall() - col_names = [desc.name for desc in cur.description] - # 获取列索引 - pattern_idx = col_names.index("associated_pattern") - pipe_flow_id_idx = col_names.index("associated_pipe_flow_id") - for record in records: - associated_pattern = record[pattern_idx] - associated_pipe_flow_id = record[pipe_flow_id_idx] - if associated_pipe_flow_id: - # 根据 associated_pipe_flow_id 查询对应的记录 - cur.execute( - """ - SELECT associated_pattern, transmission_mode - FROM scada_info - WHERE associated_element_id = %s; - """, - (associated_pipe_flow_id,), - ) - pipe_flow_record = cur.fetchone() - if pipe_flow_record: - pipe_flow_associated_pattern = pipe_flow_record[0] - transmission_mode = pipe_flow_record[1] - if transmission_mode == "realtime": - # 将 associated_pattern 记录到字典中 - if ( - pipe_flow_associated_pattern - not in globals.pipe_flow_region_patterns - ): - globals.pipe_flow_region_patterns[ - pipe_flow_associated_pattern - ] = [] - globals.pipe_flow_region_patterns[ - pipe_flow_associated_pattern - ].append(associated_pattern) - logging.info("生成 pipe_flow_region_patterns 成功。") - except psycopg.Error as e: - logging.error(f"数据库连接或查询出错: {e}") - except Exception as ex: - logging.error(f"处理数据时出错: {ex}") - return globals.pipe_flow_region_patterns - - -# 2025/02/15 -def query_SCADA_ID_corresponding_info(name: str, SCADA_ID: str) -> dict: - """ - 在地图上拾取SCADA元素后,获取SCADA_ID,在pg数据库中查询该SCADA设备对应的associated_element_id和api_query_id - :param name: pg数据库的名称 - :param SCADA_ID: SCADA设备的ID - :return: 包含associated_element_id和api_query_id的字典 - """ - conn_string = get_project_pgconn_string(db_name=name) - try: - # 使用 psycopg.connect 创建连接 - with psycopg.connect(conn_string) as conn: - with conn.cursor() as cur: - # 执行查询 - query = """ - SELECT associated_element_id, API_query_id - FROM scada_info - WHERE id = %s - """ - cur.execute(query, (SCADA_ID,)) # 执行查询并传递参数 - # 获取查询结果 - result = cur.fetchone() - if result: - # 将结果转换为字典 - associated_info = { - "associated_element_id": result[0], - "API_query_id": result[1], - } - return associated_info - else: - # 如果没有找到记录 - return None - except Exception as e: - print(f"An error occurred: {e}") - return None - - -# 2025/01/11 -def get_source_outflow_region_id( - name: str, - source_outflow_region: dict, - column_prefix: str = "associated_source_outflow_id", -) -> dict: - """ - 基于 source_outflow_region,将其中的 associated_source_outflow_id 替换为对应的 api_query_id, - 生成新的字典 source_outflow_region_id。 - - :param name: 数据库名称 - :param source_outflow_region: 包含区域与对应 associated_source_outflow_id 的字典 - :param column_prefix: 需要提取的列的前缀 - :return: 包含区域与对应 api_query_id 的字典 - """ - globals.source_outflow_region_id = { - region: [] for region in globals.source_outflow_region.keys() - } - # 提取所有唯一的 associated_source_outflow_id - all_ids = set() - for ids in globals.source_outflow_region.values(): - all_ids.update(ids) - if not all_ids: - logging.warning( - "No associated_source_outflow_id found in source_outflow_region." + for mapping in target_maps.values(): + mapping.clear() + with project_connection(name) as conn, conn.cursor() as cur: + cur.execute( + """ + SELECT device_type, COALESCE(node_id, link_id) AS element_id, + api_query_id + FROM asset.scada_devices + WHERE transmission_mode = 'realtime' + AND api_query_id IS NOT NULL + """ ) - return globals.source_outflow_region_id - conn_string = get_project_pgconn_string(db_name=name) - try: - with psycopg.connect(conn_string) as conn: - with conn.cursor() as cur: - # 查询 associated_element_id 和 api_query_id - query = f""" - SELECT associated_element_id, api_query_id - FROM scada_info - WHERE associated_element_id = ANY(%s) - """ - cur.execute(query, (list(all_ids),)) - rows = cur.fetchall() - # 构建 associated_source_outflow_id 到 api_query_id 的映射 - id_to_api_query_id = {} - for row in rows: - associated_id = row[0] - api_query_id = row[1] - if associated_id in all_ids and api_query_id is not None: - id_to_api_query_id[associated_id] = str(api_query_id) - # 替换 source_outflow_region 中的 associated_source_outflow_id 为 api_query_id - for region, ids in globals.source_outflow_region.items(): - for id_ in ids: - api_id = id_to_api_query_id.get(id_) - if api_id: - globals.source_outflow_region_id[region].append(api_id) - else: - logging.warning( - f"No api_query_id found for associated_source_outflow_id: {id_}" - ) - except psycopg.Error as e: - logging.error(f"数据库连接或查询出错: {e}") - except Exception as ex: - logging.error(f"处理数据时出错: {ex}") - return globals.source_outflow_region_id - - -# 2025/01/18 -def get_realtime_region_patterns( - name: str, - source_outflow_region_id: dict, - realtime_region_pipe_flow_and_demand_id: dict, -) -> Tuple[dict, dict]: - """ - 根据每个 region,从 scada_info 表中查询 api_query_id 对应的 associated_pattern。 - 将结果分别存储到 source_outflow_region_patterns 和 realtime_region_pipe_flow_and_demand_patterns 两个字典中。 - :param name: 数据库名称 - :param source_outflow_region_id: 包含 region 与对应 api_query_id 的字典 - :param realtime_region_pipe_flow_and_demand_id: 包含 region 与对应 api_query_id 的字典 - :return: source_outflow_region_patterns 和 realtime_region_pipe_flow_and_demand_patterns 两个字典 - """ - # 初始化返回的字典 - globals.source_outflow_region_patterns = { - region: [] for region in globals.source_outflow_region_id.keys() - } - globals.realtime_region_pipe_flow_and_demand_patterns = { - region: [] for region in globals.realtime_region_pipe_flow_and_demand_id.keys() - } - conn_string = get_project_pgconn_string(db_name=name) - try: - with psycopg.connect(conn_string) as conn: - with conn.cursor() as cur: - # 遍历每个 region - for region in globals.source_outflow_region_id.keys(): - # 获取 source_outflow_region_id 的 api_query_id 并查询 associated_pattern - source_outflow_api_ids = globals.source_outflow_region_id[region] - if source_outflow_api_ids: - api_query_ids_str = ", ".join( - [f"'{api_id}'" for api_id in source_outflow_api_ids] - ) - cur.execute( - f""" - SELECT api_query_id, associated_pattern - FROM scada_info - WHERE api_query_id IN ({api_query_ids_str}); - """ - ) - results = cur.fetchall() - globals.source_outflow_region_patterns[region] = [ - associated_pattern - for _, associated_pattern in results - if associated_pattern - ] - # 获取 realtime_region_pipe_flow_and_demand_id 的 api_query_id 并查询 associated_pattern - realtime_api_ids = globals.realtime_region_pipe_flow_and_demand_id[ - region - ] - if realtime_api_ids: - api_query_ids_str = ", ".join( - [f"'{api_id}'" for api_id in realtime_api_ids] - ) - cur.execute( - f""" - SELECT api_query_id, associated_pattern - FROM scada_info - WHERE api_query_id IN ({api_query_ids_str}); - """ - ) - results = cur.fetchall() - globals.realtime_region_pipe_flow_and_demand_patterns[ - region - ] = [ - associated_pattern - for _, associated_pattern in results - if associated_pattern - ] - logging.info( - "生成 source_outflow_region_patterns 和 realtime_region_pipe_flow_and_demand_patterns 成功。" - ) - except psycopg.Error as e: - logging.error(f"数据库连接或查询出错: {e}") - except Exception as ex: - logging.error(f"处理数据时出错: {ex}") - return ( - globals.source_outflow_region_patterns, - globals.realtime_region_pipe_flow_and_demand_patterns, - ) + for record in cur.fetchall(): + device_type = record["device_type"] + element_id = record["element_id"] + api_query_id = record["api_query_id"] + mapping = target_maps.get(str(device_type).lower()) + if mapping is not None: + mapping[str(element_id)] = str(api_query_id) def get_pattern_index(cur_datetime: str) -> int: @@ -671,20 +138,6 @@ def convert_time_format(original_time: str) -> str: return normalized_time.replace(microsecond=0).strftime("%Y-%m-%d %H:%M:%S") -def get_history_pattern_info(project_name, pattern_name): - """读取选定pattern的保存的历史pattern信息flow和factor""" - flow_list = [] - factor_list = [] - patterns_info = read_all( - project_name, - f"select * from history_patterns_flows where id = '{pattern_name}' order by _order", - ) - for item in patterns_info: - flow_list.append(float(item["flow"])) - factor_list.append(float(item["factor"])) - return flow_list, factor_list - - def _apply_valve_control( project_name: str, valve_control: dict[str, dict] ) -> None: @@ -722,8 +175,11 @@ def run_simulation( modify_valve_opening: dict[str, float] = None, scheme_type: str = None, scheme_name: str = None, + result_db_name: str = None, valve_control: dict[str, dict] = None, -) -> None: + scheme_username: str = "system", + scheme_detail: dict | None = None, +) -> UUID | None: """ 传入需要修改的参数,改变数据库中对应位置的值,然后计算,返回结果 :param name: 模型名称,数据库中对应的名字 @@ -823,6 +279,7 @@ def run_simulation( reservoir_SCADA_data_dict = TimescaleInternalQueries.query_scada_by_ids_time( device_ids=list(globals.reservoirs_id.values()), query_time=modify_pattern_start_time, + db_name=name, ) # 2.构建出新字典,形式如{'ZBBDJSCP000002': '3.1231', 'R00003': '2.7387'} reservoir_dict = { @@ -847,6 +304,7 @@ def run_simulation( tank_SCADA_data_dict = TimescaleInternalQueries.query_scada_by_ids_time( device_ids=list(globals.tanks_id.values()), query_time=modify_pattern_start_time, + db_name=name, ) tank_dict = { key: tank_SCADA_data_dict[value] for key, value in globals.tanks_id.items() @@ -863,6 +321,7 @@ def run_simulation( fixed_pump_SCADA_data_dict = TimescaleInternalQueries.query_scada_by_ids_time( device_ids=list(globals.fixed_pumps_id.values()), query_time=modify_pattern_start_time, + db_name=name, ) # print(fixed_pump_SCADA_data_dict) fixed_pump_dict = { @@ -887,6 +346,7 @@ def run_simulation( TimescaleInternalQueries.query_scada_by_ids_time( device_ids=list(globals.variable_pumps_id.values()), query_time=modify_pattern_start_time, + db_name=name, ) ) variable_pump_dict = { @@ -907,6 +367,7 @@ def run_simulation( demand_SCADA_data_dict = TimescaleInternalQueries.query_scada_by_ids_time( device_ids=list(globals.demand_id.values()), query_time=modify_pattern_start_time, + db_name=name, ) demand_dict = { key: demand_SCADA_data_dict[value] @@ -928,219 +389,7 @@ def run_simulation( set_pattern(name_c, cs) # 水质、压力实时数据使用方法待补充 ############################# - if globals.source_outflow_pattern_id: - # 基于实时的出厂流量计数据,修改出厂流量计绑定的pattern - source_outflow_SCADA_data_dict = ( - TimescaleInternalQueries.query_scada_by_ids_time( - device_ids=list(globals.source_outflow_pattern_id.values()), - query_time=modify_pattern_start_time, - ) - ) - # print(source_outflow_SCADA_data_dict) - source_outflow_dict = { - key: source_outflow_SCADA_data_dict[value] - for key, value in globals.source_outflow_pattern_id.items() - } - # print(source_outflow_dict) - for pattern_name in source_outflow_dict.keys(): - # print(pattern_name) - history_source_outflow_flow_list, history_source_outflow_factor_list = ( - get_history_pattern_info(name_c, pattern_name) - ) - history_source_outflow_flow = history_source_outflow_flow_list[modify_index] - history_source_outflow_factor = history_source_outflow_factor_list[ - modify_index - ] - # print(source_outflow_dict[pattern_name]) - # print(history_source_outflow_flow) - # print(history_source_outflow_factor) - if source_outflow_dict[pattern_name]: - realtime_source_outflow = float(source_outflow_dict[pattern_name]) - multiply_factor = realtime_source_outflow / history_source_outflow_flow - # print(multiply_factor) - pattern = get_pattern(name_c, pattern_name) - pattern["factors"][modify_index] = ( - multiply_factor * history_source_outflow_factor - ) - # print(pattern['factors'][modify_index]) - cs = ChangeSet() - cs.append(pattern) - set_pattern(name_c, cs) - if globals.realtime_pipe_flow_pattern_id: - # 基于实时的pipe_flow类数据,修改pipe_flow类绑定的pattern - realtime_pipe_flow_SCADA_data_dict = ( - TimescaleInternalQueries.query_scada_by_ids_time( - device_ids=list(globals.realtime_pipe_flow_pattern_id.values()), - query_time=modify_pattern_start_time, - ) - ) - realtime_pipe_flow_dict = { - key: realtime_pipe_flow_SCADA_data_dict[value] - for key, value in globals.realtime_pipe_flow_pattern_id.items() - } - for pattern_name in realtime_pipe_flow_dict.keys(): - history_pipe_flow_flow_list, history_pipe_flow_factor_list = ( - get_history_pattern_info(name_c, pattern_name) - ) - history_pipe_flow_flow = history_pipe_flow_flow_list[modify_index] - history_pipe_flow_factor = history_pipe_flow_factor_list[modify_index] - if realtime_pipe_flow_dict[pattern_name]: - realtime_pipe_flow = float(realtime_pipe_flow_dict[pattern_name]) - multiply_factor = realtime_pipe_flow / history_pipe_flow_flow - pattern = get_pattern(name_c, pattern_name) - pattern["factors"][modify_index] = ( - multiply_factor * history_pipe_flow_factor - ) - cs = ChangeSet() - cs.append(pattern) - set_pattern(name_c, cs) - if globals.pipe_flow_region_patterns: - # 基于实时的pipe_flow类数据,修改pipe_flow分区流量计范围内的non_realtime的demand绑定的pattern - temp_realtime_pipe_flow_pattern_id = {} - # 遍历 pipe_flow_region_patterns 字典的 key - for ( - pipe_flow_region, - demand_patterns, - ) in globals.pipe_flow_region_patterns.items(): - # 获取对应的实时值 - query_api_id = globals.realtime_pipe_flow_pattern_id.get(pipe_flow_region) - temp_realtime_pipe_flow_pattern_id[pipe_flow_region] = query_api_id - temp_realtime_pipe_flow_SCADA_data_dict = ( - TimescaleInternalQueries.query_scada_by_ids_time( - device_ids=list(temp_realtime_pipe_flow_pattern_id.values()), - query_time=modify_pattern_start_time, - ) - ) - temp_realtime_pipe_flow_dict = { - key: temp_realtime_pipe_flow_SCADA_data_dict[value] - for key, value in temp_realtime_pipe_flow_pattern_id.items() - } - for pattern_name in temp_realtime_pipe_flow_dict.keys(): - temp_history_pipe_flow_flow_list, temp_history_pipe_flow_factor_list = ( - get_history_pattern_info(name_c, pattern_name) - ) - temp_history_pipe_flow_flow = temp_history_pipe_flow_flow_list[modify_index] - if temp_realtime_pipe_flow_dict[pattern_name]: - temp_realtime_pipe_flow = float( - temp_realtime_pipe_flow_dict[pattern_name] - ) - temp_multiply_factor = ( - temp_realtime_pipe_flow / temp_history_pipe_flow_flow - ) - temp_non_realtime_demand_pattern_list = ( - globals.pipe_flow_region_patterns[pattern_name] - ) - for demand_pattern_name in temp_non_realtime_demand_pattern_list: - ( - history_non_realtime_demand_flow_list, - history_non_realtime_demand_factor_list, - ) = get_history_pattern_info(name_c, demand_pattern_name) - history_non_realtime_demand_factor = ( - history_non_realtime_demand_factor_list[modify_index] - ) - pattern = get_pattern(name_c, demand_pattern_name) - pattern["factors"][modify_index] = ( - temp_multiply_factor * history_non_realtime_demand_factor - ) - cs = ChangeSet() - cs.append(pattern) - set_pattern(name_c, cs) - if globals.source_outflow_region: - # 根据associated_source_outflow_id进行分区,各分区用(出厂的流量计 - 实时的pipe_flow和demand)进行数据更新 - for region in globals.source_outflow_region.keys(): - temp_source_outflow_region_id = globals.source_outflow_region_id.get( - region, [] - ) - temp_realtime_region_pipe_flow_and_demand_id = ( - globals.realtime_region_pipe_flow_and_demand_id.get(region, []) - ) - temp_source_outflow_region_patterns = ( - globals.source_outflow_region_patterns.get(region, []) - ) - temp_realtime_region_pipe_flow_and_demand_patterns = ( - globals.realtime_region_pipe_flow_and_demand_patterns.get(region, []) - ) - temp_non_realtime_region_patterns = ( - globals.non_realtime_region_patterns.get(region, []) - ) - region_source_outflow_data_dict = ( - TimescaleInternalQueries.query_scada_by_ids_time( - device_ids=temp_source_outflow_region_id, - query_time=modify_pattern_start_time, - ) - ) - region_realtime_region_pipe_flow_and_demand_data_dict = ( - TimescaleInternalQueries.query_scada_by_ids_time( - device_ids=temp_realtime_region_pipe_flow_and_demand_id, - query_time=modify_pattern_start_time, - ) - ) - # 2025/02/12 确保 region_source_outflow_data_dict 和 - # region_realtime_region_pipe_flow_and_demand_data_dict中的每个值都不是 None 且不为 0 - region_source_outflow_valid_values = [ - float(value) - for value in region_source_outflow_data_dict.values() - if value not in [None, 0] - ] - valid_values = [ - float(value) - for value in region_realtime_region_pipe_flow_and_demand_data_dict.values() - if value not in [None, 0] - ] - # 如果都非空,则执行 sum 操作 - if region_source_outflow_valid_values and valid_values: - region_total_source_outflow = sum(region_source_outflow_valid_values) - history_region_total_source_outflow = 0 - for source_outflow_pattern_name in temp_source_outflow_region_patterns: - ( - temp_history_source_outflow_flow_list, - temp_history_source_outflow_factor_list, - ) = get_history_pattern_info(name_c, source_outflow_pattern_name) - history_region_total_source_outflow += ( - temp_history_source_outflow_flow_list[modify_index] - ) - region_total_realtime_region_pipe_flow_and_demand = sum(valid_values) - history_region_total_realtime_region_pipe_flow_and_demand = 0 - for ( - pipe_flow_and_demand_pattern_name - ) in temp_realtime_region_pipe_flow_and_demand_patterns: - ( - temp_history_pipe_flow_and_demand_flow_list, - temp_history_pipe_flow_and_demand_factor_list, - ) = get_history_pattern_info( - name_c, pipe_flow_and_demand_pattern_name - ) - history_region_total_realtime_region_pipe_flow_and_demand += ( - temp_history_pipe_flow_and_demand_flow_list[modify_index] - ) - temp_multiply_factor = ( - region_total_source_outflow - - region_total_realtime_region_pipe_flow_and_demand - ) / ( - history_region_total_source_outflow - - history_region_total_realtime_region_pipe_flow_and_demand - ) - for ( - non_realtime_region_pattern_name - ) in temp_non_realtime_region_patterns: - ( - history_non_realtime_region_pattern_flow_list, - history_non_realtime_region_pattern_factor_list, - ) = get_history_pattern_info( - name_c, non_realtime_region_pattern_name - ) - history_non_realtime_region_pattern_factor = ( - history_non_realtime_region_pattern_factor_list[modify_index] - ) - pattern = get_pattern(name_c, non_realtime_region_pattern_name) - pattern["factors"][modify_index] = ( - temp_multiply_factor - * history_non_realtime_region_pattern_factor - ) - cs = ChangeSet() - cs.append(pattern) - set_pattern(name_c, cs) - # 根据输入的参数进行数据修改,后面修改的可以覆盖前面的,用于EXTENDED类的方案模拟 + # 显式请求参数覆盖实时设备数据,用于扩展模拟。 # 修改清水池(reservoir)液位的pattern if modify_reservoir_head_pattern: for reservoir_name in modify_reservoir_head_pattern.keys(): @@ -1277,10 +526,7 @@ def run_simulation( # 存储 starttime = time.time() # 临时处理输入的name问题,后续需要优化,需要传递/获取iot数据库的名字 - if name.find("_") != -1: - db_name = name.split("_")[2] - else: - db_name = name + db_name = result_db_name or name if simulation_type.upper() == "REALTIME": TimescaleInternalStorage.store_realtime_simulation( node_result, link_result, modify_pattern_start_time, db_name=db_name @@ -1289,15 +535,45 @@ def run_simulation( result_timestep_seconds = times_info.get("report_step") if result_timestep_seconds is None: raise RuntimeError("run_project output missing times.report_step") - TimescaleInternalStorage.store_scheme_simulation( - scheme_type, - scheme_name, - node_result, - link_result, - modify_pattern_start_time, - num_periods_result, - result_timestep_seconds, - db_name=db_name, + if not scheme_type or not scheme_name: + raise ValueError("extended simulation requires analysis run type and name") + detail = scheme_detail or {} + run_id = create_analysis_run( + name=db_name, + scheme_name=scheme_name, + scheme_type=scheme_type, + username=scheme_username, + scheme_start_time=modify_pattern_start_time, + scheme_detail=detail, + ) + try: + TimescaleInternalStorage.store_analysis_simulation( + run_id, + node_result, + link_result, + modify_pattern_start_time, + num_periods_result, + result_timestep_seconds, + db_name=db_name, + ) + except Exception: + try: + update_analysis_run( + db_name, + run_id, + status="failed", + username=scheme_username, + scheme_detail=detail, + ) + except Exception: + logging.exception("failed to mark analysis run %s as failed", run_id) + raise + update_analysis_run( + db_name, + run_id, + status="completed", + username=scheme_username, + scheme_detail=detail, ) endtime = time.time() logging.info("store time: %f", endtime - starttime) @@ -1305,82 +581,4 @@ def run_simulation( # TimescaleInternalQueries.fill_scheme_simulation_result_to_SCADA(scheme_type=scheme_type, scheme_name=scheme_name) print("after store result") - - -if __name__ == "__main__": - # 计算前,获取scada_info中的信息,按照设定的方法修改pg数据库 - query_corresponding_element_id_and_query_id(project_info.name) - query_corresponding_pattern_id_and_query_id(project_info.name) - region_result = query_non_realtime_region(project_info.name) - - globals.source_outflow_region_id = get_source_outflow_region_id( - project_info.name, region_result - ) - globals.realtime_region_pipe_flow_and_demand_id = ( - query_realtime_region_pipe_flow_and_demand_id(project_info.name, region_result) - ) - globals.pipe_flow_region_patterns = query_pipe_flow_region_patterns( - project_info.name - ) - - globals.non_realtime_region_patterns = query_non_realtime_region_patterns( - project_info.name, region_result - ) - ( - globals.source_outflow_region_patterns, - globals.realtime_region_pipe_flow_and_demand_patterns, - ) = get_realtime_region_patterns( - project_info.name, - globals.source_outflow_region_id, - globals.realtime_region_pipe_flow_and_demand_id, - ) - - # 基础日期和时间(日期部分保持不变) - base_date = datetime(2025, 5, 4) - - # 循环生成96个时间点(15分钟间隔) - for i in range(96): - # 计算当前时间偏移 - time_offset = timedelta(minutes=15 * i) - - # 生成完整时间对象 - current_time = base_date + time_offset - - # 格式化成ISO8601带时区格式 - iso_time = current_time.strftime("%Y-%m-%dT%H:%M:%S") + "+08:00" - - # 执行函数调用 - run_simulation( - name=project_info.name, - simulation_type="realtime", - modify_pattern_start_time=iso_time, - ) - - # 打印字典内容以验证 - # print("Reservoirs ID:", globals.reservoirs_id) - # print("Tanks ID:", globals.tanks_id) - # print("Fixed Pumps ID:", globals.fixed_pumps_id) - # print("Variable Pumps ID:", globals.variable_pumps_id) - # print("Pressure ID:", globals.pressure_id) - # print("Demand ID:", globals.demand_id) - # print("Quality ID:", globals.quality_id) - # print("Source Outflow Pattern ID:", globals.source_outflow_pattern_id) - # print("Realtime Pipe Flow Pattern ID:", globals.realtime_pipe_flow_pattern_id) - # print("Pipe Flow Region Patterns:", globals.pipe_flow_region_patterns) - # print("Source Outflow Region:", region_result) - # print('Source Outflow Region ID:', globals.source_outflow_region_id) - # print('Source Outflow Region Patterns:', globals.source_outflow_region_patterns) - # print("Non Realtime Region Patterns:", globals.non_realtime_region_patterns) - # print("Realtime Region Pipe Flow And Demand ID:", globals.realtime_region_pipe_flow_and_demand_id) - # print("Realtime Region Pipe Flow And Demand Patterns:", globals.realtime_region_pipe_flow_and_demand_patterns) - - # dump_inp(name='bb', inp="sensor_placement.inp", version='2') - # 模拟示例1 - # run_simulation(name='bb', simulation_type="realtime", modify_pattern_start_time='2025-02-25T23:45:00+08:00') - # 模拟示例2 - # run_simulation(name='bb', simulation_type="extended", modify_pattern_start_time='2025-03-10T12:00:00+08:00', - # modify_total_duration=1800, scheme_type="burst_Analysis", scheme_name="scheme1") - - # 查询示例1:query_SCADA_ID_corresponding_info - # result = query_SCADA_ID_corresponding_info(name='bb', SCADA_ID='P10755') - # print(result) + return run_id if simulation_type.upper() == "EXTENDED" else None diff --git a/app/services/simulation_ops.py b/app/services/simulation_ops.py index 08b8020..8621ac7 100644 --- a/app/services/simulation_ops.py +++ b/app/services/simulation_ops.py @@ -44,8 +44,6 @@ def project_management( datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S") + " -- Start Copying Database." ) - # CopyProjectEx()(prj_name, new_name, - # ['operation', 'current_operation', 'restore_operation', 'batch_operation', 'operation_table']) copy_project(prj_name + "_template", new_name) print( datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S") @@ -96,8 +94,6 @@ def scheduling_simulation( datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S") + " -- Start Copying Database." ) - # CopyProjectEx()(prj_name, new_name, - # ['operation', 'current_operation', 'restore_operation', 'batch_operation', 'operation_table']) copy_project(prj_name + "_template", new_name) print( datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S") @@ -188,8 +184,6 @@ def daily_scheduling_simulation( datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S") + " -- Start Copying Database." ) - # CopyProjectEx()(prj_name, new_name, - # ['operation', 'current_operation', 'restore_operation', 'batch_operation', 'operation_table']) copy_project(prj_name + "_template", new_name) print( datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S") diff --git a/app/services/tjnetwork.py b/app/services/tjnetwork.py index 2400e38..272b68c 100644 --- a/app/services/tjnetwork.py +++ b/app/services/tjnetwork.py @@ -1,1359 +1,320 @@ +"""Application-facing water-network service boundary. + +The native WNDB package is organized by responsibility. This module only +combines operations that need more than one native module and re-exports the +small set of operations used by HTTP and algorithm services. +""" + from typing import Any -import app.native.wndb as api -from app.native.wndb.s36_wda_cal import * + import app.infra.epanet as epanet +from app.algorithms.water_demand import ( + calculate_demand_to_network, + calculate_demand_to_nodes, + calculate_demand_to_region, +) +from app.infra.db.postgresql.scada_assets import ( + get_all_scada_info, + get_scada_info, + get_scada_info_schema, +) +from app.native.wndb.commands.api import ( + delete_curve_cascade as delete_curve, + delete_junction_cascade as delete_junction, + delete_pattern_cascade as delete_pattern, + delete_pipe_cascade as delete_pipe, + delete_pump_cascade as delete_pump, + delete_reservoir_cascade as delete_reservoir, + delete_tank_cascade as delete_tank, + delete_valve_cascade as delete_valve, + set_option_ex as set_option, + set_option_v3_ex as set_option_v3, +) +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, + get_backdrop_schema, + set_backdrop, +) +from app.native.wndb.gis.coordinates import get_node_coord +from app.native.wndb.gis.labels import ( + add_label, + delete_label, + get_label, + get_label_schema, + set_label, +) +from app.native.wndb.gis.region_geometry import get_nodes_in_region +from app.native.wndb.gis.regions import ( + add_region, + delete_region, + get_region, + get_region_schema, + set_region, +) +from app.native.wndb.gis.vertices import ( + add_vertex, + delete_vertex, + get_all_vertex_links, + get_all_vertices, + get_vertex, + get_vertex_schema, + set_vertex, +) +from app.native.wndb.inp.exporter import dump_inp, export_inp +from app.native.wndb.inp.importer import convert_inp_v3_to_v2, read_inp +from app.native.wndb.model.controls import ( + get_control, + get_control_schema, + set_control, +) +from app.native.wndb.model.curves import ( + add_curve, + get_curve, + get_curve_schema, + set_curve, +) +from app.native.wndb.model.demands import get_demand, get_demand_schema, set_demand +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, + is_junction, + is_link, + is_node, + is_pattern, + is_pipe, + is_pump, + is_reservoir, + is_tank, + is_valve, +) +from app.native.wndb.model.emitters import ( + get_emitter, + get_emitter_schema, + set_emitter, +) +from app.native.wndb.model.energy import ( + get_energy, + get_energy_schema, + get_pump_energy, + get_pump_energy_schema, + set_energy, + set_pump_energy, +) +from app.native.wndb.model.junctions import ( + add_junction, + get_all_junctions, + get_junction, + get_junction_schema, + set_junction, +) +from app.native.wndb.model.mixing import ( + add_mixing, + delete_mixing, + get_mixing, + get_mixing_schema, + set_mixing, +) +from app.native.wndb.model.options import ( + OPTION_DEMAND_MODEL_PDA, + OPTION_QUALITY_CHEMICAL, + get_option, + get_option_v3, + get_option_v3_schema, +) +from app.native.wndb.model.patterns import ( + add_pattern, + get_pattern, + get_pattern_schema, + set_pattern, +) +from app.native.wndb.model.pipes import ( + PIPE_STATUS_OPEN, + add_pipe, + get_all_pipes, + get_pipe, + get_pipe_schema, + set_pipe, +) +from app.native.wndb.model.pumps import ( + add_pump, + get_all_pumps, + get_pump, + get_pump_schema, + set_pump, +) +from app.native.wndb.model.quality import ( + get_quality, + get_quality_schema, + set_quality, +) +from app.native.wndb.model.reactions import ( + get_pipe_reaction, + get_pipe_reaction_schema, + get_reaction, + get_reaction_schema, + get_tank_reaction, + get_tank_reaction_schema, + set_pipe_reaction, + set_reaction, + set_tank_reaction, +) +from app.native.wndb.model.reservoirs import ( + add_reservoir, + get_all_reservoirs, + get_reservoir, + get_reservoir_schema, + set_reservoir, +) +from app.native.wndb.model.rules import get_rule, get_rule_schema, set_rule +from app.native.wndb.model.sources import ( + SOURCE_TYPE_SETPOINT, + add_source, + delete_source, + get_source, + get_source_schema, + set_source, +) +from app.native.wndb.model.status import ( + get_status, + get_status_schema, + set_status, +) +from app.native.wndb.model.tags import get_tag, get_tag_schema, get_tags, set_tag +from app.native.wndb.model.tanks import ( + add_tank, + get_all_tanks, + get_tank, + get_tank_schema, + set_tank, +) +from app.native.wndb.model.times import get_time, get_time_schema, set_time +from app.native.wndb.model.title import get_title, get_title_schema, set_title +from app.native.wndb.model.valves import ( + VALVES_TYPE_PRV, + add_valve, + get_all_valves, + get_valve, + get_valve_schema, + set_valve, +) -############################################################ -# ChangeSet -############################################################ - -API_ADD = api.API_ADD -API_UPDATE = api.API_UPDATE -API_DELETE = api.API_DELETE - -ChangeSet = api.ChangeSet - - -############################################################ -# enum -############################################################ - -JUNCTION = api.JUNCTION -RESERVOIR = api.RESERVOIR -TANK = api.TANK -PIPE = api.PIPE -PUMP = api.PUMP -VALVE = api.VALVE -PATTERN = api.PATTERN -CURVE = api.CURVE - -OVERFLOW_YES = api.OVERFLOW_YES -OVERFLOW_NO = api.OVERFLOW_NO - -PIPE_STATUS_OPEN = api.PIPE_STATUS_OPEN -PIPE_STATUS_CLOSED = api.PIPE_STATUS_CLOSED -PIPE_STATUS_CV = api.PIPE_STATUS_CV - -VALVES_TYPE_PRV = api.VALVES_TYPE_PRV -VALVES_TYPE_PSV = api.VALVES_TYPE_PSV -VALVES_TYPE_PBV = api.VALVES_TYPE_PBV -VALVES_TYPE_FCV = api.VALVES_TYPE_FCV -VALVES_TYPE_TCV = api.VALVES_TYPE_TCV -VALVES_TYPE_GPV = api.VALVES_TYPE_GPV - -TAG_TYPE_NODE = api.TAG_TYPE_NODE -TAG_TYPE_LINK = api.TAG_TYPE_LINK - -LINK_STATUS_OPEN = api.LINK_STATUS_OPEN -LINK_STATUS_CLOSED = api.LINK_STATUS_CLOSED -LINK_STATUS_ACTIVE = api.LINK_STATUS_ACTIVE - -CURVE_TYPE_PUMP = api.CURVE_TYPE_PUMP -CURVE_TYPE_EFFICIENCY = api.CURVE_TYPE_EFFICIENCY -CURVE_TYPE_VOLUME = api.CURVE_TYPE_VOLUME -CURVE_TYPE_HEADLOSS = api.CURVE_TYPE_HEADLOSS - -SOURCE_TYPE_CONCEN = api.SOURCE_TYPE_CONCEN -SOURCE_TYPE_MASS = api.SOURCE_TYPE_MASS -SOURCE_TYPE_FLOWPACED = api.SOURCE_TYPE_FLOWPACED -SOURCE_TYPE_SETPOINT = api.SOURCE_TYPE_SETPOINT - -MIXING_MODEL_MIXED = api.MIXING_MODEL_MIXED -MIXING_MODEL_2COMP = api.MIXING_MODEL_2COMP -MIXING_MODEL_FIFO = api.MIXING_MODEL_FIFO -MIXING_MODEL_LIFO = api.MIXING_MODEL_LIFO - -TIME_STATISTIC_NONE = api.TIME_STATISTIC_NONE -TIME_STATISTIC_AVERAGED = api.TIME_STATISTIC_AVERAGED -TIME_STATISTIC_MINIMUM = api.TIME_STATISTIC_MINIMUM -TIME_STATISTIC_MAXIMUM = api.TIME_STATISTIC_MAXIMUM -TIME_STATISTIC_RANGE = api.TIME_STATISTIC_RANGE - -OPTION_UNITS_CFS = api.OPTION_UNITS_CFS -OPTION_UNITS_GPM = api.OPTION_UNITS_GPM -OPTION_UNITS_MGD = api.OPTION_UNITS_MGD -OPTION_UNITS_IMGD = api.OPTION_UNITS_IMGD -OPTION_UNITS_AFD = api.OPTION_UNITS_AFD -OPTION_UNITS_LPS = api.OPTION_UNITS_LPS -OPTION_UNITS_LPM = api.OPTION_UNITS_LPM -OPTION_UNITS_MLD = api.OPTION_UNITS_MLD -OPTION_UNITS_CMH = api.OPTION_UNITS_CMH -OPTION_UNITS_CMD = api.OPTION_UNITS_CMD - -OPTION_PRESSURE_PSI = api.OPTION_PRESSURE_PSI -OPTION_PRESSURE_KPA = api.OPTION_PRESSURE_KPA -OPTION_PRESSURE_METERS = api.OPTION_PRESSURE_METERS - -OPTION_HEADLOSS_HW = api.OPTION_HEADLOSS_HW -OPTION_HEADLOSS_DW = api.OPTION_HEADLOSS_DW -OPTION_HEADLOSS_CM = api.OPTION_HEADLOSS_CM - -OPTION_UNBALANCED_STOP = api.OPTION_UNBALANCED_STOP -OPTION_UNBALANCED_CONTINUE = api.OPTION_UNBALANCED_CONTINUE - -OPTION_DEMAND_MODEL_DDA = api.OPTION_DEMAND_MODEL_DDA -OPTION_DEMAND_MODEL_PDA = api.OPTION_DEMAND_MODEL_PDA - -OPTION_QUALITY_NONE = api.OPTION_QUALITY_NONE -OPTION_QUALITY_CHEMICAL = api.OPTION_QUALITY_CHEMICAL -OPTION_QUALITY_AGE = api.OPTION_QUALITY_AGE -OPTION_QUALITY_TRACE = api.OPTION_QUALITY_TRACE - -OPTION_V3_FLOW_UNITS_CFS = api.OPTION_V3_FLOW_UNITS_CFS -OPTION_V3_FLOW_UNITS_GPM = api.OPTION_V3_FLOW_UNITS_GPM -OPTION_V3_FLOW_UNITS_MGD = api.OPTION_V3_FLOW_UNITS_MGD -OPTION_V3_FLOW_UNITS_IMGD = api.OPTION_V3_FLOW_UNITS_IMGD -OPTION_V3_FLOW_UNITS_AFD = api.OPTION_V3_FLOW_UNITS_AFD -OPTION_V3_FLOW_UNITS_LPS = api.OPTION_V3_FLOW_UNITS_LPS -OPTION_V3_FLOW_UNITS_LPM = api.OPTION_V3_FLOW_UNITS_LPM -OPTION_V3_FLOW_UNITS_MLD = api.OPTION_V3_FLOW_UNITS_MLD -OPTION_V3_FLOW_UNITS_CMH = api.OPTION_V3_FLOW_UNITS_CMH -OPTION_V3_FLOW_UNITS_CMD = api.OPTION_V3_FLOW_UNITS_CMD - -OPTION_V3_PRESSURE_UNITS_PSI = api.OPTION_V3_PRESSURE_UNITS_PSI -OPTION_V3_PRESSURE_UNITS_KPA = api.OPTION_V3_PRESSURE_UNITS_KPA -OPTION_V3_PRESSURE_UNITS_METERS = api.OPTION_V3_PRESSURE_UNITS_METERS - -OPTION_V3_HEADLOSS_MODEL_HW = api.OPTION_V3_HEADLOSS_MODEL_HW -OPTION_V3_HEADLOSS_MODEL_DW = api.OPTION_V3_HEADLOSS_MODEL_DW -OPTION_V3_HEADLOSS_MODEL_CM = api.OPTION_V3_HEADLOSS_MODEL_CM - -OPTION_V3_STEP_SIZING_FULL = api.OPTION_V3_STEP_SIZING_FULL -OPTION_V3_STEP_SIZING_RELAXATION = api.OPTION_V3_STEP_SIZING_RELAXATION -OPTION_V3_STEP_SIZING_LINESEARCH = api.OPTION_V3_STEP_SIZING_LINESEARCH - -OPTION_V3_IF_UNBALANCED_STOP = api.OPTION_V3_IF_UNBALANCED_STOP -OPTION_V3_IF_UNBALANCED_CONTINUE = api.OPTION_V3_IF_UNBALANCED_CONTINUE - -OPTION_V3_DEMAND_MODEL_FIXED = api.OPTION_V3_DEMAND_MODEL_FIXED -OPTION_V3_DEMAND_MODEL_CONSTRAINED = api.OPTION_V3_DEMAND_MODEL_CONSTRAINED -OPTION_V3_DEMAND_MODEL_POWER = api.OPTION_V3_DEMAND_MODEL_POWER -OPTION_V3_DEMAND_MODEL_LOGISTIC = api.OPTION_V3_DEMAND_MODEL_LOGISTIC - -OPTION_V3_LEAKAGE_MODEL_NONE = api.OPTION_V3_LEAKAGE_MODEL_NONE -OPTION_V3_LEAKAGE_MODEL_POWER = api.OPTION_V3_LEAKAGE_MODEL_POWER -OPTION_V3_LEAKAGE_MODEL_FAVAD = api.OPTION_V3_LEAKAGE_MODEL_FAVAD - -OPTION_V3_QUALITY_MODEL_NONE = api.OPTION_V3_QUALITY_MODEL_NONE -OPTION_V3_QUALITY_MODEL_CHEMICAL = api.OPTION_V3_QUALITY_MODEL_CHEMICAL -OPTION_V3_QUALITY_MODEL_AGE = api.OPTION_V3_QUALITY_MODEL_AGE -OPTION_V3_QUALITY_MODEL_TRACE = api.OPTION_V3_QUALITY_MODEL_TRACE - -OPTION_V3_QUALITY_UNITS_HRS = api.OPTION_V3_QUALITY_UNITS_HRS -OPTION_V3_QUALITY_UNITS_PCNT = api.OPTION_V3_QUALITY_UNITS_PCNT -OPTION_V3_QUALITY_UNITS_MGL = api.OPTION_V3_QUALITY_UNITS_MGL -OPTION_V3_QUALITY_UNITS_UGL = api.OPTION_V3_QUALITY_UNITS_UGL - -SCADA_DEVICE_TYPE_PRESSURE = api.SCADA_DEVICE_TYPE_PRESSURE -SCADA_DEVICE_TYPE_DEMAND = api.SCADA_DEVICE_TYPE_DEMAND -SCADA_DEVICE_TYPE_QUALITY = api.SCADA_DEVICE_TYPE_QUALITY -SCADA_DEVICE_TYPE_LEVEL = api.SCADA_DEVICE_TYPE_LEVEL -SCADA_DEVICE_TYPE_FLOW = api.SCADA_DEVICE_TYPE_FLOW -SCADA_DEVICE_TYPE_UNKNOWN = api.SCADA_DEVICE_TYPE_UNKNOWN - - -SCADA_MODEL_TYPE_JUNCTION = api.SCADA_MODEL_TYPE_JUNCTION -SCADA_MODEL_TYPE_RESERVOIR = api.SCADA_MODEL_TYPE_RESERVOIR -SCADA_MODEL_TYPE_TANK = api.SCADA_MODEL_TYPE_TANK -SCADA_MODEL_TYPE_PIPE = api.SCADA_MODEL_TYPE_PIPE -SCADA_MODEL_TYPE_PUMP = api.SCADA_MODEL_TYPE_PUMP -SCADA_MODEL_TYPE_VALVE = api.SCADA_MODEL_TYPE_VALVE - - -SCADA_ELEMENT_STATUS_ONLINE = api.SCADA_ELEMENT_STATUS_ONLINE -SCADA_ELEMENT_STATUS_OFFLINE = api.SCADA_ELEMENT_STATUS_OFFLINE - - -PARTITION_TYPE_RB = api.PARTITION_TYPE_RB -PARTITION_TYPE_KWAY = api.PARTITION_TYPE_KWAY - - -############################################################ -# project -############################################################ - -def list_project() -> list[str]: - return api.list_project() - -def have_project(name: str) -> bool: - return api.have_project(name) - -def create_project(name: str) -> None: - return api.create_project(name) - -def delete_project(name: str) -> None: - return api.delete_project(name) - -def clean_project(excluded: list[str] = []) -> None: - return api.clean_project(excluded) - -def is_project_open(name: str) -> bool: - return api.is_project_open(name) - -def open_project(name: str) -> None: - return api.open_project(name) - -def close_project(name: str) -> None: - return api.close_project(name) - -def copy_project(source: str, new: str) -> None: - return api.copy_project(source, new) - -def read_inp(name: str, inp: str, version: str = '3') -> bool: - return api.read_inp(name, inp, version) - -def dump_inp(name: str, inp: str, version: str = '3') -> None: - return api.dump_inp(name, inp, version) - -def import_inp(name: str, cs: ChangeSet, version: str = '3') -> bool: - return api.import_inp(name, cs, version) - -def export_inp(name: str, version: str = '3') -> ChangeSet: - return api.export_inp(name, version) - -#DingZQ, 2025-02-04, 返回dict[str, Any] def run_project_return_dict(name: str) -> dict[str, Any]: return epanet.run_project_return_dict(name, True) -# original code + def run_project(name: str) -> str: return epanet.run_project(name) -# put in inp folder, name without extension + def run_inp(name: str) -> str: return epanet.run_inp(name) -# path is absolute path + def dump_output(path: str) -> str: return epanet.dump_output(path) -#DingZQ, 2024-12-28, convert inp v3 to v2 -def convert_inp_v3_to_v2(inp: str) -> ChangeSet: - return api.convert_inp_v3_to_v2(inp) -############################################################ -# operation -############################################################ - -def get_current_operation(name: str) -> int: - return api.get_current_operation(name) - -def execute_undo(name: str, discard: bool = False) -> ChangeSet: - return api.execute_undo(name, discard) - -def execute_redo(name: str) -> ChangeSet: - return api.execute_redo(name) - -def list_snapshot(name: str) -> list[tuple[int, str]]: - return api.list_snapshot(name) - -def have_snapshot(name: str, tag: str) -> bool: - return api.have_snapshot(name, tag) - -def have_snapshot_for_operation(name: str, operation: int) -> bool: - return api.have_snapshot_for_operation(name, operation) - -def have_snapshot_for_current_operation(name: str) -> bool: - return api.have_snapshot_for_current_operation(name) - -def take_snapshot_for_operation(name: str, operation: int, tag: str) -> None: - return api.take_snapshot_for_operation(name, operation, tag) - -def take_snapshot_for_current_operation(name: str, tag: str) -> None: - return api.take_snapshot_for_current_operation(name, tag) - -# deprecated ! use take_snapshot_for_current_operation instead -def take_snapshot(name: str, tag: str) -> None: - return api.take_snapshot(name, tag) - -def update_snapshot(name: str, operation: int, tag: str) -> None: - return api.update_snapshot(name, operation, tag) - -def update_snapshot_for_current_operation(name: str, tag: str) -> None: - return api.update_snapshot_for_current_operation(name, tag) - -def delete_snapshot(name: str, tag: str) -> None: - return api.delete_snapshot(name, tag) - -def delete_snapshot_by_operation(name: str, operation: int) -> None: - return api.delete_snapshot_by_operation(name, operation) - -def get_operation_by_snapshot(name: str, tag: str) -> int | None: - return api.get_operation_by_snapshot(name, tag) - -def get_snapshot_by_operation(name: str, operation: int) -> str | None: - return api.get_snapshot_by_operation(name, operation) - -def pick_snapshot(name: str, tag: str, discard: bool = False) -> ChangeSet: - return api.pick_snapshot(name, tag, discard) - -def pick_operation(name: str, operation: int, discard: bool = False) -> ChangeSet: - return api.pick_operation(name, operation, discard) - -def sync_with_server(name: str, operation: int) -> ChangeSet: - return api.sync_with_server(name, operation) - -# combine commands as one undo/redo unit -def execute_batch_command(name: str, cs: ChangeSet) -> ChangeSet: - return api.execute_batch_command(name, cs) - -# execute command one by one -def execute_batch_commands(name: str, cs: ChangeSet) -> ChangeSet: - return api.execute_batch_commands(name, cs) - -def get_restore_operation(name: str) -> int: - return api.get_restore_operation(name) - -def set_restore_operation(name: str, operation: int) -> None: - return api.set_restore_operation(name, operation) - -def set_restore_operation_to_current(name: str) -> None: - return api.set_restore_operation_to_current(name) - -def restore(name: str, discard: bool = False) -> ChangeSet: - return api.restore(name, discard) - -def read(name: str, sql: str): - return api.read(name, sql) - -def try_read(name: str, sql: str): - return api.try_read(name, sql) - -def read_all(name: str, sql: str): - return api.read_all(name, sql) - -def write(name: str, sql: str): - return api.write(name, sql) - - -############################################################ -# extension_data -############################################################ - -def get_all_extension_data_keys(name: str) -> list[str]: - return api.get_all_extension_data_keys(name) - -def get_all_extension_data(name: str) -> dict[str, Any]: - return api.get_all_extension_data(name) - -def get_extension_data(name: str, key: str) -> str | None: - return api.get_extension_data(name, key) - -def set_extension_data(name: str, cs: ChangeSet) -> ChangeSet: - return api.set_extension_data(name, cs) - - -############################################################ -# type -############################################################ - -def is_node(name: str, node_id: str) -> bool: - return api.is_node(name, node_id) - -def is_junction(name: str, node_id: str) -> bool: - return api.is_junction(name, node_id) - -def is_reservoir(name: str, node_id: str) -> bool: - return api.is_reservoir(name, node_id) - -def is_tank(name: str, node_id: str) -> bool: - return api.is_tank(name, node_id) - -def is_link(name: str, link_id: str) -> bool: - return api.is_link(name, link_id) - -def is_pipe(name: str, link_id: str) -> bool: - return api.is_pipe(name, link_id) - -def is_pump(name: str, link_id: str) -> bool: - return api.is_pump(name, link_id) - -def is_valve(name: str, link_id: str) -> bool: - return api.is_valve(name, link_id) - -def is_curve(name: str, curve_id: str) -> bool: - return api.is_curve(name, curve_id) - -def is_pattern(name: str, pattern_id: str) -> bool: - return api.is_pattern(name, pattern_id) - -# DingZQ, 2025-02-05 -def get_node_type(name: str, node_id: str) -> str: - return api.get_node_type(name, node_id) - -def get_link_type(name: str, link_id: str) -> str: - return api.get_link_type(name, link_id) - -def get_element_type(name: str, element_id: str) -> str: - return api.get_element_type(name, element_id) - -def get_element_type_value(name: str, element_id: str) -> int: - return api.get_element_type_value(name, element_id) - -def get_nodes(name: str) -> list[str]: - return api.get_nodes(name) - -def get_links(name: str) -> list[str]: - return api.get_links(name) - -def get_curves(name: str) -> list[str]: - return api.get_curves(name) - -def get_patterns(name: str) -> list[str]: - return api.get_patterns(name) - -def get_node_links(name: str, node_id: str) -> list[str]: - return api.get_node_links(name, node_id) - -# DingZQ, 2025-02-05 def get_node_properties(name: str, node_id: str) -> dict[str, Any]: - if api.is_junction(name, node_id): - return api.get_junction(name, node_id) - elif api.is_reservoir(name, node_id): - return api.get_reservoir(name, node_id) - elif api.is_tank(name, node_id): - return api.get_tank(name, node_id) + if is_junction(name, node_id): + return get_junction(name, node_id) + if is_reservoir(name, node_id): + return get_reservoir(name, node_id) + if is_tank(name, node_id): + return get_tank(name, node_id) + return {} + def get_link_properties(name: str, link_id: str) -> dict[str, Any]: - if api.is_pipe(name, link_id): - return api.get_pipe(name, link_id) - elif api.is_pump(name, link_id): - return api.get_pump(name, link_id) - elif api.is_valve(name, link_id): - return api.get_valve(name, link_id) + if is_pipe(name, link_id): + return get_pipe(name, link_id) + if is_pump(name, link_id): + return get_pump(name, link_id) + if is_valve(name, link_id): + return get_valve(name, link_id) + return {} -# type can be 'node' or 'link' -def get_element_properties_with_type(name: str, type: str, element_id: str) -> dict[str, Any]: - if type == 'node': + +def get_element_properties_with_type( + name: str, element_type: str, element_id: str +) -> dict[str, Any]: + if element_type == "node": return get_node_properties(name, element_id) - elif type == 'link': + if element_type == "link": return get_link_properties(name, element_id) - elif type == 'scada': + if element_type == "scada": return get_scada_info(name, element_id) - else: - return {} - -# DingZQ, 2025-02-05 -# element_id can be 'node' 'link' 'scada' + return {} + + def get_element_properties(name: str, element_id: str) -> dict[str, Any]: - if api.is_node(name, element_id): + if is_node(name, element_id): return get_node_properties(name, element_id) - elif api.is_link(name, element_id): + if is_link(name, element_id): return get_link_properties(name, element_id) - else: - # return get_scada_element(name, element_id) - return get_scada_info(name, element_id) - -############################################################ -# title 1.[TITLE] -############################################################ + return get_scada_info(name, element_id) -def get_title_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_title_schema(name) -def get_title(name: str) -> dict[str, Any]: - return api.get_title(name) +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 set_title(name: str, cs: ChangeSet) -> ChangeSet: - return api.set_title(name, cs) +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) + } -############################################################ -# junction 2.[JUNCTIONS] -############################################################ -def get_junction_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_junction_schema(name) - -def get_junction(name: str, id: str) -> dict[str, Any]: - return api.get_junction(name, id) - -# DingZQ, 2025-03-29 -def get_all_junctions(name: str) -> list[dict[str, Any]]: - return api.get_all_junctions(name) - -def set_junction(name: str, cs: ChangeSet) -> ChangeSet: - return api.set_junction(name, cs) - -# example: add_junction(p, ChangeSet({'id': 'j0', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) -def add_junction(name: str, cs: ChangeSet) -> ChangeSet: - return api.add_junction(name, cs) - -def delete_junction(name: str, cs: ChangeSet) -> ChangeSet: - return api.delete_junction_cascade(name, cs) - - -############################################################ -# reservoir 3.[RESERVOIRS] -############################################################ - -def get_reservoir_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_reservoir_schema(name) - -def get_reservoir(name: str, id: str) -> dict[str, Any]: - return api.get_reservoir(name, id) - -# DingZQ, 2025-03-29 -def get_all_reservoirs(name: str) -> list[dict[str, Any]]: - return api.get_all_reservoirs(name) - -def set_reservoir(name: str, cs: ChangeSet) -> ChangeSet: - return api.set_reservoir(name, cs) - -# example: add_reservoir(p, ChangeSet({'id': 'r0', 'x': 0.0, 'y': 10.0, 'head': 20.0})) -def add_reservoir(name: str, cs: ChangeSet) -> ChangeSet: - return api.add_reservoir(name, cs) - -def delete_reservoir(name: str, cs: ChangeSet) -> ChangeSet: - return api.delete_reservoir_cascade(name, cs) - - -############################################################ -# tank 4.[TANKS] -############################################################ - -def get_tank_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_tank_schema(name) - -def get_tank(name: str, id: str) -> dict[str, Any]: - return api.get_tank(name, id) - -# DingZQ, 2025-03-29 -def get_all_tanks(name: str) -> list[dict[str, Any]]: - return api.get_all_tanks(name) - -def set_tank(name: str, cs: ChangeSet) -> ChangeSet: - return api.set_tank(name, cs) - -# example: add_tank(p, ChangeSet({'id': 't0', 'x': 0.0, 'y': 10.0, 'elevation': 20.0, 'init_level': 1.0, 'min_level': 0.0, 'max_level': 2.0, 'diameter': 10.0, 'min_vol': 100.0})) -def add_tank(name: str, cs: ChangeSet) -> ChangeSet: - return api.add_tank(name, cs) - -def delete_tank(name: str, cs: ChangeSet) -> ChangeSet: - return api.delete_tank_cascade(name, cs) - - -############################################################ -# pipe 5.[PIPES] -############################################################ - -def get_pipe_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_pipe_schema(name) - -def get_pipe(name: str, id: str) -> dict[str, Any]: - return api.get_pipe(name, id) - -# DingZQ, 2025-03-29 -def get_all_pipes(name: str) -> list[dict[str, Any]]: - return api.get_all_pipes(name) - -def set_pipe(name: str, cs: ChangeSet) -> ChangeSet: - return api.set_pipe(name, cs) - -# example: add_pipe(p, ChangeSet({'id': 'p0', 'node1': 'j1', 'node2': 'j2', 'length': 100.0, 'diameter': 10.0, 'roughness': 0.1, 'minor_loss': 0.5, 'status': PIPE_STATUS_OPEN })) -def add_pipe(name: str, cs: ChangeSet) -> ChangeSet: - return api.add_pipe(name, cs) - -def delete_pipe(name: str, cs: ChangeSet) -> ChangeSet: - return api.delete_pipe_cascade(name, cs) - - -############################################################ -# pump 6.[PUMPS] -############################################################ - -def get_pump_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_pump_schema(name) - -def get_pump(name: str, id: str) -> dict[str, Any]: - return api.get_pump(name, id) - -# DingZQ, 2025-03-29 -def get_all_pumps(name: str) -> list[dict[str, Any]]: - return api.get_all_pumps(name) - -def set_pump(name: str, cs: ChangeSet) -> ChangeSet: - return api.set_pump(name, cs) - -# example: add_pump(p, ChangeSet({'id': 'p0', 'node1': 'j1', 'node2': 'j2', 'power': 0})) -def add_pump(name: str, cs: ChangeSet) -> ChangeSet: - return api.add_pump(name, cs) - -def delete_pump(name: str, cs: ChangeSet) -> ChangeSet: - return api.delete_pump_cascade(name, cs) - - -############################################################ -# valve 7.[VALVES] -############################################################ - -def get_valve_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_valve_schema(name) - -def get_valve(name: str, id: str) -> dict[str, Any]: - return api.get_valve(name, id) - -# DingZQ, 2025-03-29 -def get_all_valves(name: str) -> list[dict[str, Any]]: - return api.get_all_valves(name) - -def set_valve(name: str, cs: ChangeSet) -> ChangeSet: - return api.set_valve(name, cs) - -# example: add_valve(p, ChangeSet({'id': 'v0', 'node1': 'j1', 'node2': 'j2', 'diameter': 10.0, 'v_type': VALVES_TYPE_FCV, 'setting': 0.1, 'minor_loss': 0.5 })) -def add_valve(name: str, cs: ChangeSet) -> ChangeSet: - return api.add_valve(name, cs) - -def delete_valve(name: str, cs: ChangeSet) -> ChangeSet: - return api.delete_valve_cascade(name, cs) - - -############################################################ -# tag 8.[TAGS] -############################################################ - -def get_tag_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_tag_schema(name) - -def get_tags(name: str) -> list[dict[str, Any]]: - return api.get_tags(name) - -def get_tag(name: str, t_type: str, id: str) -> dict[str, Any]: - return api.get_tag(name, t_type, id) - -# example: -# set_tag(p, ChangeSet({'t_type': TAG_TYPE_NODE, 'id': 'j1', 'tag': 'j1t' })) -# set_tag(p, ChangeSet({'t_type': TAG_TYPE_LINK, 'id': 'p0', 'tag': 'p0t' })) -def set_tag(name: str, cs: ChangeSet) -> ChangeSet: - return api.set_tag(name, cs) - - -############################################################ -# demand 9.[DEMANDS] -############################################################ - -def get_demand_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_demand_schema(name) - -def get_demand(name: str, junction: str) -> dict[str, Any]: - return api.get_demand(name, junction) - -# example: set_demand(p, ChangeSet({'junction': 'j1', 'demands': [{'demand': 10.0, 'pattern': None, 'category': 'x'}, {'demand': 20.0, 'pattern': None, 'category': None}]})) -def set_demand(name: str, cs: ChangeSet) -> ChangeSet: - return api.set_demand(name, cs) - - -############################################################ -# status 10.[STATUS] -############################################################ - -def get_status_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_status_schema(name) - -def get_status(name: str, link: str) -> dict[str, Any]: - return api.get_status(name, link) - -# example: set_status(p, ChangeSet({'link': 'p0', 'status': LINK_STATUS_OPEN, 'setting': 10.0})) -def set_status(name: str, cs: ChangeSet) -> ChangeSet: - return api.set_status(name, cs) - - -############################################################ -# pattern 11.[PATTERNS] -############################################################ - -def get_pattern_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_pattern_schema(name) - -def get_pattern(name: str, id: str) -> dict[str, Any]: - return api.get_pattern(name, id) - -def set_pattern(name: str, cs: ChangeSet) -> ChangeSet: - return api.set_pattern(name, cs) - -# example: add_pattern(p, ChangeSet({'id' : 'p0', 'factors': [1.0, 2.0, 3.0]})) -def add_pattern(name: str, cs: ChangeSet) -> ChangeSet: - return api.add_pattern(name, cs) - -def delete_pattern(name: str, cs: ChangeSet) -> ChangeSet: - return api.delete_pattern_cascade(name, cs) - - -############################################################ -# curve 12.[CURVES] -############################################################ - -def get_curve_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_curve_schema(name) - -def get_curve(name: str, id: str) -> dict[str, Any]: - return api.get_curve(name, id) - -def set_curve(name: str, cs: ChangeSet) -> ChangeSet: - return api.set_curve(name, cs) - -# example: add_curve(p, ChangeSet({'id' : 'c0', 'c_type' : CURVE_TYPE_PUMP, 'coords': [{'x': 1.0, 'y': 2.0}, {'x': 2.0, 'y': 1.0}]})) -def add_curve(name: str, cs: ChangeSet) -> ChangeSet: - return api.add_curve(name, cs) - -def delete_curve(name: str, cs: ChangeSet) -> ChangeSet: - return api.delete_curve_cascade(name, cs) - - -############################################################ -# control 13.[CONTROLS] -############################################################ - -def get_control_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_control_schema(name) - -def get_control(name: str) -> dict[str, Any]: - return api.get_control(name) - -# example: set_control(p, ChangeSet({'control': 'x'})) -def set_control(name: str, cs: ChangeSet) -> ChangeSet: - return api.set_control(name, cs) - - -############################################################ -# rule 14.[RULES] -############################################################ - -def get_rule_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_rule_schema(name) - -def get_rule(name: str) -> dict[str, Any]: - return api.get_rule(name) - -# example: set_rule(p, ChangeSet({'rule': 'x'})) -def set_rule(name: str, cs: ChangeSet) -> ChangeSet: - return api.set_rule(name, cs) - - -############################################################ -# energy 15.[ENERGY] -############################################################ - -def get_energy_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_energy_schema(name) - -def get_energy(name: str) -> dict[str, Any]: - return api.get_energy(name) - -def set_energy(name: str, cs: ChangeSet) -> ChangeSet: - return api.set_energy(name, cs) - -def get_pump_energy_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_pump_energy_schema(name) - -def get_pump_energy(name: str, pump: str) -> dict[str, Any]: - return api.get_pump_energy(name, pump) - -def set_pump_energy(name: str, cs: ChangeSet) -> ChangeSet: - return api.set_pump_energy(name, cs) - - -############################################################ -# emitter 16.[EMITTERS] -############################################################ - -def get_emitter_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_emitter_schema(name) - -def get_emitter(name: str, junction: str) -> dict[str, Any]: - return api.get_emitter(name, junction) - -# example: set_emitter(p, ChangeSet({'junction': 'j1', 'coefficient': 10.0})) -def set_emitter(name: str, cs: ChangeSet) -> ChangeSet: - return api.set_emitter(name, cs) - - -############################################################ -# quality 17.[QUALITY] -############################################################ - -def get_quality_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_quality_schema(name) - -def get_quality(name: str, node: str) -> dict[str, Any]: - return api.get_quality(name, node) - -# example: set_quality(p, ChangeSet({'node': 'j1', 'quality': 10.0})) -def set_quality(name: str, cs: ChangeSet) -> ChangeSet: - return api.set_quality(name, cs) - - -############################################################ -# source 18.[SOURCES] -############################################################ - -def get_source_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_source_schema(name) - -def get_source(name: str, node: str) -> dict[str, Any]: - return api.get_source(name, node) - -def set_source(name: str, cs: ChangeSet) -> ChangeSet: - return api.set_source(name, cs) - -# example: add_source(p, ChangeSet({'node': 'j0', 's_type': SOURCE_TYPE_CONCEN, 'strength': 10.0, 'pattern': 'p0'})) -def add_source(name: str, cs: ChangeSet) -> ChangeSet: - return api.add_source(name, cs) - -def delete_source(name: str, cs: ChangeSet) -> ChangeSet: - return api.delete_source(name, cs) - - -############################################################ -# reaction 19.[REACTIONS] -############################################################ - -def get_reaction_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_reaction_schema(name) - -def get_reaction(name: str) -> dict[str, Any]: - return api.get_reaction(name) - -def set_reaction(name: str, cs: ChangeSet) -> ChangeSet: - return api.set_reaction(name, cs) - -def get_pipe_reaction_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_pipe_reaction_schema(name) - -def get_pipe_reaction(name: str, pipe: str) -> dict[str, Any]: - return api.get_pipe_reaction(name, pipe) - -def set_pipe_reaction(name: str, cs: ChangeSet) -> ChangeSet: - return api.set_pipe_reaction(name, cs) - -def get_tank_reaction_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_tank_reaction_schema(name) - -def get_tank_reaction(name: str, tank: str) -> dict[str, Any]: - return api.get_tank_reaction(name, tank) - -def set_tank_reaction(name: str, cs: ChangeSet) -> ChangeSet: - return api.set_tank_reaction(name, cs) - - -############################################################ -# mixing 20.[MIXING] -############################################################ - -def get_mixing_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_mixing_schema(name) - -def get_mixing(name: str, tank: str) -> dict[str, Any]: - return api.get_mixing(name, tank) - -def set_mixing(name: str, cs: ChangeSet) -> ChangeSet: - return api.set_mixing(name, cs) - -# example: add_mixing(p, ChangeSet({'tank': 't0', 'model': MIXING_MODEL_MIXED, 'value': 10.0})) -def add_mixing(name: str, cs: ChangeSet) -> ChangeSet: - return api.add_mixing(name, cs) - -def delete_mixing(name: str, cs: ChangeSet) -> ChangeSet: - return api.delete_mixing(name, cs) - - -############################################################ -# time 21.[TIMES] -############################################################ - -def get_time_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_time_schema(name) - -def get_time(name: str) -> dict[str, Any]: - return api.get_time(name) - -def set_time(name: str, cs: ChangeSet) -> ChangeSet: - return api.set_time(name, cs) - - -############################################################ -# report 22.[REPORT] -############################################################ - -# hardcode... - - -############################################################ -# option 23.[OPTIONS] -############################################################ - -def get_option_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_option_schema(name) - -def get_option(name: str) -> dict[str, Any]: - return api.get_option(name) - -def set_option(name: str, cs: ChangeSet) -> ChangeSet: - return api.set_option_ex(name, cs) - - -############################################################ -# option_v3 23.[EPA3][OPTIONS] -############################################################ - -def get_option_v3_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_option_v3_schema(name) - -def get_option_v3(name: str) -> dict[str, Any]: - return api.get_option_v3(name) - -def set_option_v3(name: str, cs: ChangeSet) -> ChangeSet: - return api.set_option_v3_ex(name, cs) - - -############################################################ -# coord 24.[COORDINATES] -############################################################ - -def get_node_coord(name: str, node_id: str) -> dict[str, float]: - return api.get_node_coord(name, node_id) - -# DingZQ, 2024-12-08, get all node coord -# id, x, y, type -def get_network_node_coords(name: str) -> dict[str, dict[str, float]]: - nodes_id_and_type = api.get_nodes_id_and_type(name) - result = {} - for node_id, node_type in nodes_id_and_type.items(): - coord = api.get_node_coord(name, node_id) - coord['type'] = node_type - result[node_id] = coord - return result - -# DingZQ 2024-12-31 -# id, x, y, type -def get_major_node_coords(name: str, diameter: int) -> dict[str, dict[str, float]]: - nodes_id_and_type = api.get_nodes_id_and_type(name) - major_node_ids = api.get_major_nodes(name, diameter) - result = {} - for node_id in major_node_ids: - coord = api.get_node_coord(name, node_id) - coord['type'] = nodes_id_and_type[node_id] - result[node_id] = coord - return result - -def get_network_in_extent(name: str, x1: float, y1: float, x2: float, y2: float) -> dict[str, Any]: - pass - - - -# DingZQ, 2024-12-08, get all links' start and end node -# link_id:link_type:node_id1:node_id2 def get_network_link_nodes(name: str) -> list[str]: - links_id_and_type = api.get_links_id_and_type(name) - result = [] - for link_id, link_type in links_id_and_type.items(): - nodes = api.get_link_nodes(name, link_id) - result.append(f"{link_id}:{link_type}:{nodes[0]}:{nodes[1]}") - return result - -# DingZQ 2024-12-31 -# link_id:pipe:node_id1:node_id2 -def get_major_pipe_nodes(name: str, diameter: int) -> list[str]: - major_pipe_ids = api.get_major_pipes(name, diameter) - result = [] - for link_id in major_pipe_ids: - nodes = api.get_link_nodes(name, link_id) - result.append(f"{link_id}:pipe:{nodes[0]}:{nodes[1]}") - return result - -############################################################ -# vertex 25.[VERTICES] -############################################################ - -def get_vertex_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_vertex_schema(name) - -def get_vertex(name: str, link: str) -> dict[str, Any]: - return api.get_vertex(name, link) - -def set_vertex(name: str, cs: ChangeSet) -> ChangeSet: - return api.set_vertex(name, cs) - -def add_vertex(name: str, cs: ChangeSet) -> ChangeSet: - return api.add_vertex(name, cs) - -def delete_vertex(name: str, cs: ChangeSet) -> ChangeSet: - return api.delete_vertex(name, cs) - -def get_all_vertex_links(name: str) -> list[str]: - return api.get_all_vertex_links(name) - -def get_all_vertices(name: str) -> list[dict[str, Any]]: - return api.get_all_vertices(name) - - -############################################################ -# label 26.[LABELS] -############################################################ - -def get_label_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_label_schema(name) - -def get_label(name: str, x: float, y: float) -> dict[str, Any]: - return api.get_label(name, x, y) - -def set_label(name: str, cs: ChangeSet) -> ChangeSet: - return api.set_label(name, cs) - -def add_label(name: str, cs: ChangeSet) -> ChangeSet: - return api.add_label(name, cs) - -def delete_label(name: str, cs: ChangeSet) -> ChangeSet: - return api.delete_label(name, cs) - - -############################################################ -# backdrop 27.[BACKDROP] -############################################################ - -def get_backdrop_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_backdrop_schema(name) - -def get_backdrop(name: str) -> dict[str, Any]: - return api.get_backdrop(name) - -def set_backdrop(name: str, cs: ChangeSet) -> ChangeSet: - return api.set_backdrop(name, cs) - - -############################################################ -# end 28.[END] -############################################################ - - -############################################################ -# scada_device 29 -############################################################ - -def get_scada_device_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_scada_device_schema(name) - -def get_scada_device(name: str, id: str) -> dict[str, Any]: - return api.get_scada_device(name, id) - -def set_scada_device(name: str, cs: ChangeSet) -> ChangeSet: - return api.set_scada_device(name, cs) - -def add_scada_device(name: str, cs: ChangeSet) -> ChangeSet: - return api.add_scada_device(name, cs) - -def delete_scada_device(name: str, cs: ChangeSet) -> ChangeSet: - return api.delete_scada_device(name, cs) - -def clean_scada_device(name: str) -> ChangeSet: - return api.clean_scada_device(name) - -def get_all_scada_device_ids(name: str) -> list[str]: - return api.get_all_scada_device_ids(name) - -def get_all_scada_devices(name: str) -> list[dict[str, Any]]: - return api.get_all_scada_devices(name) - - -############################################################ -# scada_device_data 30 -############################################################ - -def get_scada_device_data_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_scada_device_data_schema(name) - -def get_scada_device_data(name: str, device_id: str) -> dict[str, Any]: - return api.get_scada_device_data(name, device_id) - -# example: set_scada_device_data(p, ChangeSet({'device_id': 'sm_device', 'data': [{ 'time': '2023-02-10 00:02:22', 'value': 100.0 }, { 'time': '2023-02-10 00:03:22', 'value': 200.0 }]})) -# time format must be 'YYYY-MM-DD HH:MM:SS' -def set_scada_device_data(name: str, cs: ChangeSet) -> ChangeSet: - return api.set_scada_device_data(name, cs) - -# example: add_scada_device_data(p, ChangeSet({'device_id': 'sm_device', 'time': '2023-02-10 00:02:22', 'value': 100.0})) -def add_scada_device_data(name: str, cs: ChangeSet) -> ChangeSet: - return api.add_scada_device_data(name, cs) - -# example: delete_scada_device_data(p, ChangeSet({'device_id': 'sm_device', 'time': '2023-02-12 00:02:22'})) -def delete_scada_device_data(name: str, cs: ChangeSet) -> ChangeSet: - return api.delete_scada_device_data(name, cs) - -def clean_scada_device_data(name: str) -> ChangeSet: - return api.clean_scada_device_data(name) - - -############################################################ -# scada_element 31 -############################################################ - -def get_scada_element_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_scada_element_schema(name) - -def get_scada_element(name: str, id: str) -> dict[str, Any]: - return api.get_scada_element(name, id) - -def set_scada_element(name: str, cs: ChangeSet) -> ChangeSet: - return api.set_scada_element(name, cs) - -def add_scada_element(name: str, cs: ChangeSet) -> ChangeSet: - return api.add_scada_element(name, cs) - -def delete_scada_element(name: str, cs: ChangeSet) -> ChangeSet: - return api.delete_scada_element(name, cs) - -def clean_scada_element(name: str) -> ChangeSet: - return api.clean_scada_element(name) - -def get_all_scada_element_ids(name: str) -> list[str]: - return api.get_all_scada_element_ids(name) - -def get_all_scada_elements(name: str) -> list[dict[str, Any]]: - return api.get_all_scada_elements(name) - - -############################################################ -# general_region 32 -############################################################ - -def get_nodes_in_boundary(name: str, boundary: list[tuple[float, float]]) -> list[str]: - return api.get_nodes_in_boundary(name, boundary) - -def get_nodes_in_region(name: str, region_id: str) -> list[str]: - return api.get_nodes_in_region(name, region_id) - -def get_links_on_region_boundary(name: str, region_id: str) -> list[str]: - return api.get_links_on_region_boundary(name, region_id) - -def calculate_convex_hull(name: str, nodes: list[str]) -> list[tuple[float, float]]: - return api.calculate_convex_hull(name, nodes) - -def calculate_boundary(name: str, nodes: list[str]) -> list[tuple[float, float]]: - return api.calculate_boundary(name, nodes) - -def inflate_boundary(name: str, boundary: list[tuple[float, float]], delta: float = 0.5) -> list[tuple[float, float]]: - return api.inflate_boundary(name, boundary, delta) - -def inflate_region(name: str, region_id: str, delta: float = 0.5) -> list[tuple[float, float]]: - return api.inflate_region(name, region_id, delta) - -def get_region_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_region_schema(name) - -def get_region(name: str, id: str) -> dict[str, Any]: - return api.get_region(name, id) - -def set_region(name: str, cs: ChangeSet) -> ChangeSet: - return api.set_region(name, cs) - -# example: add_region(p, ChangeSet({'id': 'r', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)]})) -def add_region(name: str, cs: ChangeSet) -> ChangeSet: - return api.add_region(name, cs) - -def delete_region(name: str, cs: ChangeSet) -> ChangeSet: - return api.delete_region(name, cs) - - -############################################################ -# district_metering_area 33 -############################################################ - -def calculate_district_metering_area_for_nodes(name: str, nodes: list[str], part_count: int = 1, part_type: int = PARTITION_TYPE_RB) -> list[list[str]]: - return api.calculate_district_metering_area_for_nodes(name, nodes, part_count, part_type) - -def calculate_district_metering_area_for_region(name: str, region: str, part_count: int = 1, part_type: int = PARTITION_TYPE_RB) -> list[list[str]]: - return api.calculate_district_metering_area_for_region(name, region, part_count, part_type) - -def calculate_district_metering_area_for_network(name: str, part_count: int = 1, part_type: int = PARTITION_TYPE_RB) -> list[list[str]]: - return api.calculate_district_metering_area_for_network(name, part_count, part_type) - -def get_district_metering_area_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_district_metering_area_schema(name) - -def get_district_metering_area(name: str, id: str) -> dict[str, Any]: - return api.get_district_metering_area(name, id) - -def set_district_metering_area(name: str, cs: ChangeSet) -> ChangeSet: - return api.set_district_metering_area(name, cs) - -def add_district_metering_area(name: str, cs: ChangeSet) -> ChangeSet: - return api.add_district_metering_area(name, cs) - -def delete_district_metering_area(name: str, cs: ChangeSet) -> ChangeSet: - return api.delete_district_metering_area(name, cs) - -def get_all_district_metering_area_ids(name: str) -> list[str]: - return api.get_all_district_metering_area_ids(name) - -def get_all_district_metering_areas(name: str) -> list[dict[str, Any]]: - return api.get_all_district_metering_areas(name) - -def generate_district_metering_area(name: str, part_count: int = 1, part_type: int = PARTITION_TYPE_RB, inflate_delta: float = 0.5) -> ChangeSet: - return api.generate_district_metering_area(name, part_count, part_type, inflate_delta) - -def generate_sub_district_metering_area(name: str, dma: str, part_count: int = 1, part_type: int = PARTITION_TYPE_RB, inflate_delta: float = 0.5) -> ChangeSet: - return api.generate_sub_district_metering_area(name, dma, part_count, part_type, inflate_delta) - - -############################################################ -# service_area 34 -############################################################ - -def calculate_service_area(name: str) -> list[dict[str, list[str]]]: - return api.calculate_service_area(name) - -def get_service_area_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_service_area_schema(name) - -def get_service_area(name: str, id: str) -> dict[str, Any]: - return api.get_service_area(name, id) - -def set_service_area(name: str, cs: ChangeSet) -> ChangeSet: - return api.set_service_area(name, cs) - -def add_service_area(name: str, cs: ChangeSet) -> ChangeSet: - return api.add_service_area(name, cs) - -def delete_service_area(name: str, cs: ChangeSet) -> ChangeSet: - return api.delete_service_area(name, cs) - -def get_all_service_area_ids(name: str) -> list[str]: - return api.get_all_service_area_ids(name) - -def get_all_service_areas(name: str) -> list[dict[str, Any]]: - return api.get_all_service_areas(name) - -def generate_service_area(name: str, inflate_delta: float = 0.5) -> ChangeSet: - return api.generate_service_area(name, inflate_delta) - - -############################################################ -# virtual_district 35 -############################################################ - -def calculate_virtual_district(name: str, centers: list[str]) -> dict[str, list[Any]]: - return api.calculate_virtual_district(name, centers) - -def get_virtual_district_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_virtual_district_schema(name) - -def get_virtual_district(name: str, id: str) -> dict[str, Any]: - return api.get_virtual_district(name, id) - -def set_virtual_district(name: str, cs: ChangeSet) -> ChangeSet: - return api.set_virtual_district(name, cs) - -def add_virtual_district(name: str, cs: ChangeSet) -> ChangeSet: - return api.add_virtual_district(name, cs) - -def delete_virtual_district(name: str, cs: ChangeSet) -> ChangeSet: - return api.delete_virtual_district(name, cs) - -def get_all_virtual_district_ids(name: str) -> list[str]: - return api.get_all_virtual_district_ids(name) - -def get_all_virtual_districts(name: str) -> list[dict[str, Any]]: - return api.get_all_virtual_districts(name) - -def generate_virtual_district(name: str, centers: list[str], inflate_delta: float = 0.5) -> ChangeSet: - return api.generate_virtual_district(name, centers, inflate_delta) - - -############################################################ -# water_distribution_area 36 -############################################################ - -def calculate_demand_to_nodes(name: str, demand: float, nodes: list[str]) -> dict[str, float]: - return api.calculate_demand_to_nodes(name, demand, nodes) - -# if region is general or wda => get_nodes_in_boundary -# if region is dma, sa or vd => get stored nodes in table -# TODO: more test -def calculate_demand_to_region(name: str, demand: float, region: str) -> dict[str, float]: - return api.calculate_demand_to_region(name, demand, region) - -def calculate_demand_to_network(name: str, demand: float) -> dict[str, float]: - return api.calculate_demand_to_network(name, demand) - - -############################################################ -# scada_info 38 | WMH -############################################################ - -def get_scada_info_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_scada_info_schema(name) - -def get_scada_info(name: str, id: str) -> dict[str, Any]: - return api.get_scada_info(name, id) - - -def get_all_scada_info(name: str) -> list[dict[str, Any]]: - return api.get_all_scada_info(name) - -############################################################ -# scheme 40 -############################################################ -def get_scheme_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_scheme_schema(name) - -def get_scheme(name: str, schema_name: str) -> dict[str, Any]: - return api.get_scheme(name, schema_name) - -def get_all_schemes( - name: str, - scheme_type: str | None = None, - query_date: Any | None = None, -) -> list[dict[str, Any]]: - if scheme_type is None and query_date is None: - return api.get_all_schemes(name) - - from app.services.scheme_management import query_scheme_list - - rows = query_scheme_list(name, scheme_type=scheme_type, query_date=query_date) or [] - columns = [ - "scheme_id", - "scheme_name", - "scheme_type", - "username", - "create_time", - "scheme_start_time", - "scheme_detail", + 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)) ] - result = [] - for row in rows: - item = dict(zip(columns, row, strict=False)) - detail = item.get("scheme_detail") - if isinstance(detail, dict) and detail.get("network") not in (None, name): - continue - result.append(item) - return result -############################################################ -# pipe_risk_probability 41 -############################################################ -def get_pipe_risk_probability_now(name: str, pipe_id: str) -> dict[str, Any]: - return api.get_pipe_risk_probability_now(name, pipe_id) -def get_pipe_risk_probability(name: str, pipe_id: str) -> dict[str, Any]: - return api.get_pipe_risk_probability(name, pipe_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_pipes_risk_probability(name: str, pipe_ids: list[str]) -> list[dict[str, Any]]: - return api.get_pipes_risk_probability(name, pipe_ids) -def get_network_pipe_risk_probability_now(name: str) -> list[dict[str, Any]]: - return api.get_network_pipe_risk_probability_now(name) - -def get_pipe_risk_probability_geometries(name: str) -> dict[str, Any]: - return api.get_pipe_risk_probability_geometries(name) - -############################################################ -# sensor_placement 42 -############################################################ -def get_all_sensor_placements(name: str) -> list[dict[Any, Any]]: - return api.get_all_sensor_placements(name) - -############################################################ -# burst_locate_result 43 -############################################################ -def get_all_burst_locate_results(name: str) -> list[dict[Any, Any]]: - return api.get_all_burst_locate_results(name) +def get_network_in_extent( + name: str, x1: float, y1: float, x2: float, y2: float +) -> dict[str, Any]: + # Kept as the existing API placeholder; spatial filtering belongs in GIS. + return {} diff --git a/contracts/manifest.json b/contracts/manifest.json index 70f691e..920c9a4 100644 --- a/contracts/manifest.json +++ b/contracts/manifest.json @@ -3,7 +3,7 @@ "contracts": { "server": { "file": "server-v1.openapi.json", - "sha256": "ac9b6fac185dfd999f1791cba51eb482df17a427b361963250aafa5fb1a276b4" + "sha256": "404a196c0177faed2aa5b46ee86430a034dfe990e0a77a43428a727748a882b6" } } } diff --git a/contracts/server-v1.openapi.json b/contracts/server-v1.openapi.json index 5c176bc..a53537d 100644 --- a/contracts/server-v1.openapi.json +++ b/contracts/server-v1.openapi.json @@ -532,32 +532,6 @@ "title": "Body_post_timeseries_realtime_simulation_results", "type": "object" }, - "Body_post_timeseries_schemes_simulation_results": { - "properties": { - "link_result_list": { - "description": "管道模拟结果列表", - "items": { - "type": "object" - }, - "title": "Link Result List", - "type": "array" - }, - "node_result_list": { - "description": "节点模拟结果列表", - "items": { - "type": "object" - }, - "title": "Node Result List", - "type": "array" - } - }, - "required": [ - "node_result_list", - "link_result_list" - ], - "title": "Body_post_timeseries_schemes_simulation_results", - "type": "object" - }, "BurstDetectionRequestRest": { "properties": { "data_source": { @@ -686,29 +660,18 @@ "description": "传感器节点列表", "title": "Sensor Nodes" }, - "simulation_scheme_name": { + "simulation_run_id": { "anyOf": [ { + "format": "uuid", "type": "string" }, { "type": "null" } ], - "description": "模拟方案名称", - "title": "Simulation Scheme Name" - }, - "simulation_scheme_type": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "模拟方案类型", - "title": "Simulation Scheme Type" + "description": "分析模拟运行 ID", + "title": "Simulation Run Id" }, "target_time": { "anyOf": [ @@ -931,32 +894,21 @@ "type": "null" } ], - "description": "方案名称", + "description": "爆管定位运行名称", "title": "Scheme Name" }, - "simulation_scheme_name": { + "simulation_run_id": { "anyOf": [ { + "format": "uuid", "type": "string" }, { "type": "null" } ], - "description": "模拟方案名称", - "title": "Simulation Scheme Name" - }, - "simulation_scheme_type": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "模拟方案类型", - "title": "Simulation Scheme Type" + "description": "分析模拟运行 ID", + "title": "Simulation Run Id" }, "use_scada_flow": { "default": false, @@ -1597,11 +1549,11 @@ "title": "Page[ProjectSummaryResponse]", "type": "object" }, - "Page_dict_Any__Any__": { + "Page_SensorPlacementSchemeResponse_": { "properties": { "items": { "items": { - "type": "object" + "$ref": "#/components/schemas/SensorPlacementSchemeResponse" }, "title": "Items", "type": "array" @@ -1625,7 +1577,7 @@ "limit", "offset" ], - "title": "Page[dict[Any, Any]]", + "title": "Page[SensorPlacementSchemeResponse]", "type": "object" }, "Page_dict_str__Any__": { @@ -1659,77 +1611,6 @@ "title": "Page[dict[str, Any]]", "type": "object" }, - "Page_dict_str__list_str___": { - "properties": { - "items": { - "items": { - "additionalProperties": { - "items": { - "type": "string" - }, - "type": "array" - }, - "type": "object" - }, - "title": "Items", - "type": "array" - }, - "limit": { - "title": "Limit", - "type": "integer" - }, - "offset": { - "title": "Offset", - "type": "integer" - }, - "total": { - "title": "Total", - "type": "integer" - } - }, - "required": [ - "items", - "total", - "limit", - "offset" - ], - "title": "Page[dict[str, list[str]]]", - "type": "object" - }, - "Page_list_str__": { - "properties": { - "items": { - "items": { - "items": { - "type": "string" - }, - "type": "array" - }, - "title": "Items", - "type": "array" - }, - "limit": { - "title": "Limit", - "type": "integer" - }, - "offset": { - "title": "Offset", - "type": "integer" - }, - "total": { - "title": "Total", - "type": "integer" - } - }, - "required": [ - "items", - "total", - "limit", - "offset" - ], - "title": "Page[list[str]]", - "type": "object" - }, "Page_str_": { "properties": { "items": { @@ -1761,47 +1642,6 @@ "title": "Page[str]", "type": "object" }, - "Page_tuple_int__str__": { - "properties": { - "items": { - "items": { - "maxItems": 2, - "minItems": 2, - "prefixItems": [ - { - "type": "integer" - }, - { - "type": "string" - } - ], - "type": "array" - }, - "title": "Items", - "type": "array" - }, - "limit": { - "title": "Limit", - "type": "integer" - }, - "offset": { - "title": "Offset", - "type": "integer" - }, - "total": { - "title": "Total", - "type": "integer" - } - }, - "required": [ - "items", - "total", - "limit", - "offset" - ], - "title": "Page[tuple[int, str]]", - "type": "object" - }, "PressureRegulationRest": { "properties": { "duration": { @@ -1859,32 +1699,6 @@ "title": "PressureRegulationRest", "type": "object" }, - "PressureSensorPlacementRest": { - "properties": { - "min_diameter": { - "default": 0, - "description": "最小管径限制", - "title": "Min Diameter", - "type": "integer" - }, - "scheme_name": { - "description": "方案名称", - "title": "Scheme Name", - "type": "string" - }, - "sensor_number": { - "description": "传感器数量", - "title": "Sensor Number", - "type": "integer" - } - }, - "required": [ - "scheme_name", - "sensor_number" - ], - "title": "PressureSensorPlacementRest", - "type": "object" - }, "ProblemDetails": { "description": "RFC 9457 compatible error response used by the REST contract.", "properties": { @@ -2417,18 +2231,18 @@ "title": "Adjustment Status", "type": "object" }, - "sensor_location": { + "sensor_locations": { "items": { "type": "string" }, "maxItems": 200, "minItems": 1, - "title": "Sensor Location", + "title": "Sensor Locations", "type": "array" } }, "required": [ - "sensor_location" + "sensor_locations" ], "title": "SensorPlacementExportRequest", "type": "object" @@ -2449,10 +2263,10 @@ "title": "Min Diameter", "type": "integer" }, - "scheme_name": { - "maxLength": 32, + "run_name": { + "maxLength": 64, "minLength": 1, - "title": "Scheme Name", + "title": "Run Name", "type": "string" }, "sensor_count": { @@ -2468,7 +2282,7 @@ } }, "required": [ - "scheme_name", + "run_name", "sensor_type", "method", "sensor_count" @@ -2483,34 +2297,39 @@ "title": "Can Edit", "type": "boolean" }, - "create_time": { + "created_at": { "format": "date-time", - "title": "Create Time", + "title": "Created At", "type": "string" }, - "id": { - "title": "Id", - "type": "integer" + "created_by": { + "title": "Created By", + "type": "string" }, "min_diameter": { "title": "Min Diameter", "type": "integer" }, - "scheme_name": { - "title": "Scheme Name", + "name": { + "title": "Name", "type": "string" }, - "sensor_location": { + "run_id": { + "format": "uuid", + "title": "Run Id", + "type": "string" + }, + "sensor_count": { + "title": "Sensor Count", + "type": "integer" + }, + "sensor_locations": { "items": { "type": "string" }, - "title": "Sensor Location", + "title": "Sensor Locations", "type": "array" }, - "sensor_number": { - "title": "Sensor Number", - "type": "integer" - }, "sensor_points": { "items": { "$ref": "#/components/schemas/SensorPointResponse" @@ -2518,19 +2337,20 @@ "title": "Sensor Points", "type": "array" }, - "username": { - "title": "Username", + "status": { + "title": "Status", "type": "string" } }, "required": [ - "id", - "scheme_name", - "sensor_number", + "run_id", + "name", + "sensor_count", "min_diameter", - "username", - "create_time", - "sensor_location", + "created_by", + "created_at", + "status", + "sensor_locations", "sensor_points" ], "title": "SensorPlacementSchemeResponse", @@ -2538,28 +2358,28 @@ }, "SensorPlacementUpdateRequest": { "properties": { - "expected_sensor_location": { + "expected_sensor_locations": { "items": { "type": "string" }, "maxItems": 200, "minItems": 1, - "title": "Expected Sensor Location", + "title": "Expected Sensor Locations", "type": "array" }, - "sensor_location": { + "sensor_locations": { "items": { "type": "string" }, "maxItems": 200, "minItems": 1, - "title": "Sensor Location", + "title": "Sensor Locations", "type": "array" } }, "required": [ - "expected_sensor_location", - "sensor_location" + "expected_sensor_locations", + "sensor_locations" ], "title": "SensorPlacementUpdateRequest", "type": "object" @@ -5007,226 +4827,6 @@ ] } }, - "/api/v1/all-extension-data-keys": { - "get": { - "description": "获取指定网络的所有扩展数据的键列表", - "operationId": "get_all_extension_data_keys", - "parameters": [ - { - "in": "query", - "name": "limit", - "required": false, - "schema": { - "default": 100, - "maximum": 1000, - "minimum": 1, - "title": "Limit", - "type": "integer" - } - }, - { - "in": "query", - "name": "offset", - "required": false, - "schema": { - "default": 0, - "minimum": 0, - "title": "Offset", - "type": "integer" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Page_str_" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取所有扩展数据键", - "tags": [ - "Extension" - ] - } - }, - "/api/v1/all-extension-datas": { - "get": { - "description": "获取指定网络的所有扩展数据", - "operationId": "get_all_extension_datas", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "title": "Response Get All Extension Datas", - "type": "object" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取所有扩展数据", - "tags": [ - "Extension" - ] - } - }, "/api/v1/all-scada-properties": { "get": { "description": "获取指定水网中所有SCADA点的属性信息", @@ -5469,6 +5069,338 @@ ] } }, + "/api/v1/analysis/runs": { + "get": { + "description": "获取所有方案信息\n\n返回项目中所有方案的详细信息", + "operationId": "get_analysis_runs", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取分析运行列表", + "tags": [ + "Project Data" + ] + } + }, + "/api/v1/analysis/runs/{run_id}": { + "get": { + "description": "获取所有爆管定位结果\n\n返回项目中所有的爆管定位分析结果", + "operationId": "get_analysis_runs_run_id", + "parameters": [ + { + "in": "path", + "name": "run_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Run Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取分析运行", + "tags": [ + "Project Data" + ] + } + }, + "/api/v1/analysis/runs/{run_id}/results": { + "get": { + "description": "根据爆管事件ID查询爆管定位结果\n\n参数:\n burst_incident: 爆管事件的唯一标识符", + "operationId": "get_analysis_runs_run_id_results", + "parameters": [ + { + "in": "path", + "name": "run_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Run Id", + "type": "string" + } + }, + { + "description": "结果类型", + "in": "query", + "name": "result_type", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "结果类型", + "title": "Result Type" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取分析结果", + "tags": [ + "Project Data" + ] + } + }, "/api/v1/audit-events": { "post": { "operationId": "post_audit_events", @@ -6626,125 +6558,6 @@ } }, "/api/v1/burst-locations": { - "get": { - "description": "获取网络中所有爆管定位的分析结果", - "operationId": "get_burst_locations", - "parameters": [ - { - "in": "query", - "name": "limit", - "required": false, - "schema": { - "default": 100, - "maximum": 1000, - "minimum": 1, - "title": "Limit", - "type": "integer" - } - }, - { - "in": "query", - "name": "offset", - "required": false, - "schema": { - "default": 0, - "minimum": 0, - "title": "Offset", - "type": "integer" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Page_dict_Any__Any__" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取所有爆管定位结果", - "tags": [ - "Misc" - ] - }, "post": { "description": "基于压力和流量数据定位管网中的爆管位置", "operationId": "post_burst_locations", @@ -6854,213 +6667,6 @@ ] } }, - "/api/v1/burst-locations/database-view": { - "get": { - "description": "使用连接池查询所有爆管定位结果", - "operationId": "get_burst_locations_database_view", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取爆管定位结果", - "tags": [ - "Project Data" - ] - } - }, - "/api/v1/burst-locations/{burst_incident}": { - "get": { - "description": "根据爆管事件ID查询对应的爆管定位结果", - "operationId": "get_burst_locations_burst_incident", - "parameters": [ - { - "description": "爆管事件ID", - "in": "path", - "name": "burst_incident", - "required": true, - "schema": { - "description": "爆管事件ID", - "title": "Burst Incident", - "type": "string" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "按事件查询爆管定位结果", - "tags": [ - "Project Data" - ] - } - }, "/api/v1/contaminant-simulations": { "post": { "description": "对管网中的污染物扩散进行模拟,评估污染源对管网的影响范围和浓度分布。支持指定污染源位置、污染浓度和扩散模式。", @@ -7427,105 +7033,6 @@ ] } }, - "/api/v1/current-operation-ids": { - "get": { - "description": "获取网络当前的操作ID", - "operationId": "get_current_operation_ids", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "title": "Response Get Current Operation Ids", - "type": "integer" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取当前操作ID", - "tags": [ - "Snapshots" - ] - } - }, "/api/v1/curves": { "delete": { "description": "从网络中删除指定的曲线", @@ -8826,1134 +8333,6 @@ ] } }, - "/api/v1/district-metering-area-generation-runs": { - "post": { - "description": "根据参数自动生成水网的DMA分区方案", - "operationId": "post_district_metering_area_generation_runs", - "parameters": [ - { - "description": "分区数量", - "in": "query", - "name": "part_count", - "required": true, - "schema": { - "description": "分区数量", - "exclusiveMinimum": 0, - "title": "Part Count", - "type": "integer" - } - }, - { - "description": "分区类型", - "in": "query", - "name": "part_type", - "required": true, - "schema": { - "description": "分区类型", - "title": "Part Type", - "type": "integer" - } - }, - { - "description": "膨胀参数", - "in": "query", - "name": "inflate_delta", - "required": true, - "schema": { - "description": "膨胀参数", - "title": "Inflate Delta", - "type": "number" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "生成DMA分区", - "tags": [ - "Regions & DMAs" - ] - } - }, - "/api/v1/district-metering-areas": { - "delete": { - "description": "删除指定的区域计量(DMA)", - "operationId": "delete_district_metering_areas", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "删除DMA", - "tags": [ - "Regions & DMAs" - ] - }, - "get": { - "description": "获取指定水网中所有DMA的详细信息", - "operationId": "get_district_metering_areas", - "parameters": [ - { - "in": "query", - "name": "limit", - "required": false, - "schema": { - "default": 100, - "maximum": 1000, - "minimum": 1, - "title": "Limit", - "type": "integer" - } - }, - { - "in": "query", - "name": "offset", - "required": false, - "schema": { - "default": 0, - "minimum": 0, - "title": "Offset", - "type": "integer" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Page_dict_str__Any__" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取所有DMA", - "tags": [ - "Regions & DMAs" - ] - }, - "patch": { - "description": "修改指定DMA的属性信息", - "operationId": "patch_district_metering_areas", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "设置DMA属性", - "tags": [ - "Regions & DMAs" - ] - }, - "post": { - "description": "向水网添加一个新的区域计量(DMA)", - "operationId": "post_district_metering_areas", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "添加新DMA", - "tags": [ - "Regions & DMAs" - ] - } - }, - "/api/v1/district-metering-areas/detail": { - "get": { - "description": "获取指定ID的区域计量(DMA)详细信息", - "operationId": "get_district_metering_areas_detail", - "parameters": [ - { - "description": "DMA ID", - "in": "query", - "name": "id", - "required": true, - "schema": { - "description": "DMA ID", - "title": "Id", - "type": "string" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "title": "Response Get District Metering Areas Detail", - "type": "object" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取DMA信息", - "tags": [ - "Regions & DMAs" - ] - } - }, - "/api/v1/district-metering-areas/for-network": { - "post": { - "description": "为整个水网计算区域计量(DMA)分区方案", - "operationId": "post_district_metering_areas_for_network", - "parameters": [ - { - "in": "query", - "name": "limit", - "required": false, - "schema": { - "default": 100, - "maximum": 1000, - "minimum": 1, - "title": "Limit", - "type": "integer" - } - }, - { - "in": "query", - "name": "offset", - "required": false, - "schema": { - "default": 0, - "minimum": 0, - "title": "Offset", - "type": "integer" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Page_list_str__" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "计算整网DMA分区", - "tags": [ - "Regions & DMAs" - ] - } - }, - "/api/v1/district-metering-areas/for-nodes": { - "post": { - "description": "为指定节点集计算区域计量(DMA)分区方案", - "operationId": "post_district_metering_areas_for_nodes", - "parameters": [ - { - "in": "query", - "name": "limit", - "required": false, - "schema": { - "default": 100, - "maximum": 1000, - "minimum": 1, - "title": "Limit", - "type": "integer" - } - }, - { - "in": "query", - "name": "offset", - "required": false, - "schema": { - "default": 0, - "minimum": 0, - "title": "Offset", - "type": "integer" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Page_list_str__" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "计算节点DMA分区", - "tags": [ - "Regions & DMAs" - ] - } - }, - "/api/v1/district-metering-areas/for-region": { - "post": { - "description": "为指定区域计算区域计量(DMA)分区方案", - "operationId": "post_district_metering_areas_for_region", - "parameters": [ - { - "in": "query", - "name": "limit", - "required": false, - "schema": { - "default": 100, - "maximum": 1000, - "minimum": 1, - "title": "Limit", - "type": "integer" - } - }, - { - "in": "query", - "name": "offset", - "required": false, - "schema": { - "default": 0, - "minimum": 0, - "title": "Offset", - "type": "integer" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Page_list_str__" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "计算区域内DMA分区", - "tags": [ - "Regions & DMAs" - ] - } - }, - "/api/v1/district-metering-areas/ids": { - "get": { - "description": "获取指定水网中所有DMA的ID列表", - "operationId": "get_district_metering_areas_ids", - "parameters": [ - { - "in": "query", - "name": "limit", - "required": false, - "schema": { - "default": 100, - "maximum": 1000, - "minimum": 1, - "title": "Limit", - "type": "integer" - } - }, - { - "in": "query", - "name": "offset", - "required": false, - "schema": { - "default": 0, - "minimum": 0, - "title": "Offset", - "type": "integer" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Page_str_" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取所有DMA ID", - "tags": [ - "Regions & DMAs" - ] - } - }, "/api/v1/element-properties": { "get": { "description": "获取指定元素的属性信息", @@ -10720,219 +9099,6 @@ ] } }, - "/api/v1/extension-datas": { - "get": { - "description": "获取指定网络中指定键的扩展数据值", - "operationId": "get_extension_datas", - "parameters": [ - { - "description": "扩展数据键", - "in": "query", - "name": "key", - "required": true, - "schema": { - "description": "扩展数据键", - "title": "Key", - "type": "string" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Response Get Extension Datas" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取指定扩展数据", - "tags": [ - "Extension" - ] - }, - "patch": { - "description": "设置指定网络中的扩展数据", - "operationId": "patch_extension_datas", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "设置扩展数据", - "tags": [ - "Extension" - ] - } - }, "/api/v1/flushing-analyses": { "post": { "description": "高级版本的冲洗分析,支持按状态和设置值控制多个可选阀门,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。", @@ -15157,202 +13323,6 @@ ] } }, - "/api/v1/network-command-batches": { - "post": { - "description": "执行多个网络操作命令", - "operationId": "post_network_command_batches", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "执行批量命令", - "tags": [ - "Snapshots" - ] - } - }, - "/api/v1/network-command-batches/compressed": { - "post": { - "description": "执行压缩的批量命令", - "operationId": "post_network_command_batches_compressed", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "执行压缩批量命令", - "tags": [ - "Snapshots" - ] - } - }, "/api/v1/network-in-extents": { "get": { "description": "获取指定地理范围内的网络节点和管线", @@ -16215,127 +14185,6 @@ ] } }, - "/api/v1/network-pipe-risk-probability-nows": { - "get": { - "description": "获取指定网络中所有管道的当前风险概率值", - "operationId": "get_network_pipe_risk_probability_nows", - "parameters": [ - { - "in": "query", - "name": "limit", - "required": false, - "schema": { - "default": 100, - "maximum": 1000, - "minimum": 1, - "title": "Limit", - "type": "integer" - } - }, - { - "in": "query", - "name": "offset", - "required": false, - "schema": { - "default": 0, - "minimum": 0, - "title": "Offset", - "type": "integer" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Page_dict_str__Any__" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取整个网络的管道风险概率", - "tags": [ - "Risk" - ] - } - }, "/api/v1/network-schemas/backdrop": { "get": { "description": "获取网络中背景对象的架构定义", @@ -16744,108 +14593,6 @@ ] } }, - "/api/v1/network-schemas/district-metering-area": { - "get": { - "description": "获取指定水网的区域计量(DMA)属性架构定义", - "operationId": "get_network_schemas_district_metering_area", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "additionalProperties": { - "type": "object" - }, - "title": "Response Get Network Schemas District Metering Area", - "type": "object" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取DMA属性架构", - "tags": [ - "Regions & DMAs" - ] - } - }, "/api/v1/network-schemas/emitter": { "get": { "description": "获取网络中发射器对象的架构定义", @@ -18174,7 +15921,6 @@ }, "/api/v1/network-schemas/region": { "get": { - "description": "获取指定水网的区域属性架构定义", "operationId": "get_network_schemas_region", "parameters": [ { @@ -18378,7 +16124,6 @@ }, "/api/v1/network-schemas/scada-device": { "get": { - "description": "获取SCADA设备的数据架构\n\n返回SCADA设备表的字段定义和类型信息。\n\nArgs:\n network: 管网名称(或数据库名称)\n \nReturns:\n SCADA设备的字段架构信息", "operationId": "get_network_schemas_scada_device", "parameters": [ { @@ -18472,417 +16217,9 @@ "OAuth2PasswordBearer": [] } ], - "summary": "获取SCADA设备架构", + "summary": "获取 SCADA 设备结构", "tags": [ - "SCADA设备" - ] - } - }, - "/api/v1/network-schemas/scada-device-data": { - "get": { - "description": "获取SCADA设备数据的表结构\n\n返回SCADA设备数据表的字段定义和类型信息。\n\nArgs:\n network: 管网名称(或数据库名称)\n \nReturns:\n SCADA设备数据的字段架构信息", - "operationId": "get_network_schemas_scada_device_data", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "additionalProperties": { - "type": "object" - }, - "title": "Response Get Network Schemas Scada Device Data", - "type": "object" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取SCADA设备数据架构", - "tags": [ - "SCADA设备数据" - ] - } - }, - "/api/v1/network-schemas/scada-element": { - "get": { - "description": "获取SCADA元素映射的表结构\n\n返回SCADA元素映射表的字段定义和类型信息。\n\nArgs:\n network: 管网名称(或数据库名称)\n \nReturns:\n SCADA元素映射的字段架构信息", - "operationId": "get_network_schemas_scada_element", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "additionalProperties": { - "type": "object" - }, - "title": "Response Get Network Schemas Scada Element", - "type": "object" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取SCADA元素架构", - "tags": [ - "SCADA元素映射" - ] - } - }, - "/api/v1/network-schemas/scheme": { - "get": { - "description": "获取指定网络的方案模式定义", - "operationId": "get_network_schemas_scheme", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "additionalProperties": { - "type": "object" - }, - "title": "Response Get Network Schemas Scheme", - "type": "object" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取方案模式", - "tags": [ - "Schemes" - ] - } - }, - "/api/v1/network-schemas/service-area": { - "get": { - "description": "获取指定水网的服务区属性架构定义", - "operationId": "get_network_schemas_service_area", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "additionalProperties": { - "type": "object" - }, - "title": "Response Get Network Schemas Service Area", - "type": "object" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取服务区属性架构", - "tags": [ - "Regions & DMAs" + "SCADA Metadata" ] } }, @@ -19600,108 +16937,6 @@ ] } }, - "/api/v1/network-schemas/virtual-district": { - "get": { - "description": "获取指定水网的虚拟分区属性架构定义", - "operationId": "get_network_schemas_virtual_district", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "additionalProperties": { - "type": "object" - }, - "title": "Response Get Network Schemas Virtual District", - "type": "object" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取虚拟分区属性架构", - "tags": [ - "Regions & DMAs" - ] - } - }, "/api/v1/node-coords": { "get": { "description": "获取指定节点的地理坐标(X, Y)", @@ -20505,127 +17740,6 @@ ] } }, - "/api/v1/operations": { - "patch": { - "description": "选择并恢复到指定的操作", - "operationId": "patch_operations", - "parameters": [ - { - "description": "操作ID", - "in": "query", - "name": "operation", - "required": true, - "schema": { - "description": "操作ID", - "title": "Operation", - "type": "integer" - } - }, - { - "description": "是否丢弃当前更改", - "in": "query", - "name": "discard", - "required": false, - "schema": { - "default": false, - "description": "是否丢弃当前更改", - "title": "Discard", - "type": "boolean" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "选择操作", - "tags": [ - "Snapshots" - ] - } - }, "/api/v1/outputs": { "get": { "description": "导出指定路径的模拟输出文件内容。参数应为绝对路径。", @@ -22119,138 +19233,6 @@ ] } }, - "/api/v1/pipes-risk-probabilities": { - "get": { - "description": "批量获取多条管道的风险概率值", - "operationId": "get_pipes_risk_probabilities", - "parameters": [ - { - "description": "逗号分隔的管道ID列表", - "in": "query", - "name": "pipe_ids", - "required": true, - "schema": { - "description": "逗号分隔的管道ID列表", - "title": "Pipe Ids", - "type": "string" - } - }, - { - "in": "query", - "name": "limit", - "required": false, - "schema": { - "default": 100, - "maximum": 1000, - "minimum": 1, - "title": "Limit", - "type": "integer" - } - }, - { - "in": "query", - "name": "offset", - "required": false, - "schema": { - "default": 0, - "minimum": 0, - "title": "Offset", - "type": "integer" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Page_dict_str__Any__" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "批量获取多条管道风险概率", - "tags": [ - "Risk" - ] - } - }, "/api/v1/pipes/diameter": { "get": { "description": "获取指定管道的管径", @@ -23753,325 +20735,6 @@ ] } }, - "/api/v1/pipes/risk-probability": { - "get": { - "description": "获取指定管道的风险概率历史数据", - "operationId": "get_pipes_risk_probability", - "parameters": [ - { - "description": "管道ID", - "in": "query", - "name": "pipe_id", - "required": true, - "schema": { - "description": "管道ID", - "title": "Pipe Id", - "type": "string" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "title": "Response Get Pipes Risk Probability", - "type": "object" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取管道风险概率历史", - "tags": [ - "Risk" - ] - } - }, - "/api/v1/pipes/risk-probability-geometries": { - "get": { - "description": "获取指定网络中管道的风险相关几何数据", - "operationId": "get_pipes_risk_probability_geometries", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "title": "Response Get Pipes Risk Probability Geometries", - "type": "object" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取管道风险几何信息", - "tags": [ - "Risk" - ] - } - }, - "/api/v1/pipes/risk-probability-now": { - "get": { - "description": "获取指定管道当前时刻的风险概率值", - "operationId": "get_pipes_risk_probability_now", - "parameters": [ - { - "description": "管道ID", - "in": "query", - "name": "pipe_id", - "required": true, - "schema": { - "description": "管道ID", - "title": "Pipe Id", - "type": "string" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "title": "Response Get Pipes Risk Probability Now", - "type": "object" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取管道当前风险概率", - "tags": [ - "Risk" - ] - } - }, "/api/v1/pipes/roughness": { "get": { "description": "获取指定管道的粗糙度", @@ -24772,486 +21435,6 @@ ] } }, - "/api/v1/pressure-sensor-placement-kmeans": { - "post": { - "description": "高级版本的压力传感器放置分析,通过JSON请求体提供详细参数。基于KMeans聚类算法确定最优放置位置。", - "operationId": "post_pressure_sensor_placement_kmeans", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PressureSensorPlacementRest", - "description": "传感器放置分析参数" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "压力传感器放置-KMeans聚类分析(高级)", - "tags": [ - "Simulation Control" - ] - } - }, - "/api/v1/pressure-sensor-placement-kmeans-calculations": { - "post": { - "description": "基于KMeans聚类算法,为指定管网项目确定压力传感器的最优放置位置。此为基础版本。", - "operationId": "post_pressure_sensor_placement_kmeans_calculations", - "parameters": [ - { - "description": "放置方案名称", - "in": "query", - "name": "scheme_name", - "required": true, - "schema": { - "description": "放置方案名称", - "title": "Scheme Name", - "type": "string" - } - }, - { - "description": "传感器数量", - "in": "query", - "name": "sensor_number", - "required": true, - "schema": { - "description": "传感器数量", - "title": "Sensor Number", - "type": "integer" - } - }, - { - "description": "最小管径限制(毫米)", - "in": "query", - "name": "min_diameter", - "required": true, - "schema": { - "description": "最小管径限制(毫米)", - "title": "Min Diameter", - "type": "integer" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "压力传感器放置-KMeans聚类分析(基础)", - "tags": [ - "Simulation Control" - ] - } - }, - "/api/v1/pressure-sensor-placement-sensitivities": { - "post": { - "description": "高级版本的压力传感器放置分析,通过JSON请求体提供详细参数。基于灵敏度分析方法确定最优放置位置。", - "operationId": "post_pressure_sensor_placement_sensitivities", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PressureSensorPlacementRest", - "description": "传感器放置分析参数" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "压力传感器放置-灵敏度分析(高级)", - "tags": [ - "Simulation Control" - ] - } - }, - "/api/v1/pressure-sensor-placement-sensitivity-calculations": { - "post": { - "description": "基于灵敏度分析方法,为指定管网项目确定最优的压力传感器放置位置。此为基础版本。", - "operationId": "post_pressure_sensor_placement_sensitivity_calculations", - "parameters": [ - { - "description": "放置方案名称", - "in": "query", - "name": "scheme_name", - "required": true, - "schema": { - "description": "放置方案名称", - "title": "Scheme Name", - "type": "string" - } - }, - { - "description": "传感器数量", - "in": "query", - "name": "sensor_number", - "required": true, - "schema": { - "description": "传感器数量", - "title": "Sensor Number", - "type": "integer" - } - }, - { - "description": "最小管径限制(毫米)", - "in": "query", - "name": "min_diameter", - "required": true, - "schema": { - "description": "最小管径限制(毫米)", - "title": "Min Diameter", - "type": "integer" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "压力传感器放置-灵敏度分析(基础)", - "tags": [ - "Simulation Control" - ] - } - }, "/api/v1/project-codes": { "get": { "description": "获取服务器上所有可用的供水管网项目名称列表。", @@ -29361,107 +25544,8 @@ ] } }, - "/api/v1/redos": { - "post": { - "description": "重做网络上被撤销的操作", - "operationId": "post_redos", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "重做操作", - "tags": [ - "Snapshots" - ] - } - }, "/api/v1/regions": { "delete": { - "description": "删除指定的区域", "operationId": "delete_regions", "parameters": [ { @@ -29549,8 +25633,125 @@ "Regions & DMAs" ] }, + "get": { + "operationId": "get_regions", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_dict_str__Any__" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取区域列表", + "tags": [ + "Regions & DMAs" + ] + }, "patch": { - "description": "修改指定区域的属性信息", "operationId": "patch_regions", "parameters": [ { @@ -29640,13 +25841,12 @@ "OAuth2PasswordBearer": [] } ], - "summary": "设置区域属性", + "summary": "修改区域", "tags": [ "Regions & DMAs" ] }, "post": { - "description": "向水网添加一个新的区域", "operationId": "post_regions", "parameters": [ { @@ -29736,7 +25936,7 @@ "OAuth2PasswordBearer": [] } ], - "summary": "添加新区域", + "summary": "添加区域", "tags": [ "Regions & DMAs" ] @@ -29744,16 +25944,15 @@ }, "/api/v1/regions/detail": { "get": { - "description": "获取指定ID的区域详细信息", "operationId": "get_regions_detail", "parameters": [ { - "description": "区域ID", + "description": "区域 ID", "in": "query", "name": "id", "required": true, "schema": { - "description": "区域ID", + "description": "区域 ID", "title": "Id", "type": "string" } @@ -29852,6 +26051,137 @@ ] } }, + "/api/v1/regions/nodes": { + "get": { + "operationId": "get_regions_nodes", + "parameters": [ + { + "description": "区域 ID", + "in": "query", + "name": "id", + "required": true, + "schema": { + "description": "区域 ID", + "title": "Id", + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_str_" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取区域节点", + "tags": [ + "Regions & DMAs" + ] + } + }, "/api/v1/reservoirs": { "delete": { "description": "从指定供水网络中删除指定的水库/水源节点", @@ -31735,212 +28065,6 @@ ] } }, - "/api/v1/restore-operations": { - "get": { - "description": "获取网络的恢复操作ID", - "operationId": "get_restore_operations", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "title": "Response Get Restore Operations", - "type": "integer" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取恢复操作ID", - "tags": [ - "Snapshots" - ] - }, - "patch": { - "description": "设置网络的恢复操作ID", - "operationId": "patch_restore_operations", - "parameters": [ - { - "description": "操作ID", - "in": "query", - "name": "operation", - "required": true, - "schema": { - "description": "操作ID", - "title": "Operation", - "type": "integer" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "设置恢复操作ID", - "tags": [ - "Snapshots" - ] - } - }, "/api/v1/rule-properties": { "get": { "description": "获取指定网络中的规则属性信息", @@ -32238,687 +28362,8 @@ ] } }, - "/api/v1/scada-device-cleaning-runs": { - "post": { - "description": "清空SCADA设备表\n\n删除指定管网中所有的SCADA设备。\n\nArgs:\n network: 管网名称(或数据库名称)\n \nReturns:\n 变更集合信息", - "operationId": "post_scada_device_cleaning_runs", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "清空SCADA设备表", - "tags": [ - "SCADA设备" - ] - } - }, - "/api/v1/scada-device-data-cleaning-runs": { - "post": { - "description": "清空SCADA设备数据表\n\n删除指定管网中所有SCADA设备的数据。\n\nArgs:\n network: 管网名称(或数据库名称)\n \nReturns:\n 变更集合信息", - "operationId": "post_scada_device_data_cleaning_runs", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "清空SCADA设备数据表", - "tags": [ - "SCADA设备数据" - ] - } - }, - "/api/v1/scada-device-datas": { - "delete": { - "description": "删除SCADA设备数据\n\n删除指定SCADA设备的数据记录。\n\nArgs:\n network: 管网名称(或数据库名称)\n req: 请求体,包含要删除的数据ID\n \nReturns:\n 变更集合信息", - "operationId": "delete_scada_device_datas", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "删除SCADA设备数据", - "tags": [ - "SCADA设备数据" - ] - }, - "patch": { - "description": "更新SCADA设备数据\n\n修改指定SCADA设备的数据。\n\nArgs:\n network: 管网名称(或数据库名称)\n req: 请求体,包含要更新的数据\n \nReturns:\n 变更集合信息", - "operationId": "patch_scada_device_datas", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "更新SCADA设备数据", - "tags": [ - "SCADA设备数据" - ] - }, - "post": { - "description": "添加新的SCADA设备数据\n\n为指定SCADA设备添加新的数据记录。\n\nArgs:\n network: 管网名称(或数据库名称)\n req: 请求体,包含新数据的内容\n \nReturns:\n 变更集合信息", - "operationId": "post_scada_device_datas", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "添加SCADA设备数据", - "tags": [ - "SCADA设备数据" - ] - } - }, - "/api/v1/scada-device-datas/detail": { - "get": { - "description": "获取单个SCADA设备的数据\n\n查询指定设备的监测数据或配置数据。\n\nArgs:\n network: 管网名称(或数据库名称)\n device_id: SCADA设备ID\n \nReturns:\n SCADA设备数据", - "operationId": "get_scada_device_datas_detail", - "parameters": [ - { - "description": "SCADA设备ID", - "in": "query", - "name": "device_id", - "required": true, - "schema": { - "description": "SCADA设备ID", - "title": "Device Id", - "type": "string" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "title": "Response Get Scada Device Datas Detail", - "type": "object" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取SCADA设备数据", - "tags": [ - "SCADA设备数据" - ] - } - }, "/api/v1/scada-devices": { - "delete": { - "description": "删除SCADA设备\n\n从指定管网中删除一个SCADA设备。\n\nArgs:\n network: 管网名称(或数据库名称)\n req: 请求体,包含要删除的设备ID\n \nReturns:\n 变更集合信息", - "operationId": "delete_scada_devices", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "删除SCADA设备", - "tags": [ - "SCADA设备" - ] - }, "get": { - "description": "获取指定管网所有SCADA设备的完整信息\n\nArgs:\n network: 管网名称(或数据库名称)\n \nReturns:\n SCADA设备信息列表", "operationId": "get_scada_devices", "parameters": [ { @@ -33031,217 +28476,24 @@ "OAuth2PasswordBearer": [] } ], - "summary": "获取所有SCADA设备", + "summary": "获取 SCADA 设备列表", "tags": [ - "SCADA设备" - ] - }, - "patch": { - "description": "更新SCADA设备信息\n\n修改指定SCADA设备的属性。\n\nArgs:\n network: 管网名称(或数据库名称)\n req: 请求体,包含要更新的设备属性\n \nReturns:\n 变更集合信息", - "operationId": "patch_scada_devices", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "更新SCADA设备", - "tags": [ - "SCADA设备" - ] - }, - "post": { - "description": "添加新的SCADA设备\n\n在指定管网中添加一个新的SCADA设备。\n\nArgs:\n network: 管网名称(或数据库名称)\n req: 请求体,包含新设备的属性\n \nReturns:\n 变更集合信息", - "operationId": "post_scada_devices", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "添加SCADA设备", - "tags": [ - "SCADA设备" + "SCADA Metadata" ] } }, "/api/v1/scada-devices/detail": { "get": { - "description": "获取单个SCADA设备的信息\n\n根据设备ID查询该设备的详细信息。\n\nArgs:\n network: 管网名称(或数据库名称)\n id: SCADA设备ID\n \nReturns:\n SCADA设备信息", "operationId": "get_scada_devices_detail", "parameters": [ { - "description": "SCADA设备ID", + "description": "SCADA 设备 ID", "in": "query", - "name": "id", + "name": "device_id", "required": true, "schema": { - "description": "SCADA设备ID", - "title": "Id", + "description": "SCADA 设备 ID", + "title": "Device Id", "type": "string" } }, @@ -33333,963 +28585,9 @@ "OAuth2PasswordBearer": [] } ], - "summary": "获取SCADA设备", + "summary": "获取 SCADA 设备", "tags": [ - "SCADA设备" - ] - } - }, - "/api/v1/scada-devices/ids": { - "get": { - "description": "获取指定管网所有SCADA设备的ID列表\n\nArgs:\n network: 管网名称(或数据库名称)\n \nReturns:\n SCADA设备ID列表", - "operationId": "get_scada_devices_ids", - "parameters": [ - { - "in": "query", - "name": "limit", - "required": false, - "schema": { - "default": 100, - "maximum": 1000, - "minimum": 1, - "title": "Limit", - "type": "integer" - } - }, - { - "in": "query", - "name": "offset", - "required": false, - "schema": { - "default": 0, - "minimum": 0, - "title": "Offset", - "type": "integer" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Page_str_" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取所有SCADA设备ID", - "tags": [ - "SCADA设备" - ] - } - }, - "/api/v1/scada-element-cleaning-runs": { - "post": { - "description": "清空SCADA元素映射表\n\n删除指定管网中所有的SCADA元素映射。\n\nArgs:\n network: 管网名称(或数据库名称)\n \nReturns:\n 变更集合信息", - "operationId": "post_scada_element_cleaning_runs", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "清空SCADA元素映射表", - "tags": [ - "SCADA元素映射" - ] - } - }, - "/api/v1/scada-elements": { - "delete": { - "description": "删除SCADA元素映射\n\n移除SCADA设备与管网元素的映射关系。\n\nArgs:\n network: 管网名称(或数据库名称)\n req: 请求体,包含要删除的映射ID\n \nReturns:\n 变更集合信息", - "operationId": "delete_scada_elements", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "删除SCADA元素映射", - "tags": [ - "SCADA元素映射" - ] - }, - "get": { - "description": "获取指定管网所有SCADA元素映射\n\n查询所有SCADA设备与管网元素(节点/管道)的映射关系。\n\nArgs:\n network: 管网名称(或数据库名称)\n \nReturns:\n SCADA元素映射列表", - "operationId": "get_scada_elements", - "parameters": [ - { - "in": "query", - "name": "limit", - "required": false, - "schema": { - "default": 100, - "maximum": 1000, - "minimum": 1, - "title": "Limit", - "type": "integer" - } - }, - { - "in": "query", - "name": "offset", - "required": false, - "schema": { - "default": 0, - "minimum": 0, - "title": "Offset", - "type": "integer" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Page_dict_str__Any__" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取所有SCADA元素映射", - "tags": [ - "SCADA元素映射" - ] - }, - "patch": { - "description": "更新SCADA元素映射\n\n修改SCADA设备与管网元素的映射关系。\n\nArgs:\n network: 管网名称(或数据库名称)\n req: 请求体,包含要更新的映射信息\n \nReturns:\n 变更集合信息", - "operationId": "patch_scada_elements", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "更新SCADA元素映射", - "tags": [ - "SCADA元素映射" - ] - }, - "post": { - "description": "添加新的SCADA元素映射\n\n创建SCADA设备与管网元素的新映射关系。\n\nArgs:\n network: 管网名称(或数据库名称)\n req: 请求体,包含新映射的信息\n \nReturns:\n 变更集合信息", - "operationId": "post_scada_elements", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "添加SCADA元素映射", - "tags": [ - "SCADA元素映射" - ] - } - }, - "/api/v1/scada-elements/detail": { - "get": { - "description": "获取单个SCADA元素映射的信息\n\n根据ID查询特定的SCADA设备与管网元素的映射关系。\n\nArgs:\n network: 管网名称(或数据库名称)\n id: SCADA元素映射ID\n \nReturns:\n SCADA元素映射信息", - "operationId": "get_scada_elements_detail", - "parameters": [ - { - "description": "SCADA元素映射ID", - "in": "query", - "name": "id", - "required": true, - "schema": { - "description": "SCADA元素映射ID", - "title": "Id", - "type": "string" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "title": "Response Get Scada Elements Detail", - "type": "object" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取单个SCADA元素映射", - "tags": [ - "SCADA元素映射" - ] - } - }, - "/api/v1/scada-info": { - "get": { - "description": "获取指定管网所有SCADA的信息\n\n查询该管网下所有已配置的SCADA的完整信息。\n\nArgs:\n network: 管网名称(或数据库名称)\n \nReturns:\n SCADA信息列表", - "operationId": "get_scada_info", - "parameters": [ - { - "in": "query", - "name": "limit", - "required": false, - "schema": { - "default": 100, - "maximum": 1000, - "minimum": 1, - "title": "Limit", - "type": "integer" - } - }, - { - "in": "query", - "name": "offset", - "required": false, - "schema": { - "default": 0, - "minimum": 0, - "title": "Offset", - "type": "integer" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Page_dict_str__Any__" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取所有SCADA信息", - "tags": [ - "SCADA信息" - ] - } - }, - "/api/v1/scada-info-schemas": { - "get": { - "description": "获取SCADA信息表的结构\n\n返回SCADA信息表的字段定义和类型信息。\n\nArgs:\n network: 管网名称(或数据库名称)\n \nReturns:\n SCADA信息的字段架构信息", - "operationId": "get_scada_info_schemas", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "additionalProperties": { - "type": "object" - }, - "title": "Response Get Scada Info Schemas", - "type": "object" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取SCADA信息架构", - "tags": [ - "SCADA信息" + "SCADA Metadata" ] } }, @@ -34391,116 +28689,6 @@ ] } }, - "/api/v1/scada-info/detail": { - "get": { - "description": "获取单个SCADA信息\n\n根据ID查询SCADA的详细配置信息。\n\nArgs:\n network: 管网名称(或数据库名称)\n id: SCADA信息ID\n \nReturns:\n SCADA信息详情", - "operationId": "get_scada_info_detail", - "parameters": [ - { - "description": "SCADA信息ID", - "in": "query", - "name": "id", - "required": true, - "schema": { - "description": "SCADA信息ID", - "title": "Id", - "type": "string" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "title": "Response Get Scada Info Detail", - "type": "object" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取SCADA信息", - "tags": [ - "SCADA信息" - ] - } - }, "/api/v1/scada-properties": { "get": { "description": "获取指定SCADA点的属性信息", @@ -34721,500 +28909,6 @@ ] } }, - "/api/v1/schemes": { - "get": { - "description": "获取指定网络的所有方案信息", - "operationId": "get_schemes", - "parameters": [ - { - "description": "方案类型;为空时返回全部类型", - "in": "query", - "name": "scheme_type", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "方案类型;为空时返回全部类型", - "title": "Scheme Type" - } - }, - { - "description": "查询日期(可选)", - "in": "query", - "name": "query_date", - "required": false, - "schema": { - "anyOf": [ - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "查询日期(可选)", - "title": "Query Date" - } - }, - { - "in": "query", - "name": "limit", - "required": false, - "schema": { - "default": 100, - "maximum": 1000, - "minimum": 1, - "title": "Limit", - "type": "integer" - } - }, - { - "in": "query", - "name": "offset", - "required": false, - "schema": { - "default": 0, - "minimum": 0, - "title": "Offset", - "type": "integer" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Page_dict_Any__Any__" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取所有方案", - "tags": [ - "Schemes" - ] - } - }, - "/api/v1/schemes/detail": { - "get": { - "description": "根据名称获取指定的方案信息", - "operationId": "get_schemes_detail", - "parameters": [ - { - "description": "方案名称", - "in": "query", - "name": "schema_name", - "required": true, - "schema": { - "description": "方案名称", - "title": "Schema Name", - "type": "string" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "title": "Response Get Schemes Detail", - "type": "object" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取单个方案", - "tags": [ - "Schemes" - ] - } - }, - "/api/v1/schemes/list-with-connection": { - "get": { - "description": "使用连接池查询所有方案信息", - "operationId": "get_schemes_list_with_connection", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取方案列表", - "tags": [ - "Project Data" - ] - } - }, - "/api/v1/schemes/{scheme_name}": { - "get": { - "description": "按方案类型获取指定方案详情", - "operationId": "get_schemes_scheme_name", - "parameters": [ - { - "description": "方案名称", - "in": "path", - "name": "scheme_name", - "required": true, - "schema": { - "description": "方案名称", - "title": "Scheme Name", - "type": "string" - } - }, - { - "description": "方案类型;为空时返回通用方案详情", - "in": "query", - "name": "scheme_type", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "方案类型;为空时返回通用方案详情", - "title": "Scheme Type" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "title": "Response Get Schemes Scheme Name", - "type": "object" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取方案详情", - "tags": [ - "Schemes" - ] - } - }, "/api/v1/sensor-placement-candidates/{node_id}": { "get": { "operationId": "get_sensor_placement_candidates_node_id", @@ -35323,9 +29017,127 @@ ] } }, - "/api/v1/sensor-placement-optimization-runs": { + "/api/v1/sensor-placement-runs": { + "get": { + "operationId": "get_sensor_placement_runs", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_SensorPlacementSchemeResponse_" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取监测点优化运行", + "tags": [ + "Sensor Placement" + ] + }, "post": { - "operationId": "post_sensor_placement_optimization_runs", + "operationId": "post_sensor_placement_runs", "parameters": [ { "in": "header", @@ -35430,291 +29242,18 @@ ] } }, - "/api/v1/sensor-placement-schemes": { + "/api/v1/sensor-placement-runs/{run_id}": { "get": { - "description": "获取网络中所有传感器的放置位置信息", - "operationId": "get_sensor_placement_schemes", - "parameters": [ - { - "in": "query", - "name": "limit", - "required": false, - "schema": { - "default": 100, - "maximum": 1000, - "minimum": 1, - "title": "Limit", - "type": "integer" - } - }, - { - "in": "query", - "name": "offset", - "required": false, - "schema": { - "default": 0, - "minimum": 0, - "title": "Offset", - "type": "integer" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Page_dict_Any__Any__" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取所有传感器位置", - "tags": [ - "Misc" - ] - }, - "post": { - "description": "创建新的传感器放置方案,支持灵敏度分析和KMeans聚类两种方法。根据指定的方法自动计算最优的传感器放置位置。", - "operationId": "post_sensor_placement_schemes", - "parameters": [ - { - "description": "放置方案名称", - "in": "query", - "name": "scheme_name", - "required": true, - "schema": { - "description": "放置方案名称", - "title": "Scheme Name", - "type": "string" - } - }, - { - "description": "传感器类型", - "in": "query", - "name": "sensor_type", - "required": true, - "schema": { - "description": "传感器类型", - "title": "Sensor Type", - "type": "string" - } - }, - { - "description": "放置方法('sensitivity'或'kmeans')", - "in": "query", - "name": "method", - "required": true, - "schema": { - "description": "放置方法('sensitivity'或'kmeans')", - "title": "Method", - "type": "string" - } - }, - { - "description": "传感器数量", - "in": "query", - "name": "sensor_count", - "required": true, - "schema": { - "description": "传感器数量", - "title": "Sensor Count", - "type": "integer" - } - }, - { - "description": "最小管径限制(毫米),默认0", - "in": "query", - "name": "min_diameter", - "required": false, - "schema": { - "default": 0, - "description": "最小管径限制(毫米),默认0", - "title": "Min Diameter", - "type": "integer" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "title": "Response Post Sensor Placement Schemes", - "type": "string" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "传感器放置方案创建", - "tags": [ - "Simulation Control" - ] - } - }, - "/api/v1/sensor-placement-schemes/{scheme_id}": { - "get": { - "operationId": "get_sensor_placement_schemes_scheme_id", + "operationId": "get_sensor_placement_runs_run_id", "parameters": [ { "in": "path", - "name": "scheme_id", + "name": "run_id", "required": true, "schema": { - "title": "Scheme Id", - "type": "integer" + "format": "uuid", + "title": "Run Id", + "type": "string" } }, { @@ -35810,15 +29349,16 @@ ] }, "put": { - "operationId": "put_sensor_placement_schemes_scheme_id", + "operationId": "put_sensor_placement_runs_run_id", "parameters": [ { "in": "path", - "name": "scheme_id", + "name": "run_id", "required": true, "schema": { - "title": "Scheme Id", - "type": "integer" + "format": "uuid", + "title": "Run Id", + "type": "string" } }, { @@ -35924,17 +29464,18 @@ ] } }, - "/api/v1/sensor-placement-schemes/{scheme_id}/exports/excel": { + "/api/v1/sensor-placement-runs/{run_id}/exports/excel": { "post": { - "operationId": "post_sensor_placement_schemes_scheme_id_exports_excel", + "operationId": "post_sensor_placement_runs_run_id_exports_excel", "parameters": [ { "in": "path", - "name": "scheme_id", + "name": "run_id", "required": true, "schema": { - "title": "Scheme Id", - "type": "integer" + "format": "uuid", + "title": "Run Id", + "type": "string" } }, { @@ -36040,748 +29581,6 @@ ] } }, - "/api/v1/service-area-calculations": { - "post": { - "description": "计算指定水网的服务区分区,返回全部时间步结果", - "operationId": "post_service_area_calculations", - "parameters": [ - { - "in": "query", - "name": "limit", - "required": false, - "schema": { - "default": 100, - "maximum": 1000, - "minimum": 1, - "title": "Limit", - "type": "integer" - } - }, - { - "in": "query", - "name": "offset", - "required": false, - "schema": { - "default": 0, - "minimum": 0, - "title": "Offset", - "type": "integer" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Page_dict_str__list_str___" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "计算服务区", - "tags": [ - "Regions & DMAs" - ] - } - }, - "/api/v1/service-area-generation-runs": { - "post": { - "description": "根据参数自动生成水网的服务区分区", - "operationId": "post_service_area_generation_runs", - "parameters": [ - { - "description": "膨胀参数", - "in": "query", - "name": "inflate_delta", - "required": true, - "schema": { - "description": "膨胀参数", - "title": "Inflate Delta", - "type": "number" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "生成服务区分区", - "tags": [ - "Regions & DMAs" - ] - } - }, - "/api/v1/service-areas": { - "delete": { - "description": "删除指定的服务区", - "operationId": "delete_service_areas", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "删除服务区", - "tags": [ - "Regions & DMAs" - ] - }, - "get": { - "description": "获取指定水网中的所有服务区信息", - "operationId": "get_service_areas", - "parameters": [ - { - "in": "query", - "name": "limit", - "required": false, - "schema": { - "default": 100, - "maximum": 1000, - "minimum": 1, - "title": "Limit", - "type": "integer" - } - }, - { - "in": "query", - "name": "offset", - "required": false, - "schema": { - "default": 0, - "minimum": 0, - "title": "Offset", - "type": "integer" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Page_dict_str__Any__" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取所有服务区", - "tags": [ - "Regions & DMAs" - ] - }, - "patch": { - "description": "修改指定服务区的属性信息", - "operationId": "patch_service_areas", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "设置服务区属性", - "tags": [ - "Regions & DMAs" - ] - }, - "post": { - "description": "向水网添加一个新的服务区", - "operationId": "post_service_areas", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "添加新服务区", - "tags": [ - "Regions & DMAs" - ] - } - }, - "/api/v1/service-areas/detail": { - "get": { - "description": "获取指定ID的服务区详细信息", - "operationId": "get_service_areas_detail", - "parameters": [ - { - "description": "服务区ID", - "in": "query", - "name": "id", - "required": true, - "schema": { - "description": "服务区ID", - "title": "Id", - "type": "string" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "title": "Response Get Service Areas Detail", - "type": "object" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取服务区信息", - "tags": [ - "Regions & DMAs" - ] - } - }, "/api/v1/simulation-runs": { "post": { "description": "根据指定的开始时间和持续时间,手动运行水力模拟。开始时间必须是显式带时区的 ISO 8601 / RFC3339 时间。", @@ -36895,897 +29694,6 @@ ] } }, - "/api/v1/snapshot-for-current-operations": { - "get": { - "description": "检查当前操作的快照是否存在", - "operationId": "get_snapshot_for_current_operations", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "title": "Response Get Snapshot For Current Operations", - "type": "boolean" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "检查当前操作快照是否存在", - "tags": [ - "Snapshots" - ] - }, - "post": { - "description": "为当前操作创建快照", - "operationId": "post_snapshot_for_current_operations", - "parameters": [ - { - "description": "快照标签", - "in": "query", - "name": "tag", - "required": true, - "schema": { - "description": "快照标签", - "title": "Tag", - "type": "string" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "为当前操作创建快照", - "tags": [ - "Snapshots" - ] - } - }, - "/api/v1/snapshot-for-operations": { - "get": { - "description": "检查指定操作ID的快照是否存在", - "operationId": "get_snapshot_for_operations", - "parameters": [ - { - "description": "操作ID", - "in": "query", - "name": "operation", - "required": true, - "schema": { - "description": "操作ID", - "title": "Operation", - "type": "integer" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "title": "Response Get Snapshot For Operations", - "type": "boolean" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "检查操作快照是否存在", - "tags": [ - "Snapshots" - ] - }, - "post": { - "description": "为指定的操作创建快照", - "operationId": "post_snapshot_for_operations", - "parameters": [ - { - "description": "操作ID", - "in": "query", - "name": "operation", - "required": true, - "schema": { - "description": "操作ID", - "title": "Operation", - "type": "integer" - } - }, - { - "description": "快照标签", - "in": "query", - "name": "tag", - "required": true, - "schema": { - "description": "快照标签", - "title": "Tag", - "type": "string" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "为操作创建快照", - "tags": [ - "Snapshots" - ] - } - }, - "/api/v1/snapshots": { - "get": { - "description": "获取网络中的所有快照", - "operationId": "get_snapshots", - "parameters": [ - { - "in": "query", - "name": "limit", - "required": false, - "schema": { - "default": 100, - "maximum": 1000, - "minimum": 1, - "title": "Limit", - "type": "integer" - } - }, - { - "in": "query", - "name": "offset", - "required": false, - "schema": { - "default": 0, - "minimum": 0, - "title": "Offset", - "type": "integer" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Page_tuple_int__str__" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取快照列表", - "tags": [ - "Snapshots" - ] - }, - "patch": { - "description": "选择并恢复到指定的快照", - "operationId": "patch_snapshots", - "parameters": [ - { - "description": "快照标签", - "in": "query", - "name": "tag", - "required": true, - "schema": { - "description": "快照标签", - "title": "Tag", - "type": "string" - } - }, - { - "description": "是否丢弃当前更改", - "in": "query", - "name": "discard", - "required": false, - "schema": { - "default": false, - "description": "是否丢弃当前更改", - "title": "Discard", - "type": "boolean" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "选择快照", - "tags": [ - "Snapshots" - ] - }, - "post": { - "description": "为网络创建一个快照", - "operationId": "post_snapshots", - "parameters": [ - { - "description": "快照标签", - "in": "query", - "name": "tag", - "required": true, - "schema": { - "description": "快照标签", - "title": "Tag", - "type": "string" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "创建快照", - "tags": [ - "Snapshots" - ] - } - }, - "/api/v1/snapshots/existence": { - "get": { - "description": "检查指定标签的快照是否存在", - "operationId": "get_snapshots_existence", - "parameters": [ - { - "description": "快照标签", - "in": "query", - "name": "tag", - "required": true, - "schema": { - "description": "快照标签", - "title": "Tag", - "type": "string" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "title": "Response Get Snapshots Existence", - "type": "boolean" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "检查快照是否存在", - "tags": [ - "Snapshots" - ] - } - }, "/api/v1/sources": { "delete": { "description": "从网络中删除指定节点的水源", @@ -38511,149 +30419,6 @@ ] } }, - "/api/v1/sub-district-metering-areas": { - "post": { - "description": "为指定DMA生成子DMA分区", - "operationId": "post_sub_district_metering_areas", - "parameters": [ - { - "description": "DMA ID", - "in": "query", - "name": "dma", - "required": true, - "schema": { - "description": "DMA ID", - "title": "Dma", - "type": "string" - } - }, - { - "description": "分区数量", - "in": "query", - "name": "part_count", - "required": true, - "schema": { - "description": "分区数量", - "exclusiveMinimum": 0, - "title": "Part Count", - "type": "integer" - } - }, - { - "description": "分区类型", - "in": "query", - "name": "part_type", - "required": true, - "schema": { - "description": "分区类型", - "title": "Part Type", - "type": "integer" - } - }, - { - "description": "膨胀参数", - "in": "query", - "name": "inflate_delta", - "required": true, - "schema": { - "description": "膨胀参数", - "title": "Inflate Delta", - "type": "number" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "生成DMA子分区", - "tags": [ - "Regions & DMAs" - ] - } - }, "/api/v1/tags": { "get": { "description": "获取指定水网中的所有标签信息", @@ -42624,6 +34389,552 @@ ] } }, + "/api/v1/timeseries/analysis/runs/{run_id}/links/{link_id}": { + "get": { + "operationId": "get_timeseries_analysis_runs_run_id_links_link_id", + "parameters": [ + { + "in": "path", + "name": "run_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Run Id", + "type": "string" + } + }, + { + "in": "path", + "name": "link_id", + "required": true, + "schema": { + "title": "Link Id", + "type": "string" + } + }, + { + "in": "query", + "name": "start_time", + "required": true, + "schema": { + "format": "date-time", + "title": "Start Time", + "type": "string" + } + }, + { + "in": "query", + "name": "end_time", + "required": true, + "schema": { + "format": "date-time", + "title": "End Time", + "type": "string" + } + }, + { + "in": "query", + "name": "field", + "required": true, + "schema": { + "title": "Field", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Get Analysis Link Series", + "tags": [ + "TimescaleDB - Analysis" + ] + } + }, + "/api/v1/timeseries/analysis/runs/{run_id}/nodes/{node_id}": { + "get": { + "operationId": "get_timeseries_analysis_runs_run_id_nodes_node_id", + "parameters": [ + { + "in": "path", + "name": "run_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Run Id", + "type": "string" + } + }, + { + "in": "path", + "name": "node_id", + "required": true, + "schema": { + "title": "Node Id", + "type": "string" + } + }, + { + "in": "query", + "name": "start_time", + "required": true, + "schema": { + "format": "date-time", + "title": "Start Time", + "type": "string" + } + }, + { + "in": "query", + "name": "end_time", + "required": true, + "schema": { + "format": "date-time", + "title": "End Time", + "type": "string" + } + }, + { + "in": "query", + "name": "field", + "required": true, + "schema": { + "title": "Field", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Get Analysis Node Series", + "tags": [ + "TimescaleDB - Analysis" + ] + } + }, + "/api/v1/timeseries/analysis/runs/{run_id}/results": { + "post": { + "operationId": "post_timeseries_analysis_runs_run_id_results", + "parameters": [ + { + "description": "分析运行 ID", + "in": "path", + "name": "run_id", + "required": true, + "schema": { + "description": "分析运行 ID", + "format": "uuid", + "title": "Run Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "title": "Payload", + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Store Analysis Results", + "tags": [ + "TimescaleDB - Analysis" + ] + } + }, + "/api/v1/timeseries/analysis/runs/{run_id}/values": { + "get": { + "operationId": "get_timeseries_analysis_runs_run_id_values", + "parameters": [ + { + "in": "path", + "name": "run_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Run Id", + "type": "string" + } + }, + { + "in": "query", + "name": "result_time", + "required": true, + "schema": { + "format": "date-time", + "title": "Result Time", + "type": "string" + } + }, + { + "in": "query", + "name": "element_type", + "required": true, + "schema": { + "pattern": "^(node|link)$", + "title": "Element Type", + "type": "string" + } + }, + { + "in": "query", + "name": "field", + "required": true, + "schema": { + "title": "Field", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Get Analysis Values At Time", + "tags": [ + "TimescaleDB - Analysis" + ] + } + }, "/api/v1/timeseries/realtime/links": { "delete": { "description": "按时间范围删除实时管道数据。start_time 和 end_time 必须显式带时区;允许传 UTC+8,服务端按请求中的绝对时间删除对应 UTC 数据。", @@ -44632,1751 +36943,6 @@ ] } }, - "/api/v1/timeseries/schemes/links": { - "delete": { - "description": "删除指定方案和时间范围内的管道数据\n\n删除在指定方案和时间范围内的所有管道模拟数据。\n\nArgs:\n scheme_type: 方案类型\n scheme_name: 方案名称\n start_time: 删除开始时间\n end_time: 删除结束时间\n\nReturns:\n 删除结果信息", - "operationId": "delete_timeseries_schemes_links", - "parameters": [ - { - "description": "方案类型", - "in": "query", - "name": "scheme_type", - "required": true, - "schema": { - "description": "方案类型", - "title": "Scheme Type", - "type": "string" - } - }, - { - "description": "方案名称", - "in": "query", - "name": "scheme_name", - "required": true, - "schema": { - "description": "方案名称", - "title": "Scheme Name", - "type": "string" - } - }, - { - "description": "删除开始时间", - "in": "query", - "name": "start_time", - "required": true, - "schema": { - "description": "删除开始时间", - "format": "date-time", - "title": "Start Time", - "type": "string" - } - }, - { - "description": "删除结束时间", - "in": "query", - "name": "end_time", - "required": true, - "schema": { - "description": "删除结束时间", - "format": "date-time", - "title": "End Time", - "type": "string" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "删除方案管道数据", - "tags": [ - "TimescaleDB - Scheme" - ] - }, - "get": { - "description": "查询指定方案和时间范围内的管道数据\n\n根据方案和时间范围查询管道的模拟值。\n\nArgs:\n scheme_type: 方案类型\n scheme_name: 方案名称\n start_time: 查询开始时间\n end_time: 查询结束时间\n\nReturns:\n 方案管道数据列表", - "operationId": "get_timeseries_schemes_links", - "parameters": [ - { - "description": "方案类型", - "in": "query", - "name": "scheme_type", - "required": true, - "schema": { - "description": "方案类型", - "title": "Scheme Type", - "type": "string" - } - }, - { - "description": "方案名称", - "in": "query", - "name": "scheme_name", - "required": true, - "schema": { - "description": "方案名称", - "title": "Scheme Name", - "type": "string" - } - }, - { - "description": "查询开始时间", - "in": "query", - "name": "start_time", - "required": true, - "schema": { - "description": "查询开始时间", - "format": "date-time", - "title": "Start Time", - "type": "string" - } - }, - { - "description": "查询结束时间", - "in": "query", - "name": "end_time", - "required": true, - "schema": { - "description": "查询结束时间", - "format": "date-time", - "title": "End Time", - "type": "string" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "查询方案管道数据", - "tags": [ - "TimescaleDB - Scheme" - ] - } - }, - "/api/v1/timeseries/schemes/links/batches": { - "post": { - "description": "批量插入方案管道数据\n\n将特定方案的管道模拟数据批量插入时间序列数据库。\n\nArgs:\n data: 方案管道数据列表\n\nReturns:\n 插入成功的记录数", - "operationId": "post_timeseries_schemes_links_batches", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "description": "方案管道数据列表", - "items": { - "type": "object" - }, - "title": "Data", - "type": "array" - } - } - }, - "required": true - }, - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "批量插入方案管道数据", - "tags": [ - "TimescaleDB - Scheme" - ] - } - }, - "/api/v1/timeseries/schemes/links/{link_id}/field": { - "get": { - "description": "查询指定方案管道的特定字段数据\n\n查询特定方案中指定管道在时间范围内的特定字段值。\n\nArgs:\n link_id: 管道ID\n scheme_type: 方案类型\n scheme_name: 方案名称\n start_time: 查询开始时间\n end_time: 查询结束时间\n field: 字段名称\n\nReturns:\n 字段数据列表\n\nRaises:\n HTTPException: 当查询参数无效时返回400错误", - "operationId": "get_timeseries_schemes_links_link_id_field", - "parameters": [ - { - "description": "管道ID", - "in": "path", - "name": "link_id", - "required": true, - "schema": { - "description": "管道ID", - "title": "Link Id", - "type": "string" - } - }, - { - "description": "方案类型", - "in": "query", - "name": "scheme_type", - "required": true, - "schema": { - "description": "方案类型", - "title": "Scheme Type", - "type": "string" - } - }, - { - "description": "方案名称", - "in": "query", - "name": "scheme_name", - "required": true, - "schema": { - "description": "方案名称", - "title": "Scheme Name", - "type": "string" - } - }, - { - "description": "查询开始时间", - "in": "query", - "name": "start_time", - "required": true, - "schema": { - "description": "查询开始时间", - "format": "date-time", - "title": "Start Time", - "type": "string" - } - }, - { - "description": "查询结束时间", - "in": "query", - "name": "end_time", - "required": true, - "schema": { - "description": "查询结束时间", - "format": "date-time", - "title": "End Time", - "type": "string" - } - }, - { - "description": "要查询的字段名称", - "in": "query", - "name": "field", - "required": true, - "schema": { - "description": "要查询的字段名称", - "title": "Field", - "type": "string" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "查询方案管道字段数据", - "tags": [ - "TimescaleDB - Scheme" - ] - }, - "patch": { - "description": "更新指定方案管道的字段值\n\n更新特定方案中指定管道在某个时间的字段数据。\n\nArgs:\n link_id: 管道ID\n scheme_type: 方案类型\n scheme_name: 方案名称\n time: 数据时间戳\n field: 字段名称\n value: 字段新值\n\nReturns:\n 更新结果信息\n\nRaises:\n HTTPException: 当字段不存在或更新失败时返回400错误", - "operationId": "patch_timeseries_schemes_links_link_id_field", - "parameters": [ - { - "description": "管道ID", - "in": "path", - "name": "link_id", - "required": true, - "schema": { - "description": "管道ID", - "title": "Link Id", - "type": "string" - } - }, - { - "description": "方案类型", - "in": "query", - "name": "scheme_type", - "required": true, - "schema": { - "description": "方案类型", - "title": "Scheme Type", - "type": "string" - } - }, - { - "description": "方案名称", - "in": "query", - "name": "scheme_name", - "required": true, - "schema": { - "description": "方案名称", - "title": "Scheme Name", - "type": "string" - } - }, - { - "description": "更新数据的时间戳", - "in": "query", - "name": "time", - "required": true, - "schema": { - "description": "更新数据的时间戳", - "format": "date-time", - "title": "Time", - "type": "string" - } - }, - { - "description": "要更新的字段名称", - "in": "query", - "name": "field", - "required": true, - "schema": { - "description": "要更新的字段名称", - "title": "Field", - "type": "string" - } - }, - { - "description": "更新的字段值", - "in": "query", - "name": "value", - "required": true, - "schema": { - "description": "更新的字段值", - "title": "Value", - "type": "number" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "更新方案管道字段", - "tags": [ - "TimescaleDB - Scheme" - ] - } - }, - "/api/v1/timeseries/schemes/nodes": { - "delete": { - "description": "删除指定方案和时间范围内的节点数据\n\n删除在指定方案和时间范围内的所有节点模拟数据。\n\nArgs:\n scheme_type: 方案类型\n scheme_name: 方案名称\n start_time: 删除开始时间\n end_time: 删除结束时间\n\nReturns:\n 删除结果信息", - "operationId": "delete_timeseries_schemes_nodes", - "parameters": [ - { - "description": "方案类型", - "in": "query", - "name": "scheme_type", - "required": true, - "schema": { - "description": "方案类型", - "title": "Scheme Type", - "type": "string" - } - }, - { - "description": "方案名称", - "in": "query", - "name": "scheme_name", - "required": true, - "schema": { - "description": "方案名称", - "title": "Scheme Name", - "type": "string" - } - }, - { - "description": "删除开始时间", - "in": "query", - "name": "start_time", - "required": true, - "schema": { - "description": "删除开始时间", - "format": "date-time", - "title": "Start Time", - "type": "string" - } - }, - { - "description": "删除结束时间", - "in": "query", - "name": "end_time", - "required": true, - "schema": { - "description": "删除结束时间", - "format": "date-time", - "title": "End Time", - "type": "string" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "删除方案节点数据", - "tags": [ - "TimescaleDB - Scheme" - ] - } - }, - "/api/v1/timeseries/schemes/nodes/batches": { - "post": { - "description": "批量插入方案节点数据\n\n将特定方案的节点模拟数据批量插入时间序列数据库。\n\nArgs:\n data: 方案节点数据列表\n\nReturns:\n 插入成功的记录数", - "operationId": "post_timeseries_schemes_nodes_batches", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "description": "方案节点数据列表", - "items": { - "type": "object" - }, - "title": "Data", - "type": "array" - } - } - }, - "required": true - }, - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "批量插入方案节点数据", - "tags": [ - "TimescaleDB - Scheme" - ] - } - }, - "/api/v1/timeseries/schemes/nodes/{node_id}/field": { - "get": { - "description": "查询指定方案节点的特定字段数据\n\n查询特定方案中指定节点在时间范围内的特定字段值。\n\nArgs:\n node_id: 节点ID\n scheme_type: 方案类型\n scheme_name: 方案名称\n start_time: 查询开始时间\n end_time: 查询结束时间\n field: 字段名称\n\nReturns:\n 字段数据列表\n\nRaises:\n HTTPException: 当查询参数无效时返回400错误", - "operationId": "get_timeseries_schemes_nodes_node_id_field", - "parameters": [ - { - "description": "节点ID", - "in": "path", - "name": "node_id", - "required": true, - "schema": { - "description": "节点ID", - "title": "Node Id", - "type": "string" - } - }, - { - "description": "方案类型", - "in": "query", - "name": "scheme_type", - "required": true, - "schema": { - "description": "方案类型", - "title": "Scheme Type", - "type": "string" - } - }, - { - "description": "方案名称", - "in": "query", - "name": "scheme_name", - "required": true, - "schema": { - "description": "方案名称", - "title": "Scheme Name", - "type": "string" - } - }, - { - "description": "查询开始时间", - "in": "query", - "name": "start_time", - "required": true, - "schema": { - "description": "查询开始时间", - "format": "date-time", - "title": "Start Time", - "type": "string" - } - }, - { - "description": "查询结束时间", - "in": "query", - "name": "end_time", - "required": true, - "schema": { - "description": "查询结束时间", - "format": "date-time", - "title": "End Time", - "type": "string" - } - }, - { - "description": "要查询的字段名称", - "in": "query", - "name": "field", - "required": true, - "schema": { - "description": "要查询的字段名称", - "title": "Field", - "type": "string" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "查询方案节点字段数据", - "tags": [ - "TimescaleDB - Scheme" - ] - }, - "patch": { - "description": "更新指定方案节点的字段值\n\n更新特定方案中指定节点在某个时间的字段数据。\n\nArgs:\n node_id: 节点ID\n scheme_type: 方案类型\n scheme_name: 方案名称\n time: 数据时间戳\n field: 字段名称\n value: 字段新值\n\nReturns:\n 更新结果信息\n\nRaises:\n HTTPException: 当字段不存在或更新失败时返回400错误", - "operationId": "patch_timeseries_schemes_nodes_node_id_field", - "parameters": [ - { - "description": "节点ID", - "in": "path", - "name": "node_id", - "required": true, - "schema": { - "description": "节点ID", - "title": "Node Id", - "type": "string" - } - }, - { - "description": "方案类型", - "in": "query", - "name": "scheme_type", - "required": true, - "schema": { - "description": "方案类型", - "title": "Scheme Type", - "type": "string" - } - }, - { - "description": "方案名称", - "in": "query", - "name": "scheme_name", - "required": true, - "schema": { - "description": "方案名称", - "title": "Scheme Name", - "type": "string" - } - }, - { - "description": "更新数据的时间戳", - "in": "query", - "name": "time", - "required": true, - "schema": { - "description": "更新数据的时间戳", - "format": "date-time", - "title": "Time", - "type": "string" - } - }, - { - "description": "要更新的字段名称", - "in": "query", - "name": "field", - "required": true, - "schema": { - "description": "要更新的字段名称", - "title": "Field", - "type": "string" - } - }, - { - "description": "更新的字段值", - "in": "query", - "name": "value", - "required": true, - "schema": { - "description": "更新的字段值", - "title": "Value", - "type": "number" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "更新方案节点字段", - "tags": [ - "TimescaleDB - Scheme" - ] - } - }, - "/api/v1/timeseries/schemes/records": { - "get": { - "description": "按指定方案、时间和属性查询所有方案数据\n\n查询在特定方案和时间点,所有指定类型元素的特定属性值。\n\nArgs:\n scheme_type: 方案类型\n scheme_name: 方案名称\n query_time: 查询时间\n type: 元素类型(pipe或junction)\n property: 属性名称\n\nReturns:\n 查询结果列表\n\nRaises:\n HTTPException: 当查询参数无效时返回400错误", - "operationId": "get_timeseries_schemes_records", - "parameters": [ - { - "description": "方案类型", - "in": "query", - "name": "scheme_type", - "required": true, - "schema": { - "description": "方案类型", - "title": "Scheme Type", - "type": "string" - } - }, - { - "description": "方案名称", - "in": "query", - "name": "scheme_name", - "required": true, - "schema": { - "description": "方案名称", - "title": "Scheme Name", - "type": "string" - } - }, - { - "description": "查询时间", - "in": "query", - "name": "query_time", - "required": true, - "schema": { - "description": "查询时间", - "title": "Query Time", - "type": "string" - } - }, - { - "description": "元素类型,pipe(管道)或 junction(节点)", - "in": "query", - "name": "type", - "required": true, - "schema": { - "description": "元素类型,pipe(管道)或 junction(节点)", - "title": "Type", - "type": "string" - } - }, - { - "description": "要查询的属性名称", - "in": "query", - "name": "property", - "required": true, - "schema": { - "description": "要查询的属性名称", - "title": "Property", - "type": "string" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "按方案、时间和属性查询数据", - "tags": [ - "TimescaleDB - Scheme" - ] - } - }, - "/api/v1/timeseries/schemes/simulation-results": { - "get": { - "description": "按指定ID和时间查询方案模拟结果\n\n查询特定方案中的元素在某一时间点的模拟数据。\n\nArgs:\n scheme_type: 方案类型\n scheme_name: 方案名称\n id: 元素ID\n type: 元素类型(pipe或junction)\n query_time: 查询时间\n\nReturns:\n 模拟结果数据\n\nRaises:\n HTTPException: 当查询参数无效时返回400错误", - "operationId": "get_timeseries_schemes_simulation_results", - "parameters": [ - { - "description": "方案类型", - "in": "query", - "name": "scheme_type", - "required": true, - "schema": { - "description": "方案类型", - "title": "Scheme Type", - "type": "string" - } - }, - { - "description": "方案名称", - "in": "query", - "name": "scheme_name", - "required": true, - "schema": { - "description": "方案名称", - "title": "Scheme Name", - "type": "string" - } - }, - { - "description": "元素ID(管道ID或节点ID)", - "in": "query", - "name": "id", - "required": true, - "schema": { - "description": "元素ID(管道ID或节点ID)", - "title": "Id", - "type": "string" - } - }, - { - "description": "元素类型,pipe(管道)或 junction(节点)", - "in": "query", - "name": "type", - "required": true, - "schema": { - "description": "元素类型,pipe(管道)或 junction(节点)", - "title": "Type", - "type": "string" - } - }, - { - "description": "查询时间", - "in": "query", - "name": "query_time", - "required": true, - "schema": { - "description": "查询时间", - "title": "Query Time", - "type": "string" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "按ID和时间查询方案模拟数据", - "tags": [ - "TimescaleDB - Scheme" - ] - }, - "post": { - "description": "存储方案模拟结果到时间序列数据库\n\n将特定方案的节点和管道模拟计算结果批量存储到TimescaleDB数据库。\n\nArgs:\n scheme_type: 方案类型\n scheme_name: 方案名称\n node_result_list: 节点模拟结果列表\n link_result_list: 管道模拟结果列表\n result_start_time: 模拟结果对应的起始时间\n\nReturns:\n 存储结果信息", - "operationId": "post_timeseries_schemes_simulation_results", - "parameters": [ - { - "description": "方案类型", - "in": "query", - "name": "scheme_type", - "required": true, - "schema": { - "description": "方案类型", - "title": "Scheme Type", - "type": "string" - } - }, - { - "description": "方案名称", - "in": "query", - "name": "scheme_name", - "required": true, - "schema": { - "description": "方案名称", - "title": "Scheme Name", - "type": "string" - } - }, - { - "description": "模拟结果开始时间", - "in": "query", - "name": "result_start_time", - "required": true, - "schema": { - "description": "模拟结果开始时间", - "title": "Result Start Time", - "type": "string" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Body_post_timeseries_schemes_simulation_results" - } - } - }, - "required": true - }, - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "存储方案模拟结果", - "tags": [ - "TimescaleDB - Scheme" - ] - } - }, "/api/v1/timeseries/views/element-scada-readings": { "get": { "description": "获取link/node关联的SCADA监测值\n\n根据传入的link/node id,匹配SCADA信息,\n如果存在关联的SCADA device_id,获取实际的监测数据。\n\nArgs:\n element_id: 管网元素ID\n start_time: 查询开始时间\n end_time: 查询结束时间\n use_cleaned: 是否使用清洗后的数据,默认为False使用原始数据\n timescale_conn: TimescaleDB连接\n postgres_conn: PostgreSQL连接\n \nReturns:\n 管网元素关联的SCADA监测数据\n \nRaises:\n HTTPException: 当查询参数无效时返回400错误,未找到关联数据返回404错误", @@ -46524,7 +37090,7 @@ }, "/api/v1/timeseries/views/element-simulations": { "get": { - "description": "获取link/node模拟值\n\n根据传入的featureInfos,找到关联的link/node,\n并根据对应的type,查询对应的模拟数据。支持查询实时或方案数据。\n\nArgs:\n start_time: 查询开始时间\n end_time: 查询结束时间\n feature_infos: 格式为 \"element_id1:type1,element_id2:type2\"\n 例如: \"P1:pipe,J1:junction\"\n scheme_type: 方案类型,若为空则查询实时数据\n scheme_name: 方案名称,若为空则查询实时数据\n timescale_conn: TimescaleDB连接\n \nReturns:\n 管网元素的模拟数据\n \nRaises:\n HTTPException: 当feature_infos为空返回400错误,未找到数据返回404错误,其他错误返回400错误", + "description": "获取link/node模拟值\n\n根据传入的featureInfos,找到关联的link/node,\n并根据对应的type,查询对应的模拟数据。支持查询实时或分析运行数据。\n\nArgs:\n start_time: 查询开始时间\n end_time: 查询结束时间\n feature_infos: 格式为 \"element_id1:type1,element_id2:type2\"\n 例如: \"P1:pipe,J1:junction\"\n run_id: 分析运行 ID,若为空则查询实时数据\n timescale_conn: TimescaleDB连接\n \nReturns:\n 管网元素的模拟数据\n \nRaises:\n HTTPException: 当feature_infos为空返回400错误,未找到数据返回404错误,其他错误返回400错误", "operationId": "get_timeseries_views_element_simulations", "parameters": [ { @@ -46563,25 +37129,22 @@ } }, { - "description": "方案类型,若为空则查询实时数据", + "description": "分析运行 ID;为空时查询实时数据", "in": "query", - "name": "scheme_type", + "name": "run_id", "required": false, "schema": { - "description": "方案类型,若为空则查询实时数据", - "title": "Scheme Type", - "type": "string" - } - }, - { - "description": "方案名称,若为空则查询实时数据", - "in": "query", - "name": "scheme_name", - "required": false, - "schema": { - "description": "方案名称,若为空则查询实时数据", - "title": "Scheme Name", - "type": "string" + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "分析运行 ID;为空时查询实时数据", + "title": "Run Id" } }, { @@ -46679,7 +37242,7 @@ }, "/api/v1/timeseries/views/scada-simulations": { "get": { - "description": "获取SCADA关联的link/node模拟值\n\n根据传入的SCADA device_ids,找到关联的link/node,\n并根据对应的type,查询对应的模拟数据。支持查询实时或方案数据。\n\nArgs:\n start_time: 查询开始时间\n end_time: 查询结束时间\n device_ids: SCADA设备ID列表,用逗号分隔\n scheme_type: 方案类型,若为空则查询实时数据\n scheme_name: 方案名称,若为空则查询实时数据\n timescale_conn: TimescaleDB连接\n postgres_conn: PostgreSQL连接\n \nReturns:\n SCADA关联的模拟数据\n \nRaises:\n HTTPException: 当查询参数无效时返回400错误,未找到数据时返回404错误", + "description": "获取SCADA关联的link/node模拟值\n\n根据传入的SCADA device_ids,找到关联的link/node,\n并根据对应的type,查询对应的模拟数据。支持查询实时或分析运行数据。\n\nArgs:\n start_time: 查询开始时间\n end_time: 查询结束时间\n device_ids: SCADA设备ID列表,用逗号分隔\n run_id: 分析运行 ID,若为空则查询实时数据\n timescale_conn: TimescaleDB连接\n postgres_conn: PostgreSQL连接\n \nReturns:\n SCADA关联的模拟数据\n \nRaises:\n HTTPException: 当查询参数无效时返回400错误,未找到数据时返回404错误", "operationId": "get_timeseries_views_scada_simulations", "parameters": [ { @@ -46718,25 +37281,22 @@ } }, { - "description": "方案类型,若为空则查询实时数据", + "description": "分析运行 ID;为空时查询实时数据", "in": "query", - "name": "scheme_type", + "name": "run_id", "required": false, "schema": { - "description": "方案类型,若为空则查询实时数据", - "title": "Scheme Type", - "type": "string" - } - }, - { - "description": "方案名称,若为空则查询实时数据", - "in": "query", - "name": "scheme_name", - "required": false, - "schema": { - "description": "方案名称,若为空则查询实时数据", - "title": "Scheme Name", - "type": "string" + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "分析运行 ID;为空时查询实时数据", + "title": "Run Id" } }, { @@ -47129,104 +37689,6 @@ ] } }, - "/api/v1/undos": { - "post": { - "description": "撤销网络上最后的一个操作", - "operationId": "post_undos", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "撤销操作", - "tags": [ - "Snapshots" - ] - } - }, "/api/v1/valve-closure-analyses": { "post": { "description": "高级版本的阀门关闭分析,支持同时关闭多个阀门,并在指定持续时间内进行模拟。返回纯文本格式的分析结果。", @@ -49522,744 +39984,6 @@ ] } }, - "/api/v1/virtual-district-calculations": { - "post": { - "description": "根据指定的压力监测节点作为中心节点计算虚拟分区方案", - "operationId": "post_virtual_district_calculations", - "parameters": [ - { - "description": "压力监测节点ID列表", - "in": "query", - "name": "centers", - "required": true, - "schema": { - "description": "压力监测节点ID列表", - "items": { - "type": "string" - }, - "title": "Centers", - "type": "array" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "additionalProperties": { - "items": {}, - "type": "array" - }, - "title": "Response Post Virtual District Calculations", - "type": "object" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "计算虚拟分区", - "tags": [ - "Regions & DMAs" - ] - } - }, - "/api/v1/virtual-district-generation-runs": { - "post": { - "description": "根据参数自动生成虚拟分区方案", - "operationId": "post_virtual_district_generation_runs", - "parameters": [ - { - "description": "膨胀参数", - "in": "query", - "name": "inflate_delta", - "required": true, - "schema": { - "description": "膨胀参数", - "title": "Inflate Delta", - "type": "number" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "生成虚拟分区", - "tags": [ - "Regions & DMAs" - ] - } - }, - "/api/v1/virtual-districts": { - "delete": { - "description": "删除指定的虚拟分区", - "operationId": "delete_virtual_districts", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "删除虚拟分区", - "tags": [ - "Regions & DMAs" - ] - }, - "get": { - "description": "获取指定水网中的所有虚拟分区信息", - "operationId": "get_virtual_districts", - "parameters": [ - { - "in": "query", - "name": "limit", - "required": false, - "schema": { - "default": 100, - "maximum": 1000, - "minimum": 1, - "title": "Limit", - "type": "integer" - } - }, - { - "in": "query", - "name": "offset", - "required": false, - "schema": { - "default": 0, - "minimum": 0, - "title": "Offset", - "type": "integer" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Page_dict_str__Any__" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取所有虚拟分区", - "tags": [ - "Regions & DMAs" - ] - }, - "patch": { - "description": "修改指定虚拟分区的属性信息", - "operationId": "patch_virtual_districts", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "设置虚拟分区属性", - "tags": [ - "Regions & DMAs" - ] - }, - "post": { - "description": "向水网添加一个新的虚拟分区", - "operationId": "post_virtual_districts", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "添加新虚拟分区", - "tags": [ - "Regions & DMAs" - ] - } - }, - "/api/v1/virtual-districts/detail": { - "get": { - "description": "获取指定ID的虚拟分区详细信息", - "operationId": "get_virtual_districts_detail", - "parameters": [ - { - "description": "虚拟分区ID", - "in": "query", - "name": "id", - "required": true, - "schema": { - "description": "虚拟分区ID", - "title": "Id", - "type": "string" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "title": "Response Get Virtual Districts Detail", - "type": "object" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取虚拟分区信息", - "tags": [ - "Regions & DMAs" - ] - } - }, "/api/v1/visual-elements": { "delete": { "description": "从网络中删除指定的图形元素", @@ -51002,115 +40726,6 @@ "Web Search" ] } - }, - "/api/v1/with-servers": { - "post": { - "description": "将网络与服务器同步到指定操作", - "operationId": "post_with_servers", - "parameters": [ - { - "description": "目标操作ID", - "in": "query", - "name": "operation", - "required": true, - "schema": { - "description": "目标操作ID", - "title": "Operation", - "type": "integer" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "与服务器同步", - "tags": [ - "Snapshots" - ] - } } } } diff --git a/resources/db_v2/DATABASE_ARCHITECTURE.md b/resources/db_v2/DATABASE_ARCHITECTURE.md new file mode 100644 index 0000000..5bb3e35 --- /dev/null +++ b/resources/db_v2/DATABASE_ARCHITECTURE.md @@ -0,0 +1,454 @@ +# TJWater 数据库改造说明与当前结构 + +> 本文记录 2026-08-25 的数据库实际状态。结构、约束、行数、TimescaleDB chunk 和策略均直接读取数据库,不以仓库中的 SQL 脚本为依据。文中不包含主机、端口、账号、密码或 DSN。 + +## 改造范围与当前状态 + +本次改造保留原 `tjwater` 业务库和时序库,新建 `tjwater_next` 作为隔离验证环境。元数据库仍为 `system_hub`,新项目通过 `biz_data` 和 `iot_data` 两条路由分别关联新业务库与新时序库。项目完成迁移和联调后已切换为 `active`,原数据库没有被覆盖,仍可用于对照和回退。 + +已经完成的数据库修改包括: + +- 新建并迁移 `tjwater_next` 业务库,将旧 `public` 中混合存放的管网、GIS、SCADA 配置和分析数据按领域拆分。 +- 新建并迁移 `tjwater_next` 时序库,将 SCADA、实时计算和分析计算结果分开存放。 +- `realtime` 采用冷热数据策略,72 小时后的 chunk 自动转为有序列存。 +- `analysis` 按 `stored_at` 分区,入库满 24 小时的 chunk 自动转为有序列存。 +- GIS 查询层改用物化视图,当前 7 张物化视图均已填充。 +- GeoServer 已建立 `tjwater_next` 工作空间和同名数据存储,从业务库 `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 访问均使用有界连接池,闲置项目按最近使用顺序回收,实时覆盖写入使用单一事务。 +- 后端批量元素查询读取 GIS 物化视图,模型增删改和 INP 导入提交后执行并发刷新;批量事务只刷新一次。 +- `pattern_values`、`pattern_flow_samples`、`curve_points`、`demands` 和 `link_vertices` 的顺序号按所属父对象编号,主键已改为父对象 ID 与 `sequence_no` 的复合键。 + +`tjwater_next` 当前为 `active`。排水项目 `lingang` 已迁入 `system_hub.public`,供水和排水后端共用同一套项目、成员、数据库路由和审计表。 + +## 数据库总体关系 + +```mermaid +flowchart LR + subgraph META["system_hub 元数据库"] + MP["public
统一元数据
5 个项目"] + end + + subgraph OLD["原数据库,保持不变"] + OB["tjwater 业务库
public"] + OT["tjwater 时序库
scada / realtime / scheme"] + end + + subgraph NEXT["隔离验证数据库"] + NB["tjwater_next 业务库
network / gis / asset / analysis"] + NT["tjwater_next 时序库
scada / realtime / analysis"] + end + + GS["GeoServer
tjwater_next 工作空间"] + WEB["供水前端
tjwater_next 图层配置"] + + MP -->|"tjwater 的 biz_data"| OB + MP -->|"tjwater 的 iot_data"| OT + MP -->|"tjwater_next 的 biz_data"| NB + MP -->|"tjwater_next 的 iot_data"| NT + MP -->|"lingang 的两条数据库路由"| DRAIN["排水项目数据库"] + NB -->|"gis 物化视图"| GS -->|"WFS / WMTS"| WEB +``` + +一个项目对应一个业务数据库和一个时序数据库。项目本地表不保存 `project_id`,项目边界由元数据库路由和数据库连接共同确定。 + +## 元数据库 system_hub + +### public:当前主元数据 + +`public` 当前有 5 个项目、1 个用户、5 条成员关系和 10 条数据库路由。5 个项目均配置了一条 `biz_data` 和一条 `iot_data` 路由。审计日志数量会随接口请求持续增加,不在文档中固化行数。 + +| 表 | 用途 | 主要字段 | +| --- | --- | --- | +| `users` | Keycloak 用户身份快照和系统授权状态 | `id`、`keycloak_id`、`username`、`email`、`role`、`is_active`、`is_superuser`、`attributes`、时间字段 | +| `projects` | 项目基本信息和地图配置 | `id`、`name`、`code`、`gs_workspace`、`map_extent`、`map_config`、`status`、时间字段 | +| `user_project_membership` | 用户和项目的成员关系 | `id`、`user_id`、`project_id`、`project_role` | +| `project_databases` | 项目业务库和时序库路由 | `id`、`project_id`、`db_role`、`db_type`、`dsn_encrypted`、`pool_min_size`、`pool_max_size` | +| `audit_logs` | 独立保留的操作审计 | `id`、`user_id`、`project_id`、`action`、资源字段、请求字段、`response_status`、`timestamp` | + +```mermaid +erDiagram + USERS { + uuid id PK + uuid keycloak_id UK + varchar username UK + varchar email UK + varchar role + boolean is_active + boolean is_superuser + } + + PROJECTS { + uuid id PK + varchar code UK + varchar name + varchar gs_workspace UK + jsonb map_extent + jsonb map_config + varchar status + } + + USER_PROJECT_MEMBERSHIP { + uuid id PK + uuid user_id FK + uuid project_id FK + varchar project_role + } + + PROJECT_DATABASES { + uuid id PK + uuid project_id FK + varchar db_role + varchar db_type + text dsn_encrypted + int pool_min_size + int pool_max_size + } + + AUDIT_LOGS { + uuid id PK + uuid user_id + uuid project_id + varchar action + timestamptz timestamp + } + + USERS ||--o{ USER_PROJECT_MEMBERSHIP : "参与项目" + PROJECTS ||--o{ USER_PROJECT_MEMBERSHIP : "包含成员" + PROJECTS ||--o{ PROJECT_DATABASES : "配置路由" +``` + +数据库强制执行以下关系和约束: + +- 成员表的 `user_id`、`project_id` 分别引用用户和项目,删除用户或项目时级联删除成员关系。 +- `project_databases.project_id` 引用项目,删除项目时级联删除数据库路由。 +- 同一项目的 `db_role` 唯一。 +- `biz_data` 必须使用 `postgresql`,`iot_data` 必须使用 `timescaledb`。 +- `pool_min_size` 不小于 1,`pool_max_size` 不小于 `pool_min_size`。 +- 项目状态限定为 `active`、`inactive` 或 `archived`。 +- 审计表中的用户和项目 ID 不设置外键,删除业务对象不会连带删除历史日志。 + +### 排水元数据合并 + +排水后端原先使用独立的 `hub` schema,其中有 1 个 `lingang` 项目、2 条数据库路由和 203 条审计记录,没有用户或成员关系。项目 UUID 保持不变,两条路由转换为 `public.project_databases` 使用的 Fernet 加密格式,连接池参数由 `pool_size + max_overflow` 映射为 `pool_min_size + pool_max_size`。原审计记录已迁入 `public.audit_logs`。 + +排水后端已改用 `public.projects`、`public.project_databases`、`public.user_project_membership`、`public.users` 和 `public.audit_logs`。供水和排水服务读取同一份项目状态、Keycloak 身份、项目权限及数据库路由。原 `hub` schema 已在迁移校验和连接测试通过后删除。 + +## 原业务库 tjwater + +原业务库将大部分业务对象放在 `public`,当前有 68 张表、9 张视图和 2 张物化视图。管网模型、GIS、SCADA 配置、分析结果、方案数据、临时表和操作记录混在同一命名空间中。 + +其中还安装了 `postgis_tiger_geocoder` 和 `postgis_topology`,因此存在 `tiger` 和 `topology` 扩展 schema。新业务库只保留当前实际使用的 PostGIS 能力,没有继续安装这两个扩展。 + +## 相对原数据库的结构变化 + +以下对比以当前仍保留的原业务库 `tjwater`、原时序库 `tjwater` 与新库 `tjwater_next` 的实际对象为准。对象名称的对应关系表示业务实体或数据职责的迁移方向,不表示所有字段均一对一复制。 + +### 归并和调整 + +| 原库对象或职责 | 新库对象或职责 | 调整内容 | +| --- | --- | --- | +| `_node`、`junctions`、`reservoirs`、`tanks` | `network.nodes` 及节点类型子表 | 节点统一由主表管理,类型专有字段保留在共享主键子表。 | +| `_link`、`pipes`、`pumps`、`valves` | `network.links` 及连接类型子表 | 连接统一记录端点和类型,管道、泵、阀门参数移入对应子表。 | +| `coordinates`、`vertices` | `gis.node_geometries`、`gis.link_vertices` | 管网几何从模型参数中拆出,分别保存节点位置和连接折点。 | +| 旧的 GIS 视图和物化视图 | `gis` 下 7 张物化视图 | 前端查询层统一为节点、连接和设备的物化视图,底层仍读取 `network`、`gis`、`asset` 的规范化表。 | +| SCADA 设备配置表 | `asset.scada_devices` | 设备配置集中到资产域,并以外键关联一个节点或一条连接。当前已迁入 118 台设备。 | +| `scheme_list` 及方案结果相关表 | `analysis.runs`、`analysis.results` | 运行批次与非时序摘要结果留在业务库,逐时逐元素结果迁入时序库。当前已迁入 124 次运行和 17 条非时序结果。 | +| `scada.scada_data` | `scada.measurements` | SCADA 测量值迁入新时序库并按设备和时间保存。 | +| `realtime.node_simulation`、`realtime.link_simulation` | `realtime.node_results`、`realtime.link_results` | 实时仿真结果保留为节点和连接两类时序数据。 | +| `scheme.node_simulation`、`scheme.link_simulation` | `analysis.node_results`、`analysis.link_results` | 方案或分析的元素时序结果以 `run_id` 区分批次,和业务库中的 `analysis.runs` 形成逻辑关联。 | + +### 已删减且未迁入的对象 + +- 云端操作记录相关的 `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` 系统对象。 + +### 尚未完整承接的范围 + +原库有 `region`、`region_dma`、`region_sa`、`region_vd`、`region_wda` 五张区域细分表。检查时这些表均无数据,因此当前新库只建立了通用的 `gis.regions`、`gis.region_nodes`,没有为 DMA、分区计量、分区调度或用水分区固化专用结构。 + +这不是删除已有业务数据,而是暂缓固化尚未使用的模型。后续确认 DMA 和 `VA` 的业务含义后,再决定是在 `gis` 中增加区域类型专有表,还是放入独立的业务 schema。当前库中没有名为 `va` 的表;若该名称指阀门,则对应 `network.valves`,若指 `region_vd`,则属于上述尚未迁入的区域细分模型。 + +### 新增的结构能力 + +- 业务库由单一 `public` 命名空间拆为 `network`、`gis`、`asset`、`analysis` 四个领域 schema,降低模型、空间数据、设备配置和分析记录之间的耦合。 +- `gis` 增加了面向前端的 7 张物化视图,以及 `gis.refresh_all_materialized_views` 刷新过程。后端在模型批量修改和 INP 导入提交后统一刷新,不使用数据库触发器或定时任务。 +- 新时序库增加 `migration` schema,用于保留迁移过程的设置和日志,不与业务时序数据混放。 +- `realtime` 两张 hypertable 已启用 72 小时后的列存压缩策略,`analysis` 两张 hypertable 按入库时间执行 24 小时冷热转换。`scada` 保持独立行存。 +- 元数据库的 `public.project_databases` 增加项目外键、数据库角色和类型约束,以及连接池上下限约束,用于保证每个项目的业务库和时序库路由有效。 + +## 新业务库 tjwater_next + +新业务库使用 PostgreSQL 和 PostGIS。业务对象分布在 4 个 schema 中,`public` 只保留 PostGIS 提供的系统对象。 + +| Schema | 普通表 | 物化视图 | 用途 | +| --- | ---: | ---: | --- | +| `network` | 32 | 0 | 供水管网模型、仿真参数、规则和曲线 | +| `gis` | 6 | 7 | 原始空间数据、区域关系和前端查询层 | +| `asset` | 1 | 0 | SCADA 设备配置及其管网关联 | +| `analysis` | 2 | 0 | 分析运行记录和非时序结果 | + +当前迁移数据包含 87,907 个节点、91,054 条连接、118 个 SCADA 设备、124 次分析运行和 17 条非时序分析结果。其中有 87,894 个普通节点、13 个水源、91,052 条管道和 2 个阀门。 + +### network:管网模型 + +`network.nodes` 和 `network.links` 是节点、连接的统一主表。具体类型通过共享主键的一对一子表扩展: + +- 节点:`junctions`、`reservoirs`、`tanks`。 +- 连接:`pipes`、`pumps`、`valves`。 +- 模式和曲线:`patterns`、`pattern_values`、`pattern_flow_samples`、`curves`、`curve_points`。 +- 节点附属数据:`demands`、`emitters`、`sources`、`initial_quality`、`tank_mixing`、`node_tags`。 +- 连接附属数据:`link_initial_settings`、`link_tags`、`pump_energy_settings`、`pipe_reaction_coefficients`、`tank_reaction_coefficients`。 +- 模型配置:`controls`、`rules`、`simulation_settings`、`time_settings`、`report_settings`、`energy_settings`、`reaction_settings`、`model_titles`。 + +```mermaid +erDiagram + NODES { + text id PK + text node_type + } + JUNCTIONS { + text node_id PK,FK + float elevation + } + RESERVOIRS { + text node_id PK,FK + float head + text pattern_id FK + } + TANKS { + text node_id PK,FK + float elevation + float initial_level + text volume_curve_id FK + } + LINKS { + text id PK + text link_type + text start_node_id FK + text end_node_id FK + } + PIPES { + text link_id PK,FK + float length + float diameter + text status + } + PUMPS { + text link_id PK,FK + float power + text head_curve_id FK + text pattern_id FK + } + VALVES { + text link_id PK,FK + float diameter + text valve_type + text setting + } + PATTERNS { + text id PK + } + CURVES { + text id PK + text curve_type + } + + NODES ||--o| JUNCTIONS : "节点类型" + NODES ||--o| RESERVOIRS : "节点类型" + NODES ||--o| TANKS : "节点类型" + NODES ||--o{ LINKS : "起点" + NODES ||--o{ LINKS : "终点" + LINKS ||--o| PIPES : "连接类型" + LINKS ||--o| PUMPS : "连接类型" + LINKS ||--o| VALVES : "连接类型" + PATTERNS ||--o{ RESERVOIRS : "水位模式" + PATTERNS ||--o{ PUMPS : "运行模式" + CURVES ||--o{ PUMPS : "扬程曲线" + CURVES ||--o{ TANKS : "容积曲线" +``` + +删除节点或连接时,对应类型子表、GIS 几何、标签和关联参数按外键规则同步删除。连接的起点和终点必须引用已有节点,且不能是同一个节点。 + +`pattern_values`、`pattern_flow_samples` 和 `curve_points` 的 `sequence_no` 分别表示同一模式或曲线内部的顺序,`demands.sequence_no` 表示同一节点下多条需水记录的顺序。因此,这四张明细表使用父对象 ID 与 `sequence_no` 组成主键,不要求顺序号在全表唯一。该约束与 WNDB 的局部编号、INP 导入和按父对象排序查询一致。 + +### gis:空间数据和查询层 + +基础空间表包括: + +| 表 | 用途 | 主要关系 | +| --- | --- | --- | +| `node_geometries` | 节点点位 | `node_id` 引用 `network.nodes` | +| `link_vertices` | 连接中间折点 | `link_id` 引用 `network.links` | +| `labels` | 地图标注 | 可选关联节点 | +| `backdrops` | 模型背景配置 | 单条配置记录 | +| `regions` | 区域边界 | 保存区域类型和面几何 | +| `region_nodes` | 区域和节点的多对多关系 | 同时引用区域和节点 | + +`link_vertices` 使用 `(link_id, sequence_no)` 复合主键。折点顺序只在同一条连接内部有效,不同连接可以从相同的顺序号开始编号。 + +前端查询使用 7 张物化视图:`junctions`、`reservoirs`、`tanks`、`pipes`、`pumps`、`valves` 和 `scada_devices`。这些视图把模型属性和几何合并,避免前端与 GeoServer 每次重复执行跨表计算。当前 7 张视图均已填充,并为元素 ID 建立唯一索引,为几何建立 GiST 索引。 + +原始模型几何继续使用项目坐标系 `EPSG:900914`。点要素物化视图中的 `x`、`y` 仍保存该坐标系下的模型坐标,供 WNDB 和 INP 读写使用;提供给 GeoServer 的 `geom` 统一转换为 `EPSG:3857`。管道发布为线,水泵和阀门使用连接线中点发布为点,SCADA 设备按设备自有位置、关联节点位置或关联连接中点依次取值。当前数据库中的 7 个 `geom` 字段均已核对为 `EPSG:3857`。 + +数据库中的 `gis.refresh_all_materialized_views(boolean)` 存储过程统一刷新全部物化视图。后端批量读取普通节点、水源、水箱、管道、水泵、阀门和 SCADA 设备时直接查询这些视图;单条模型增删改提交后立即刷新,批量修改和 INP 导入成功提交后只刷新一次。实际库中一次全量并发刷新约 6 秒。视图已有数据和唯一索引,因此刷新期间查询仍可继续。 + +```mermaid +flowchart LR + N["network 节点和连接"] --> G["gis 原始几何"] + A["asset.scada_devices"] --> G + N --> MV["gis 物化视图"] + G --> MV + A --> MV + MV --> GS["GeoServer tjwater_next"] + GS --> WFS["WFS 要素查询"] + GS --> GWC["GeoWebCache WMTS"] + WFS --> F["供水前端"] + GWC --> F + R["refresh_all_materialized_views"] --> MV +``` + +### GeoServer 与前端图层 + +`system_hub.public.projects` 中的 `tjwater_next` 项目已配置 `gs_workspace=tjwater_next`,当前状态为 `active`。GeoServer 的 `tjwater_next` 数据存储连接同名业务库并限定到 `gis` schema,图层名称直接采用物化视图名称。7 个图层使用相同的项目管网发布边界,空图层和视口内没有要素的瓦片会返回空 MVT,不会产生越界错误。 + +| 前端数据源 | GeoServer 图层 | 几何 | 当前要素数 | +| --- | --- | --- | ---: | +| `junctions` | `tjwater_next:junctions` | Point,`EPSG:3857` | 87,894 | +| `reservoirs` | `tjwater_next:reservoirs` | Point,`EPSG:3857` | 13 | +| `tanks` | `tjwater_next:tanks` | Point,`EPSG:3857` | 0 | +| `pipes` | `tjwater_next:pipes` | LineString,`EPSG:3857` | 91,052 | +| `pumps` | `tjwater_next:pumps` | Point,`EPSG:3857` | 0 | +| `valves` | `tjwater_next:valves` | Point,`EPSG:3857` | 2 | +| `scada` | `tjwater_next:scada_devices` | Point,`EPSG:3857` | 118 | + +供水前端的 `refactor/tjwater-next-integration` 分支已切换到这些图层和新字段名。地图切片走 `WebMercatorQuad` 的 MVT,定位和详情查询走 WFS。旧 `geo_pipes_mat`、`geo_junctions_mat` 和其他 `geo_*` 图层名不再作为新前端的兼容别名。 + +方案查询统一读取 `/api/v1/analysis/runs`,详情和时序结果按 UUID `run_id` 关联。时间轴读取 `/api/v1/timeseries/analysis/runs/{run_id}/values`,爆管定位的模拟数据源也传递 `simulation_run_id`。监测点优化使用 `/api/v1/sensor-placement-runs`。前端不再调用旧的 `/schemes`、`/timeseries/schemes` 和 `/sensor-placement-schemes` 接口。 + +模型修改提交后,后端先调用 `gis.refresh_all_materialized_views(boolean)` 刷新数据库查询层。GeoWebCache 不会感知 PostgreSQL 物化视图刷新,因此 7 个新图层配置了 300 秒的服务端和客户端缓存有效期,前端最迟在 5 分钟后读到新瓦片。部署或批量迁移完成后仍可执行一次图层缓存清空,避免等待已有瓦片自然过期。 + +### asset:SCADA 设备配置 + +`asset.scada_devices` 保存设备类型、采集接口标识、传输模式、频率、可靠性和可选几何。每台设备必须关联一个节点或一条连接,不能同时关联两者。设备的历史测量值不放在业务库,保存在时序库 `scada.measurements`。 + +### analysis:分析运行和非时序结果 + +`analysis.runs` 表示一次实际执行,保存运行名称、类型、创建人、开始时间、状态和参数。当前没有单独的 `scenarios` 模板表。 + +每次执行都会创建新的 `run_id`,名称和业务时间相同也不会覆盖旧结果。扩展仿真开始写结果前,业务库先记录 `running`;时序库写入完成后更新为 `completed`,写入失败则保留为 `failed`。业务库和时序库据此共用同一个执行标识。 + +`analysis.results` 保存不适合放入时序表的结果摘要或结构化业务结果。每条结果必须属于一个运行,可以关联一个节点、一条连接,或者不关联任何管网元素。元素级时序结果保存在时序库,某次分析只有摘要结果、只有元素时序结果或两者都有都是合法状态。 + +```mermaid +erDiagram + RUNS { + uuid run_id PK + text name + text run_type + text created_by + timestamptz started_at + text status + jsonb parameters + } + RESULTS { + uuid result_id PK + uuid run_id FK + text result_type + text node_id FK + text link_id FK + jsonb payload + } + RUNS ||--o{ RESULTS : "产生可选结果" +``` + +### 业务表如何扩展 + +当前没有预建空的 `business` schema。管网模型放在 `network`,空间区域和 DMA 放在 `gis`,设备配置放在 `asset`,运行和结果放在 `analysis`。后续出现工单、巡检、告警或资产养护等明确业务实体时,再按稳定的业务边界增加独立 schema;不把新业务表重新堆回 `public`,也不为尚未确定的角色或流程提前建表。 + +## 原时序库 tjwater + +原时序库运行 TimescaleDB 2.21.3,共有 5 张 hypertable: + +- `scada.scada_data` +- `realtime.node_simulation` +- `realtime.link_simulation` +- `scheme.node_simulation` +- `scheme.link_simulation` + +这些表均为行存,没有启用压缩策略。方案结果使用 `scheme_name` 关联业务库中的方案记录。 + +## 新时序库 tjwater_next + +新时序库仍运行 TimescaleDB 2.21.3。业务时序数据分为 `scada`、`realtime` 和 `analysis`,迁移过程状态单独放在 `migration`。 + +| 表 | 主键 | Chunk 间隔 | 当前行数 | 当前占用 | +| --- | --- | --- | ---: | ---: | +| `scada.measurements` | `(time, device_id)` | 7 天 | 1,249,602 | 约 0.18 GiB | +| `realtime.node_results` | `(time, node_id)` | 1 天 | 159,111,670 | 约 1.89 GiB | +| `realtime.link_results` | `(time, link_id)` | 1 天 | 164,807,740 | 约 2.18 GiB | +| `analysis.node_results` | `(stored_at, time, run_id, node_id)` | 1 天 | 21,800,936 | 约 2.93 GiB | +| `analysis.link_results` | `(stored_at, time, run_id, link_id)` | 1 天 | 22,581,392 | 约 4.82 GiB | + +### scada + +`scada.measurements` 保存设备测量值和清洗值。`device_id` 与业务库的 `asset.scada_devices.device_id` 是跨数据库逻辑关联,数据库不能建立物理外键。 + +时序表当前有 118 个设备标识,与业务库的 118 台设备一一对应。迁移后曾发现设备标识 `11`、`12`、`13`、`14` 没有对应的 SCADA 点位配置;确认属于无效孤立数据后,已从新时序库删除其 5,376 条记录。原 `tjwater` 时序库保持不变,必要时仍可追溯迁移前数据。 + +### realtime + +`realtime.node_results` 和 `realtime.link_results` 保存当前实时计算窗口。主键保证同一时刻、同一元素只能有一条记录。相同时间窗口由后端先删除、再通过 `COPY` 批量插入;节点和连接两次替换位于同一个最外层事务,其中任一步失败都会整体回滚。 + +两张表当前各有 19 个 chunk,均已转为列存,因为现有数据都早于 72 小时热窗口。数据库每小时执行一次策略检查,将 72 小时以前的 chunk 转为有序列存。节点结果按 `node_id, time DESC` 排序,连接结果按 `link_id, time DESC` 排序。新写入的数据使用 1 天 chunk,并在 72 小时内保持行存。 + +### analysis + +`analysis.node_results` 和 `analysis.link_results` 保存每次分析运行的元素级时间序列,通过 `run_id` 与业务库 `analysis.runs` 逻辑关联。`time` 是仿真业务时刻,继续用于曲线和时间范围查询;`stored_at` 是该批结果的入库完成时间,作为 hypertable 的分区时间。 + +两张表按 1 天创建 chunk,最近 24 小时保持热数据。超过 24 小时的 chunk 会由压缩策略转为列存冷数据,策略按 `run_id` 与元素 ID 分段、按仿真 `time DESC` 排序。重新计算相同历史时间段时,新结果写入当前的 `stored_at` 热分区,不需要改写历史冷分区。当前各有 20 个 chunk,均因入库时间超过 24 小时而完成压缩。 + +迁移期间曾保留按仿真时间分区的 `*_time_partitioned_legacy` 表用于回退校验。清理前已核对 46 个 `run_id`:节点 21,800,936 行、连接 22,581,392 行的总数、逐运行数量、时间范围和全字段校验值均一致。两张旧表随后删除,释放约 12.16 GiB;原 `tjwater` 时序库仍保留,不受此次清理影响。 + +### migration + +`migration.copy_log` 记录每个来源表、迁移时间窗口和源行数,当前有 150 条记录。`migration.settings` 保存迁移截止时间等运行参数,当前有 1 条记录。它们是迁移审计和断点信息,不是业务数据,也不是仍待处理的中间结果。正式数据迁移已经完成。 + +## 后端连接与事务 + +元数据库通过 SQLAlchemy 异步连接池访问;项目请求按 `system_hub.public.project_databases` 路由到业务库和时序库。异步业务查询和异步时序查询由项目级动态池管理,原生 WNDB 同步访问使用按数据库缓存的 `psycopg_pool.ConnectionPool`,数据库创建、复制和删除使用独立的 PostgreSQL 管理池,同步 TimescaleDB 访问也使用按数据库缓存的连接池。应用目录中已没有直接调用 `psycopg.connect` 的业务代码。 + +WNDB 批量修改和 INP 数据导入在同一条池连接和同一事务中执行,提交后再刷新 GIS 物化视图。实时节点和连接结果也在一个事务中执行先删后写,同一结果时间使用事务级锁避免并发覆盖竞态;分析结果按 `run_id` 加事务级锁,防止同一运行被并发写入两次。 + +自动化真实数据库测试分别执行 64 次业务库和 64 次时序库并发借用,查询结果一致,连接均能归还池中。嵌套 WNDB 写入和分析运行生命周期测试会在外层强制回滚,数据库没有残留记录。`DatabaseCommand` 的 pattern 新增、修改、删除也在同一池化事务中完成,并验证了五张明细表的复合主键、级联解除需求模式关联、结果变更和整体回滚。实时覆盖测试确认第二批数据替换第一批数据,外层回滚后测试记录为 0。 + +## 跨数据库逻辑关系 + +PostgreSQL 不能对另一个数据库中的表建立外键。业务库与时序库之间通过稳定 ID 保持逻辑一致性,完整性由迁移校验和后端事务负责。 + +```mermaid +flowchart LR + P["system_hub.public.projects"] -->|"biz_data 路由"| B["项目业务库"] + P -->|"iot_data 路由"| T["项目时序库"] + + BN["network.nodes.id"] -. "node_id" .-> RN["realtime.node_results"] + BN -. "node_id" .-> AN["analysis.node_results"] + BL["network.links.id"] -. "link_id" .-> RL["realtime.link_results"] + BL -. "link_id" .-> AL["analysis.link_results"] + SD["asset.scada_devices.device_id"] -. "device_id" .-> SM["scada.measurements"] + AR["analysis.runs.run_id"] -. "run_id" .-> AN + AR -. "run_id" .-> AL +``` + +实线表示元数据库保存的项目路由,虚线表示跨数据库逻辑关联。每个项目使用独立业务库和时序库,不需要在项目本地表中重复保存 `project_id`。 + +## 仍需处理的事项 + +- 当前没有修改 SCADA 设备配置的后端接口。以后增加这类写接口时,也要在提交后调用统一的物化视图刷新函数。 +- 用户角色和项目角色还未定型,数据库只要求字段非空,不限制具体取值。 diff --git a/resources/db_v2/WNDB_STRUCTURE.md b/resources/db_v2/WNDB_STRUCTURE.md new file mode 100644 index 0000000..80a8771 --- /dev/null +++ b/resources/db_v2/WNDB_STRUCTURE.md @@ -0,0 +1,168 @@ +# WNDB 文件结构说明 + +本文记录 2026-08-25 完成的 WNDB 目录与命令结构重构。检查对象是当前代码,不以旧 SQL 脚本或历史目录为依据。 + +## 结论 + +当前结构适合继续维护。WNDB 已按连接基础设施、管网模型、GIS、INP 和命令执行分组,原来的编号文件名、根目录聚合门面和星号导入已经移除。WDA、SCADA 资产查询和测压点选址也已离开底层模型目录。 + +本次调整了代码文件、导入关系、命令分派和 WNDB 内部命令对象,没有改变 HTTP 接口。历史撤销日志已从数据库中移除,内部接口不再保留无效的兼容字段。真实库回归时发现五张明细表错误地把局部顺序号设成全局主键,已在 `tjwater_next` 中改为父对象 ID 与 `sequence_no` 的复合主键。 + +## 当前目录 + +```text +app/native/wndb/ +├── __init__.py +├── core/ +│ ├── connection.py +│ ├── database.py +│ └── projects.py +├── model/ +│ ├── elements.py +│ ├── junctions.py +│ ├── reservoirs.py +│ ├── tanks.py +│ ├── pipes.py +│ ├── pumps.py +│ ├── valves.py +│ ├── patterns.py +│ ├── curves.py +│ ├── options.py +│ └── 其他 EPANET 模型模块 +├── gis/ +│ ├── coordinates.py +│ ├── vertices.py +│ ├── labels.py +│ ├── backdrop.py +│ ├── regions.py +│ └── region_geometry.py +├── inp/ +│ ├── sections.py +│ ├── importer.py +│ └── exporter.py +└── commands/ + ├── api.py + ├── cascade.py + └── executor.py +``` + +目录内共有 47 个 Python 文件,约 7,100 行。`app/native/wndb/__init__.py` 只保留包说明,不再统一导出所有函数。调用方需要从具体职责模块导入,依赖来源可以直接从文件头确认。 + +## 各目录的职责 + +### core:连接、事务和数据库基础能力 + +`connection.py` 管理项目连接池、管理连接池和项目事务上下文。连接池按路由后的 DSN 复用,并限制缓存规模。 + +`database.py` 提供 `ChangeSet`、`DatabaseCommand`、参数化查询和物化视图刷新。`DatabaseCommand` 只保存待执行的 SQL 和执行成功后返回给调用方的变更列表,不再生成或保存撤销 SQL。模型直接修改时按需刷新视图;批量命令在外层事务提交后只刷新一次。物化视图保留模型坐标 `x`、`y`,同时将供 GeoServer 使用的 `geom` 转换为 `EPSG:3857`,WNDB 查询不会把发布坐标误当成模型坐标。 + +`projects.py` 只负责项目数据库的创建、复制、打开、关闭和删除,不再混入模型查询。 + +### model:管网模型和仿真配置 + +`model` 按业务实体命名,不再使用 `s2_junctions.py` 这类 INP 章节编号。节点、连接、模式、曲线、需求、规则和仿真设置都能从文件名直接定位。 + +每个实体模块保留三类紧密相关的函数:读取实体、生成并执行实体变更、转换该实体对应的一行或一段 INP 内容。完整文件的读取顺序、事务和项目生命周期由 `inp` 目录负责。因此,实体级编解码仍靠近实体定义,跨章节编排已经集中。 + +`elements.py` 保存节点、连接、模式、曲线和区域的通用类型判断及拓扑查询。它不再承担业务算法。 + +### gis:空间数据和区域几何 + +`coordinates.py`、`vertices.py`、`labels.py` 和 `backdrop.py` 对应原始 GIS 数据。`regions.py` 负责区域持久化,`region_geometry.py` 负责边界、凸包、膨胀和区域内元素查询。 + +管网实体修改会调用坐标 SQL 辅助函数,区域几何也会读取管网拓扑。这里存在明确的模型与 GIS 协作,但没有模块导入环。现阶段继续拆出抽象接口只会增加层级,没有实际收益。 + +### inp:文件级导入导出 + +`sections.py` 只保存 INP 章节名称和输出顺序。旧文件中混放的 `s1_title`、`s2_junction` 等命令类型常量已经移除。 + +`importer.py` 负责文件分段、导入顺序、项目事务、版本转换和导入后的物化视图刷新。`exporter.py` 负责按 EPANET 版本组织各章节并写出文件或 `ChangeSet`。 + +### commands:批量修改和级联关系 + +`api.py` 为级联删除和选项同步补齐命令元数据。`cascade.py` 将删除节点、连接、模式和曲线的请求展开为完整的关联修改。`executor.py` 在一个项目事务中执行展开后的命令。 + +元素命令分派已经由类型到处理函数的显式注册表实现。新增元素时,只需把受支持的新增、修改或删除处理函数登记到对应注册表。没有处理函数的命令保持空操作,行为与改造前一致。 + +### 命令执行与事务 + +```mermaid +flowchart LR + REQUEST[ChangeSet 请求] + EXPAND[展开级联修改] + BUILD[实体模块生成 DatabaseCommand] + EXECUTE[执行 SQL] + RESULT[返回 ChangeSet] + COMMIT[提交项目事务] + REFRESH[刷新物化视图] + + REQUEST --> EXPAND --> BUILD --> EXECUTE --> RESULT + EXECUTE --> COMMIT --> REFRESH +``` + +实体模块根据请求生成 `DatabaseCommand`,其中 `sql` 是要执行的语句,`changes` 是成功后的变更结果。批量命令先在同一个项目事务中展开级联关系,再依次执行 SQL;任何一步失败都会回滚整个事务。事务提交后统一刷新物化视图,避免一次批量修改触发多次刷新。直接调用单个实体修改时,如果当前没有外层项目事务,则由 `execute_command` 完成提交并按影响范围刷新视图。 + +旧实现中的 `DbChangeSet` 同时保存执行和撤销两套 SQL、两套变更结果,但数据库已经不再提供 operation 或 snapshot 撤销日志,这些字段没有消费者。当前代码已经删除 `undo_sql`、`undo_cs` 及各实体模块中的撤销 SQL 构造,也删除了只为撤销结果读取旧记录的查询和辅助方法。局部修改仍会读取一次当前记录,用于补齐请求中未提供的字段;这类读取属于更新语义,不是撤销机制。 + +## WNDB 之外的业务代码 + +以下代码不再放在 `app/native/wndb`: + +| 职责 | 当前路径 | 原因 | +| --- | --- | --- | +| 用水量分配计算 | `app/algorithms/water_demand/` | 属于业务算法,WNDB 只提供节点、需求和区域查询 | +| SCADA 资产查询 | `app/infra/db/postgresql/scada_assets.py` | 对应 `asset.scada_devices` 的 PostgreSQL 仓储 | +| 测压点选址结果 | `app/infra/db/postgresql/sensor_placement.py` | 对应 `analysis.runs` 和 `analysis.results` 的仓储 | +| 服务组合入口 | `app/services/tjnetwork.py` | 组合 WNDB、EPANET 和业务仓储,供接口与算法层调用 | + +`tjnetwork.py` 从约 995 行缩减到约 320 行。它不再通过 WNDB 根包获得全部函数,只显式导入当前服务使用的能力。过时的 `scripts/test_tjnetwork.py` 依赖已移除的 operation、snapshot、DMA 和旧 SCADA API,已经一并删除。 + +## 依赖方向 + +```mermaid +flowchart TD + API[HTTP 接口与业务服务] + SERVICE[services.tjnetwork] + ALGORITHM[业务算法] + REPOSITORY[PostgreSQL 业务仓储] + COMMANDS[wndb.commands] + INP[wndb.inp] + MODEL[wndb.model] + GIS[wndb.gis] + CORE[wndb.core] + DB[(项目业务数据库)] + + API --> SERVICE + API --> ALGORITHM + API --> REPOSITORY + SERVICE --> COMMANDS + SERVICE --> INP + SERVICE --> MODEL + SERVICE --> GIS + SERVICE --> REPOSITORY + COMMANDS --> MODEL + COMMANDS --> GIS + COMMANDS --> CORE + INP --> MODEL + INP --> GIS + INP --> CORE + MODEL --> CORE + GIS --> CORE + MODEL --> GIS + GIS --> MODEL + CORE --> DB + REPOSITORY --> DB +``` + +WNDB 根包不再作为依赖汇聚点。上层若只需要管道查询,应直接依赖 `model.pipes`;需要区域几何时依赖 `gis.region_geometry`;需要项目连接时依赖 `core.connection`。 + +## 仍需留意的文件规模 + +`importer.py` 和 `region_geometry.py` 行数较多,但函数仍围绕单一职责。只有在继续增加 INP 格式或区域算法时,才需要分别拆出版本转换器或边界算法模块。目前没有必要为了控制文件行数继续分层。 + +## 验证结果 + +- 本地 conda 环境全量测试:239 项通过,10 项按条件跳过。 +- Docker 镜像构建成功,镜像内全量测试结果一致。 +- `tjwater_next` 真实数据库测试:8 项通过,覆盖业务库和时序库并发借用、嵌套事务回滚、分析运行生命周期、恶意标识符转义、五张明细表的复合主键,以及 WNDB pattern 增删改、级联解除需求关联和整体回滚。 +- Python 编译、未使用导入扫描、撤销字段残留扫描和 `git diff --check` 均通过。 diff --git a/scripts/online_Analysis.py b/scripts/online_Analysis.py index 72b2dba..82e9dda 100644 --- a/scripts/online_Analysis.py +++ b/scripts/online_Analysis.py @@ -30,7 +30,6 @@ from app.services.tjnetwork import ( set_source, set_time, ) -from app.native.wndb.project import copy_project from app.algorithms.simulation.runner import run_simulation_ex, from_clock_to_seconds_2 from math import sqrt, pi from app.infra.epanet.epanet import Output diff --git a/scripts/test_tjnetwork.py b/scripts/test_tjnetwork.py deleted file mode 100644 index 908ec13..0000000 --- a/scripts/test_tjnetwork.py +++ /dev/null @@ -1,6869 +0,0 @@ -import pytest -import random -from app.services.tjnetwork import ( - API_ADD, - API_DELETE, - API_UPDATE, - CURVE, - CURVE_TYPE_EFFICIENCY, - CURVE_TYPE_PUMP, - ChangeSet, - JUNCTION, - LINK_STATUS_OPEN, - MIXING_MODEL_2COMP, - MIXING_MODEL_MIXED, - OPTION_DEMAND_MODEL_DDA, - OPTION_DEMAND_MODEL_PDA, - OPTION_HEADLOSS_DW, - OPTION_HEADLOSS_HW, - OPTION_PRESSURE_KPA, - OPTION_PRESSURE_METERS, - OPTION_PRESSURE_PSI, - OPTION_QUALITY_NONE, - OPTION_QUALITY_TRACE, - OPTION_UNBALANCED_CONTINUE, - OPTION_UNBALANCED_STOP, - OPTION_UNITS_GPM, - OPTION_UNITS_LPS, - OPTION_V3_DEMAND_MODEL_CONSTRAINED, - OPTION_V3_DEMAND_MODEL_FIXED, - OPTION_V3_DEMAND_MODEL_LOGISTIC, - OPTION_V3_DEMAND_MODEL_POWER, - OPTION_V3_FLOW_UNITS_GPM, - OPTION_V3_FLOW_UNITS_LPS, - OPTION_V3_HEADLOSS_MODEL_DW, - OPTION_V3_HEADLOSS_MODEL_HW, - OPTION_V3_IF_UNBALANCED_CONTINUE, - OPTION_V3_IF_UNBALANCED_STOP, - OPTION_V3_LEAKAGE_MODEL_NONE, - OPTION_V3_LEAKAGE_MODEL_POWER, - OPTION_V3_PRESSURE_UNITS_KPA, - OPTION_V3_PRESSURE_UNITS_METERS, - OPTION_V3_PRESSURE_UNITS_PSI, - OPTION_V3_QUALITY_MODEL_CHEMICAL, - OPTION_V3_QUALITY_MODEL_NONE, - OPTION_V3_QUALITY_MODEL_TRACE, - OPTION_V3_QUALITY_UNITS_HRS, - OPTION_V3_QUALITY_UNITS_MGL, - OPTION_V3_STEP_SIZING_FULL, - OPTION_V3_STEP_SIZING_RELAXATION, - OVERFLOW_NO, - OVERFLOW_YES, - PATTERN, - PIPE, - PIPE_STATUS_CLOSED, - PIPE_STATUS_OPEN, - PUMP, - RESERVOIR, - SCADA_DEVICE_TYPE_FLOW, - SCADA_DEVICE_TYPE_PRESSURE, - SCADA_ELEMENT_STATUS_OFFLINE, - SCADA_ELEMENT_STATUS_ONLINE, - SCADA_MODEL_TYPE_JUNCTION, - SCADA_MODEL_TYPE_PIPE, - SOURCE_TYPE_CONCEN, - SOURCE_TYPE_FLOWPACED, - TAG_TYPE_LINK, - TAG_TYPE_NODE, - TANK, - TIME_STATISTIC_AVERAGED, - TIME_STATISTIC_NONE, - VALVE, - VALVES_TYPE_FCV, - VALVES_TYPE_GPV, - add_curve, - add_district_metering_area, - add_junction, - add_label, - add_mixing, - add_pattern, - add_pipe, - add_pump, - add_region, - add_reservoir, - add_scada_device, - add_scada_device_data, - add_scada_element, - add_service_area, - add_source, - add_tank, - add_valve, - add_virtual_district, - calculate_boundary, - calculate_convex_hull, - calculate_demand_to_network, - calculate_demand_to_nodes, - calculate_demand_to_region, - calculate_district_metering_area_for_network, - calculate_district_metering_area_for_nodes, - calculate_district_metering_area_for_region, - calculate_service_area, - calculate_virtual_district, - clean_scada_device, - clean_scada_device_data, - clean_scada_element, - close_project, - create_project, - delete_curve, - delete_district_metering_area, - delete_junction, - delete_label, - delete_mixing, - delete_pattern, - delete_pipe, - delete_project, - delete_pump, - delete_region, - delete_reservoir, - delete_scada_device, - delete_scada_device_data, - delete_scada_element, - delete_service_area, - delete_source, - delete_tank, - delete_valve, - delete_virtual_district, - execute_batch_command, - execute_batch_commands, - execute_redo, - execute_undo, - generate_district_metering_area, - generate_service_area, - generate_sub_district_metering_area, - generate_virtual_district, - get_all_district_metering_area_ids, - get_all_extension_data, - get_all_extension_data_keys, - get_all_scada_device_ids, - get_all_scada_element_ids, - get_all_service_area_ids, - get_all_service_areas, - get_all_virtual_district_ids, - get_all_virtual_districts, - get_backdrop, - get_control, - get_current_operation, - get_curve, - get_demand, - get_district_metering_area, - get_emitter, - get_energy, - get_extension_data, - get_junction, - get_label, - get_links, - get_links_on_region_boundary, - get_mixing, - get_node_links, - get_nodes, - get_nodes_in_boundary, - get_nodes_in_region, - get_operation_by_snapshot, - get_option, - get_option_v3, - get_pattern, - get_pipe, - get_pipe_reaction, - get_pump, - get_pump_energy, - get_quality, - get_reaction, - get_region, - get_reservoir, - get_restore_operation, - get_rule, - get_scada_device, - get_scada_device_data, - get_scada_element, - get_service_area, - get_snapshot_by_operation, - get_source, - get_status, - get_tag, - get_tags, - get_tank, - get_tank_reaction, - get_time, - get_title, - get_valve, - get_vertex, - get_virtual_district, - have_project, - inflate_boundary, - inflate_region, - is_curve, - is_junction, - is_link, - is_node, - is_pattern, - is_pipe, - is_project_open, - is_pump, - is_reservoir, - is_tank, - is_valve, - open_project, - pick_operation, - pick_snapshot, - read_all, - read_inp, - set_backdrop, - set_control, - set_curve, - set_demand, - set_district_metering_area, - set_emitter, - set_energy, - set_extension_data, - set_junction, - set_label, - set_mixing, - set_option, - set_option_v3, - set_pattern, - set_pipe, - set_pipe_reaction, - set_pump, - set_pump_energy, - set_quality, - set_reaction, - set_region, - set_reservoir, - set_rule, - set_scada_device, - set_scada_device_data, - set_scada_element, - set_service_area, - set_source, - set_status, - set_tag, - set_tank, - set_tank_reaction, - set_time, - set_title, - set_valve, - set_vertex, - set_virtual_district, - sync_with_server, - take_snapshot, - update_snapshot_for_current_operation, - write, -) - -class TestApi: - def enter(self, p): - if is_project_open(p): - close_project(p) - - if have_project(p): - delete_project(p) - - create_project(p) - open_project(p) - - - def leave(self, p): - close_project(p) - delete_project(p) - - - # encoding - - - def test_utf8(self): - p = 'test_utf8' - self.enter(p) - - write(p, f"create table {p} (a varchar(32))") - write(p, f"insert into {p} values ('你好')") - result = read_all(p, f"select * from {p}") - assert result == [{'a': '你好'}] - - self.leave(p) - - - # project - - - def test_project(self): - p = 'test_project' - - assert not have_project(p) - assert not is_project_open(p) - - create_project(p) - - assert have_project(p) - assert not is_project_open(p) - - open_project(p) - - assert have_project(p) - assert is_project_open(p) - - close_project(p) - - assert have_project(p) - assert not is_project_open(p) - - delete_project(p) - - assert not have_project(p) - assert not is_project_open(p) - - - def test_project_name(self): - p = 'test_PROJECT_name' - - assert not have_project(p) - assert not is_project_open(p) - - create_project(p) - - assert have_project(p) - assert not is_project_open(p) - - open_project(p) - - assert have_project(p) - assert is_project_open(p) - - close_project(p) - - assert have_project(p) - assert not is_project_open(p) - - delete_project(p) - - assert not have_project(p) - assert not is_project_open(p) - - - # operation - - - def test_snapshot(self): - p = "test_snapshot" - self.enter(p) - - add_junction(p, ChangeSet({'id': 'j1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j2', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j3', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j4', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - - op_4 = get_current_operation(p) - assert op_4 == 4 - take_snapshot(p, 'x') - assert get_snapshot_by_operation(p, 4) == 'x' - assert get_operation_by_snapshot(p, 'x') == 4 - update_snapshot_for_current_operation(p, 'y') - assert get_snapshot_by_operation(p, 4) == 'y' - - execute_undo(p) - execute_undo(p) - add_junction(p, ChangeSet({'id': 'j5', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j6', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - - op_6 = get_current_operation(p) - assert op_6 == 6 - take_snapshot(p, 'xx') - assert get_snapshot_by_operation(p, 6) == 'xx' - assert get_operation_by_snapshot(p, 'xx') == 6 - - cs = sync_with_server(p, op_4).operations - cs[0]['operation'] = API_DELETE - cs[0]['id'] = 'j4' - cs[1]['operation'] = API_DELETE - cs[1]['id'] = 'j3' - cs[2]['operation'] = API_ADD - cs[2]['id'] = 'j5' - cs[3]['operation'] = API_ADD - cs[3]['id'] = 'j6' - - cs = pick_snapshot(p, 'y').operations - cs[0]['operation'] = 'delete' - cs[0]['id'] = 'j6' - cs[1]['operation'] = 'delete' - cs[1]['id'] = 'j5' - cs[2]['operation'] = 'add' - cs[2]['id'] = 'j3' - cs[3]['operation'] = 'add' - cs[3]['id'] = 'j4' - - assert get_nodes(p) == ['j1', 'j2', 'j3', 'j4'] - - self.leave(p) - - - def test_batch_commands(self): - p = 'test_batch_commands' - self.enter(p) - - cs = ChangeSet() - cs.add({'type': JUNCTION, 'id': 'j1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0}) - cs.add({'type': JUNCTION, 'id': 'j2', 'x': 0.0, 'y': 10.0, 'elevation': 20.0}) - cs.add({'type': JUNCTION, 'id': 'j2', 'x': 0.0, 'y': 10.0, 'elevation': 20.0}) # fail - - cs = execute_batch_commands(p, cs) - assert len(cs.operations) == 2 - - cs = ChangeSet() - cs.delete({'type': JUNCTION, 'id': 'j1'}) - cs.delete({'type': JUNCTION, 'id': 'j2'}) - - cs = execute_batch_commands(p, cs) - assert len(cs.operations) == 2 - - cs = execute_undo(p) - assert len(cs.operations) == 1 - - self.leave(p) - - - def test_batch_command(self): - p = 'test_batch_command' - self.enter(p) - - cs = ChangeSet() - cs.add({'type': JUNCTION, 'id': 'j1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0}) - cs.add({'type': JUNCTION, 'id': 'j2', 'x': 0.0, 'y': 10.0, 'elevation': 20.0}) - cs.add({'type': JUNCTION, 'id': 'j2', 'x': 0.0, 'y': 10.0, 'elevation': 20.0}) # fail - - cs = execute_batch_command(p, cs) - assert len(cs.operations) == 2 - - assert get_current_operation(p) == 1 - - cs = ChangeSet() - cs.delete({'type': JUNCTION, 'id': 'j1'}) - cs.delete({'type': JUNCTION, 'id': 'j2'}) - - cs = execute_batch_command(p, cs) - - assert get_current_operation(p) == 2 - - cs = execute_undo(p) - assert get_current_operation(p) == 1 - - cs = execute_undo(p) - assert get_current_operation(p) == 0 - - self.leave(p) - - - # extension_data - - - def test_extension_data(self): - p = 'test_extension_data' - self.enter(p) - - assert get_all_extension_data_keys(p) == [] - assert get_all_extension_data(p) == {} - assert get_extension_data(p, '') == None - - set_extension_data(p, ChangeSet({'key': 'key', 'value': None})) - assert get_extension_data(p, 'key') == None - - set_extension_data(p, ChangeSet({'key': 'key', 'value': ''})) - assert get_extension_data(p, 'key') == '' - - set_extension_data(p, ChangeSet({'key': 'key', 'value': 'value'})) - assert get_extension_data(p, 'key') == 'value' - - set_extension_data(p, ChangeSet({'key': 'key', 'value': 'val'})) - assert get_extension_data(p, 'key') == 'val' - - set_extension_data(p, ChangeSet({'key': 'key1', 'value': 'val1'})) - assert get_extension_data(p, 'key1') == 'val1' - - assert get_all_extension_data_keys(p) == ['key', 'key1'] - assert get_all_extension_data(p) == {'key': 'val', 'key1': 'val1'} - - self.leave(p) - - - def test_extension_data_op(self): - p = 'test_extension_data_op' - self.enter(p) - - cs = set_extension_data(p, ChangeSet({'key': 'key', 'value': ''})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'extension_data' - assert cs['key'] == 'key' - assert cs['value'] == '' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'extension_data' - assert cs['key'] == 'key' - assert cs['value'] == None - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'extension_data' - assert cs['key'] == 'key' - assert cs['value'] == '' - - cs = set_extension_data(p, ChangeSet({'key': 'key', 'value': 'value'})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'extension_data' - assert cs['key'] == 'key' - assert cs['value'] == 'value' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'extension_data' - assert cs['key'] == 'key' - assert cs['value'] == '' - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'extension_data' - assert cs['key'] == 'key' - assert cs['value'] == 'value' - - self.leave(p) - - - # complex test - - - def test_delete_node_link_then_restore(self): - p = 'test_remove_node_link_then_restore' - read_inp(p, f'./inp/net3.inp', '2') - - open_project(p) - - nodes = [] - links = [] - - nodes.append('131') - links += get_node_links(p, nodes[-1]) - delete_junction(p, ChangeSet({'id': '131'})) - - links.append('137') - delete_pipe(p, ChangeSet({'id': '137'})) - - nodes.append('129') - links += get_node_links(p, nodes[-1]) - delete_junction(p, ChangeSet({'id': '129'})) - - nodes.append('127') - links += get_node_links(p, nodes[-1]) - delete_junction(p, ChangeSet({'id': '127'})) - - links.append('135') - delete_pipe(p, ChangeSet({'id': '135'})) - - links.append('135') - delete_pipe(p, ChangeSet({'id': '133'})) - - nodes.append('20') - links += get_node_links(p, nodes[-1]) - delete_junction(p, ChangeSet({'id': '20'})) - - nodes.append('3') - links += get_node_links(p, nodes[-1]) - delete_tank(p, ChangeSet({'id': '3'})) - - for node in nodes: - assert is_node(p, node) == False - for link in links: - assert is_link(p, link) == False - - op = get_restore_operation(p) - pick_operation(p, op) - - for node in nodes: - assert is_node(p, node) - for link in links: - assert is_link(p, link) - - self.leave(p) - - - def test_delete_nodes_then_restore(self): - p = 'test_delete_nodes_then_restore' - read_inp(p, f'./inp/net3.inp', '2') - - open_project(p) - - nodes = get_nodes(p) - links = get_links(p) - - for _ in range(10): - random.shuffle(nodes) - for node in nodes: - if is_junction(p, node): - delete_junction(p, ChangeSet({'id': node})) - if is_reservoir(p, node): - delete_reservoir(p, ChangeSet({'id': node})) - if is_tank(p, node): - delete_tank(p, ChangeSet({'id': node})) - - for node in nodes: - assert is_node(p, node) == False - for link in links: - assert is_link(p, link) == False - - assert get_nodes(p) == [] - assert get_links(p) == [] - - op = get_restore_operation(p) - pick_operation(p, op) - - for node in nodes: - assert is_node(p, node) - for link in links: - assert is_link(p, link) - - self.leave(p) - - - def test_delete_links_then_restore(self): - p = 'test_delete_links_then_restore' - read_inp(p, f'./inp/net3.inp', '2') - - open_project(p) - - nodes = get_nodes(p) - links = get_links(p) - - for _ in range(10): - random.shuffle(links) - for link in links: - if is_pipe(p, link): - delete_pipe(p, ChangeSet({'id': link})) - if is_pump(p, link): - delete_pump(p, ChangeSet({'id': link})) - if is_valve(p, link): - delete_valve(p, ChangeSet({'id': link})) - - for node in nodes: - assert is_node(p, node) - for link in links: - assert is_link(p, link) == False - - assert get_links(p) == [] - - op = get_restore_operation(p) - pick_operation(p, op) - - for node in nodes: - assert is_node(p, node) - for link in links: - assert is_link(p, link) - - self.leave(p) - - - def test_delete_nodes_then_restore_commands(self): - p = 'test_delete_nodes_then_restore_commands' - read_inp(p, f'./inp/net3.inp', '2') - - open_project(p) - - nodes = get_nodes(p) - links = get_links(p) - - for _ in range(10): - random.shuffle(nodes) - - batch = ChangeSet() - for node in nodes: - if is_junction(p, node): - batch.delete({'type' : 'junction', 'id': node }) - if is_reservoir(p, node): - batch.delete({'type' : 'reservoir', 'id': node }) - if is_tank(p, node): - batch.delete({'type' : 'tank', 'id': node }) - execute_batch_commands(p, batch) - - for node in nodes: - assert is_node(p, node) == False - for link in links: - assert is_link(p, link) == False - - assert get_nodes(p) == [] - assert get_links(p) == [] - - op = get_restore_operation(p) - pick_operation(p, op) - - for node in nodes: - assert is_node(p, node) - for link in links: - assert is_link(p, link) - - self.leave(p) - - - def test_delete_links_then_restore_commands(self): - p = 'test_delete_links_then_restore_commands' - read_inp(p, f'./inp/net3.inp', '2') - - open_project(p) - - nodes = get_nodes(p) - links = get_links(p) - - for _ in range(10): - random.shuffle(links) - - batch = ChangeSet() - for link in links: - if is_pipe(p, link): - batch.delete({'type' : 'pipe', 'id': link }) - if is_pump(p, link): - batch.delete({'type' : 'pump', 'id': link }) - if is_valve(p, link): - batch.delete({'type' : 'valve', 'id': link }) - execute_batch_commands(p, batch) - - for node in nodes: - assert is_node(p, node) - for link in links: - assert is_link(p, link) == False - - assert get_links(p) == [] - - op = get_restore_operation(p) - pick_operation(p, op) - - for node in nodes: - assert is_node(p, node) - for link in links: - assert is_link(p, link) - - self.leave(p) - - - def test_delete_nodes_then_restore_command(self): - p = 'test_delete_nodes_then_restore_commands' - read_inp(p, f'./inp/net3.inp', '2') - - open_project(p) - - nodes = get_nodes(p) - links = get_links(p) - - for _ in range(10): - random.shuffle(nodes) - - batch = ChangeSet() - for node in nodes: - if is_junction(p, node): - batch.delete({'type' : 'junction', 'id': node }) - if is_reservoir(p, node): - batch.delete({'type' : 'reservoir', 'id': node }) - if is_tank(p, node): - batch.delete({'type' : 'tank', 'id': node }) - execute_batch_command(p, batch) - - for node in nodes: - assert is_node(p, node) == False - for link in links: - assert is_link(p, link) == False - - assert get_nodes(p) == [] - assert get_links(p) == [] - - op = get_restore_operation(p) - pick_operation(p, op) - - for node in nodes: - assert is_node(p, node) - for link in links: - assert is_link(p, link) - - self.leave(p) - - - def test_delete_links_then_restore_command(self): - p = 'test_delete_links_then_restore_commands' - read_inp(p, f'./inp/net3.inp', '2') - - open_project(p) - - nodes = get_nodes(p) - links = get_links(p) - - for _ in range(10): - random.shuffle(links) - - batch = ChangeSet() - for link in links: - if is_pipe(p, link): - batch.delete({'type' : 'pipe', 'id': link }) - if is_pump(p, link): - batch.delete({'type' : 'pump', 'id': link }) - if is_valve(p, link): - batch.delete({'type' : 'valve', 'id': link }) - execute_batch_command(p, batch) - - for node in nodes: - assert is_node(p, node) - for link in links: - assert is_link(p, link) == False - - assert get_links(p) == [] - - op = get_restore_operation(p) - pick_operation(p, op) - - for node in nodes: - assert is_node(p, node) - for link in links: - assert is_link(p, link) - - self.leave(p) - - - def test_delete_nodes_links_then_restore_v2(self): - p = 'test_delete_nodes_links_then_restore_v2' - read_inp(p, f'./inp/net3.inp', '2') - - open_project(p) - - nls : list[tuple[str, str]] = [] - - nodes = get_nodes(p) - for node in nodes: - nls.append(('node', node)) - - links = get_links(p) - for link in links: - nls.append(('link', link)) - - for _ in range(10): - random.shuffle(nls) - for nl in nls: - if nl[0] == 'node': - node = nl[1] - if is_junction(p, node): - delete_junction(p, ChangeSet({'id': node})) - if is_reservoir(p, node): - delete_reservoir(p, ChangeSet({'id': node})) - if is_tank(p, node): - delete_tank(p, ChangeSet({'id': node})) - else: - link = nl[1] - if is_pipe(p, link): - delete_pipe(p, ChangeSet({'id': link})) - if is_pump(p, link): - delete_pump(p, ChangeSet({'id': link})) - if is_valve(p, link): - delete_valve(p, ChangeSet({'id': link})) - - for node in nodes: - assert is_node(p, node) == False - for link in links: - assert is_link(p, link) == False - - assert get_nodes(p) == [] - assert get_links(p) == [] - - op = get_restore_operation(p) - pick_operation(p, op) - - for node in nodes: - assert is_node(p, node) - for link in links: - assert is_link(p, link) - - self.leave(p) - - - def test_delete_nodes_links_then_restore_v3(self): - p = 'test_delete_nodes_links_then_restore_v3' - read_inp(p, f'./inp/net3.inp', '3') - - open_project(p) - - nls : list[tuple[str, str]] = [] - - nodes = get_nodes(p) - for node in nodes: - nls.append(('node', node)) - - links = get_links(p) - for link in links: - nls.append(('link', link)) - - for _ in range(10): - random.shuffle(nls) - for nl in nls: - if nl[0] == 'node': - node = nl[1] - if is_junction(p, node): - delete_junction(p, ChangeSet({'id': node})) - if is_reservoir(p, node): - delete_reservoir(p, ChangeSet({'id': node})) - if is_tank(p, node): - delete_tank(p, ChangeSet({'id': node})) - else: - link = nl[1] - if is_pipe(p, link): - delete_pipe(p, ChangeSet({'id': link})) - if is_pump(p, link): - delete_pump(p, ChangeSet({'id': link})) - if is_valve(p, link): - delete_valve(p, ChangeSet({'id': link})) - - for node in nodes: - assert is_node(p, node) == False - for link in links: - assert is_link(p, link) == False - - assert get_nodes(p) == [] - assert get_links(p) == [] - - op = get_restore_operation(p) - pick_operation(p, op) - - for node in nodes: - assert is_node(p, node) - for link in links: - assert is_link(p, link) - - self.leave(p) - - - # 1 title - - - def test_title(self): - p = 'test_title' - self.enter(p) - - assert get_title(p)['value'] == '' - - change = set_title(p, ChangeSet({'value': 'title'})).operations[0] - assert change['operation'] == 'update' - assert change['type'] == 'title' - assert get_title(p)['value'] == 'title' - - set_title(p, ChangeSet({'value': 'test'})) - assert get_title(p)['value'] == 'test' - - self.leave(p) - - - def test_title_op(self): - p = 'test_title_op' - self.enter(p) - - assert get_title(p)['value'] == '' - - cs = set_title(p, ChangeSet({'value': 'title'})).operations[0] - assert cs['operation'] == 'update' - assert cs['type'] == 'title' - assert cs['value'] == 'title' - assert get_title(p)['value'] == 'title' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == 'update' - assert cs['type'] == 'title' - assert cs['value'] == '' - assert get_title(p)['value'] == '' - - cs = execute_redo(p).operations[0] - assert cs['operation'] == 'update' - assert cs['type'] == 'title' - assert cs['value'] == 'title' - assert get_title(p)['value'] == 'title' - - cs = execute_undo(p, True).operations[0] - assert cs['operation'] == 'update' - assert cs['type'] == 'title' - assert cs['value'] == '' - assert get_title(p)['value'] == '' - - cs = execute_redo(p) - assert len(cs.operations) == 0 - assert get_title(p)['value'] == '' - - self.leave(p) - - - # 2 junction - - - def test_junction(self): - p = 'test_junction' - self.enter(p) - - assert get_junction(p, 'j0') == {} - - add_junction(p, ChangeSet({'id': 'j0', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - j0 = get_junction(p, 'j0') - assert j0['x'] == 0.0 - assert j0['y'] == 10.0 - assert j0['elevation'] == 20.0 - assert j0['links'] == [] - - set_junction(p, ChangeSet({'id': 'j0', 'x': 100.0, 'y': 200.0})) - j0 = get_junction(p, 'j0') - assert j0['x'] == 100.0 - assert j0['y'] == 200.0 - - set_junction(p, ChangeSet({'id': 'j0', 'elevation': 100.0})) - j0 = get_junction(p, 'j0') - assert j0['elevation'] == 100.0 - - # TODO: pattern - - add_junction(p, ChangeSet({'id': 'j1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - nodes = get_nodes(p) - assert len(nodes) == 2 - assert nodes[0] == 'j0' - assert nodes[1] == 'j1' - assert is_junction(p, 'j0') - assert is_junction(p, 'j1') - - delete_junction(p, ChangeSet({'id': 'j1'})) - nodes = get_nodes(p) - assert len(nodes) == 1 - assert nodes[0] == 'j0' - - delete_junction(p, ChangeSet({'id': 'j0'})) - nodes = get_nodes(p) - assert len(nodes) == 0 - - assert get_junction(p, 'j0') == {} - - add_junction(p, ChangeSet({'id': 'j1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j2', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j3', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_pipe(p, ChangeSet({'id': 'p1', 'node1': 'j1', 'node2': 'j2', 'length': 100.0, 'diameter': 10.0, 'roughness': 0.1, 'minor_loss': 0.5, 'status': PIPE_STATUS_OPEN })) - add_pump(p, ChangeSet({'id': 'p2', 'node1': 'j1', 'node2': 'j2', 'power': 0.0})) - add_valve(p, ChangeSet({'id': 'v1', 'node1': 'j2', 'node2': 'j3', 'diameter': 10.0, 'v_type': VALVES_TYPE_FCV, 'setting': 0.1, 'minor_loss': 0.5 })) - assert get_junction(p, 'j1')['links'] == ['p1', 'p2'] - assert get_junction(p, 'j2')['links'] == ['p1', 'p2', 'v1'] - assert get_junction(p, 'j3')['links'] == ['v1'] - - self.leave(p) - - - def test_junction_op(self): - p = 'test_junction_op' - self.enter(p) - - cs = add_junction(p, ChangeSet({'id': 'j0', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})).operations[0] - assert cs['operation'] == 'add' - assert cs['type'] == JUNCTION - assert cs['id'] == 'j0' - assert cs['x'] == 0.0 - assert cs['y'] == 10.0 - assert cs['elevation'] == 20.0 - - cs = execute_undo(p).operations[0] - assert cs['operation'] == 'delete' - assert cs['type'] == JUNCTION - assert cs['id'] == 'j0' - - cs = execute_redo(p).operations[0] - assert cs['operation'] == 'add' - assert cs['type'] == JUNCTION - assert cs['id'] == 'j0' - assert cs['x'] == 0.0 - assert cs['y'] == 10.0 - assert cs['elevation'] == 20.0 - - cs = execute_undo(p, True).operations[0] - assert cs['operation'] == 'delete' - assert cs['type'] == JUNCTION - assert cs['id'] == 'j0' - - cs = execute_redo(p) - assert len(cs.operations) == 0 - - nodes = get_nodes(p) - assert len(nodes) == 0 - - add_junction(p, ChangeSet({'id': 'j0', 'x': 0.0, 'y': 10.0, 'elevation': 20.0, 'demand': 100.0})) - - nodes = get_nodes(p) - assert len(nodes) == 1 - - cs = delete_junction(p, ChangeSet({'id': 'j0'})).operations[0] - assert cs['operation'] == 'delete' - assert cs['type'] == JUNCTION - assert cs['id'] == 'j0' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == 'add' - assert cs['type'] == JUNCTION - assert cs['id'] == 'j0' - assert cs['x'] == 0.0 - assert cs['y'] == 10.0 - assert cs['elevation'] == 20.0 - - cs = execute_redo(p).operations[0] - assert cs['operation'] == 'delete' - assert cs['type'] == JUNCTION - assert cs['id'] == 'j0' - - cs = execute_undo(p, True).operations[0] - assert cs['operation'] == 'add' - assert cs['type'] == JUNCTION - assert cs['id'] == 'j0' - assert cs['x'] == 0.0 - assert cs['y'] == 10.0 - assert cs['elevation'] == 20.0 - - cs = execute_redo(p) - assert len(cs.operations) == 0 - - nodes = get_nodes(p) - assert len(nodes) == 1 - - cs = set_junction(p, ChangeSet({'id': 'j0', 'x': 100.0, 'y': 200.0})).operations[0] - assert cs['operation'] == 'update' - assert cs['type'] == JUNCTION - assert cs['id'] == 'j0' - assert cs['x'] == 100.0 - assert cs['y'] == 200.0 - assert cs['elevation'] == 20.0 - - cs = execute_undo(p).operations[0] - assert cs['operation'] == 'update' - assert cs['type'] == JUNCTION - assert cs['id'] == 'j0' - assert cs['x'] == 0.0 - assert cs['y'] == 10.0 - assert cs['elevation'] == 20.0 - - self.leave(p) - - - def test_junction_del(self): - p = 'test_junction_del' - self.enter(p) - - add_junction(p, ChangeSet({'id': 'j0', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_pipe(p, ChangeSet({'id': 'p0', 'node1': 'j0', 'node2': 'j1', 'length': 100.0, 'diameter': 10.0, 'roughness': 0.1, 'minor_loss': 0.5, 'status': PIPE_STATUS_OPEN })) - set_tag(p, ChangeSet({'t_type': TAG_TYPE_NODE, 'id': 'j0', 'tag': 'j0t' })) - set_demand(p, ChangeSet({'junction': 'j0', 'demands': [{'demand': 10.0, 'pattern': None, 'category': 'x'}, {'demand': 20.0, 'pattern': None, 'category': None}]})) - set_emitter(p, ChangeSet({'junction': 'j0', 'coefficient': 10.0})) - set_quality(p, ChangeSet({'node': 'j0', 'quality': 10.0})) - add_source(p, ChangeSet({'node': 'j0', 's_type': SOURCE_TYPE_CONCEN, 'strength': 10.0, 'pattern': None })) - add_label(p, ChangeSet({'x': 0.0, 'y': 0.0, 'label': 'x', 'node': 'j0'})) - assert is_junction(p, 'j0') - assert is_junction(p, 'j1') - assert is_pipe(p, 'p0') - assert get_tag(p, TAG_TYPE_NODE, 'j0')['tag'] == 'j0t' - assert get_demand(p, 'j0')['demands'] == [{'demand': 10.0, 'pattern': None, 'category': 'x'}, {'demand': 20.0, 'pattern': None, 'category': None}] - assert get_emitter(p, 'j0')['coefficient'] == 10.0 - assert get_quality(p, 'j0')['quality'] == 10.0 - assert get_source(p, 'j0')['s_type'] == SOURCE_TYPE_CONCEN - assert get_label(p, 0.0, 0.0)['node'] == 'j0' - - delete_junction(p, ChangeSet({'id': 'j0'})) - assert is_junction(p, 'j0') == False - assert is_junction(p, 'j1') - assert is_pipe(p, 'p0') == False - assert get_tag(p, TAG_TYPE_NODE, 'j0')['tag'] == None - assert get_demand(p, 'j1')['demands'] == [] - assert get_emitter(p, 'j0')['coefficient'] == None - assert get_quality(p, 'j0')['quality'] == None - assert get_source(p, 'j0') == {} - assert get_label(p, 0.0, 0.0)['node'] == None - - execute_undo(p) - assert is_junction(p, 'j0') - assert is_junction(p, 'j1') - assert is_pipe(p, 'p0') - assert get_tag(p, TAG_TYPE_NODE, 'j0')['tag'] == 'j0t' - assert get_demand(p, 'j0')['demands'] == [{'demand': 10.0, 'pattern': None, 'category': 'x'}, {'demand': 20.0, 'pattern': None, 'category': None}] - assert get_emitter(p, 'j0')['coefficient'] == 10.0 - assert get_quality(p, 'j0')['quality'] == 10.0 - assert get_source(p, 'j0')['s_type'] == SOURCE_TYPE_CONCEN - assert get_label(p, 0.0, 0.0)['node'] == 'j0' - - execute_redo(p) - assert is_junction(p, 'j0') == False - assert is_junction(p, 'j1') - assert is_pipe(p, 'p0') == False - assert get_tag(p, TAG_TYPE_NODE, 'j0')['tag'] == None - assert get_demand(p, 'j1')['demands'] == [] - assert get_emitter(p, 'j0')['coefficient'] == None - assert get_quality(p, 'j0')['quality'] == None - assert get_source(p, 'j0') == {} - assert get_label(p, 0.0, 0.0)['node'] == None - - self.leave(p) - - - # 3 reservoir - - - def test_reservoir(self): - p = 'test_reservoir' - self.enter(p) - - assert get_reservoir(p, 'r0') == {} - - add_reservoir(p, ChangeSet({'id': 'r0', 'x': 0.0, 'y': 10.0, 'head': 20.0})) - r0 = get_reservoir(p, 'r0') - assert r0['x'] == 0.0 - assert r0['y'] == 10.0 - assert r0['head'] == 20.0 - assert r0['pattern'] == None - assert r0['links'] == [] - - set_reservoir(p, ChangeSet({'id': 'r0', 'x': 100.0, 'y': 200.0})) - r0 = get_reservoir(p, 'r0') - assert r0['x'] == 100.0 - assert r0['y'] == 200.0 - - set_reservoir(p, ChangeSet({'id': 'r0', 'head': 100.0})) - r0 = get_reservoir(p, 'r0') - assert r0['head'] == 100.0 - - # TODO: pattern - - add_reservoir(p, ChangeSet({'id': 'r1', 'x': 0.0, 'y': 10.0, 'head': 20.0})) - nodes = get_nodes(p) - assert len(nodes) == 2 - assert nodes[0] == 'r0' - assert nodes[1] == 'r1' - assert is_reservoir(p, 'r0') - assert is_reservoir(p, 'r1') - - delete_reservoir(p, ChangeSet({'id': 'r1'})) - nodes = get_nodes(p) - assert len(nodes) == 1 - assert nodes[0] == 'r0' - - delete_reservoir(p, ChangeSet({'id': 'r0'})) - nodes = get_nodes(p) - assert len(nodes) == 0 - - assert get_reservoir(p, 'r0') == {} - - add_reservoir(p, ChangeSet({'id': 'r1', 'x': 0.0, 'y': 10.0, 'head': 20.0})) - add_reservoir(p, ChangeSet({'id': 'r2', 'x': 0.0, 'y': 10.0, 'head': 20.0})) - add_reservoir(p, ChangeSet({'id': 'r3', 'x': 0.0, 'y': 10.0, 'head': 20.0})) - add_pipe(p, ChangeSet({'id': 'p1', 'node1': 'r1', 'node2': 'r2', 'length': 100.0, 'diameter': 10.0, 'roughness': 0.1, 'minor_loss': 0.5, 'status': PIPE_STATUS_OPEN })) - add_pump(p, ChangeSet({'id': 'p2', 'node1': 'r1', 'node2': 'r2', 'power': 0.0})) - add_valve(p, ChangeSet({'id': 'v1', 'node1': 'r2', 'node2': 'r3', 'diameter': 10.0, 'v_type': VALVES_TYPE_FCV, 'setting': 0.1, 'minor_loss': 0.5 })) - assert get_reservoir(p, 'r1')['links'] == ['p1', 'p2'] - assert get_reservoir(p, 'r2')['links'] == ['p1', 'p2', 'v1'] - assert get_reservoir(p, 'r3')['links'] == ['v1'] - - self.leave(p) - - - def test_reservoir_op(self): - p = 'test_reservoir_op' - self.enter(p) - - cs = add_reservoir(p, ChangeSet({'id': 'r0', 'x': 0.0, 'y': 10.0, 'head': 20.0})).operations[0] - assert cs['operation'] == 'add' - assert cs['type'] == RESERVOIR - assert cs['id'] == 'r0' - assert cs['x'] == 0.0 - assert cs['y'] == 10.0 - assert cs['head'] == 20.0 - assert cs['pattern'] == None - - cs = execute_undo(p).operations[0] - assert cs['operation'] == 'delete' - assert cs['type'] == RESERVOIR - assert cs['id'] == 'r0' - - cs = execute_redo(p).operations[0] - assert cs['operation'] == 'add' - assert cs['type'] == RESERVOIR - assert cs['id'] == 'r0' - assert cs['x'] == 0.0 - assert cs['y'] == 10.0 - assert cs['head'] == 20.0 - assert cs['pattern'] == None - - cs = execute_undo(p, True).operations[0] - assert cs['operation'] == 'delete' - assert cs['type'] == RESERVOIR - assert cs['id'] == 'r0' - - cs = execute_redo(p) - assert len(cs.operations) == 0 - - nodes = get_nodes(p) - assert len(nodes) == 0 - - add_reservoir(p, ChangeSet({'id': 'r0', 'x': 0.0, 'y': 10.0, 'head': 20.0})) - - nodes = get_nodes(p) - assert len(nodes) == 1 - - cs = delete_reservoir(p, ChangeSet({'id': 'r0'})).operations[0] - assert cs['operation'] == 'delete' - assert cs['type'] == RESERVOIR - assert cs['id'] == 'r0' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == 'add' - assert cs['type'] == RESERVOIR - assert cs['id'] == 'r0' - assert cs['x'] == 0.0 - assert cs['y'] == 10.0 - assert cs['head'] == 20.0 - assert cs['pattern'] == None - - cs = execute_redo(p).operations[0] - assert cs['operation'] == 'delete' - assert cs['type'] == RESERVOIR - assert cs['id'] == 'r0' - - cs = execute_undo(p, True).operations[0] - assert cs['operation'] == 'add' - assert cs['type'] == RESERVOIR - assert cs['id'] == 'r0' - assert cs['x'] == 0.0 - assert cs['y'] == 10.0 - assert cs['head'] == 20.0 - assert cs['pattern'] == None - - cs = execute_redo(p) - assert len(cs.operations) == 0 - - nodes = get_nodes(p) - assert len(nodes) == 1 - - cs = set_reservoir(p, ChangeSet({'id': 'r0', 'x': 100.0, 'y': 200.0})).operations[0] - assert cs['operation'] == 'update' - assert cs['type'] == RESERVOIR - assert cs['id'] == 'r0' - assert cs['x'] == 100.0 - assert cs['y'] == 200.0 - assert cs['head'] == 20.0 - assert cs['pattern'] == None - - cs = execute_undo(p).operations[0] - assert cs['operation'] == 'update' - assert cs['type'] == RESERVOIR - assert cs['id'] == 'r0' - assert cs['x'] == 0.0 - assert cs['y'] == 10.0 - assert cs['head'] == 20.0 - assert cs['pattern'] == None - - self.leave(p) - - - def test_reservoir_del(self): - p = 'test_reservoir_del' - self.enter(p) - - add_junction(p, ChangeSet({'id': 'j0', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_reservoir(p, ChangeSet({'id': 'r0', 'x': 0.0, 'y': 10.0, 'head': 20.0})) - add_pipe(p, ChangeSet({'id': 'p0', 'node1': 'j0', 'node2': 'r0', 'length': 100.0, 'diameter': 10.0, 'roughness': 0.1, 'minor_loss': 0.5, 'status': PIPE_STATUS_OPEN })) - assert is_junction(p, 'j0') - assert is_reservoir(p, 'r0') - assert is_pipe(p, 'p0') - - delete_reservoir(p, ChangeSet({'id': 'r0'})) - assert is_junction(p, 'j0') - assert is_reservoir(p, 'r0') == False - assert is_pipe(p, 'p0') == False - - execute_undo(p) - assert is_junction(p, 'j0') - assert is_reservoir(p, 'r0') - assert is_pipe(p, 'p0') - - execute_redo(p) - assert is_junction(p, 'j0') - assert is_reservoir(p, 'r0') == False - assert is_pipe(p, 'p0') == False - - self.leave(p) - - - # 4 tank - - - def test_tank(self): - p = 'test_tank' - self.enter(p) - - assert get_tank(p, 't0') == {} - - add_tank(p, ChangeSet({'id': 't0', 'x': 0.0, 'y': 10.0, 'elevation': 20.0, 'init_level': 1.0, 'min_level': 0.0, 'max_level': 2.0, 'diameter': 10.0, 'min_vol': 100.0, 'vol_curve': None, 'overflow': OVERFLOW_NO})) - t0 = get_tank(p, 't0') - assert t0['x'] == 0.0 - assert t0['y'] == 10.0 - assert t0['elevation'] == 20.0 - assert t0['init_level'] == 1.0 - assert t0['min_level'] == 0.0 - assert t0['max_level'] == 2.0 - assert t0['diameter'] == 10.0 - assert t0['min_vol'] == 100.0 - assert t0['vol_curve'] == None - assert t0['overflow'] == OVERFLOW_NO - assert t0['links'] == [] - - set_tank(p, ChangeSet({'id': 't0', 'x': 100.0, 'y': 200.0})) - t0 = get_tank(p, 't0') - assert t0['x'] == 100.0 - assert t0['y'] == 200.0 - - set_tank(p, ChangeSet({'id': 't0', 'elevation': 100.0})) - t0 = get_tank(p, 't0') - assert t0['elevation'] == 100.0 - - set_tank(p, ChangeSet({'id': 't0', 'init_level': 100.0, 'min_level': 50.0, 'max_level': 200.0})) - t0 = get_tank(p, 't0') - assert t0['init_level'] == 100.0 - assert t0['min_level'] == 50.0 - assert t0['max_level'] == 200.0 - - set_tank(p, ChangeSet({'id': 't0', 'diameter': 100.0})) - t0 = get_tank(p, 't0') - assert t0['diameter'] == 100.0 - - set_tank(p, ChangeSet({'id': 't0', 'min_vol': 200.0})) - t0 = get_tank(p, 't0') - assert t0['min_vol'] == 200.0 - - # TODO: vol_curve - - set_tank(p, ChangeSet({'id': 't0', 'overflow': OVERFLOW_YES})) - t0 = get_tank(p, 't0') - assert t0['overflow'] == OVERFLOW_YES - - add_tank(p, ChangeSet({'id': 't1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0, 'init_level': 1.0, 'min_level': 0.0, 'max_level': 2.0, 'diameter': 10.0, 'min_vol': 100.0, 'vol_curve': None, 'overflow': OVERFLOW_NO})) - nodes = get_nodes(p) - assert len(nodes) == 2 - assert nodes[0] == 't0' - assert nodes[1] == 't1' - assert is_tank(p, 't0') - assert is_tank(p, 't1') - - delete_tank(p, ChangeSet({'id': 't1'})) - nodes = get_nodes(p) - assert len(nodes) == 1 - assert nodes[0] == 't0' - - delete_tank(p, ChangeSet({'id': 't0'})) - nodes = get_nodes(p) - assert len(nodes) == 0 - - assert get_tank(p, 't0') == {} - - add_tank(p, ChangeSet({'id': 't1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0, 'init_level': 1.0, 'min_level': 0.0, 'max_level': 2.0, 'diameter': 10.0, 'min_vol': 100.0, 'vol_curve': None, 'overflow': OVERFLOW_NO})) - add_tank(p, ChangeSet({'id': 't2', 'x': 0.0, 'y': 10.0, 'elevation': 20.0, 'init_level': 1.0, 'min_level': 0.0, 'max_level': 2.0, 'diameter': 10.0, 'min_vol': 100.0, 'vol_curve': None, 'overflow': OVERFLOW_NO})) - add_tank(p, ChangeSet({'id': 't3', 'x': 0.0, 'y': 10.0, 'elevation': 20.0, 'init_level': 1.0, 'min_level': 0.0, 'max_level': 2.0, 'diameter': 10.0, 'min_vol': 100.0, 'vol_curve': None, 'overflow': OVERFLOW_NO})) - add_pipe(p, ChangeSet({'id': 'p1', 'node1': 't1', 'node2': 't2', 'length': 100.0, 'diameter': 10.0, 'roughness': 0.1, 'minor_loss': 0.5, 'status': PIPE_STATUS_OPEN })) - add_pump(p, ChangeSet({'id': 'p2', 'node1': 't1', 'node2': 't2', 'power': 0.0})) - add_valve(p, ChangeSet({'id': 'v1', 'node1': 't2', 'node2': 't3', 'diameter': 10.0, 'v_type': VALVES_TYPE_FCV, 'setting': 0.1, 'minor_loss': 0.5 })) - assert get_tank(p, 't1')['links'] == ['p1', 'p2'] - assert get_tank(p, 't2')['links'] == ['p1', 'p2', 'v1'] - assert get_tank(p, 't3')['links'] == ['v1'] - - self.leave(p) - - - def test_tank_op(self): - p = 'test_tank_op' - self.enter(p) - - cs = add_tank(p, ChangeSet({'id': 't0', 'x': 0.0, 'y': 10.0, 'elevation': 20.0, 'init_level': 1.0, 'min_level': 0.0, 'max_level': 2.0, 'diameter': 10.0, 'min_vol': 100.0, 'vol_curve': None, 'overflow': OVERFLOW_NO})).operations[0] - assert cs['operation'] == 'add' - assert cs['type'] == TANK - assert cs['id'] == 't0' - assert cs['x'] == 0.0 - assert cs['y'] == 10.0 - assert cs['elevation'] == 20.0 - assert cs['init_level'] == 1.0 - assert cs['min_level'] == 0.0 - assert cs['max_level'] == 2.0 - assert cs['diameter'] == 10.0 - assert cs['min_vol'] == 100.0 - assert cs['vol_curve'] == None - assert cs['overflow'] == OVERFLOW_NO - - cs = execute_undo(p).operations[0] - assert cs['operation'] == 'delete' - assert cs['type'] == TANK - assert cs['id'] == 't0' - - cs = execute_redo(p).operations[0] - assert cs['operation'] == 'add' - assert cs['type'] == TANK - assert cs['id'] == 't0' - assert cs['x'] == 0.0 - assert cs['y'] == 10.0 - assert cs['elevation'] == 20.0 - assert cs['init_level'] == 1.0 - assert cs['min_level'] == 0.0 - assert cs['max_level'] == 2.0 - assert cs['diameter'] == 10.0 - assert cs['min_vol'] == 100.0 - assert cs['vol_curve'] == None - assert cs['overflow'] == OVERFLOW_NO - - cs = execute_undo(p, True).operations[0] - assert cs['operation'] == 'delete' - assert cs['type'] == TANK - assert cs['id'] == 't0' - - cs = execute_redo(p) - assert len(cs.operations) == 0 - - nodes = get_nodes(p) - assert len(nodes) == 0 - - add_tank(p, ChangeSet({'id': 't0', 'x': 0.0, 'y': 10.0, 'elevation': 20.0, 'init_level': 1.0, 'min_level': 0.0, 'max_level': 2.0, 'diameter': 10.0, 'min_vol': 100.0, 'vol_curve': None, 'overflow': OVERFLOW_NO})) - - nodes = get_nodes(p) - assert len(nodes) == 1 - - cs = delete_tank(p, ChangeSet({'id': 't0'})).operations[0] - assert cs['operation'] == 'delete' - assert cs['type'] == TANK - assert cs['id'] == 't0' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == 'add' - assert cs['type'] == TANK - assert cs['id'] == 't0' - assert cs['x'] == 0.0 - assert cs['y'] == 10.0 - assert cs['elevation'] == 20.0 - assert cs['init_level'] == 1.0 - assert cs['min_level'] == 0.0 - assert cs['max_level'] == 2.0 - assert cs['diameter'] == 10.0 - assert cs['min_vol'] == 100.0 - assert cs['vol_curve'] == None - assert cs['overflow'] == OVERFLOW_NO - - cs = execute_redo(p).operations[0] - assert cs['operation'] == 'delete' - assert cs['type'] == TANK - assert cs['id'] == 't0' - - cs = execute_undo(p, True).operations[0] - assert cs['operation'] == 'add' - assert cs['type'] == TANK - assert cs['id'] == 't0' - assert cs['x'] == 0.0 - assert cs['y'] == 10.0 - assert cs['elevation'] == 20.0 - assert cs['init_level'] == 1.0 - assert cs['min_level'] == 0.0 - assert cs['max_level'] == 2.0 - assert cs['diameter'] == 10.0 - assert cs['min_vol'] == 100.0 - assert cs['vol_curve'] == None - assert cs['overflow'] == OVERFLOW_NO - - cs = execute_redo(p) - assert len(cs.operations) == 0 - - nodes = get_nodes(p) - assert len(nodes) == 1 - - cs = set_tank(p, ChangeSet({'id': 't0', 'x': 100.0, 'y': 200.0})).operations[0] - assert cs['operation'] == 'update' - assert cs['type'] == TANK - assert cs['id'] == 't0' - assert cs['x'] == 100.0 - assert cs['y'] == 200.0 - assert cs['elevation'] == 20.0 - assert cs['init_level'] == 1.0 - assert cs['min_level'] == 0.0 - assert cs['max_level'] == 2.0 - assert cs['diameter'] == 10.0 - assert cs['min_vol'] == 100.0 - assert cs['vol_curve'] == None - assert cs['overflow'] == OVERFLOW_NO - - cs = execute_undo(p).operations[0] - assert cs['operation'] == 'update' - assert cs['type'] == TANK - assert cs['id'] == 't0' - assert cs['x'] == 0.0 - assert cs['y'] == 10.0 - assert cs['elevation'] == 20.0 - assert cs['init_level'] == 1.0 - assert cs['min_level'] == 0.0 - assert cs['max_level'] == 2.0 - assert cs['diameter'] == 10.0 - assert cs['min_vol'] == 100.0 - assert cs['vol_curve'] == None - assert cs['overflow'] == OVERFLOW_NO - - self.leave(p) - - - def test_tank_del(self): - p = 'test_tank_del' - self.enter(p) - - add_junction(p, ChangeSet({'id': 'j0', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_tank(p, ChangeSet({'id': 't0', 'x': 0.0, 'y': 10.0, 'elevation': 20.0, 'init_level': 1.0, 'min_level': 0.0, 'max_level': 2.0, 'diameter': 10.0, 'min_vol': 100.0, 'vol_curve': None, 'overflow': OVERFLOW_NO})) - add_pipe(p, ChangeSet({'id': 'p0', 'node1': 'j0', 'node2': 't0', 'length': 100.0, 'diameter': 10.0, 'roughness': 0.1, 'minor_loss': 0.5, 'status': PIPE_STATUS_OPEN })) - set_tank_reaction(p, ChangeSet({'tank': 't0', 'value': 10.0})) - assert is_junction(p, 'j0') - assert is_tank(p, 't0') - assert is_pipe(p, 'p0') - assert get_tank_reaction(p, 't0')['value'] == 10.0 - - delete_tank(p, ChangeSet({'id': 't0'})) - assert is_junction(p, 'j0') - assert is_tank(p, 't0') == False - assert is_pipe(p, 'p0') == False - assert get_tank_reaction(p, 't0')['value'] == None - - execute_undo(p) - assert is_junction(p, 'j0') - assert is_tank(p, 't0') - assert is_pipe(p, 'p0') - assert get_tank_reaction(p, 't0')['value'] == 10.0 - - execute_redo(p) - assert is_junction(p, 'j0') - assert is_tank(p, 't0') == False - assert is_pipe(p, 'p0') == False - assert get_tank_reaction(p, 't0')['value'] == None - - self.leave(p) - - - # 5 pipe - - - def test_pipe(self): - p = 'test_pipe' - self.enter(p) - - add_junction(p, ChangeSet({'id': 'j1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j2', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j3', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j4', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - assert is_junction(p, 'j1') - assert is_junction(p, 'j2') - assert is_junction(p, 'j3') - assert is_junction(p, 'j4') - - assert get_pipe(p, 'p0') == {} - - add_pipe(p, ChangeSet({'id': 'p0', 'node1': 'j1', 'node2': 'j2', 'length': 100.0, 'diameter': 10.0, 'roughness': 0.1, 'minor_loss': 0.5, 'status': PIPE_STATUS_OPEN })) - p0 = get_pipe(p, 'p0') - assert p0['node1'] == 'j1' - assert p0['node2'] == 'j2' - assert p0['length'] == 100.0 - assert p0['diameter'] == 10.0 - assert p0['roughness'] == 0.1 - assert p0['minor_loss'] == 0.5 - assert p0['status'] == PIPE_STATUS_OPEN - - set_pipe(p, ChangeSet({'id': 'p0', 'node1': 'j3', 'node2': 'j4'})) - p0 = get_pipe(p, 'p0') - assert p0['node1'] == 'j3' - assert p0['node2'] == 'j4' - - set_pipe(p, ChangeSet({'id': 'p0', 'length': 200.0})) - p0 = get_pipe(p, 'p0') - assert p0['length'] == 200.0 - - set_pipe(p, ChangeSet({'id': 'p0', 'diameter': 100.0})) - p0 = get_pipe(p, 'p0') - assert p0['diameter'] == 100.0 - - set_pipe(p, ChangeSet({'id': 'p0', 'roughness': 0.2})) - p0 = get_pipe(p, 'p0') - assert p0['roughness'] == 0.2 - - set_pipe(p, ChangeSet({'id': 'p0', 'minor_loss': 0.1})) - p0 = get_pipe(p, 'p0') - assert p0['minor_loss'] == 0.1 - - set_pipe(p, ChangeSet({'id': 'p0', 'status': PIPE_STATUS_CLOSED})) - p0 = get_pipe(p, 'p0') - assert p0['status'] == PIPE_STATUS_CLOSED - - add_pipe(p, ChangeSet({'id': 'p1', 'node1': 'j1', 'node2': 'j2', 'length': 100.0, 'diameter': 10.0, 'roughness': 0.1, 'minor_loss': 0.5, 'status': PIPE_STATUS_OPEN })) - links = get_links(p) - assert len(links) == 2 - assert links[0] == 'p0' - assert links[1] == 'p1' - assert is_pipe(p, 'p0') - assert is_pipe(p, 'p1') - - delete_pipe(p, ChangeSet({'id': 'p1'})) - links = get_links(p) - assert len(links) == 1 - assert links[0] == 'p0' - - delete_pipe(p, ChangeSet({'id': 'p0'})) - links = get_links(p) - assert len(links) == 0 - - assert get_pipe(p, 'p0') == {} - - self.leave(p) - - - def test_pipe_op(self): - p = 'test_pipe_op' - self.enter(p) - - add_junction(p, ChangeSet({'id': 'j1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j2', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j3', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j4', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - assert is_junction(p, 'j1') - assert is_junction(p, 'j2') - assert is_junction(p, 'j3') - assert is_junction(p, 'j4') - - cs = add_pipe(p, ChangeSet({'id': 'p0', 'node1': 'j1', 'node2': 'j2', 'length': 100.0, 'diameter': 10.0, 'roughness': 0.1, 'minor_loss': 0.5, 'status': PIPE_STATUS_OPEN })).operations[0] - assert cs['operation'] == 'add' - assert cs['type'] == PIPE - assert cs['id'] == 'p0' - assert cs['node1'] == 'j1' - assert cs['node2'] == 'j2' - assert cs['length'] == 100.0 - assert cs['diameter'] == 10.0 - assert cs['roughness'] == 0.1 - assert cs['minor_loss'] == 0.5 - assert cs['status'] == PIPE_STATUS_OPEN - - cs = execute_undo(p).operations[0] - assert cs['operation'] == 'delete' - assert cs['type'] == PIPE - assert cs['id'] == 'p0' - - cs = execute_redo(p).operations[0] - assert cs['operation'] == 'add' - assert cs['type'] == PIPE - assert cs['id'] == 'p0' - assert cs['node1'] == 'j1' - assert cs['node2'] == 'j2' - assert cs['length'] == 100.0 - assert cs['diameter'] == 10.0 - assert cs['roughness'] == 0.1 - assert cs['minor_loss'] == 0.5 - assert cs['status'] == PIPE_STATUS_OPEN - - cs = execute_undo(p, True).operations[0] - assert cs['operation'] == 'delete' - assert cs['type'] == PIPE - assert cs['id'] == 'p0' - - cs = execute_redo(p) - assert len(cs.operations) == 0 - - links = get_links(p) - assert len(links) == 0 - - add_pipe(p, ChangeSet({'id': 'p0', 'node1': 'j1', 'node2': 'j2', 'length': 100.0, 'diameter': 10.0, 'roughness': 0.1, 'minor_loss': 0.5, 'status': PIPE_STATUS_OPEN })) - - links = get_links(p) - assert len(links) == 1 - - cs = delete_pipe(p, ChangeSet({'id': 'p0'})).operations[0] - assert cs['operation'] == 'delete' - assert cs['type'] == PIPE - assert cs['id'] == 'p0' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == 'add' - assert cs['type'] == PIPE - assert cs['id'] == 'p0' - assert cs['node1'] == 'j1' - assert cs['node2'] == 'j2' - assert cs['length'] == 100.0 - assert cs['diameter'] == 10.0 - assert cs['roughness'] == 0.1 - assert cs['minor_loss'] == 0.5 - assert cs['status'] == PIPE_STATUS_OPEN - - cs = execute_redo(p).operations[0] - assert cs['operation'] == 'delete' - assert cs['type'] == PIPE - assert cs['id'] == 'p0' - - cs = execute_undo(p, True).operations[0] - assert cs['operation'] == 'add' - assert cs['type'] == PIPE - assert cs['id'] == 'p0' - assert cs['node1'] == 'j1' - assert cs['node2'] == 'j2' - assert cs['length'] == 100.0 - assert cs['diameter'] == 10.0 - assert cs['roughness'] == 0.1 - assert cs['minor_loss'] == 0.5 - assert cs['status'] == PIPE_STATUS_OPEN - - cs = execute_redo(p) - assert len(cs.operations) == 0 - - links = get_links(p) - assert len(links) == 1 - - cs = set_pipe(p, ChangeSet({'id': 'p0', 'node1': 'j3', 'node2': 'j4'})).operations[0] - assert cs['operation'] == 'update' - assert cs['type'] == PIPE - assert cs['id'] == 'p0' - assert cs['node1'] == 'j3' - assert cs['node2'] == 'j4' - assert cs['length'] == 100.0 - assert cs['diameter'] == 10.0 - assert cs['roughness'] == 0.1 - assert cs['minor_loss'] == 0.5 - assert cs['status'] == PIPE_STATUS_OPEN - - cs = execute_undo(p).operations[0] - assert cs['operation'] == 'update' - assert cs['type'] == PIPE - assert cs['id'] == 'p0' - assert cs['node1'] == 'j1' - assert cs['node2'] == 'j2' - assert cs['length'] == 100.0 - assert cs['diameter'] == 10.0 - assert cs['roughness'] == 0.1 - assert cs['minor_loss'] == 0.5 - assert cs['status'] == PIPE_STATUS_OPEN - - self.leave(p) - - - def test_pipe_del(self): - p = 'test_pipe_del' - self.enter(p) - - add_junction(p, ChangeSet({'id': 'j0', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_pipe(p, ChangeSet({'id': 'p0', 'node1': 'j0', 'node2': 'j1', 'length': 100.0, 'diameter': 10.0, 'roughness': 0.1, 'minor_loss': 0.5, 'status': PIPE_STATUS_OPEN })) - set_tag(p, ChangeSet({'t_type': TAG_TYPE_LINK, 'id': 'p0', 'tag': 'p0t' })) - set_status(p, ChangeSet({'link': 'p0', 'status': LINK_STATUS_OPEN, 'setting': 10.0})) - set_pipe_reaction(p, ChangeSet({'pipe': 'p0', 'bulk': 10.0, 'wall': 20.0})) - set_vertex(p, ChangeSet({'link' : 'p0', 'coords': [{'x': 1.0, 'y': 2.0}, {'x': 2.0, 'y': 1.0}]})) - assert is_pipe(p, 'p0') - assert get_tag(p, TAG_TYPE_LINK, 'p0')['tag'] == 'p0t' - assert get_status(p, 'p0')['status'] == LINK_STATUS_OPEN - assert get_status(p, 'p0')['setting'] == 10.0 - assert get_pipe_reaction(p, 'p0')['bulk'] == 10.0 - assert get_pipe_reaction(p, 'p0')['wall'] == 20.0 - assert get_vertex(p, 'p0')['coords'] == [{'x': 1.0, 'y': 2.0}, {'x': 2.0, 'y': 1.0}] - - delete_pipe(p, ChangeSet({'id': 'p0'})) - assert is_pipe(p, 'p0') == False - assert get_tag(p, TAG_TYPE_LINK, 'p0')['tag'] == None - assert get_status(p, 'p0')['status'] == None - assert get_status(p, 'p0')['setting'] == None - assert get_pipe_reaction(p, 'p0')['bulk'] == None - assert get_pipe_reaction(p, 'p0')['wall'] == None - assert get_vertex(p, 'p0')['coords'] == [] - - execute_undo(p) - assert is_pipe(p, 'p0') - assert get_tag(p, TAG_TYPE_LINK, 'p0')['tag'] == 'p0t' - assert get_status(p, 'p0')['status'] == LINK_STATUS_OPEN - assert get_status(p, 'p0')['setting'] == 10.0 - assert get_pipe_reaction(p, 'p0')['bulk'] == 10.0 - assert get_pipe_reaction(p, 'p0')['wall'] == 20.0 - assert get_vertex(p, 'p0')['coords'] == [{'x': 1.0, 'y': 2.0}, {'x': 2.0, 'y': 1.0}] - - execute_redo(p) - assert is_pipe(p, 'p0') == False - assert get_tag(p, TAG_TYPE_LINK, 'p0')['tag'] == None - assert get_status(p, 'p0')['status'] == None - assert get_status(p, 'p0')['setting'] == None - assert get_pipe_reaction(p, 'p0')['bulk'] == None - assert get_pipe_reaction(p, 'p0')['wall'] == None - assert get_vertex(p, 'p0')['coords'] == [] - - self.leave(p) - - - # 6 pump - - - def test_pump(self): - p = 'test_pump' - self.enter(p) - - add_junction(p, ChangeSet({'id': 'j1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j2', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j3', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j4', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - assert is_junction(p, 'j1') - assert is_junction(p, 'j2') - assert is_junction(p, 'j3') - assert is_junction(p, 'j4') - - assert get_pump(p, 'p0') == {} - - add_pump(p, ChangeSet({'id': 'p0', 'node1': 'j1', 'node2': 'j2', 'power': 0.0})) - p0 = get_pump(p, 'p0') - assert p0['node1'] == 'j1' - assert p0['node2'] == 'j2' - assert p0['power'] == 0.0 - assert p0['head'] == None - assert p0['speed'] == None - assert p0['pattern'] == None - - set_pump(p, ChangeSet({'id': 'p0', 'node1': 'j3', 'node2': 'j4'})) - p0 = get_pump(p, 'p0') - assert p0['node1'] == 'j3' - assert p0['node2'] == 'j4' - - set_pump(p, ChangeSet({'id': 'p0', 'power': 100.0})) - p0 = get_pump(p, 'p0') - assert p0['power'] == 100.0 - - add_pump(p, ChangeSet({'id': 'p1', 'node1': 'j1', 'node2': 'j2', 'power': 0.0})) - links = get_links(p) - assert len(links) == 2 - assert links[0] == 'p0' - assert links[1] == 'p1' - assert is_pump(p, 'p0') - assert is_pump(p, 'p1') - - delete_pump(p, ChangeSet({'id': 'p1'})) - links = get_links(p) - assert len(links) == 1 - assert links[0] == 'p0' - - delete_pump(p, ChangeSet({'id': 'p0'})) - links = get_links(p) - assert len(links) == 0 - - assert get_pump(p, 'p0') == {} - - self.leave(p) - - - def test_pump_op(self): - p = 'test_pump_op' - self.enter(p) - - add_junction(p, ChangeSet({'id': 'j1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j2', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j3', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j4', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - assert is_junction(p, 'j1') - assert is_junction(p, 'j2') - assert is_junction(p, 'j3') - assert is_junction(p, 'j4') - - cs = add_pump(p, ChangeSet({'id': 'p0', 'node1': 'j1', 'node2': 'j2', 'power': 0.0})).operations[0] - assert cs['operation'] == 'add' - assert cs['type'] == PUMP - assert cs['id'] == 'p0' - assert cs['node1'] == 'j1' - assert cs['node2'] == 'j2' - assert cs['power'] == 0.0 - - cs = execute_undo(p).operations[0] - assert cs['operation'] == 'delete' - assert cs['type'] == PUMP - assert cs['id'] == 'p0' - - cs = execute_redo(p).operations[0] - assert cs['operation'] == 'add' - assert cs['type'] == PUMP - assert cs['id'] == 'p0' - assert cs['node1'] == 'j1' - assert cs['node2'] == 'j2' - - cs = execute_undo(p, True).operations[0] - assert cs['operation'] == 'delete' - assert cs['type'] == PUMP - assert cs['id'] == 'p0' - - cs = execute_redo(p) - assert len(cs.operations) == 0 - - links = get_links(p) - assert len(links) == 0 - - add_pump(p, ChangeSet({'id': 'p0', 'node1': 'j1', 'node2': 'j2', 'power': 0.0})) - - links = get_links(p) - assert len(links) == 1 - - cs = delete_pump(p, ChangeSet({'id': 'p0'})).operations[0] - assert cs['operation'] == 'delete' - assert cs['type'] == PUMP - assert cs['id'] == 'p0' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == 'add' - assert cs['type'] == PUMP - assert cs['id'] == 'p0' - assert cs['node1'] == 'j1' - assert cs['node2'] == 'j2' - - cs = execute_redo(p).operations[0] - assert cs['operation'] == 'delete' - assert cs['type'] == PUMP - assert cs['id'] == 'p0' - - cs = execute_undo(p, True).operations[0] - assert cs['operation'] == 'add' - assert cs['type'] == PUMP - assert cs['id'] == 'p0' - assert cs['node1'] == 'j1' - assert cs['node2'] == 'j2' - - cs = execute_redo(p) - assert len(cs.operations) == 0 - - links = get_links(p) - assert len(links) == 1 - - cs = set_pump(p, ChangeSet({'id': 'p0', 'node1': 'j3', 'node2': 'j4'})).operations[0] - assert cs['operation'] == 'update' - assert cs['type'] == PUMP - assert cs['id'] == 'p0' - assert cs['node1'] == 'j3' - assert cs['node2'] == 'j4' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == 'update' - assert cs['type'] == PUMP - assert cs['id'] == 'p0' - assert cs['node1'] == 'j1' - assert cs['node2'] == 'j2' - - cs = set_pump(p, ChangeSet({'id': 'p0', 'power': 100.0})).operations[0] - assert cs['operation'] == 'update' - assert cs['type'] == PUMP - assert cs['id'] == 'p0' - assert cs['power'] == 100.0 - - cs = execute_undo(p).operations[0] - assert cs['operation'] == 'update' - assert cs['type'] == PUMP - assert cs['id'] == 'p0' - assert cs['power'] == 0.0 - - self.leave(p) - - - def test_pump_del(self): - p = 'test_pump_del' - self.enter(p) - - add_junction(p, ChangeSet({'id': 'j0', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_pump(p, ChangeSet({'id': 'p0', 'node1': 'j0', 'node2': 'j1', 'power': 0.0})) - set_tag(p, ChangeSet({'t_type': TAG_TYPE_LINK, 'id': 'p0', 'tag': 'p0t' })) - set_status(p, ChangeSet({'link': 'p0', 'status': LINK_STATUS_OPEN, 'setting': 10.0})) - set_pump_energy(p, ChangeSet({'pump' : 'p0', 'price': 1.0})) - set_vertex(p, ChangeSet({'link' : 'p0', 'coords': [{'x': 1.0, 'y': 2.0}, {'x': 2.0, 'y': 1.0}]})) - assert is_pump(p, 'p0') - assert get_tag(p, TAG_TYPE_LINK, 'p0')['tag'] == 'p0t' - assert get_status(p, 'p0')['status'] == LINK_STATUS_OPEN - assert get_status(p, 'p0')['setting'] == 10.0 - assert get_pump_energy(p, 'p0')['price'] == 1.0 - assert get_vertex(p, 'p0')['coords'] == [{'x': 1.0, 'y': 2.0}, {'x': 2.0, 'y': 1.0}] - - delete_pump(p, ChangeSet({'id': 'p0'})) - assert is_pump(p, 'p0') == False - assert get_tag(p, TAG_TYPE_LINK, 'p0')['tag'] == None - assert get_status(p, 'p0')['status'] == None - assert get_status(p, 'p0')['setting'] == None - assert get_pump_energy(p, 'p0')['price'] == None - assert get_vertex(p, 'p0')['coords'] == [] - - execute_undo(p) - assert is_pump(p, 'p0') - assert get_tag(p, TAG_TYPE_LINK, 'p0')['tag'] == 'p0t' - assert get_status(p, 'p0')['status'] == LINK_STATUS_OPEN - assert get_status(p, 'p0')['setting'] == 10.0 - assert get_pump_energy(p, 'p0')['price'] == 1.0 - assert get_vertex(p, 'p0')['coords'] == [{'x': 1.0, 'y': 2.0}, {'x': 2.0, 'y': 1.0}] - - execute_redo(p) - assert is_pump(p, 'p0') == False - assert get_tag(p, TAG_TYPE_LINK, 'p0')['tag'] == None - assert get_status(p, 'p0')['status'] == None - assert get_status(p, 'p0')['setting'] == None - assert get_pump_energy(p, 'p0')['price'] == None - assert get_vertex(p, 'p0')['coords'] == [] - - self.leave(p) - - - # 7 valve - - - def test_valve(self): - p = 'test_valve' - self.enter(p) - - add_junction(p, ChangeSet({'id': 'j1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j2', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j3', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j4', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - assert is_junction(p, 'j1') - assert is_junction(p, 'j2') - assert is_junction(p, 'j3') - assert is_junction(p, 'j4') - - assert get_valve(p, 'v0') == {} - - add_valve(p, ChangeSet({'id': 'v0', 'node1': 'j1', 'node2': 'j2', 'diameter': 10.0, 'v_type': VALVES_TYPE_FCV, 'setting': '0.1', 'minor_loss': 0.5 })) - v0 = get_valve(p, 'v0') - assert v0['node1'] == 'j1' - assert v0['node2'] == 'j2' - assert v0['diameter'] == 10.0 - assert v0['v_type'] == VALVES_TYPE_FCV - assert v0['setting'] == '0.1' - assert v0['minor_loss'] == 0.5 - - set_valve(p, ChangeSet({'id': 'v0', 'node1': 'j3', 'node2': 'j4'})) - v0 = get_valve(p, 'v0') - assert v0['node1'] == 'j3' - assert v0['node2'] == 'j4' - - set_valve(p, ChangeSet({'id': 'v0', 'diameter': 100.0})) - v0 = get_valve(p, 'v0') - assert v0['diameter'] == 100.0 - - set_valve(p, ChangeSet({'id': 'v0', 'v_type': VALVES_TYPE_GPV})) - v0 = get_valve(p, 'v0') - assert v0['v_type'] == VALVES_TYPE_GPV - - set_valve(p, ChangeSet({'id': 'v0', 'setting': '0.2'})) - v0 = get_valve(p, 'v0') - assert v0['setting'] == '0.2' - - set_valve(p, ChangeSet({'id': 'v0', 'minor_loss': 0.1})) - v0 = get_valve(p, 'v0') - assert v0['minor_loss'] == 0.1 - - add_valve(p, ChangeSet({'id': 'v1', 'node1': 'j1', 'node2': 'j2', 'diameter': 10.0, 'v_type': VALVES_TYPE_FCV, 'setting': '0.1', 'minor_loss': 0.5 })) - links = get_links(p) - assert len(links) == 2 - assert links[0] == 'v0' - assert links[1] == 'v1' - assert is_valve(p, 'v0') - assert is_valve(p, 'v1') - - delete_valve(p, ChangeSet({'id': 'v1'})) - links = get_links(p) - assert len(links) == 1 - assert links[0] == 'v0' - - delete_valve(p, ChangeSet({'id': 'v0'})) - links = get_links(p) - assert len(links) == 0 - - assert get_valve(p, 'v0') == {} - - self.leave(p) - - - def test_valve_op(self): - p = 'test_valve_op' - self.enter(p) - - add_junction(p, ChangeSet({'id': 'j1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j2', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j3', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j4', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - assert is_junction(p, 'j1') - assert is_junction(p, 'j2') - assert is_junction(p, 'j3') - assert is_junction(p, 'j4') - - cs = add_valve(p, ChangeSet({'id': 'v0', 'node1': 'j1', 'node2': 'j2', 'diameter': 10.0, 'v_type': VALVES_TYPE_FCV, 'setting': '0.1', 'minor_loss': 0.5 })).operations[0] - assert cs['operation'] == 'add' - assert cs['type'] == VALVE - assert cs['id'] == 'v0' - assert cs['node1'] == 'j1' - assert cs['node2'] == 'j2' - assert cs['diameter'] == 10.0 - assert cs['v_type'] == VALVES_TYPE_FCV - assert cs['setting'] == '0.1' - assert cs['minor_loss'] == 0.5 - - cs = execute_undo(p).operations[0] - assert cs['operation'] == 'delete' - assert cs['type'] == VALVE - assert cs['id'] == 'v0' - - cs = execute_redo(p).operations[0] - assert cs['operation'] == 'add' - assert cs['type'] == VALVE - assert cs['id'] == 'v0' - assert cs['node1'] == 'j1' - assert cs['node2'] == 'j2' - assert cs['diameter'] == 10.0 - assert cs['v_type'] == VALVES_TYPE_FCV - assert cs['setting'] == '0.1' - assert cs['minor_loss'] == 0.5 - - cs = execute_undo(p, True).operations[0] - assert cs['operation'] == 'delete' - assert cs['type'] == VALVE - assert cs['id'] == 'v0' - - cs = execute_redo(p) - assert len(cs.operations) == 0 - - links = get_links(p) - assert len(links) == 0 - - add_valve(p, ChangeSet({'id': 'v0', 'node1': 'j1', 'node2': 'j2', 'diameter': 10.0, 'v_type': VALVES_TYPE_FCV, 'setting': '0.1', 'minor_loss': 0.5 })) - - links = get_links(p) - assert len(links) == 1 - - cs = delete_valve(p, ChangeSet({'id': 'v0'})).operations[0] - assert cs['operation'] == 'delete' - assert cs['type'] == VALVE - assert cs['id'] == 'v0' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == 'add' - assert cs['type'] == VALVE - assert cs['id'] == 'v0' - assert cs['node1'] == 'j1' - assert cs['node2'] == 'j2' - assert cs['diameter'] == 10.0 - assert cs['v_type'] == VALVES_TYPE_FCV - assert cs['setting'] == '0.1' - assert cs['minor_loss'] == 0.5 - - cs = execute_redo(p).operations[0] - assert cs['operation'] == 'delete' - assert cs['type'] == VALVE - assert cs['id'] == 'v0' - - cs = execute_undo(p, True).operations[0] - assert cs['operation'] == 'add' - assert cs['type'] == VALVE - assert cs['id'] == 'v0' - assert cs['node1'] == 'j1' - assert cs['node2'] == 'j2' - assert cs['diameter'] == 10.0 - assert cs['v_type'] == VALVES_TYPE_FCV - assert cs['setting'] == '0.1' - assert cs['minor_loss'] == 0.5 - - cs = execute_redo(p) - assert len(cs.operations) == 0 - - links = get_links(p) - assert len(links) == 1 - - cs = set_valve(p, ChangeSet({'id': 'v0', 'node1': 'j3', 'node2': 'j4'})).operations[0] - assert cs['operation'] == 'update' - assert cs['type'] == VALVE - assert cs['id'] == 'v0' - assert cs['node1'] == 'j3' - assert cs['node2'] == 'j4' - assert cs['diameter'] == 10.0 - assert cs['v_type'] == VALVES_TYPE_FCV - assert cs['setting'] == '0.1' - assert cs['minor_loss'] == 0.5 - - cs = execute_undo(p).operations[0] - assert cs['operation'] == 'update' - assert cs['type'] == VALVE - assert cs['id'] == 'v0' - assert cs['node1'] == 'j1' - assert cs['node2'] == 'j2' - assert cs['diameter'] == 10.0 - assert cs['v_type'] == VALVES_TYPE_FCV - assert cs['setting'] == '0.1' - assert cs['minor_loss'] == 0.5 - - self.leave(p) - - - def test_valve_del(self): - p = 'test_valve_del' - self.enter(p) - - add_junction(p, ChangeSet({'id': 'j0', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_valve(p, ChangeSet({'id': 'v0', 'node1': 'j0', 'node2': 'j1', 'diameter': 10.0, 'v_type': VALVES_TYPE_FCV, 'setting': '0.1', 'minor_loss': 0.5 })) - set_tag(p, ChangeSet({'t_type': TAG_TYPE_LINK, 'id': 'v0', 'tag': 'v0t' })) - set_status(p, ChangeSet({'link': 'v0', 'status': LINK_STATUS_OPEN, 'setting': 10.0})) - set_vertex(p, ChangeSet({'link' : 'v0', 'coords': [{'x': 1.0, 'y': 2.0}, {'x': 2.0, 'y': 1.0}]})) - assert is_valve(p, 'v0') - assert get_tag(p, TAG_TYPE_LINK, 'v0')['tag'] == 'v0t' - assert get_status(p, 'v0')['status'] == LINK_STATUS_OPEN - assert get_status(p, 'v0')['setting'] == 10.0 - assert get_vertex(p, 'v0')['coords'] == [{'x': 1.0, 'y': 2.0}, {'x': 2.0, 'y': 1.0}] - - delete_valve(p, ChangeSet({'id': 'v0'})) - assert is_valve(p, 'v0') == False - assert get_tag(p, TAG_TYPE_LINK, 'v0')['tag'] == None - assert get_status(p, 'v0')['status'] == None - assert get_status(p, 'v0')['setting'] == None - assert get_vertex(p, 'v0')['coords'] == [] - - execute_undo(p) - assert is_valve(p, 'v0') - assert get_tag(p, TAG_TYPE_LINK, 'v0')['tag'] == 'v0t' - assert get_status(p, 'v0')['status'] == LINK_STATUS_OPEN - assert get_status(p, 'v0')['setting'] == 10.0 - assert get_vertex(p, 'v0')['coords'] == [{'x': 1.0, 'y': 2.0}, {'x': 2.0, 'y': 1.0}] - - execute_redo(p) - assert is_valve(p, 'v0') == False - assert get_tag(p, TAG_TYPE_LINK, 'v0')['tag'] == None - assert get_status(p, 'v0')['status'] == None - assert get_status(p, 'v0')['setting'] == None - assert get_vertex(p, 'v0')['coords'] == [] - - self.leave(p) - - - # 8 tag - - - def test_tag(self): - p = 'test_tag' - self.enter(p) - - add_junction(p, ChangeSet({'id': 'j1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j2', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - assert is_junction(p, 'j1') - assert is_junction(p, 'j2') - - add_pipe(p, ChangeSet({'id': 'p0', 'node1': 'j1', 'node2': 'j2', 'length': 100.0, 'diameter': 10.0, 'roughness': 0.1, 'minor_loss': 0.5, 'status': PIPE_STATUS_OPEN })) - assert is_pipe(p, 'p0') - - t = get_tag(p, TAG_TYPE_NODE, 'j1') - assert t['tag'] == None - set_tag(p, ChangeSet({'t_type': TAG_TYPE_NODE, 'id': 'j1', 'tag': 'j1t' })) - t = get_tag(p, TAG_TYPE_NODE, 'j1') - assert t['tag'] == 'j1t' - - tags = get_tags(p) - assert len(tags) == 1 - assert tags[0]['t_type'] == TAG_TYPE_NODE - assert tags[0]['id'] == 'j1' - assert tags[0]['tag'] == 'j1t' - - set_tag(p, ChangeSet({'t_type': TAG_TYPE_NODE, 'id': 'j1', 'tag': None })) - t = get_tag(p, TAG_TYPE_NODE, 'j1') - assert t['tag'] == None - - tags = get_tags(p) - assert len(tags) == 0 - - t = get_tag(p, TAG_TYPE_NODE, 'j2') - assert t['tag'] == None - set_tag(p, ChangeSet({'t_type': TAG_TYPE_NODE, 'id': 'j2', 'tag': 'j2t' })) - t = get_tag(p, TAG_TYPE_NODE, 'j2') - assert t['tag'] == 'j2t' - - t = get_tag(p, TAG_TYPE_LINK, 'p0') - assert t['tag'] == None - set_tag(p, ChangeSet({'t_type': TAG_TYPE_LINK, 'id': 'p0', 'tag': 'p0t' })) - t = get_tag(p, TAG_TYPE_LINK, 'p0') - assert t['tag'] == 'p0t' - - tags = get_tags(p) - assert len(tags) == 2 - assert tags[0]['t_type'] == TAG_TYPE_NODE - assert tags[0]['id'] == 'j2' - assert tags[0]['tag'] == 'j2t' - assert tags[1]['t_type'] == TAG_TYPE_LINK - assert tags[1]['id'] == 'p0' - assert tags[1]['tag'] == 'p0t' - - self.leave(p) - - - def test_tag_op(self): - p = 'test_tag_op' - self.enter(p) - - add_junction(p, ChangeSet({'id': 'j1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j2', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - assert is_junction(p, 'j1') - assert is_junction(p, 'j2') - - add_pipe(p, ChangeSet({'id': 'p0', 'node1': 'j1', 'node2': 'j2', 'length': 100.0, 'diameter': 10.0, 'roughness': 0.1, 'minor_loss': 0.5, 'status': PIPE_STATUS_OPEN })) - assert is_pipe(p, 'p0') - - cs = set_tag(p, ChangeSet({'t_type': TAG_TYPE_NODE, 'id': 'j1', 'tag': 'j1t' })).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'tag' - assert cs['t_type'] == TAG_TYPE_NODE - assert cs['id'] == 'j1' - assert cs['tag'] == 'j1t' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'tag' - assert cs['t_type'] == TAG_TYPE_NODE - assert cs['id'] == 'j1' - assert cs['tag'] == None - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'tag' - assert cs['t_type'] == TAG_TYPE_NODE - assert cs['id'] == 'j1' - assert cs['tag'] == 'j1t' - - cs = set_tag(p, ChangeSet({'t_type': TAG_TYPE_LINK, 'id': 'p0', 'tag': 'p0t' })).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'tag' - assert cs['t_type'] == TAG_TYPE_LINK - assert cs['id'] == 'p0' - assert cs['tag'] == 'p0t' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'tag' - assert cs['t_type'] == TAG_TYPE_LINK - assert cs['id'] == 'p0' - assert cs['tag'] == None - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'tag' - assert cs['t_type'] == TAG_TYPE_LINK - assert cs['id'] == 'p0' - assert cs['tag'] == 'p0t' - - self.leave(p) - - - # 9 demand - - - def test_demand(self): - p = 'test_demand' - self.enter(p) - - add_junction(p, ChangeSet({'id': 'j1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - assert is_junction(p, 'j1') - - d = get_demand(p, 'j1') - assert d['junction'] == 'j1' - assert d['demands'] == [] - - set_demand(p, ChangeSet({'junction': 'j1', 'demands': [{'demand': 10.0, 'pattern': None, 'category': 'x'}, - {'demand': 20.0, 'pattern': None, 'category': None}]})) - - d = get_demand(p, 'j1') - assert d['junction'] == 'j1' - ds = d['demands'] - assert len(ds) == 2 - assert ds[0]['demand'] == 10.0 - assert ds[0]['pattern'] == None - assert ds[0]['category'] == 'x' - assert ds[1]['demand'] == 20.0 - assert ds[1]['pattern'] == None - assert ds[1]['category'] == None - - set_demand(p, ChangeSet({'junction': 'j1', 'demands': []})) - - d = get_demand(p, 'j1') - assert d['junction'] == 'j1' - assert d['demands'] == [] - - self.leave(p) - - - def test_demand_op(self): - p = 'test_demand_op' - self.enter(p) - - add_junction(p, ChangeSet({'id': 'j1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - assert is_junction(p, 'j1') - - result = set_demand(p, ChangeSet({'junction': 'j1', 'demands': [{'demand': 10.0, 'pattern': None, 'category': 'x'}, - {'demand': 20.0, 'pattern': None, 'category': None}]})) - cs = result.operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'demand' - assert cs['junction'] == 'j1' - ds = cs['demands'] - assert len(ds) == 2 - assert ds[0]['demand'] == 10.0 - assert ds[0]['pattern'] == None - assert ds[0]['category'] == 'x' - assert ds[1]['demand'] == 20.0 - assert ds[1]['pattern'] == None - assert ds[1]['category'] == None - - result = execute_undo(p) - cs = result.operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'demand' - assert cs['junction'] == 'j1' - assert len(cs['demands']) == 0 - - result = execute_redo(p) - cs = result.operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'demand' - assert cs['junction'] == 'j1' - ds = cs['demands'] - assert len(ds) == 2 - assert ds[0]['demand'] == 10.0 - assert ds[0]['pattern'] == None - assert ds[0]['category'] == 'x' - assert ds[1]['demand'] == 20.0 - assert ds[1]['pattern'] == None - assert ds[1]['category'] == None - - self.leave(p) - - - # 10 status - - - def test_status(self): - p = 'test_status' - self.enter(p) - - add_junction(p, ChangeSet({'id': 'j1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j2', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - assert is_junction(p, 'j1') - assert is_junction(p, 'j2') - - add_pipe(p, ChangeSet({'id': 'p0', 'node1': 'j1', 'node2': 'j2', 'length': 100.0, 'diameter': 10.0, 'roughness': 0.1, 'minor_loss': 0.5, 'status': PIPE_STATUS_OPEN })) - assert is_pipe(p, 'p0') - - s = get_status(p, 'p0') - assert s['link'] == 'p0' - assert s['status'] == None - assert s['setting'] == None - - set_status(p, ChangeSet({'link': 'p0', 'status': LINK_STATUS_OPEN, 'setting': 10.0})) - s = get_status(p, 'p0') - assert s['link'] == 'p0' - assert s['status'] == LINK_STATUS_OPEN - assert s['setting'] == 10.0 - - set_status(p, ChangeSet({'link': 'p0', 'status': None, 'setting': None})) - s = get_status(p, 'p0') - assert s['link'] == 'p0' - assert s['status'] == None - assert s['setting'] == None - - self.leave(p) - - - def test_status_op(self): - p = 'test_status_op' - self.enter(p) - - add_junction(p, ChangeSet({'id': 'j1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j2', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - assert is_junction(p, 'j1') - assert is_junction(p, 'j2') - - add_pipe(p, ChangeSet({'id': 'p0', 'node1': 'j1', 'node2': 'j2', 'length': 100.0, 'diameter': 10.0, 'roughness': 0.1, 'minor_loss': 0.5, 'status': PIPE_STATUS_OPEN })) - assert is_pipe(p, 'p0') - - s = get_status(p, 'p0') - assert s['link'] == 'p0' - assert s['status'] == None - assert s['setting'] == None - - cs = set_status(p, ChangeSet({'link': 'p0', 'status': LINK_STATUS_OPEN, 'setting': 10.0})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'status' - assert cs['link'] == 'p0' - assert cs['status'] == LINK_STATUS_OPEN - assert cs['setting'] == 10.0 - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'status' - assert cs['link'] == 'p0' - assert cs['status'] == None - assert cs['setting'] == None - - s = get_status(p, 'p0') - assert s['link'] == 'p0' - assert s['status'] == None - assert s['setting'] == None - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'status' - assert cs['link'] == 'p0' - assert cs['status'] == LINK_STATUS_OPEN - assert cs['setting'] == 10.0 - - self.leave(p) - - - # 11 pattern - - - def test_pattern(self): - p = 'test_pattern' - self.enter(p) - - assert is_pattern(p, 'p0') == False - - assert get_pattern(p, 'p0') == {} - - add_pattern(p, ChangeSet({'id' : 'p0', 'factors': [1.0, 2.0, 3.0]})) - - assert is_pattern(p, 'p0') - p0 = get_pattern(p, 'p0') - assert p0['id'] == 'p0' - assert p0['factors'] == [1.0, 2.0, 3.0] - - set_pattern(p, ChangeSet({'id' : 'p0', 'factors': [1.0, 2.0]})) - - assert is_pattern(p, 'p0') - p0 = get_pattern(p, 'p0') - assert p0['id'] == 'p0' - assert p0['factors'] == [1.0, 2.0] - - set_pattern(p, ChangeSet({'id' : 'p0', 'factors': []})) - - assert is_pattern(p, 'p0') - p0 = get_pattern(p, 'p0') - assert p0['id'] == 'p0' - assert p0['factors'] == [] - - delete_pattern(p, ChangeSet({'id' : 'p0'})) - assert is_pattern(p, 'p0') == False - - assert get_pattern(p, 'p0') == {} - - self.leave(p) - - - def test_pattern_op(self): - p = 'test_pattern_op' - self.enter(p) - - cs = add_pattern(p, ChangeSet({'id' : 'p0', 'factors': [1.0, 2.0, 3.0]})).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == PATTERN - assert cs['id'] == 'p0' - assert cs['factors'] == [1.0, 2.0, 3.0] - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == PATTERN - assert cs['id'] == 'p0' - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == PATTERN - assert cs['id'] == 'p0' - assert cs['factors'] == [1.0, 2.0, 3.0] - - cs = set_pattern(p, ChangeSet({'id' : 'p0', 'factors': [1.0, 2.0]})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == PATTERN - assert cs['id'] == 'p0' - assert cs['factors'] == [1.0, 2.0] - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == PATTERN - assert cs['id'] == 'p0' - assert cs['factors'] == [1.0, 2.0, 3.0] - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == PATTERN - assert cs['id'] == 'p0' - assert cs['factors'] == [1.0, 2.0] - - cs = set_pattern(p, ChangeSet({'id' : 'p0', 'factors': []})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == PATTERN - assert cs['id'] == 'p0' - assert cs['factors'] == [] - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == PATTERN - assert cs['id'] == 'p0' - assert cs['factors'] == [1.0, 2.0] - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == PATTERN - assert cs['id'] == 'p0' - assert cs['factors'] == [] - - cs = delete_pattern(p, ChangeSet({'id' : 'p0'})).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == PATTERN - assert cs['id'] == 'p0' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == PATTERN - assert cs['id'] == 'p0' - assert cs['factors'] == [] - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == PATTERN - assert cs['id'] == 'p0' - - self.leave(p) - - - def test_pattern_del(self): - p = 'test_pattern_del' - self.enter(p) - - add_pattern(p, ChangeSet({'id' : 'p0', 'factors': [1.0, 2.0, 3.0]})) - add_junction(p, ChangeSet({'id': 'j0', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_reservoir(p, ChangeSet({'id': 'r0', 'x': 0.0, 'y': 10.0, 'head': 20.0, 'pattern': 'p0'})) - add_pump(p, ChangeSet({'id': 'pump0', 'node1': 'j0', 'node2': 'r0', 'power': 0.0, 'pattern': 'p0'})) - set_demand(p, ChangeSet({'junction': 'j0', 'demands': [{'demand': 10.0, 'pattern': 'p0', 'category': 'x'}, {'demand': 20.0, 'pattern': 'p0', 'category': None}]})) - set_pump_energy(p, ChangeSet({'pump' : 'pump0', 'pattern': 'p0'})) - add_source(p, ChangeSet({'node': 'j0', 's_type': SOURCE_TYPE_CONCEN, 'strength': 10.0, 'pattern': 'p0' })) - assert is_pattern(p, 'p0') - assert get_reservoir(p, 'r0')['pattern'] == 'p0' - assert get_pump(p, 'pump0')['pattern'] == 'p0' - assert get_demand(p, 'j0')['demands'] == [{'demand': 10.0, 'pattern': 'p0', 'category': 'x'}, {'demand': 20.0, 'pattern': 'p0', 'category': None}] - assert get_pump_energy(p, 'pump0')['pattern'] == 'p0' - assert get_source(p, 'j0')['pattern'] == 'p0' - - delete_pattern(p, ChangeSet({'id': 'p0'})) - assert is_pattern(p, 'p0') == False - assert get_reservoir(p, 'r0')['pattern'] == None - assert get_pump(p, 'pump0')['pattern'] == None - assert get_demand(p, 'j0')['demands'] == [{'demand': 10.0, 'pattern': None, 'category': 'x'}, {'demand': 20.0, 'pattern': None, 'category': None}] - assert get_pump_energy(p, 'pump0')['pattern'] == None - assert get_source(p, 'j0')['pattern'] == None - - execute_undo(p) - assert is_pattern(p, 'p0') - assert get_reservoir(p, 'r0')['pattern'] == 'p0' - assert get_pump(p, 'pump0')['pattern'] == 'p0' - assert get_demand(p, 'j0')['demands'] == [{'demand': 10.0, 'pattern': 'p0', 'category': 'x'}, {'demand': 20.0, 'pattern': 'p0', 'category': None}] - assert get_pump_energy(p, 'pump0')['pattern'] == 'p0' - assert get_source(p, 'j0')['pattern'] == 'p0' - - execute_redo(p) - assert is_pattern(p, 'p0') == False - assert get_reservoir(p, 'r0')['pattern'] == None - assert get_pump(p, 'pump0')['pattern'] == None - assert get_demand(p, 'j0')['demands'] == [{'demand': 10.0, 'pattern': None, 'category': 'x'}, {'demand': 20.0, 'pattern': None, 'category': None}] - assert get_pump_energy(p, 'pump0')['pattern'] == None - assert get_source(p, 'j0')['pattern'] == None - - self.leave(p) - - - # 12 curve - - - def test_curve(self): - p = 'test_curve' - self.enter(p) - - assert is_curve(p, 'c0') == False - - assert get_curve(p, 'c0') == {} - - add_curve(p, ChangeSet({'id' : 'c0', 'c_type' : CURVE_TYPE_PUMP, 'coords': [{'x': 1.0, 'y': 2.0}, {'x': 2.0, 'y': 1.0}]})) - - assert is_curve(p, 'c0') - c0 = get_curve(p, 'c0') - assert c0['id'] == 'c0' - assert c0['c_type'] == CURVE_TYPE_PUMP - xys = c0['coords'] - assert len(xys) == 2 - assert xys[0]['x'] == 1.0 - assert xys[0]['y'] == 2.0 - assert xys[1]['x'] == 2.0 - assert xys[1]['y'] == 1.0 - - set_curve(p, ChangeSet({'id' : 'c0', 'c_type': CURVE_TYPE_EFFICIENCY})) - - c0 = get_curve(p, 'c0') - assert c0['id'] == 'c0' - assert c0['c_type'] == CURVE_TYPE_EFFICIENCY - xys = c0['coords'] - assert len(xys) == 2 - assert xys[0]['x'] == 1.0 - assert xys[0]['y'] == 2.0 - assert xys[1]['x'] == 2.0 - assert xys[1]['y'] == 1.0 - - set_curve(p, ChangeSet({'id' : 'c0', 'coords': []})) - - c0 = get_curve(p, 'c0') - assert c0['id'] == 'c0' - assert c0['c_type'] == CURVE_TYPE_EFFICIENCY - assert c0['coords'] == [] - - delete_curve(p, ChangeSet({'id' : 'c0'})) - - assert is_curve(p, 'c0') == False - - assert get_curve(p, 'c0') == {} - - self.leave(p) - - - def test_curve_op(self): - p = 'test_curve_op' - self.enter(p) - - cs = add_curve(p, ChangeSet({'id' : 'c0', 'c_type' : CURVE_TYPE_PUMP, 'coords': [{'x': 1.0, 'y': 2.0}, {'x': 2.0, 'y': 1.0}]})).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == CURVE - assert cs['id'] == 'c0' - assert cs['c_type'] == CURVE_TYPE_PUMP - xys = cs['coords'] - assert len(xys) == 2 - assert xys[0]['x'] == 1.0 - assert xys[0]['y'] == 2.0 - assert xys[1]['x'] == 2.0 - assert xys[1]['y'] == 1.0 - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == CURVE - assert cs['id'] == 'c0' - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == CURVE - assert cs['id'] == 'c0' - assert cs['c_type'] == CURVE_TYPE_PUMP - xys = cs['coords'] - assert len(xys) == 2 - assert xys[0]['x'] == 1.0 - assert xys[0]['y'] == 2.0 - assert xys[1]['x'] == 2.0 - assert xys[1]['y'] == 1.0 - - cs = set_curve(p, ChangeSet({'id' : 'c0', 'c_type': CURVE_TYPE_EFFICIENCY})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == CURVE - assert cs['id'] == 'c0' - assert cs['c_type'] == CURVE_TYPE_EFFICIENCY - xys = cs['coords'] - assert len(xys) == 2 - assert xys[0]['x'] == 1.0 - assert xys[0]['y'] == 2.0 - assert xys[1]['x'] == 2.0 - assert xys[1]['y'] == 1.0 - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == CURVE - assert cs['id'] == 'c0' - assert cs['c_type'] == CURVE_TYPE_PUMP - xys = cs['coords'] - assert len(xys) == 2 - assert xys[0]['x'] == 1.0 - assert xys[0]['y'] == 2.0 - assert xys[1]['x'] == 2.0 - assert xys[1]['y'] == 1.0 - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == CURVE - assert cs['id'] == 'c0' - assert cs['c_type'] == CURVE_TYPE_EFFICIENCY - xys = cs['coords'] - assert len(xys) == 2 - assert xys[0]['x'] == 1.0 - assert xys[0]['y'] == 2.0 - assert xys[1]['x'] == 2.0 - assert xys[1]['y'] == 1.0 - - cs = set_curve(p, ChangeSet({'id' : 'c0', 'coords': []})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == CURVE - assert cs['id'] == 'c0' - assert cs['c_type'] == CURVE_TYPE_EFFICIENCY - assert cs['coords'] == [] - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == CURVE - assert cs['id'] == 'c0' - assert cs['c_type'] == CURVE_TYPE_EFFICIENCY - xys = cs['coords'] - assert len(xys) == 2 - assert xys[0]['x'] == 1.0 - assert xys[0]['y'] == 2.0 - assert xys[1]['x'] == 2.0 - assert xys[1]['y'] == 1.0 - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == CURVE - assert cs['id'] == 'c0' - assert cs['c_type'] == CURVE_TYPE_EFFICIENCY - assert cs['coords'] == [] - - self.leave(p) - - - def test_curve_del(self): - p = 'test_curve_del' - self.enter(p) - - add_curve(p, ChangeSet({'id' : 'c0', 'c_type' : CURVE_TYPE_PUMP, 'coords': [{'x': 1.0, 'y': 2.0}, {'x': 2.0, 'y': 1.0}]})) - add_tank(p, ChangeSet({'id': 't0', 'x': 0.0, 'y': 10.0, 'elevation': 20.0, 'init_level': 1.0, 'min_level': 0.0, 'max_level': 2.0, 'diameter': 10.0, 'min_vol': 100.0, 'vol_curve': 'c0', 'overflow': OVERFLOW_NO})) - add_junction(p, ChangeSet({'id': 'j0', 'x': 0.0, 'y': 10.0, 'elevation': 20.0, 'pattern': None})) - add_reservoir(p, ChangeSet({'id': 'r0', 'x': 0.0, 'y': 10.0, 'head': 20.0, 'pattern': None})) - add_pump(p, ChangeSet({'id': 'p0', 'node1': 'j0', 'node2': 'r0', 'head': 'c0'})) - set_pump_energy(p, ChangeSet({'pump' : 'p0', 'effic': 'c0'})) - assert is_curve(p, 'c0') - assert get_tank(p, 't0')['vol_curve'] == 'c0' - assert get_pump(p, 'p0')['head'] == 'c0' - assert get_pump_energy(p, 'p0')['effic'] == 'c0' - - delete_curve(p, ChangeSet({'id': 'c0'})) - assert is_curve(p, 'c0') == False - assert get_tank(p, 't0')['vol_curve'] == None - assert get_pump(p, 'p0')['head'] == None - assert get_pump_energy(p, 'p0')['effic'] == None - - execute_undo(p) - assert is_curve(p, 'c0') - assert get_tank(p, 't0')['vol_curve'] == 'c0' - assert get_pump(p, 'p0')['head'] == 'c0' - assert get_pump_energy(p, 'p0')['effic'] == 'c0' - - execute_redo(p) - assert is_curve(p, 'c0') == False - assert get_tank(p, 't0')['vol_curve'] == None - assert get_pump(p, 'p0')['head'] == None - assert get_pump_energy(p, 'p0')['effic'] == None - - self.leave(p) - - - # 13 control - - - def test_control(self): - p = 'test_control' - self.enter(p) - - assert get_control(p)['controls'] == [] - - set_control(p, ChangeSet({'controls': ['x']})) - assert get_control(p)['controls'] == ['x'] - - self.leave(p) - - - def test_control_op(self): - p = 'test_control_op' - self.enter(p) - - cs = set_control(p, ChangeSet({'controls': ['x']})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'control' - assert cs['controls'] == ['x'] - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'control' - assert cs['controls'] == [] - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'control' - assert cs['controls'] == ['x'] - - self.leave(p) - - - # 14 rule - - - def test_rule(self): - p = 'test_rule' - self.enter(p) - - assert get_rule(p)['rules'] == [] - - set_rule(p, ChangeSet({'rules': ['x']})) - assert get_rule(p)['rules'] == ['x'] - - self.leave(p) - - - def test_rule_op(self): - p = 'test_rule_op' - self.enter(p) - - cs = set_rule(p, ChangeSet({'rules': ['x']})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'rule' - assert cs['rules'] == ['x'] - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'rule' - assert cs['rules'] == [] - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'rule' - assert cs['rules'] == ['x'] - - self.leave(p) - - - # 15 energy - - - def test_energy(self): - p = 'test_energy' - self.enter(p) - - ge = get_energy(p) - assert ge['GLOBAL PRICE'] == '0' - assert ge['GLOBAL PATTERN'] == '' - assert ge['GLOBAL EFFIC'] == '75' - assert ge['DEMAND CHARGE'] == '0' - - set_energy(p, ChangeSet({ 'GLOBAL PRICE' : '10' })) - ge = get_energy(p) - assert ge['GLOBAL PRICE'] == '10' - assert ge['GLOBAL PATTERN'] == '' - assert ge['GLOBAL EFFIC'] == '75' - assert ge['DEMAND CHARGE'] == '0' - - add_pattern(p, ChangeSet({'id' : 'p0', 'factors': [1.0, 2.0, 3.0]})) - set_energy(p, ChangeSet({ 'GLOBAL PATTERN' : 'p0' })) - ge = get_energy(p) - assert ge['GLOBAL PRICE'] == '10' - assert ge['GLOBAL PATTERN'] == 'p0' - assert ge['GLOBAL EFFIC'] == '75' - assert ge['DEMAND CHARGE'] == '0' - - set_energy(p, ChangeSet({ 'GLOBAL EFFIC' : '0' })) - ge = get_energy(p) - assert ge['GLOBAL PRICE'] == '10' - assert ge['GLOBAL PATTERN'] == 'p0' - assert ge['GLOBAL EFFIC'] == '0' - assert ge['DEMAND CHARGE'] == '0' - - set_energy(p, ChangeSet({ 'DEMAND CHARGE' : '10' })) - ge = get_energy(p) - assert ge['GLOBAL PRICE'] == '10' - assert ge['GLOBAL PATTERN'] == 'p0' - assert ge['GLOBAL EFFIC'] == '0' - assert ge['DEMAND CHARGE'] == '10' - - self.leave(p) - - - def test_energy_op(self): - p = 'test_energy_op' - self.enter(p) - - add_pattern(p, ChangeSet({'id' : 'p0', 'factors': [1.0, 2.0, 3.0]})) - - cs = set_energy(p, ChangeSet({ 'GLOBAL PRICE' : '10' })).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'energy' - assert cs['GLOBAL PRICE'] == '10' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'energy' - assert cs['GLOBAL PRICE'] == '0' - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'energy' - assert cs['GLOBAL PRICE'] == '10' - - cs = set_energy(p, ChangeSet({ 'GLOBAL PATTERN' : 'p0' })).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'energy' - assert cs['GLOBAL PATTERN'] == 'p0' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'energy' - assert cs['GLOBAL PATTERN'] == '' - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'energy' - assert cs['GLOBAL PATTERN'] == 'p0' - - cs = set_energy(p, ChangeSet({ 'GLOBAL EFFIC' : '0' })).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'energy' - assert cs['GLOBAL EFFIC'] == '0' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'energy' - assert cs['GLOBAL EFFIC'] == '75' - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'energy' - assert cs['GLOBAL EFFIC'] == '0' - - cs = set_energy(p, ChangeSet({ 'DEMAND CHARGE' : '10' })).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'energy' - assert cs['DEMAND CHARGE'] == '10' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'energy' - assert cs['DEMAND CHARGE'] == '0' - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'energy' - assert cs['DEMAND CHARGE'] == '10' - - self.leave(p) - - - def test_pump_energy(self): - p = 'test_pump_energy' - self.enter(p) - - ge = get_pump_energy(p, 'p0') - assert ge['pump'] == 'p0' - assert ge['price'] == None - assert ge['pattern'] == None - assert ge['effic'] == None - - add_junction(p, ChangeSet({'id': 'j1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j2', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_pump(p, ChangeSet({'id': 'p0', 'node1': 'j1', 'node2': 'j2', 'power': 0.0})) - - set_pump_energy(p, ChangeSet({'pump' : 'p0'})) - ge = get_pump_energy(p, 'p0') - assert ge['pump'] == 'p0' - assert ge['price'] == None - assert ge['pattern'] == None - assert ge['effic'] == None - - set_pump_energy(p, ChangeSet({'pump' : 'p0', 'price': 0.0})) - ge = get_pump_energy(p, 'p0') - assert ge['pump'] == 'p0' - assert ge['price'] == 0.0 - assert ge['pattern'] == None - assert ge['effic'] == None - - add_pattern(p, ChangeSet({'id' : 'pa0', 'factors': [1.0, 2.0, 3.0]})) - set_pump_energy(p, ChangeSet({'pump' : 'p0', 'pattern': 'pa0'})) - ge = get_pump_energy(p, 'p0') - assert ge['pump'] == 'p0' - assert ge['price'] == 0.0 - assert ge['pattern'] == 'pa0' - assert ge['effic'] == None - - add_curve(p, ChangeSet({'id' : 'c0', 'c_type' : CURVE_TYPE_PUMP, 'coords': [{'x': 1.0, 'y': 2.0}, {'x': 2.0, 'y': 1.0}]})) - set_pump_energy(p, ChangeSet({'pump' : 'p0', 'effic': 'c0'})) - ge = get_pump_energy(p, 'p0') - assert ge['pump'] == 'p0' - assert ge['price'] == 0.0 - assert ge['pattern'] == 'pa0' - assert ge['effic'] == 'c0' - - self.leave(p) - - - def test_pump_energy_op(self): - p = 'test_pump_energy_op' - self.enter(p) - - add_junction(p, ChangeSet({'id': 'j1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j2', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_pump(p, ChangeSet({'id': 'p0', 'node1': 'j1', 'node2': 'j2', 'power': 0.0})) - add_pattern(p, ChangeSet({'id' : 'pa0', 'factors': [1.0, 2.0, 3.0]})) - add_curve(p, ChangeSet({'id' : 'c0', 'c_type' : CURVE_TYPE_PUMP, 'coords': [{'x': 1.0, 'y': 2.0}, {'x': 2.0, 'y': 1.0}]})) - - cs = set_pump_energy(p, ChangeSet({'pump' : 'p0'})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'pump_energy' - assert cs['pump'] == 'p0' - assert cs['price'] == None - assert cs['pattern'] == None - assert cs['effic'] == None - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'pump_energy' - assert cs['pump'] == 'p0' - assert cs['price'] == None - assert cs['pattern'] == None - assert cs['effic'] == None - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'pump_energy' - assert cs['pump'] == 'p0' - assert cs['price'] == None - assert cs['pattern'] == None - assert cs['effic'] == None - - cs = set_pump_energy(p, ChangeSet({'pump' : 'p0', 'price': 0.0})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'pump_energy' - assert cs['pump'] == 'p0' - assert cs['price'] == 0.0 - assert cs['pattern'] == None - assert cs['effic'] == None - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'pump_energy' - assert cs['pump'] == 'p0' - assert cs['price'] == None - assert cs['pattern'] == None - assert cs['effic'] == None - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'pump_energy' - assert cs['pump'] == 'p0' - assert cs['price'] == 0.0 - assert cs['pattern'] == None - assert cs['effic'] == None - - cs = set_pump_energy(p, ChangeSet({'pump' : 'p0', 'pattern': 'pa0'})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'pump_energy' - assert cs['pump'] == 'p0' - assert cs['price'] == 0.0 - assert cs['pattern'] == 'pa0' - assert cs['effic'] == None - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'pump_energy' - assert cs['pump'] == 'p0' - assert cs['price'] == 0.0 - assert cs['pattern'] == None - assert cs['effic'] == None - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'pump_energy' - assert cs['pump'] == 'p0' - assert cs['price'] == 0.0 - assert cs['pattern'] == 'pa0' - assert cs['effic'] == None - - cs = set_pump_energy(p, ChangeSet({'pump' : 'p0', 'effic': 'c0'})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'pump_energy' - assert cs['pump'] == 'p0' - assert cs['price'] == 0.0 - assert cs['pattern'] == 'pa0' - assert cs['effic'] == 'c0' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'pump_energy' - assert cs['pump'] == 'p0' - assert cs['price'] == 0.0 - assert cs['pattern'] == 'pa0' - assert cs['effic'] == None - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'pump_energy' - assert cs['pump'] == 'p0' - assert cs['price'] == 0.0 - assert cs['pattern'] == 'pa0' - assert cs['effic'] == 'c0' - - cs = set_pump_energy(p, ChangeSet({'pump' : 'p0', 'price': None, 'pattern': None, 'effic': None})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'pump_energy' - assert cs['pump'] == 'p0' - assert cs['price'] == None - assert cs['pattern'] == None - assert cs['effic'] == None - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'pump_energy' - assert cs['pump'] == 'p0' - assert cs['price'] == 0.0 - assert cs['pattern'] == 'pa0' - assert cs['effic'] == 'c0' - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'pump_energy' - assert cs['pump'] == 'p0' - assert cs['price'] == None - assert cs['pattern'] == None - assert cs['effic'] == None - - cs = set_pump_energy(p, ChangeSet({'pump' : 'p0', 'price': 0.0, 'pattern': 'pa0', 'effic': 'c0'})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'pump_energy' - assert cs['pump'] == 'p0' - assert cs['price'] == 0.0 - assert cs['pattern'] == 'pa0' - assert cs['effic'] == 'c0' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'pump_energy' - assert cs['pump'] == 'p0' - assert cs['price'] == None - assert cs['pattern'] == None - assert cs['effic'] == None - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'pump_energy' - assert cs['pump'] == 'p0' - assert cs['price'] == 0.0 - assert cs['pattern'] == 'pa0' - assert cs['effic'] == 'c0' - - self.leave(p) - - - # 16 emitter - - - def test_emitter(self): - p = 'test_emitter' - self.enter(p) - - add_junction(p, ChangeSet({'id': 'j1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - assert is_junction(p, 'j1') - - e = get_emitter(p, 'j1') - assert e['junction'] == 'j1' - assert e['coefficient'] == None - - set_emitter(p, ChangeSet({'junction': 'j1', 'coefficient': 10.0})) - - e = get_emitter(p, 'j1') - assert e['junction'] == 'j1' - assert e['coefficient'] == 10.0 - - set_emitter(p, ChangeSet({'junction': 'j1', 'coefficient': None})) - - e = get_emitter(p, 'j1') - assert e['junction'] == 'j1' - assert e['coefficient'] == None - - self.leave(p) - - - def test_emitter_op(self): - p = 'test_emitter_op' - self.enter(p) - - add_junction(p, ChangeSet({'id': 'j1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - assert is_junction(p, 'j1') - - cs = set_emitter(p, ChangeSet({'junction': 'j1', 'coefficient': 10.0})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'emitter' - assert cs['junction'] == 'j1' - assert cs['coefficient'] == 10.0 - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'emitter' - assert cs['junction'] == 'j1' - assert cs['coefficient'] == None - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'emitter' - assert cs['junction'] == 'j1' - assert cs['coefficient'] == 10.0 - - self.leave(p) - - - # 17 quality - - - def test_quality(self): - p = 'test_quality' - self.enter(p) - - add_junction(p, ChangeSet({'id': 'j1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - assert is_junction(p, 'j1') - - e = get_quality(p, 'j1') - assert e['node'] == 'j1' - assert e['quality'] == None - - set_quality(p, ChangeSet({'node': 'j1', 'quality': 10.0})) - - e = get_quality(p, 'j1') - assert e['node'] == 'j1' - assert e['quality'] == 10.0 - - set_quality(p, ChangeSet({'node': 'j1', 'quality': None})) - - e = get_quality(p, 'j1') - assert e['node'] == 'j1' - assert e['quality'] == None - - self.leave(p) - - - def test_quality_op(self): - p = 'test_quality_op' - self.enter(p) - - add_junction(p, ChangeSet({'id': 'j1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - assert is_junction(p, 'j1') - - cs = set_quality(p, ChangeSet({'node': 'j1', 'quality': 10.0})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'quality' - assert cs['node'] == 'j1' - assert cs['quality'] == 10.0 - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'quality' - assert cs['node'] == 'j1' - assert cs['quality'] == None - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'quality' - assert cs['node'] == 'j1' - assert cs['quality'] == 10.0 - - self.leave(p) - - - # 18 source - - - def test_source(self): - p = 'test_source' - self.enter(p) - - add_junction(p, ChangeSet({'id': 'j0', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_pattern(p, ChangeSet({'id' : 'p0', 'factors': [1.0, 2.0, 3.0]})) - add_pattern(p, ChangeSet({'id' : 'p1', 'factors': [1.0, 2.0, 3.0]})) - - assert get_source(p, 'j0') == {} - - add_source(p, ChangeSet({'node': 'j0', 's_type': SOURCE_TYPE_CONCEN, 'strength': 10.0, 'pattern': 'p0'})) - s = get_source(p, 'j0') - assert s['node'] == 'j0' - assert s['s_type'] == SOURCE_TYPE_CONCEN - assert s['strength'] == 10.0 - assert s['pattern'] == 'p0' - - set_source(p, ChangeSet({'node': 'j0', 's_type': SOURCE_TYPE_FLOWPACED})) - s = get_source(p, 'j0') - assert s['node'] == 'j0' - assert s['s_type'] == SOURCE_TYPE_FLOWPACED - assert s['strength'] == 10.0 - assert s['pattern'] == 'p0' - - set_source(p, ChangeSet({'node': 'j0', 'strength': 20.0})) - s = get_source(p, 'j0') - assert s['node'] == 'j0' - assert s['s_type'] == SOURCE_TYPE_FLOWPACED - assert s['strength'] == 20.0 - assert s['pattern'] == 'p0' - - set_source(p, ChangeSet({'node': 'j0', 'pattern': 'p1'})) - s = get_source(p, 'j0') - assert s['node'] == 'j0' - assert s['s_type'] == SOURCE_TYPE_FLOWPACED - assert s['strength'] == 20.0 - assert s['pattern'] == 'p1' - - delete_source(p, ChangeSet({'node': 'j0'})) - - assert get_source(p, 'j0') == {} - - self.leave(p) - - - def test_source_op(self): - p = 'test_source_op' - self.enter(p) - - add_junction(p, ChangeSet({'id': 'j0', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_pattern(p, ChangeSet({'id' : 'p0', 'factors': [1.0, 2.0, 3.0]})) - add_pattern(p, ChangeSet({'id' : 'p1', 'factors': [1.0, 2.0, 3.0]})) - - cs = add_source(p, ChangeSet({'node': 'j0', 's_type': SOURCE_TYPE_CONCEN, 'strength': 10.0, 'pattern': 'p0'})).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'source' - assert cs['node'] == 'j0' - assert cs['s_type'] == SOURCE_TYPE_CONCEN - assert cs['strength'] == 10.0 - assert cs['pattern'] == 'p0' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'source' - assert cs['node'] == 'j0' - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'source' - assert cs['node'] == 'j0' - assert cs['s_type'] == SOURCE_TYPE_CONCEN - assert cs['strength'] == 10.0 - assert cs['pattern'] == 'p0' - - cs = set_source(p, ChangeSet({'node': 'j0', 's_type': SOURCE_TYPE_FLOWPACED})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'source' - assert cs['node'] == 'j0' - assert cs['s_type'] == SOURCE_TYPE_FLOWPACED - assert cs['strength'] == 10.0 - assert cs['pattern'] == 'p0' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'source' - assert cs['node'] == 'j0' - assert cs['s_type'] == SOURCE_TYPE_CONCEN - assert cs['strength'] == 10.0 - assert cs['pattern'] == 'p0' - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'source' - assert cs['node'] == 'j0' - assert cs['s_type'] == SOURCE_TYPE_FLOWPACED - assert cs['strength'] == 10.0 - assert cs['pattern'] == 'p0' - - cs = set_source(p, ChangeSet({'node': 'j0', 'strength': 20.0})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'source' - assert cs['node'] == 'j0' - assert cs['s_type'] == SOURCE_TYPE_FLOWPACED - assert cs['strength'] == 20.0 - assert cs['pattern'] == 'p0' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'source' - assert cs['node'] == 'j0' - assert cs['s_type'] == SOURCE_TYPE_FLOWPACED - assert cs['strength'] == 10.0 - assert cs['pattern'] == 'p0' - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'source' - assert cs['node'] == 'j0' - assert cs['s_type'] == SOURCE_TYPE_FLOWPACED - assert cs['strength'] == 20.0 - assert cs['pattern'] == 'p0' - - cs = set_source(p, ChangeSet({'node': 'j0', 'pattern': 'p1'})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'source' - assert cs['node'] == 'j0' - assert cs['s_type'] == SOURCE_TYPE_FLOWPACED - assert cs['strength'] == 20.0 - assert cs['pattern'] == 'p1' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'source' - assert cs['node'] == 'j0' - assert cs['s_type'] == SOURCE_TYPE_FLOWPACED - assert cs['strength'] == 20.0 - assert cs['pattern'] == 'p0' - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'source' - assert cs['node'] == 'j0' - assert cs['s_type'] == SOURCE_TYPE_FLOWPACED - assert cs['strength'] == 20.0 - assert cs['pattern'] == 'p1' - - cs = delete_source(p, ChangeSet({'node': 'j0'})).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'source' - assert cs['node'] == 'j0' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'source' - assert cs['node'] == 'j0' - assert cs['s_type'] == SOURCE_TYPE_FLOWPACED - assert cs['strength'] == 20.0 - assert cs['pattern'] == 'p1' - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'source' - assert cs['node'] == 'j0' - - self.leave(p) - - - # 19 reaction - - - def test_reaction(self): - p = 'test_reaction' - self.enter(p) - - gr = get_reaction(p) - assert gr['ORDER BULK'] == '1' - assert gr['ORDER WALL'] == '1' - assert gr['ORDER TANK'] == '1' - assert gr['GLOBAL BULK'] == '0' - assert gr['GLOBAL WALL'] == '0' - assert gr['LIMITING POTENTIAL'] == '0' - assert gr['ROUGHNESS CORRELATION'] == '0' - - set_reaction(p, ChangeSet({ 'ORDER BULK' : '10' })) - gr = get_reaction(p) - assert gr['ORDER BULK'] == '10' - assert gr['ORDER WALL'] == '1' - assert gr['ORDER TANK'] == '1' - assert gr['GLOBAL BULK'] == '0' - assert gr['GLOBAL WALL'] == '0' - assert gr['LIMITING POTENTIAL'] == '0' - assert gr['ROUGHNESS CORRELATION'] == '0' - - set_reaction(p, ChangeSet({ 'ORDER BULK' : '1' })) - gr = get_reaction(p) - assert gr['ORDER BULK'] == '1' - assert gr['ORDER WALL'] == '1' - assert gr['ORDER TANK'] == '1' - assert gr['GLOBAL BULK'] == '0' - assert gr['GLOBAL WALL'] == '0' - assert gr['LIMITING POTENTIAL'] == '0' - assert gr['ROUGHNESS CORRELATION'] == '0' - - self.leave(p) - - - def test_reaction_op(self): - p = 'test_reaction_op' - self.enter(p) - - cs = set_reaction(p, ChangeSet({ 'ORDER BULK' : '10' })).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'reaction' - assert cs['ORDER BULK'] == '10' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'reaction' - assert cs['ORDER BULK'] == '1' - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'reaction' - assert cs['ORDER BULK'] == '10' - - self.leave(p) - - - def test_pipe_reaction(self): - p = 'test_pipe_reaction' - self.enter(p) - - add_junction(p, ChangeSet({'id': 'j1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j2', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_pipe(p, ChangeSet({'id': 'p0', 'node1': 'j1', 'node2': 'j2', 'length': 100.0, 'diameter': 10.0, 'roughness': 0.1, 'minor_loss': 0.5, 'status': PIPE_STATUS_OPEN })) - - pp = get_pipe_reaction(p, 'p0') - assert pp['pipe'] == 'p0' - assert pp['bulk'] == None - assert pp['wall'] == None - - set_pipe_reaction(p, ChangeSet({'pipe': 'p0', 'bulk': 10.0, 'wall': 20.0})) - pp = get_pipe_reaction(p, 'p0') - assert pp['pipe'] == 'p0' - assert pp['bulk'] == 10.0 - assert pp['wall'] == 20.0 - - set_pipe_reaction(p, ChangeSet({'pipe': 'p0', 'bulk': None, 'wall': None})) - pp = get_pipe_reaction(p, 'p0') - assert pp['pipe'] == 'p0' - assert pp['bulk'] == None - assert pp['wall'] == None - - self.leave(p) - - - def test_pipe_reaction_op(self): - p = 'test_pipe_reaction_op' - self.enter(p) - - add_junction(p, ChangeSet({'id': 'j1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j2', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_pipe(p, ChangeSet({'id': 'p0', 'node1': 'j1', 'node2': 'j2', 'length': 100.0, 'diameter': 10.0, 'roughness': 0.1, 'minor_loss': 0.5, 'status': PIPE_STATUS_OPEN })) - - cs = set_pipe_reaction(p, ChangeSet({'pipe': 'p0', 'bulk': 10.0, 'wall': 20.0})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'pipe_reaction' - assert cs['pipe'] == 'p0' - assert cs['bulk'] == 10.0 - assert cs['wall'] == 20.0 - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'pipe_reaction' - assert cs['pipe'] == 'p0' - assert cs['bulk'] == None - assert cs['wall'] == None - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'pipe_reaction' - assert cs['pipe'] == 'p0' - assert cs['bulk'] == 10.0 - assert cs['wall'] == 20.0 - - self.leave(p) - - - def test_tank_reaction(self): - p = 'test_tank_reaction' - self.enter(p) - - add_tank(p, ChangeSet({'id': 't0', 'x': 0.0, 'y': 10.0, 'elevation': 20.0, 'init_level': 1.0, 'min_level': 0.0, 'max_level': 2.0, 'diameter': 10.0, 'min_vol': 100.0, 'vol_curve': None, 'overflow': OVERFLOW_NO})) - - pt = get_tank_reaction(p, 't0') - assert pt['tank'] == 't0' - assert pt['value'] == None - - set_tank_reaction(p, ChangeSet({'tank': 't0', 'value': 10.0})) - pt = get_tank_reaction(p, 't0') - assert pt['tank'] == 't0' - assert pt['value'] == 10.0 - - set_tank_reaction(p, ChangeSet({'tank': 't0', 'value': None})) - pt = get_tank_reaction(p, 't0') - assert pt['tank'] == 't0' - assert pt['value'] == None - - self.leave(p) - - - def test_tank_reaction_op(self): - p = 'test_tank_reaction_op' - self.enter(p) - - add_tank(p, ChangeSet({'id': 't0', 'x': 0.0, 'y': 10.0, 'elevation': 20.0, 'init_level': 1.0, 'min_level': 0.0, 'max_level': 2.0, 'diameter': 10.0, 'min_vol': 100.0, 'vol_curve': None, 'overflow': OVERFLOW_NO})) - - cs = set_tank_reaction(p, ChangeSet({'tank': 't0', 'value': 10.0})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'tank_reaction' - assert cs['tank'] == 't0' - assert cs['value'] == 10.0 - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'tank_reaction' - assert cs['tank'] == 't0' - assert cs['value'] == None - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'tank_reaction' - assert cs['tank'] == 't0' - assert cs['value'] == 10.0 - - self.leave(p) - - - # 20 mixing - - - def test_mixing(self): - p = 'test_mixing' - self.enter(p) - - add_tank(p, ChangeSet({'id': 't0', 'x': 0.0, 'y': 10.0, 'elevation': 20.0, 'init_level': 1.0, 'min_level': 0.0, 'max_level': 2.0, 'diameter': 10.0, 'min_vol': 100.0, 'vol_curve': None, 'overflow': OVERFLOW_NO})) - - assert get_mixing(p, 't0') == {} - - add_mixing(p, ChangeSet({'tank': 't0', 'model': MIXING_MODEL_MIXED, 'value': 10.0})) - m = get_mixing(p,'t0') - assert m['tank'] == 't0' - assert m['model'] == MIXING_MODEL_MIXED - assert m['value'] == 10.0 - - set_mixing(p, ChangeSet({'tank': 't0', 'model': MIXING_MODEL_2COMP})) - m = get_mixing(p,'t0') - assert m['tank'] == 't0' - assert m['model'] == MIXING_MODEL_2COMP - assert m['value'] == 10.0 - - set_mixing(p, ChangeSet({'tank': 't0', 'value': 20.0})) - m = get_mixing(p,'t0') - assert m['tank'] == 't0' - assert m['model'] == MIXING_MODEL_2COMP - assert m['value'] == 20.0 - - delete_mixing(p, ChangeSet({'tank': 't0'})) - - assert get_mixing(p, 't0') == {} - - self.leave(p) - - - def test_mixing_op(self): - p = 'test_mixing_op' - self.enter(p) - - add_tank(p, ChangeSet({'id': 't0', 'x': 0.0, 'y': 10.0, 'elevation': 20.0, 'init_level': 1.0, 'min_level': 0.0, 'max_level': 2.0, 'diameter': 10.0, 'min_vol': 100.0, 'vol_curve': None, 'overflow': OVERFLOW_NO})) - - cs = add_mixing(p, ChangeSet({'tank': 't0', 'model': MIXING_MODEL_MIXED, 'value': 10.0})).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'mixing' - assert cs['tank'] == 't0' - assert cs['model'] == MIXING_MODEL_MIXED - assert cs['value'] == 10.0 - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'mixing' - assert cs['tank'] == 't0' - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'mixing' - assert cs['tank'] == 't0' - assert cs['model'] == MIXING_MODEL_MIXED - assert cs['value'] == 10.0 - - cs = set_mixing(p, ChangeSet({'tank': 't0', 'model': MIXING_MODEL_2COMP})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'mixing' - assert cs['tank'] == 't0' - assert cs['model'] == MIXING_MODEL_2COMP - assert cs['value'] == 10.0 - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'mixing' - assert cs['tank'] == 't0' - assert cs['model'] == MIXING_MODEL_MIXED - assert cs['value'] == 10.0 - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'mixing' - assert cs['tank'] == 't0' - assert cs['model'] == MIXING_MODEL_2COMP - assert cs['value'] == 10.0 - - cs = set_mixing(p, ChangeSet({'tank': 't0', 'value': 20.0})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'mixing' - assert cs['tank'] == 't0' - assert cs['model'] == MIXING_MODEL_2COMP - assert cs['value'] == 20.0 - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'mixing' - assert cs['tank'] == 't0' - assert cs['model'] == MIXING_MODEL_2COMP - assert cs['value'] == 10.0 - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'mixing' - assert cs['tank'] == 't0' - assert cs['model'] == MIXING_MODEL_2COMP - assert cs['value'] == 20.0 - - cs = delete_mixing(p, ChangeSet({'tank': 't0'})).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'mixing' - assert cs['tank'] == 't0' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'mixing' - assert cs['tank'] == 't0' - assert cs['model'] == MIXING_MODEL_2COMP - assert cs['value'] == 20.0 - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'mixing' - assert cs['tank'] == 't0' - - self.leave(p) - - - # 21 time - - - def test_time(self): - p = 'test_time' - self.enter(p) - - t = get_time(p) - assert t['DURATION'] == '0:00' - assert t['HYDRAULIC TIMESTEP'] == '1:00' - assert t['QUALITY TIMESTEP'] == '0:05' - assert t['RULE TIMESTEP'] == '0:05' - assert t['PATTERN TIMESTEP'] == '1:00' - assert t['PATTERN START'] == '0:00' - assert t['REPORT TIMESTEP'] == '1:00' - assert t['REPORT START'] == '0:00' - assert t['START CLOCKTIME'] == '12:00 AM' - assert t['STATISTIC'] == TIME_STATISTIC_NONE - - t['STATISTIC'] = TIME_STATISTIC_AVERAGED - set_time(p, ChangeSet(t)) - - t = get_time(p) - assert t['DURATION'] == '0:00' - assert t['HYDRAULIC TIMESTEP'] == '1:00' - assert t['QUALITY TIMESTEP'] == '0:05' - assert t['RULE TIMESTEP'] == '0:05' - assert t['PATTERN TIMESTEP'] == '1:00' - assert t['PATTERN START'] == '0:00' - assert t['REPORT TIMESTEP'] == '1:00' - assert t['REPORT START'] == '0:00' - assert t['START CLOCKTIME'] == '12:00 AM' - assert t['STATISTIC'] == TIME_STATISTIC_AVERAGED - - self.leave(p) - - - def test_time_op(self): - p = 'test_time_op' - self.enter(p) - - t = get_time(p) - assert t['DURATION'] == '0:00' - assert t['HYDRAULIC TIMESTEP'] == '1:00' - assert t['QUALITY TIMESTEP'] == '0:05' - assert t['RULE TIMESTEP'] == '0:05' - assert t['PATTERN TIMESTEP'] == '1:00' - assert t['PATTERN START'] == '0:00' - assert t['REPORT TIMESTEP'] == '1:00' - assert t['REPORT START'] == '0:00' - assert t['START CLOCKTIME'] == '12:00 AM' - assert t['STATISTIC'] == TIME_STATISTIC_NONE - - t['STATISTIC'] = TIME_STATISTIC_AVERAGED - cs = set_time(p, ChangeSet(t)).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'time' - assert cs['DURATION'] == '0:00' - assert cs['HYDRAULIC TIMESTEP'] == '1:00' - assert cs['QUALITY TIMESTEP'] == '0:05' - assert cs['RULE TIMESTEP'] == '0:05' - assert cs['PATTERN TIMESTEP'] == '1:00' - assert cs['PATTERN START'] == '0:00' - assert cs['REPORT TIMESTEP'] == '1:00' - assert cs['REPORT START'] == '0:00' - assert cs['START CLOCKTIME'] == '12:00 AM' - assert cs['STATISTIC'] == TIME_STATISTIC_AVERAGED - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'time' - assert cs['DURATION'] == '0:00' - assert cs['HYDRAULIC TIMESTEP'] == '1:00' - assert cs['QUALITY TIMESTEP'] == '0:05' - assert cs['RULE TIMESTEP'] == '0:05' - assert cs['PATTERN TIMESTEP'] == '1:00' - assert cs['PATTERN START'] == '0:00' - assert cs['REPORT TIMESTEP'] == '1:00' - assert cs['REPORT START'] == '0:00' - assert cs['START CLOCKTIME'] == '12:00 AM' - assert cs['STATISTIC'] == TIME_STATISTIC_NONE - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'time' - assert cs['DURATION'] == '0:00' - assert cs['HYDRAULIC TIMESTEP'] == '1:00' - assert cs['QUALITY TIMESTEP'] == '0:05' - assert cs['RULE TIMESTEP'] == '0:05' - assert cs['PATTERN TIMESTEP'] == '1:00' - assert cs['PATTERN START'] == '0:00' - assert cs['REPORT TIMESTEP'] == '1:00' - assert cs['REPORT START'] == '0:00' - assert cs['START CLOCKTIME'] == '12:00 AM' - assert cs['STATISTIC'] == TIME_STATISTIC_AVERAGED - - self.leave(p) - - - # 22 option - - - def test_option(self): - p = 'test_option' - self.enter(p) - - o = get_option(p) - assert o['UNITS'] == OPTION_UNITS_LPS - assert o['PRESSURE'] == OPTION_PRESSURE_METERS - assert o['HEADLOSS'] == OPTION_HEADLOSS_HW - assert o['QUALITY'] == OPTION_QUALITY_NONE - assert o['UNBALANCED'] == OPTION_UNBALANCED_STOP - assert o['PATTERN'] == '1' - assert o['DEMAND MODEL'] == OPTION_DEMAND_MODEL_DDA - assert o['DEMAND MULTIPLIER'] == '1.0' - assert o['EMITTER EXPONENT'] == '0.5' - assert o['VISCOSITY'] == '1.0' - assert o['DIFFUSIVITY'] == '1.0' - assert o['SPECIFIC GRAVITY'] == '1.0' - assert o['TRIALS'] == '40' - assert o['ACCURACY'] == '0.001' - assert o['HEADERROR'] == '0.0001' - assert o['FLOWCHANGE'] == '0.0001' - assert o['MINIMUM PRESSURE'] == '0.0001' - assert o['REQUIRED PRESSURE'] == '20.0' - assert o['PRESSURE EXPONENT'] == '0.5' - assert o['TOLERANCE'] == '0.01' - assert o['HTOL'] == '0.0005' - assert o['QTOL'] == '0.0001' - assert o['RQTOL'] == '0.0000001' - assert o['CHECKFREQ'] == '2' - assert o['MAXCHECK'] == '10' - assert o['DAMPLIMIT'] == '0' - - o3 = get_option_v3(p) - assert o3['FLOW_UNITS'] == OPTION_V3_FLOW_UNITS_LPS - assert o3['PRESSURE_UNITS'] == OPTION_V3_PRESSURE_UNITS_METERS - assert o3['HEADLOSS_MODEL'] == OPTION_V3_HEADLOSS_MODEL_HW - assert o3['SPECIFIC_GRAVITY'] == '1.0' - assert o3['SPECIFIC_VISCOSITY'] == '1.0' - assert o3['MAXIMUM_TRIALS'] == '40' - assert o3['HEAD_TOLERANCE'] == '0.0005' - assert o3['FLOW_TOLERANCE'] == '0.0001' - assert o3['FLOW_CHANGE_LIMIT'] == '0.0001' - assert o3['RELATIVE_ACCURACY'] == '0.001' - assert o3['TIME_WEIGHT'] == '0.0' - assert o3['STEP_SIZING'] == OPTION_V3_STEP_SIZING_FULL - assert o3['IF_UNBALANCED'] == OPTION_V3_IF_UNBALANCED_STOP - assert o3['DEMAND_MODEL'] == OPTION_V3_DEMAND_MODEL_FIXED - assert o3['DEMAND_PATTERN'] == '1' - assert o3['DEMAND_MULTIPLIER'] == '1.0' - assert o3['MINIMUM_PRESSURE'] == '0.0001' - assert o3['SERVICE_PRESSURE'] == '20.0' - assert o3['PRESSURE_EXPONENT'] == '0.5' - assert o3['LEAKAGE_MODEL'] == OPTION_V3_LEAKAGE_MODEL_NONE - assert o3['LEAKAGE_COEFF1'] == '0.0' - assert o3['LEAKAGE_COEFF2'] == '0.0' - assert o3['EMITTER_EXPONENT'] == '0.5' - assert o3['QUALITY_MODEL'] == OPTION_V3_QUALITY_MODEL_NONE - assert o3['QUALITY_NAME'] == OPTION_V3_QUALITY_MODEL_CHEMICAL - assert o3['QUALITY_UNITS'] == OPTION_V3_QUALITY_UNITS_MGL - assert o3['TRACE_NODE'] == '' - assert o3['SPECIFIC_DIFFUSIVITY'] == '1.0' - assert o3['QUALITY_TOLERANCE'] == '0.01' - - o['UNITS'] = OPTION_UNITS_LPS - o['PRESSURE'] = OPTION_PRESSURE_KPA - o['HEADLOSS'] = OPTION_HEADLOSS_DW - o['QUALITY'] = f'{OPTION_QUALITY_TRACE} 1' - o['UNBALANCED'] = OPTION_UNBALANCED_CONTINUE - o['PATTERN'] = '2' - o['DEMAND MODEL'] = OPTION_DEMAND_MODEL_PDA - o['DEMAND MULTIPLIER'] = '2.0' - o['EMITTER EXPONENT'] = '1.5' - o['VISCOSITY'] = '2.0' - o['DIFFUSIVITY'] = '2.0' - o['SPECIFIC GRAVITY'] = '2.0' - o['TRIALS'] = '50' - o['ACCURACY'] = '0.0001' - o['HEADERROR'] = '0.01' - o['FLOWCHANGE'] = '0.01' - o['MINIMUM PRESSURE'] = '0.01' - o['REQUIRED PRESSURE'] = '0.01' - o['PRESSURE EXPONENT'] = '0.05' - o['TOLERANCE'] = '0.001' - o['HTOL'] = '0.005' - o['QTOL'] = '0.001' - o['RQTOL'] = '0.000001' - o['CHECKFREQ'] = '1' - o['MAXCHECK'] = '15' - o['DAMPLIMIT'] = '1' - - set_option(p, ChangeSet(o)) - - o = get_option(p) - assert o['UNITS'] == OPTION_UNITS_LPS - assert o['PRESSURE'] == OPTION_PRESSURE_KPA - assert o['HEADLOSS'] == OPTION_HEADLOSS_DW - assert o['QUALITY'] == f'{OPTION_QUALITY_TRACE} 1' - assert o['UNBALANCED'] == OPTION_UNBALANCED_CONTINUE - assert o['PATTERN'] == '2' - assert o['DEMAND MODEL'] == OPTION_DEMAND_MODEL_PDA - assert o['DEMAND MULTIPLIER'] == '2.0' - assert o['EMITTER EXPONENT'] == '1.5' - assert o['VISCOSITY'] == '2.0' - assert o['DIFFUSIVITY'] == '2.0' - assert o['SPECIFIC GRAVITY'] == '2.0' - assert o['TRIALS'] == '50' - assert o['ACCURACY'] == '0.0001' - assert o['HEADERROR'] == '0.01' - assert o['FLOWCHANGE'] == '0.01' - assert o['MINIMUM PRESSURE'] == '0.01' - assert o['REQUIRED PRESSURE'] == '0.01' - assert o['PRESSURE EXPONENT'] == '0.05' - assert o['TOLERANCE'] == '0.001' - assert o['HTOL'] == '0.005' - assert o['QTOL'] == '0.001' - assert o['RQTOL'] == '0.000001' - assert o['CHECKFREQ'] == '1' - assert o['MAXCHECK'] == '15' - assert o['DAMPLIMIT'] == '1' - - o3 = get_option_v3(p) - assert o3['FLOW_UNITS'] == OPTION_V3_FLOW_UNITS_LPS - assert o3['PRESSURE_UNITS'] == OPTION_V3_PRESSURE_UNITS_KPA - assert o3['HEADLOSS_MODEL'] == OPTION_V3_HEADLOSS_MODEL_DW - assert o3['SPECIFIC_GRAVITY'] == '2.0' - assert o3['SPECIFIC_VISCOSITY'] == '2.0' - assert o3['MAXIMUM_TRIALS'] == '50' - assert o3['HEAD_TOLERANCE'] == '0.005' - assert o3['FLOW_TOLERANCE'] == '0.001' - assert o3['FLOW_CHANGE_LIMIT'] == '0.01' - assert o3['RELATIVE_ACCURACY'] == '0.0001' - assert o3['TIME_WEIGHT'] == '0.0' - assert o3['STEP_SIZING'] == OPTION_V3_STEP_SIZING_FULL - assert o3['IF_UNBALANCED'] == OPTION_V3_IF_UNBALANCED_CONTINUE - assert o3['DEMAND_MODEL'] == OPTION_V3_DEMAND_MODEL_POWER - assert o3['DEMAND_PATTERN'] == '2' - assert o3['DEMAND_MULTIPLIER'] == '2.0' - assert o3['MINIMUM_PRESSURE'] == '0.01' - assert o3['SERVICE_PRESSURE'] == '0.01' - assert o3['PRESSURE_EXPONENT'] == '0.05' - assert o3['LEAKAGE_MODEL'] == OPTION_V3_LEAKAGE_MODEL_NONE - assert o3['LEAKAGE_COEFF1'] == '0.0' - assert o3['LEAKAGE_COEFF2'] == '0.0' - assert o3['EMITTER_EXPONENT'] == '1.5' - assert o3['QUALITY_MODEL'] == OPTION_V3_QUALITY_MODEL_TRACE - assert o3['QUALITY_NAME'] == OPTION_V3_QUALITY_MODEL_CHEMICAL - assert o3['QUALITY_UNITS'] == OPTION_V3_QUALITY_UNITS_MGL - assert o3['TRACE_NODE'] == '1' - assert o3['SPECIFIC_DIFFUSIVITY'] == '2.0' - assert o3['QUALITY_TOLERANCE'] == '0.001' - - o3['FLOW_UNITS'] = OPTION_V3_FLOW_UNITS_GPM - o3['PRESSURE_UNITS'] = OPTION_V3_PRESSURE_UNITS_PSI - o3['HEADLOSS_MODEL'] = OPTION_V3_HEADLOSS_MODEL_HW - o3['SPECIFIC_GRAVITY'] = '1.0' - o3['SPECIFIC_VISCOSITY'] = '1.0' - o3['MAXIMUM_TRIALS'] = '40' - o3['HEAD_TOLERANCE'] = '0.0005' - o3['FLOW_TOLERANCE'] = '0.0001' - o3['FLOW_CHANGE_LIMIT'] = '0.0' - o3['RELATIVE_ACCURACY'] = '0.001' - o3['TIME_WEIGHT'] = '0.0' - o3['STEP_SIZING'] = OPTION_V3_STEP_SIZING_RELAXATION - o3['IF_UNBALANCED'] = OPTION_V3_IF_UNBALANCED_STOP - o3['DEMAND_MODEL'] = OPTION_V3_DEMAND_MODEL_FIXED - o3['DEMAND_PATTERN'] = '1' - o3['DEMAND_MULTIPLIER'] = '1.0' - o3['MINIMUM_PRESSURE'] = '0.0' - o3['SERVICE_PRESSURE'] = '0.1' - o3['PRESSURE_EXPONENT'] = '0.5' - o3['LEAKAGE_MODEL'] = OPTION_V3_LEAKAGE_MODEL_POWER - o3['LEAKAGE_COEFF1'] = '1.0' - o3['LEAKAGE_COEFF2'] = '2.0' - o3['EMITTER_EXPONENT'] = '0.5' - o3['QUALITY_MODEL'] = OPTION_V3_QUALITY_MODEL_TRACE - o3['QUALITY_NAME'] = OPTION_V3_QUALITY_MODEL_NONE - o3['QUALITY_UNITS'] = OPTION_V3_QUALITY_UNITS_HRS - o3['TRACE_NODE'] = '2' - o3['SPECIFIC_DIFFUSIVITY'] = '1.0' - o3['QUALITY_TOLERANCE'] = '0.01' - - set_option_v3(p, ChangeSet(o3)) - - o3 = get_option_v3(p) - assert o3['FLOW_UNITS'] == OPTION_V3_FLOW_UNITS_GPM - assert o3['PRESSURE_UNITS'] == OPTION_V3_PRESSURE_UNITS_PSI - assert o3['HEADLOSS_MODEL'] == OPTION_V3_HEADLOSS_MODEL_HW - assert o3['SPECIFIC_GRAVITY'] == '1.0' - assert o3['SPECIFIC_VISCOSITY'] == '1.0' - assert o3['MAXIMUM_TRIALS'] == '40' - assert o3['HEAD_TOLERANCE'] == '0.0005' - assert o3['FLOW_TOLERANCE'] == '0.0001' - assert o3['FLOW_CHANGE_LIMIT'] == '0.0' - assert o3['RELATIVE_ACCURACY'] == '0.001' - assert o3['TIME_WEIGHT'] == '0.0' - assert o3['STEP_SIZING'] == OPTION_V3_STEP_SIZING_RELAXATION - assert o3['IF_UNBALANCED'] == OPTION_V3_IF_UNBALANCED_STOP - assert o3['DEMAND_MODEL'] == OPTION_V3_DEMAND_MODEL_FIXED - assert o3['DEMAND_PATTERN'] == '1' - assert o3['DEMAND_MULTIPLIER'] == '1.0' - assert o3['MINIMUM_PRESSURE'] == '0.0' - assert o3['SERVICE_PRESSURE'] == '0.1' - assert o3['PRESSURE_EXPONENT'] == '0.5' - assert o3['LEAKAGE_MODEL'] == OPTION_V3_LEAKAGE_MODEL_POWER - assert o3['LEAKAGE_COEFF1'] == '1.0' - assert o3['LEAKAGE_COEFF2'] == '2.0' - assert o3['EMITTER_EXPONENT'] == '0.5' - assert o3['QUALITY_MODEL'] == OPTION_V3_QUALITY_MODEL_TRACE - assert o3['QUALITY_NAME'] == OPTION_V3_QUALITY_MODEL_NONE - assert o3['QUALITY_UNITS'] == OPTION_V3_QUALITY_UNITS_HRS - assert o3['TRACE_NODE'] == '2' - assert o3['SPECIFIC_DIFFUSIVITY'] == '1.0' - assert o3['QUALITY_TOLERANCE'] == '0.01' - - o = get_option(p) - assert o['UNITS'] == OPTION_UNITS_GPM - assert o['PRESSURE'] == OPTION_PRESSURE_PSI - assert o['HEADLOSS'] == OPTION_HEADLOSS_HW - assert o['QUALITY'] == f'{OPTION_QUALITY_TRACE} 2' - assert o['UNBALANCED'] == OPTION_UNBALANCED_STOP - assert o['PATTERN'] == '1' - assert o['DEMAND MODEL'] == OPTION_DEMAND_MODEL_DDA - assert o['DEMAND MULTIPLIER'] == '1.0' - assert o['EMITTER EXPONENT'] == '0.5' - assert o['VISCOSITY'] == '1.0' - assert o['DIFFUSIVITY'] == '1.0' - assert o['SPECIFIC GRAVITY'] == '1.0' - assert o['TRIALS'] == '40' - assert o['ACCURACY'] == '0.001' - assert o['HEADERROR'] == '0.01' - assert o['FLOWCHANGE'] == '0.0' - assert o['MINIMUM PRESSURE'] == '0.0' - assert o['REQUIRED PRESSURE'] == '0.1' - assert o['PRESSURE EXPONENT'] == '0.5' - assert o['TOLERANCE'] == '0.01' - assert o['HTOL'] == '0.0005' - assert o['QTOL'] == '0.0001' - assert o['RQTOL'] == '0.000001' - assert o['CHECKFREQ'] == '1' - assert o['MAXCHECK'] == '15' - assert o['DAMPLIMIT'] == '1' - - o3['DEMAND_MODEL'] = OPTION_V3_DEMAND_MODEL_CONSTRAINED - set_option_v3(p, ChangeSet(o3)) - assert o3['DEMAND_MODEL'] == OPTION_V3_DEMAND_MODEL_CONSTRAINED - o = get_option(p) - assert o['DEMAND MODEL'] == OPTION_DEMAND_MODEL_PDA - - o3['DEMAND_MODEL'] = OPTION_V3_DEMAND_MODEL_LOGISTIC - set_option_v3(p, ChangeSet(o3)) - assert o3['DEMAND_MODEL'] == OPTION_V3_DEMAND_MODEL_LOGISTIC - o = get_option(p) - assert o['DEMAND MODEL'] == OPTION_DEMAND_MODEL_PDA - - self.leave(p) - - - def test_option_op(self): - p = 'test_option_op' - self.enter(p) - - o = get_option(p) - assert o['UNITS'] == OPTION_UNITS_LPS - assert o['PRESSURE'] == OPTION_PRESSURE_METERS - assert o['HEADLOSS'] == OPTION_HEADLOSS_HW - assert o['QUALITY'] == OPTION_QUALITY_NONE - assert o['UNBALANCED'] == OPTION_UNBALANCED_STOP - assert o['PATTERN'] == '1' - assert o['DEMAND MODEL'] == OPTION_DEMAND_MODEL_DDA - assert o['DEMAND MULTIPLIER'] == '1.0' - assert o['EMITTER EXPONENT'] == '0.5' - assert o['VISCOSITY'] == '1.0' - assert o['DIFFUSIVITY'] == '1.0' - assert o['SPECIFIC GRAVITY'] == '1.0' - assert o['TRIALS'] == '40' - assert o['ACCURACY'] == '0.001' - assert o['HEADERROR'] == '0.0001' - assert o['FLOWCHANGE'] == '0.0001' - assert o['MINIMUM PRESSURE'] == '0.0001' - assert o['REQUIRED PRESSURE'] == '20.0' - assert o['PRESSURE EXPONENT'] == '0.5' - assert o['TOLERANCE'] == '0.01' - assert o['HTOL'] == '0.0005' - assert o['QTOL'] == '0.0001' - assert o['RQTOL'] == '0.0000001' - assert o['CHECKFREQ'] == '2' - assert o['MAXCHECK'] == '10' - assert o['DAMPLIMIT'] == '0' - - o3 = get_option_v3(p) - assert o3['FLOW_UNITS'] == OPTION_V3_FLOW_UNITS_LPS - assert o3['PRESSURE_UNITS'] == OPTION_V3_PRESSURE_UNITS_METERS - assert o3['HEADLOSS_MODEL'] == OPTION_V3_HEADLOSS_MODEL_HW - assert o3['SPECIFIC_GRAVITY'] == '1.0' - assert o3['SPECIFIC_VISCOSITY'] == '1.0' - assert o3['MAXIMUM_TRIALS'] == '40' - assert o3['HEAD_TOLERANCE'] == '0.0005' - assert o3['FLOW_TOLERANCE'] == '0.0001' - assert o3['FLOW_CHANGE_LIMIT'] == '0.0001' - assert o3['RELATIVE_ACCURACY'] == '0.001' - assert o3['TIME_WEIGHT'] == '0.0' - assert o3['STEP_SIZING'] == OPTION_V3_STEP_SIZING_FULL - assert o3['IF_UNBALANCED'] == OPTION_V3_IF_UNBALANCED_STOP - assert o3['DEMAND_MODEL'] == OPTION_V3_DEMAND_MODEL_FIXED - assert o3['DEMAND_PATTERN'] == '1' - assert o3['DEMAND_MULTIPLIER'] == '1.0' - assert o3['MINIMUM_PRESSURE'] == '0.0001' - assert o3['SERVICE_PRESSURE'] == '20.0' - assert o3['PRESSURE_EXPONENT'] == '0.5' - assert o3['LEAKAGE_MODEL'] == OPTION_V3_LEAKAGE_MODEL_NONE - assert o3['LEAKAGE_COEFF1'] == '0.0' - assert o3['LEAKAGE_COEFF2'] == '0.0' - assert o3['EMITTER_EXPONENT'] == '0.5' - assert o3['QUALITY_MODEL'] == OPTION_V3_QUALITY_MODEL_NONE - assert o3['QUALITY_NAME'] == OPTION_V3_QUALITY_MODEL_CHEMICAL - assert o3['QUALITY_UNITS'] == OPTION_V3_QUALITY_UNITS_MGL - assert o3['TRACE_NODE'] == '' - assert o3['SPECIFIC_DIFFUSIVITY'] == '1.0' - assert o3['QUALITY_TOLERANCE'] == '0.01' - - o['UNITS'] = OPTION_UNITS_LPS - o['PRESSURE'] = OPTION_PRESSURE_KPA - o['HEADLOSS'] = OPTION_HEADLOSS_DW - o['QUALITY'] = f'{OPTION_QUALITY_TRACE} 1' - o['UNBALANCED'] = OPTION_UNBALANCED_CONTINUE - o['PATTERN'] = '2' - o['DEMAND MODEL'] = OPTION_DEMAND_MODEL_PDA - o['DEMAND MULTIPLIER'] = '2.0' - o['EMITTER EXPONENT'] = '1.5' - o['VISCOSITY'] = '2.0' - o['DIFFUSIVITY'] = '2.0' - o['SPECIFIC GRAVITY'] = '2.0' - o['TRIALS'] = '50' - o['ACCURACY'] = '0.0001' - o['HEADERROR'] = '0.01' - o['FLOWCHANGE'] = '0.01' - o['MINIMUM PRESSURE'] = '0.01' - o['REQUIRED PRESSURE'] = '0.01' - o['PRESSURE EXPONENT'] = '0.05' - o['TOLERANCE'] = '0.001' - o['HTOL'] = '0.005' - o['QTOL'] = '0.001' - o['RQTOL'] = '0.000001' - o['CHECKFREQ'] = '1' - o['MAXCHECK'] = '15' - o['DAMPLIMIT'] = '1' - - css = set_option(p, ChangeSet(o)).operations - cs = css[0] - assert cs['UNITS'] == OPTION_UNITS_LPS - assert cs['PRESSURE'] == OPTION_PRESSURE_KPA - assert cs['HEADLOSS'] == OPTION_HEADLOSS_DW - assert cs['QUALITY'] == f'{OPTION_QUALITY_TRACE} 1' - assert cs['UNBALANCED'] == OPTION_UNBALANCED_CONTINUE - assert cs['PATTERN'] == '2' - assert cs['DEMAND MODEL'] == OPTION_DEMAND_MODEL_PDA - assert cs['DEMAND MULTIPLIER'] == '2.0' - assert cs['EMITTER EXPONENT'] == '1.5' - assert cs['VISCOSITY'] == '2.0' - assert cs['DIFFUSIVITY'] == '2.0' - assert cs['SPECIFIC GRAVITY'] == '2.0' - assert cs['TRIALS'] == '50' - assert cs['ACCURACY'] == '0.0001' - assert cs['HEADERROR'] == '0.01' - assert cs['FLOWCHANGE'] == '0.01' - assert cs['MINIMUM PRESSURE'] == '0.01' - assert cs['REQUIRED PRESSURE'] == '0.01' - assert cs['PRESSURE EXPONENT'] == '0.05' - assert cs['TOLERANCE'] == '0.001' - assert cs['HTOL'] == '0.005' - assert cs['QTOL'] == '0.001' - assert cs['RQTOL'] == '0.000001' - assert cs['CHECKFREQ'] == '1' - assert cs['MAXCHECK'] == '15' - assert cs['DAMPLIMIT'] == '1' - cs = css[1] - assert cs['FLOW_UNITS'] == OPTION_V3_FLOW_UNITS_LPS - assert cs['PRESSURE_UNITS'] == OPTION_V3_PRESSURE_UNITS_KPA - assert cs['HEADLOSS_MODEL'] == OPTION_V3_HEADLOSS_MODEL_DW - assert cs['SPECIFIC_GRAVITY'] == '2.0' - assert cs['SPECIFIC_VISCOSITY'] == '2.0' - assert cs['MAXIMUM_TRIALS'] == '50' - assert cs['HEAD_TOLERANCE'] == '0.005' - assert cs['FLOW_TOLERANCE'] == '0.001' - assert cs['FLOW_CHANGE_LIMIT'] == '0.01' - assert cs['RELATIVE_ACCURACY'] == '0.0001' - assert cs['IF_UNBALANCED'] == OPTION_V3_IF_UNBALANCED_CONTINUE - assert cs['DEMAND_MODEL'] == OPTION_V3_DEMAND_MODEL_POWER - assert cs['DEMAND_PATTERN'] == '2' - assert cs['DEMAND_MULTIPLIER'] == '2.0' - assert cs['MINIMUM_PRESSURE'] == '0.01' - assert cs['SERVICE_PRESSURE'] == '0.01' - assert cs['PRESSURE_EXPONENT'] == '0.05' - assert cs['EMITTER_EXPONENT'] == '1.5' - assert cs['QUALITY_MODEL'] == OPTION_V3_QUALITY_MODEL_TRACE - assert cs['TRACE_NODE'] == '1' - assert cs['SPECIFIC_DIFFUSIVITY'] == '2.0' - assert cs['QUALITY_TOLERANCE'] == '0.001' - - css = execute_undo(p).operations - cs = css[0] - assert cs['FLOW_UNITS'] == OPTION_V3_FLOW_UNITS_LPS - assert cs['PRESSURE_UNITS'] == OPTION_V3_PRESSURE_UNITS_METERS - assert cs['HEADLOSS_MODEL'] == OPTION_V3_HEADLOSS_MODEL_HW - assert cs['SPECIFIC_GRAVITY'] == '1.0' - assert cs['SPECIFIC_VISCOSITY'] == '1.0' - assert cs['MAXIMUM_TRIALS'] == '40' - assert cs['HEAD_TOLERANCE'] == '0.0005' - assert cs['FLOW_TOLERANCE'] == '0.0001' - assert cs['FLOW_CHANGE_LIMIT'] == '0.0001' - assert cs['RELATIVE_ACCURACY'] == '0.001' - assert cs['IF_UNBALANCED'] == OPTION_V3_IF_UNBALANCED_STOP - assert cs['DEMAND_MODEL'] == OPTION_V3_DEMAND_MODEL_FIXED - assert cs['DEMAND_PATTERN'] == '1' - assert cs['DEMAND_MULTIPLIER'] == '1.0' - assert cs['MINIMUM_PRESSURE'] == '0.0001' - assert cs['SERVICE_PRESSURE'] == '20.0' - assert cs['PRESSURE_EXPONENT'] == '0.5' - assert cs['EMITTER_EXPONENT'] == '0.5' - assert cs['QUALITY_MODEL'] == OPTION_V3_QUALITY_MODEL_NONE - assert cs['TRACE_NODE'] == '' - assert cs['SPECIFIC_DIFFUSIVITY'] == '1.0' - assert cs['QUALITY_TOLERANCE'] == '0.01' - cs = css[1] - assert cs['UNITS'] == OPTION_UNITS_LPS - assert cs['PRESSURE'] == OPTION_PRESSURE_METERS - assert cs['HEADLOSS'] == OPTION_HEADLOSS_HW - assert cs['QUALITY'] == OPTION_QUALITY_NONE - assert cs['UNBALANCED'] == OPTION_UNBALANCED_STOP - assert cs['PATTERN'] == '1' - assert cs['DEMAND MODEL'] == OPTION_DEMAND_MODEL_DDA - assert cs['DEMAND MULTIPLIER'] == '1.0' - assert cs['EMITTER EXPONENT'] == '0.5' - assert cs['VISCOSITY'] == '1.0' - assert cs['DIFFUSIVITY'] == '1.0' - assert cs['SPECIFIC GRAVITY'] == '1.0' - assert cs['TRIALS'] == '40' - assert cs['ACCURACY'] == '0.001' - assert cs['HEADERROR'] == '0.0001' - assert cs['FLOWCHANGE'] == '0.0001' - assert cs['MINIMUM PRESSURE'] == '0.0001' - assert cs['REQUIRED PRESSURE'] == '20.0' - assert cs['PRESSURE EXPONENT'] == '0.5' - assert cs['TOLERANCE'] == '0.01' - assert cs['HTOL'] == '0.0005' - assert cs['QTOL'] == '0.0001' - assert cs['RQTOL'] == '0.0000001' - assert cs['CHECKFREQ'] == '2' - assert cs['MAXCHECK'] == '10' - assert cs['DAMPLIMIT'] == '0' - - css = execute_redo(p).operations - cs = css[0] - assert cs['UNITS'] == OPTION_UNITS_LPS - assert cs['PRESSURE'] == OPTION_PRESSURE_KPA - assert cs['HEADLOSS'] == OPTION_HEADLOSS_DW - assert cs['QUALITY'] == f'{OPTION_QUALITY_TRACE} 1' - assert cs['UNBALANCED'] == OPTION_UNBALANCED_CONTINUE - assert cs['PATTERN'] == '2' - assert cs['DEMAND MODEL'] == OPTION_DEMAND_MODEL_PDA - assert cs['DEMAND MULTIPLIER'] == '2.0' - assert cs['EMITTER EXPONENT'] == '1.5' - assert cs['VISCOSITY'] == '2.0' - assert cs['DIFFUSIVITY'] == '2.0' - assert cs['SPECIFIC GRAVITY'] == '2.0' - assert cs['TRIALS'] == '50' - assert cs['ACCURACY'] == '0.0001' - assert cs['HEADERROR'] == '0.01' - assert cs['FLOWCHANGE'] == '0.01' - assert cs['MINIMUM PRESSURE'] == '0.01' - assert cs['REQUIRED PRESSURE'] == '0.01' - assert cs['PRESSURE EXPONENT'] == '0.05' - assert cs['TOLERANCE'] == '0.001' - assert cs['HTOL'] == '0.005' - assert cs['QTOL'] == '0.001' - assert cs['RQTOL'] == '0.000001' - assert cs['CHECKFREQ'] == '1' - assert cs['MAXCHECK'] == '15' - assert cs['DAMPLIMIT'] == '1' - cs = css[1] - assert cs['FLOW_UNITS'] == OPTION_V3_FLOW_UNITS_LPS - assert cs['PRESSURE_UNITS'] == OPTION_V3_PRESSURE_UNITS_KPA - assert cs['HEADLOSS_MODEL'] == OPTION_V3_HEADLOSS_MODEL_DW - assert cs['SPECIFIC_GRAVITY'] == '2.0' - assert cs['SPECIFIC_VISCOSITY'] == '2.0' - assert cs['MAXIMUM_TRIALS'] == '50' - assert cs['HEAD_TOLERANCE'] == '0.005' - assert cs['FLOW_TOLERANCE'] == '0.001' - assert cs['FLOW_CHANGE_LIMIT'] == '0.01' - assert cs['RELATIVE_ACCURACY'] == '0.0001' - assert cs['IF_UNBALANCED'] == OPTION_V3_IF_UNBALANCED_CONTINUE - assert cs['DEMAND_MODEL'] == OPTION_V3_DEMAND_MODEL_POWER - assert cs['DEMAND_PATTERN'] == '2' - assert cs['DEMAND_MULTIPLIER'] == '2.0' - assert cs['MINIMUM_PRESSURE'] == '0.01' - assert cs['SERVICE_PRESSURE'] == '0.01' - assert cs['PRESSURE_EXPONENT'] == '0.05' - assert cs['EMITTER_EXPONENT'] == '1.5' - assert cs['QUALITY_MODEL'] == OPTION_V3_QUALITY_MODEL_TRACE - assert cs['TRACE_NODE'] == '1' - assert cs['SPECIFIC_DIFFUSIVITY'] == '2.0' - assert cs['QUALITY_TOLERANCE'] == '0.001' - - o3['FLOW_UNITS'] = OPTION_V3_FLOW_UNITS_GPM - o3['PRESSURE_UNITS'] = OPTION_V3_PRESSURE_UNITS_PSI - o3['HEADLOSS_MODEL'] = OPTION_V3_HEADLOSS_MODEL_HW - o3['SPECIFIC_GRAVITY'] = '1.0' - o3['SPECIFIC_VISCOSITY'] = '1.0' - o3['MAXIMUM_TRIALS'] = '40' - o3['HEAD_TOLERANCE'] = '0.0005' - o3['FLOW_TOLERANCE'] = '0.0001' - o3['FLOW_CHANGE_LIMIT'] = '0.0' - o3['RELATIVE_ACCURACY'] = '0.001' - o3['TIME_WEIGHT'] = '0.0' - o3['STEP_SIZING'] = OPTION_V3_STEP_SIZING_RELAXATION - o3['IF_UNBALANCED'] = OPTION_V3_IF_UNBALANCED_STOP - o3['DEMAND_MODEL'] = OPTION_V3_DEMAND_MODEL_FIXED - o3['DEMAND_PATTERN'] = '1' - o3['DEMAND_MULTIPLIER'] = '1.0' - o3['MINIMUM_PRESSURE'] = '0.0' - o3['SERVICE_PRESSURE'] = '0.1' - o3['PRESSURE_EXPONENT'] = '0.5' - o3['LEAKAGE_MODEL'] = OPTION_V3_LEAKAGE_MODEL_POWER - o3['LEAKAGE_COEFF1'] = '1.0' - o3['LEAKAGE_COEFF2'] = '2.0' - o3['EMITTER_EXPONENT'] = '0.5' - o3['QUALITY_MODEL'] = OPTION_V3_QUALITY_MODEL_TRACE - o3['QUALITY_NAME'] = OPTION_V3_QUALITY_MODEL_NONE - o3['QUALITY_UNITS'] = OPTION_V3_QUALITY_UNITS_HRS - o3['TRACE_NODE'] = '2' - o3['SPECIFIC_DIFFUSIVITY'] = '1.0' - o3['QUALITY_TOLERANCE'] = '0.01' - - css = set_option_v3(p, ChangeSet(o3)).operations - cs = css[0] - assert cs['FLOW_UNITS'] == OPTION_V3_FLOW_UNITS_GPM - assert cs['PRESSURE_UNITS'] == OPTION_V3_PRESSURE_UNITS_PSI - assert cs['HEADLOSS_MODEL'] == OPTION_V3_HEADLOSS_MODEL_HW - assert cs['SPECIFIC_GRAVITY'] == '1.0' - assert cs['SPECIFIC_VISCOSITY'] == '1.0' - assert cs['MAXIMUM_TRIALS'] == '40' - assert cs['HEAD_TOLERANCE'] == '0.0005' - assert cs['FLOW_TOLERANCE'] == '0.0001' - assert cs['FLOW_CHANGE_LIMIT'] == '0.0' - assert cs['RELATIVE_ACCURACY'] == '0.001' - assert cs['TIME_WEIGHT'] == '0.0' - assert cs['STEP_SIZING'] == OPTION_V3_STEP_SIZING_RELAXATION - assert cs['IF_UNBALANCED'] == OPTION_V3_IF_UNBALANCED_STOP - assert cs['DEMAND_MODEL'] == OPTION_V3_DEMAND_MODEL_FIXED - assert cs['DEMAND_PATTERN'] == '1' - assert cs['DEMAND_MULTIPLIER'] == '1.0' - assert cs['MINIMUM_PRESSURE'] == '0.0' - assert cs['SERVICE_PRESSURE'] == '0.1' - assert cs['PRESSURE_EXPONENT'] == '0.5' - assert cs['LEAKAGE_MODEL'] == OPTION_V3_LEAKAGE_MODEL_POWER - assert cs['LEAKAGE_COEFF1'] == '1.0' - assert cs['LEAKAGE_COEFF2'] == '2.0' - assert cs['EMITTER_EXPONENT'] == '0.5' - assert cs['QUALITY_MODEL'] == OPTION_V3_QUALITY_MODEL_TRACE - assert cs['QUALITY_NAME'] == OPTION_V3_QUALITY_MODEL_NONE - assert cs['QUALITY_UNITS'] == OPTION_V3_QUALITY_UNITS_HRS - assert cs['TRACE_NODE'] == '2' - assert cs['SPECIFIC_DIFFUSIVITY'] == '1.0' - assert cs['QUALITY_TOLERANCE'] == '0.01' - cs = css[1] - assert cs['UNITS'] == OPTION_UNITS_GPM - assert cs['PRESSURE'] == OPTION_PRESSURE_PSI - assert cs['HEADLOSS'] == OPTION_HEADLOSS_HW - assert cs['QUALITY'] == f'{OPTION_QUALITY_TRACE} 2' - assert cs['UNBALANCED'] == OPTION_UNBALANCED_STOP - assert cs['PATTERN'] == '1' - assert cs['DEMAND MODEL'] == OPTION_DEMAND_MODEL_DDA - assert cs['DEMAND MULTIPLIER'] == '1.0' - assert cs['EMITTER EXPONENT'] == '0.5' - assert cs['VISCOSITY'] == '1.0' - assert cs['DIFFUSIVITY'] == '1.0' - assert cs['SPECIFIC GRAVITY'] == '1.0' - assert cs['TRIALS'] == '40' - assert cs['ACCURACY'] == '0.001' - assert cs['FLOWCHANGE'] == '0.0' - assert cs['MINIMUM PRESSURE'] == '0.0' - assert cs['REQUIRED PRESSURE'] == '0.1' - assert cs['PRESSURE EXPONENT'] == '0.5' - assert cs['TOLERANCE'] == '0.01' - assert cs['HTOL'] == '0.0005' - assert cs['QTOL'] == '0.0001' - - css = execute_undo(p).operations - cs = css[0] - assert cs['UNITS'] == OPTION_UNITS_LPS - assert cs['PRESSURE'] == OPTION_PRESSURE_KPA - assert cs['HEADLOSS'] == OPTION_HEADLOSS_DW - assert cs['QUALITY'] == f'{OPTION_QUALITY_TRACE} 1' - assert cs['UNBALANCED'] == OPTION_UNBALANCED_CONTINUE - assert cs['PATTERN'] == '2' - assert cs['DEMAND MODEL'] == OPTION_DEMAND_MODEL_PDA - assert cs['DEMAND MULTIPLIER'] == '2.0' - assert cs['EMITTER EXPONENT'] == '1.5' - assert cs['VISCOSITY'] == '2.0' - assert cs['DIFFUSIVITY'] == '2.0' - assert cs['SPECIFIC GRAVITY'] == '2.0' - assert cs['TRIALS'] == '50' - assert cs['ACCURACY'] == '0.0001' - assert cs['FLOWCHANGE'] == '0.01' - assert cs['MINIMUM PRESSURE'] == '0.01' - assert cs['REQUIRED PRESSURE'] == '0.01' - assert cs['PRESSURE EXPONENT'] == '0.05' - assert cs['TOLERANCE'] == '0.001' - assert cs['HTOL'] == '0.005' - assert cs['QTOL'] == '0.001' - cs = css[1] - o3['FLOW_UNITS'] = OPTION_V3_FLOW_UNITS_GPM - o3['PRESSURE_UNITS'] = OPTION_V3_PRESSURE_UNITS_PSI - o3['HEADLOSS_MODEL'] = OPTION_V3_HEADLOSS_MODEL_HW - o3['SPECIFIC_GRAVITY'] = '1.0' - o3['SPECIFIC_VISCOSITY'] = '1.0' - o3['MAXIMUM_TRIALS'] = '40' - o3['HEAD_TOLERANCE'] = '0.0005' - o3['FLOW_TOLERANCE'] = '0.0001' - o3['FLOW_CHANGE_LIMIT'] = '0.0' - o3['RELATIVE_ACCURACY'] = '0.001' - o3['TIME_WEIGHT'] = '0.0' - o3['STEP_SIZING'] = OPTION_V3_STEP_SIZING_RELAXATION - o3['IF_UNBALANCED'] = OPTION_V3_IF_UNBALANCED_STOP - o3['DEMAND_MODEL'] = OPTION_V3_DEMAND_MODEL_FIXED - o3['DEMAND_PATTERN'] = '1' - o3['DEMAND_MULTIPLIER'] = '1.0' - o3['MINIMUM_PRESSURE'] = '0.0' - o3['SERVICE_PRESSURE'] = '0.1' - o3['PRESSURE_EXPONENT'] = '0.5' - o3['LEAKAGE_MODEL'] = OPTION_V3_LEAKAGE_MODEL_POWER - o3['LEAKAGE_COEFF1'] = '1.0' - o3['LEAKAGE_COEFF2'] = '2.0' - o3['EMITTER_EXPONENT'] = '0.5' - o3['QUALITY_MODEL'] = OPTION_V3_QUALITY_MODEL_TRACE - o3['QUALITY_NAME'] = OPTION_V3_QUALITY_MODEL_NONE - o3['QUALITY_UNITS'] = OPTION_V3_QUALITY_UNITS_HRS - o3['TRACE_NODE'] = '2' - o3['SPECIFIC_DIFFUSIVITY'] = '1.0' - o3['QUALITY_TOLERANCE'] = '0.01' - - css = execute_redo(p).operations - cs = css[0] - assert cs['FLOW_UNITS'] == OPTION_V3_FLOW_UNITS_GPM - assert cs['PRESSURE_UNITS'] == OPTION_V3_PRESSURE_UNITS_PSI - assert cs['HEADLOSS_MODEL'] == OPTION_V3_HEADLOSS_MODEL_HW - assert cs['SPECIFIC_GRAVITY'] == '1.0' - assert cs['SPECIFIC_VISCOSITY'] == '1.0' - assert cs['MAXIMUM_TRIALS'] == '40' - assert cs['HEAD_TOLERANCE'] == '0.0005' - assert cs['FLOW_TOLERANCE'] == '0.0001' - assert cs['FLOW_CHANGE_LIMIT'] == '0.0' - assert cs['RELATIVE_ACCURACY'] == '0.001' - assert cs['TIME_WEIGHT'] == '0.0' - assert cs['STEP_SIZING'] == OPTION_V3_STEP_SIZING_RELAXATION - assert cs['IF_UNBALANCED'] == OPTION_V3_IF_UNBALANCED_STOP - assert cs['DEMAND_MODEL'] == OPTION_V3_DEMAND_MODEL_FIXED - assert cs['DEMAND_PATTERN'] == '1' - assert cs['DEMAND_MULTIPLIER'] == '1.0' - assert cs['MINIMUM_PRESSURE'] == '0.0' - assert cs['SERVICE_PRESSURE'] == '0.1' - assert cs['PRESSURE_EXPONENT'] == '0.5' - assert cs['LEAKAGE_MODEL'] == OPTION_V3_LEAKAGE_MODEL_POWER - assert cs['LEAKAGE_COEFF1'] == '1.0' - assert cs['LEAKAGE_COEFF2'] == '2.0' - assert cs['EMITTER_EXPONENT'] == '0.5' - assert cs['QUALITY_MODEL'] == OPTION_V3_QUALITY_MODEL_TRACE - assert cs['QUALITY_NAME'] == OPTION_V3_QUALITY_MODEL_NONE - assert cs['QUALITY_UNITS'] == OPTION_V3_QUALITY_UNITS_HRS - assert cs['TRACE_NODE'] == '2' - assert cs['SPECIFIC_DIFFUSIVITY'] == '1.0' - assert cs['QUALITY_TOLERANCE'] == '0.01' - cs = css[1] - assert cs['UNITS'] == OPTION_UNITS_GPM - assert cs['PRESSURE'] == OPTION_PRESSURE_PSI - assert cs['HEADLOSS'] == OPTION_HEADLOSS_HW - assert cs['QUALITY'] == f'{OPTION_QUALITY_TRACE} 2' - assert cs['UNBALANCED'] == OPTION_UNBALANCED_STOP - assert cs['PATTERN'] == '1' - assert cs['DEMAND MODEL'] == OPTION_DEMAND_MODEL_DDA - assert cs['DEMAND MULTIPLIER'] == '1.0' - assert cs['EMITTER EXPONENT'] == '0.5' - assert cs['VISCOSITY'] == '1.0' - assert cs['DIFFUSIVITY'] == '1.0' - assert cs['SPECIFIC GRAVITY'] == '1.0' - assert cs['TRIALS'] == '40' - assert cs['ACCURACY'] == '0.001' - assert cs['FLOWCHANGE'] == '0.0' - assert cs['MINIMUM PRESSURE'] == '0.0' - assert cs['REQUIRED PRESSURE'] == '0.1' - assert cs['PRESSURE EXPONENT'] == '0.5' - assert cs['TOLERANCE'] == '0.01' - assert cs['HTOL'] == '0.0005' - assert cs['QTOL'] == '0.0001' - - o3 = get_option_v3(p) - - o3['DEMAND_MODEL'] = OPTION_V3_DEMAND_MODEL_CONSTRAINED - - css = set_option_v3(p, ChangeSet(o3)).operations - cs = css[0] - assert cs['DEMAND_MODEL'] == OPTION_V3_DEMAND_MODEL_CONSTRAINED - cs = css[1] - assert cs['DEMAND MODEL'] == OPTION_DEMAND_MODEL_PDA - - css = execute_undo(p).operations - cs = css[0] - assert cs['DEMAND MODEL'] == OPTION_DEMAND_MODEL_DDA - cs = css[1] - assert cs['DEMAND_MODEL'] == OPTION_V3_DEMAND_MODEL_FIXED - - css = execute_redo(p).operations - cs = css[0] - assert cs['DEMAND_MODEL'] == OPTION_V3_DEMAND_MODEL_CONSTRAINED - cs = css[1] - assert cs['DEMAND MODEL'] == OPTION_DEMAND_MODEL_PDA - - o3 = get_option_v3(p) - - o3['DEMAND_MODEL'] = OPTION_V3_DEMAND_MODEL_LOGISTIC - - css = set_option_v3(p, ChangeSet(o3)).operations - cs = css[0] - assert cs['DEMAND_MODEL'] == OPTION_V3_DEMAND_MODEL_LOGISTIC - cs = css[1] - assert cs['DEMAND MODEL'] == OPTION_DEMAND_MODEL_PDA - - css = execute_undo(p).operations - cs = css[0] - assert cs['DEMAND MODEL'] == OPTION_DEMAND_MODEL_PDA - cs = css[1] - assert cs['DEMAND_MODEL'] == OPTION_V3_DEMAND_MODEL_CONSTRAINED - - css = execute_redo(p).operations - cs = css[0] - assert cs['DEMAND_MODEL'] == OPTION_V3_DEMAND_MODEL_LOGISTIC - cs = css[1] - assert cs['DEMAND MODEL'] == OPTION_DEMAND_MODEL_PDA - - self.leave(p) - - - # 25 vertex - - - def test_vertex(self): - p = 'test_vertex' - self.enter(p) - - add_junction(p, ChangeSet({'id': 'j1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j2', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_pipe(p, ChangeSet({'id': 'p0', 'node1': 'j1', 'node2': 'j2', 'length': 100.0, 'diameter': 10.0, 'roughness': 0.1, 'minor_loss': 0.5, 'status': PIPE_STATUS_OPEN })) - - v = get_vertex(p, 'p0') - assert v['link'] == 'p0' - assert v['coords'] == [] - - set_vertex(p, ChangeSet({'link' : 'p0', 'coords': [{'x': 1.0, 'y': 2.0}, {'x': 2.0, 'y': 1.0}]})) - - v = get_vertex(p, 'p0') - xys = v['coords'] - assert len(xys) == 2 - assert xys[0]['x'] == 1.0 - assert xys[0]['y'] == 2.0 - assert xys[1]['x'] == 2.0 - assert xys[1]['y'] == 1.0 - - set_vertex(p, ChangeSet({'link' : 'p0', 'coords': []})) - - v = get_vertex(p, 'p0') - assert v['link'] == 'p0' - assert v['coords'] == [] - - self.leave(p) - - - def test_vertex_op(self): - p = 'test_vertex_op' - self.enter(p) - - add_junction(p, ChangeSet({'id': 'j1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j2', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_pipe(p, ChangeSet({'id': 'p0', 'node1': 'j1', 'node2': 'j2', 'length': 100.0, 'diameter': 10.0, 'roughness': 0.1, 'minor_loss': 0.5, 'status': PIPE_STATUS_OPEN })) - - cs = set_vertex(p, ChangeSet({'link' : 'p0', 'coords': [{'x': 1.0, 'y': 2.0}, {'x': 2.0, 'y': 1.0}]})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'vertex' - assert cs['link'] == 'p0' - xys = cs['coords'] - assert len(xys) == 2 - assert xys[0]['x'] == 1.0 - assert xys[0]['y'] == 2.0 - assert xys[1]['x'] == 2.0 - assert xys[1]['y'] == 1.0 - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'vertex' - assert cs['link'] == 'p0' - assert cs['coords'] == [] - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'vertex' - assert cs['link'] == 'p0' - xys = cs['coords'] - assert len(xys) == 2 - assert xys[0]['x'] == 1.0 - assert xys[0]['y'] == 2.0 - assert xys[1]['x'] == 2.0 - assert xys[1]['y'] == 1.0 - - self.leave(p) - - - # 26 label - - - def test_label(self): - p = 'test_label' - self.enter(p) - - l = get_label(p, 0.0, 0.0) - assert l['x'] == 0.0 - assert l['y'] == 0.0 - assert l['label'] == None - assert l['node'] == None - - add_junction(p, ChangeSet({'id': 'j0', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_label(p, ChangeSet({'x': 0.0, 'y': 0.0, 'label': 'x', 'node': 'j0'})) - l = get_label(p, 0.0, 0.0) - assert l['x'] == 0.0 - assert l['y'] == 0.0 - assert l['label'] == 'x' - assert l['node'] == 'j0' - - add_junction(p, ChangeSet({'id': 'j1', 'x': 1.0, 'y': 20.0, 'elevation': 20.0})) - set_label(p, ChangeSet({'x': 0.0, 'y': 0.0, 'label': 'xxx', 'node': 'j1'})) - l = get_label(p, 0.0, 0.0) - assert l['x'] == 0.0 - assert l['y'] == 0.0 - assert l['label'] == 'xxx' - assert l['node'] == 'j1' - - delete_label(p, ChangeSet({'x': 0.0, 'y': 0.0})) - l = get_label(p, 0.0, 0.0) - assert l['x'] == 0.0 - assert l['y'] == 0.0 - assert l['label'] == None - assert l['node'] == None - - self.leave(p) - - - def test_label_op(self): - p = 'test_label_op' - self.enter(p) - - add_junction(p, ChangeSet({'id': 'j0', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j1', 'x': 1.0, 'y': 20.0, 'elevation': 20.0})) - - cs = add_label(p, ChangeSet({'x': 0.0, 'y': 0.0, 'label': 'x', 'node': 'j0'})).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'label' - assert cs['x'] == 0.0 - assert cs['y'] == 0.0 - assert cs['label'] == 'x' - assert cs['node'] == 'j0' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'label' - assert cs['x'] == 0.0 - assert cs['y'] == 0.0 - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'label' - assert cs['x'] == 0.0 - assert cs['y'] == 0.0 - assert cs['label'] == 'x' - assert cs['node'] == 'j0' - - cs = set_label(p, ChangeSet({'x': 0.0, 'y': 0.0, 'label': 'xxx', 'node': 'j1'})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'label' - assert cs['x'] == 0.0 - assert cs['y'] == 0.0 - assert cs['label'] == 'xxx' - assert cs['node'] == 'j1' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'label' - assert cs['x'] == 0.0 - assert cs['y'] == 0.0 - assert cs['label'] == 'x' - assert cs['node'] == 'j0' - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'label' - assert cs['x'] == 0.0 - assert cs['y'] == 0.0 - assert cs['label'] == 'xxx' - assert cs['node'] == 'j1' - - cs = delete_label(p, ChangeSet({'x': 0.0, 'y': 0.0})).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'label' - assert cs['x'] == 0.0 - assert cs['y'] == 0.0 - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'label' - assert cs['x'] == 0.0 - assert cs['y'] == 0.0 - assert cs['label'] == 'xxx' - assert cs['node'] == 'j1' - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'label' - assert cs['x'] == 0.0 - assert cs['y'] == 0.0 - - self.leave(p) - - - # 27 backdrop - - - def test_backdrop(self): - p = 'test_backdrop' - self.enter(p) - - assert get_backdrop(p)['content'] == '' - - set_backdrop(p, ChangeSet({'content': 'x'})) - assert get_backdrop(p)['content'] == 'x' - - self.leave(p) - - - def test_backdrop_op(self): - p = 'test_backdrop_op' - self.enter(p) - - cs = set_backdrop(p, ChangeSet({'content': 'x'})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'backdrop' - assert cs['content'] == 'x' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'backdrop' - assert cs['content'] == '' - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'backdrop' - assert cs['content'] == 'x' - - self.leave(p) - - - # 28 end - - - # 29 scada_device - - - def test_scada_device(self): - p = 'test_scada_device' - self.enter(p) - - assert get_all_scada_device_ids(p) == [] - - d = get_scada_device(p, 'd0') - assert d == {} - - add_scada_device(p, ChangeSet({'id': 'd0'})) - d = get_scada_device(p, 'd0') - assert d['id'] == 'd0' - assert d['name'] == None - assert d['address'] == None - assert d['sd_type'] == None - - set_scada_device(p, ChangeSet({'id': 'd0', 'name': 'device0', 'address': 'x', 'sd_type': SCADA_DEVICE_TYPE_FLOW})) - d = get_scada_device(p, 'd0') - assert d['id'] == 'd0' - assert d['name'] == 'device0' - assert d['address'] == 'x' - assert d['sd_type'] == SCADA_DEVICE_TYPE_FLOW - - add_scada_device(p, ChangeSet({'id': 'd1', 'name': 'device1', 'address': 'x', 'sd_type': SCADA_DEVICE_TYPE_PRESSURE})) - d = get_scada_device(p, 'd1') - assert d['id'] == 'd1' - assert d['name'] == 'device1' - assert d['address'] == 'x' - assert d['sd_type'] == SCADA_DEVICE_TYPE_PRESSURE - - devices = get_all_scada_device_ids(p) - assert len(devices) == 2 - assert devices[0] == 'd0' - assert devices[1] == 'd1' - - delete_scada_device(p, ChangeSet({'id': 'd0'})) - d = get_scada_device(p, 'd0') - assert d == {} - - delete_scada_device(p, ChangeSet({'id': 'd1'})) - d = get_scada_device(p, 'd1') - assert d == {} - - assert get_all_scada_device_ids(p) == [] - - add_scada_device(p, ChangeSet({'id': 'd0'})) - d = get_scada_device(p, 'd0') - assert d['id'] == 'd0' - - add_scada_device(p, ChangeSet({'id': 'd1'})) - d = get_scada_device(p, 'd1') - assert d['id'] == 'd1' - - devices = get_all_scada_device_ids(p) - assert len(devices) == 2 - assert devices[0] == 'd0' - assert devices[1] == 'd1' - - clean_scada_device(p) - d = get_scada_device(p, 'd0') - assert d == {} - d = get_scada_device(p, 'd1') - assert d == {} - - assert get_all_scada_device_ids(p) == [] - - self.leave(p) - - - def test_scada_device_op(self): - p = 'test_scada_device_op' - - self.enter(p) - - cs = add_scada_device(p, ChangeSet({'id': 'd0'})).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'scada_device' - assert cs['id'] == 'd0' - assert cs['name'] == None - assert cs['address'] == None - assert cs['sd_type'] == None - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'scada_device' - assert cs['id'] == 'd0' - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'scada_device' - assert cs['id'] == 'd0' - assert cs['name'] == None - assert cs['address'] == None - assert cs['sd_type'] == None - - cs = set_scada_device(p, ChangeSet({'id': 'd0', 'name': 'device0', 'address': 'x', 'sd_type': SCADA_DEVICE_TYPE_FLOW})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'scada_device' - assert cs['id'] == 'd0' - assert cs['name'] == 'device0' - assert cs['address'] == 'x' - assert cs['sd_type'] == SCADA_DEVICE_TYPE_FLOW - - cs = add_scada_device(p, ChangeSet({'id': 'd1', 'name': 'device1', 'address': 'x', 'sd_type': SCADA_DEVICE_TYPE_PRESSURE})).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'scada_device' - assert cs['id'] == 'd1' - assert cs['name'] == 'device1' - assert cs['address'] == 'x' - assert cs['sd_type'] == SCADA_DEVICE_TYPE_PRESSURE - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'scada_device' - assert cs['id'] == 'd1' - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'scada_device' - assert cs['id'] == 'd1' - assert cs['name'] == 'device1' - assert cs['address'] == 'x' - assert cs['sd_type'] == SCADA_DEVICE_TYPE_PRESSURE - - cs = delete_scada_device(p, ChangeSet({'id': 'd0'})).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'scada_device' - assert cs['id'] == 'd0' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'scada_device' - assert cs['id'] == 'd0' - assert cs['name'] == 'device0' - assert cs['address'] == 'x' - assert cs['sd_type'] == SCADA_DEVICE_TYPE_FLOW - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'scada_device' - assert cs['id'] == 'd0' - - cs = delete_scada_device(p, ChangeSet({'id': 'd1'})).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'scada_device' - assert cs['id'] == 'd1' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'scada_device' - assert cs['id'] == 'd1' - assert cs['name'] == 'device1' - assert cs['address'] == 'x' - assert cs['sd_type'] == SCADA_DEVICE_TYPE_PRESSURE - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'scada_device' - assert cs['id'] == 'd1' - - cs = add_scada_device(p, ChangeSet({'id': 'd0'})).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'scada_device' - assert cs['id'] == 'd0' - assert cs['name'] == None - assert cs['address'] == None - assert cs['sd_type'] == None - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'scada_device' - assert cs['id'] == 'd0' - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'scada_device' - assert cs['id'] == 'd0' - assert cs['name'] == None - assert cs['address'] == None - assert cs['sd_type'] == None - - cs = add_scada_device(p, ChangeSet({'id': 'd1'})).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'scada_device' - assert cs['id'] == 'd1' - assert cs['name'] == None - assert cs['address'] == None - assert cs['sd_type'] == None - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'scada_device' - assert cs['id'] == 'd1' - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'scada_device' - assert cs['id'] == 'd1' - assert cs['name'] == None - assert cs['address'] == None - assert cs['sd_type'] == None - - css = clean_scada_device(p).operations - assert len(css) == 2 - cs = css[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'scada_device' - assert cs['id'] == 'd0' - cs = css[1] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'scada_device' - assert cs['id'] == 'd1' - - css = execute_undo(p).operations - assert len(css) == 2 - cs = css[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'scada_device' - assert cs['id'] == 'd1' - assert cs['name'] == None - assert cs['address'] == None - assert cs['sd_type'] == None - cs = css[1] - assert cs['operation'] == API_ADD - assert cs['type'] == 'scada_device' - assert cs['id'] == 'd0' - assert cs['name'] == None - assert cs['address'] == None - assert cs['sd_type'] == None - - css = execute_redo(p).operations - assert len(css) == 2 - cs = css[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'scada_device' - assert cs['id'] == 'd0' - cs = css[1] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'scada_device' - assert cs['id'] == 'd1' - - self.leave(p) - - - # 30 scada_device_data - - - def test_scada_device_data(self): - p = 'test_scada_device_data' - self.enter(p) - - add_scada_device(p, ChangeSet({'id': 'sd'})) - - sa = get_scada_device_data(p, 'sd') - assert sa['device_id'] == 'sd' - assert sa['data'] == [] - - set_scada_device_data(p, ChangeSet({'device_id': 'sd', 'data': [{ 'time': '2023-02-10 00:02:22', 'value': 100.0 }]})) - sa = get_scada_device_data(p, 'sd') - assert sa['device_id'] == 'sd' - assert len(sa['data']) == 1 - assert sa['data'][0]['time'] == '2023-02-10 00:02:22' - assert sa['data'][0]['value'] == 100.0 - - set_scada_device_data(p, ChangeSet({'device_id': 'sd', 'data': [{ 'time': '2023-02-10 00:02:22', 'value': 100.0 }, { 'time': '2023-02-10 00:03:22', 'value': 200.0 }]})) - sa = get_scada_device_data(p, 'sd') - assert sa['device_id'] == 'sd' - assert len(sa['data']) == 2 - assert sa['data'][0]['time'] == '2023-02-10 00:02:22' - assert sa['data'][0]['value'] == 100.0 - assert sa['data'][1]['time'] == '2023-02-10 00:03:22' - assert sa['data'][1]['value'] == 200.0 - - add_scada_device_data(p, ChangeSet({'device_id': 'sd', 'time': '2023-02-10 00:02:22', 'value': 100.0})) - sa = get_scada_device_data(p, 'sd') - assert sa['device_id'] == 'sd' - assert len(sa['data']) == 2 - assert sa['data'][0]['time'] == '2023-02-10 00:02:22' - assert sa['data'][0]['value'] == 100.0 - assert sa['data'][1]['time'] == '2023-02-10 00:03:22' - assert sa['data'][1]['value'] == 200.0 - - add_scada_device_data(p, ChangeSet({'device_id': 'sd', 'time': '2023-02-11 00:02:22', 'value': 100.0})) - sa = get_scada_device_data(p, 'sd') - assert sa['device_id'] == 'sd' - assert len(sa['data']) == 3 - assert sa['data'][0]['time'] == '2023-02-10 00:02:22' - assert sa['data'][0]['value'] == 100.0 - assert sa['data'][1]['time'] == '2023-02-10 00:03:22' - assert sa['data'][1]['value'] == 200.0 - assert sa['data'][2]['time'] == '2023-02-11 00:02:22' - assert sa['data'][2]['value'] == 100.0 - - delete_scada_device_data(p, ChangeSet({'device_id': 'sd', 'time': '2023-02-12 00:02:22'})) - sa = get_scada_device_data(p, 'sd') - assert sa['device_id'] == 'sd' - assert len(sa['data']) == 3 - assert sa['data'][0]['time'] == '2023-02-10 00:02:22' - assert sa['data'][0]['value'] == 100.0 - assert sa['data'][1]['time'] == '2023-02-10 00:03:22' - assert sa['data'][1]['value'] == 200.0 - assert sa['data'][2]['time'] == '2023-02-11 00:02:22' - assert sa['data'][2]['value'] == 100.0 - - delete_scada_device_data(p, ChangeSet({'device_id': 'sd', 'time': '2023-02-11 00:02:22'})) - sa = get_scada_device_data(p, 'sd') - assert sa['device_id'] == 'sd' - assert len(sa['data']) == 2 - assert sa['data'][0]['time'] == '2023-02-10 00:02:22' - assert sa['data'][0]['value'] == 100.0 - assert sa['data'][1]['time'] == '2023-02-10 00:03:22' - assert sa['data'][1]['value'] == 200.0 - - set_scada_device_data(p, ChangeSet({'device_id': 'sd', 'data': []})) - sa = get_scada_device_data(p, 'sd') - assert sa['device_id'] == 'sd' - assert sa['data'] == [] - - add_scada_device(p, ChangeSet({'id': 'sd0'})) - add_scada_device_data(p, ChangeSet({'device_id': 'sd0', 'time': '2023-02-10 00:02:22', 'value': 100.0})) - add_scada_device_data(p, ChangeSet({'device_id': 'sd0', 'time': '2023-02-11 00:02:22', 'value': 200.0})) - add_scada_device_data(p, ChangeSet({'device_id': 'sd0', 'time': '2023-02-12 00:02:22', 'value': 300.0})) - - add_scada_device(p, ChangeSet({'id': 'sd1'})) - set_scada_device_data(p, ChangeSet({'device_id': 'sd1', 'data': [{'time': '2023-02-10 00:02:22', 'value': 100.0}, {'time': '2023-02-11 00:02:22', 'value': 200.0}, {'time': '2023-02-12 00:02:22', 'value': 300.0}]})) - - clean_scada_device_data(p) - sa = get_scada_device_data(p, 'sd0') - assert sa['device_id'] == 'sd0' - assert sa['data'] == [] - sa = get_scada_device_data(p, 'sd1') - assert sa['device_id'] == 'sd1' - assert sa['data'] == [] - - self.leave(p) - - - def test_scada_device_data_op(self): - p = 'test_scada_device_data_op' - self.enter(p) - - add_scada_device(p, ChangeSet({'id': 'sd'})) - - cs = set_scada_device_data(p, ChangeSet({'device_id': 'sd', 'data': [{ 'time': '2023-02-10 00:02:22', 'value': 100.0 }]})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'scada_device_data' - assert cs['device_id'] == 'sd' - assert len(cs['data']) == 1 - assert cs['data'][0]['time'] == '2023-02-10 00:02:22' - assert cs['data'][0]['value'] == 100.0 - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'scada_device_data' - assert cs['device_id'] == 'sd' - assert cs['data'] == [] - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'scada_device_data' - assert cs['device_id'] == 'sd' - assert len(cs['data']) == 1 - assert cs['data'][0]['time'] == '2023-02-10 00:02:22' - assert cs['data'][0]['value'] == 100.0 - - cs = set_scada_device_data(p, ChangeSet({'device_id': 'sd', 'data': [{ 'time': '2023-02-10 00:02:22', 'value': 100.0 }, { 'time': '2023-02-10 00:03:22', 'value': 200.0 }]})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'scada_device_data' - assert cs['device_id'] == 'sd' - assert len(cs['data']) == 2 - assert cs['data'][0]['time'] == '2023-02-10 00:02:22' - assert cs['data'][0]['value'] == 100.0 - assert cs['data'][1]['time'] == '2023-02-10 00:03:22' - assert cs['data'][1]['value'] == 200.0 - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'scada_device_data' - assert cs['device_id'] == 'sd' - assert len(cs['data']) == 1 - assert cs['data'][0]['time'] == '2023-02-10 00:02:22' - assert cs['data'][0]['value'] == 100.0 - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'scada_device_data' - assert cs['device_id'] == 'sd' - assert len(cs['data']) == 2 - assert cs['data'][0]['time'] == '2023-02-10 00:02:22' - assert cs['data'][0]['value'] == 100.0 - assert cs['data'][1]['time'] == '2023-02-10 00:03:22' - assert cs['data'][1]['value'] == 200.0 - - cs = add_scada_device_data(p, ChangeSet({'device_id': 'sd', 'time': '2023-02-10 00:02:22', 'value': 100.0})) - assert len(cs.operations) == 0 - - cs = add_scada_device_data(p, ChangeSet({'device_id': 'sd', 'time': '2023-02-11 00:02:22', 'value': 100.0})).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'scada_device_data' - assert cs['device_id'] == 'sd' - assert cs['time'] == '2023-02-11 00:02:22' - assert cs['value'] == 100.0 - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'scada_device_data' - assert cs['device_id'] == 'sd' - assert cs['time'] == '2023-02-11 00:02:22' - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'scada_device_data' - assert cs['device_id'] == 'sd' - assert cs['time'] == '2023-02-11 00:02:22' - assert cs['value'] == 100.0 - - cs = delete_scada_device_data(p, ChangeSet({'device_id': 'sd', 'time': '2023-02-12 00:02:22'})) - assert len(cs.operations) == 0 - - cs = delete_scada_device_data(p, ChangeSet({'device_id': 'sd', 'time': '2023-02-11 00:02:22'})).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'scada_device_data' - assert cs['device_id'] == 'sd' - assert cs['time'] == '2023-02-11 00:02:22' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'scada_device_data' - assert cs['device_id'] == 'sd' - assert cs['time'] == '2023-02-11 00:02:22' - assert cs['value'] == 100.0 - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'scada_device_data' - assert cs['device_id'] == 'sd' - assert cs['time'] == '2023-02-11 00:02:22' - - cs = set_scada_device_data(p, ChangeSet({'device_id': 'sd', 'data': []})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'scada_device_data' - assert cs['device_id'] == 'sd' - assert cs['data'] == [] - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'scada_device_data' - assert cs['device_id'] == 'sd' - assert len(cs['data']) == 2 - assert cs['data'][0]['time'] == '2023-02-10 00:02:22' - assert cs['data'][0]['value'] == 100.0 - assert cs['data'][1]['time'] == '2023-02-10 00:03:22' - assert cs['data'][1]['value'] == 200.0 - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'scada_device_data' - assert cs['device_id'] == 'sd' - assert cs['data'] == [] - - add_scada_device(p, ChangeSet({'id': 'sd0'})) - add_scada_device_data(p, ChangeSet({'device_id': 'sd0', 'time': '2023-02-10 00:02:22', 'value': 100.0})) - add_scada_device_data(p, ChangeSet({'device_id': 'sd0', 'time': '2023-02-11 00:02:22', 'value': 200.0})) - add_scada_device_data(p, ChangeSet({'device_id': 'sd0', 'time': '2023-02-12 00:02:22', 'value': 300.0})) - - add_scada_device(p, ChangeSet({'id': 'sd1'})) - set_scada_device_data(p, ChangeSet({'device_id': 'sd1', 'data': [{'time': '2023-02-10 00:02:22', 'value': 100.0}, {'time': '2023-02-11 00:02:22', 'value': 200.0}, {'time': '2023-02-12 00:02:22', 'value': 300.0}]})) - - css = clean_scada_device_data(p).operations - cs = css[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'scada_device_data' - assert cs['device_id'] == 'sd0' - assert cs['data'] == [] - cs = css[1] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'scada_device_data' - assert cs['device_id'] == 'sd1' - assert cs['data'] == [] - - css = execute_undo(p).operations - cs = css[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'scada_device_data' - assert cs['device_id'] == 'sd1' - assert cs['data'][0] == {'time': '2023-02-10 00:02:22', 'value': 100.0} - assert cs['data'][1] == {'time': '2023-02-11 00:02:22', 'value': 200.0} - assert cs['data'][2] == {'time': '2023-02-12 00:02:22', 'value': 300.0} - cs = css[1] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'scada_device_data' - assert cs['device_id'] == 'sd0' - assert cs['data'][0] == {'time': '2023-02-10 00:02:22', 'value': 100.0} - assert cs['data'][1] == {'time': '2023-02-11 00:02:22', 'value': 200.0} - assert cs['data'][2] == {'time': '2023-02-12 00:02:22', 'value': 300.0} - - css = execute_redo(p).operations - cs = css[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'scada_device_data' - assert cs['device_id'] == 'sd0' - assert cs['data'] == [] - cs = css[1] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'scada_device_data' - assert cs['device_id'] == 'sd1' - assert cs['data'] == [] - - self.leave(p) - - - # 31 scada_element - - - def test_scada_element(self): - p = 'test_scada_element' - self.enter(p) - - add_junction(p, ChangeSet({'id': 'j1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j2', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_pipe(p, ChangeSet({'id': 'p1', 'node1': 'j1', 'node2': 'j2', 'length': 100.0, 'diameter': 10.0, 'roughness': 0.1, 'minor_loss': 0.5, 'status': PIPE_STATUS_OPEN })) - add_scada_device(p, ChangeSet({'id': 'sd0', 'name': 'device0', 'address': 'x0', 'sd_type': SCADA_DEVICE_TYPE_PRESSURE})) - add_scada_device(p, ChangeSet({'id': 'sd1', 'name': 'device1', 'address': 'x1', 'sd_type': SCADA_DEVICE_TYPE_FLOW})) - - assert get_all_scada_element_ids(p) == [] - - add_scada_element(p, ChangeSet({'id': 'sm0', 'x': 0.0, 'y': 1.0, 'device_id': 'sd0', 'model_id': 'j0', 'model_type': SCADA_MODEL_TYPE_JUNCTION, 'status': SCADA_ELEMENT_STATUS_OFFLINE})) - sm = get_scada_element(p, 'sm0') - assert sm == {} - add_scada_element(p, ChangeSet({'id': 'sm0', 'x': 0.0, 'y': 1.0, 'device_id': 'sd0', 'model_id': 'j0', 'status': SCADA_ELEMENT_STATUS_OFFLINE})) - sm = get_scada_element(p, 'sm0') - assert sm == {} - add_scada_element(p, ChangeSet({'id': 'sm0', 'x': 0.0, 'y': 1.0, 'device_id': 'sd0', 'model_type': SCADA_MODEL_TYPE_JUNCTION, 'status': SCADA_ELEMENT_STATUS_OFFLINE})) - sm = get_scada_element(p, 'sm0') - assert sm == {} - add_scada_element(p, ChangeSet({'id': 'sm0', 'x': 0.0, 'y': 1.0, 'device_id': 'sd0', 'model_type': SCADA_MODEL_TYPE_PIPE, 'status': SCADA_ELEMENT_STATUS_OFFLINE})) - sm = get_scada_element(p, 'sm0') - assert sm == {} - - add_scada_element(p, ChangeSet({'id': 'sm0', 'x': 0.0, 'y': 1.0, 'device_id': 'sd0', 'model_id': 'j1', 'model_type': SCADA_MODEL_TYPE_JUNCTION, 'status': SCADA_ELEMENT_STATUS_OFFLINE})) - sm = get_scada_element(p, 'sm0') - assert sm['id'] == 'sm0' - assert sm['x'] == 0.0 - assert sm['y'] == 1.0 - assert sm['device_id'] == 'sd0' - assert sm['model_id'] == 'j1' - assert sm['model_type'] == SCADA_MODEL_TYPE_JUNCTION - assert sm['status'] == SCADA_ELEMENT_STATUS_OFFLINE - - add_scada_element(p, ChangeSet({'id': 'sm1', 'x': 1.0, 'y': 2.0, 'device_id': 'sd1', 'model_id': 'p0', 'model_type': SCADA_MODEL_TYPE_PIPE, 'status': SCADA_ELEMENT_STATUS_ONLINE})) - sm = get_scada_element(p, 'sm1') - assert sm == {} - add_scada_element(p, ChangeSet({'id': 'sm1', 'x': 1.0, 'y': 2.0, 'device_id': 'sd1', 'model_id': 'p0', 'status': SCADA_ELEMENT_STATUS_ONLINE})) - sm = get_scada_element(p, 'sm1') - assert sm == {} - add_scada_element(p, ChangeSet({'id': 'sm1', 'x': 1.0, 'y': 2.0, 'device_id': 'sd1', 'model_type': SCADA_MODEL_TYPE_PIPE, 'status': SCADA_ELEMENT_STATUS_ONLINE})) - sm = get_scada_element(p, 'sm1') - assert sm == {} - add_scada_element(p, ChangeSet({'id': 'sm1', 'x': 1.0, 'y': 2.0, 'device_id': 'sd1', 'model_id': 'p0', 'model_type': SCADA_MODEL_TYPE_JUNCTION, 'status': SCADA_ELEMENT_STATUS_ONLINE})) - sm = get_scada_element(p, 'sm1') - assert sm == {} - - add_scada_element(p, ChangeSet({'id': 'sm1', 'x': 1.0, 'y': 2.0, 'device_id': 'sd1', 'model_id': 'p1', 'model_type': SCADA_MODEL_TYPE_PIPE, 'status': SCADA_ELEMENT_STATUS_ONLINE})) - sm = get_scada_element(p, 'sm1') - assert sm['id'] == 'sm1' - assert sm['x'] == 1.0 - assert sm['y'] == 2.0 - assert sm['device_id'] == 'sd1' - assert sm['model_id'] == 'p1' - assert sm['model_type'] == SCADA_MODEL_TYPE_PIPE - assert sm['status'] == SCADA_ELEMENT_STATUS_ONLINE - - elements = get_all_scada_element_ids(p) - assert len(elements) == 2 - assert elements[0] == 'sm0' - assert elements[1] == 'sm1' - - set_scada_element(p, ChangeSet({'id': 'sm0', 'x': 1.0, 'y': 2.0, 'device_id': 'sd1', 'model_id': 'p1', 'model_type': SCADA_MODEL_TYPE_PIPE, 'status': SCADA_ELEMENT_STATUS_ONLINE})) - sm = get_scada_element(p, 'sm0') - assert sm['id'] == 'sm0' - assert sm['x'] == 1.0 - assert sm['y'] == 2.0 - assert sm['device_id'] == 'sd1' - assert sm['model_id'] == 'p1' - assert sm['model_type'] == SCADA_MODEL_TYPE_PIPE - assert sm['status'] == SCADA_ELEMENT_STATUS_ONLINE - - set_scada_element(p, ChangeSet({'id': 'sm0', 'x': 1.0, 'y': 2.0, 'device_id': 'sd1', 'model_id': 'p1', 'model_type': SCADA_MODEL_TYPE_JUNCTION, 'status': SCADA_ELEMENT_STATUS_ONLINE})) - sm = get_scada_element(p, 'sm0') - assert sm['id'] == 'sm0' - assert sm['x'] == 1.0 - assert sm['y'] == 2.0 - assert sm['device_id'] == 'sd1' - assert sm['model_id'] == 'p1' - assert sm['model_type'] == SCADA_MODEL_TYPE_PIPE - assert sm['status'] == SCADA_ELEMENT_STATUS_ONLINE - - set_scada_element(p, ChangeSet({'id': 'sm1', 'x': 0.0, 'y': 1.0, 'device_id': 'sd0', 'model_id': 'j1', 'model_type': SCADA_MODEL_TYPE_JUNCTION, 'status': SCADA_ELEMENT_STATUS_OFFLINE})) - sm = get_scada_element(p, 'sm1') - assert sm['id'] == 'sm1' - assert sm['x'] == 0.0 - assert sm['y'] == 1.0 - assert sm['device_id'] == 'sd0' - assert sm['model_id'] == 'j1' - assert sm['model_type'] == SCADA_MODEL_TYPE_JUNCTION - assert sm['status'] == SCADA_ELEMENT_STATUS_OFFLINE - - set_scada_element(p, ChangeSet({'id': 'sm1', 'x': 0.0, 'y': 1.0, 'device_id': 'sd0', 'model_id': 'j1', 'model_type': SCADA_MODEL_TYPE_PIPE, 'status': SCADA_ELEMENT_STATUS_OFFLINE})) - sm = get_scada_element(p, 'sm1') - assert sm['id'] == 'sm1' - assert sm['x'] == 0.0 - assert sm['y'] == 1.0 - assert sm['device_id'] == 'sd0' - assert sm['model_id'] == 'j1' - assert sm['model_type'] == SCADA_MODEL_TYPE_JUNCTION - assert sm['status'] == SCADA_ELEMENT_STATUS_OFFLINE - - delete_scada_element(p, ChangeSet({'id': 'sm0'})) - sm = get_scada_element(p, 'sm0') - assert sm == {} - - delete_scada_element(p, ChangeSet({'id': 'sm1'})) - sm = get_scada_element(p, 'sm1') - assert sm == {} - - assert get_all_scada_element_ids(p) == [] - - add_scada_element(p, ChangeSet({'id': 'sm0', 'x': 0.0, 'y': 1.0, 'device_id': 'sd0', 'model_id': 'j1', 'model_type': SCADA_MODEL_TYPE_JUNCTION, 'status': SCADA_ELEMENT_STATUS_OFFLINE})) - sm = get_scada_element(p, 'sm0') - assert sm['id'] == 'sm0' - assert sm['x'] == 0.0 - assert sm['y'] == 1.0 - assert sm['device_id'] == 'sd0' - assert sm['model_id'] == 'j1' - assert sm['model_type'] == SCADA_MODEL_TYPE_JUNCTION - assert sm['status'] == SCADA_ELEMENT_STATUS_OFFLINE - - add_scada_element(p, ChangeSet({'id': 'sm1', 'x': 1.0, 'y': 2.0, 'device_id': 'sd1', 'model_id': 'p1', 'model_type': SCADA_MODEL_TYPE_PIPE, 'status': SCADA_ELEMENT_STATUS_ONLINE})) - sm = get_scada_element(p, 'sm1') - assert sm['id'] == 'sm1' - assert sm['x'] == 1.0 - assert sm['y'] == 2.0 - assert sm['device_id'] == 'sd1' - assert sm['model_id'] == 'p1' - assert sm['model_type'] == SCADA_MODEL_TYPE_PIPE - assert sm['status'] == SCADA_ELEMENT_STATUS_ONLINE - - elements = get_all_scada_element_ids(p) - assert len(elements) == 2 - assert elements[0] == 'sm0' - assert elements[1] == 'sm1' - - clean_scada_element(p) - sm = get_scada_element(p, 'sm0') - assert sm == {} - sm = get_scada_element(p, 'sm1') - assert sm == {} - - assert get_all_scada_element_ids(p) == [] - - self.leave(p) - - - def test_scada_element_op(self): - p = 'test_scada_element_op' - self.enter(p) - - add_junction(p, ChangeSet({'id': 'j1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_junction(p, ChangeSet({'id': 'j2', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - add_pipe(p, ChangeSet({'id': 'p1', 'node1': 'j1', 'node2': 'j2', 'length': 100.0, 'diameter': 10.0, 'roughness': 0.1, 'minor_loss': 0.5, 'status': PIPE_STATUS_OPEN })) - add_scada_device(p, ChangeSet({'id': 'sd0', 'name': 'device0', 'address': 'x0', 'sd_type': SCADA_DEVICE_TYPE_PRESSURE})) - add_scada_device(p, ChangeSet({'id': 'sd1', 'name': 'device1', 'address': 'x1', 'sd_type': SCADA_DEVICE_TYPE_FLOW})) - - css = add_scada_element(p, ChangeSet({'id': 'sm0', 'x': 0.0, 'y': 1.0, 'device_id': 'sd0', 'model_id': 'j0', 'model_type': SCADA_MODEL_TYPE_JUNCTION, 'status': SCADA_ELEMENT_STATUS_OFFLINE})).operations - assert len(css) == 0 - cs = add_scada_element(p, ChangeSet({'id': 'sm0', 'x': 0.0, 'y': 1.0, 'device_id': 'sd0', 'model_id': 'j1', 'model_type': SCADA_MODEL_TYPE_JUNCTION, 'status': SCADA_ELEMENT_STATUS_OFFLINE})).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'scada_element' - assert cs['id'] == 'sm0' - assert cs['x'] == 0.0 - assert cs['y'] == 1.0 - assert cs['device_id'] == 'sd0' - assert cs['model_id'] == 'j1' - assert cs['model_type'] == SCADA_MODEL_TYPE_JUNCTION - assert cs['status'] == SCADA_ELEMENT_STATUS_OFFLINE - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'scada_element' - assert cs['id'] == 'sm0' - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'scada_element' - assert cs['id'] == 'sm0' - assert cs['x'] == 0.0 - assert cs['y'] == 1.0 - assert cs['device_id'] == 'sd0' - assert cs['model_id'] == 'j1' - assert cs['model_type'] == SCADA_MODEL_TYPE_JUNCTION - assert cs['status'] == SCADA_ELEMENT_STATUS_OFFLINE - - css = add_scada_element(p, ChangeSet({'id': 'sm1', 'x': 1.0, 'y': 2.0, 'device_id': 'sd1', 'model_id': 'p0', 'model_type': SCADA_MODEL_TYPE_PIPE, 'status': SCADA_ELEMENT_STATUS_ONLINE})).operations - assert len(css) == 0 - cs = add_scada_element(p, ChangeSet({'id': 'sm1', 'x': 1.0, 'y': 2.0, 'device_id': 'sd1', 'model_id': 'p1', 'model_type': SCADA_MODEL_TYPE_PIPE, 'status': SCADA_ELEMENT_STATUS_ONLINE})).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'scada_element' - assert cs['id'] == 'sm1' - assert cs['x'] == 1.0 - assert cs['y'] == 2.0 - assert cs['device_id'] == 'sd1' - assert cs['model_id'] == 'p1' - assert cs['model_type'] == SCADA_MODEL_TYPE_PIPE - assert cs['status'] == SCADA_ELEMENT_STATUS_ONLINE - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'scada_element' - assert cs['id'] == 'sm1' - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'scada_element' - assert cs['id'] == 'sm1' - assert cs['x'] == 1.0 - assert cs['y'] == 2.0 - assert cs['device_id'] == 'sd1' - assert cs['model_id'] == 'p1' - assert cs['model_type'] == SCADA_MODEL_TYPE_PIPE - assert cs['status'] == SCADA_ELEMENT_STATUS_ONLINE - - cs = set_scada_element(p, ChangeSet({'id': 'sm0', 'x': 1.0, 'y': 2.0, 'device_id': 'sd1', 'model_id': 'p1', 'model_type': SCADA_MODEL_TYPE_PIPE, 'status': SCADA_ELEMENT_STATUS_ONLINE})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'scada_element' - assert cs['id'] == 'sm0' - assert cs['x'] == 1.0 - assert cs['y'] == 2.0 - assert cs['device_id'] == 'sd1' - assert cs['model_id'] == 'p1' - assert cs['model_type'] == SCADA_MODEL_TYPE_PIPE - assert cs['status'] == SCADA_ELEMENT_STATUS_ONLINE - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'scada_element' - assert cs['id'] == 'sm0' - assert cs['x'] == 0.0 - assert cs['y'] == 1.0 - assert cs['device_id'] == 'sd0' - assert cs['model_id'] == 'j1' - assert cs['model_type'] == SCADA_MODEL_TYPE_JUNCTION - assert cs['status'] == SCADA_ELEMENT_STATUS_OFFLINE - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'scada_element' - assert cs['id'] == 'sm0' - assert cs['x'] == 1.0 - assert cs['y'] == 2.0 - assert cs['device_id'] == 'sd1' - assert cs['model_id'] == 'p1' - assert cs['model_type'] == SCADA_MODEL_TYPE_PIPE - assert cs['status'] == SCADA_ELEMENT_STATUS_ONLINE - - cs = set_scada_element(p, ChangeSet({'id': 'sm1', 'x': 0.0, 'y': 1.0, 'device_id': 'sd0', 'model_id': 'j1', 'model_type': SCADA_MODEL_TYPE_JUNCTION, 'status': SCADA_ELEMENT_STATUS_OFFLINE})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'scada_element' - assert cs['id'] == 'sm1' - assert cs['x'] == 0.0 - assert cs['y'] == 1.0 - assert cs['device_id'] == 'sd0' - assert cs['model_id'] == 'j1' - assert cs['model_type'] == SCADA_MODEL_TYPE_JUNCTION - assert cs['status'] == SCADA_ELEMENT_STATUS_OFFLINE - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'scada_element' - assert cs['id'] == 'sm1' - assert cs['x'] == 1.0 - assert cs['y'] == 2.0 - assert cs['device_id'] == 'sd1' - assert cs['model_id'] == 'p1' - assert cs['model_type'] == SCADA_MODEL_TYPE_PIPE - assert cs['status'] == SCADA_ELEMENT_STATUS_ONLINE - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'scada_element' - assert cs['id'] == 'sm1' - assert cs['x'] == 0.0 - assert cs['y'] == 1.0 - assert cs['device_id'] == 'sd0' - assert cs['model_id'] == 'j1' - assert cs['model_type'] == SCADA_MODEL_TYPE_JUNCTION - assert cs['status'] == SCADA_ELEMENT_STATUS_OFFLINE - - cs = delete_scada_element(p, ChangeSet({'id': 'sm0'})).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'scada_element' - assert cs['id'] == 'sm0' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'scada_element' - assert cs['id'] == 'sm0' - assert cs['x'] == 1.0 - assert cs['y'] == 2.0 - assert cs['device_id'] == 'sd1' - assert cs['model_id'] == 'p1' - assert cs['model_type'] == SCADA_MODEL_TYPE_PIPE - assert cs['status'] == SCADA_ELEMENT_STATUS_ONLINE - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'scada_element' - assert cs['id'] == 'sm0' - - cs = delete_scada_element(p, ChangeSet({'id': 'sm1'})).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'scada_element' - assert cs['id'] == 'sm1' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'scada_element' - assert cs['id'] == 'sm1' - assert cs['x'] == 0.0 - assert cs['y'] == 1.0 - assert cs['device_id'] == 'sd0' - assert cs['model_id'] == 'j1' - assert cs['model_type'] == SCADA_MODEL_TYPE_JUNCTION - assert cs['status'] == SCADA_ELEMENT_STATUS_OFFLINE - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'scada_element' - assert cs['id'] == 'sm1' - - cs = add_scada_element(p, ChangeSet({'id': 'sm0', 'x': 0.0, 'y': 1.0, 'device_id': 'sd0', 'model_id': 'j1', 'model_type': SCADA_MODEL_TYPE_JUNCTION, 'status': SCADA_ELEMENT_STATUS_OFFLINE})).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'scada_element' - assert cs['id'] == 'sm0' - assert cs['x'] == 0.0 - assert cs['y'] == 1.0 - assert cs['device_id'] == 'sd0' - assert cs['model_id'] == 'j1' - assert cs['model_type'] == SCADA_MODEL_TYPE_JUNCTION - assert cs['status'] == SCADA_ELEMENT_STATUS_OFFLINE - - cs = add_scada_element(p, ChangeSet({'id': 'sm1', 'x': 1.0, 'y': 2.0, 'device_id': 'sd1', 'model_id': 'p1', 'model_type': SCADA_MODEL_TYPE_PIPE, 'status': SCADA_ELEMENT_STATUS_ONLINE})).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'scada_element' - assert cs['id'] == 'sm1' - assert cs['x'] == 1.0 - assert cs['y'] == 2.0 - assert cs['device_id'] == 'sd1' - assert cs['model_id'] == 'p1' - assert cs['model_type'] == SCADA_MODEL_TYPE_PIPE - assert cs['status'] == SCADA_ELEMENT_STATUS_ONLINE - - css = clean_scada_element(p).operations - cs = css[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'scada_element' - assert cs['id'] == 'sm0' - cs = css[1] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'scada_element' - assert cs['id'] == 'sm1' - - css = execute_undo(p).operations - cs = css[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'scada_element' - assert cs['id'] == 'sm1' - assert cs['x'] == 1.0 - assert cs['y'] == 2.0 - assert cs['device_id'] == 'sd1' - assert cs['model_id'] == 'p1' - assert cs['model_type'] == SCADA_MODEL_TYPE_PIPE - assert cs['status'] == SCADA_ELEMENT_STATUS_ONLINE - cs = css[1] - assert cs['operation'] == API_ADD - assert cs['type'] == 'scada_element' - assert cs['id'] == 'sm0' - assert cs['x'] == 0.0 - assert cs['y'] == 1.0 - assert cs['device_id'] == 'sd0' - assert cs['model_id'] == 'j1' - assert cs['model_type'] == SCADA_MODEL_TYPE_JUNCTION - assert cs['status'] == SCADA_ELEMENT_STATUS_OFFLINE - - css = execute_redo(p).operations - cs = css[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'scada_element' - assert cs['id'] == 'sm0' - cs = css[1] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'scada_element' - assert cs['id'] == 'sm1' - - self.leave(p) - - - # 32 region_util - - - def test_get_nodes_in_boundary(self): - p = 'test_get_nodes_in_boundary' - read_inp(p, f'./inp/net3.inp', '3') - open_project(p) - - vds = calculate_virtual_district(p, ['107', '139', '267', '211'])['virtual_districts'] - boundary = calculate_boundary(p, vds[0]['nodes']) - boundary = inflate_boundary(p, boundary) - nodes = get_nodes_in_boundary(p, boundary) - assert nodes == ['10', '101', '103', '105', '107', '109', '111', '115', '117', '119', '120', '257', '259', '261', '263', 'Lake'] - - self.leave(p) - - - def test_get_nodes_in_region(self): - p = 'test_get_nodes_in_region' - read_inp(p, f'./inp/net3.inp', '3') - open_project(p) - - vds = calculate_virtual_district(p, ['107', '139', '267', '211'])['virtual_districts'] - boundary = calculate_boundary(p, vds[0]['nodes']) - boundary = inflate_boundary(p, boundary) - - add_region(p, ChangeSet({'id': 'r1', 'boundary': boundary})) - - nodes = get_nodes_in_region(p, 'r1') - assert nodes == ['10', '101', '103', '105', '107', '109', '111', '115', '117', '119', '120', '257', '259', '261', '263', 'Lake'] - - add_district_metering_area(p, ChangeSet({'id': 'r2', 'boundary': boundary, 'nodes': ['10', '101', '103']})) - nodes = get_nodes_in_region(p, 'r2') - assert nodes == ['10', '101', '103'] - - self.leave(p) - - - def test_get_links_on_boundary(self): - p = 'get_links_on_boundary' - read_inp(p, f'./inp/net3.inp', '3') - open_project(p) - - add_region(p, ChangeSet({'id': 'r', 'boundary': [(24.614,13.087), (24.835,11.069), (26.144,10.747), (27.290,11.543), (25.726,12.987), (24.614,13.087)]})) - links = get_links_on_region_boundary(p, 'r') - assert links == ['183', '185', '229', '313', '315'] - - self.leave(p) - - - def test_calculate_convex_hull(self): - p = 'test_calculate_convex_hull' - read_inp(p, f'./inp/net3.inp', '3') - open_project(p) - - nodes = ['10', '101', '103', '105', '107', '109', '111', '115', '117', '119', '120', '257', '259', '261', '263', 'Lake'] - ch = calculate_convex_hull(p, nodes) - assert ch == [(20.21, 17.53), (12.96, 21.31), (8.0, 27.53), (9.0, 27.85), (23.7, 22.76), (20.21, 17.53)] - - self.leave(p) - - - def test_calculate_boundary(self): - p = 'test_calculate_boundary' - read_inp(p, f'./inp/net3.inp', '3') - open_project(p) - - nodes = ['10', '101', '103', '105', '107', '109', '111', '115', '117', '119', '120', '257', '259', '261', '263', 'Lake'] - b = calculate_boundary(p, nodes) - assert b == [(23.7, 22.76), (22.08, 23.1), (21.17, 23.32), (20.8, 23.4), (20.32, 21.57), (16.97, 21.28), (13.81, 22.94), (9.0, 27.85), (8.0, 27.53), (9.0, 27.85), (13.81, 22.94), (12.96, 21.31), (17.64, 18.92), (20.21, 17.53), (20.98, 19.18), (21.69, 21.28), (22.08, 23.1), (23.7, 22.76)] - - self.leave(p) - - - def test_inflate_boundary(self): - p = 'test_inflate_boundary' - read_inp(p, f'./inp/net3.inp', '3') - open_project(p) - - nodes = ['10', '101', '103', '105', '107', '109', '111', '115', '117', '119', '120', '257', '259', '261', '263', 'Lake'] - b = calculate_boundary(p, nodes) - b = inflate_boundary(p, b) - assert b == [(20.57, 17.12), (21.44, 18.98), (21.45, 19.01), (22.17, 21.13), (22.18, 21.16), (22.46, 22.5), (24.09, 22.17), (24.29, 23.150000000000002), (22.19, 23.580000000000002), (22.2, 23.59), (21.28, 23.81), (20.71, 23.93), (20.37, 23.72), (19.92, 22.03), (17.06, 21.79), (14.120000000000001, 23.330000000000002), (9.26, 28.3), (8.98, 28.37), (7.37, 27.85), (7.68, 26.900000000000002), (8.85, 27.27), (13.19, 22.84), (12.42, 21.36), (12.55, 20.96), (17.41, 18.47), (20.16, 16.990000000000002), (20.57, 17.12)] - - self.leave(p) - - - def test_inflate_region(self): - p = 'test_inflate_region' - read_inp(p, f'./inp/net3.inp', '3') - open_project(p) - - vds = calculate_virtual_district(p, ['107', '139', '267', '211'])['virtual_districts'] - boundary = calculate_boundary(p, vds[0]['nodes']) - add_region(p, ChangeSet({'id': 'r', 'boundary': boundary})) - b = inflate_region(p, 'r') - assert b == [(20.57, 17.12), (21.44, 18.98), (21.45, 19.01), (22.17, 21.13), (22.18, 21.16), (22.46, 22.5), (24.09, 22.17), (24.29, 23.150000000000002), (22.19, 23.580000000000002), (22.2, 23.59), (21.28, 23.81), (20.71, 23.93), (20.37, 23.72), (19.92, 22.03), (17.06, 21.79), (14.120000000000001, 23.330000000000002), (9.26, 28.3), (8.98, 28.37), (7.37, 27.85), (7.68, 26.900000000000002), (8.85, 27.27), (13.19, 22.84), (12.42, 21.36), (12.55, 20.96), (17.41, 18.47), (20.16, 16.990000000000002), (20.57, 17.12)] - - self.leave(p) - - - # 32 region - - - def test_region(self): - p = 'test_region' - self.enter(p) - - r = get_region(p, 'r') - assert r == {} - - add_region(p, ChangeSet({'id': 'r', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)]})) - r = get_region(p, 'r') - assert r == { 'id': 'r', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] } - - set_region(p, ChangeSet({'id': 'r', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 2.0), (0.0, 0.0)]})) - r = get_region(p, 'r') - assert r == { 'id': 'r', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 2.0), (0.0, 0.0)] } - - delete_region(p, ChangeSet({'id': 'r'})) - r = get_region(p, 'r') - assert r == {} - - self.leave(p) - - - def test_region_op(self): - p = 'test_region_op' - self.enter(p) - - cs = add_region(p, ChangeSet({'id': 'r', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)]})).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'region' - assert cs['id'] == 'r' - assert cs['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'region' - assert cs['id'] == 'r' - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'region' - assert cs['id'] == 'r' - assert cs['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] - - cs = set_region(p, ChangeSet({'id': 'r', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 2.0), (0.0, 0.0)]})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'region' - assert cs['id'] == 'r' - assert cs['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 2.0), (0.0, 0.0)] - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'region' - assert cs['id'] == 'r' - assert cs['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'region' - assert cs['id'] == 'r' - assert cs['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 2.0), (0.0, 0.0)] - - cs = delete_region(p, ChangeSet({'id': 'r'})).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'region' - assert cs['id'] == 'r' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'region' - assert cs['id'] == 'r' - assert cs['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 2.0), (0.0, 0.0)] - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'region' - assert cs['id'] == 'r' - - self.leave(p) - - - # 33 district_metering_area - - - def test_calculate_district_metering_area_for_nodes(self): - p = 'test_calculate_district_metering_area_for_nodes' - read_inp(p, f'./inp/net3.inp', '3') - open_project(p) - - dmas = calculate_district_metering_area_for_nodes(p, get_nodes(p), 3) - assert len(dmas) == 3 - assert dmas[0] == ['117', '119', '120', '121', '123', '125', '127', '129', '131', '139', '141', '143', '145', '147', '149', '15', '151', '153', '157', '159', '161', '195', '20', '257', '259', '261', '263', '3', '60', '601', '61', 'River'] - assert dmas[1] == ['1', '163', '164', '166', '167', '169', '171', '173', '177', '179', '181', '183', '184', '185', '187', '189', '191', '193', '199', '201', '203', '204', '205', '207', '265', '267', '269', '271', '273', '275', '35', '40'] - assert dmas[2] == ['10', '101', '103', '105', '107', '109', '111', '113', '115', '197', '2', '206', '208', '209', '211', '213', '215', '217', '219', '225', '229', '231', '237', '239', '241', '243', '247', '249', '251', '253', '255', '50', 'Lake'] - - self.leave(p) - - - def test_calculate_district_metering_area_for_region(self): - p = 'test_calculate_district_metering_area_for_region' - read_inp(p, f'./inp/net3.inp', '3') - open_project(p) - - add_region(p, ChangeSet({'id': 'r', 'boundary': [(-10000.0, -10000.0), (10000.0, -10000.0), (10000.0, 10000.0), (-10000.0, 10000.0), (-10000.0, -10000.0)]})) - - nodes = get_nodes_in_region(p, 'r') - assert len(nodes) == 97 - - dmas = calculate_district_metering_area_for_region(p, 'r', 3) - assert dmas[0] == ['15', '20', '60', '601', '61', '117', '119', '120', '121', '123', '125', '127', '129', '131', '139', '141', '143', '145', '147', '149', '151', '153', '157', '159', '161', '195', '257', '259', '261', '263', 'River', '3'] - assert dmas[1] == ['50', '171', '173', '184', '199', '201', '203', '205', '206', '207', '208', '209', '211', '213', '215', '217', '219', '225', '229', '231', '237', '239', '241', '243', '247', '249', '251', '253', '255', '273', '275', '2'] - assert dmas[2] == ['10', '35', '40', '101', '103', '105', '107', '109', '111', '113', '115', '163', '164', '166', '167', '169', '177', '179', '181', '183', '185', '187', '189', '191', '193', '197', '204', '265', '267', '269', '271', 'Lake', '1'] - - self.leave(p) - - - def test_calculate_district_metering_area_for_network(self): - p = 'test_calculate_district_metering_area_for_region' - read_inp(p, f'./inp/net3.inp', '3') - open_project(p) - - dmas = calculate_district_metering_area_for_network(p, 3) - assert len(dmas) == 3 - assert dmas[0] == ['117', '119', '120', '121', '123', '125', '127', '129', '131', '139', '141', '143', '145', '147', '149', '15', '151', '153', '157', '159', '161', '195', '20', '257', '259', '261', '263', '3', '60', '601', '61', 'River'] - assert dmas[1] == ['1', '163', '164', '166', '167', '169', '171', '173', '177', '179', '181', '183', '184', '185', '187', '189', '191', '193', '199', '201', '203', '204', '205', '207', '265', '267', '269', '271', '273', '275', '35', '40'] - assert dmas[2] == ['10', '101', '103', '105', '107', '109', '111', '113', '115', '197', '2', '206', '208', '209', '211', '213', '215', '217', '219', '225', '229', '231', '237', '239', '241', '243', '247', '249', '251', '253', '255', '50', 'Lake'] - - self.leave(p) - - - def test_district_metering_area(self): - p = 'test_district_metering_area' - self.enter(p) - - dma = get_district_metering_area(p, 'dma') - assert dma == {} - - add_district_metering_area(p, ChangeSet({'id': 'dma', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)]})) - dma = get_district_metering_area(p, 'dma') - assert dma == {} - - add_district_metering_area(p, ChangeSet({'id': 'dma', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)], 'parent': '1'})) - dma = get_district_metering_area(p, 'dma') - assert dma == {} - - add_district_metering_area(p, ChangeSet({'id': 'dma', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)]})) - dma = get_district_metering_area(p, 'dma') - assert dma['id'] == 'dma' - assert dma['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] - assert dma['parent'] == None - assert dma['nodes'] == [] - assert dma['level'] == 1 - - set_district_metering_area(p, ChangeSet({'id': 'dma', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)], 'parent': '1'})) - dma = get_district_metering_area(p, 'dma') - assert dma['id'] == 'dma' - assert dma['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] - assert dma['parent'] == None - assert dma['nodes'] == [] - assert dma['level'] == 1 - - add_district_metering_area(p, ChangeSet({'id': 'd0', 'boundary': [(0.0, 0.0), (1.0, 0.0), (2.0, 2.0), (0.0, 0.0)]})) - set_district_metering_area(p, ChangeSet({'id': 'dma', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)], 'parent': 'd0'})) - dma = get_district_metering_area(p, 'dma') - assert dma['id'] == 'dma' - assert dma['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] - assert dma['parent'] == 'd0' - assert dma['nodes'] == [] - assert dma['level'] == 2 - - set_district_metering_area(p, ChangeSet({'id': 'dma', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)], 'nodes': ['1']})) - dma = get_district_metering_area(p, 'dma') - assert dma['id'] == 'dma' - assert dma['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] - assert dma['parent'] == 'd0' - assert dma['nodes'] == [] - assert dma['level'] == 2 - - add_junction(p, ChangeSet({'id': 'j0', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - set_district_metering_area(p, ChangeSet({'id': 'dma', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)], 'nodes': ['j0']})) - dma = get_district_metering_area(p, 'dma') - assert dma['id'] == 'dma' - assert dma['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] - assert dma['parent'] == 'd0' - assert dma['nodes'] == ['j0'] - assert dma['level'] == 2 - - delete_district_metering_area(p, ChangeSet({'id': 'dma'})) - dma = get_district_metering_area(p, 'dma') - assert dma == {} - - add_district_metering_area(p, ChangeSet({'id': 'dma', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)], 'parent': 'd0', 'nodes': ['j0']})) - dma = get_district_metering_area(p, 'dma') - assert dma['id'] == 'dma' - assert dma['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] - assert dma['parent'] == 'd0' - assert dma['nodes'] == ['j0'] - assert dma['level'] == 2 - - delete_district_metering_area(p, ChangeSet({'id': 'd0'})) - dma = get_district_metering_area(p, 'd0') - assert dma != {} - - delete_district_metering_area(p, ChangeSet({'id': 'dma'})) - dma = get_district_metering_area(p, 'dma') - assert dma == {} - - delete_district_metering_area(p, ChangeSet({'id': 'd0'})) - dma = get_district_metering_area(p, 'd0') - assert dma == {} - - self.leave(p) - - - def test_district_metering_area_op(self): - p = 'test_district_metering_area_op' - self.enter(p) - - cs = add_district_metering_area(p, ChangeSet({'id': 'dma', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)]})).operations - assert len(cs) == 0 - - cs = add_district_metering_area(p, ChangeSet({'id': 'dma', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)], 'parent': '1'})).operations - assert len(cs) == 0 - - cs = add_district_metering_area(p, ChangeSet({'id': 'dma', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)]})).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'district_metering_area' - assert cs['id'] == 'dma' - assert cs['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] - assert cs['parent'] == None - assert cs['nodes'] == [] - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'district_metering_area' - assert cs['id'] == 'dma' - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'district_metering_area' - assert cs['id'] == 'dma' - assert cs['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] - assert cs['parent'] == None - assert cs['nodes'] == [] - - cs = set_district_metering_area(p, ChangeSet({'id': 'dma', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)], 'parent': '1'})).operations - assert len(cs) == 0 - - add_district_metering_area(p, ChangeSet({'id': 'd0', 'boundary': [(0.0, 0.0), (1.0, 0.0), (2.0, 2.0), (0.0, 0.0)]})) - cs = set_district_metering_area(p, ChangeSet({'id': 'dma', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)], 'parent': 'd0'})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'district_metering_area' - assert cs['id'] == 'dma' - assert cs['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] - assert cs['parent'] == 'd0' - assert cs['nodes'] == [] - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'district_metering_area' - assert cs['id'] == 'dma' - assert cs['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] - assert cs['parent'] == None - assert cs['nodes'] == [] - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'district_metering_area' - assert cs['id'] == 'dma' - assert cs['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] - assert cs['parent'] == 'd0' - assert cs['nodes'] == [] - - cs = set_district_metering_area(p, ChangeSet({'id': 'dma', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)], 'nodes': ['1']})).operations - assert len(cs) == 0 - - add_junction(p, ChangeSet({'id': 'j0', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - cs = set_district_metering_area(p, ChangeSet({'id': 'dma', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)], 'nodes': ['j0']})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'district_metering_area' - assert cs['id'] == 'dma' - assert cs['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] - assert cs['parent'] == 'd0' - assert cs['nodes'] == ['j0'] - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'district_metering_area' - assert cs['id'] == 'dma' - assert cs['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] - assert cs['parent'] == 'd0' - assert cs['nodes'] == [] - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'district_metering_area' - assert cs['id'] == 'dma' - assert cs['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] - assert cs['parent'] == 'd0' - assert cs['nodes'] == ['j0'] - - cs = delete_district_metering_area(p, ChangeSet({'id': 'xxx'})).operations - assert len(cs) == 0 - - cs = delete_district_metering_area(p, ChangeSet({'id': 'dma'})).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'district_metering_area' - assert cs['id'] == 'dma' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'district_metering_area' - assert cs['id'] == 'dma' - assert cs['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] - assert cs['parent'] == 'd0' - assert cs['nodes'] == ['j0'] - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'district_metering_area' - assert cs['id'] == 'dma' - - cs = add_district_metering_area(p, ChangeSet({'id': 'dma', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)], 'parent': 'd0', 'nodes': ['j0']})).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'district_metering_area' - assert cs['id'] == 'dma' - assert cs['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] - assert cs['parent'] == 'd0' - assert cs['nodes'] == ['j0'] - - cs = delete_district_metering_area(p, ChangeSet({'id': 'd0'})).operations - assert len(cs) == 0 - - cs = delete_district_metering_area(p, ChangeSet({'id': 'dma'})).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'district_metering_area' - assert cs['id'] == 'dma' - - cs = delete_district_metering_area(p, ChangeSet({'id': 'dma'})).operations - assert len(cs) == 0 - - cs = delete_district_metering_area(p, ChangeSet({'id': 'd0'})).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'district_metering_area' - assert cs['id'] == 'd0' - - self.leave(p) - - - def test_district_metering_area_gen(self): - p = 'test_district_metering_area_gen' - read_inp(p, f'./inp/net3.inp', '3') - open_project(p) - - cs = generate_district_metering_area(p, 3).operations - assert len(cs) == 3 - assert cs[0]['operation'] == API_ADD - assert cs[0]['type'] == 'district_metering_area' - assert cs[0]['id'] == 'DMA_1_1' - assert cs[1]['operation'] == API_ADD - assert cs[1]['type'] == 'district_metering_area' - assert cs[1]['id'] == 'DMA_1_2' - assert cs[2]['operation'] == API_ADD - assert cs[2]['type'] == 'district_metering_area' - assert cs[2]['id'] == 'DMA_1_3' - - dmas = get_all_district_metering_area_ids(p) - assert len(dmas) == 3 - assert dmas[0] == 'DMA_1_1' - assert dmas[1] == 'DMA_1_2' - assert dmas[2] == 'DMA_1_3' - - cs = generate_district_metering_area(p, 3).operations - assert len(cs) == 6 - assert cs[0]['operation'] == API_DELETE - assert cs[0]['type'] == 'district_metering_area' - assert cs[0]['id'] == 'DMA_1_1' - assert cs[1]['operation'] == API_DELETE - assert cs[1]['type'] == 'district_metering_area' - assert cs[1]['id'] == 'DMA_1_2' - assert cs[2]['operation'] == API_DELETE - assert cs[2]['type'] == 'district_metering_area' - assert cs[2]['id'] == 'DMA_1_3' - assert cs[3]['operation'] == API_ADD - assert cs[3]['type'] == 'district_metering_area' - assert cs[3]['id'] == 'DMA_1_1' - assert cs[4]['operation'] == API_ADD - assert cs[4]['type'] == 'district_metering_area' - assert cs[4]['id'] == 'DMA_1_2' - assert cs[5]['operation'] == API_ADD - assert cs[5]['type'] == 'district_metering_area' - assert cs[5]['id'] == 'DMA_1_3' - - dmas = get_all_district_metering_area_ids(p) - assert len(dmas) == 3 - assert dmas[0] == 'DMA_1_1' - assert dmas[1] == 'DMA_1_2' - assert dmas[2] == 'DMA_1_3' - - cs = generate_sub_district_metering_area(p, 'DMA_1_1', 2).operations - assert len(cs) == 2 - assert cs[0]['operation'] == API_ADD - assert cs[0]['type'] == 'district_metering_area' - assert cs[0]['id'] == 'DMA_[DMA_1_1]_2_1' - assert cs[1]['operation'] == API_ADD - assert cs[1]['type'] == 'district_metering_area' - assert cs[1]['id'] == 'DMA_[DMA_1_1]_2_2' - - cs = generate_sub_district_metering_area(p, 'DMA_1_2', 3).operations - assert len(cs) == 3 - assert cs[0]['operation'] == API_ADD - assert cs[0]['type'] == 'district_metering_area' - assert cs[0]['id'] == 'DMA_[DMA_1_2]_2_1' - assert cs[1]['operation'] == API_ADD - assert cs[1]['type'] == 'district_metering_area' - assert cs[1]['id'] == 'DMA_[DMA_1_2]_2_2' - assert cs[2]['operation'] == API_ADD - assert cs[2]['type'] == 'district_metering_area' - assert cs[2]['id'] == 'DMA_[DMA_1_2]_2_3' - - cs = generate_sub_district_metering_area(p, 'DMA_1_3', 2).operations - assert len(cs) == 2 - assert cs[0]['operation'] == API_ADD - assert cs[0]['type'] == 'district_metering_area' - assert cs[0]['id'] == 'DMA_[DMA_1_3]_2_1' - assert cs[1]['operation'] == API_ADD - assert cs[1]['type'] == 'district_metering_area' - assert cs[1]['id'] == 'DMA_[DMA_1_3]_2_2' - - dmas = get_all_district_metering_area_ids(p) - assert len(dmas) == 10 - - cs = generate_district_metering_area(p, 3).operations - assert len(cs) == 13 - - dmas = get_all_district_metering_area_ids(p) - assert len(dmas) == 3 - - self.leave(p) - - - # 34 service_area - - - def test_calculate_service_area(self): - p = 'test_calculate_service_area' - 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'] - - assert sass[1]['River'] == ['River', '60', '61', '123', '601'] - assert sass[1]['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[1]['Lake'] == ['105', '107', 'Lake', '10', '101', '103', '109'] - assert sass[1]['1'] == ['189', '185', '271', '184', '205', '273', '1', '40', '179', '177', '183', '181', '35'] - assert sass[1]['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'] - - self.leave(p) - - - def test_service_area(self): - p = 'test_service_area' - self.enter(p) - - sa = get_service_area(p, 'sa') - assert sa == {} - - add_service_area(p, ChangeSet({'id': 'sa', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)]})) - sa = get_service_area(p, 'sa') - assert sa == {} - - add_service_area(p, ChangeSet({'id': 'sa', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)], 'source': 'j0'})) - sa = get_service_area(p, 'sa') - assert sa == {} - - add_junction(p, ChangeSet({'id': 'j0', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - - add_service_area(p, ChangeSet({'id': 'sa', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)], 'source': 'j0'})) - sa = get_service_area(p, 'sa') - assert sa == {} - - add_service_area(p, ChangeSet({'id': 'sa', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)], 'source': 'j0', 'nodes' : ['x']})) - sa = get_service_area(p, 'sa') - assert sa == {} - - add_service_area(p, ChangeSet({'id': 'sa', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)], 'source': 'j0', 'time_index' : 0, 'nodes': ['j0']})) - sa = get_service_area(p, 'sa') - assert sa['id'] == 'sa' - assert sa['time_index'] == 0 - assert sa['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] - assert sa['source'] == 'j0' - assert sa['nodes'] == ['j0'] - - add_junction(p, ChangeSet({'id': 'j1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - - set_service_area(p, ChangeSet({'id': 'sa', 'source': 'j1', 'time_index' : 1, 'nodes': ['j1']})) - sa = get_service_area(p, 'sa') - assert sa['id'] == 'sa' - assert sa['time_index'] == 1 - assert sa['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] - assert sa['source'] == 'j1' - assert sa['nodes'] == ['j1'] - - assert get_all_service_area_ids(p) == ['sa'] - sas = get_all_service_areas(p) - assert len(sas) == 1 - sa = sas[0] - assert sa['id'] == 'sa' - assert sa['time_index'] == 1 - assert sa['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] - assert sa['source'] == 'j1' - assert sa['nodes'] == ['j1'] - - delete_service_area(p, ChangeSet({'id': 'sa'})) - sa = get_service_area(p, 'sa') - assert sa == {} - - self.leave(p) - - - def test_service_area_op(self): - p = 'test_service_area_op' - self.enter(p) - - cs = add_service_area(p, ChangeSet({'id': 'sa', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)]})).operations - assert len(cs) == 0 - - cs = add_service_area(p, ChangeSet({'id': 'sa', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)], 'source': 'j0'})).operations - assert len(cs) == 0 - - add_junction(p, ChangeSet({'id': 'j0', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - - cs = add_service_area(p, ChangeSet({'id': 'sa', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)], 'source': 'j0'})).operations - assert len(cs) == 0 - - cs = add_service_area(p, ChangeSet({'id': 'sa', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)], 'source': 'j0', 'nodes' : ['x']})).operations - assert len(cs) == 0 - - cs = add_service_area(p, ChangeSet({'id': 'sa', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)], 'source': 'j0', 'time_index' : 0, 'nodes': ['j0']})).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'service_area' - assert cs['id'] == 'sa' - assert cs['time_index'] == 0 - assert cs['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] - assert cs['source'] == 'j0' - assert cs['nodes'] == ['j0'] - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'service_area' - assert cs['id'] == 'sa' - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'service_area' - assert cs['id'] == 'sa' - assert cs['time_index'] == 0 - assert cs['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] - assert cs['source'] == 'j0' - assert cs['nodes'] == ['j0'] - - add_junction(p, ChangeSet({'id': 'j1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - - cs = set_service_area(p, ChangeSet({'id': 'sa', 'source': 'j1', 'time_index' : 1, 'nodes': ['j1']})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'service_area' - assert cs['id'] == 'sa' - assert cs['time_index'] == 1 - assert cs['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] - assert cs['source'] == 'j1' - assert cs['nodes'] == ['j1'] - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'service_area' - assert cs['id'] == 'sa' - assert cs['time_index'] == 0 - assert cs['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] - assert cs['source'] == 'j0' - assert cs['nodes'] == ['j0'] - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'service_area' - assert cs['id'] == 'sa' - assert cs['time_index'] == 1 - assert cs['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] - assert cs['source'] == 'j1' - assert cs['nodes'] == ['j1'] - - cs = delete_service_area(p, ChangeSet({'id': 'sa'})).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'service_area' - assert cs['id'] == 'sa' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'service_area' - assert cs['id'] == 'sa' - assert cs['time_index'] == 1 - assert cs['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] - assert cs['source'] == 'j1' - assert cs['nodes'] == ['j1'] - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'service_area' - assert cs['id'] == 'sa' - - self.leave(p) - - - def test_service_area_gen(self): - p = 'test_service_area_gen' - read_inp(p, f'./inp/net3.inp', '3') - open_project(p) - - cs = generate_service_area(p).operations - assert len(cs) == 78 - - assert len(get_all_service_area_ids(p)) == 78 - assert len(get_all_service_areas(p)) == 78 - - cs = generate_service_area(p).operations - assert len(cs) == 78 * 2 - - assert len(get_all_service_area_ids(p)) == 78 - assert len(get_all_service_areas(p)) == 78 - - self.leave(p) - - - # 35 virtual_district - - - def test_calculate_virtual_district(self): - p = 'test_calculate_virtual_district' - read_inp(p, f'./inp/net3.inp', '3') - open_project(p) - - result = calculate_virtual_district(p, ['107', '139', '267', '211']) - assert result['isolated_nodes'] == [] - vds = result['virtual_districts'] - assert len(vds) == 4 - assert vds[0]['center'] == '107' - assert vds[0]['nodes'] == ['10', '101', '103', '105', '107', '109', '111', '115', '117', '119', '120', '257', '259', '261', '263', 'Lake'] - assert vds[1]['center'] == '139' - assert vds[1]['nodes'] == ['15', '20', '60', '601', '61', '121', '123', '125', '127', '129', '131', '139', '141', '143', '145', '147', '149', '151', '153', 'River', '3'] - assert vds[2]['center'] == '267' - assert vds[2]['nodes'] == ['35', '40', '113', '157', '159', '161', '163', '164', '166', '167', '169', '171', '173', '177', '179', '181', '183', '184', '185', '187', '189', '191', '193', '195', '197', '204', '265', '267', '269', '271', '1'] - assert vds[3]['center'] == '211' - assert vds[3]['nodes'] == ['50', '199', '201', '203', '205', '206', '207', '208', '209', '211', '213', '215', '217', '219', '225', '229', '231', '237', '239', '241', '243', '247', '249', '251', '253', '255', '273', '275', '2'] - - self.leave(p) - - - def test_virtual_district(self): - p = 'test_virtual_district' - self.enter(p) - - vd = get_virtual_district(p, 'vd') - assert vd == {} - - add_virtual_district(p, ChangeSet({'id': 'vd', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)]})) - vd = get_virtual_district(p, 'vd') - assert vd == {} - - add_virtual_district(p, ChangeSet({'id': 'vd', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)], 'center': 'j0'})) - vd = get_virtual_district(p, 'vd') - assert vd == {} - - add_junction(p, ChangeSet({'id': 'j0', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - - add_virtual_district(p, ChangeSet({'id': 'vd', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)], 'center': 'j0', 'nodes' : ['x']})) - vd = get_virtual_district(p, 'vd') - assert vd == {} - - add_virtual_district(p, ChangeSet({'id': 'vd', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)], 'center': 'j0', 'nodes': ['j0']})) - vd = get_virtual_district(p, 'vd') - assert vd['id'] == 'vd' - assert vd['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] - assert vd['center'] == 'j0' - assert vd['nodes'] == ['j0'] - - add_junction(p, ChangeSet({'id': 'j1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - - set_virtual_district(p, ChangeSet({'id': 'vd', 'center': 'j1', 'nodes': ['j1']})) - vd = get_virtual_district(p, 'vd') - assert vd['id'] == 'vd' - assert vd['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] - assert vd['center'] == 'j1' - assert vd['nodes'] == ['j1'] - - assert get_all_virtual_district_ids(p) == ['vd'] - vds = get_all_virtual_districts(p) - assert len(vds) == 1 - vd = vds[0] - assert vd['id'] == 'vd' - assert vd['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] - assert vd['center'] == 'j1' - assert vd['nodes'] == ['j1'] - - delete_virtual_district(p, ChangeSet({'id': 'vd'})) - vd = get_virtual_district(p, 'vd') - assert vd == {} - - self.leave(p) - - - def test_virtual_district_op(self): - p = 'test_virtual_district_op' - self.enter(p) - - cs = add_virtual_district(p, ChangeSet({'id': 'vd', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)]})).operations - assert len(cs) == 0 - - cs = add_virtual_district(p, ChangeSet({'id': 'vd', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)], 'center': 'j0'})).operations - assert len(cs) == 0 - - add_junction(p, ChangeSet({'id': 'j0', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - - cs = add_virtual_district(p, ChangeSet({'id': 'vd', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)], 'center': 'j0', 'nodes' : ['x']})).operations - assert len(cs) == 0 - - cs = add_virtual_district(p, ChangeSet({'id': 'vd', 'boundary': [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)], 'center': 'j0', 'nodes': ['j0']})).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'virtual_district' - assert cs['id'] == 'vd' - assert cs['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] - assert cs['center'] == 'j0' - assert cs['nodes'] == ['j0'] - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'virtual_district' - assert cs['id'] == 'vd' - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'virtual_district' - assert cs['id'] == 'vd' - assert cs['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] - assert cs['center'] == 'j0' - assert cs['nodes'] == ['j0'] - - add_junction(p, ChangeSet({'id': 'j1', 'x': 0.0, 'y': 10.0, 'elevation': 20.0})) - - cs = set_virtual_district(p, ChangeSet({'id': 'vd', 'center': 'j1', 'nodes': ['j1']})).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'virtual_district' - assert cs['id'] == 'vd' - assert cs['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] - assert cs['center'] == 'j1' - assert cs['nodes'] == ['j1'] - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'virtual_district' - assert cs['id'] == 'vd' - assert cs['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] - assert cs['center'] == 'j0' - assert cs['nodes'] == ['j0'] - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_UPDATE - assert cs['type'] == 'virtual_district' - assert cs['id'] == 'vd' - assert cs['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] - assert cs['center'] == 'j1' - assert cs['nodes'] == ['j1'] - - cs = delete_virtual_district(p, ChangeSet({'id': 'vd'})).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'virtual_district' - assert cs['id'] == 'vd' - - cs = execute_undo(p).operations[0] - assert cs['operation'] == API_ADD - assert cs['type'] == 'virtual_district' - assert cs['id'] == 'vd' - assert cs['boundary'] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] - assert cs['center'] == 'j1' - assert cs['nodes'] == ['j1'] - - cs = execute_redo(p).operations[0] - assert cs['operation'] == API_DELETE - assert cs['type'] == 'virtual_district' - assert cs['id'] == 'vd' - - self.leave(p) - - - def test_virtual_district_gen(self): - p = 'test_virtual_district_gen' - read_inp(p, f'./inp/net3.inp', '3') - open_project(p) - - cs = generate_virtual_district(p, ['107', '139', '267', '211']).operations - assert len(cs) == 4 - - assert len(get_all_virtual_district_ids(p)) == 4 - assert len(get_all_virtual_districts(p)) == 4 - - cs = generate_virtual_district(p, ['107', '139', '267', '211']).operations - assert len(cs) == 8 - - assert len(get_all_virtual_district_ids(p)) == 4 - assert len(get_all_virtual_districts(p)) == 4 - - self.leave(p) - - - # 36 water_distribution - - - def test_calculate_demand_to_nodes(self): - p = 'test_calculate_demand_to_nodes' - read_inp(p, f'./inp/net3.inp', '3') - open_project(p) - - vds = calculate_virtual_district(p, ['107', '139', '267', '211'])['virtual_districts'] - result = calculate_demand_to_nodes(p, 100.0, vds[0]['nodes']) - assert result == {'10': 17.357291284684024, '101': 22.112211221122113, '103': 6.466202175773133, '105': 8.232489915658233, '107': 4.180418041804181, '109': 7.260726072607261, '111': 3.862608483070529, '115': 6.466202175773133, '117': 5.738907224055739, '119': 0.892311453367559, '120': 3.9665077618873, '257': 2.9275149737195942, '259': 2.1391027991688056, '261': 2.9275149737195942, '263': 5.469991443588803} - - self.leave(p) - - - def test_calculate_demand_to_region(self): - p = 'test_calculate_demand_to_region' - read_inp(p, f'./inp/net3.inp', '3') - open_project(p) - - vds = calculate_virtual_district(p, ['107', '139', '267', '211'])['virtual_districts'] - nodes = vds[0]['nodes'] - boundary = calculate_boundary(p, nodes) - boundary = inflate_boundary(p, boundary, 0.1) - add_region(p, ChangeSet({'id': 'r', 'boundary': boundary})) - - result = calculate_demand_to_region(p, 100.0, 'r') - assert result == {'10': 17.357291284684024, '101': 22.112211221122113, '103': 6.466202175773133, '105': 8.232489915658233, '107': 4.180418041804181, '109': 7.260726072607261, '111': 3.862608483070529, '115': 6.466202175773133, '117': 5.738907224055739, '119': 0.892311453367559, '120': 3.9665077618873, '257': 2.9275149737195942, '259': 2.1391027991688056, '261': 2.9275149737195942, '263': 5.469991443588803} - - self.leave(p) - - - def test_calculate_demand_to_network(self): - p = 'test_calculate_demand_to_network' - read_inp(p, f'./inp/net3.inp', '3') - open_project(p) - - result = calculate_demand_to_network(p, 100.0) - assert result == {'10': 3.2914286561977604, '101': 4.1930946753955975, '103': 1.226173069808884, '105': 1.5611107041895715, '107': 0.7927243664927, '109': 1.3768370575925843, '111': 1.7685634258302052, '113': 1.237762607330707, '115': 1.689754570681808, '117': 1.0882575732991893, '119': 1.9169095061095407, '120': 1.2273320235610663, '121': 1.5020040628282736, '123': 10.894165270513714, '125': 2.0235332513103135, '127': 1.1415694458995753, '129': 2.34804030192136, '131': 1.5020040628282738, '139': 1.1125956020950176, '141': 1.6132636230377755, '143': 0.7069617888312091, '145': 1.323525184992198, '147': 0.713915511344303, '149': 0.4404024258292778, '15': 0.3824547382201623, '151': 1.3096177399660103, '153': 1.3281610000009272, '157': 1.1566358446779454, '159': 1.1380925846430283, '161': 1.0963702495644652, '163': 0.4125875357769024, '164': 0.14834608027933568, '166': 0.11357746771386638, '167': 0.01390744502618772, '169': 0.5947750656199615, '171': 0.482124760907841, '173': 0.9387525392676711, '177': 0.01390744502618772, '179': 0.3314607731241407, '181': 0.07417304013966784, '183': 0.4798068534034764, '184': 1.0731679954457753, '185': 0.44849192301951035, '187': 0.7324355923041763, '189': 0.8636523361262574, '191': 0.9804748743462343, '193': 0.859943684119274, '195': 0.6165633961609889, '197': 0.9132555567196603, '199': 1.241239468587254, '20': 0.2049030233858324, '201': 0.24338028795828512, '203': 0.02781489005237544, '204': 0.3302786402969147, '205': 1.4776660340324455, '206': 0.2225191204190035, '207': 0.713915511344303, '208': 0.32334809685886445, '209': 0.48560162216438785, '211': 0.9920644118680574, '213': 1.7326358595125533, '215': 1.3779960113447665, '217': 1.2215372548001548, '219': 0.4751710383947471, '225': 0.3615935706808807, '229': 1.1473642146604868, '231': 0.45430987085546554, '237': 0.7834527364752415, '239': 0.22599598167555046, '241': 0.6211992111697181, '243': 0.5099396509602164, '247': 0.4276539345552724, '249': 0.4380845183249132, '251': 0.5910664136129782, '253': 0.2549698254801082, '255': 1.0465352382206259, '257': 0.5551388472953265, '259': 0.4056338132638085, '261': 0.5551388472953265, '263': 1.0372636082031674, '265': 0.7811348289708769, '267': 0.9225271867371188, '269': 0.5984837176269449, '271': 0.5354366335082272, '273': 0.8344467015712631, '275': 0.9178913717283894, '35': 0.00695372251309386, '40': 0.29877827731259954, '50': 0.23735372844693708, '60': 0.28556620453772114, '601': 0.000463581500872924, '61': 10.546710935609457} - self.leave(p) - - -if __name__ == '__main__': - pytest.main() diff --git a/tests/api/test_openapi_contract.py b/tests/api/test_openapi_contract.py index bfd18d2..848b291 100644 --- a/tests/api/test_openapi_contract.py +++ b/tests/api/test_openapi_contract.py @@ -10,12 +10,10 @@ from fastapi import APIRouter, FastAPI, Query from fastapi.routing import APIRoute from fastapi.testclient import TestClient -from app.api.v1.endpoints import schemes as schemes_endpoint from app.api.v1.endpoints import simulation as simulation_endpoint from app.api.pagination import PaginatedList 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.metadata_dependencies import get_current_metadata_user from app.auth.project_dependencies import ( ProjectContext, get_project_business_routing, @@ -173,12 +171,6 @@ def test_rest_contract_uses_header_project_context() -> None: assert "network" not in schema.get("properties", {}) assert "network_name" not in schema.get("properties", {}) - placement_schema = document["components"]["schemas"][ - "PressureSensorPlacementRest" - ] - assert "name" not in placement_schema["properties"] - assert "username" not in placement_schema["properties"] - assert "/api/v1/burst-analysis" not in document["paths"] assert "/api/v1/getpipeproperties/" not in document["paths"] @@ -294,54 +286,10 @@ def test_sensor_placement_excel_export_is_post() -> None: if isinstance(route, APIRoute) } assert methods_by_path[ - "/sensor-placement-schemes/{scheme_id}/exports/excel" + "/sensor-placement-runs/{run_id}/exports/excel" ] == {"POST"} -def test_rest_runtime_consumes_injected_project_context(monkeypatch) -> None: - captured: dict[str, object] = {} - - def fake_get_all_schemes(network, scheme_type=None, query_date=None): - captured.update( - network=network, - scheme_type=scheme_type, - query_date=query_date, - business_dsn=get_project_pgconn_string(network), - ) - return [{"scheme_name": "burst_case", "scheme_type": scheme_type}] - - monkeypatch.setattr( - schemes_endpoint, - "get_all_schemes", - fake_get_all_schemes, - ) - app = FastAPI(redirect_slashes=False) - app.include_router(api_router, prefix="/api/v1") - project_context = ProjectContext( - project_id=uuid4(), - project_code="fengyang", - user_id=uuid4(), - project_role="viewer", - ) - _override_project_routing(app, project_context) - - response = TestClient(app, raise_server_exceptions=False).get( - "/api/v1/schemes", - params={"scheme_type": "burst_analysis"}, - ) - - assert response.status_code == 200 - assert captured == { - "network": "fengyang", - "scheme_type": "burst_analysis", - "query_date": None, - "business_dsn": "postgresql://user:password@biz/fengyang", - } - assert response.json()["items"] == [ - {"scheme_name": "burst_case", "scheme_type": "burst_analysis"} - ] - - def test_rest_runtime_wraps_handler_paginated_list() -> None: source_router = APIRouter() @@ -370,51 +318,6 @@ def test_rest_runtime_wraps_handler_paginated_list() -> None: } -def test_sensor_placement_body_uses_authenticated_project_and_user( - monkeypatch, -) -> None: - captured: dict[str, object] = {} - - def fake_pressure_sensor_placement_kmeans(**kwargs): - captured.update(kwargs) - - monkeypatch.setattr( - simulation_endpoint, - "pressure_sensor_placement_kmeans", - fake_pressure_sensor_placement_kmeans, - ) - app = FastAPI(redirect_slashes=False) - app.include_router(api_router, prefix="/api/v1") - project_context = ProjectContext( - project_id=uuid4(), - project_code="project_a", - user_id=uuid4(), - project_role="member", - ) - _override_project_routing(app, project_context) - app.dependency_overrides[get_current_metadata_user] = lambda: type( - "User", (), {"username": "alice"} - )() - - response = TestClient(app, raise_server_exceptions=False).post( - "/api/v1/pressure-sensor-placement-kmeans", - json={ - "scheme_name": "placement_01", - "sensor_number": 5, - "min_diameter": 100, - }, - ) - - assert response.status_code == 200 - assert captured == { - "name": "project_a", - "scheme_name": "placement_01", - "sensor_number": 5, - "min_diameter": 100, - "username": "alice", - } - - def test_rest_runtime_json_encodes_untyped_datetime_response() -> None: source_router = APIRouter() diff --git a/tests/api/test_project_endpoints.py b/tests/api/test_project_endpoints.py index 31efb35..b4d1834 100644 --- a/tests/api/test_project_endpoints.py +++ b/tests/api/test_project_endpoints.py @@ -42,11 +42,10 @@ def _load_project_module(monkeypatch): "read_inp": lambda network, inp: True, "dump_inp": lambda network, inp: True, "get_all_vertices": lambda network: [], - "get_all_scada_elements": lambda network: [], + "get_all_scada_info": lambda network: [], "get_all_district_metering_areas": lambda network: [], "get_all_service_areas": lambda network: [], "get_all_virtual_districts": lambda network: [], - "get_extension_data": lambda network, key: None, "convert_inp_v3_to_v2": lambda inp: DummyChangeSet({"inp": inp}), }, ) @@ -55,16 +54,6 @@ def _load_project_module(monkeypatch): "app.auth.project_dependencies", {"get_metadata_repository": lambda: None}, ) - install_stub( - monkeypatch, - "app.infra.db.postgresql.database", - {"get_database_instance": lambda network: None}, - ) - install_stub( - monkeypatch, - "app.infra.db.timescaledb.database", - {"get_database_instance": lambda network: None}, - ) return load_module_from_path( "tests_project_endpoints_module", "app/api/v1/endpoints/project.py", @@ -109,16 +98,12 @@ def test_project_info_returns_project_workspace(monkeypatch): assert "geoserver" not in payload -def test_open_project_returns_network_even_when_db_connection_fails(monkeypatch): +def test_open_project_uses_unified_wndb_connection_path(monkeypatch): module = _load_project_module(monkeypatch) called = [] monkeypatch.setattr(module, "open_project", lambda network: called.append(network)) - async def failing_get_pg_db(network): - raise RuntimeError("db down") - - monkeypatch.setattr(module, "get_pg_db", failing_get_pg_db) client = TestClient(build_test_app(module.router, "/api/v1")) response = client.post("/api/v1/projects/current", params={"network": "demo"}) diff --git a/tests/api/test_regions_endpoints.py b/tests/api/test_regions_endpoints.py index a1d0898..41b3cca 100644 --- a/tests/api/test_regions_endpoints.py +++ b/tests/api/test_regions_endpoints.py @@ -1,5 +1,3 @@ -from typing import Any - from fastapi.testclient import TestClient from tests.conftest import build_test_app, install_stub, load_module_from_path @@ -7,16 +5,7 @@ from tests.conftest import build_test_app, install_stub, load_module_from_path class DummyChangeSet: def __init__(self, operations=None): - if operations is None: - self.operations = [] - elif isinstance(operations, dict): - self.operations = [operations] - else: - self.operations = operations - - -def _noop(*args, **kwargs): - return None + self.operations = [operations] if isinstance(operations, dict) else operations or [] def _load_regions_module(monkeypatch): @@ -25,41 +14,18 @@ def _load_regions_module(monkeypatch): monkeypatch, "app.services.tjnetwork", { - "Any": Any, "ChangeSet": DummyChangeSet, - "add_district_metering_area": _noop, - "add_region": _noop, - "add_service_area": _noop, - "add_virtual_district": _noop, - "calculate_district_metering_area_for_network": lambda *args, **kwargs: [], - "calculate_district_metering_area_for_nodes": lambda *args, **kwargs: [], - "calculate_district_metering_area_for_region": lambda *args, **kwargs: [], - "calculate_service_area": lambda network: [], - "calculate_virtual_district": lambda *args, **kwargs: {}, - "delete_district_metering_area": _noop, - "delete_region": _noop, - "delete_service_area": _noop, - "delete_virtual_district": _noop, - "generate_district_metering_area": _noop, - "generate_service_area": _noop, - "generate_sub_district_metering_area": _noop, - "generate_virtual_district": _noop, - "get_all_district_metering_area_ids": lambda network: [], - "get_all_district_metering_areas": lambda network: [], - "get_all_service_areas": lambda network: [], - "get_all_virtual_districts": lambda network: [], - "get_district_metering_area": lambda network, area_id: {}, - "get_district_metering_area_schema": lambda network: {}, - "get_region": lambda network, region_id: {}, - "get_region_schema": lambda network: {}, - "get_service_area": lambda network, area_id: {}, - "get_service_area_schema": lambda network: {}, - "get_virtual_district": lambda network, area_id: {}, - "get_virtual_district_schema": lambda network: {}, - "set_district_metering_area": _noop, - "set_region": _noop, - "set_service_area": _noop, - "set_virtual_district": _noop, + "add_region": lambda network, cs: cs, + "delete_region": lambda network, cs: cs, + "get_nodes_in_region": lambda network, region_id: ["J1"], + "get_region": lambda network, region_id: { + "id": region_id, + "region_type": "DMA", + "boundary": [[0, 0], [1, 0], [0, 0]], + }, + "get_region_schema": lambda network: {"id": {"type": "str"}}, + "get_regions": lambda network: ["DMA-1"], + "set_region": lambda network, cs: cs, }, ) return load_module_from_path( @@ -68,87 +34,47 @@ def _load_regions_module(monkeypatch): ) -def test_removed_routes_are_absent_and_return_404(monkeypatch): +def test_regions_are_exposed_as_one_generic_resource(monkeypatch): module = _load_regions_module(monkeypatch) client = TestClient(build_test_app(module.router, "/api/v1")) - openapi = client.get("/openapi.json").json() - - assert "/api/v1/calculateregion/" not in openapi["paths"] - assert "/api/v1/getallregions/" not in openapi["paths"] - assert "/api/v1/generateregion/" not in openapi["paths"] - assert "/api/v1/calculatedistrictmeteringarea/" not in openapi["paths"] - assert client.get("/api/v1/calculateregion/", params={"network": "demo", "time_index": 0}).status_code == 404 - assert client.get("/api/v1/calculatedistrictmeteringarea/", params={"network": "demo"}).status_code == 404 - - -def test_calculate_service_area_contract_uses_only_network(monkeypatch): - module = _load_regions_module(monkeypatch) - calls = [] - monkeypatch.setattr( - module, - "calculate_service_area", - lambda network: calls.append(network) or [{"source-1": ["n1", "n2"]}], - ) - client = TestClient(build_test_app(module.router, "/api/v1")) - - response = client.post( - "/api/v1/service-area-calculations", - params={"network": "demo", "time_index": 5}, - ) - schema = client.get("/openapi.json").json() + response = client.get("/api/v1/regions", params={"network": "demo"}) assert response.status_code == 200 - assert response.json() == [{"source-1": ["n1", "n2"]}] - assert calls == ["demo"] - parameter_names = [ - item["name"] - for item in schema["paths"]["/api/v1/service-area-calculations"]["post"]["parameters"] - ] - assert parameter_names == ["network"] + assert response.json()[0]["region_type"] == "DMA" -def test_add_district_metering_area_converts_boundary_to_tuples(monkeypatch): +def test_add_region_converts_boundary_to_tuples(monkeypatch): module = _load_regions_module(monkeypatch) captured = {} - def fake_add(network, change_set): - captured["network"] = network - captured["boundary"] = change_set.operations[0]["boundary"] - return {"ok": True} + def add(network, changeset): + captured["operation"] = changeset.operations[0] + return changeset - monkeypatch.setattr(module, "add_district_metering_area", fake_add) + monkeypatch.setattr(module, "add_region", add) client = TestClient(build_test_app(module.router, "/api/v1")) - response = client.post( - "/api/v1/district-metering-areas", + "/api/v1/regions", params={"network": "demo"}, - json={"id": "dma-1", "boundary": [[1, 2], [3, 4], [1, 2]]}, + json={ + "id": "DMA-1", + "region_type": "DMA", + "boundary": [[0, 0], [1, 0], [0, 0]], + }, ) assert response.status_code == 200 - assert captured == { - "network": "demo", - "boundary": [(1, 2), (3, 4), (1, 2)], - } + assert captured["operation"]["boundary"] == [(0, 0), (1, 0), (0, 0)] -def test_generate_virtual_district_reads_centers_from_body(monkeypatch): +def test_region_nodes_use_generic_region_id(monkeypatch): module = _load_regions_module(monkeypatch) - captured = {} - - def fake_generate(network, centers, inflate_delta): - captured["args"] = (network, centers, inflate_delta) - return {"generated": True} - - monkeypatch.setattr(module, "generate_virtual_district", fake_generate) client = TestClient(build_test_app(module.router, "/api/v1")) - response = client.post( - "/api/v1/virtual-district-generation-runs", - params={"network": "demo", "inflate_delta": 0.75}, - json={"centers": ["J1", "J2"]}, + response = client.get( + "/api/v1/regions/nodes", params={"network": "demo", "id": "DMA-1"} ) assert response.status_code == 200 - assert captured["args"] == ("demo", ["J1", "J2"], 0.75) + assert response.json() == ["J1"] diff --git a/tests/api/test_schemes_endpoints.py b/tests/api/test_schemes_endpoints.py deleted file mode 100644 index 319a5ad..0000000 --- a/tests/api/test_schemes_endpoints.py +++ /dev/null @@ -1,102 +0,0 @@ -from datetime import date - -from fastapi import FastAPI -from fastapi.testclient import TestClient - -from app.api.v1.endpoints import schemes as schemes_endpoint - - -def _build_client() -> TestClient: - app = FastAPI() - app.include_router(schemes_endpoint.router, prefix="/api/v1") - return TestClient(app) - - -def test_get_schemes_forwards_optional_scheme_type(monkeypatch): - captured = {} - - def fake_get_all_schemes(network, scheme_type=None, query_date=None): - captured["network"] = network - captured["scheme_type"] = scheme_type - captured["query_date"] = query_date - return [ - { - "scheme_id": 1, - "scheme_name": "burst_case", - "scheme_type": scheme_type, - } - ] - - monkeypatch.setattr(schemes_endpoint, "get_all_schemes", fake_get_all_schemes) - - response = _build_client().get( - "/api/v1/schemes", - params={"network": "demo", "scheme_type": "burst_analysis"}, - ) - - assert response.status_code == 200 - assert captured == { - "network": "demo", - "scheme_type": "burst_analysis", - "query_date": None, - } - assert response.json()[0]["scheme_type"] == "burst_analysis" - - -def test_get_schemes_forwards_query_date(monkeypatch): - captured = {} - - def fake_get_all_schemes(network, scheme_type=None, query_date=None): - captured["network"] = network - captured["scheme_type"] = scheme_type - captured["query_date"] = query_date - return [] - - monkeypatch.setattr(schemes_endpoint, "get_all_schemes", fake_get_all_schemes) - - response = _build_client().get( - "/api/v1/schemes", - params={ - "network": "demo", - "scheme_type": "dma_leak_identification", - "query_date": "2026-01-02T00:00:00+08:00", - }, - ) - - assert response.status_code == 200 - assert captured == { - "network": "demo", - "scheme_type": "dma_leak_identification", - "query_date": date(2026, 1, 2), - } - - -def test_get_scheme_detail_forwards_scheme_type(monkeypatch): - captured = {} - - def fake_query_scheme_detail(name, scheme_name, scheme_type=None): - captured["name"] = name - captured["scheme_name"] = scheme_name - captured["scheme_type"] = scheme_type - return { - "scheme_name": scheme_name, - "scheme_type": scheme_type, - "rows": [{"Area": "1", "LeakageFlow_m3_per_s": 0.1}], - } - - monkeypatch.setattr( - schemes_endpoint, "query_scheme_detail", fake_query_scheme_detail - ) - - response = _build_client().get( - "/api/v1/schemes/dma_001", - params={"network": "demo", "scheme_type": "dma_leak_identification"}, - ) - - assert response.status_code == 200 - assert captured == { - "name": "demo", - "scheme_name": "dma_001", - "scheme_type": "dma_leak_identification", - } - assert response.json()["scheme_name"] == "dma_001" diff --git a/tests/api/test_sensor_placement_endpoints.py b/tests/api/test_sensor_placement_endpoints.py index 73b42ef..f4bc828 100644 --- a/tests/api/test_sensor_placement_endpoints.py +++ b/tests/api/test_sensor_placement_endpoints.py @@ -1,424 +1,124 @@ from datetime import datetime, timezone from io import BytesIO from types import SimpleNamespace +from uuid import uuid4 -import pytest from fastapi.testclient import TestClient -from tests.conftest import build_test_app, install_stub, load_module_from_path +from app.api.v1.endpoints import sensor_placement as endpoint +from tests.conftest import build_test_app -class NotFoundError(LookupError): - pass +RUN_ID = uuid4() -class ValidationError(ValueError): - pass - - -class ConflictError(RuntimeError): - pass - - -def _scheme(**overrides): +def _run(**overrides): value = { - "id": 7, - "scheme_name": "北区测压点", - "sensor_number": 2, + "run_id": RUN_ID, + "name": "北区测压点", + "sensor_count": 1, "min_diameter": 300, - "username": "alice", - "create_time": datetime(2026, 7, 30, 8, 0, tzinfo=timezone.utc), - "sensor_location": ["J1", "J2"], + "created_by": "alice", + "created_at": datetime(2026, 8, 24, tzinfo=timezone.utc), + "status": "completed", + "sensor_locations": ["J1"], "sensor_points": [ { "node_id": "J1", "max_pipe_diameter": 400.0, - "project_x": 13500000.0, - "project_y": 3600000.0, - "map_x": 13500000.0, - "map_y": 3600000.0, + "project_x": 1.0, + "project_y": 2.0, + "map_x": 3.0, + "map_y": 4.0, "longitude": 121.0, "latitude": 31.0, - "elevation": 4.5, - }, - { - "node_id": "J2", - "max_pipe_diameter": 300.0, - "project_x": 13500100.0, - "project_y": 3600100.0, - "map_x": 13500100.0, - "map_y": 3600100.0, - "longitude": 121.001, - "latitude": 31.001, "elevation": 5.0, - }, + } ], } value.update(overrides) return value -def _load_module(monkeypatch): - install_stub(monkeypatch, "app.algorithms", package=True) - install_stub( - monkeypatch, - "app.algorithms.sensor", - { - "pressure_sensor_placement_kmeans": lambda **kwargs: {"id": 7}, - "pressure_sensor_placement_sensitivity": lambda **kwargs: {"id": 7}, - }, +def _client(project_role="member", username="alice", role="user"): + app = build_test_app(endpoint.router, "/api/v1") + app.dependency_overrides[endpoint.get_project_context] = lambda: SimpleNamespace( + project_code="tjwater", project_role=project_role ) - install_stub(monkeypatch, "app.auth", package=True) - - async def current_user(): - return SimpleNamespace( - username="alice", - role="user", - is_superuser=False, - ) - - install_stub( - monkeypatch, - "app.auth.metadata_dependencies", - {"get_current_metadata_user": current_user}, - ) - - class ProjectContext: - def __init__(self, project_code: str, project_role: str = "member"): - self.project_code = project_code - self.project_role = project_role - - async def project_context(): - return ProjectContext("tjwater") - - install_stub( - monkeypatch, - "app.auth.project_dependencies", - { - "ProjectContext": ProjectContext, - "get_project_context": project_context, - }, - ) - install_stub(monkeypatch, "app.services", package=True) - install_stub( - monkeypatch, - "app.services.sensor_placement", - { - "SensorPlacementConflictError": ConflictError, - "SensorPlacementNotFoundError": NotFoundError, - "SensorPlacementValidationError": ValidationError, - "build_sensor_placement_workbook": lambda **kwargs: BytesIO(b"xlsx"), - "can_edit_sensor_placement": ( - lambda user, scheme: user.username == scheme["username"] - or user.role == "admin" - or user.is_superuser - ), - "get_sensor_placement_scheme": lambda network, scheme_id: _scheme( - id=scheme_id - ), - "get_sensor_placement_candidate": ( - lambda network, node_id: _scheme()["sensor_points"][0] - ), - "update_sensor_placement_scheme": ( - lambda network, scheme_id, **kwargs: _scheme( - id=scheme_id, - sensor_location=kwargs["sensor_location"], - sensor_number=len(kwargs["sensor_location"]), - ) - ), - }, - ) - return load_module_from_path( - "tests_sensor_placement_endpoints_module", - "app/api/v1/endpoints/sensor_placement.py", - ) - - -def _client(module, user=None, project_role="member"): - app = build_test_app(module.router, "/api/v1") - if user is None: - user = SimpleNamespace( - username="alice", - role="user", - is_superuser=False, - ) - app.dependency_overrides[module.get_current_metadata_user] = lambda: user - app.dependency_overrides[module.get_project_context] = lambda: ( - module.ProjectContext("tjwater", project_role) + app.dependency_overrides[endpoint.get_current_metadata_user] = lambda: SimpleNamespace( + username=username, role=role, is_superuser=False ) return TestClient(app) -def test_optimize_returns_created_scheme(monkeypatch): - module = _load_module(monkeypatch) +def test_optimize_returns_analysis_run(monkeypatch): captured = {} + monkeypatch.setattr( + endpoint, + "pressure_sensor_placement_kmeans", + lambda **kwargs: captured.update(kwargs) or {"run_id": RUN_ID}, + ) + monkeypatch.setattr(endpoint, "get_sensor_placement_run", lambda *_: _run()) - def optimize(**kwargs): - captured.update(kwargs) - return {"id": 7} - - monkeypatch.setattr(module, "pressure_sensor_placement_kmeans", optimize) - response = _client(module).post( - "/api/v1/sensor-placement-optimization-runs", + response = _client().post( + "/api/v1/sensor-placement-runs", json={ "network": "tjwater", - "scheme_name": "北区测压点", + "run_name": "北区测压点", "sensor_type": "pressure", "method": "kmeans", - "sensor_count": 2, + "sensor_count": 1, "min_diameter": 300, }, ) assert response.status_code == 200 - assert response.json()["sensor_location"] == ["J1", "J2"] + assert response.json()["run_id"] == str(RUN_ID) assert captured["username"] == "alice" -def test_get_candidate_returns_maximum_incident_pipe_diameter(monkeypatch): - module = _load_module(monkeypatch) - - response = _client(module).get( - "/api/v1/sensor-placement-candidates/J1", - ) - - assert response.status_code == 200 - assert response.json()["node_id"] == "J1" - assert response.json()["max_pipe_diameter"] == 400.0 - - -def test_optimize_rejects_unsupported_sensor_type(monkeypatch): - module = _load_module(monkeypatch) - response = _client(module).post( - "/api/v1/sensor-placement-optimization-runs", +def test_optimize_rejects_project_mismatch(monkeypatch): + response = _client().post( + "/api/v1/sensor-placement-runs", json={ - "network": "tjwater", - "scheme_name": "北区测流点", - "sensor_type": "flow", - "method": "kmeans", - "sensor_count": 2, - "min_diameter": 300, - }, - ) - - assert response.status_code == 422 - - -def test_optimize_rejects_network_outside_project_context(monkeypatch): - module = _load_module(monkeypatch) - response = _client(module).post( - "/api/v1/sensor-placement-optimization-runs", - json={ - "network": "other_project", - "scheme_name": "越权方案", + "network": "other", + "run_name": "越权运行", "sensor_type": "pressure", "method": "kmeans", - "sensor_count": 2, - "min_diameter": 300, + "sensor_count": 1, }, ) assert response.status_code == 403 -def test_optimize_rejects_network_path_traversal(monkeypatch): - module = _load_module(monkeypatch) - response = _client(module).post( - "/api/v1/sensor-placement-optimization-runs", +def test_viewer_cannot_update_run(monkeypatch): + monkeypatch.setattr(endpoint, "get_sensor_placement_run", lambda *_: _run()) + + response = _client(project_role="viewer").put( + f"/api/v1/sensor-placement-runs/{RUN_ID}", + params={"network": "tjwater"}, json={ - "network": "../other_project", - "scheme_name": "非法路径", - "sensor_type": "pressure", - "method": "kmeans", - "sensor_count": 2, - "min_diameter": 300, - }, - ) - - assert response.status_code == 422 - - -def test_optimize_rejects_unbounded_sensor_count(monkeypatch): - module = _load_module(monkeypatch) - response = _client(module).post( - "/api/v1/sensor-placement-optimization-runs", - json={ - "network": "tjwater", - "scheme_name": "超大方案", - "sensor_type": "pressure", - "method": "kmeans", - "sensor_count": 201, - "min_diameter": 300, - }, - ) - - assert response.status_code == 422 - - -def test_optimize_rejects_viewer_project_role(monkeypatch): - module = _load_module(monkeypatch) - response = _client(module, project_role="viewer").post( - "/api/v1/sensor-placement-optimization-runs", - json={ - "network": "tjwater", - "scheme_name": "只读成员方案", - "sensor_type": "pressure", - "method": "kmeans", - "sensor_count": 2, - "min_diameter": 300, + "expected_sensor_locations": ["J1"], + "sensor_locations": ["J2"], }, ) assert response.status_code == 403 -@pytest.mark.parametrize( - "project_role", - ["owner", "admin", "modeler", "dispatcher", "auditor"], -) -def test_legacy_project_roles_cannot_optimize(monkeypatch, project_role): - module = _load_module(monkeypatch) - response = _client(module, project_role=project_role).post( - "/api/v1/sensor-placement-optimization-runs", - json={ - "network": "tjwater", - "scheme_name": f"{project_role}方案", - "sensor_type": "pressure", - "method": "kmeans", - "sensor_count": 2, - "min_diameter": 300, - }, +def test_export_returns_xlsx(monkeypatch): + monkeypatch.setattr(endpoint, "get_sensor_placement_run", lambda *_: _run()) + monkeypatch.setattr( + endpoint, + "build_sensor_placement_workbook", + lambda **kwargs: BytesIO(b"xlsx"), ) - assert response.status_code == 403 - - -def test_optimize_maps_running_project_job_to_409(monkeypatch): - module = _load_module(monkeypatch) - - def conflict(**kwargs): - raise ConflictError("当前项目已有监测点优化任务正在运行,请稍后重试") - - monkeypatch.setattr(module, "pressure_sensor_placement_kmeans", conflict) - response = _client(module).post( - "/api/v1/sensor-placement-optimization-runs", - json={ - "network": "tjwater", - "scheme_name": "并发方案", - "sensor_type": "pressure", - "method": "kmeans", - "sensor_count": 2, - "min_diameter": 300, - }, - ) - - assert response.status_code == 409 - - -def test_viewer_reads_scheme_as_non_editable(monkeypatch): - module = _load_module(monkeypatch) - response = _client(module, project_role="viewer").get( - "/api/v1/sensor-placement-schemes/7", + response = _client().post( + f"/api/v1/sensor-placement-runs/{RUN_ID}/exports/excel", params={"network": "tjwater"}, - ) - - assert response.status_code == 200 - assert response.json()["can_edit"] is False - - -def test_update_rejects_non_owner(monkeypatch): - module = _load_module(monkeypatch) - response = _client( - module, - SimpleNamespace(username="bob", role="user", is_superuser=False), - ).put( - "/api/v1/sensor-placement-schemes/7", - params={"network": "tjwater"}, - json={ - "expected_sensor_location": ["J1", "J2"], - "sensor_location": ["J1", "J3"], - }, - ) - - assert response.status_code == 403 - - -def test_update_rejects_owner_with_viewer_project_role(monkeypatch): - module = _load_module(monkeypatch) - response = _client(module, project_role="viewer").put( - "/api/v1/sensor-placement-schemes/7", - params={"network": "tjwater"}, - json={ - "expected_sensor_location": ["J1", "J2"], - "sensor_location": ["J1", "J3"], - }, - ) - - assert response.status_code == 403 - - -def test_admin_can_overwrite_scheme(monkeypatch): - module = _load_module(monkeypatch) - response = _client( - module, - SimpleNamespace(username="ops", role="admin", is_superuser=False), - ).put( - "/api/v1/sensor-placement-schemes/7", - params={"network": "tjwater"}, - json={ - "expected_sensor_location": ["J1", "J2"], - "sensor_location": ["J1", "J3"], - }, - ) - - assert response.status_code == 200 - assert response.json()["sensor_number"] == 2 - assert response.json()["sensor_location"] == ["J1", "J3"] - - -def test_update_maps_concurrent_change_to_409(monkeypatch): - module = _load_module(monkeypatch) - - def conflict(*args, **kwargs): - raise ConflictError("方案已被其他用户修改,请重新加载") - - monkeypatch.setattr(module, "update_sensor_placement_scheme", conflict) - response = _client(module).put( - "/api/v1/sensor-placement-schemes/7", - params={"network": "tjwater"}, - json={ - "expected_sensor_location": ["J1", "J2"], - "sensor_location": ["J1", "J3"], - }, - ) - - assert response.status_code == 409 - assert "重新加载" in response.json()["detail"] - - -def test_update_rejects_duplicate_nodes_before_service(monkeypatch): - module = _load_module(monkeypatch) - response = _client(module).put( - "/api/v1/sensor-placement-schemes/7", - params={"network": "tjwater"}, - json={ - "expected_sensor_location": ["J1", "J2"], - "sensor_location": ["J1", "J1"], - }, - ) - - assert response.status_code == 422 - - -def test_export_returns_xlsx_download(monkeypatch): - module = _load_module(monkeypatch) - response = _client(module).post( - "/api/v1/sensor-placement-schemes/7/exports/excel", - params={"network": "tjwater"}, - json={ - "sensor_location": ["J1", "J2"], - "adjustment_status": {"J1": "original", "J2": "replaced"}, - }, + json={"sensor_locations": ["J1"], "adjustment_status": {}}, ) assert response.status_code == 200 @@ -426,4 +126,3 @@ def test_export_returns_xlsx_download(monkeypatch): assert response.headers["content-type"].startswith( "application/vnd.openxmlformats-officedocument" ) - assert "filename*=UTF-8" in response.headers["content-disposition"] diff --git a/tests/integration/test_database_pooling_live.py b/tests/integration/test_database_pooling_live.py new file mode 100644 index 0000000..e5f3818 --- /dev/null +++ b/tests/integration/test_database_pooling_live.py @@ -0,0 +1,238 @@ +import os +from concurrent.futures import ThreadPoolExecutor +from uuid import uuid4 + +import pytest + +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.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 + + +pytestmark = pytest.mark.skipif( + os.getenv("RUN_DB_INTEGRATION") != "1", + reason="set RUN_DB_INTEGRATION=1 to test configured PostgreSQL databases", +) + +PROJECT = os.getenv("DB_INTEGRATION_PROJECT", "tjwater_next") + + +def _read_business_database(_: int) -> str: + with project_connection(PROJECT) as conn, conn.cursor() as cur: + cur.execute("select current_database()") + return str(cur.fetchone()["current_database"]) + + +def _read_timeseries_database(_: int) -> str: + with timescale_connection(PROJECT) as conn, conn.cursor() as cur: + cur.execute("select current_database()") + return str(cur.fetchone()["current_database"]) + + +def test_business_pool_handles_concurrent_borrows() -> None: + with ThreadPoolExecutor(max_workers=16) as executor: + names = list(executor.map(_read_business_database, range(64))) + + assert names == [PROJECT] * 64 + + +def test_timeseries_pool_handles_concurrent_borrows() -> None: + with ThreadPoolExecutor(max_workers=16) as executor: + names = list(executor.map(_read_timeseries_database, range(64))) + + assert names == [PROJECT] * 64 + + +def test_nested_wndb_writes_roll_back_as_one_transaction() -> None: + with pytest.raises(RuntimeError, match="force rollback"): + with project_transaction(PROJECT) as conn: + with conn.cursor() as cur: + cur.execute( + "create temporary table wndb_pool_rollback_probe (value integer) on commit drop" + ) + cur.execute("insert into wndb_pool_rollback_probe values (1)") + with project_connection(PROJECT) as nested: + assert nested is conn + raise RuntimeError("force rollback") + + with project_connection(PROJECT) as conn, conn.cursor() as cur: + cur.execute("select to_regclass('pg_temp.wndb_pool_rollback_probe')") + assert cur.fetchone()["to_regclass"] is None + + +def test_analysis_run_lifecycle_uses_one_execution_id() -> None: + run_id = None + with pytest.raises(RuntimeError, match="force rollback"): + with project_transaction(PROJECT) as conn: + run_id = create_analysis_run( + PROJECT, + "integration-lifecycle-probe", + "integration_test", + "pytest", + "2026-08-24T00:00:00Z", + {"temporary": True}, + ) + update_analysis_run( + PROJECT, + run_id, + status="completed", + username="pytest", + scheme_detail={"temporary": True}, + ) + with conn.cursor() as cur: + cur.execute( + "select status from analysis.runs where run_id = %s", (run_id,) + ) + assert cur.fetchone()["status"] == "completed" + raise RuntimeError("force rollback") + + with project_connection(PROJECT) as conn, conn.cursor() as cur: + cur.execute("select count(*) as count from analysis.runs where run_id = %s", (run_id,)) + assert cur.fetchone()["count"] == 0 + + +def test_legacy_wndb_batch_treats_malicious_id_as_data() -> None: + malicious = "integration'); DROP SCHEMA network CASCADE; --" + command = patterns._add_pattern( + PROJECT, + ChangeSet({"id": malicious, "factors": [1.0]}), + ) + + with pytest.raises(RuntimeError, match="force rollback"): + with project_transaction(PROJECT) as conn: + write(PROJECT, command.sql) + with conn.cursor() as cur: + cur.execute("select count(*) as count from network.patterns where id = %s", (malicious,)) + assert cur.fetchone()["count"] == 1 + cur.execute("select to_regnamespace('network') as namespace") + assert cur.fetchone()["namespace"] is not None + raise RuntimeError("force rollback") + + +def test_wndb_database_command_pattern_crud_uses_pooled_transaction() -> None: + pattern_id = f"integration-command-{uuid4()}" + + with pytest.raises(RuntimeError, match="force rollback"): + with project_transaction(PROJECT) as conn: + added = patterns.add_pattern( + PROJECT, + ChangeSet({"id": pattern_id, "factors": [1.0, 1.1]}), + ) + updated = patterns.set_pattern( + PROJECT, + ChangeSet({"id": pattern_id, "factors": [0.8, 1.2, 1.0]}), + ) + + with conn.cursor() as cur: + cur.execute( + "select factor from network.pattern_values " + "where pattern_id = %s order by sequence_no", + (pattern_id,), + ) + assert [float(row["factor"]) for row in cur.fetchall()] == [ + 0.8, + 1.2, + 1.0, + ] + + deleted = patterns.delete_pattern( + PROJECT, + ChangeSet({"id": pattern_id}), + ) + with conn.cursor() as cur: + cur.execute( + "select count(*) as count from network.patterns where id = %s", + (pattern_id,), + ) + assert cur.fetchone()["count"] == 0 + + assert added.operations[0]["operation"] == "add" + assert updated.operations[0]["operation"] == "update" + assert deleted.operations == [ + {"operation": "delete", "type": "pattern", "id": pattern_id} + ] + raise RuntimeError("force rollback") + + with project_connection(PROJECT) as conn, conn.cursor() as cur: + cur.execute( + "select count(*) as count from network.patterns where id = %s", + (pattern_id,), + ) + assert cur.fetchone()["count"] == 0 + + +def test_wndb_ordered_detail_tables_use_parent_scoped_primary_keys() -> None: + expected = { + "gis.link_vertices": "PRIMARY KEY (link_id, sequence_no)", + "network.curve_points": "PRIMARY KEY (curve_id, sequence_no)", + "network.demands": "PRIMARY KEY (junction_id, sequence_no)", + "network.pattern_flow_samples": "PRIMARY KEY (pattern_id, sequence_no)", + "network.pattern_values": "PRIMARY KEY (pattern_id, sequence_no)", + } + + with project_connection(PROJECT) as conn, conn.cursor() as cur: + cur.execute( + "select conrelid::regclass::text as table_name, " + "pg_get_constraintdef(oid) as definition " + "from pg_constraint " + "where conname = any(%s) order by 1", + ([f"{table.rsplit('.', 1)[1]}_pkey" for table in expected],), + ) + actual = {row["table_name"]: row["definition"] for row in cur.fetchall()} + + assert actual == expected + + +def test_wndb_pattern_cascade_unsets_dependent_demand_atomically() -> None: + suffix = uuid4() + junction_id = f"integration-junction-{suffix}" + pattern_id = f"integration-cascade-{suffix}" + + with pytest.raises(RuntimeError, match="force rollback"): + with project_transaction(PROJECT): + junctions.add_junction( + PROJECT, + ChangeSet( + {"id": junction_id, "x": 0.0, "y": 0.0, "elevation": 1.0} + ), + ) + patterns.add_pattern( + PROJECT, + ChangeSet({"id": pattern_id, "factors": [1.0]}), + ) + demands.set_demand( + PROJECT, + ChangeSet( + { + "junction": junction_id, + "demands": [ + { + "demand": 1.0, + "pattern": pattern_id, + "category": "integration", + } + ], + } + ), + ) + + result = delete_pattern_cascade( + PROJECT, + ChangeSet(g_delete_prefix | {"id": pattern_id}), + ) + + assert patterns.get_pattern(PROJECT, pattern_id) == {} + assert demands.get_demand(PROJECT, junction_id)["demands"] == [ + {"demand": 1.0, "pattern": None, "category": "integration"} + ] + assert result.operations[-1] == { + "operation": "delete", + "type": "pattern", + "id": pattern_id, + } + raise RuntimeError("force rollback") + + assert junctions.get_junction(PROJECT, junction_id) == {} diff --git a/tests/unit/test_analysis_results_repository.py b/tests/unit/test_analysis_results_repository.py new file mode 100644 index 0000000..48f8afc --- /dev/null +++ b/tests/unit/test_analysis_results_repository.py @@ -0,0 +1,91 @@ +import asyncio +from datetime import datetime, timezone +from uuid import uuid4 + +import pytest + +from app.infra.db.timescaledb.repositories.analysis import AnalysisResultsRepository + + +class _FakeCursor: + def __init__(self): + self.calls = [] + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def execute(self, query, params): + self.calls.append((str(query), params)) + + async def fetchall(self): + return [] + + +class _FakeConnection: + def __init__(self): + self.cursor_instance = _FakeCursor() + + def cursor(self): + return self.cursor_instance + + +def test_prepare_simulation_rows_uses_run_timestep(): + nodes, links = AnalysisResultsRepository.prepare_simulation_rows( + [{"node": "J1", "result": [{"pressure": 1.0}, {"pressure": 2.0}]}], + [{"link": "P1", "result": [{"flow": 3.0}, {"flow": 4.0}]}], + "2026-08-24T08:00:00+08:00", + num_periods=2, + result_timestep_seconds=900, + ) + + assert [row["time"] for row in nodes] == [ + datetime(2026, 8, 24, 0, 0, tzinfo=timezone.utc), + datetime(2026, 8, 24, 0, 15, tzinfo=timezone.utc), + ] + assert nodes[0]["node_id"] == "J1" + assert links[1]["link_id"] == "P1" + + +def test_node_series_rejects_unknown_field(): + with pytest.raises(ValueError, match="invalid node result field"): + asyncio.run( + AnalysisResultsRepository.get_node_series( + _FakeConnection(), + uuid4(), + "J1", + datetime.now(timezone.utc), + datetime.now(timezone.utc), + "unknown", + ) + ) + + +def test_node_series_filters_by_run_and_node(): + conn = _FakeConnection() + run_id = uuid4() + start = datetime(2026, 8, 24, tzinfo=timezone.utc) + end = datetime(2026, 8, 25, tzinfo=timezone.utc) + + asyncio.run( + AnalysisResultsRepository.get_node_series( + conn, run_id, "J1", start, end, "pressure" + ) + ) + + query, params = conn.cursor_instance.calls[0] + assert "analysis.node_results" in query + assert params == (run_id, "J1", start, end) + + +def test_analysis_store_locks_run_before_empty_check(): + cursor = _FakeCursor() + run_id = uuid4() + + asyncio.run(AnalysisResultsRepository._lock_run(cursor, run_id)) + + query, params = cursor.calls[0] + assert "pg_advisory_xact_lock" in query + assert params == (run_id,) diff --git a/tests/unit/test_analysis_simulation.py b/tests/unit/test_analysis_simulation.py new file mode 100644 index 0000000..d9a16ab --- /dev/null +++ b/tests/unit/test_analysis_simulation.py @@ -0,0 +1,167 @@ +import inspect +import json +from uuid import uuid4 + + +def test_run_simulation_exposes_explicit_valve_control(): + from app.services import simulation + + assert "valve_control" in inspect.signature(simulation.run_simulation).parameters + + +def test_apply_valve_control_matches_runner_semantics(monkeypatch): + from app.services import simulation + + updates: dict[str, dict] = {} + monkeypatch.setattr( + simulation, + "get_status", + lambda project_name, valve_name: { + "link": valve_name, + "status": "OPEN", + "setting": 1.0, + }, + ) + monkeypatch.setattr( + simulation, + "set_status", + lambda project_name, changeset: updates.update( + {changeset.operations[0]["link"]: changeset.operations[0].copy()} + ), + ) + + simulation._apply_valve_control( + "demo", + { + "V-status": {"status": "ACTIVE"}, + "V-setting": {"setting": 2.5}, + "V-closed": {"status": "ACTIVE", "setting": 9.0, "k": 0}, + "V-k": {"status": "ACTIVE", "setting": 9.0, "k": 0.5}, + }, + ) + + assert updates["V-status"]["status"] == "ACTIVE" + assert updates["V-setting"]["setting"] == 2.5 + assert updates["V-closed"]["status"] == "CLOSED" + assert updates["V-k"]["setting"] == 0.1036 * pow(0.5, -3.105) + + +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) + monkeypatch.setattr( + simulation, + "get_time", + lambda name: { + "HYDRAULIC TIMESTEP": "00:15:00", + "REPORT TIMESTEP": "1:00", + "DURATION": "0:00", + "PATTERN START": "0:00", + }, + ) + monkeypatch.setattr(simulation, "set_time", lambda name, changeset: None) + monkeypatch.setattr( + simulation, + "run_project", + lambda name: json.dumps( + { + "output": { + "times": {"num_periods": 2, "report_step": 900}, + "node_results": [{"node": "J1", "result": [{}, {}]}], + "link_results": [{"link": "P1", "result": [{}, {}]}], + } + } + ), + ) + lifecycle_calls: list[tuple] = [] + monkeypatch.setattr(simulation, "create_analysis_run", lambda **kwargs: run_id) + monkeypatch.setattr( + simulation, + "update_analysis_run", + lambda *args, **kwargs: lifecycle_calls.append((args, kwargs)), + ) + monkeypatch.setattr( + simulation.TimescaleInternalStorage, + "store_analysis_simulation", + staticmethod(lambda *args, **kwargs: storage_calls.append((args, kwargs))), + ) + + returned_run_id = simulation.run_simulation( + name="demo", + simulation_type="extended", + modify_pattern_start_time="2026-07-16T00:00:00+08:00", + modify_total_duration=900, + scheme_type="burst_analysis", + scheme_name="case", + ) + + args, kwargs = storage_calls[0] + assert args[0] == run_id + assert args[4:] == (2, 900) + assert kwargs["db_name"] == "demo" + assert returned_run_id == run_id + assert lifecycle_calls[-1][1]["status"] == "completed" + + +def test_extended_simulation_marks_run_failed_when_result_storage_fails(monkeypatch): + from app.services import simulation + + run_id = uuid4() + lifecycle_calls: list[tuple] = [] + monkeypatch.setattr(simulation, "open_project", lambda name: None) + monkeypatch.setattr( + simulation, + "get_time", + lambda name: { + "HYDRAULIC TIMESTEP": "00:15:00", + "REPORT TIMESTEP": "1:00", + "DURATION": "0:00", + "PATTERN START": "0:00", + }, + ) + monkeypatch.setattr(simulation, "set_time", lambda name, changeset: None) + monkeypatch.setattr( + simulation, + "run_project", + lambda name: json.dumps( + { + "output": { + "times": {"num_periods": 1, "report_step": 900}, + "node_results": [{"node": "J1", "result": [{}]}], + "link_results": [{"link": "P1", "result": [{}]}], + } + } + ), + ) + monkeypatch.setattr(simulation, "create_analysis_run", lambda **kwargs: run_id) + monkeypatch.setattr( + simulation, + "update_analysis_run", + lambda *args, **kwargs: lifecycle_calls.append((args, kwargs)), + ) + + def fail_storage(*args, **kwargs): + raise RuntimeError("timescale write failed") + + monkeypatch.setattr( + simulation.TimescaleInternalStorage, + "store_analysis_simulation", + staticmethod(fail_storage), + ) + + import pytest + + with pytest.raises(RuntimeError, match="timescale write failed"): + simulation.run_simulation( + name="demo", + simulation_type="extended", + modify_pattern_start_time="2026-07-16T00:00:00+08:00", + modify_total_duration=900, + scheme_type="burst_analysis", + scheme_name="case", + ) + + assert [call[1]["status"] for call in lifecycle_calls] == ["failed"] diff --git a/tests/unit/test_burst_location_service.py b/tests/unit/test_burst_location_service.py index c80f813..99f7ac0 100644 --- a/tests/unit/test_burst_location_service.py +++ b/tests/unit/test_burst_location_service.py @@ -1,723 +1,101 @@ -import importlib.util -import sys -import types -from datetime import datetime, timedelta, timezone -from pathlib import Path +from datetime import datetime, timezone +from uuid import uuid4 import pytest - -def _load_burst_location_module(): - module_path = ( - Path(__file__).resolve().parents[2] / "app" / "services" / "burst_location.py" - ) - - missing = object() - previous_modules = {} - - def install_module(name: str, module: types.ModuleType) -> None: - previous_modules.setdefault(name, sys.modules.get(name, missing)) - sys.modules[name] = module - - def ensure_package(name: str) -> types.ModuleType: - module = sys.modules.get(name) - if module is None: - module = types.ModuleType(name) - module.__path__ = [] - install_module(name, module) - return module - - for package_name in [ - "app", - "app.algorithms", - "app.infra", - "app.infra.db", - "app.infra.db.timescaledb", - "app.services", - ]: - ensure_package(package_name) - - time_api_module = types.ModuleType("app.services.time_api") - time_api_module.parse_utc_time = ( - lambda value, field_name="datetime": ( - value.astimezone(timezone.utc) - if isinstance(value, datetime) and value.tzinfo is not None - else datetime.fromisoformat(value).astimezone(timezone.utc) - ) - ) - time_api_module.extract_date = ( - lambda value, field_name="date": ( - value.date() - if isinstance(value, datetime) - else datetime.fromisoformat(value).date() - ) - ) - time_api_module.utc_now = lambda: datetime.now(timezone.utc) - install_module("app.services.time_api", time_api_module) - - algorithms_module = types.ModuleType("app.algorithms.burst_location") - algorithms_module.run_burst_location = lambda **kwargs: {} - install_module("app.algorithms.burst_location", algorithms_module) - - internal_queries_module = types.ModuleType( - "app.infra.db.timescaledb.internal_queries" - ) - - class DummyInternalQueries: - @staticmethod - def query_scada_by_ids_timerange(**kwargs): - return {} - - @staticmethod - def query_scheme_simulation_by_ids_timerange(**kwargs): - return {} - - @staticmethod - def query_realtime_simulation_by_ids_timerange(**kwargs): - return {} - - internal_queries_module.InternalQueries = DummyInternalQueries - install_module( - "app.infra.db.timescaledb.internal_queries", internal_queries_module - ) - - scheme_management_module = types.ModuleType("app.services.scheme_management") - scheme_management_module.query_burst_location_scheme_detail = lambda *args, **kwargs: {} - scheme_management_module.query_burst_location_schemes = lambda *args, **kwargs: [] - scheme_management_module.query_scheme_list = lambda *args, **kwargs: [] - scheme_management_module.scheme_name_exists = lambda *args, **kwargs: False - scheme_management_module.store_scheme_info = lambda *args, **kwargs: None - install_module("app.services.scheme_management", scheme_management_module) - - tjnetwork_module = types.ModuleType("app.services.tjnetwork") - tjnetwork_module.dump_inp = lambda *args, **kwargs: None - tjnetwork_module.get_all_scada_info = lambda *args, **kwargs: [] - install_module("app.services.tjnetwork", tjnetwork_module) - - module_name = "tests_burst_location_under_test" - spec = importlib.util.spec_from_file_location(module_name, module_path) - module = importlib.util.module_from_spec(spec) - assert spec and spec.loader - try: - spec.loader.exec_module(module) - finally: - for name, previous in reversed(previous_modules.items()): - if previous is missing: - sys.modules.pop(name, None) - else: - sys.modules[name] = previous - return module +from app.services import burst_location -def test_run_burst_location_uses_single_timerange_with_burst_source_split(monkeypatch, tmp_path): - module = _load_burst_location_module() +START = datetime(2026, 8, 1, tzinfo=timezone.utc) +END = datetime(2026, 8, 2, tzinfo=timezone.utc) + + +def test_analysis_simulation_query_is_keyed_by_run_id(monkeypatch): + run_id = uuid4() captured = {} - scheme_calls = [] - realtime_calls = [] - monkeypatch.setattr( - module, - "get_all_scada_info", - lambda network: [ - { - "type": "pressure", - "associated_element_id": "J1", - "api_query_id": "pressure-query", - }, - { - "type": "pipe_flow", - "associated_element_id": "P1", - "api_query_id": "pipe-flow-query", - }, - { - "type": "demand", - "associated_element_id": "J2", - "api_query_id": "demand-query", - }, - ], - ) - monkeypatch.setattr(module, "_prepare_burst_inp", lambda network: str(tmp_path / "fake.inp")) - - def fake_run_burst_location(**kwargs): + def fake_query(**kwargs): captured.update(kwargs) - return { - "located_pipe": "Pipe-001", - "simulation_times": 3, - "similarity_mode": "combined", - } - - monkeypatch.setattr(module, "run_burst_location", fake_run_burst_location) - - def _build_series(start_time: str, values: list[float]) -> list[dict]: - base_time = datetime.fromisoformat(start_time) - return [ - { - "time": (base_time + timedelta(minutes=15 * index)).isoformat(), - "value": value, - } - for index, value in enumerate(values) - ] - - def fake_scheme_query(**kwargs): - scheme_calls.append(kwargs) - start_hour = datetime.fromisoformat(kwargs["start_time"]).astimezone( - timezone(timedelta(hours=8)) - ).hour - if kwargs["element_type"] == "node" and kwargs["field"] == "pressure": - values = [12.0, 14.0, 16.0, 18.0] if start_hour == 8 else [8.0, 10.0, 12.0, 14.0] - return {"J1": _build_series(kwargs["start_time"], values)} - if kwargs["element_type"] == "link" and kwargs["field"] == "flow": - values = [5.0, 7.0, 9.0, 11.0] if start_hour == 8 else [2.0, 4.0, 6.0, 8.0] - return {"P1": _build_series(kwargs["start_time"], values)} - if kwargs["element_type"] == "node" and kwargs["field"] == "actual_demand": - values = [3.0, 5.0, 7.0, 9.0] if start_hour == 8 else [1.0, 3.0, 5.0, 7.0] - return {"J2": _build_series(kwargs["start_time"], values)} - raise AssertionError(f"Unexpected scheme query: {kwargs}") - - def fake_realtime_query(**kwargs): - realtime_calls.append(kwargs) - if kwargs["element_type"] == "node" and kwargs["field"] == "pressure": - return {"J1": _build_series(kwargs["start_time"], [8.0, 10.0, 12.0, 14.0])} - if kwargs["element_type"] == "link" and kwargs["field"] == "flow": - return {"P1": _build_series(kwargs["start_time"], [2.0, 4.0, 6.0, 8.0])} - if kwargs["element_type"] == "node" and kwargs["field"] == "actual_demand": - return {"J2": _build_series(kwargs["start_time"], [1.0, 3.0, 5.0, 7.0])} - raise AssertionError(f"Unexpected realtime query: {kwargs}") + return {"J1": []} monkeypatch.setattr( - module.InternalQueries, - "query_scheme_simulation_by_ids_timerange", - staticmethod(fake_scheme_query), - ) - monkeypatch.setattr( - module.InternalQueries, - "query_realtime_simulation_by_ids_timerange", - staticmethod(fake_realtime_query), - ) - monkeypatch.setattr( - module, - "query_scheme_list", - lambda name, scheme_type=None: [ - ( - 1, - "BurstSchemeA", - "burst_analysis", - "testuser", - None, - None, - {"burst_ID": ["Pipe-009", "Pipe-010"]}, - ) - ], + burst_location.InternalQueries, + "query_analysis_simulation_by_ids_timerange", + staticmethod(fake_query), ) - result = module.run_burst_location_by_network( - network="tjwater", - username="testuser", - data_source="simulation", - simulation_scheme_name="BurstSchemeA", - simulation_scheme_type="burst_analysis", - burst_leakage=10.0, - scada_burst_start=datetime(2025, 1, 1, 8, 0, 0, tzinfo=timezone(timedelta(hours=8))), - scada_burst_end=datetime(2025, 1, 1, 9, 0, 0, tzinfo=timezone(timedelta(hours=8))), - use_scada_flow=True, + result = burst_location._query_simulation_values( + network="demo", + element_ids=["J1"], + element_type="node", + field="pressure", + start_dt=START, + end_dt=END, + simulation_source="analysis", + simulation_run_id=run_id, ) - assert result["observed_source"] == "simulation_scheme_burst_realtime_normal_timerange" - assert result["simulation_scheme"] == { - "name": "BurstSchemeA", - "type": "burst_analysis", - "burst_ids": ["Pipe-009", "Pipe-010"], - } - assert result["pressure_samples"] == {"burst": 4, "normal": 4} - assert result["flow_samples"] == {"burst": 4, "normal": 4} - assert captured["visualize_partition"] is False - assert list(captured["burst_pressure"].index) == ["J1"] - assert captured["burst_pressure"]["J1"] == pytest.approx(15.0) - assert captured["normal_pressure"]["J1"] == pytest.approx(11.0) - assert captured["burst_flow"]["J2"] == pytest.approx(6.0) - assert captured["burst_flow"]["P1"] == pytest.approx(8.0) - assert captured["normal_flow"]["J2"] == pytest.approx(4.0) - assert captured["normal_flow"]["P1"] == pytest.approx(5.0) - assert all(call["scheme_name"] == "BurstSchemeA" for call in scheme_calls) - assert len(scheme_calls) == 3 - assert any(call["element_type"] == "node" and call["field"] == "pressure" for call in scheme_calls) - assert any(call["element_type"] == "link" and call["field"] == "flow" for call in scheme_calls) - assert any(call["element_type"] == "node" and call["field"] == "actual_demand" for call in scheme_calls) - assert len(realtime_calls) == 3 - assert any(call["element_type"] == "node" and call["field"] == "pressure" for call in realtime_calls) - assert any(call["element_type"] == "link" and call["field"] == "flow" for call in realtime_calls) - assert any(call["element_type"] == "node" and call["field"] == "actual_demand" for call in realtime_calls) - assert {call["start_time"] for call in scheme_calls + realtime_calls} == { - "2025-01-01T00:00:00+00:00" - } - assert {call["end_time"] for call in scheme_calls + realtime_calls} == { - "2025-01-01T01:00:00+00:00" - } - assert result["scada_window"] == { - "burst_start": "2025-01-01T00:00:00+00:00", - "burst_end": "2025-01-01T01:00:00+00:00", - "normal_start": "2025-01-01T00:00:00+00:00", - "normal_end": "2025-01-01T01:00:00+00:00", - } + assert result == {"J1": []} + assert captured["run_id"] == run_id + assert "scheme_name" not in captured + assert "scheme_type" not in captured -def test_run_burst_location_requires_simulation_scheme_name(monkeypatch, tmp_path): - module = _load_burst_location_module() - monkeypatch.setattr( - module, - "get_all_scada_info", - lambda network: [ - { - "type": "pressure", - "associated_element_id": "J1", - "api_query_id": "pressure-query", - } - ], - ) - monkeypatch.setattr(module, "_prepare_burst_inp", lambda network: str(tmp_path / "fake.inp")) - monkeypatch.setattr(module, "run_burst_location", lambda **kwargs: {}) - - with pytest.raises(ValueError, match="simulation_scheme_name"): - module.run_burst_location_by_network( - network="tjwater", - username="testuser", - data_source="simulation", - burst_leakage=1.0, - scada_burst_start=datetime(2025, 1, 1, 8, 0, 0, tzinfo=timezone(timedelta(hours=8))), - scada_burst_end=datetime(2025, 1, 1, 9, 0, 0, tzinfo=timezone(timedelta(hours=8))), +def test_analysis_simulation_query_requires_run_id(): + with pytest.raises(ValueError, match="simulation_run_id"): + burst_location._query_simulation_values( + network="demo", + element_ids=["J1"], + element_type="node", + field="pressure", + start_dt=START, + end_dt=END, + simulation_source="analysis", + simulation_run_id=None, ) -def test_build_observed_series_from_simulation_normalizes_result_ids(monkeypatch): - module = _load_burst_location_module() - query_calls = [] - +def test_scada_mapping_uses_canonical_asset_fields(monkeypatch): monkeypatch.setattr( - module, + burst_location, "get_all_scada_info", lambda network: [ { - "type": "pressure", - "associated_element_id": " 100026 ", - "api_query_id": " pressure-query ", - } - ], - ) - - def fake_scheme_query(**kwargs): - query_calls.append(kwargs) - return { - 100026: [ - {"time": kwargs["start_time"], "value": 10.0}, - {"time": kwargs["end_time"], "value": 14.0}, - ] - } - - monkeypatch.setattr( - module.InternalQueries, - "query_scheme_simulation_by_ids_timerange", - staticmethod(fake_scheme_query), - ) - - series, sample_count = module._build_observed_series_from_simulation( - network="tjwater", - sensor_ids=["100026"], - start_dt=datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc), - end_dt=datetime(2025, 1, 1, 1, 0, 0, tzinfo=timezone.utc), - data_type="pressure", - series_name="burst_pressure", - simulation_source="scheme", - simulation_scheme_name="BurstSchemeA", - simulation_scheme_type="burst_analysis", - ) - - assert query_calls[0]["element_ids"] == ["100026"] - assert sample_count == 2 - assert series["100026"] == pytest.approx(12.0) - - -def test_build_observed_series_from_scada_uses_chinese_error_label(monkeypatch): - module = _load_burst_location_module() - - monkeypatch.setattr( - module, - "get_all_scada_info", - lambda network: [ - { - "type": "pressure", - "associated_element_id": "100026", - "api_query_id": "pressure-query", - } - ], - ) - monkeypatch.setattr( - module.InternalQueries, - "query_scada_by_ids_timerange", - staticmethod(lambda **kwargs: {"pressure-query": []}), - ) - - with pytest.raises(ValueError) as exc_info: - module._build_observed_series_from_scada( - network="tjwater", - sensor_ids=["100026"], - start_dt=datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc), - end_dt=datetime(2025, 1, 1, 1, 0, 0, tzinfo=timezone.utc), - data_type="pressure", - series_name="burst_pressure", - ) - - message = str(exc_info.value) - assert "爆管压力数据 在时间窗内无有效数据: 100026" in message - assert "burst_pressure" not in message - - -def test_build_observed_series_from_scada_skips_missing_sensor_values(monkeypatch): - module = _load_burst_location_module() - - monkeypatch.setattr( - module, - "get_all_scada_info", - lambda network: [ - {"type": "pressure", "associated_element_id": "J1", "api_query_id": "q1"}, - {"type": "pressure", "associated_element_id": "J2", "api_query_id": "q2"}, - {"type": "pressure", "associated_element_id": "J3", "api_query_id": "q3"}, - ], - ) - monkeypatch.setattr( - module.InternalQueries, - "query_scada_by_ids_timerange", - staticmethod( - lambda **kwargs: { - "q1": [ - {"time": kwargs["start_time"], "value": 10.0}, - {"time": kwargs["end_time"], "value": 12.0}, - ], - "q2": [], - "q3": [ - {"time": kwargs["start_time"], "value": None}, - {"time": kwargs["end_time"], "value": 18.0}, - ], - } - ), - ) - - series, sample_count = module._build_observed_series_from_scada( - network="tjwater", - sensor_ids=["J1", "J2", "J3"], - start_dt=datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc), - end_dt=datetime(2025, 1, 1, 1, 0, 0, tzinfo=timezone.utc), - data_type="pressure", - series_name="burst_pressure", - ) - - assert list(series.index) == ["J1", "J3"] - assert series["J1"] == pytest.approx(11.0) - assert series["J3"] == pytest.approx(18.0) - assert sample_count == 1 - - -def test_run_burst_location_monitoring_uses_scada_for_burst_and_normal( - monkeypatch, tmp_path -): - module = _load_burst_location_module() - captured = {} - scada_calls = [] - realtime_calls = [] - - monkeypatch.setattr( - module, - "get_all_scada_info", - lambda network: [ - { - "type": "pressure", - "associated_element_id": "J1", - "api_query_id": "pressure-query", - } - ], - ) - monkeypatch.setattr(module, "_prepare_burst_inp", lambda network: str(tmp_path / "fake.inp")) - monkeypatch.setattr( - module, - "run_burst_location", - lambda **kwargs: captured.update(kwargs) or {"located_pipe": "Pipe-001"}, - ) - - def fake_scada_query(**kwargs): - scada_calls.append(kwargs) - start_hour = datetime.fromisoformat(kwargs["start_time"]).astimezone( - timezone(timedelta(hours=8)) - ).hour - values = [20.0, 22.0] if start_hour == 8 else [10.0, 12.0] - return { - "pressure-query": [ - {"time": kwargs["start_time"], "value": values[0]}, - {"time": kwargs["end_time"], "value": values[1]}, - ] - } - - def fake_realtime_query(**kwargs): - realtime_calls.append(kwargs) - return { - "J1": [ - {"time": kwargs["start_time"], "value": 10.0}, - {"time": kwargs["end_time"], "value": 12.0}, - ] - } - - monkeypatch.setattr( - module.InternalQueries, - "query_scada_by_ids_timerange", - staticmethod(fake_scada_query), - ) - monkeypatch.setattr( - module.InternalQueries, - "query_realtime_simulation_by_ids_timerange", - staticmethod(fake_realtime_query), - ) - - result = module.run_burst_location_by_network( - network="tjwater", - username="testuser", - data_source="monitoring", - burst_leakage=1.0, - scada_burst_start=datetime(2025, 1, 1, 8, 0, 0, tzinfo=timezone(timedelta(hours=8))), - scada_burst_end=datetime(2025, 1, 1, 9, 0, 0, tzinfo=timezone(timedelta(hours=8))), - scada_normal_start=datetime(2025, 1, 1, 7, 0, 0, tzinfo=timezone(timedelta(hours=8))), - scada_normal_end=datetime(2025, 1, 1, 8, 0, 0, tzinfo=timezone(timedelta(hours=8))), - ) - - assert result["observed_source"] == "scada_burst_scada_normal_timerange" - assert len(scada_calls) == 2 - assert len(realtime_calls) == 0 - assert captured["burst_pressure"]["J1"] == pytest.approx(21.0) - assert captured["normal_pressure"]["J1"] == pytest.approx(11.0) - assert result["scada_window"] == { - "burst_start": "2025-01-01T00:00:00+00:00", - "burst_end": "2025-01-01T01:00:00+00:00", - "normal_start": "2024-12-31T23:00:00+00:00", - "normal_end": "2025-01-01T00:00:00+00:00", - } - - -def test_run_burst_location_monitoring_defaults_normal_window_to_previous_day( - monkeypatch, tmp_path -): - module = _load_burst_location_module() - captured = {} - scada_calls = [] - - monkeypatch.setattr( - module, - "get_all_scada_info", - lambda network: [ - { - "type": "pressure", - "associated_element_id": "J1", - "api_query_id": "pressure-query", - } - ], - ) - monkeypatch.setattr(module, "_prepare_burst_inp", lambda network: str(tmp_path / "fake.inp")) - monkeypatch.setattr( - module, - "run_burst_location", - lambda **kwargs: captured.update(kwargs) or {"located_pipe": "Pipe-001"}, - ) - - def fake_scada_query(**kwargs): - scada_calls.append(kwargs) - start_time = datetime.fromisoformat(kwargs["start_time"]) - values = ( - [20.0, 22.0] - if start_time.date().isoformat() == "2025-01-01" - else [10.0, 12.0] - ) - return { - "pressure-query": [ - {"time": kwargs["start_time"], "value": values[0]}, - {"time": kwargs["end_time"], "value": values[1]}, - ] - } - - monkeypatch.setattr( - module.InternalQueries, - "query_scada_by_ids_timerange", - staticmethod(fake_scada_query), - ) - monkeypatch.setattr( - module.InternalQueries, - "query_realtime_simulation_by_ids_timerange", - staticmethod(lambda **kwargs: pytest.fail("monitoring mode must not query realtime simulation")), - ) - - result = module.run_burst_location_by_network( - network="tjwater", - username="testuser", - data_source="monitoring", - burst_leakage=1.0, - scada_burst_start=datetime(2025, 1, 1, 8, 0, 0, tzinfo=timezone(timedelta(hours=8))), - scada_burst_end=datetime(2025, 1, 1, 9, 0, 0, tzinfo=timezone(timedelta(hours=8))), - ) - - assert result["observed_source"] == "scada_burst_scada_normal_timerange" - assert len(scada_calls) == 2 - assert datetime.fromisoformat(scada_calls[1]["start_time"]) == ( - datetime.fromisoformat(scada_calls[0]["start_time"]) - timedelta(days=1) - ) - assert datetime.fromisoformat(scada_calls[1]["end_time"]) == ( - datetime.fromisoformat(scada_calls[0]["end_time"]) - timedelta(days=1) - ) - assert captured["burst_pressure"]["J1"] == pytest.approx(21.0) - assert captured["normal_pressure"]["J1"] == pytest.approx(11.0) - assert result["scada_window"] == { - "burst_start": "2025-01-01T00:00:00+00:00", - "burst_end": "2025-01-01T01:00:00+00:00", - "normal_start": "2024-12-31T00:00:00+00:00", - "normal_end": "2024-12-31T01:00:00+00:00", - } - - -def test_run_burst_location_monitoring_flow_uses_previous_day_normal_window( - monkeypatch, tmp_path -): - module = _load_burst_location_module() - captured = {} - scada_calls = [] - - monkeypatch.setattr( - module, - "get_all_scada_info", - lambda network: [ - { - "type": "pressure", - "associated_element_id": "J1", - "api_query_id": "pressure-query", + "device_id": "pressure-1", + "device_type": "pressure", + "node_id": "J1", + "link_id": None, + "api_query_id": "q-pressure", }, { - "type": "pipe_flow", - "associated_element_id": "P1", - "api_query_id": "flow-query", + "device_id": "flow-1", + "device_type": "pipe_flow", + "node_id": None, + "link_id": "P1", + "api_query_id": "q-flow", }, ], ) - monkeypatch.setattr(module, "_prepare_burst_inp", lambda network: str(tmp_path / "fake.inp")) + + assert burst_location._build_scada_mapping("demo", "pressure") == { + "J1": "q-pressure" + } + assert burst_location._build_scada_mapping("demo", "flow") == { + "P1": "q-flow" + } + + +def test_burst_ids_are_read_from_analysis_run(monkeypatch): + run_id = uuid4() monkeypatch.setattr( - module, - "run_burst_location", - lambda **kwargs: captured.update(kwargs) or {"located_pipe": "Pipe-001"}, + burst_location, + "get_analysis_run", + lambda network, value: { + "run_id": value, + "parameters": {"burst_ID": ["P1", "P2"]}, + }, ) - def fake_scada_query(**kwargs): - scada_calls.append(kwargs) - is_burst_day = ( - datetime.fromisoformat(kwargs["start_time"]).date().isoformat() - == "2025-01-01" - ) - if kwargs["device_ids"] == ["pressure-query"]: - values = [20.0, 22.0] if is_burst_day else [10.0, 12.0] - query_id = "pressure-query" - else: - values = [7.0, 9.0] if is_burst_day else [3.0, 5.0] - query_id = "flow-query" - return { - query_id: [ - {"time": kwargs["start_time"], "value": values[0]}, - {"time": kwargs["end_time"], "value": values[1]}, - ] - } - - monkeypatch.setattr( - module.InternalQueries, - "query_scada_by_ids_timerange", - staticmethod(fake_scada_query), - ) - - result = module.run_burst_location_by_network( - network="tjwater", - username="testuser", - data_source="monitoring", - burst_leakage=1.0, - scada_burst_start=datetime(2025, 1, 1, 8, 0, 0, tzinfo=timezone(timedelta(hours=8))), - scada_burst_end=datetime(2025, 1, 1, 9, 0, 0, tzinfo=timezone(timedelta(hours=8))), - use_scada_flow=True, - ) - - assert result["observed_source"] == "scada_burst_scada_normal_timerange" - assert len(scada_calls) == 4 - for burst_call, normal_call in [ - (scada_calls[0], scada_calls[1]), - (scada_calls[2], scada_calls[3]), - ]: - assert datetime.fromisoformat(normal_call["start_time"]) == ( - datetime.fromisoformat(burst_call["start_time"]) - timedelta(days=1) - ) - assert datetime.fromisoformat(normal_call["end_time"]) == ( - datetime.fromisoformat(burst_call["end_time"]) - timedelta(days=1) - ) - assert captured["burst_pressure"]["J1"] == pytest.approx(21.0) - assert captured["normal_pressure"]["J1"] == pytest.approx(11.0) - assert captured["burst_flow"]["P1"] == pytest.approx(8.0) - assert captured["normal_flow"]["P1"] == pytest.approx(4.0) - - -def test_run_burst_location_monitoring_aligns_partial_scada_data( - monkeypatch, tmp_path -): - module = _load_burst_location_module() - captured = {} - - monkeypatch.setattr( - module, - "get_all_scada_info", - lambda network: [ - {"type": "pressure", "associated_element_id": "J1", "api_query_id": "q1"}, - {"type": "pressure", "associated_element_id": "J2", "api_query_id": "q2"}, - {"type": "pressure", "associated_element_id": "J3", "api_query_id": "q3"}, - ], - ) - monkeypatch.setattr(module, "_prepare_burst_inp", lambda network: str(tmp_path / "fake.inp")) - monkeypatch.setattr( - module, - "run_burst_location", - lambda **kwargs: captured.update(kwargs) or {"located_pipe": "Pipe-001"}, - ) - - def fake_scada_query(**kwargs): - start_hour = datetime.fromisoformat(kwargs["start_time"]).astimezone( - timezone(timedelta(hours=8)) - ).hour - if start_hour == 8: - return { - "q1": [{"time": kwargs["start_time"], "value": 20.0}], - "q2": [{"time": kwargs["start_time"], "value": 30.0}], - "q3": [], - } - return { - "q1": [{"time": kwargs["start_time"], "value": 10.0}], - "q2": [], - "q3": [{"time": kwargs["start_time"], "value": 12.0}], - } - - monkeypatch.setattr( - module.InternalQueries, - "query_scada_by_ids_timerange", - staticmethod(fake_scada_query), - ) - - result = module.run_burst_location_by_network( - network="tjwater", - username="testuser", - data_source="monitoring", - burst_leakage=1.0, - scada_burst_start=datetime(2025, 1, 1, 8, 0, 0, tzinfo=timezone(timedelta(hours=8))), - scada_burst_end=datetime(2025, 1, 1, 9, 0, 0, tzinfo=timezone(timedelta(hours=8))), - scada_normal_start=datetime(2025, 1, 1, 7, 0, 0, tzinfo=timezone(timedelta(hours=8))), - scada_normal_end=datetime(2025, 1, 1, 8, 0, 0, tzinfo=timezone(timedelta(hours=8))), - ) - - assert result["pressure_scada_ids"] == ["J1"] - assert captured["pressure_scada_ids"] == ["J1"] - assert list(captured["burst_pressure"].index) == ["J1"] - assert list(captured["normal_pressure"].index) == ["J1"] - assert captured["burst_pressure"]["J1"] == pytest.approx(20.0) - assert captured["normal_pressure"]["J1"] == pytest.approx(10.0) + assert burst_location._get_simulation_run_burst_ids( + network="demo", run_id=run_id + ) == ["P1", "P2"] diff --git a/tests/unit/test_postgres_scada_repository.py b/tests/unit/test_postgres_scada_repository.py index d1f4cd7..a0ed677 100644 --- a/tests/unit/test_postgres_scada_repository.py +++ b/tests/unit/test_postgres_scada_repository.py @@ -19,15 +19,16 @@ class _FakeCursor: async def fetchall(self): return [ { - "id": " 25470001 ", - "type": " PRESSURE ", - "associated_element_id": " J1 ", + "device_id": " 25470001 ", + "device_type": " PRESSURE ", + "node_id": " J1 ", + "link_id": None, "api_query_id": "query-1", "transmission_mode": "realtime", "transmission_frequency": None, - "reliability": "0.95", - "x_coor": "117.1", - "y_coor": "32.9", + "reliability": "95", + "x": "117.1", + "y": "32.9", } ] @@ -47,16 +48,18 @@ def test_get_scadas_normalizes_id_and_type(): assert result == [ { - "id": "25470001", - "type": "pressure", - "associated_element_id": "J1", + "device_id": "25470001", + "device_type": "pressure", + "node_id": "J1", + "link_id": None, "api_query_id": "query-1", "transmission_mode": "realtime", "transmission_frequency": None, - "reliability": 0.95, + "reliability": 95, "x": 117.1, "y": 32.9, } ] - assert "associated_element_id" in conn.cursor_instance.query - assert "FROM public.scada_info" in conn.cursor_instance.query + assert "node_id" in conn.cursor_instance.query + assert "link_id" in conn.cursor_instance.query + assert "FROM gis.scada_devices" in conn.cursor_instance.query diff --git a/tests/unit/test_postgresql_analysis_repository.py b/tests/unit/test_postgresql_analysis_repository.py new file mode 100644 index 0000000..110ec89 --- /dev/null +++ b/tests/unit/test_postgresql_analysis_repository.py @@ -0,0 +1,54 @@ +import asyncio +from uuid import uuid4 + +from app.infra.db.postgresql.analysis import AnalysisRepository + + +class _FakeCursor: + def __init__(self): + self.calls = [] + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def execute(self, query, params): + self.calls.append((str(query), params)) + + async def fetchall(self): + return [] + + +class _FakeConnection: + def __init__(self): + self.cursor_instance = _FakeCursor() + + def cursor(self): + return self.cursor_instance + + +def test_list_results_uses_typed_comparison_when_result_type_is_present(): + conn = _FakeConnection() + run_id = uuid4() + + asyncio.run( + AnalysisRepository.list_results(conn, run_id, "leakage_identification") + ) + + query, params = conn.cursor_instance.calls[0] + assert "result_type = %s" in query + assert "%s IS NULL" not in query + assert params == (run_id, "leakage_identification") + + +def test_list_results_omits_result_type_filter_when_not_requested(): + conn = _FakeConnection() + run_id = uuid4() + + asyncio.run(AnalysisRepository.list_results(conn, run_id)) + + query, params = conn.cursor_instance.calls[0] + assert "result_type = %s" not in query + assert params == (run_id,) diff --git a/tests/unit/test_project_scada_metadata.py b/tests/unit/test_project_scada_metadata.py index b977838..e1eecf5 100644 --- a/tests/unit/test_project_scada_metadata.py +++ b/tests/unit/test_project_scada_metadata.py @@ -1,15 +1,17 @@ import asyncio from datetime import datetime, timezone from unittest.mock import AsyncMock +from uuid import uuid4 from app.api.v1.endpoints import project_data from app.infra.db.timescaledb import composite_queries PROJECT_SCADA = { - "id": "fengyang-pressure-1", - "type": "pressure", - "associated_element_id": "J1", + "device_id": "fengyang-pressure-1", + "device_type": "pressure", + "node_id": "J1", + "link_id": None, "api_query_id": "query-1", "transmission_mode": "realtime", "transmission_frequency": None, @@ -42,13 +44,13 @@ def test_realtime_scada_simulation_uses_current_project_metadata(monkeypatch): composite_queries.CompositeQueries.get_scada_associated_realtime_simulation_data( object(), object(), - [PROJECT_SCADA["id"]], + [PROJECT_SCADA["device_id"]], START_TIME, END_TIME, ) ) - assert result[PROJECT_SCADA["id"]][0]["scada_id"] == PROJECT_SCADA["id"] + assert result[PROJECT_SCADA["device_id"]][0]["scada_id"] == PROJECT_SCADA["device_id"] assert query_mock.await_count == 1 assert query_mock.await_args.args[1:] == ( START_TIME, @@ -58,36 +60,35 @@ def test_realtime_scada_simulation_uses_current_project_metadata(monkeypatch): ) -def test_scheme_scada_simulation_uses_current_project_metadata(monkeypatch): +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}]) monkeypatch.setattr( - composite_queries.SchemeRepository, - "get_node_field_by_scheme_and_time_range", + composite_queries.AnalysisResultsRepository, + "get_node_series", query_mock, ) result = asyncio.run( - composite_queries.CompositeQueries.get_scada_associated_scheme_simulation_data( + composite_queries.CompositeQueries.get_scada_associated_analysis_simulation_data( object(), object(), - [PROJECT_SCADA["id"]], + [PROJECT_SCADA["device_id"]], START_TIME, END_TIME, - "baseline", - "scheme-1", + uuid4(), ) ) - assert result[PROJECT_SCADA["id"]][0]["scada_id"] == PROJECT_SCADA["id"] - assert query_mock.await_args.args[5:] == ("J1", "pressure") + 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") def test_element_scada_query_uses_current_project_metadata(monkeypatch): _patch_project_scadas(monkeypatch) query_mock = AsyncMock( return_value={ - PROJECT_SCADA["id"]: [{"time": START_TIME, "value": 26.5}] + PROJECT_SCADA["device_id"]: [{"time": START_TIME, "value": 26.5}] } ) monkeypatch.setattr( diff --git a/tests/unit/test_realtime_repository.py b/tests/unit/test_realtime_repository.py index 9559840..f692f94 100644 --- a/tests/unit/test_realtime_repository.py +++ b/tests/unit/test_realtime_repository.py @@ -1,4 +1,5 @@ import asyncio +from contextlib import contextmanager from datetime import datetime, timezone from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository @@ -65,3 +66,38 @@ def test_get_nodes_by_time_range_normalizes_inputs_to_utc(): datetime(2026, 6, 1, 0, 0, tzinfo=timezone.utc), datetime(2026, 6, 1, 1, 0, tzinfo=timezone.utc), ) + + +class _SyncTransactionConnection: + def __init__(self): + self.transactions = 0 + + @contextmanager + def transaction(self): + self.transactions += 1 + yield + + +def test_realtime_node_and_link_replacement_share_outer_transaction(monkeypatch): + conn = _SyncTransactionConnection() + calls: list[str] = [] + monkeypatch.setattr( + RealtimeRepository, + "insert_nodes_batch_sync", + lambda _conn, _data: calls.append("nodes"), + ) + monkeypatch.setattr( + RealtimeRepository, + "insert_links_batch_sync", + lambda _conn, _data: calls.append("links"), + ) + + RealtimeRepository.store_realtime_simulation_result_sync( + conn, + [{"node": "N1", "result": [{"pressure": 1.0}]}], + [{"link": "L1", "result": [{"flow": 2.0}]}], + "2026-06-01T00:00:00Z", + ) + + assert conn.transactions == 1 + assert calls == ["nodes", "links"] diff --git a/tests/unit/test_scada_cleaning.py b/tests/unit/test_scada_cleaning.py index 6a37145..10268fa 100644 --- a/tests/unit/test_scada_cleaning.py +++ b/tests/unit/test_scada_cleaning.py @@ -17,7 +17,7 @@ def test_clean_scada_uses_current_project_metadata(monkeypatch): composite_queries.ScadaInfoRepository, "get_scadas", AsyncMock( - return_value=[{"id": "fengyang-pressure-1", "type": "pressure"}] + return_value=[{"device_id": "fengyang-pressure-1", "device_type": "pressure"}] ), ) monkeypatch.setattr( @@ -66,7 +66,7 @@ def test_clean_scada_rejects_devices_missing_from_project_metadata(monkeypatch): monkeypatch.setattr( composite_queries.ScadaInfoRepository, "get_scadas", - AsyncMock(return_value=[{"id": "other-device", "type": "pressure"}]), + AsyncMock(return_value=[{"device_id": "other-device", "device_type": "pressure"}]), ) query_mock = AsyncMock() monkeypatch.setattr( @@ -94,7 +94,7 @@ def test_clean_scada_rejects_zero_database_updates(monkeypatch): composite_queries.ScadaInfoRepository, "get_scadas", AsyncMock( - return_value=[{"id": "fengyang-pressure-1", "type": "pressure"}] + return_value=[{"device_id": "fengyang-pressure-1", "device_type": "pressure"}] ), ) monkeypatch.setattr( @@ -141,7 +141,7 @@ def test_clean_scada_propagates_write_failures(monkeypatch): composite_queries.ScadaInfoRepository, "get_scadas", AsyncMock( - return_value=[{"id": "fengyang-pressure-1", "type": "pressure"}] + return_value=[{"device_id": "fengyang-pressure-1", "device_type": "pressure"}] ), ) monkeypatch.setattr( diff --git a/tests/unit/test_scada_repository.py b/tests/unit/test_scada_repository.py index c8a01e3..36f9077 100644 --- a/tests/unit/test_scada_repository.py +++ b/tests/unit/test_scada_repository.py @@ -65,8 +65,8 @@ def test_update_scada_field_inserts_when_update_hits_no_rows(): ) assert len(conn.cursor_instance.calls) == 2 - assert "UPDATE scada.scada_data SET" in conn.cursor_instance.calls[0][0] - assert "INSERT INTO scada.scada_data" in conn.cursor_instance.calls[1][0] + assert "UPDATE scada.measurements SET" in conn.cursor_instance.calls[0][0] + assert "INSERT INTO scada.measurements" in conn.cursor_instance.calls[1][0] def test_update_scada_field_skips_insert_when_update_succeeds(): @@ -85,4 +85,4 @@ def test_update_scada_field_skips_insert_when_update_succeeds(): ) assert len(conn.cursor_instance.calls) == 1 - assert "UPDATE scada.scada_data SET" in conn.cursor_instance.calls[0][0] + assert "UPDATE scada.measurements SET" in conn.cursor_instance.calls[0][0] diff --git a/tests/unit/test_scheme_list_filter.py b/tests/unit/test_scheme_list_filter.py deleted file mode 100644 index 8631396..0000000 --- a/tests/unit/test_scheme_list_filter.py +++ /dev/null @@ -1,135 +0,0 @@ -from app.services import scheme_management, tjnetwork - - -class _FakeCursor: - def __init__(self): - self.calls = [] - - def __enter__(self): - return self - - def __exit__(self, *_exc_info): - return False - - def execute(self, statement, params=None): - self.calls.append((str(statement), params)) - - def fetchall(self): - return [] - - -class _FakeConnection: - def __init__(self, cursor): - self._cursor = cursor - - def __enter__(self): - return self - - def __exit__(self, *_exc_info): - return False - - def cursor(self): - return self._cursor - - -def test_query_scheme_list_pushes_scheme_type_into_sql(monkeypatch): - cursor = _FakeCursor() - monkeypatch.setattr( - scheme_management, - "get_project_pgconn_string", - lambda db_name=None: "postgres://test", - ) - monkeypatch.setattr( - scheme_management.psycopg, "connect", lambda _conn_string: _FakeConnection(cursor) - ) - - assert scheme_management.query_scheme_list("demo", scheme_type="burst_analysis") == [] - - statement, params = cursor.calls[0] - assert "WHERE scheme_type = %s" in statement - assert params == ("burst_analysis",) - - -def test_get_all_schemes_filters_central_scheme_list_by_type(monkeypatch): - captured = {} - - def fake_query_scheme_list(name, scheme_type=None, query_date=None): - captured["name"] = name - captured["scheme_type"] = scheme_type - captured["query_date"] = query_date - return [ - ( - 7, - "burst_case", - "burst_analysis", - "alice", - "2026-01-01T00:00:00+08:00", - "2026-01-01T01:00:00+08:00", - {"burst_ID": ["P1"]}, - ) - ] - - monkeypatch.setattr( - scheme_management, "query_scheme_list", fake_query_scheme_list - ) - - result = tjnetwork.get_all_schemes("demo", scheme_type="burst_analysis") - - assert captured == { - "name": "demo", - "scheme_type": "burst_analysis", - "query_date": None, - } - assert result == [ - { - "scheme_id": 7, - "scheme_name": "burst_case", - "scheme_type": "burst_analysis", - "username": "alice", - "create_time": "2026-01-01T00:00:00+08:00", - "scheme_start_time": "2026-01-01T01:00:00+08:00", - "scheme_detail": {"burst_ID": ["P1"]}, - } - ] - - -def test_query_scheme_detail_rejects_wrong_specialized_type(monkeypatch): - monkeypatch.setattr( - scheme_management, - "query_burst_detection_scheme_detail", - lambda name, scheme_name: { - "scheme_name": scheme_name, - "scheme_type": "burst_analysis", - "network": name, - }, - ) - - assert ( - scheme_management.query_scheme_detail( - "demo", - "same_name", - scheme_type="burst_detection", - ) - == {} - ) - - -def test_query_scheme_detail_rejects_wrong_network(monkeypatch): - monkeypatch.setattr( - scheme_management, - "query_burst_location_scheme_detail", - lambda name, scheme_name: { - "scheme_name": scheme_name, - "scheme_type": "burst_location", - "network": "other_network", - }, - ) - - assert ( - scheme_management.query_scheme_detail( - "demo", - "same_name", - scheme_type="burst_location", - ) - == {} - ) diff --git a/tests/unit/test_scheme_management_lifecycle.py b/tests/unit/test_scheme_management_lifecycle.py new file mode 100644 index 0000000..da2ed87 --- /dev/null +++ b/tests/unit/test_scheme_management_lifecycle.py @@ -0,0 +1,62 @@ +from unittest.mock import MagicMock + +from app.services import scheme_management + + +def _mock_connection(monkeypatch): + cursor = MagicMock() + cursor.rowcount = 1 + connection = MagicMock() + connection.cursor.return_value.__enter__.return_value = cursor + context = MagicMock() + context.__enter__.return_value = connection + monkeypatch.setattr(scheme_management, "project_connection", lambda _name: context) + return cursor + + +def test_repeated_analysis_names_create_distinct_run_ids(monkeypatch) -> None: + cursor = _mock_connection(monkeypatch) + arguments = { + "name": "tjwater_next", + "scheme_name": "same-window", + "scheme_type": "burst_analysis", + "username": "alice", + "scheme_start_time": "2026-08-24T00:00:00Z", + "scheme_detail": {}, + } + + first = scheme_management.create_analysis_run(**arguments) + second = scheme_management.create_analysis_run(**arguments) + + assert first != second + assert cursor.execute.call_count == 2 + assert all( + "insert into analysis.runs" in call.args[0] + for call in cursor.execute.call_args_list + ) + + +def test_update_analysis_run_targets_execution_id(monkeypatch) -> None: + cursor = _mock_connection(monkeypatch) + run_id = scheme_management.create_analysis_run( + name="tjwater_next", + scheme_name="run", + scheme_type="burst_analysis", + username="alice", + scheme_start_time="2026-08-24T00:00:00Z", + scheme_detail={}, + ) + cursor.reset_mock() + + scheme_management.update_analysis_run( + "tjwater_next", + run_id, + status="completed", + username="alice", + scheme_detail={"window": "24h"}, + ) + + statement, params = cursor.execute.call_args.args + assert "where run_id = %s" in statement + assert params[-1] == run_id + assert params[1] == "completed" diff --git a/tests/unit/test_scheme_simulation_timestep.py b/tests/unit/test_scheme_simulation_timestep.py deleted file mode 100644 index 8057b0a..0000000 --- a/tests/unit/test_scheme_simulation_timestep.py +++ /dev/null @@ -1,219 +0,0 @@ -import inspect -import json -from datetime import timedelta - -import pytest - -from app.infra.db.timescaledb.repositories.scheme import SchemeRepository -from app.services.time_api import parse_utc_time - - -def test_run_simulation_exposes_explicit_valve_control(): - from app.services import simulation - - parameters = inspect.signature(simulation.run_simulation).parameters - - assert "valve_control" in parameters - - -def test_apply_valve_control_matches_run_simulation_ex_semantics(monkeypatch): - from app.services import simulation - - updates: dict[str, dict] = {} - - monkeypatch.setattr( - simulation, - "get_status", - lambda project_name, valve_name: { - "link": valve_name, - "status": "OPEN", - "setting": 1.0, - }, - ) - monkeypatch.setattr( - simulation, - "set_status", - lambda project_name, changeset: updates.update( - { - changeset.operations[0]["link"]: changeset.operations[0].copy() - } - ), - ) - - simulation._apply_valve_control( - "demo", - { - "V-status": {"status": "ACTIVE"}, - "V-setting": {"setting": 2.5}, - "V-closed": {"status": "ACTIVE", "setting": 9.0, "k": 0}, - "V-k": {"status": "ACTIVE", "setting": 9.0, "k": 0.5}, - }, - ) - - assert updates["V-status"] == { - "link": "V-status", - "status": "ACTIVE", - "setting": 1.0, - } - assert updates["V-setting"] == { - "link": "V-setting", - "status": "OPEN", - "setting": 2.5, - } - assert updates["V-closed"] == { - "link": "V-closed", - "status": "CLOSED", - "setting": 9.0, - } - assert updates["V-k"] == { - "link": "V-k", - "status": "ACTIVE", - "setting": 0.1036 * pow(0.5, -3.105), - } - - -def _node_result(periods: int) -> list[dict]: - return [ - { - "node": "J1", - "result": [ - {"demand": index, "head": index, "pressure": index, "quality": index} - for index in range(periods) - ], - } - ] - - -def _link_result(periods: int) -> list[dict]: - return [ - { - "link": "P1", - "result": [ - { - "flow": index, - "friction": index, - "headloss": index, - "quality": index, - "reaction": index, - "setting": index, - "status": index, - "velocity": index, - } - for index in range(periods) - ], - } - ] - - -def test_store_scheme_simulation_uses_15_minute_report_step(monkeypatch): - inserted: dict[str, list[dict]] = {} - - monkeypatch.setattr( - SchemeRepository, - "insert_nodes_batch_sync", - staticmethod(lambda conn, data: inserted.setdefault("nodes", data)), - ) - monkeypatch.setattr( - SchemeRepository, - "insert_links_batch_sync", - staticmethod(lambda conn, data: inserted.setdefault("links", data)), - ) - - SchemeRepository.store_scheme_simulation_result_sync( - conn=object(), - scheme_type="burst_analysis", - scheme_name="five_hour_case", - node_result_list=_node_result(21), - link_result_list=_link_result(21), - result_start_time="2026-07-16T00:00:00Z", - num_periods=21, - result_timestep_seconds=900, - ) - - start_time = parse_utc_time("2026-07-16T00:00:00Z") - assert len(inserted["nodes"]) == 21 - assert inserted["nodes"][0]["time"] == start_time - assert inserted["nodes"][-1]["time"] == start_time + timedelta(hours=5) - assert inserted["links"][-1]["time"] == start_time + timedelta(hours=5) - - -def test_store_scheme_simulation_uses_hourly_report_step(monkeypatch): - inserted: dict[str, list[dict]] = {} - - monkeypatch.setattr( - SchemeRepository, - "insert_nodes_batch_sync", - staticmethod(lambda conn, data: inserted.setdefault("nodes", data)), - ) - monkeypatch.setattr( - SchemeRepository, - "insert_links_batch_sync", - staticmethod(lambda conn, data: inserted.setdefault("links", data)), - ) - - SchemeRepository.store_scheme_simulation_result_sync( - conn=object(), - scheme_type="burst_analysis", - scheme_name="hourly_case", - node_result_list=_node_result(6), - link_result_list=_link_result(6), - result_start_time="2026-07-16T00:00:00Z", - num_periods=6, - result_timestep_seconds=3600, - ) - - start_time = parse_utc_time("2026-07-16T00:00:00Z") - assert [item["time"] for item in inserted["nodes"]] == [ - start_time + timedelta(hours=index) for index in range(6) - ] - - -def test_run_simulation_passes_report_step_for_extended_scheme(monkeypatch): - import app.services.simulation as simulation - - time_updates: list[dict] = [] - storage_calls: list[tuple] = [] - - monkeypatch.setattr(simulation, "open_project", lambda name: None) - monkeypatch.setattr( - simulation, - "get_time", - lambda name: { - "HYDRAULIC TIMESTEP": "00:15:00", - "REPORT TIMESTEP": "1:00", - "DURATION": "0:00", - "PATTERN START": "0:00", - }, - ) - monkeypatch.setattr( - simulation, - "set_time", - lambda name, changeset: time_updates.append(changeset.operations[0]), - ) - monkeypatch.setattr(simulation, "run_project", lambda name: json.dumps({ - "simulation_result": "successful", - "output": { - "times": {"num_periods": 21, "report_step": 900}, - "node_results": _node_result(21), - "link_results": _link_result(21), - }, - })) - monkeypatch.setattr( - simulation.TimescaleInternalStorage, - "store_scheme_simulation", - staticmethod(lambda *args, **kwargs: storage_calls.append((args, kwargs))), - ) - - simulation.run_simulation( - name="fengyang", - simulation_type="extended", - modify_pattern_start_time="2026-07-16T00:00:00+08:00", - modify_total_duration=18000, - scheme_type="burst_analysis", - scheme_name="five_hour_case", - ) - - assert time_updates[0]["DURATION"] == "05:00:00" - assert time_updates[0]["REPORT TIMESTEP"] == "1:00" - assert storage_calls[0][0][5] == 21 - assert storage_calls[0][0][6] == 900 diff --git a/tests/unit/test_sensor_placement_service.py b/tests/unit/test_sensor_placement_service.py index e14e06b..01884d6 100644 --- a/tests/unit/test_sensor_placement_service.py +++ b/tests/unit/test_sensor_placement_service.py @@ -1,10 +1,11 @@ from datetime import datetime, timezone from unittest.mock import MagicMock +from uuid import uuid4 import pytest from openpyxl import load_workbook -from app.native.wndb import s42_sensor_placement +from app.infra.db.postgresql import sensor_placement as sensor_placement_repository from app.services import sensor_placement @@ -15,180 +16,121 @@ def _mock_project_cursor(monkeypatch): connection_context = MagicMock() connection_context.__enter__.return_value = connection monkeypatch.setattr( - s42_sensor_placement, + sensor_placement_repository, "project_connection", lambda _network: connection_context, ) return cursor -def test_build_workbook_contains_engineering_columns(monkeypatch): - monkeypatch.setattr( - sensor_placement, - "_sensor_points", - lambda network, locations: [ - { - "node_id": "J1", - "max_pipe_diameter": 400.0, - "project_x": 13500000.0, - "project_y": 3600000.0, - "map_x": 13500000.0, - "map_y": 3600000.0, - "longitude": 121.0, - "latitude": 31.0, - "elevation": 4.5, - } - ], - ) - scheme = { - "id": 7, - "scheme_name": "北区测压点", - "sensor_number": 2, +def _run() -> dict: + return { + "run_id": uuid4(), + "name": "北区测压点", + "sensor_count": 2, "min_diameter": 300, - "username": "alice", - "create_time": datetime(2026, 7, 30, tzinfo=timezone.utc), - "sensor_location": ["J1", "J2"], + "created_by": "alice", + "created_at": datetime(2026, 7, 30, tzinfo=timezone.utc), + "status": "completed", + "sensor_locations": ["J1", "J2"], } + +def _point() -> dict: + return { + "node_id": "J1", + "max_pipe_diameter": 400.0, + "project_x": 3038.94, + "project_y": -34446.59, + "map_x": 13525191.530279, + "map_y": 3622984.760237, + "longitude": 121.498863, + "latitude": 30.924784, + "elevation": 4.5, + } + + +def test_build_workbook_uses_analysis_run_metadata(monkeypatch): + monkeypatch.setattr(sensor_placement, "_sensor_points", lambda *_: [_point()]) output = sensor_placement.build_sensor_placement_workbook( network="tjwater", - scheme=scheme, + scheme=_run(), sensor_location=["J1"], adjustment_status={"J1": "replaced"}, ) workbook = load_workbook(output) assert workbook.sheetnames == ["方案信息", "监测点清单"] - headers = [cell.value for cell in workbook["监测点清单"][1]] - assert headers == [ - "序号", - "节点 ID", - "经度", - "纬度", - "工程 X", - "工程 Y", - "地图 X", - "地图 Y", - "高程", - "调整状态", - ] assert workbook["监测点清单"]["J2"].value == "替换" assert workbook["方案信息"]["B8"].value == "未保存草稿" -def test_candidate_keeps_engineering_coordinates_and_transforms_map_coordinates( - monkeypatch, -): +def test_candidate_transforms_map_coordinates(monkeypatch): + row = _point().copy() + row.pop("longitude") + row.pop("latitude") monkeypatch.setattr( - sensor_placement.wndb, + sensor_placement.sensor_placement_repository, "get_sensor_placement_nodes", - lambda network, node_ids: [ - { - "node_id": "J1", - "max_pipe_diameter": 400.0, - "project_x": 3038.94, - "project_y": -34446.59, - "map_x": 13525191.530279, - "map_y": 3622984.760237, - "elevation": 4.5, - } - ], + lambda network, node_ids: [row], ) point = sensor_placement.get_sensor_placement_candidate("tjwater", "J1") assert point["project_x"] == 3038.94 - assert point["project_y"] == -34446.59 - assert point["max_pipe_diameter"] == 400.0 assert point["longitude"] == pytest.approx(121.498863, abs=1e-6) - assert point["latitude"] == pytest.approx(30.924784, abs=1e-6) def test_update_validates_nodes_before_write(monkeypatch): monkeypatch.setattr( - sensor_placement.wndb, + sensor_placement.sensor_placement_repository, "get_sensor_placement_nodes", lambda network, node_ids: [], ) - try: - sensor_placement.update_sensor_placement_scheme( + with pytest.raises(sensor_placement.SensorPlacementValidationError, match="missing"): + sensor_placement.update_sensor_placement_run( "tjwater", - 7, - expected_sensor_location=["J1"], - sensor_location=["missing"], + uuid4(), + expected_sensor_locations=["J1"], + sensor_locations=["missing"], ) - except sensor_placement.SensorPlacementValidationError as exc: - assert "missing" in str(exc) - else: - raise AssertionError("expected invalid node to be rejected") -def test_sensor_nodes_use_materialized_web_mercator_geometry(monkeypatch): +def test_sensor_nodes_query_uses_new_network_and_gis_schemas(monkeypatch): cursor = _mock_project_cursor(monkeypatch) cursor.fetchall.return_value = [] - s42_sensor_placement.get_sensor_placement_nodes("tjwater", ["J1"]) + sensor_placement_repository.get_sensor_placement_nodes("tjwater", ["J1"]) query = cursor.execute.call_args.args[0] - assert "geo_junctions_mat" in query - assert "ST_X(c.coord)" in query - assert "ST_Y(c.coord)" in query - assert "ST_X(gj.geom)" in query - assert "ST_Y(gj.geom)" in query - assert "MAX(diameter) AS max_pipe_diameter" in query + assert "network.pipes" in query + assert "network.links" in query + assert "gis.node_geometries" in query + assert "ST_Transform(g.geom, 3857)" in query assert cursor.execute.call_args.args[1] == (["J1"], ["J1"], ["J1"]) -def test_workbook_escapes_formula_in_scheme_metadata(monkeypatch): - monkeypatch.setattr( - sensor_placement, - "_sensor_points", - lambda network, locations: [ - { - "node_id": "J1", - "max_pipe_diameter": 400.0, - "project_x": 3038.94, - "project_y": -34446.59, - "map_x": 13525191.53, - "map_y": 3622984.76, - "longitude": 121.49, - "latitude": 30.92, - "elevation": 4.5, - } - ], - ) - output = sensor_placement.build_sensor_placement_workbook( - network="tjwater", - scheme={ - "scheme_name": "=1+1", - "sensor_location": ["J1"], - "min_diameter": 300, - "username": "alice", - "create_time": datetime(2026, 7, 30, tzinfo=timezone.utc), - }, - sensor_location=["J1"], - adjustment_status={}, - ) - - workbook = load_workbook(output, data_only=False) - assert workbook["方案信息"]["B2"].value == "'=1+1" - assert workbook["方案信息"]["B2"].data_type == "s" - - -def test_create_sensor_placement_returns_inserted_record(monkeypatch): +def test_create_sensor_placement_writes_run_and_result_atomically(monkeypatch): cursor = _mock_project_cursor(monkeypatch) - cursor.fetchone.return_value = {"id": 7, "sensor_location": ["J1", "J2"]} + created_at = datetime(2026, 8, 24, tzinfo=timezone.utc) + cursor.fetchone.return_value = { + "run_id": uuid4(), + "name": "北区测压点", + "created_by": "alice", + "created_at": created_at, + "status": "completed", + } - created = s42_sensor_placement.create_sensor_placement( + created = sensor_placement_repository.create_sensor_placement( "tjwater", - scheme_name="北区测压点", + run_name="北区测压点", min_diameter=300, - username="alice", - sensor_location=["J1", "J2"], + created_by="alice", + sensor_locations=["J1", "J2"], ) - assert created["id"] == 7 - query, parameters = cursor.execute.call_args.args - assert "INSERT INTO sensor_placement" in query - assert parameters == ("北区测压点", 2, 300, "alice", ["J1", "J2"]) + assert created["sensor_count"] == 2 + statements = [call.args[0] for call in cursor.execute.call_args_list] + assert "INSERT INTO analysis.runs" in statements[0] + assert "INSERT INTO analysis.results" in statements[1] diff --git a/tests/unit/test_timescale_sync_pool.py b/tests/unit/test_timescale_sync_pool.py new file mode 100644 index 0000000..a3f345c --- /dev/null +++ b/tests/unit/test_timescale_sync_pool.py @@ -0,0 +1,152 @@ +from contextlib import contextmanager + +import pytest + +from app.infra.db.timescaledb import sync_pool + + +class _FakePool: + def __init__(self, *, conninfo, **_kwargs): + self.conninfo = conninfo + self.closed = False + self.borrowed = 0 + self.returned = 0 + + def close(self): + self.closed = True + + @contextmanager + def connection(self): + self.borrowed += 1 + try: + yield object() + finally: + self.returned += 1 + + +@pytest.fixture(autouse=True) +def clear_pools(): + sync_pool._pools.clear() + sync_pool._pool_conninfo.clear() + sync_pool._pool_borrows.clear() + yield + sync_pool._pools.clear() + sync_pool._pool_conninfo.clear() + sync_pool._pool_borrows.clear() + + +def test_pool_reuses_same_routed_timescale_dsn(monkeypatch): + monkeypatch.setattr(sync_pool, "ConnectionPool", _FakePool) + monkeypatch.setattr( + sync_pool, + "get_project_timescale_pgconn_string", + lambda *, db_name: f"dbname={db_name}", + ) + + first = sync_pool.get_timescale_pool("tjwater_next") + second = sync_pool.get_timescale_pool("tjwater_next") + + assert first is second + + +def test_pool_rebuilds_after_routing_change(monkeypatch): + monkeypatch.setattr(sync_pool, "ConnectionPool", _FakePool) + dsn = {"value": "host=old dbname=tjwater_next"} + monkeypatch.setattr( + sync_pool, + "get_project_timescale_pgconn_string", + lambda *, db_name: dsn["value"], + ) + + old = sync_pool.get_timescale_pool("tjwater_next") + dsn["value"] = "host=new dbname=tjwater_next" + new = sync_pool.get_timescale_pool("tjwater_next") + + assert old.closed is True + assert new is not old + + +def test_connection_is_returned_to_pool(monkeypatch): + monkeypatch.setattr(sync_pool, "ConnectionPool", _FakePool) + monkeypatch.setattr( + sync_pool, + "get_project_timescale_pgconn_string", + lambda *, db_name: f"dbname={db_name}", + ) + + pool = sync_pool.get_timescale_pool("tjwater_next") + with sync_pool.timescale_connection("tjwater_next"): + assert pool.borrowed == 1 + assert pool.returned == 0 + assert pool.returned == 1 + + +def test_close_removes_pool(monkeypatch): + monkeypatch.setattr(sync_pool, "ConnectionPool", _FakePool) + monkeypatch.setattr( + sync_pool, + "get_project_timescale_pgconn_string", + lambda *, db_name: f"dbname={db_name}", + ) + + pool = sync_pool.get_timescale_pool("tjwater_next") + sync_pool.close_timescale_pool("tjwater_next") + + assert pool.closed is True + assert "tjwater_next" not in sync_pool._pools + + +def test_close_all_removes_every_pool(monkeypatch): + monkeypatch.setattr(sync_pool, "ConnectionPool", _FakePool) + monkeypatch.setattr( + sync_pool, + "get_project_timescale_pgconn_string", + lambda *, db_name: f"dbname={db_name}", + ) + + first = sync_pool.get_timescale_pool("first") + second = sync_pool.get_timescale_pool("second") + sync_pool.close_all_timescale_pools() + + assert first.closed is True + assert second.closed is True + assert sync_pool._pools == {} + + +def test_pool_cache_evicts_least_recently_used_idle_pool(monkeypatch): + monkeypatch.setattr(sync_pool, "ConnectionPool", _FakePool) + monkeypatch.setattr(sync_pool.settings, "PROJECT_TS_CACHE_SIZE", 2) + monkeypatch.setattr( + sync_pool, + "get_project_timescale_pgconn_string", + lambda *, db_name: f"dbname={db_name}", + ) + + first = sync_pool.get_timescale_pool("first") + second = sync_pool.get_timescale_pool("second") + sync_pool.get_timescale_pool("first") + third = sync_pool.get_timescale_pool("third") + + assert list(sync_pool._pools) == ["first", "third"] + assert second.closed is True + assert first.closed is False + assert third.closed is False + + +def test_pool_cache_does_not_evict_active_pool(monkeypatch): + monkeypatch.setattr(sync_pool, "ConnectionPool", _FakePool) + monkeypatch.setattr(sync_pool.settings, "PROJECT_TS_CACHE_SIZE", 1) + monkeypatch.setattr( + sync_pool, + "get_project_timescale_pgconn_string", + lambda *, db_name: f"dbname={db_name}", + ) + + with sync_pool.timescale_connection("active"): + active = sync_pool._pools["active"] + sync_pool.get_timescale_pool("new") + assert active.closed is False + assert set(sync_pool._pools) == {"active", "new"} + + assert list(sync_pool._pools) == ["new"] + assert active.closed is True diff --git a/tests/unit/test_wndb_batch_transactions.py b/tests/unit/test_wndb_batch_transactions.py new file mode 100644 index 0000000..b192276 --- /dev/null +++ b/tests/unit/test_wndb_batch_transactions.py @@ -0,0 +1,83 @@ +from contextlib import contextmanager + +import pytest + +from app.native.wndb.commands import executor +from app.native.wndb.core.database import ChangeSet + + +def test_batch_commits_before_materialized_view_refresh(monkeypatch) -> None: + events: list[str] = [] + + @contextmanager + def fake_transaction(_name: str): + events.append("transaction-enter") + yield object() + events.append("transaction-exit") + + monkeypatch.setattr(executor, "project_transaction", fake_transaction) + monkeypatch.setattr( + executor, + "expand_command", + lambda _name, change_set: change_set, + ) + monkeypatch.setattr( + executor, + "_execute_update_command", + lambda _name, _change_set: events.append("write") or ChangeSet(), + ) + monkeypatch.setattr( + executor, + "refresh_materialized_views", + lambda _name: events.append("refresh"), + ) + + executor.execute_batch_commands( + "project_a", + ChangeSet({"operation": "update", "type": "junction", "id": "J1"}), + ) + + assert events == [ + "transaction-enter", + "write", + "transaction-exit", + "refresh", + ] + + +def test_failed_batch_does_not_refresh_materialized_views(monkeypatch) -> None: + events: list[str] = [] + + @contextmanager + def fake_transaction(_name: str): + events.append("transaction-enter") + try: + yield object() + finally: + events.append("transaction-exit") + + monkeypatch.setattr(executor, "project_transaction", fake_transaction) + monkeypatch.setattr( + executor, + "expand_command", + lambda _name, change_set: change_set, + ) + + def fail_write(_name, _change_set): + events.append("write") + raise RuntimeError("write failed") + + monkeypatch.setattr(executor, "_execute_update_command", fail_write) + monkeypatch.setattr( + executor, + "refresh_materialized_views", + lambda _name: events.append("refresh"), + ) + + with pytest.raises(RuntimeError, match="write failed"): + executor.execute_batch_commands( + "project_a", + ChangeSet({"operation": "update", "type": "junction", "id": "J1"}), + ) + + assert events == ["transaction-enter", "write", "transaction-exit"] diff --git a/tests/unit/test_wndb_connection.py b/tests/unit/test_wndb_connection.py index 0db0002..a61c9ad 100644 --- a/tests/unit/test_wndb_connection.py +++ b/tests/unit/test_wndb_connection.py @@ -1,145 +1,228 @@ +from contextlib import contextmanager + import pytest -from app.native.wndb import connection -from app.native.wndb import database -from app.native.wndb import project +from app.native.wndb.core import connection -class _FakeCursor: - def __init__(self, connection): - self.connection = connection +class _FakePool: + def __init__(self, *, conninfo, **_kwargs): + self.conninfo = conninfo + self.closed = False + self.borrowed = 0 + self.returned = 0 - def __enter__(self): - return self + def close(self): + self.closed = True - def __exit__(self, exc_type, exc, tb): - return False - - def execute(self, sql): - self.connection.executed.append(sql) - if self.connection.fail_ping and sql == "SELECT 1": - raise connection.pg.OperationalError("server closed the connection") - - def fetchall(self): - return self.connection.rows + @contextmanager + def connection(self): + self.borrowed += 1 + try: + yield _FakeConnection() + finally: + self.returned += 1 class _FakeConnection: - def __init__(self, rows=None, *, closed=False, fail_ping=False): - self.rows = list(rows or []) - self.closed = closed - self.fail_ping = fail_ping - self.executed = [] - self.close_calls = 0 + def __init__(self): + self.transactions = 0 - def cursor(self, row_factory=None): - if self.closed: - raise RuntimeError("the connection is closed") - return _FakeCursor(self) - - def close(self): - self.close_calls += 1 - self.closed = True + @contextmanager + def transaction(self): + self.transactions += 1 + yield @pytest.fixture(autouse=True) -def clear_native_connections(): - connection.g_conn_dict.clear() - connection.g_conninfo_dict.clear() - connection._project_locks.clear() +def clear_pools(): + connection._pools.clear() + connection._pool_conninfo.clear() + connection._pool_borrows.clear() + connection._admin_pools.clear() + connection._admin_pool_borrows.clear() yield - connection.g_conn_dict.clear() - connection.g_conninfo_dict.clear() - connection._project_locks.clear() + connection._pools.clear() + connection._pool_conninfo.clear() + connection._pool_borrows.clear() + connection._admin_pools.clear() + connection._admin_pool_borrows.clear() -def test_is_project_open_drops_closed_cached_connection(): - connection.g_conn_dict["fengyang"] = _FakeConnection(closed=True) +def test_project_pool_is_reused_for_same_routed_dsn(monkeypatch): + monkeypatch.setattr(connection, "ConnectionPool", _FakePool) + monkeypatch.setattr(connection, "get_project_pgconn_string", lambda *, db_name: f"dbname={db_name}") - assert project.is_project_open("fengyang") is False - assert "fengyang" not in connection.g_conn_dict + first = connection.get_project_pool("fengyang") + second = connection.get_project_pool("fengyang") + + assert first is second + assert first.conninfo == "dbname=fengyang" -def test_open_connection_reuses_healthy_cached_connection(monkeypatch): - cached = _FakeConnection() - connection.g_conn_dict["fengyang"] = cached - connection.g_conninfo_dict["fengyang"] = "dbname=fengyang" - monkeypatch.setattr( - connection, "get_project_pgconn_string", lambda db_name: f"dbname={db_name}" - ) +def test_project_pool_rebuilds_when_routed_dsn_changes(monkeypatch): + monkeypatch.setattr(connection, "ConnectionPool", _FakePool) + dsn = {"value": "host=old dbname=fengyang"} + monkeypatch.setattr(connection, "get_project_pgconn_string", lambda *, db_name: dsn["value"]) - def fail_connect(*, conninfo, autocommit): - raise AssertionError("cached connection should be reused") + old = connection.get_project_pool("fengyang") + dsn["value"] = "host=new dbname=fengyang" + new = connection.get_project_pool("fengyang") - monkeypatch.setattr(connection.pg, "connect", fail_connect) - - assert connection.open_connection("fengyang") is cached - assert cached.executed == ["SELECT 1"] + assert old.closed is True + assert new is not old + assert new.conninfo == "host=new dbname=fengyang" -def test_read_all_reopens_closed_cached_connection(monkeypatch): - stale = _FakeConnection(closed=True) - fresh = _FakeConnection(rows=[{"key": "DURATION", "value": "01:00:00"}]) - connection.g_conn_dict["fengyang"] = stale +def test_project_connection_returns_connection_to_pool(monkeypatch): + monkeypatch.setattr(connection, "ConnectionPool", _FakePool) + monkeypatch.setattr(connection, "get_project_pgconn_string", lambda *, db_name: f"dbname={db_name}") - opened = [] - - def fake_connect(*, conninfo, autocommit): - opened.append((conninfo, autocommit)) - return fresh - - monkeypatch.setattr(connection.pg, "connect", fake_connect) - monkeypatch.setattr( - connection, "get_project_pgconn_string", lambda db_name: f"dbname={db_name}" - ) - - rows = database.read_all("fengyang", "select * from times") - - assert rows == [{"key": "DURATION", "value": "01:00:00"}] - assert opened == [("dbname=fengyang", True)] - assert connection.g_conn_dict["fengyang"] is fresh - assert fresh.executed == ["select * from times"] + pool = connection.get_project_pool("fengyang") + with connection.project_connection("fengyang"): + assert pool.borrowed == 1 + assert pool.returned == 0 + assert pool.returned == 1 -def test_read_all_reopens_cached_connection_when_health_check_fails(monkeypatch): - stale = _FakeConnection(fail_ping=True) - fresh = _FakeConnection(rows=[{"scheme_name": "base"}]) - connection.g_conn_dict["fengyang"] = stale - connection.g_conninfo_dict["fengyang"] = "dbname=fengyang" - - opened = [] - - def fake_connect(*, conninfo, autocommit): - opened.append((conninfo, autocommit)) - return fresh - - monkeypatch.setattr(connection.pg, "connect", fake_connect) - monkeypatch.setattr( - connection, "get_project_pgconn_string", lambda db_name: f"dbname={db_name}" - ) - - rows = database.read_all("fengyang", "select * from scheme_list") - - assert rows == [{"scheme_name": "base"}] - assert stale.executed == ["SELECT 1"] - assert stale.close_calls == 1 - assert opened == [("dbname=fengyang", True)] - assert connection.g_conn_dict["fengyang"] is fresh - assert fresh.executed == ["select * from scheme_list"] - - -def test_open_connection_replaces_cache_when_project_dsn_changes(monkeypatch): - cached = _FakeConnection() - fresh = _FakeConnection() - connection.g_conn_dict["fengyang"] = cached - connection.g_conninfo_dict["fengyang"] = "host=old dbname=fengyang" +def test_project_transaction_reuses_one_pooled_connection(monkeypatch): + monkeypatch.setattr(connection, "ConnectionPool", _FakePool) monkeypatch.setattr( connection, "get_project_pgconn_string", - lambda db_name: f"host=new dbname={db_name}", + lambda *, db_name: f"dbname={db_name}", ) - monkeypatch.setattr(connection.pg, "connect", lambda **_kwargs: fresh) - assert connection.open_connection("fengyang") is fresh - assert cached.close_calls == 1 - assert connection.g_conninfo_dict["fengyang"] == "host=new dbname=fengyang" + pool = connection.get_project_pool("fengyang") + with connection.project_transaction("fengyang") as transaction_conn: + with connection.project_connection("fengyang") as nested_conn: + assert nested_conn is transaction_conn + assert transaction_conn.transactions == 1 + + assert pool.borrowed == 1 + assert pool.returned == 1 + + +def test_close_project_pool_removes_and_closes_pool(monkeypatch): + monkeypatch.setattr(connection, "ConnectionPool", _FakePool) + monkeypatch.setattr(connection, "get_project_pgconn_string", lambda *, db_name: f"dbname={db_name}") + + pool = connection.get_project_pool("fengyang") + connection.close_project_pool("fengyang") + + assert pool.closed is True + assert "fengyang" not in connection._pools + + +def test_admin_connection_is_pooled(monkeypatch): + monkeypatch.setattr(connection, "ConnectionPool", _FakePool) + monkeypatch.setattr( + connection, + "get_project_pgconn_string", + lambda *, db_name: f"dbname={db_name}", + ) + + first = connection.get_admin_pool() + second = connection.get_admin_pool() + with connection.admin_connection(): + pass + + assert first is second + assert first.conninfo == "dbname=postgres" + assert first.borrowed == 1 + assert first.returned == 1 + + +def test_close_all_closes_project_and_admin_pools(monkeypatch): + monkeypatch.setattr(connection, "ConnectionPool", _FakePool) + monkeypatch.setattr( + connection, + "get_project_pgconn_string", + lambda *, db_name: f"dbname={db_name}", + ) + + project_pool = connection.get_project_pool("fengyang") + admin_pool = connection.get_admin_pool() + connection.close_all_project_pools() + + assert project_pool.closed is True + assert admin_pool.closed is True + assert connection._pools == {} + assert connection._admin_pools == {} + + +def test_admin_pools_are_isolated_by_routed_host(monkeypatch): + monkeypatch.setattr(connection, "ConnectionPool", _FakePool) + route = {"host": "one"} + monkeypatch.setattr( + connection, + "get_project_pgconn_string", + lambda *, db_name: f"host={route['host']} dbname={db_name}", + ) + + first = connection.get_admin_pool() + route["host"] = "two" + second = connection.get_admin_pool() + + assert first is not second + assert first.closed is False + assert second.closed is False + + +def test_project_pool_cache_evicts_least_recently_used_idle_pool(monkeypatch): + monkeypatch.setattr(connection, "ConnectionPool", _FakePool) + monkeypatch.setattr(connection.settings, "PROJECT_PG_CACHE_SIZE", 2) + monkeypatch.setattr( + connection, + "get_project_pgconn_string", + lambda *, db_name: f"dbname={db_name}", + ) + + first = connection.get_project_pool("first") + second = connection.get_project_pool("second") + connection.get_project_pool("first") + third = connection.get_project_pool("third") + + assert list(connection._pools) == ["first", "third"] + assert second.closed is True + assert first.closed is False + assert third.closed is False + + +def test_project_pool_cache_does_not_evict_active_pool(monkeypatch): + monkeypatch.setattr(connection, "ConnectionPool", _FakePool) + monkeypatch.setattr(connection.settings, "PROJECT_PG_CACHE_SIZE", 1) + monkeypatch.setattr( + connection, + "get_project_pgconn_string", + lambda *, db_name: f"dbname={db_name}", + ) + + with connection.project_connection("active"): + active = connection._pools["active"] + connection.get_project_pool("new") + assert active.closed is False + assert set(connection._pools) == {"active", "new"} + + assert list(connection._pools) == ["new"] + assert active.closed is True + + +def test_route_health_check_does_not_close_active_pool(monkeypatch): + monkeypatch.setattr(connection, "ConnectionPool", _FakePool) + route = {"value": "host=old dbname=project"} + monkeypatch.setattr( + connection, + "get_project_pgconn_string", + lambda *, db_name: route["value"], + ) + + with connection.project_connection("project"): + pool = connection._pools["project"] + route["value"] = "host=new dbname=project" + assert connection.is_project_pool_open("project") is False + assert pool.closed is False + + replacement = connection.get_project_pool("project") + assert pool.closed is True + assert replacement is not pool diff --git a/tests/unit/test_wndb_materialized_refresh.py b/tests/unit/test_wndb_materialized_refresh.py new file mode 100644 index 0000000..8943b42 --- /dev/null +++ b/tests/unit/test_wndb_materialized_refresh.py @@ -0,0 +1,94 @@ +from app.native.wndb.core import database + + +def _command(statement: str) -> database.DatabaseCommand: + return database.DatabaseCommand(statement, []) + + +def test_database_command_has_no_removed_undo_state() -> None: + changes = [{"operation": "update", "type": "title", "value": "new"}] + + command = database.DatabaseCommand("SELECT 1", changes) + + assert vars(command) == {"sql": "SELECT 1", "changes": changes} + assert not hasattr(command, "undo_sql") + assert not hasattr(command, "undo_cs") + + +def test_direct_model_write_refreshes_materialized_views(monkeypatch) -> None: + events: list[str] = [] + monkeypatch.setattr( + database, + "write", + lambda _name, _statement: events.append("write"), + ) + monkeypatch.setattr( + database, + "is_project_transaction_active", + lambda _name: False, + ) + monkeypatch.setattr( + database, + "refresh_materialized_views", + lambda _name: events.append("refresh"), + ) + + result = database.execute_command( + "project_a", + database.DatabaseCommand( + "UPDATE network.junctions SET elevation = 1", + [{"operation": "update", "type": "junction", "id": "J-1"}], + ), + ) + + assert events == ["write", "refresh"] + assert result.operations == [ + {"operation": "update", "type": "junction", "id": "J-1"} + ] + + +def test_batch_model_write_defers_materialized_view_refresh(monkeypatch) -> None: + events: list[str] = [] + monkeypatch.setattr( + database, + "write", + lambda _name, _statement: events.append("write"), + ) + monkeypatch.setattr( + database, + "is_project_transaction_active", + lambda _name: True, + ) + monkeypatch.setattr( + database, + "refresh_materialized_views", + lambda _name: events.append("refresh"), + ) + + database.execute_command( + "project_a", + _command("UPDATE network.junctions SET elevation = 1"), + ) + + assert events == ["write"] + + +def test_non_gis_model_write_does_not_refresh_materialized_views(monkeypatch) -> None: + events: list[str] = [] + monkeypatch.setattr( + database, + "write", + lambda _name, _statement: events.append("write"), + ) + monkeypatch.setattr( + database, + "refresh_materialized_views", + lambda _name: events.append("refresh"), + ) + + database.execute_command( + "project_a", + _command("UPDATE network.time_settings SET value = '01:00'"), + ) + + assert events == ["write"] diff --git a/tests/unit/test_wndb_query_safety.py b/tests/unit/test_wndb_query_safety.py index 6162871..0dc9d7a 100644 --- a/tests/unit/test_wndb_query_safety.py +++ b/tests/unit/test_wndb_query_safety.py @@ -1,4 +1,9 @@ -from app.native.wndb import s2_junctions +import ast +from pathlib import Path + +from app.infra.db.postgresql import scada_assets +from app.native.wndb.core.database import ChangeSet, sql_literal +from app.native.wndb.model import controls, junctions, patterns def test_get_junction_binds_untrusted_identifier(monkeypatch) -> None: @@ -9,13 +14,104 @@ def test_get_junction_binds_untrusted_identifier(monkeypatch) -> None: calls.append((name, statement, params)) return None - monkeypatch.setattr(s2_junctions, "try_read", fake_try_read) + monkeypatch.setattr(junctions, "try_read", fake_try_read) - assert s2_junctions.get_junction("project_a", malicious_id) == {} - assert calls == [ - ( - "project_a", - "select * from junctions where id = %s", - (malicious_id,), - ) - ] + assert junctions.get_junction("project_a", malicious_id) == {} + assert len(calls) == 1 + name, statement, params = calls[0] + assert name == "project_a" + assert "network.junctions" in statement + assert "WHERE n.id = %s" in statement + assert malicious_id not in statement + assert params == (malicious_id,) + + +def test_get_all_junctions_reads_materialized_view(monkeypatch) -> None: + statements: list[str] = [] + + def fake_read_all(_name, statement): + statements.append(statement) + return [] + + monkeypatch.setattr(junctions, "read_all", fake_read_all) + monkeypatch.setattr(junctions, "get_all_node_links", lambda _name: {}) + + assert junctions.get_all_junctions("project_a") == [] + assert "FROM gis.junctions" in statements[0] + + +def test_get_all_scada_info_reads_materialized_view(monkeypatch) -> None: + statements: list[str] = [] + + def fake_read_all(_name, statement): + statements.append(statement) + return [] + + monkeypatch.setattr(scada_assets, "read_all", fake_read_all) + + assert scada_assets.get_all_scada_info("project_a") == [] + assert "FROM gis.scada_devices" in statements[0] + + +def test_sql_literal_keeps_attacker_text_inside_one_postgres_literal() -> None: + malicious = "x'); DROP SCHEMA network CASCADE; --" + + assert sql_literal("O'Brien") == "'O''Brien'" + assert sql_literal(malicious) == "'x''); DROP SCHEMA network CASCADE; --'" + assert sql_literal(None) == "NULL" + + +def test_pattern_command_quotes_malicious_identifier() -> None: + malicious = "x'); DROP SCHEMA network CASCADE; --" + change = ChangeSet({"id": malicious, "factors": [1.0]}) + + command = patterns._add_pattern("project_a", change).sql + + assert f"values ({sql_literal(malicious)})" in command + assert "values ('x'); DROP SCHEMA" not in command + + +def test_inp_control_quotes_embedded_apostrophe() -> None: + command = controls.inp_in_control("LINK P-1 STATUS 'OPEN'; DELETE") + + assert "''OPEN''" in command + assert "STATUS 'OPEN'; DELETE')" not in command + + +def test_wndb_sql_fstrings_do_not_quote_formatted_values_directly() -> None: + root = Path(__file__).resolve().parents[2] / "app" / "native" / "wndb" + violations: list[str] = [] + for path in root.rglob("*.py"): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if not isinstance(node, ast.JoinedStr): + continue + static_text = "".join( + value.value + for value in node.values + if isinstance(value, ast.Constant) and isinstance(value.value, str) + ).lower() + if not any( + keyword in static_text + for keyword in ("select ", "insert into", "update ", "delete from") + ): + continue + for index, value in enumerate(node.values): + if not isinstance(value, ast.FormattedValue): + continue + before = node.values[index - 1] if index else None + after = node.values[index + 1] if index + 1 < len(node.values) else None + left_quote = ( + isinstance(before, ast.Constant) + and isinstance(before.value, str) + and before.value.endswith("'") + ) + right_quote = ( + isinstance(after, ast.Constant) + and isinstance(after.value, str) + and after.value.startswith("'") + ) + if left_quote and right_quote: + violations.append(f"{path.name}:{node.lineno}") + + assert violations == []