refactor(backend)!: separate algorithm and data layers

Reorganize algorithm packages by business responsibility, move orchestration into services, and keep database access behind pooled repositories.

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

BREAKING CHANGE: legacy algorithm module paths and obsolete simulation endpoints are removed.
This commit is contained in:
2026-09-04 17:30:55 +08:00
parent 9b095c7439
commit 5966d039de
91 changed files with 1418 additions and 4020 deletions
+4 -4
View File
@@ -8,13 +8,13 @@ from uuid import UUID
import numpy as np
import pandas as pd
from app.algorithms.burst_detection.burst_detector import BurstDetector
from app.algorithms.burst_detection.pressure_anomaly import PressureAnomalyDetector
from app.infra.db.postgresql.scada import get_all_scada_info
from app.infra.db.timescaledb.internal_queries import InternalQueries
from app.services.scheme_management import (
store_scheme_info,
)
from app.services.tjnetwork import get_all_scada_info
from app.services.time_api import parse_utc_time, utc_now
from app.domain.time import parse_utc_time, utc_now
TARGET_DAY_COUNT = 15
@@ -178,7 +178,7 @@ def run_burst_detection(
observed_input = observed_pressure_data
observed_source = "request_payload"
detector = BurstDetector(
detector = PressureAnomalyDetector(
mu=mu,
points_per_day=points_per_day,
iforest_params=iforest_params,
+4 -3
View File
@@ -7,14 +7,15 @@ from uuid import UUID
import pandas as pd
from app.algorithms.burst_location import run_burst_location
from app.algorithms.burst_localization import run_burst_location
from app.infra.db.postgresql.scada import get_all_scada_info
from app.infra.db.timescaledb.internal_queries import InternalQueries
from app.native.wndb.inp.exporter import dump_inp
from app.services.scheme_management import (
get_analysis_run,
store_scheme_info,
)
from app.services.tjnetwork import dump_inp, get_all_scada_info
from app.services.time_api import parse_utc_time, utc_now
from app.domain.time import parse_utc_time, utc_now
SeriesInput = pd.Series | dict[str, Any] | list[dict[str, Any]]
FLOW_SCADA_TYPES = {"pipe_flow", "flow", "demand"}
+38
View File
@@ -0,0 +1,38 @@
"""Application workflows for demand allocation."""
from app.algorithms.demand_allocation import allocate_demand_by_pipe_length
from app.native.wndb.gis.region_geometry import Topology, get_nodes_in_region
from app.native.wndb.model.elements import get_nodes
def calculate_demand_to_nodes(
project_code: str,
demand: float,
node_ids: list[str],
) -> dict[str, float]:
if not node_ids or demand == 0.0:
return {}
topology = Topology(project_code, node_ids)
return allocate_demand_by_pipe_length(
demand,
topology.nodes(),
topology.links(),
)
def calculate_demand_to_region(
project_code: str,
demand: float,
region_id: str,
) -> dict[str, float]:
node_ids = get_nodes_in_region(project_code, region_id)
return calculate_demand_to_nodes(project_code, demand, node_ids)
def calculate_demand_to_network(
project_code: str,
demand: float,
) -> dict[str, float]:
node_ids = get_nodes(project_code)
return calculate_demand_to_nodes(project_code, demand, node_ids)
@@ -1,23 +1,22 @@
import math
import os
from collections import deque
from datetime import datetime
from typing import Any
import numpy as np
import pandas as pd
import wntr
from app.algorithms.leakage.identifier import LeakageIdentifier
from app.algorithms.dma_leakage_estimation.genetic_optimizer import DmaLeakageOptimizer
from app.algorithms.dma_leakage_estimation.topology_partitioning import (
build_dma_partitions,
)
from app.infra.db.timescaledb.internal_queries import InternalQueries
from app.services.scheme_management import store_analysis_run_with_result
from app.services.tjnetwork import (
dump_inp,
get_all_scada_info,
from app.infra.db.postgresql.scada import get_all_scada_info
from app.native.wndb.gis.network_views import (
get_network_link_nodes,
get_network_node_coords,
)
from app.services.time_api import parse_utc_time, utc_now
from app.native.wndb.inp.exporter import dump_inp
from app.services.scheme_management import store_analysis_run_with_result
from app.domain.time import parse_utc_time, utc_now
DEFAULT_N_WORKERS = max(1, min((os.cpu_count() or 1) - 1, 4))
@@ -26,14 +25,13 @@ def run_leakage_identification(
network: str,
username: str,
observed_pressure_data: (
str | pd.DataFrame | dict[str, list[Any]] | list[dict[str, Any]] | None
pd.DataFrame | dict[str, list[Any]] | list[dict[str, Any]] | None
) = None,
start_time: float = 0,
duration: float = 24,
timestep: float = 5,
q_sum: float = 0.2,
q_sum_unit: str = "m3/s",
output_dir: str = "db_inp",
pop_size: int = 50,
max_gen: int = 100,
n_workers: int = DEFAULT_N_WORKERS,
@@ -44,7 +42,6 @@ def run_leakage_identification(
sensor_nodes: list[str] | None = None,
scheme_name: str | None = None,
) -> dict[str, Any]:
os.makedirs(output_dir, exist_ok=True)
inp_path = _prepare_leakage_inp(network)
selected_sensor_nodes = (
@@ -75,8 +72,8 @@ def run_leakage_identification(
)
observed_df = observed_pressure_data
q_sum_m3s = LeakageIdentifier._flow_to_m3s(q_sum, q_sum_unit)
identifier = LeakageIdentifier(
q_sum_m3s = DmaLeakageOptimizer._flow_to_m3s(q_sum, q_sum_unit)
identifier = DmaLeakageOptimizer(
inp_path=inp_path,
sensor_nodes=selected_sensor_nodes,
area_map=area_map,
@@ -87,7 +84,6 @@ def run_leakage_identification(
)
result_df = identifier.run_identification(
observed_pressure_data=observed_df,
output_dir=output_dir,
pop_size=pop_size,
max_gen=max_gen,
n_workers=n_workers,
@@ -188,171 +184,15 @@ def _build_area_map_by_topology(
network: str, sensor_nodes: list[str], dma_count: int | None
) -> tuple[dict[str, str], list[dict[str, Any]], dict[str, dict[str, float]]]:
node_coords = get_network_node_coords(network)
all_nodes = list(node_coords.keys())
if not all_nodes:
raise ValueError("管网中未获取到可分区节点。")
available_sensors = [node for node in sensor_nodes if node in node_coords]
if not available_sensors:
raise ValueError("无可用压力传感器,无法生成虚拟分区。")
area_count = _resolve_dma_count(dma_count, available_sensors, all_nodes)
sensor_area_map = _cluster_sensors_to_areas(
available_sensors, node_coords, area_count
area_map, areas = build_dma_partitions(
sensor_nodes,
node_coords,
get_network_link_nodes(network),
dma_count,
)
adjacency = _build_adjacency(network, all_nodes)
distance_by_sensor = {
sensor: _bfs_distances(adjacency, sensor) for sensor in available_sensors
}
assignment_count = {sensor: 0 for sensor in available_sensors}
area_map: dict[str, str] = {}
for node_id in sorted(all_nodes):
sensor = _choose_sensor_for_node(
node_id=node_id,
sensors=available_sensors,
node_coords=node_coords,
distance_by_sensor=distance_by_sensor,
assignment_count=assignment_count,
)
assignment_count[sensor] += 1
area_map[node_id] = sensor_area_map[sensor]
if not area_map:
raise ValueError("虚拟分区结果为空,无法生成节点区域映射。")
areas = _build_area_meta(area_map, sensor_area_map)
return area_map, areas, node_coords
def _resolve_dma_count(
dma_count: int | None, sensor_nodes: list[str], all_nodes: list[str]
) -> int:
if dma_count is None:
return min(len(sensor_nodes), len(all_nodes))
if dma_count <= 0:
raise ValueError("dma_count 必须大于 0。")
if dma_count > len(all_nodes):
raise ValueError("dma_count 不能大于可分区节点数量。")
if dma_count > len(sensor_nodes):
raise ValueError("dma_count 不能大于可用传感器数量。")
return dma_count
def _cluster_sensors_to_areas(
sensor_nodes: list[str], node_coords: dict[str, dict[str, float]], area_count: int
) -> dict[str, str]:
if area_count >= len(sensor_nodes):
return {sensor: str(i + 1) for i, sensor in enumerate(sensor_nodes)}
points = np.array(
[
[float(node_coords[s]["x"]), float(node_coords[s]["y"])]
for s in sensor_nodes
],
dtype=float,
)
centers = points[:area_count].copy()
labels = np.zeros(points.shape[0], dtype=int)
for _ in range(20):
d2 = ((points[:, None, :] - centers[None, :, :]) ** 2).sum(axis=2)
new_labels = d2.argmin(axis=1)
if np.array_equal(labels, new_labels):
break
labels = new_labels
for i in range(area_count):
cluster_points = points[labels == i]
if cluster_points.size > 0:
centers[i] = cluster_points.mean(axis=0)
return {
sensor: str(int(labels[idx]) + 1) for idx, sensor in enumerate(sensor_nodes)
}
def _build_adjacency(network: str, all_nodes: list[str]) -> dict[str, set[str]]:
adjacency: dict[str, set[str]] = {node: set() for node in all_nodes}
for link in get_network_link_nodes(network):
parts = str(link).split(":")
if len(parts) < 4:
continue
node1, node2 = parts[-2], parts[-1]
if node1 in adjacency and node2 in adjacency:
adjacency[node1].add(node2)
adjacency[node2].add(node1)
return adjacency
def _bfs_distances(adjacency: dict[str, set[str]], start: str) -> dict[str, int]:
distances: dict[str, int] = {start: 0}
queue: deque[str] = deque([start])
while queue:
node = queue.popleft()
for neighbor in adjacency.get(node, set()):
if neighbor in distances:
continue
distances[neighbor] = distances[node] + 1
queue.append(neighbor)
return distances
def _choose_sensor_for_node(
node_id: str,
sensors: list[str],
node_coords: dict[str, dict[str, float]],
distance_by_sensor: dict[str, dict[str, int]],
assignment_count: dict[str, int],
) -> str:
min_distance = None
candidates: list[str] = []
for sensor in sensors:
d = distance_by_sensor.get(sensor, {}).get(node_id)
if d is None:
continue
if min_distance is None or d < min_distance:
min_distance = d
candidates = [sensor]
elif d == min_distance:
candidates.append(sensor)
if not candidates:
node_coord = node_coords[node_id]
return min(
sensors,
key=lambda sensor: _euclidean_distance(
node_coord, node_coords.get(sensor, node_coord)
),
)
return min(candidates, key=lambda sensor: (assignment_count[sensor], sensor))
def _euclidean_distance(a: dict[str, float], b: dict[str, float]) -> float:
return math.hypot(float(a["x"]) - float(b["x"]), float(a["y"]) - float(b["y"]))
def _build_area_meta(
area_map: dict[str, str], sensor_area_map: dict[str, str]
) -> list[dict[str, Any]]:
nodes_by_area: dict[str, list[str]] = {}
for node_id, area_id in area_map.items():
nodes_by_area.setdefault(area_id, []).append(node_id)
sensors_by_area: dict[str, list[str]] = {}
for sensor, area_id in sensor_area_map.items():
sensors_by_area.setdefault(area_id, []).append(sensor)
areas: list[dict[str, Any]] = []
for area_id in sorted(nodes_by_area.keys(), key=lambda x: int(x)):
node_ids = sorted(nodes_by_area.get(area_id, []))
sensor_nodes = sorted(sensors_by_area.get(area_id, []))
areas.append(
{
"area_id": area_id,
"sensor_nodes": sensor_nodes,
"node_ids": node_ids,
"node_count": len(node_ids),
}
)
return areas
def _build_area_node_map(area_map: dict[str, str]) -> dict[str, list[str]]:
area_node_map: dict[str, list[str]] = {}
for node_id, area_id in area_map.items():
+1 -1
View File
@@ -1,4 +1,4 @@
from app.services.tjnetwork import read_inp
from app.native.wndb.inp.importer import read_inp
def network_update(file_path: str, project_code: str) -> None:
+1 -1
View File
@@ -4,7 +4,7 @@ from uuid import UUID, uuid4
from app.infra.db.postgresql.analysis import AnalysisRepository
from app.native.wndb.core.connection import project_connection, project_transaction
from app.services.time_api import parse_utc_time
from app.domain.time import parse_utc_time
def store_scheme_info(
+108
View File
@@ -1,5 +1,8 @@
from contextlib import contextmanager
from datetime import datetime
import fcntl
from io import BytesIO
from pathlib import Path
from typing import Any
from uuid import UUID
@@ -8,8 +11,12 @@ from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.worksheet.worksheet import Worksheet
from openpyxl.utils import get_column_letter
from pyproj import Transformer
import wntr
from app.algorithms.pressure_sensor_placement import kmeans_placement
from app.algorithms.pressure_sensor_placement import sensitivity_placement
from app.infra.db.postgresql import sensor_placement as sensor_placement_repository
from app.native.wndb.inp.exporter import dump_inp
class SensorPlacementNotFoundError(LookupError):
@@ -24,6 +31,107 @@ class SensorPlacementConflictError(RuntimeError):
pass
def _sensor_inp_path(project_code: str) -> Path:
if (
not project_code
or project_code in {".", ".."}
or "/" in project_code
or "\\" in project_code
or "\x00" in project_code
):
raise SensorPlacementValidationError("管网名称不是有效的项目标识")
return Path("db_inp") / f"{project_code}.db.inp"
@contextmanager
def _sensor_inp_lock(project_code: str):
inp_path = _sensor_inp_path(project_code)
inp_path.parent.mkdir(parents=True, exist_ok=True)
lock_path = inp_path.with_suffix(".sensor.lock")
with lock_path.open("w", encoding="utf-8") as lock_file:
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as exc:
raise SensorPlacementConflictError(
"当前项目已有监测点优化任务正在运行,请稍后重试"
) from exc
try:
yield inp_path
finally:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
def _create_validated_placement(
project_code: str,
*,
run_name: str,
min_diameter: int,
created_by: str,
sensor_locations: list[str],
) -> dict[str, Any]:
validate_sensor_placement_nodes(project_code, sensor_locations)
return sensor_placement_repository.create_sensor_placement(
project_code,
run_name=run_name,
min_diameter=min_diameter,
created_by=created_by,
sensor_locations=sensor_locations,
)
def optimize_sensor_placement_by_sensitivity(
project_code: str,
run_name: str,
sensor_count: int,
min_diameter: int,
created_by: str,
) -> dict[str, Any]:
"""Run sensitivity placement and persist the validated result."""
with _sensor_inp_lock(project_code):
inp_path = _sensor_inp_path(project_code)
dump_inp(project_code, str(inp_path), "2")
network_model = wntr.network.WaterNetworkModel(str(inp_path))
sensor_locations = sensitivity_placement.optimize_sensor_placement(
network_model,
sensor_num=sensor_count,
min_diameter=min_diameter,
)
return _create_validated_placement(
project_code,
run_name=run_name,
min_diameter=min_diameter,
created_by=created_by,
sensor_locations=sensor_locations,
)
def optimize_sensor_placement_by_kmeans(
project_code: str,
run_name: str,
sensor_count: int,
min_diameter: int,
created_by: str,
) -> dict[str, Any]:
"""Export the model, run K-means placement, and persist the result."""
with _sensor_inp_lock(project_code) as inp_path:
dump_inp(project_code, str(inp_path), "2")
network_model = wntr.network.WaterNetworkModel(str(inp_path))
sensor_locations = kmeans_placement.optimize_sensor_placement(
network_model,
sensor_count=sensor_count,
min_diameter_mm=min_diameter,
)
return _create_validated_placement(
project_code,
run_name=run_name,
min_diameter=min_diameter,
created_by=created_by,
sensor_locations=sensor_locations,
)
_to_wgs84 = Transformer.from_crs("EPSG:3857", "EPSG:4326", always_xy=True)
_STATUS_LABELS = {
"current": "当前方案",
+13 -26
View File
@@ -1,41 +1,28 @@
import numpy as np
from app.services.tjnetwork import (
ChangeSet,
get_demand,
get_option,
get_pattern,
get_pump,
get_reservoir,
get_status,
get_tank,
get_time,
read_all,
run_project,
set_demand,
set_pattern,
set_status,
set_tank,
set_time,
)
from app.infra.epanet import run_project
from app.native.wndb.core.database import ChangeSet
from app.native.wndb.model.demands import get_demand, set_demand
from app.native.wndb.model.options import get_option
from app.native.wndb.model.patterns import get_pattern, set_pattern
from app.native.wndb.model.pumps import get_pump
from app.native.wndb.model.reservoirs import get_reservoir
from app.native.wndb.model.status import get_status, set_status
from app.native.wndb.model.tanks import get_tank, set_tank
from app.native.wndb.model.times import get_time, set_time
# from get_real_status import *
from datetime import datetime, timedelta
from datetime import datetime
from math import modf
import os
import json
import pytz
import requests
import time
from typing import Optional, Tuple
from uuid import UUID
import typing
import logging
import app.services.project_info as project_info
from app.infra.db.postgresql.scada import (
ScadaElementMappings,
load_realtime_element_mappings,
)
from app.services.time_api import parse_beijing_time, parse_clock_duration_seconds
from app.domain.time import parse_beijing_time, parse_clock_duration_seconds
from app.native.wndb.core.connection import project_transaction
from app.native.wndb.core.database import refresh_materialized_views_after_commit
from app.infra.db.timescaledb.internal_queries import (
@@ -479,7 +466,7 @@ def run_simulation(
cs = ChangeSet()
cs.append(pump_pattern)
set_pattern(name_c, cs)
# 显式阀门控制沿用 run_simulation_ex 的处理顺序和覆盖规则
# 显式阀门控制优先于旧的开度参数
if valve_control is not None:
_apply_valve_control(name_c, valve_control)
# 保留原开度参数逻辑,兼容现有方案调用。
-230
View File
@@ -1,230 +0,0 @@
import json
from datetime import datetime
from functools import wraps
from math import pi
import pytz
from app.algorithms.simulation.runner import run_simulation_ex
from app.native.wndb.core.projects import temporary_project_database
from app.services.tjnetwork import (
get_pipe,
get_tank,
)
def _isolated_operation(purpose: str):
def decorator(func):
@wraps(func)
def wrapper(prj_name: str, *args, **kwargs):
with temporary_project_database(prj_name, purpose) as temporary:
kwargs["_temporary_project"] = temporary
return func(prj_name, *args, **kwargs)
return wrapper
return decorator
############################################################
# project management 07 ***暂时不使用,与业务需求无关***
############################################################
@_isolated_operation("project_management")
def project_management(
prj_name,
start_datetime,
pump_control,
tank_initial_level_control=None,
region_demand_control=None,
_temporary_project=None,
) -> str:
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Analysis."
)
if _temporary_project is None:
raise RuntimeError("Project-management isolation was not prepared")
new_name = _temporary_project
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Copying Database."
)
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Opening Database."
)
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Database Loading OK."
)
result = run_simulation_ex(
name=new_name,
simulation_type="realtime",
start_datetime=start_datetime,
duration=86400,
pump_control=pump_control,
tank_initial_level_control=tank_initial_level_control,
region_demand_control=region_demand_control,
downloading_prohibition=True,
)
return result
############################################################
# scheduling analysis 08 ***暂时不使用,与业务需求无关***
############################################################
@_isolated_operation("scheduling")
def scheduling_simulation(
prj_name,
start_time,
pump_control,
tank_id,
water_plant_output_id,
time_delta=300,
_temporary_project=None,
) -> str:
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Analysis."
)
if _temporary_project is None:
raise RuntimeError("Scheduling isolation was not prepared")
new_name = _temporary_project
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Copying Database."
)
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Opening Database."
)
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Database Loading OK."
)
simulation_result = json.loads(
run_simulation_ex(
new_name, "realtime", start_time, duration=0, pump_control=pump_control
)
)
output_data = simulation_result.get("output")
if not isinstance(output_data, dict):
raise RuntimeError("run_simulation_ex did not return JSON output content")
tank = get_tank(new_name, tank_id) # 水塔信息
tank_floor_space = pi * pow(tank["diameter"] / 2, 2) # 水塔底面积(m^2)
tank_init_level = tank["init_level"] # 水塔初始水位(m)
tank_pipes_id = tank["links"] # pipes list
tank_pipe_flow_direction = (
{}
) # 管道流向修正系数, 水塔为下游节点时为1, 水塔为上游节点时为-1
for pipe_id in tank_pipes_id:
if get_pipe(new_name, pipe_id)["node2"] == tank_id: # 水塔为下游节点
tank_pipe_flow_direction[pipe_id] = 1
else:
tank_pipe_flow_direction[pipe_id] = -1
node_results = output_data.get("node_results") or [] # [{'node': str, 'result': [{'pressure': float}]}]
water_plant_output_pressure = 0
for node_result in node_results:
if node_result["node"] == water_plant_output_id: # 水厂出水压力(m)
water_plant_output_pressure = node_result["result"][-1]["pressure"]
water_plant_output_pressure /= 100 # 预计水厂出水压力(Mpa)
pipe_results = output_data.get("link_results") or [] # [{'link': str, 'result': [{'flow': float}]}]
tank_inflow = 0
for pipe_result in pipe_results:
for pipe_id in tank_pipes_id: # 遍历与水塔相连的管道
if pipe_result["link"] == pipe_id: # 水塔入流流量(L/s)
tank_inflow += (
pipe_result["result"][-1]["flow"]
* tank_pipe_flow_direction[pipe_id]
)
tank_inflow /= 1000 # 水塔入流流量(m^3/s)
tank_level_delta = tank_inflow * time_delta / tank_floor_space # 水塔水位改变值(m)
tank_level = tank_init_level + tank_level_delta # 预计水塔水位(m)
simulation_results = {
"water_plant_output_pressure": water_plant_output_pressure,
"tank_init_level": tank_init_level,
"tank_level": tank_level,
}
return json.dumps(simulation_results)
@_isolated_operation("daily_scheduling")
def daily_scheduling_simulation(
prj_name,
start_time,
pump_control,
reservoir_id,
tank_id,
water_plant_output_id,
_temporary_project=None,
) -> str:
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Analysis."
)
if _temporary_project is None:
raise RuntimeError("Daily-scheduling isolation was not prepared")
new_name = _temporary_project
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Copying Database."
)
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Opening Database."
)
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Database Loading OK."
)
simulation_result = json.loads(
run_simulation_ex(
new_name,
"realtime",
start_time,
duration=86400,
pump_control=pump_control,
)
)
output_data = simulation_result.get("output")
if not isinstance(output_data, dict):
raise RuntimeError("run_simulation_ex did not return JSON output content")
node_results = output_data.get("node_results") or [] # [{'node': str, 'result': [{'pressure': float, 'head': float}]}]
water_plant_output_pressure = []
reservoir_level = []
tank_level = []
for node_result in node_results:
if node_result["node"] == water_plant_output_id:
for result in node_result["result"]:
water_plant_output_pressure.append(
result["pressure"] / 100
) # 水厂出水压力(Mpa)
elif node_result["node"] == reservoir_id:
for result in node_result["result"]:
reservoir_level.append(result["head"] - 250.35) # 清水池液位(m)
elif node_result["node"] == tank_id:
for result in node_result["result"]:
tank_level.append(result["pressure"]) # 调节池液位(m)
simulation_results = {
"water_plant_output_pressure": water_plant_output_pressure,
"reservoir_level": reservoir_level,
"tank_level": tank_level,
}
return json.dumps(simulation_results)
+661
View File
@@ -0,0 +1,661 @@
from __future__ import annotations
import json
from datetime import datetime
from functools import wraps
from math import pi, sqrt
import pytz
import app.services.simulation as simulation
from app.native.wndb.core.projects import temporary_project_database
from app.native.wndb.core.database import ChangeSet
from app.native.wndb.model.demands import get_demand, set_demand
from app.native.wndb.model.elements import get_node_links, is_junction
from app.native.wndb.model.emitters import get_emitter, set_emitter
from app.native.wndb.model.options import (
OPTION_DEMAND_MODEL_PDA,
OPTION_QUALITY_CHEMICAL,
get_option,
set_option,
)
from app.native.wndb.model.patterns import add_pattern, get_pattern
from app.native.wndb.model.pipes import get_pipe
from app.native.wndb.model.sources import (
SOURCE_TYPE_SETPOINT,
add_source,
get_source,
set_source,
)
from app.native.wndb.model.times import get_time, set_time
from app.domain.time import parse_clock_duration_seconds
def _isolated_analysis(purpose: str):
def decorator(func):
@wraps(func)
def wrapper(name: str, *args, **kwargs):
with temporary_project_database(name, purpose) as temporary:
kwargs["_temporary_project"] = temporary
return func(name, *args, **kwargs)
return wrapper
return decorator
############################################################
# burst analysis 01
############################################################
def convert_to_local_unit(proj: str, emitters: float) -> float:
proj_opt = get_option(proj)
str_unit = proj_opt.get("UNITS")
if str_unit == "CMH":
return emitters * 3.6
elif str_unit == "LPS":
return emitters
elif str_unit == "CMS":
return emitters / 1000.0
elif str_unit == "MGD":
return emitters * 0.0438126
# Unknown unit: log and return original value
print(str_unit)
return emitters
@_isolated_analysis("burst_analysis")
def burst_analysis(
name: str,
modify_pattern_start_time: str,
burst_ID: list | str = None,
burst_size: list | float | int = None,
modify_total_duration: int = 900,
modify_fixed_pump_pattern: dict[str, list] = None,
modify_variable_pump_pattern: dict[str, list] = None,
modify_valve_opening: dict[str, float] = None,
scheme_name: str = None,
username: str | None = None,
_temporary_project: str | None = None,
) -> None:
"""
爆管模拟
:param name: 模型名称,数据库中对应的名字
:param modify_pattern_start_time: 模拟开始时间,格式为'2024-11-25T09:00:00+08:00'
:param burst_ID: 爆管管道的ID,选取的是管道,单独传入一个爆管管道,可以是str或list,传入多个爆管管道是用list
:param burst_size: 爆管管道破裂的孔口面积,和burst_ID列表各位置的ID对应,以cm*cm计算
:param modify_total_duration: 模拟总历时,秒
:param modify_fixed_pump_pattern: dict中包含多个水泵模式,str为工频水泵的id,list为修改后的pattern
:param modify_variable_pump_pattern: dict中包含多个水泵模式,str为变频水泵的id,list为修改后的pattern
:param modify_valve_opening: dict中包含多个阀门开启度,str为阀门的id,float为修改后的阀门开启度
:param scheme_name: 方案名称
:return:
"""
if not username:
raise ValueError("username is required when storing burst analysis scheme")
scheme_detail: dict = {
"burst_ID": burst_ID,
"burst_size": burst_size,
"modify_total_duration": modify_total_duration,
"modify_fixed_pump_pattern": modify_fixed_pump_pattern,
"modify_variable_pump_pattern": modify_variable_pump_pattern,
"modify_valve_opening": modify_valve_opening,
}
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Analysis."
)
if _temporary_project is None:
raise RuntimeError("Burst analysis isolation was not prepared")
new_name = _temporary_project
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Copying Database."
)
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Opening Database."
)
simulation.run_simulation(
name=new_name,
simulation_type="manually_temporary",
modify_pattern_start_time=modify_pattern_start_time,
)
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Database Loading OK."
)
##step 1 set the emitter coefficient of end node of busrt pipe
if isinstance(burst_ID, list):
if (burst_size is not None) and (type(burst_size) is not list):
return json.dumps("Type mismatch.")
# 转化为列表形式
elif isinstance(burst_ID, str):
burst_ID = [burst_ID]
if burst_size is not None:
if isinstance(burst_size, float) or isinstance(burst_size, int):
burst_size = [burst_size]
else:
return json.dumps("Type mismatch.")
else:
return json.dumps("Type mismatch.")
if burst_size is None:
burst_size = [-1] * len(burst_ID)
elif len(burst_size) < len(burst_ID):
burst_size += [-1] * (len(burst_ID) - len(burst_size))
elif len(burst_size) > len(burst_ID):
# burst_size = burst_size[:len(burst_ID)]
return json.dumps("Length mismatch.")
for burst_ID_, burst_size_ in zip(burst_ID, burst_size):
pipe = get_pipe(new_name, burst_ID_)
str_start_node = pipe["node1"]
str_end_node = pipe["node2"]
d_pipe = pipe["diameter"] / 1000.0
if burst_size_ <= 0:
burst_size_ = 3.14 * d_pipe * d_pipe / 4 / 8
else:
burst_size_ = burst_size_ / 10000
emitter_coeff = (
0.65 * burst_size_ * sqrt(19.6) * 1000
) # 1/8开口面积作为coeff,单位 L/S
emitter_coeff = convert_to_local_unit(new_name, emitter_coeff)
emitter_node = ""
if is_junction(new_name, str_end_node):
emitter_node = str_end_node
elif is_junction(new_name, str_start_node):
emitter_node = str_start_node
old_emitter = get_emitter(new_name, emitter_node)
if old_emitter != None:
old_emitter["coefficient"] = emitter_coeff # 爆管的emitter coefficient设置
else:
old_emitter = {"junction": emitter_node, "coefficient": emitter_coeff}
new_emitter = ChangeSet()
new_emitter.append(old_emitter)
set_emitter(new_name, new_emitter)
# step 2. run simulation
# 涉及关阀计算,可能导致关阀后仍有流量,改为压力驱动PDA
options = get_option(new_name)
options["DEMAND MODEL"] = OPTION_DEMAND_MODEL_PDA
options["REQUIRED PRESSURE"] = "10.0000"
cs_options = ChangeSet()
cs_options.append(options)
set_option(new_name, cs_options)
# valve_control = None
# if modify_valve_opening is not None:
# valve_control = {}
# for valve in modify_valve_opening:
# valve_control[valve] = {'status': 'CLOSED'}
# result = run_simulation_ex(new_name,'realtime', modify_pattern_start_time,
# end_datetime=modify_pattern_start_time,
# modify_total_duration=modify_total_duration,
# modify_pump_pattern=modify_pump_pattern,
# valve_control=valve_control,
# downloading_prohibition=True)
simulation.run_simulation(
name=new_name,
simulation_type="extended",
modify_pattern_start_time=modify_pattern_start_time,
modify_total_duration=modify_total_duration,
modify_fixed_pump_pattern=modify_fixed_pump_pattern,
modify_variable_pump_pattern=modify_variable_pump_pattern,
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) #有疑惑
############################################################
# valve closing analysis 02
############################################################
@_isolated_analysis("valve_close_analysis")
def valve_close_analysis(
name: str,
modify_pattern_start_time: str,
modify_total_duration: int = 900,
modify_valve_opening: dict[str, float] = None,
scheme_name: str = None,
_temporary_project: str | None = None,
) -> None:
"""
关阀模拟
:param name: 模型名称,数据库中对应的名字
:param modify_pattern_start_time: 模拟开始时间,格式为'2024-11-25T09:00:00+08:00'
:param modify_total_duration: 模拟总历时,秒
:param modify_valve_opening: dict中包含多个阀门开启度,str为阀门的id,float为修改后的阀门开启度
:param scheme_name: 方案名称
:return:
"""
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Analysis."
)
if _temporary_project is None:
raise RuntimeError("Valve-close analysis isolation was not prepared")
new_name = _temporary_project
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Copying Database."
)
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Opening Database."
)
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Database Loading OK."
)
# step 1. change the valves status to 'closed'
# for valve in valves:
# if not is_valve(new_name,valve):
# result='ID:{}is not a valve type'.format(valve)
# return result
# cs=ChangeSet()
# status=get_status(new_name,valve)
# status['status']='CLOSED'
# cs.append(status)
# set_status(new_name,cs)
# step 2. run simulation
# 涉及关阀计算,可能导致关阀后仍有流量,改为压力驱动PDA
options = get_option(new_name)
options["DEMAND MODEL"] = OPTION_DEMAND_MODEL_PDA
options["REQUIRED PRESSURE"] = "20.0000"
cs_options = ChangeSet()
cs_options.append(options)
set_option(new_name, cs_options)
# result = run_simulation_ex(new_name,'realtime', modify_pattern_start_time, modify_pattern_start_time, modify_total_duration,
# downloading_prohibition=True)
simulation.run_simulation(
name=new_name,
simulation_type="extended",
modify_pattern_start_time=modify_pattern_start_time,
modify_total_duration=modify_total_duration,
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:
# execute_undo(name)
# return result
############################################################
# flushing analysis 03
# Pipe_Flushing_Analysis(prj_name,date_time, Valve_id_list, Drainage_Node_Id, Flushing_flow[opt], Flushing_duration[opt])->out_file:string
############################################################
@_isolated_analysis("flushing_analysis")
def flushing_analysis(
name: str,
modify_pattern_start_time: str,
modify_total_duration: int = 900,
modify_valve_opening: dict[str, float] = None,
drainage_node_ID: str = None,
flushing_flow: float = 0,
scheme_name: str = None,
username: str | None = None,
valve_control: dict[str, dict] = None,
_temporary_project: str | None = None,
) -> None:
"""
管道冲洗模拟
:param name: 模型名称,数据库中对应的名字
:param modify_pattern_start_time: 模拟开始时间,格式为'2024-11-25T09:00:00+08:00'
:param modify_total_duration: 模拟总历时,秒
:param modify_valve_opening: dict中包含多个阀门开启度,str为阀门的id,float为修改后的阀门开启度
:param valve_control: dict中可分别指定阀门的status、setting和k
:param drainage_node_ID: 冲洗排放口所在节点ID
:param flushing_flow: 冲洗水量,传入参数单位为m3/h
:param scheme_name: 方案名称
:return:
"""
if not username:
raise ValueError("username is required when storing flushing analysis scheme")
scheme_detail: dict = {
"duration": modify_total_duration,
"valve_opening": modify_valve_opening,
"valve_control": valve_control,
"drainage_node_ID": drainage_node_ID,
"flushing_flow": flushing_flow,
}
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Analysis."
)
if _temporary_project is None:
raise RuntimeError("Flushing analysis isolation was not prepared")
new_name = _temporary_project
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Copying Database."
)
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Opening Database."
)
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Database Loading OK."
)
if not is_junction(new_name, drainage_node_ID):
return "Wrong Drainage node type"
# step 1. change the valves status to 'closed'
# for valve, valve_k in zip(valves, valves_k):
# cs=ChangeSet()
# status=get_status(new_name,valve)
# # status['status']='CLOSED'
# if valve_k == 0:
# status['status'] = 'CLOSED'
# elif valve_k < 1:
# status['status'] = 'OPEN'
# status['setting'] = 0.1036 * pow(valve_k, -3.105)
# cs.append(status)
# set_status(new_name,cs)
options = get_option(new_name)
units = options["UNITS"]
# step 2. set the emitter coefficient of drainage node or add flush flow to the drainage node
# 新建 pattern
time_option = get_time(new_name)
hydraulic_step = time_option["HYDRAULIC TIMESTEP"]
secs = parse_clock_duration_seconds(hydraulic_step)
cs_pattern = ChangeSet()
pt = {}
factors = []
tmp_duration = modify_total_duration
while tmp_duration > 0:
factors.append(1.0)
tmp_duration = tmp_duration - secs
pt["id"] = "flushing_pt"
pt["factors"] = factors
cs_pattern.append(pt)
add_pattern(new_name, cs_pattern)
# 为 emitter_demand 添加新的 pattern
emitter_demand = get_demand(new_name, drainage_node_ID)
cs = ChangeSet()
if flushing_flow > 0:
if units == "LPS":
emitter_demand["demands"].append(
{
"demand": flushing_flow / 3.6,
"pattern": "flushing_pt",
"category": None,
}
)
elif units == "CMH":
emitter_demand["demands"].append(
{"demand": flushing_flow, "pattern": "flushing_pt", "category": None}
)
cs.append(emitter_demand)
set_demand(new_name, cs)
else:
pipes = get_node_links(new_name, drainage_node_ID)
flush_diameter = 50
for pipe in pipes:
d = get_pipe(new_name, pipe)["diameter"]
if flush_diameter < d:
flush_diameter = d
flush_diameter /= 1000
emitter_coeff = (
0.65 * 3.14 * (flush_diameter * flush_diameter / 4) * sqrt(19.6) * 1000
) # 全开口面积作为coeff
old_emitter = get_emitter(new_name, drainage_node_ID)
if old_emitter != None:
old_emitter["coefficient"] = emitter_coeff # 爆管的emitter coefficient设置
else:
old_emitter = {"junction": drainage_node_ID, "coefficient": emitter_coeff}
new_emitter = ChangeSet()
new_emitter.append(old_emitter)
set_emitter(new_name, new_emitter)
# step 3. run simulation
# 涉及关阀计算,可能导致关阀后仍有流量,改为压力驱动PDA
options = get_option(new_name)
options["DEMAND MODEL"] = OPTION_DEMAND_MODEL_PDA
options["REQUIRED PRESSURE"] = "20.0000"
cs_options = ChangeSet()
cs_options.append(options)
set_option(new_name, cs_options)
# result = run_simulation_ex(new_name,'realtime', modify_pattern_start_time, modify_pattern_start_time, modify_total_duration,
# downloading_prohibition=True)
simulation.run_simulation(
name=new_name,
simulation_type="extended",
modify_pattern_start_time=modify_pattern_start_time,
modify_total_duration=modify_total_duration,
modify_valve_opening=modify_valve_opening,
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
# return result
############################################################
# Contaminant simulation 04
#
############################################################
@_isolated_analysis("contaminant_simulation")
def contaminant_simulation(
name: str,
modify_pattern_start_time: str, # 模拟开始时间,格式为'2024-11-25T09:00:00+08:00'
modify_total_duration: int, # 模拟总历时,秒
source: str, # 污染源节点ID
concentration: float, # 污染源浓度,单位mg/L
scheme_name: str = None,
source_pattern: str = None, # 污染源时间变化模式名称
username: str | None = None,
_temporary_project: str | None = None,
) -> None:
"""
污染模拟
:param name: 模型名称,数据库中对应的名字
:param modify_pattern_start_time: 模拟开始时间,格式为'2024-11-25T09:00:00+08:00'
:param modify_total_duration: 模拟总历时,秒
:param source: 污染源所在的节点ID
:param concentration: 污染源位置处的浓度,单位mg/L。默认的污染模拟setting为SOURCE_TYPE_CONCEN(改为SOURCE_TYPE_SETPOINT
:param source_pattern: 污染源的时间变化模式,若不传入则默认以恒定浓度持续模拟,时间长度等于duration;
若传入,则格式为{1.0,0.5,1.1}等系数列表pattern_step模拟等于模型的hydraulic time step
:param scheme_name: 方案名称
:return:
"""
if not username:
raise ValueError("username is required when storing contaminant analysis scheme")
scheme_detail: dict = {
"source": source,
"concentration": concentration,
"duration": modify_total_duration,
"pattern": source_pattern,
}
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Analysis."
)
if _temporary_project is None:
raise RuntimeError("Contaminant simulation isolation was not prepared")
new_name = _temporary_project
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Copying Database."
)
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Opening Database."
)
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Database Loading OK."
)
dic_time = get_time(new_name)
dic_time["QUALITY TIMESTEP"] = "0:05:00"
cs = ChangeSet()
cs.operations.append(dic_time)
set_time(new_name, cs) # set QUALITY TIMESTEP
time_option = get_time(new_name)
hydraulic_step = time_option["HYDRAULIC TIMESTEP"]
secs = parse_clock_duration_seconds(hydraulic_step)
operation_step = 0
# step 1. set duration
if modify_total_duration == None:
modify_total_duration = secs
# step 2. set pattern
if source_pattern != None:
pt = get_pattern(new_name, source_pattern)
if len(pt) == 0:
str_response = str("cant find source_pattern")
return str_response
else:
cs_pattern = ChangeSet()
pt = {}
factors = []
tmp_duration = modify_total_duration
while tmp_duration > 0:
factors.append(1.0)
tmp_duration = tmp_duration - secs
pt["id"] = "contam_pt"
pt["factors"] = factors
cs_pattern.append(pt)
add_pattern(new_name, cs_pattern)
operation_step += 1
# step 3. set source/initial quality
# source quality
cs_source = ChangeSet()
source_schema = {
"node": source,
"s_type": SOURCE_TYPE_SETPOINT,
"strength": concentration,
"pattern": pt["id"],
}
cs_source.append(source_schema)
source_node = get_source(new_name, source)
if len(source_node) == 0:
add_source(new_name, cs_source)
else:
set_source(new_name, cs_source)
dict_demand = get_demand(new_name, source)
for demands in dict_demand["demands"]:
dict_demand["demands"][dict_demand["demands"].index(demands)]["demand"] = -1
dict_demand["demands"][dict_demand["demands"].index(demands)]["pattern"] = None
cs = ChangeSet()
cs.append(dict_demand)
set_demand(new_name, cs) # set inflow node
# # initial quality
# dict_quality = get_quality(new_name, source)
# dict_quality['quality'] = concentration
# cs = ChangeSet()
# cs.append(dict_quality)
# set_quality(new_name, cs)
operation_step += 1
# step 4 set option of quality to chemical
opt = get_option(new_name)
opt["QUALITY"] = OPTION_QUALITY_CHEMICAL
cs_option = ChangeSet()
cs_option.append(opt)
set_option(new_name, cs_option)
operation_step += 1
# step 5. run simulation
# result = run_simulation_ex(new_name,'realtime', modify_pattern_start_time, modify_pattern_start_time, modify_total_duration,
# downloading_prohibition=True)
simulation.run_simulation(
name=new_name,
simulation_type="extended",
modify_pattern_start_time=modify_pattern_start_time,
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):
# execute_undo(name)
############################################################
# pressure regulation 06
############################################################
@_isolated_analysis("pressure_regulation")
def pressure_regulation(
name: str,
modify_pattern_start_time: str,
modify_total_duration: int = 900,
modify_tank_initial_level: dict[str, float] = None,
modify_fixed_pump_pattern: dict[str, list] = None,
modify_variable_pump_pattern: dict[str, list] = None,
scheme_name: str = None,
scada_mappings: simulation.ScadaElementMappings | None = None,
_temporary_project: str | None = None,
) -> None:
"""
区域调压模拟,用来模拟未来15分钟内,开关水泵对区域压力的影响
:param name: 模型名称,数据库中对应的名字
:param modify_pattern_start_time: 模拟开始时间,格式为'2024-11-25T09:00:00+08:00'
:param modify_total_duration: 模拟总历时,秒
:param modify_tank_initial_level: dict中包含多个水塔,str为水塔的idfloat为修改后的initial_level
:param modify_fixed_pump_pattern: dict中包含多个水泵模式,str为工频水泵的id,list为修改后的pattern
:param modify_variable_pump_pattern: dict中包含多个水泵模式,str为变频水泵的id,list为修改后的pattern
:param scheme_name: 模拟方案名称
:return:
"""
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Analysis."
)
if _temporary_project is None:
raise RuntimeError("Pressure-regulation isolation was not prepared")
new_name = _temporary_project
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Copying Database."
)
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Start Opening Database."
)
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
+ " -- Database Loading OK."
)
# 全部关泵后,压力计算不合理,改为压力驱动PDA
options = get_option(new_name)
options["DEMAND MODEL"] = OPTION_DEMAND_MODEL_PDA
options["REQUIRED PRESSURE"] = "15.0000"
cs_options = ChangeSet()
cs_options.append(options)
set_option(new_name, cs_options)
# result = run_simulation_ex(name=new_name,
# simulation_type='realtime',
# start_datetime=start_datetime,
# duration=900,
# pump_control=pump_control,
# tank_initial_level_control=tank_initial_level_control,
# downloading_prohibition=True)
simulation.run_simulation(
name=new_name,
simulation_type="extended",
modify_pattern_start_time=modify_pattern_start_time,
modify_total_duration=modify_total_duration,
modify_tank_initial_level=modify_tank_initial_level,
modify_fixed_pump_pattern=modify_fixed_pump_pattern,
modify_variable_pump_pattern=modify_variable_pump_pattern,
scheme_type="pressure_regulation",
scheme_name=scheme_name,
result_db_name=name,
scada_mappings=scada_mappings,
)
# return result
-189
View File
@@ -1,189 +0,0 @@
from datetime import date, datetime, time, timedelta, timezone
from dateutil import parser, tz
'''
2025-02-09T15:45:00+00:00 采用的是 ISO 8601 国际标准日期时间格式,具体特点如下:
日期部分:YYYY-MM-DD(年-月-日),例如2025-02-09表示2025年2月9日。
时间部分:HH:mm:ss(时:分:秒),例如15:45:00表示下午3点45分0秒。
分隔符:日期与时间之间用字母T连接,表明这是一个完整的时间点。
时区偏移:末尾的+00:00表示该时间基于协调世界时(UTC),即零时区。若使用Z替代+00:00(如2025-02-09T15:45:00Z),也符合ISO 8601标准,两者等价
北京时间格式
2025-02-09T15:45:00+08:00
'''
BG_TZ = tz.gettz("Asia/Shanghai")
UTC_TZ = timezone.utc
TIMEZONE_REQUIRED_MESSAGE = (
"Datetime values must include an explicit timezone offset, for example "
"'2025-02-09T15:45:00Z' or '2025-02-09T23:45:00+08:00'."
)
def parse_aware_time(query_time: datetime | str, field_name: str = "datetime") -> datetime:
"""
解析时间并确保结果带有时区信息。
"""
dt = parser.parse(query_time) if isinstance(query_time, str) else query_time
if dt.tzinfo is None:
raise ValueError(f"{field_name} is missing timezone information. {TIMEZONE_REQUIRED_MESSAGE}")
return dt
def extract_date(value: date | datetime | str, field_name: str = "date") -> date:
"""
提取日期部分,但保留调用方原始时区语义,不强制转换到 UTC。
"""
if isinstance(value, date) and not isinstance(value, datetime):
return value
return parse_aware_time(value, field_name=field_name).date()
def utc_now() -> datetime:
"""
返回带 UTC 时区的当前时间。
"""
return datetime.now(UTC_TZ)
def parse_utc_time(query_time: datetime | str, field_name: str = "datetime") -> datetime:
'''
接受带时区的时间字符串/对象,并统一转换成 UTC 时间。
'''
return parse_aware_time(query_time, field_name=field_name).astimezone(UTC_TZ)
def parse_beijing_time(query_time: datetime | str, field_name: str = "datetime") -> datetime:
'''
接受带时区的时间字符串/对象,并统一转换成北京时间。
'''
return parse_aware_time(query_time, field_name=field_name).astimezone(tz=BG_TZ)
def to_utc_time(dt: datetime | str, field_name: str = "datetime") -> datetime:
'''
将一个带时区的时间点,转换成 UTC。
'''
return parse_aware_time(dt, field_name=field_name).astimezone(UTC_TZ)
def to_beijing_time(dt: datetime | str, field_name: str = "datetime") -> datetime:
'''
将一个带时区的时间点,转换成北京时间。
'''
return parse_aware_time(dt, field_name=field_name).astimezone(tz=BG_TZ)
def to_time_range(dt: datetime, delta: float) -> tuple[datetime, datetime]:
'''
将一个时间点,转换成 start/end 时间段
有些查询按照一个时间点查不到,用时间段保证能成功
delta 单位是秒
'''
start_time = dt - timedelta(seconds=delta)
end_time = dt + timedelta(seconds=delta)
return (start_time, end_time)
def parse_clock_duration_seconds(clock: str, field_name: str = "duration") -> int:
"""
Parse EPANET-style clock durations into seconds.
Accepted formats include H:MM, HH:MM, H:MM:SS, and HH:MM:SS.
"""
if not isinstance(clock, str):
raise ValueError(f"{field_name} must be a string clock duration.")
parts = clock.strip().split(":")
if len(parts) not in (2, 3):
raise ValueError(
f"{field_name} must use H:MM or H:MM:SS format, got {clock!r}."
)
try:
values = [int(part) for part in parts]
except ValueError as exc:
raise ValueError(
f"{field_name} must contain numeric clock parts, got {clock!r}."
) from exc
if any(value < 0 for value in values):
raise ValueError(f"{field_name} must not contain negative values.")
hours, minutes = values[0], values[1]
seconds = values[2] if len(values) == 3 else 0
if minutes >= 60 or seconds >= 60:
raise ValueError(
f"{field_name} minutes and seconds must be less than 60, got {clock!r}."
)
return hours * 3600 + minutes * 60 + seconds
def parse_beijing_date_range(query_date: str) -> tuple[datetime, datetime]:
'''
将一个日期字符串,转换成 start/end 时间段,传进来的日期被认为是北京时间
日期字符串格式:YYYY-MM-DD
'''
target_date = date.fromisoformat(query_date)
start_time = datetime.combine(target_date, time.min, BG_TZ)
end_time = start_time + timedelta(days=1)
return (start_time, end_time)
def get_day_start(dt: datetime.date) -> datetime:
'''
获取 某一天的 00:00:00
这一天可以是北京时间,也可以是 utc 时间
'''
return dt.replace(hour=0, minute=0, second=0, microsecond=0)
def get_day_end(dt: datetime.date) -> datetime:
'''
获取 某一天的 23:59:59
这一天可以是北京时间,也可以是 utc 时间
'''
return dt.replace(hour=23, minute=59, second=59, microsecond=0)
def get_date_from_time(time: str) -> str:
'''
将一个时间点,转换成日期
'''
dt = parse_beijing_time(time, field_name="time")
return str(dt.date())
def is_today(query_date: str) -> bool:
'''
判断一个日期是否是今天
'''
dt = parse_beijing_time(query_date, field_name="query_date")
return dt.date() == datetime.now(BG_TZ).date()
def is_yesterday(query_date: str) -> bool:
'''
判断一个日期是否是昨天
'''
dt = parse_beijing_time(query_date, field_name="query_date")
return dt.date() == (datetime.now(BG_TZ).date() - timedelta(days=1))
def is_tomorrow(query_date: str) -> bool:
'''
判断一个日期是否是明天
'''
dt = parse_beijing_time(query_date, field_name="query_date")
return dt.date() == (datetime.now(BG_TZ).date() + timedelta(days=1))
def is_today_or_future(query_date: str) -> bool:
'''
判断一个日期是否是今天或未来
'''
dt = parse_beijing_time(query_date, field_name="query_date")
return dt.date() >= datetime.now(BG_TZ).date()
+614
View File
@@ -0,0 +1,614 @@
import time
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
import pandas as pd
from psycopg import AsyncConnection
from uuid import UUID
from app.algorithms.pipe_health_prediction.survival_predictor import (
PipeHealthSurvivalPredictor,
)
from app.algorithms.scada_cleaning.flow_series import clean_flow_data_df_kf
from app.algorithms.scada_cleaning.pressure_series import clean_pressure_data_df_km
from app.infra.db.postgresql.network_assets import NetworkAssetRepository
from app.infra.db.postgresql.scada import ScadaInfoRepository
from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository
from app.infra.db.timescaledb.repositories.analysis import AnalysisResultsRepository
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
class TimeseriesAnalysisService:
"""
复合查询类,提供跨表查询功能
"""
@staticmethod
async def _get_project_scada_index(
postgres_conn: AsyncConnection,
) -> Dict[str, Dict[str, Any]]:
scadas = await ScadaInfoRepository.get_scadas(postgres_conn)
return {scada["device_id"]: scada for scada in scadas}
@staticmethod
async def get_scada_associated_realtime_simulation_data(
timescale_conn: AsyncConnection,
postgres_conn: AsyncConnection,
device_ids: List[str],
start_time: datetime,
end_time: datetime,
) -> Dict[str, List[Dict[str, Any]]]:
"""
获取 SCADA 关联的 link/node 模拟值
根据传入的 SCADA device_ids,找到关联的 link/node
并根据对应的 type,查询对应的模拟数据
Args:
timescale_conn: TimescaleDB 异步连接
postgres_conn: PostgreSQL 异步连接
device_ids: SCADA 设备ID列表
start_time: 开始时间
end_time: 结束时间
Returns:
模拟数据字典,以 device_id 为键,值为数据列表,每个数据包含 time, value 和 scada_id
Raises:
ValueError: 当 SCADA 设备未找到或字段无效时
"""
scada_by_id = await TimeseriesAnalysisService._get_project_scada_index(postgres_conn)
link_devices: dict[str, str] = {}
node_devices: dict[str, str] = {}
for device_id in device_ids:
target_scada = scada_by_id.get(device_id)
if not target_scada:
raise ValueError(f"SCADA device {device_id} not found")
scada_type = target_scada["device_type"]
if scada_type in {"pipe_flow", "flow"}:
link_devices[device_id] = target_scada["link_id"]
elif scada_type == "pressure":
node_devices[device_id] = target_scada["node_id"]
else:
raise ValueError(f"Unknown SCADA type: {scada_type}")
link_series = await RealtimeRepository.get_link_fields_by_ids_time_range(
timescale_conn, start_time, end_time,
list(dict.fromkeys(link_devices.values())), "flow",
)
node_series = await RealtimeRepository.get_node_fields_by_ids_time_range(
timescale_conn, start_time, end_time,
list(dict.fromkeys(node_devices.values())), "pressure",
)
return {
device_id: [
{**item, "scada_id": device_id}
for item in (
link_series.get(element_id, [])
if device_id in link_devices
else node_series.get(element_id, [])
)
]
for device_id, element_id in (link_devices | node_devices).items()
}
@staticmethod
async def get_scada_associated_analysis_simulation_data(
timescale_conn: AsyncConnection,
postgres_conn: AsyncConnection,
device_ids: List[str],
start_time: datetime,
end_time: datetime,
run_id: UUID,
) -> Dict[str, List[Dict[str, Any]]]:
"""
获取 SCADA 关联的 link/node 分析模拟值
根据传入的 SCADA device_ids,找到关联的 link/node
并根据对应的 type,查询对应的模拟数据
Args:
timescale_conn: TimescaleDB 异步连接
postgres_conn: PostgreSQL 异步连接
device_ids: SCADA 设备ID列表
start_time: 开始时间
end_time: 结束时间
Returns:
模拟数据字典,以 device_id 为键,值为数据列表,每个数据包含 time, value 和 scada_id
Raises:
ValueError: 当 SCADA 设备未找到或字段无效时
"""
scada_by_id = await TimeseriesAnalysisService._get_project_scada_index(postgres_conn)
link_devices: dict[str, str] = {}
node_devices: dict[str, str] = {}
for device_id in device_ids:
target_scada = scada_by_id.get(device_id)
if not target_scada:
raise ValueError(f"SCADA device {device_id} not found")
scada_type = target_scada["device_type"]
if scada_type in {"pipe_flow", "flow"}:
link_devices[device_id] = target_scada["link_id"]
elif scada_type == "pressure":
node_devices[device_id] = target_scada["node_id"]
else:
raise ValueError(f"Unknown SCADA type: {scada_type}")
link_series = await AnalysisResultsRepository.get_series_by_ids(
timescale_conn, run_id, "link",
list(dict.fromkeys(link_devices.values())), start_time, end_time, "flow",
)
node_series = await AnalysisResultsRepository.get_series_by_ids(
timescale_conn, run_id, "node",
list(dict.fromkeys(node_devices.values())), start_time, end_time, "pressure",
)
return {
device_id: [
{**item, "scada_id": device_id}
for item in (
link_series.get(element_id, [])
if device_id in link_devices
else node_series.get(element_id, [])
)
]
for device_id, element_id in (link_devices | node_devices).items()
}
@staticmethod
async def get_realtime_simulation_data(
timescale_conn: AsyncConnection,
feature_infos: List[Tuple[str, str]],
start_time: datetime,
end_time: datetime,
) -> Dict[str, List[Dict[str, Any]]]:
"""
获取 link/node 模拟值
根据传入的 feature_infos,找到关联的 link/node
并根据对应的 type,查询对应的模拟数据
Args:
timescale_conn: TimescaleDB 异步连接
feature_infos: 传入的 feature 信息列表,包含 (element_id, type)
start_time: 开始时间
end_time: 结束时间
Returns:
模拟数据字典,以 feature_id 为键,值为数据列表,每个数据包含 time, value 和 feature_id
Raises:
ValueError: 当 SCADA 设备未找到或字段无效时
"""
pipe_ids: list[str] = []
junction_ids: list[str] = []
for feature_id, feature_type in feature_infos:
if feature_type.lower() == "pipe":
pipe_ids.append(feature_id)
elif feature_type.lower() == "junction":
junction_ids.append(feature_id)
else:
raise ValueError(f"Unknown type: {feature_type}")
link_series = await RealtimeRepository.get_link_fields_by_ids_time_range(
timescale_conn, start_time, end_time, list(dict.fromkeys(pipe_ids)), "flow"
)
node_series = await RealtimeRepository.get_node_fields_by_ids_time_range(
timescale_conn, start_time, end_time,
list(dict.fromkeys(junction_ids)), "pressure",
)
return {
feature_id: [
{**item, "feature_id": feature_id}
for item in (
link_series.get(feature_id, [])
if feature_type.lower() == "pipe"
else node_series.get(feature_id, [])
)
]
for feature_id, feature_type in feature_infos
}
@staticmethod
async def get_analysis_simulation_data(
timescale_conn: AsyncConnection,
feature_infos: List[Tuple[str, str]],
start_time: datetime,
end_time: datetime,
run_id: UUID,
) -> Dict[str, List[Dict[str, Any]]]:
"""
获取 link/node 分析模拟值
根据传入的 feature_infos,找到关联的 link/node
并根据对应的 type,查询对应的模拟数据
Args:
timescale_conn: TimescaleDB 异步连接
feature_infos: 传入的 feature 信息列表,包含 (element_id, type)
start_time: 开始时间
end_time: 结束时间
run_id: 分析运行 ID
Returns:
模拟数据字典,以 feature_id 为键,值为数据列表,每个数据包含 time, value 和 feature_id
Raises:
ValueError: 当类型无效时
"""
pipe_ids: list[str] = []
junction_ids: list[str] = []
for feature_id, feature_type in feature_infos:
if feature_type.lower() == "pipe":
pipe_ids.append(feature_id)
elif feature_type.lower() == "junction":
junction_ids.append(feature_id)
else:
raise ValueError(f"Unknown type: {feature_type}")
link_series = await AnalysisResultsRepository.get_series_by_ids(
timescale_conn, run_id, "link", list(dict.fromkeys(pipe_ids)),
start_time, end_time, "flow",
)
node_series = await AnalysisResultsRepository.get_series_by_ids(
timescale_conn, run_id, "node", list(dict.fromkeys(junction_ids)),
start_time, end_time, "pressure",
)
return {
feature_id: [
{**item, "feature_id": feature_id}
for item in (
link_series.get(feature_id, [])
if feature_type.lower() == "pipe"
else node_series.get(feature_id, [])
)
]
for feature_id, feature_type in feature_infos
}
@staticmethod
async def get_element_associated_scada_data(
timescale_conn: AsyncConnection,
postgres_conn: AsyncConnection,
element_id: str,
start_time: datetime,
end_time: datetime,
use_cleaned: bool = False,
) -> Optional[Any]:
"""
获取 link/node 关联的 SCADA 监测值
根据传入的 link/node id,匹配 SCADA 信息,
如果存在关联的 SCADA device_id,获取实际的监测数据
Args:
timescale_conn: TimescaleDB 异步连接
postgres_conn: PostgreSQL 异步连接
element_id: link 或 node 的 ID
start_time: 开始时间
end_time: 结束时间
use_cleaned: 是否使用清洗后的数据 (True: "cleaned_value", False: "monitored_value")
Returns:
SCADA 监测数据值,如果没有找到则返回 None
Raises:
ValueError: 当元素类型无效时
"""
scada_by_id = await TimeseriesAnalysisService._get_project_scada_index(postgres_conn)
associated_scada = next(
(
scada
for scada in scada_by_id.values()
if (scada.get("node_id") or scada.get("link_id")) == element_id
),
None,
)
if not associated_scada:
return None
device_id = associated_scada["device_id"]
data_field = "cleaned_value" if use_cleaned else "monitored_value"
res = await ScadaRepository.get_scada_field_by_id_time_range(
timescale_conn, [device_id], start_time, end_time, data_field
)
return {element_id: res.get(device_id, [])}
@staticmethod
async def clean_scada_data(
timescale_conn: AsyncConnection,
postgres_conn: AsyncConnection,
device_ids: List[str],
start_time: datetime,
end_time: datetime,
) -> str:
"""
清洗 SCADA 数据
根据 device_ids 查询 monitored_value,清洗后更新 cleaned_value
Args:
timescale_conn: TimescaleDB 连接
postgres_conn: PostgreSQL 连接
device_ids: 设备 ID 列表
start_time: 开始时间
end_time: 结束时间
Returns:
"success"
Raises:
ValueError: 当前项目没有可清洗设备或指定时间范围内没有监测数据
"""
scada_by_id = await TimeseriesAnalysisService._get_project_scada_index(postgres_conn)
supported_types = {"pressure", "pipe_flow", "flow"}
if device_ids:
device_ids = [str(device_id).strip() for device_id in device_ids]
missing_metadata_ids = [
device_id
for device_id in device_ids
if device_id not in scada_by_id
]
if missing_metadata_ids:
raise ValueError(
f"当前项目中有 {len(missing_metadata_ids)} 个 SCADA 设备缺少元数据"
)
unsupported_ids = [
device_id
for device_id in device_ids
if scada_by_id[device_id]["device_type"] not in supported_types
]
if unsupported_ids:
raise ValueError(
f"当前项目中有 {len(unsupported_ids)} 个 SCADA 设备类型不支持清洗"
)
else:
device_ids = [
device_id
for device_id, info in scada_by_id.items()
if info["device_type"] in supported_types
]
if not device_ids:
raise ValueError("当前项目没有可清洗的 SCADA 设备")
data = await ScadaRepository.get_scada_field_by_id_time_range(
timescale_conn, device_ids, start_time, end_time, "monitored_value"
)
if not data:
raise ValueError("指定时间范围内没有 SCADA 监测数据")
normalized_data = {
str(device_id): records for device_id, records in data.items()
}
missing_data_ids = [
device_id for device_id in device_ids if not normalized_data.get(device_id)
]
if missing_data_ids:
raise ValueError(
f"指定时间范围内有 {len(missing_data_ids)} 个 SCADA 设备没有监测数据"
)
all_records = [
{
"time": record["time"],
"device_id": device_id,
"value": record["value"],
}
for device_id, records in normalized_data.items()
for record in records
]
if not all_records:
raise ValueError("指定时间范围内没有 SCADA 监测数据")
df_long = pd.DataFrame(all_records)
df = df_long.pivot(index="time", columns="device_id", values="value")
pressure_ids = [
device_id
for device_id in df.columns
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]["device_type"] in {"pipe_flow", "flow"}
]
cleaned_rows: list[tuple[datetime, str, float | None]] = []
for grouped_ids, cleaning_function in (
(pressure_ids, clean_pressure_data_df_km),
(flow_ids, clean_flow_data_df_kf),
):
if not grouped_ids:
continue
source_df = df[grouped_ids].reset_index()
cleaned_df = cleaning_function(source_df)
time_values = cleaned_df["time"].tolist()
for device_id in grouped_ids:
if device_id not in cleaned_df.columns:
raise ValueError(f"设备 {device_id} 的清洗结果缺少数据列")
cleaned_values = cleaned_df[device_id].tolist()
for time_value, value in zip(time_values, cleaned_values):
time_dt = (
time_value
if isinstance(time_value, datetime)
else datetime.fromisoformat(str(time_value))
)
cleaned_rows.append(
(
time_dt,
device_id,
None if pd.isna(value) else float(value),
)
)
if not cleaned_rows:
raise ValueError("SCADA 数据清洗未产生任何数据库更新")
expected_rows = len({(row[0], row[1]) for row in cleaned_rows})
async with timescale_conn.transaction():
updated_rows = await ScadaRepository.update_scada_field_batch(
timescale_conn,
cleaned_rows,
"cleaned_value",
)
if updated_rows != expected_rows:
raise ValueError(
"SCADA 清洗目标在写入期间发生变化,"
f"预期更新 {expected_rows} 行,实际更新 {updated_rows}"
)
return "success"
@staticmethod
async def predict_pipeline_health(
timescale_conn: AsyncConnection,
postgres_conn: AsyncConnection,
query_time: datetime,
) -> List[Dict[str, Any]]:
"""
预测管道健康状况
根据管网名称和当前时间,查询管道信息和实时数据,
使用随机生存森林模型预测管道的生存概率
Args:
timescale_conn: TimescaleDB 异步连接
query_time: 查询时间
property_conditions: 可选的管道筛选条件,如 {"diameter": 300}
Returns:
预测结果列表,每个元素包含 link_id 和对应的生存函数
Raises:
ValueError: 当参数无效或数据不足时
FileNotFoundError: 当模型文件未找到时
"""
try:
# 1. 准备时间范围(查询时间前后1秒)
start_time = query_time - timedelta(seconds=1)
end_time = query_time + timedelta(seconds=1)
# 2. 先查询流速数据(velocity),获取有数据的管道ID列表
velocity_data = await RealtimeRepository.get_links_field_by_time_range(
timescale_conn, start_time, end_time, "velocity"
)
if not velocity_data:
raise ValueError("未找到流速数据")
# 3. 只查询有流速数据的管道的基本信息
valid_link_ids = list(velocity_data.keys())
# GIS 物化视图是低频更新管网的查询面;只读取本次有结果的管道。
all_links = await NetworkAssetRepository.get_pipes_by_ids(
postgres_conn, valid_link_ids
)
# 转换为字典以快速查找
links_dict = {str(link["id"]): link for link in all_links}
# 获取所有需要查询的节点ID
node_ids = set()
for link_id in valid_link_ids:
if link_id in links_dict:
link = links_dict[link_id]
node_ids.add(link["node1"])
node_ids.add(link["node2"])
# 4. 批量查询压力数据(pressure)
pressure_data = await RealtimeRepository.get_nodes_field_by_time_range(
timescale_conn, start_time, end_time, "pressure"
)
# 5. 组合数据结构
materials = []
diameters = []
velocities = []
pressures = []
link_ids = []
for link_id in valid_link_ids:
# 跳过不在管道字典中的ID(如泵等其他元素)
if link_id not in links_dict:
continue
link = links_dict[link_id]
diameter = link["diameter"]
node1 = link["node1"]
node2 = link["node2"]
# 获取流速数据
velocity_values = velocity_data[link_id]
velocity = velocity_values[-1]["value"] if velocity_values else 0
# 获取node1和node2的压力数据,计算平均值
node1_pressure = 0
node2_pressure = 0
if node1 in pressure_data and pressure_data[node1]:
pressure_values = pressure_data[node1]
node1_pressure = (
pressure_values[-1]["value"] if pressure_values else 0
)
if node2 in pressure_data and pressure_data[node2]:
pressure_values = pressure_data[node2]
node2_pressure = (
pressure_values[-1]["value"] if pressure_values else 0
)
# 计算平均压力
avg_pressure = (node1_pressure + node2_pressure) / 2
# 添加到列表
link_ids.append(link_id)
materials.append(7) # 默认材料类型为7,可根据实际情况调整
diameters.append(diameter)
velocities.append(velocity)
pressures.append(avg_pressure)
if not link_ids:
raise ValueError("没有找到有效的管道数据用于预测")
# 6. 创建DataFrame
data = pd.DataFrame(
{
"Material": materials,
"Diameter": diameters,
"Flow Velocity": velocities,
"Pressure": pressures,
}
)
# 7. 使用生存模型进行预测
analyzer = PipeHealthSurvivalPredictor()
survival_functions = analyzer.predict_survival(data)
# 8. 组合结果
results = []
for i, link_id in enumerate(link_ids):
sf = survival_functions[i]
results.append(
{
"link_id": link_id,
"survival_function": {
"x": sf.x.tolist(), # 时间点(年)
"y": sf.y.tolist(), # 生存概率
},
}
)
return results
except Exception as e:
raise ValueError(f"管道健康预测失败: {str(e)}")
+4 -8
View File
@@ -1,14 +1,14 @@
"""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.
The native WNDB package is organized by responsibility. This transitional
facade re-exports the operations still consumed by HTTP endpoints. Application
services and algorithms depend on their explicit native/repository modules.
"""
from typing import Any
import app.infra.epanet as epanet
from app.algorithms.water_demand import (
from app.services.demand_allocation import (
calculate_demand_to_network,
calculate_demand_to_nodes,
calculate_demand_to_region,
@@ -234,10 +234,6 @@ def run_inp(name: str) -> str:
return epanet.run_inp(name)
def dump_output(path: str) -> str:
return epanet.dump_output(path)
def get_node_properties(name: str, node_id: str) -> dict[str, Any]:
if is_junction(name, node_id):
return get_junction(name, node_id)
+4 -2
View File
@@ -1,6 +1,7 @@
from typing import Any
from app.algorithms.isolation.valve import valve_isolation_analysis
from app.algorithms.valve_isolation.topology_search import valve_isolation_analysis
from app.native.wndb.gis.network_views import get_network_link_nodes
def analyze_valve_isolation(
@@ -8,4 +9,5 @@ def analyze_valve_isolation(
accident_element: str | list[str],
disabled_valves: list[str] = None,
) -> dict[str, Any]:
return valve_isolation_analysis(network, accident_element, disabled_valves)
link_entries = get_network_link_nodes(network)
return valve_isolation_analysis(link_entries, accident_element, disabled_valves)