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:
+1
-1
@@ -19,4 +19,4 @@ logs/
|
|||||||
coverage/
|
coverage/
|
||||||
*.pyc
|
*.pyc
|
||||||
*.dump
|
*.dump
|
||||||
app/algorithms/health/model/my_survival_forest_model_quxi.joblib
|
app/algorithms/pipe_health_prediction/model/my_survival_forest_model_quxi.joblib
|
||||||
|
|||||||
+1
-1
@@ -6,5 +6,5 @@ build/
|
|||||||
.env
|
.env
|
||||||
*.dump
|
*.dump
|
||||||
.vscode/
|
.vscode/
|
||||||
app/algorithms/health/model/my_survival_forest_model_quxi.joblib
|
app/algorithms/pipe_health_prediction/model/my_survival_forest_model_quxi.joblib
|
||||||
/inp/
|
/inp/
|
||||||
|
|||||||
+2
-2
@@ -22,8 +22,8 @@ COPY infra ./infra
|
|||||||
COPY resources ./resources
|
COPY resources ./resources
|
||||||
COPY scripts/check_openapi.py ./scripts/check_openapi.py
|
COPY scripts/check_openapi.py ./scripts/check_openapi.py
|
||||||
COPY tests ./tests
|
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}'" && \
|
RUN python -c "from pathlib import Path; from zipfile import ZipFile; model_dir = Path('app/algorithms/pipe_health_prediction/model'); zip_path = model_dir / 'my_survival_forest_model_quxi.zip'; joblib_name = 'my_survival_forest_model_quxi.joblib'; joblib_path = model_dir / joblib_name; assert zip_path.exists(), f'Model archive not found: {zip_path}'; archive = ZipFile(zip_path); archive.extract(joblib_name, model_dir); archive.close(); assert joblib_path.exists(), f'Model file not extracted: {joblib_path}'" && \
|
||||||
rm -f app/algorithms/health/model/my_survival_forest_model_quxi.zip
|
rm -f app/algorithms/pipe_health_prediction/model/my_survival_forest_model_quxi.zip
|
||||||
RUN mkdir -p db_inp temp data inp
|
RUN mkdir -p db_inp temp data inp
|
||||||
|
|
||||||
# 设置 PYTHONPATH 以便 uvicorn 找到 app 模块
|
# 设置 PYTHONPATH 以便 uvicorn 找到 app 模块
|
||||||
|
|||||||
@@ -1,45 +1,5 @@
|
|||||||
"""Algorithm package with side-effect-free, lazy compatibility exports."""
|
"""Pure water-network calculation packages.
|
||||||
|
|
||||||
from importlib import import_module
|
Application workflows belong in :mod:`app.services`; database and external
|
||||||
from typing import Any
|
system access belongs in :mod:`app.infra` or :mod:`app.native`.
|
||||||
|
"""
|
||||||
|
|
||||||
_EXPORT_MODULES = {
|
|
||||||
"flow_data_clean": "app.algorithms.cleaning",
|
|
||||||
"pressure_data_clean": "app.algorithms.cleaning",
|
|
||||||
"pressure_sensor_placement_sensitivity": "app.algorithms.sensor",
|
|
||||||
"pressure_sensor_placement_kmeans": "app.algorithms.sensor",
|
|
||||||
"valve_isolation_analysis": "app.algorithms.isolation.valve",
|
|
||||||
"LeakageIdentifier": "app.algorithms.leakage",
|
|
||||||
"PipelineHealthAnalyzer": "app.algorithms.health",
|
|
||||||
"run_burst_location": "app.algorithms.burst_location",
|
|
||||||
**{
|
|
||||||
name: "app.algorithms.simulation.scenarios"
|
|
||||||
for name in (
|
|
||||||
"convert_to_local_unit",
|
|
||||||
"burst_analysis",
|
|
||||||
"valve_close_analysis",
|
|
||||||
"flushing_analysis",
|
|
||||||
"contaminant_simulation",
|
|
||||||
"age_analysis",
|
|
||||||
"pressure_regulation",
|
|
||||||
)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
__all__ = list(_EXPORT_MODULES)
|
|
||||||
|
|
||||||
|
|
||||||
def __getattr__(name: str) -> Any:
|
|
||||||
try:
|
|
||||||
module_name = _EXPORT_MODULES[name]
|
|
||||||
except KeyError as exc:
|
|
||||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc
|
|
||||||
|
|
||||||
value = getattr(import_module(module_name), name)
|
|
||||||
globals()[name] = value
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def __dir__() -> list[str]:
|
|
||||||
return sorted({*globals(), *__all__})
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
from app.algorithms.burst_detection.burst_detector import BurstDetector
|
from app.algorithms.burst_detection.pressure_anomaly import PressureAnomalyDetector
|
||||||
|
|
||||||
__all__ = ["BurstDetector"]
|
__all__ = ["PressureAnomalyDetector"]
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ PressureDataInput = (
|
|||||||
IGNORED_OBSERVATION_COLUMNS = {"time", "timestamp", "datetime", "date"}
|
IGNORED_OBSERVATION_COLUMNS = {"time", "timestamp", "datetime", "date"}
|
||||||
|
|
||||||
|
|
||||||
class BurstDetector:
|
class PressureAnomalyDetector:
|
||||||
"""FFT + IsolationForest based burst detection for daily aligned pressure data."""
|
"""FFT + IsolationForest based burst detection for daily aligned pressure data."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .pipeline import run_burst_location
|
||||||
|
|
||||||
|
__all__ = ["run_burst_location"]
|
||||||
+3
-4
@@ -11,13 +11,13 @@ import networkx as nx
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from .leak_simulator import cal_signature_pipe_multi_pf
|
from .leak_signature import cal_signature_pipe_multi_pf
|
||||||
from .network_partitioner import (
|
from .topology_partitioning import (
|
||||||
cal_group_num,
|
cal_group_num,
|
||||||
metis_grouping_pipe_weight,
|
metis_grouping_pipe_weight,
|
||||||
visualize_metis_partition,
|
visualize_metis_partition,
|
||||||
)
|
)
|
||||||
from .similarity_calculator import (
|
from .similarity_metrics import (
|
||||||
adjust_ratio,
|
adjust_ratio,
|
||||||
cal_similarity_all_multi_new_sq_improve_double_lzr,
|
cal_similarity_all_multi_new_sq_improve_double_lzr,
|
||||||
decode_mode,
|
decode_mode,
|
||||||
@@ -769,4 +769,3 @@ def DN_search_multi_simple_add_flow_count_new(
|
|||||||
final_candidates_csv,
|
final_candidates_csv,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
+4
-108
@@ -1,5 +1,3 @@
|
|||||||
import argparse
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
from multiprocessing import cpu_count
|
from multiprocessing import cpu_count
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -7,12 +5,12 @@ from typing import Any, Iterable
|
|||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from app.algorithms.burst_location import leak_simulator
|
from app.algorithms.burst_localization import leak_signature
|
||||||
|
|
||||||
from .burst_locator import (
|
from .candidate_ranking import (
|
||||||
DN_search_multi_simple_add_flow_count_new,
|
DN_search_multi_simple_add_flow_count_new,
|
||||||
)
|
)
|
||||||
from .network_model import (
|
from .topology_model import (
|
||||||
_build_node_pipe_maps,
|
_build_node_pipe_maps,
|
||||||
cal_node_coordinate,
|
cal_node_coordinate,
|
||||||
construct_graph,
|
construct_graph,
|
||||||
@@ -26,35 +24,6 @@ DEFAULT_N_WORKERS = max(1, min(cpu_count() - 1, 4))
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _read_id_list_json(path):
|
|
||||||
if path is None:
|
|
||||||
return None
|
|
||||||
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
||||||
if isinstance(data, list):
|
|
||||||
return [str(item) for item in data]
|
|
||||||
if isinstance(data, dict):
|
|
||||||
if "ids" in data and isinstance(data["ids"], list):
|
|
||||||
return [str(item) for item in data["ids"]]
|
|
||||||
raise ValueError(f"ID JSON must be list or dict with key 'ids': {path}")
|
|
||||||
raise ValueError(f"Unsupported ID JSON format: {path}")
|
|
||||||
|
|
||||||
|
|
||||||
def _read_series_csv(path):
|
|
||||||
if path is None:
|
|
||||||
return None
|
|
||||||
df = pd.read_csv(path)
|
|
||||||
if df.shape[1] < 2:
|
|
||||||
raise ValueError(f"CSV must contain at least two columns (id,value): {path}")
|
|
||||||
if {"id", "value"}.issubset(df.columns):
|
|
||||||
id_col, value_col = "id", "value"
|
|
||||||
else:
|
|
||||||
id_col, value_col = df.columns[0], df.columns[1]
|
|
||||||
series = pd.Series(
|
|
||||||
df[value_col].values, index=df[id_col].astype(str).values, dtype=float
|
|
||||||
)
|
|
||||||
return series
|
|
||||||
|
|
||||||
|
|
||||||
def _align_scada_series(
|
def _align_scada_series(
|
||||||
series: pd.Series, ids: Iterable[str], series_name: str
|
series: pd.Series, ids: Iterable[str], series_name: str
|
||||||
) -> pd.Series:
|
) -> pd.Series:
|
||||||
@@ -159,7 +128,7 @@ def run_burst_location(
|
|||||||
pipe_diameter,
|
pipe_diameter,
|
||||||
) = read_inf_inp(wn)
|
) = read_inf_inp(wn)
|
||||||
|
|
||||||
candidate_pipe, _ = leak_simulator.cal_possible_pipe(
|
candidate_pipe, _ = leak_signature.cal_possible_pipe(
|
||||||
burst_leakage, all_pipe, pipe_diameter
|
burst_leakage, all_pipe, pipe_diameter
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -267,76 +236,3 @@ def run_burst_location(
|
|||||||
"final_candidates_csv": final_candidates_csv,
|
"final_candidates_csv": final_candidates_csv,
|
||||||
"stage_timing_seconds": stage_timing,
|
"stage_timing_seconds": stage_timing,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _parse_args():
|
|
||||||
parser = argparse.ArgumentParser(description="爆管定位主函数入口")
|
|
||||||
parser.add_argument("--wn-inp", required=True, help="EPANET inp 文件路径")
|
|
||||||
parser.add_argument(
|
|
||||||
"--pressure-ids-json", required=True, help="压力SCADA ID列表 JSON 文件"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--flow-ids-json", default=None, help="(可选)流量SCADA ID列表 JSON 文件"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--burst-pressure-csv", required=True, help="爆管时压力 CSV(id,value)"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--normal-pressure-csv", required=True, help="正常时压力 CSV(id,value)"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--burst-flow-csv", default=None, help="(可选)爆管时流量 CSV(id,value)"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--normal-flow-csv", default=None, help="(可选)正常时流量 CSV(id,value)"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--burst-leakage", type=float, required=True, help="爆管漏损流量"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--min-dpressure",
|
|
||||||
type=float,
|
|
||||||
default=2.0,
|
|
||||||
help="(可选)最小压降阈值,默认 2.0",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--basic-pressure",
|
|
||||||
type=float,
|
|
||||||
default=10.0,
|
|
||||||
help="(可选)基础服务压力,默认 10.0",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--n-workers",
|
|
||||||
type=int,
|
|
||||||
default=DEFAULT_N_WORKERS,
|
|
||||||
help="(可选)特征中心模拟进程数,默认 max(1, min(cpu_count()-1, 4))",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--final-candidates-csv-path",
|
|
||||||
default="temp/burst_location/final_round_candidates.csv",
|
|
||||||
help="(可选)最后一轮候选管道明细 CSV 输出路径",
|
|
||||||
)
|
|
||||||
return parser.parse_args()
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
args = _parse_args()
|
|
||||||
result = run_burst_location(
|
|
||||||
wn_inp_path=args.wn_inp,
|
|
||||||
pressure_scada_ids=_read_id_list_json(args.pressure_ids_json),
|
|
||||||
burst_pressure=_read_series_csv(args.burst_pressure_csv),
|
|
||||||
normal_pressure=_read_series_csv(args.normal_pressure_csv),
|
|
||||||
burst_leakage=args.burst_leakage,
|
|
||||||
flow_scada_ids=_read_id_list_json(args.flow_ids_json),
|
|
||||||
burst_flow=_read_series_csv(args.burst_flow_csv),
|
|
||||||
normal_flow=_read_series_csv(args.normal_flow_csv),
|
|
||||||
min_dpressure=args.min_dpressure,
|
|
||||||
basic_pressure=args.basic_pressure,
|
|
||||||
n_workers=args.n_workers,
|
|
||||||
final_candidates_csv_path=args.final_candidates_csv_path,
|
|
||||||
)
|
|
||||||
print(json.dumps(result, ensure_ascii=False))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
+1
-2
@@ -6,7 +6,7 @@ import random
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from .leak_simulator import simple_add_leak, simple_recover_wn, simple_simulation_pf
|
from .leak_signature import simple_add_leak, simple_recover_wn, simple_simulation_pf
|
||||||
|
|
||||||
|
|
||||||
def add_noise_pd(data, noise_type, noise_para):
|
def add_noise_pd(data, noise_type, noise_para):
|
||||||
@@ -195,4 +195,3 @@ def change_para_of_wn(wn, pipe_roughness_change):
|
|||||||
pipe.roughness = pipe_roughness_change[pipe_name]
|
pipe.roughness = pipe_roughness_change[pipe_name]
|
||||||
return wn
|
return wn
|
||||||
|
|
||||||
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from .burst_location import run_burst_location
|
|
||||||
|
|
||||||
__all__ = ["run_burst_location"]
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import os
|
|
||||||
|
|
||||||
from app.algorithms.cleaning import flow as _flow_module
|
|
||||||
from app.algorithms.cleaning import pressure as _pressure_module
|
|
||||||
|
|
||||||
|
|
||||||
############################################################
|
|
||||||
# 流量监测数据清洗 ***卡尔曼滤波法***
|
|
||||||
############################################################
|
|
||||||
# 2025/08/21 hxyan
|
|
||||||
|
|
||||||
|
|
||||||
def flow_data_clean(input_csv_file: str) -> str:
|
|
||||||
"""
|
|
||||||
读取 input_csv_path 中的每列时间序列,使用一维 Kalman 滤波平滑并用预测值替换基于 3σ 检测出的异常点。
|
|
||||||
保存输出为:<input_filename>_cleaned.xlsx(与输入同目录),并返回输出文件的绝对路径。如有同名文件存在,则覆盖。
|
|
||||||
:param: input_csv_file: 输入的 CSV 文件明或路径
|
|
||||||
:return: 输出文件的绝对路径
|
|
||||||
"""
|
|
||||||
|
|
||||||
# 提供的 input_csv_path 绝对路径,以下为 默认脚本目录下同名 CSV 文件,构建绝对路径,可根据情况修改
|
|
||||||
# 使用 algorithms 根目录保持与原 data_cleaning.py 一致的行为
|
|
||||||
script_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
input_csv_path = os.path.join(script_dir, input_csv_file)
|
|
||||||
|
|
||||||
# 检查文件是否存在
|
|
||||||
if not os.path.exists(input_csv_path):
|
|
||||||
raise FileNotFoundError(f"指定的文件不存在: {input_csv_path}")
|
|
||||||
# 调用 clean_flow_data_kf 函数进行数据清洗
|
|
||||||
out_xlsx_path = _flow_module.clean_flow_data_kf(input_csv_path)
|
|
||||||
print("清洗后的数据已保存到:", out_xlsx_path)
|
|
||||||
|
|
||||||
|
|
||||||
############################################################
|
|
||||||
# 压力监测数据清洗 ***kmean++法***
|
|
||||||
############################################################
|
|
||||||
# 2025/08/21 hxyan
|
|
||||||
|
|
||||||
|
|
||||||
def pressure_data_clean(input_csv_file: str) -> str:
|
|
||||||
"""
|
|
||||||
读取 input_csv_path 中的每列时间序列,使用Kmean++清洗数据。
|
|
||||||
保存输出为:<input_filename>_cleaned.xlsx(与输入同目录),并返回输出文件的绝对路径。如有同名文件存在,则覆盖。
|
|
||||||
原始数据在 sheet 'raw_pressure_data',处理后数据在 sheet 'cleaned_pressusre_data'。
|
|
||||||
:param input_csv_path: 输入的 CSV 文件路径
|
|
||||||
:return: 输出文件的绝对路径
|
|
||||||
"""
|
|
||||||
|
|
||||||
# 提供的 input_csv_path 绝对路径,以下为 默认脚本目录下同名 CSV 文件,构建绝对路径,可根据情况修改
|
|
||||||
# 使用 algorithms 根目录保持与原 data_cleaning.py 一致的行为
|
|
||||||
script_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
input_csv_path = os.path.join(script_dir, input_csv_file)
|
|
||||||
|
|
||||||
# 检查文件是否存在
|
|
||||||
if not os.path.exists(input_csv_path):
|
|
||||||
raise FileNotFoundError(f"指定的文件不存在: {input_csv_path}")
|
|
||||||
# 调用 clean_pressure_data_km 函数进行数据清洗
|
|
||||||
out_xlsx_path = _pressure_module.clean_pressure_data_km(input_csv_path)
|
|
||||||
print("清洗后的数据已保存到:", out_xlsx_path)
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Demand allocation calculations."""
|
||||||
|
|
||||||
|
from .pipe_length_weighted import allocate_demand_by_pipe_length
|
||||||
|
|
||||||
|
__all__ = ["allocate_demand_by_pipe_length"]
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Pipe-length-weighted demand allocation.
|
||||||
|
|
||||||
|
This module deliberately accepts plain topology data and performs no database
|
||||||
|
or file access. Application services are responsible for loading topology.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
def allocate_demand_by_pipe_length(
|
||||||
|
demand: float,
|
||||||
|
topology_nodes: Mapping[str, Mapping[str, Any]],
|
||||||
|
topology_links: Mapping[str, Mapping[str, Any]],
|
||||||
|
) -> dict[str, float]:
|
||||||
|
"""Allocate total demand to junctions by half of each incident link length."""
|
||||||
|
|
||||||
|
if not topology_nodes or not topology_links or demand == 0.0:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
total_link_length = sum(
|
||||||
|
abs(float(link["length"])) for link in topology_links.values()
|
||||||
|
)
|
||||||
|
if total_link_length <= 0.0:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
demand_per_length = demand / total_link_length
|
||||||
|
result: dict[str, float] = {}
|
||||||
|
for node_id, node in topology_nodes.items():
|
||||||
|
if node["type"] != "junction":
|
||||||
|
continue
|
||||||
|
incident_length = sum(
|
||||||
|
abs(float(topology_links[link_id]["length"]))
|
||||||
|
for link_id in node["links"]
|
||||||
|
)
|
||||||
|
result[node_id] = incident_length * demand_per_length * 0.5
|
||||||
|
return result
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from app.algorithms.dma_leakage_estimation.genetic_optimizer import DmaLeakageOptimizer
|
||||||
|
|
||||||
|
__all__ = ["DmaLeakageOptimizer"]
|
||||||
+3
-49
@@ -3,7 +3,6 @@ import numpy as np
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import argparse
|
|
||||||
from multiprocessing import Pool, cpu_count
|
from multiprocessing import Pool, cpu_count
|
||||||
from typing import Any, List, Dict, Union
|
from typing import Any, List, Dict, Union
|
||||||
|
|
||||||
@@ -70,7 +69,7 @@ def _worker_init(
|
|||||||
|
|
||||||
def _worker_evaluate(raw_ratios: np.ndarray) -> float:
|
def _worker_evaluate(raw_ratios: np.ndarray) -> float:
|
||||||
d = _worker_data
|
d = _worker_data
|
||||||
effective_ratio_map = LeakageIdentifier._effective_area_ratios(
|
effective_ratio_map = DmaLeakageOptimizer._effective_area_ratios(
|
||||||
raw_ratios,
|
raw_ratios,
|
||||||
d["area_ids"],
|
d["area_ids"],
|
||||||
d["nodes_by_area"],
|
d["nodes_by_area"],
|
||||||
@@ -121,7 +120,7 @@ def _worker_evaluate(raw_ratios: np.ndarray) -> float:
|
|||||||
_cleanup_temp_files(prefix)
|
_cleanup_temp_files(prefix)
|
||||||
|
|
||||||
|
|
||||||
class LeakageIdentifier:
|
class DmaLeakageOptimizer:
|
||||||
FLOW_UNIT_TO_M3S = {
|
FLOW_UNIT_TO_M3S = {
|
||||||
"m3/s": 1.0,
|
"m3/s": 1.0,
|
||||||
"m³/s": 1.0,
|
"m³/s": 1.0,
|
||||||
@@ -543,7 +542,7 @@ class LeakageProblem(Problem):
|
|||||||
leak_ratios = x
|
leak_ratios = x
|
||||||
|
|
||||||
# 将漏损分布归一化
|
# 将漏损分布归一化
|
||||||
effective_ratio_map = LeakageIdentifier._effective_area_ratios(
|
effective_ratio_map = DmaLeakageOptimizer._effective_area_ratios(
|
||||||
leak_ratios,
|
leak_ratios,
|
||||||
self.area_ids,
|
self.area_ids,
|
||||||
self.nodes_by_area,
|
self.nodes_by_area,
|
||||||
@@ -608,48 +607,3 @@ class LeakageProblem(Problem):
|
|||||||
self._pool.close()
|
self._pool.close()
|
||||||
self._pool.join()
|
self._pool.join()
|
||||||
self._pool = None
|
self._pool = None
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
parser = argparse.ArgumentParser(description="漏损区域识别")
|
|
||||||
parser.add_argument("--inp", required=True, help=".inp 文件路径")
|
|
||||||
parser.add_argument("--map", help="节点-区域映射 CSV 路径")
|
|
||||||
parser.add_argument("--scada", help="SCADA 压力 CSV 路径 (观测数据)")
|
|
||||||
parser.add_argument("--sensors", help="传感器节点 ID 列表 (逗号分隔)")
|
|
||||||
parser.add_argument("--output", default="Results", help="输出目录")
|
|
||||||
|
|
||||||
parser.add_argument("--pop_size", type=int, default=50, help="种群大小")
|
|
||||||
parser.add_argument("--max_gen", type=int, default=100, help="最大代数")
|
|
||||||
parser.add_argument("--duration", type=float, default=24, help="模拟时长(小时)")
|
|
||||||
parser.add_argument("--q_sum", type=float, default=0.241, help="总漏损流量")
|
|
||||||
parser.add_argument(
|
|
||||||
"--q_sum_unit",
|
|
||||||
default="m3/s",
|
|
||||||
choices=list(LeakageIdentifier.FLOW_UNIT_TO_M3S.keys()),
|
|
||||||
help="q_sum 输入单位(建议与现场习惯一致,内部统一换算为 m3/s)",
|
|
||||||
)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if not args.map or not args.scada or not args.sensors:
|
|
||||||
parser.error("--map、--scada、--sensors 为必填")
|
|
||||||
|
|
||||||
q_sum_m3s = LeakageIdentifier._flow_to_m3s(args.q_sum, args.q_sum_unit)
|
|
||||||
|
|
||||||
sensors = [sensor.strip() for sensor in args.sensors.split(",") if sensor.strip()]
|
|
||||||
identifier = LeakageIdentifier(
|
|
||||||
args.inp, sensors, args.map, duration=args.duration, q_sum=q_sum_m3s
|
|
||||||
)
|
|
||||||
|
|
||||||
identifier.run_identification(
|
|
||||||
args.scada,
|
|
||||||
args.output,
|
|
||||||
pop_size=args.pop_size,
|
|
||||||
max_gen=args.max_gen,
|
|
||||||
output_flow_unit=args.q_sum_unit,
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
"""Pure topology partitioning used by DMA leakage estimation."""
|
||||||
|
|
||||||
|
import math
|
||||||
|
from collections import deque
|
||||||
|
from typing import Any, Iterable, Mapping
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
def build_dma_partitions(
|
||||||
|
sensor_nodes: list[str],
|
||||||
|
node_coords: Mapping[str, Mapping[str, Any]],
|
||||||
|
link_entries: Iterable[str],
|
||||||
|
dma_count: int | None,
|
||||||
|
) -> tuple[dict[str, str], list[dict[str, Any]]]:
|
||||||
|
"""Assign every topology node to a sensor-seeded virtual DMA."""
|
||||||
|
|
||||||
|
all_nodes = list(node_coords)
|
||||||
|
if not all_nodes:
|
||||||
|
raise ValueError("管网中未获取到可分区节点。")
|
||||||
|
available_sensors = [node for node in sensor_nodes if node in node_coords]
|
||||||
|
if not available_sensors:
|
||||||
|
raise ValueError("无可用压力传感器,无法生成虚拟分区。")
|
||||||
|
|
||||||
|
area_count = _resolve_dma_count(dma_count, available_sensors, all_nodes)
|
||||||
|
sensor_area_map = _cluster_sensors_to_areas(
|
||||||
|
available_sensors, node_coords, area_count
|
||||||
|
)
|
||||||
|
adjacency = _build_adjacency(link_entries, all_nodes)
|
||||||
|
distance_by_sensor = {
|
||||||
|
sensor: _bfs_distances(adjacency, sensor) for sensor in available_sensors
|
||||||
|
}
|
||||||
|
|
||||||
|
assignment_count = {sensor: 0 for sensor in available_sensors}
|
||||||
|
area_map: dict[str, str] = {}
|
||||||
|
for node_id in sorted(all_nodes):
|
||||||
|
sensor = _choose_sensor_for_node(
|
||||||
|
node_id,
|
||||||
|
available_sensors,
|
||||||
|
node_coords,
|
||||||
|
distance_by_sensor,
|
||||||
|
assignment_count,
|
||||||
|
)
|
||||||
|
assignment_count[sensor] += 1
|
||||||
|
area_map[node_id] = sensor_area_map[sensor]
|
||||||
|
|
||||||
|
return area_map, _build_area_meta(area_map, sensor_area_map)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_dma_count(
|
||||||
|
dma_count: int | None, sensor_nodes: list[str], all_nodes: list[str]
|
||||||
|
) -> int:
|
||||||
|
if dma_count is None:
|
||||||
|
return min(len(sensor_nodes), len(all_nodes))
|
||||||
|
if dma_count <= 0:
|
||||||
|
raise ValueError("dma_count 必须大于 0。")
|
||||||
|
if dma_count > len(all_nodes):
|
||||||
|
raise ValueError("dma_count 不能大于可分区节点数量。")
|
||||||
|
if dma_count > len(sensor_nodes):
|
||||||
|
raise ValueError("dma_count 不能大于可用传感器数量。")
|
||||||
|
return dma_count
|
||||||
|
|
||||||
|
|
||||||
|
def _cluster_sensors_to_areas(
|
||||||
|
sensor_nodes: list[str],
|
||||||
|
node_coords: Mapping[str, Mapping[str, Any]],
|
||||||
|
area_count: int,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
if area_count >= len(sensor_nodes):
|
||||||
|
return {sensor: str(index + 1) for index, sensor in enumerate(sensor_nodes)}
|
||||||
|
|
||||||
|
points = np.array(
|
||||||
|
[
|
||||||
|
[float(node_coords[sensor]["x"]), float(node_coords[sensor]["y"])]
|
||||||
|
for sensor in sensor_nodes
|
||||||
|
],
|
||||||
|
dtype=float,
|
||||||
|
)
|
||||||
|
centers = points[:area_count].copy()
|
||||||
|
labels = np.full(points.shape[0], -1, dtype=int)
|
||||||
|
for _ in range(20):
|
||||||
|
distances_squared = (
|
||||||
|
(points[:, None, :] - centers[None, :, :]) ** 2
|
||||||
|
).sum(axis=2)
|
||||||
|
next_labels = distances_squared.argmin(axis=1)
|
||||||
|
if np.array_equal(labels, next_labels):
|
||||||
|
break
|
||||||
|
labels = next_labels
|
||||||
|
for index in range(area_count):
|
||||||
|
cluster_points = points[labels == index]
|
||||||
|
if cluster_points.size > 0:
|
||||||
|
centers[index] = cluster_points.mean(axis=0)
|
||||||
|
labels = _restore_empty_area_labels(labels, points, centers, area_count)
|
||||||
|
return {
|
||||||
|
sensor: str(int(labels[index]) + 1)
|
||||||
|
for index, sensor in enumerate(sensor_nodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_empty_area_labels(
|
||||||
|
labels: np.ndarray,
|
||||||
|
points: np.ndarray,
|
||||||
|
centers: np.ndarray,
|
||||||
|
area_count: int,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Keep every requested area represented when coordinates are degenerate."""
|
||||||
|
|
||||||
|
labels = labels.copy()
|
||||||
|
for missing_area in sorted(set(range(area_count)) - set(labels.tolist())):
|
||||||
|
area_sizes = {
|
||||||
|
area: int(np.count_nonzero(labels == area)) for area in range(area_count)
|
||||||
|
}
|
||||||
|
donor_area = max(
|
||||||
|
(area for area, size in area_sizes.items() if size > 1),
|
||||||
|
key=lambda area: (area_sizes[area], -area),
|
||||||
|
)
|
||||||
|
donor_indices = np.flatnonzero(labels == donor_area)
|
||||||
|
replacement_index = max(
|
||||||
|
(int(index) for index in donor_indices),
|
||||||
|
key=lambda index: (
|
||||||
|
float(np.sum((points[index] - centers[donor_area]) ** 2)),
|
||||||
|
index,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
labels[replacement_index] = missing_area
|
||||||
|
centers[missing_area] = points[replacement_index]
|
||||||
|
return labels
|
||||||
|
|
||||||
|
|
||||||
|
def _build_adjacency(
|
||||||
|
link_entries: Iterable[str], all_nodes: list[str]
|
||||||
|
) -> dict[str, set[str]]:
|
||||||
|
adjacency: dict[str, set[str]] = {node: set() for node in all_nodes}
|
||||||
|
for link in link_entries:
|
||||||
|
parts = str(link).split(":")
|
||||||
|
if len(parts) < 4:
|
||||||
|
continue
|
||||||
|
node1, node2 = parts[-2], parts[-1]
|
||||||
|
if node1 in adjacency and node2 in adjacency:
|
||||||
|
adjacency[node1].add(node2)
|
||||||
|
adjacency[node2].add(node1)
|
||||||
|
return adjacency
|
||||||
|
|
||||||
|
|
||||||
|
def _bfs_distances(adjacency: Mapping[str, set[str]], start: str) -> dict[str, int]:
|
||||||
|
distances = {start: 0}
|
||||||
|
queue: deque[str] = deque([start])
|
||||||
|
while queue:
|
||||||
|
node = queue.popleft()
|
||||||
|
for neighbor in adjacency.get(node, set()):
|
||||||
|
if neighbor in distances:
|
||||||
|
continue
|
||||||
|
distances[neighbor] = distances[node] + 1
|
||||||
|
queue.append(neighbor)
|
||||||
|
return distances
|
||||||
|
|
||||||
|
|
||||||
|
def _choose_sensor_for_node(
|
||||||
|
node_id: str,
|
||||||
|
sensors: list[str],
|
||||||
|
node_coords: Mapping[str, Mapping[str, Any]],
|
||||||
|
distance_by_sensor: Mapping[str, Mapping[str, int]],
|
||||||
|
assignment_count: Mapping[str, int],
|
||||||
|
) -> str:
|
||||||
|
min_distance: int | None = None
|
||||||
|
candidates: list[str] = []
|
||||||
|
for sensor in sensors:
|
||||||
|
distance = distance_by_sensor.get(sensor, {}).get(node_id)
|
||||||
|
if distance is None:
|
||||||
|
continue
|
||||||
|
if min_distance is None or distance < min_distance:
|
||||||
|
min_distance = distance
|
||||||
|
candidates = [sensor]
|
||||||
|
elif distance == min_distance:
|
||||||
|
candidates.append(sensor)
|
||||||
|
if not candidates:
|
||||||
|
node_coord = node_coords[node_id]
|
||||||
|
return min(
|
||||||
|
sensors,
|
||||||
|
key=lambda sensor: math.hypot(
|
||||||
|
float(node_coord["x"]) - float(node_coords[sensor]["x"]),
|
||||||
|
float(node_coord["y"]) - float(node_coords[sensor]["y"]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return min(candidates, key=lambda sensor: (assignment_count[sensor], sensor))
|
||||||
|
|
||||||
|
|
||||||
|
def _build_area_meta(
|
||||||
|
area_map: Mapping[str, str], sensor_area_map: Mapping[str, str]
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
nodes_by_area: dict[str, list[str]] = {}
|
||||||
|
for node_id, area_id in area_map.items():
|
||||||
|
nodes_by_area.setdefault(area_id, []).append(node_id)
|
||||||
|
sensors_by_area: dict[str, list[str]] = {}
|
||||||
|
for sensor, area_id in sensor_area_map.items():
|
||||||
|
sensors_by_area.setdefault(area_id, []).append(sensor)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"area_id": area_id,
|
||||||
|
"sensor_nodes": sorted(sensors_by_area.get(area_id, [])),
|
||||||
|
"node_ids": sorted(nodes_by_area[area_id]),
|
||||||
|
"node_count": len(nodes_by_area[area_id]),
|
||||||
|
}
|
||||||
|
for area_id in sorted(nodes_by_area, key=int)
|
||||||
|
]
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from app.algorithms.health.analyzer import PipelineHealthAnalyzer
|
|
||||||
|
|
||||||
__all__ = ["PipelineHealthAnalyzer"]
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from app.algorithms.isolation.valve import valve_isolation_analysis
|
|
||||||
|
|
||||||
__all__ = ["valve_isolation_analysis"]
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
from collections import defaultdict, deque
|
|
||||||
from functools import lru_cache
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from app.services.tjnetwork import (
|
|
||||||
get_network_link_nodes,
|
|
||||||
is_node,
|
|
||||||
get_link_properties,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
VALVE_LINK_TYPE = "valve"
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_link_entry(link_entry: str) -> tuple[str, str, str, str]:
|
|
||||||
parts = link_entry.split(":", 3)
|
|
||||||
if len(parts) != 4:
|
|
||||||
raise ValueError(f"Invalid link entry format: {link_entry}")
|
|
||||||
return parts[0], parts[1], parts[2], parts[3]
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=16)
|
|
||||||
def _get_network_topology(network: str):
|
|
||||||
"""
|
|
||||||
解析并缓存网络拓扑,大幅减少重复的 API 调用和字符串解析开销。
|
|
||||||
返回:
|
|
||||||
- pipe_adj: 永久连通的管道/泵邻接表 (dict[str, set])
|
|
||||||
- all_valves: 所有阀门字典 {id: (n1, n2)}
|
|
||||||
- link_lookup: 链路快速查表 {id: (n1, n2, type)} 用于快速定位事故点
|
|
||||||
- node_set: 所有已知节点集合
|
|
||||||
"""
|
|
||||||
pipe_adj = defaultdict(set)
|
|
||||||
all_valves = {}
|
|
||||||
link_lookup = {}
|
|
||||||
node_set = set()
|
|
||||||
|
|
||||||
# 此处假设 get_network_link_nodes 获取全网数据
|
|
||||||
for link_entry in get_network_link_nodes(network):
|
|
||||||
link_id, link_type, node1, node2 = _parse_link_entry(link_entry)
|
|
||||||
link_type_name = str(link_type).lower()
|
|
||||||
|
|
||||||
link_lookup[link_id] = (node1, node2, link_type_name)
|
|
||||||
node_set.add(node1)
|
|
||||||
node_set.add(node2)
|
|
||||||
|
|
||||||
if link_type_name == VALVE_LINK_TYPE:
|
|
||||||
all_valves[link_id] = (node1, node2)
|
|
||||||
else:
|
|
||||||
# 只有非阀门(管道/泵)才进入永久连通图
|
|
||||||
pipe_adj[node1].add(node2)
|
|
||||||
pipe_adj[node2].add(node1)
|
|
||||||
|
|
||||||
return pipe_adj, all_valves, link_lookup, node_set
|
|
||||||
|
|
||||||
|
|
||||||
def valve_isolation_analysis(
|
|
||||||
network: str, accident_elements: str | list[str], disabled_valves: list[str] = None
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
关阀搜索/分析:基于拓扑结构确定事故隔离所需关阀。
|
|
||||||
:param network: 模型名称
|
|
||||||
:param accident_elements: 事故点(节点或管道/泵/阀门ID),可以是单个ID字符串或ID列表
|
|
||||||
:param disabled_valves: 故障/无法关闭的阀门ID列表
|
|
||||||
:return: dict,包含受影响节点、必须关闭阀门、可选阀门等信息
|
|
||||||
"""
|
|
||||||
if disabled_valves is None:
|
|
||||||
disabled_valves_set = set()
|
|
||||||
else:
|
|
||||||
disabled_valves_set = set(disabled_valves)
|
|
||||||
|
|
||||||
if isinstance(accident_elements, str):
|
|
||||||
target_elements = [accident_elements]
|
|
||||||
else:
|
|
||||||
target_elements = accident_elements
|
|
||||||
|
|
||||||
# 1. 获取缓存拓扑 (极快,无 IO)
|
|
||||||
pipe_adj, all_valves, link_lookup, node_set = _get_network_topology(network)
|
|
||||||
|
|
||||||
# 2. 确定起点,优先查表避免 API 调用
|
|
||||||
start_nodes = set()
|
|
||||||
for element in target_elements:
|
|
||||||
if element in node_set:
|
|
||||||
start_nodes.add(element)
|
|
||||||
elif element in link_lookup:
|
|
||||||
n1, n2, _ = link_lookup[element]
|
|
||||||
start_nodes.add(n1)
|
|
||||||
start_nodes.add(n2)
|
|
||||||
else:
|
|
||||||
# 仅当缓存中没找到时(极少见),才回退到慢速 API
|
|
||||||
if is_node(network, element):
|
|
||||||
start_nodes.add(element)
|
|
||||||
else:
|
|
||||||
props = get_link_properties(network, element)
|
|
||||||
n1, n2 = props.get("node1"), props.get("node2")
|
|
||||||
if n1 and n2:
|
|
||||||
start_nodes.add(n1)
|
|
||||||
start_nodes.add(n2)
|
|
||||||
else:
|
|
||||||
raise ValueError(
|
|
||||||
f"Accident element {element} invalid or missing endpoints"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 3. 处理故障阀门 (构建临时增量图)
|
|
||||||
# 我们不修改 cached pipe_adj,而是建立一个 extra_adj
|
|
||||||
extra_adj = defaultdict(list)
|
|
||||||
boundary_valves = {} # 当前有效的边界阀门
|
|
||||||
|
|
||||||
for vid, (n1, n2) in all_valves.items():
|
|
||||||
if vid in disabled_valves_set:
|
|
||||||
# 故障阀门:视为连通管道
|
|
||||||
extra_adj[n1].append(n2)
|
|
||||||
extra_adj[n2].append(n1)
|
|
||||||
else:
|
|
||||||
# 正常阀门:视为潜在边界
|
|
||||||
boundary_valves[vid] = (n1, n2)
|
|
||||||
|
|
||||||
# 4. BFS 搜索 (叠加 pipe_adj 和 extra_adj)
|
|
||||||
affected_nodes: set[str] = set()
|
|
||||||
queue = deque(start_nodes)
|
|
||||||
while queue:
|
|
||||||
node = queue.popleft()
|
|
||||||
if node in affected_nodes:
|
|
||||||
continue
|
|
||||||
affected_nodes.add(node)
|
|
||||||
|
|
||||||
# 遍历永久管道邻居
|
|
||||||
if node in pipe_adj:
|
|
||||||
for neighbor in pipe_adj[node]:
|
|
||||||
if neighbor not in affected_nodes:
|
|
||||||
queue.append(neighbor)
|
|
||||||
|
|
||||||
# 遍历故障阀门带来的额外邻居
|
|
||||||
if node in extra_adj:
|
|
||||||
for neighbor in extra_adj[node]:
|
|
||||||
if neighbor not in affected_nodes:
|
|
||||||
queue.append(neighbor)
|
|
||||||
|
|
||||||
# 5. 结果聚合
|
|
||||||
must_close_valves: list[str] = []
|
|
||||||
optional_valves: list[str] = []
|
|
||||||
|
|
||||||
for valve_id, (n1, n2) in boundary_valves.items():
|
|
||||||
in_n1 = n1 in affected_nodes
|
|
||||||
in_n2 = n2 in affected_nodes
|
|
||||||
if in_n1 and in_n2:
|
|
||||||
optional_valves.append(valve_id)
|
|
||||||
elif in_n1 or in_n2:
|
|
||||||
must_close_valves.append(valve_id)
|
|
||||||
|
|
||||||
must_close_valves.sort()
|
|
||||||
optional_valves.sort()
|
|
||||||
isolatable = bool(must_close_valves)
|
|
||||||
|
|
||||||
result = {
|
|
||||||
"accident_elements": target_elements,
|
|
||||||
"disabled_valves": disabled_valves,
|
|
||||||
"affected_nodes": sorted(affected_nodes) if isolatable else [],
|
|
||||||
"affected_node_count": len(affected_nodes),
|
|
||||||
"must_close_valves": must_close_valves,
|
|
||||||
"optional_valves": optional_valves,
|
|
||||||
"isolatable": isolatable,
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(target_elements) == 1:
|
|
||||||
result["accident_element"] = target_elements[0]
|
|
||||||
|
|
||||||
return result
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from app.algorithms.leakage.identifier import LeakageIdentifier
|
|
||||||
|
|
||||||
__all__ = ["LeakageIdentifier"]
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from app.algorithms.pipe_health_prediction.survival_predictor import (
|
||||||
|
PipeHealthSurvivalPredictor,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = ["PipeHealthSurvivalPredictor"]
|
||||||
+8
-18
@@ -4,7 +4,7 @@ import pandas as pd
|
|||||||
import matplotlib.pyplot as plt
|
import matplotlib.pyplot as plt
|
||||||
|
|
||||||
|
|
||||||
class PipelineHealthAnalyzer:
|
class PipeHealthSurvivalPredictor:
|
||||||
"""
|
"""
|
||||||
管道健康分析器类,使用随机生存森林模型预测管道的生存概率。
|
管道健康分析器类,使用随机生存森林模型预测管道的生存概率。
|
||||||
|
|
||||||
@@ -28,11 +28,6 @@ class PipelineHealthAnalyzer:
|
|||||||
"model",
|
"model",
|
||||||
"my_survival_forest_model_quxi.joblib",
|
"my_survival_forest_model_quxi.joblib",
|
||||||
)
|
)
|
||||||
# 确保 model 目录存在
|
|
||||||
model_dir = os.path.dirname(model_path)
|
|
||||||
if model_dir and not os.path.exists(model_dir):
|
|
||||||
os.makedirs(model_dir, exist_ok=True)
|
|
||||||
|
|
||||||
if not os.path.exists(model_path):
|
if not os.path.exists(model_path):
|
||||||
raise FileNotFoundError(f"模型文件未找到: {model_path}")
|
raise FileNotFoundError(f"模型文件未找到: {model_path}")
|
||||||
|
|
||||||
@@ -102,7 +97,7 @@ class PipelineHealthAnalyzer:
|
|||||||
|
|
||||||
# 调用说明示例
|
# 调用说明示例
|
||||||
"""
|
"""
|
||||||
在其他项目中使用PipelineHealthAnalyzer类的步骤:
|
在其他项目中使用 PipeHealthSurvivalPredictor 类的步骤:
|
||||||
|
|
||||||
1. 安装依赖(在requirements.txt中添加):
|
1. 安装依赖(在requirements.txt中添加):
|
||||||
joblib==1.5.0
|
joblib==1.5.0
|
||||||
@@ -112,34 +107,29 @@ class PipelineHealthAnalyzer:
|
|||||||
matplotlib==3.9.4
|
matplotlib==3.9.4
|
||||||
|
|
||||||
2. 导入类:
|
2. 导入类:
|
||||||
from pipeline_health_analyzer import PipelineHealthAnalyzer
|
from survival_predictor import PipeHealthSurvivalPredictor
|
||||||
|
|
||||||
3. 初始化分析器(替换为实际模型路径):
|
3. 初始化分析器(替换为实际模型路径):
|
||||||
analyzer = PipelineHealthAnalyzer(model_path='path/to/my_survival_forest_model3-10.joblib')
|
predictor = PipeHealthSurvivalPredictor(model_path='path/to/model.joblib')
|
||||||
|
|
||||||
4. 准备数据(pandas DataFrame,包含9个特征列):
|
4. 准备数据(pandas DataFrame,包含4个特征列):
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
data = pd.DataFrame({
|
data = pd.DataFrame({
|
||||||
'Material': [1, 2], # 示例数据
|
'Material': [1, 2], # 示例数据
|
||||||
'Diameter': [100, 150],
|
'Diameter': [100, 150],
|
||||||
'Flow Velocity': [1.5, 2.0],
|
'Flow Velocity': [1.5, 2.0],
|
||||||
'Pressure': [50, 60],
|
'Pressure': [50, 60]
|
||||||
'Temperature': [20, 25],
|
|
||||||
'Precipitation': [0.1, 0.2],
|
|
||||||
'Location': [1, 2],
|
|
||||||
'Structural Defects': [0, 1],
|
|
||||||
'Functional Defects': [0, 0]
|
|
||||||
})
|
})
|
||||||
|
|
||||||
5. 进行预测:
|
5. 进行预测:
|
||||||
survival_funcs = analyzer.predict_survival(data)
|
survival_funcs = predictor.predict_survival(data)
|
||||||
|
|
||||||
6. 查看结果(每个样本的生存概率随时间变化):
|
6. 查看结果(每个样本的生存概率随时间变化):
|
||||||
for i, sf in enumerate(survival_funcs):
|
for i, sf in enumerate(survival_funcs):
|
||||||
print(f"样本 {i+1}: 时间点: {sf.x[:5]}..., 生存概率: {sf.y[:5]}...")
|
print(f"样本 {i+1}: 时间点: {sf.x[:5]}..., 生存概率: {sf.y[:5]}...")
|
||||||
|
|
||||||
7. 可视化(可选):
|
7. 可视化(可选):
|
||||||
analyzer.plot_survival(survival_funcs, save_path='survival_plot.png')
|
predictor.plot_survival(survival_funcs, save_path='survival_plot.png')
|
||||||
|
|
||||||
注意:
|
注意:
|
||||||
- 数据格式必须匹配特征列表,特征值为数值型。
|
- 数据格式必须匹配特征列表,特征值为数值型。
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Pressure sensor placement calculation implementations."""
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import numpy as np
|
||||||
|
import sklearn.cluster
|
||||||
|
import wntr
|
||||||
|
|
||||||
|
|
||||||
|
class KMeansPlacement:
|
||||||
|
def __init__(self, wn, num_monitors: int, min_diameter_mm: float):
|
||||||
|
self.cluster_num = num_monitors
|
||||||
|
self.wn = wn
|
||||||
|
self.monitor_nodes: list[str] = []
|
||||||
|
self.coords: list[tuple[float, float]] = []
|
||||||
|
self.candidate_nodes: list[str] = []
|
||||||
|
self.min_diameter_mm = min_diameter_mm
|
||||||
|
|
||||||
|
def get_junctions_coordinates(self) -> None:
|
||||||
|
eligible_nodes: set[str] = set()
|
||||||
|
junction_names = set(self.wn.junction_name_list)
|
||||||
|
for pipe_name in self.wn.pipe_name_list:
|
||||||
|
pipe = self.wn.get_link(pipe_name)
|
||||||
|
if float(pipe.diameter) * 1000 < self.min_diameter_mm:
|
||||||
|
continue
|
||||||
|
eligible_nodes.update(
|
||||||
|
node_id
|
||||||
|
for node_id in (pipe.start_node_name, pipe.end_node_name)
|
||||||
|
if node_id in junction_names
|
||||||
|
)
|
||||||
|
|
||||||
|
for junction_name in self.wn.junction_name_list:
|
||||||
|
if junction_name not in eligible_nodes:
|
||||||
|
continue
|
||||||
|
junction = self.wn.get_node(junction_name)
|
||||||
|
self.candidate_nodes.append(junction_name)
|
||||||
|
self.coords.append(junction.coordinates)
|
||||||
|
|
||||||
|
def select_monitoring_points(self) -> list[str]:
|
||||||
|
if not self.coords:
|
||||||
|
self.get_junctions_coordinates()
|
||||||
|
if self.cluster_num <= 0:
|
||||||
|
raise ValueError("sensor_count must be greater than zero")
|
||||||
|
if self.cluster_num > len(self.candidate_nodes):
|
||||||
|
raise ValueError("符合最小管径条件的候选节点数量少于请求的监测点数量")
|
||||||
|
coords = np.array(self.coords)
|
||||||
|
coordinate_span = coords.max(axis=0) - coords.min(axis=0)
|
||||||
|
coordinate_span[coordinate_span == 0] = 1.0
|
||||||
|
coords_normalized = (coords - coords.min(axis=0)) / coordinate_span
|
||||||
|
kmeans = sklearn.cluster.KMeans(n_clusters=self.cluster_num, random_state=42)
|
||||||
|
kmeans.fit(coords_normalized)
|
||||||
|
|
||||||
|
selected_indices: set[int] = set()
|
||||||
|
for cluster_index, center in enumerate(kmeans.cluster_centers_):
|
||||||
|
cluster_indices = np.flatnonzero(kmeans.labels_ == cluster_index)
|
||||||
|
available_indices = [
|
||||||
|
int(index)
|
||||||
|
for index in cluster_indices
|
||||||
|
if int(index) not in selected_indices
|
||||||
|
]
|
||||||
|
if not available_indices:
|
||||||
|
available_indices = [
|
||||||
|
index
|
||||||
|
for index in range(len(self.candidate_nodes))
|
||||||
|
if index not in selected_indices
|
||||||
|
]
|
||||||
|
nearest_index = min(
|
||||||
|
available_indices,
|
||||||
|
key=lambda index: (
|
||||||
|
float(np.sum((coords_normalized[index] - center) ** 2)),
|
||||||
|
index,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
selected_indices.add(nearest_index)
|
||||||
|
nearest_node = self.candidate_nodes[nearest_index]
|
||||||
|
self.monitor_nodes.append(nearest_node)
|
||||||
|
|
||||||
|
return self.monitor_nodes
|
||||||
|
|
||||||
|
def visualize_network(self) -> None:
|
||||||
|
"""Visualize network with monitoring points."""
|
||||||
|
wntr.graphics.plot_network(
|
||||||
|
self.wn,
|
||||||
|
node_attribute=self.monitor_nodes,
|
||||||
|
node_size=30,
|
||||||
|
title="Optimal sensor",
|
||||||
|
)
|
||||||
|
plt.show()
|
||||||
|
|
||||||
|
|
||||||
|
def optimize_sensor_placement(
|
||||||
|
network_model: wntr.network.WaterNetworkModel,
|
||||||
|
sensor_count: int,
|
||||||
|
min_diameter_mm: float,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Select sensor nodes from an already loaded network model."""
|
||||||
|
|
||||||
|
placement = KMeansPlacement(network_model, sensor_count, min_diameter_mm)
|
||||||
|
return placement.select_monitoring_points()
|
||||||
-11
@@ -903,14 +903,3 @@ def optimize_sensor_placement_from_inp(
|
|||||||
sensor_num=sensor_num,
|
sensor_num=sensor_num,
|
||||||
min_diameter=min_diameter,
|
min_diameter=min_diameter,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_ID(name: str, sensor_num: int, min_diameter: int) -> list[str]:
|
|
||||||
"""Compatibility entry point used by the sensor placement service."""
|
|
||||||
|
|
||||||
inp_path = Path("db_inp") / f"{name}.db.inp"
|
|
||||||
return optimize_sensor_placement_from_inp(
|
|
||||||
inp_path,
|
|
||||||
sensor_num=sensor_num,
|
|
||||||
min_diameter=min_diameter,
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""SCADA time-series cleaning algorithms."""
|
||||||
|
|
||||||
|
from .flow_series import clean_flow_data_df_kf
|
||||||
|
from .pressure_series import clean_pressure_data_df_km
|
||||||
|
|
||||||
|
__all__ = ["clean_flow_data_df_kf", "clean_pressure_data_df_km"]
|
||||||
@@ -142,11 +142,13 @@ def clean_flow_data_kf(
|
|||||||
return os.path.abspath(output_path)
|
return os.path.abspath(output_path)
|
||||||
|
|
||||||
|
|
||||||
def clean_flow_data_df_kf(data: pd.DataFrame, show_plot: bool = False) -> dict:
|
def clean_flow_data_df_kf(
|
||||||
|
data: pd.DataFrame, show_plot: bool = False
|
||||||
|
) -> pd.DataFrame:
|
||||||
"""
|
"""
|
||||||
接收一个 DataFrame 数据结构,使用一维 Kalman 滤波平滑并用预测值替换基于 IQR 检测出的异常点。
|
接收一个 DataFrame 数据结构,使用一维 Kalman 滤波平滑并用预测值替换基于 IQR 检测出的异常点。
|
||||||
区分合理的0值(流量转换)和异常的0值(连续多个0或孤立0)。
|
区分合理的0值(流量转换)和异常的0值(连续多个0或孤立0)。
|
||||||
返回完整的清洗后的字典数据结构。
|
返回完整的清洗后 DataFrame。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data: 输入 DataFrame(可包含 time 列)
|
data: 输入 DataFrame(可包含 time 列)
|
||||||
@@ -305,42 +307,3 @@ def clean_flow_data_df_kf(data: pd.DataFrame, show_plot: bool = False) -> dict:
|
|||||||
|
|
||||||
# 返回完整的修复后字典
|
# 返回完整的修复后字典
|
||||||
return cleaned_data
|
return cleaned_data
|
||||||
|
|
||||||
|
|
||||||
# # 测试
|
|
||||||
# if __name__ == "__main__":
|
|
||||||
# # 默认:脚本目录下同名 CSV 文件
|
|
||||||
# script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
# default_csv = os.path.join(script_dir, "pipe_flow_data_to_clean2.0.csv")
|
|
||||||
# out = clean_flow_data_kf(default_csv)
|
|
||||||
# print("清洗后的数据已保存到:", out)
|
|
||||||
|
|
||||||
# 测试 clean_flow_data_dict 函数
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import random
|
|
||||||
|
|
||||||
# 读取 szh_flow_scada.csv 文件
|
|
||||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
csv_path = os.path.join(script_dir, "szh_flow_scada.csv")
|
|
||||||
data = pd.read_csv(csv_path, header=0, index_col=None, encoding="utf-8")
|
|
||||||
|
|
||||||
# 排除 Time 列,随机选择 5 列
|
|
||||||
columns_to_exclude = ["Time"]
|
|
||||||
available_columns = [col for col in data.columns if col not in columns_to_exclude]
|
|
||||||
selected_columns = random.sample(available_columns, 1)
|
|
||||||
|
|
||||||
# 将选中的列转换为字典
|
|
||||||
data_dict = {col: data[col].tolist() for col in selected_columns}
|
|
||||||
|
|
||||||
print("选中的列:", selected_columns)
|
|
||||||
print("原始数据长度:", len(data_dict[selected_columns[0]]))
|
|
||||||
|
|
||||||
# 调用函数进行清洗
|
|
||||||
cleaned_dict = clean_flow_data_df_kf(data_dict, show_plot=True)
|
|
||||||
# 将清洗后的字典写回 CSV
|
|
||||||
out_csv = os.path.join(script_dir, f"{selected_columns[0]}_clean.csv")
|
|
||||||
pd.DataFrame(cleaned_dict).to_csv(out_csv, index=False, encoding="utf-8-sig")
|
|
||||||
print("已保存清洗结果到:", out_csv)
|
|
||||||
print("清洗后的字典键:", list(cleaned_dict.keys()))
|
|
||||||
print("清洗后的数据长度:", len(cleaned_dict[selected_columns[0]]))
|
|
||||||
print("测试完成:函数运行正常")
|
|
||||||
-36
@@ -541,39 +541,3 @@ def clean_pressure_data_df_km(data: pd.DataFrame, show_plot: bool = False) -> pd
|
|||||||
plt.show()
|
plt.show()
|
||||||
|
|
||||||
return data_repaired
|
return data_repaired
|
||||||
|
|
||||||
|
|
||||||
# 测试
|
|
||||||
# if __name__ == "__main__":
|
|
||||||
# # 默认使用脚本目录下的 pressure_raw_data.csv
|
|
||||||
# script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
# default_csv = os.path.join(script_dir, "pressure_raw_data.csv")
|
|
||||||
# out_path = clean_pressure_data_km(default_csv, show_plot=False)
|
|
||||||
# print("保存路径:", out_path)
|
|
||||||
|
|
||||||
# 测试 clean_pressure_data_dict_km 函数
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import random
|
|
||||||
|
|
||||||
# 读取 szh_pressure_scada.csv 文件
|
|
||||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
csv_path = os.path.join(script_dir, "szh_pressure_scada.csv")
|
|
||||||
data = pd.read_csv(csv_path, header=0, index_col=None, encoding="utf-8")
|
|
||||||
|
|
||||||
# 排除 Time 列,随机选择 5 列
|
|
||||||
columns_to_exclude = ["Time"]
|
|
||||||
available_columns = [col for col in data.columns if col not in columns_to_exclude]
|
|
||||||
selected_columns = random.sample(available_columns, 5)
|
|
||||||
|
|
||||||
# 将选中的列转换为字典
|
|
||||||
data_dict = {col: data[col].tolist() for col in selected_columns}
|
|
||||||
|
|
||||||
print("选中的列:", selected_columns)
|
|
||||||
print("原始数据长度:", len(data_dict[selected_columns[0]]))
|
|
||||||
|
|
||||||
# 调用函数进行清洗
|
|
||||||
cleaned_dict = clean_pressure_data_df_km(data_dict, show_plot=True)
|
|
||||||
|
|
||||||
print("清洗后的字典键:", list(cleaned_dict.keys()))
|
|
||||||
print("清洗后的数据长度:", len(cleaned_dict[selected_columns[0]]))
|
|
||||||
print("测试完成:函数运行正常")
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
from contextlib import contextmanager
|
|
||||||
import fcntl
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from app.algorithms.sensor import kmeans as kmeans_sensor
|
|
||||||
from app.algorithms.sensor import sensitivity
|
|
||||||
from app.infra.db.postgresql.sensor_placement import create_sensor_placement
|
|
||||||
from app.services.sensor_placement import (
|
|
||||||
SensorPlacementConflictError,
|
|
||||||
SensorPlacementValidationError,
|
|
||||||
validate_sensor_placement_nodes,
|
|
||||||
)
|
|
||||||
from app.services.tjnetwork import dump_inp
|
|
||||||
|
|
||||||
|
|
||||||
def _sensor_inp_path(name: str) -> Path:
|
|
||||||
if (
|
|
||||||
not name
|
|
||||||
or name in {".", ".."}
|
|
||||||
or "/" in name
|
|
||||||
or "\\" in name
|
|
||||||
or "\x00" in name
|
|
||||||
):
|
|
||||||
raise SensorPlacementValidationError("管网名称不是有效的项目标识")
|
|
||||||
return Path("db_inp") / f"{name}.db.inp"
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def _sensor_inp_lock(name: str):
|
|
||||||
inp_path = _sensor_inp_path(name)
|
|
||||||
inp_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
lock_path = inp_path.with_suffix(".sensor.lock")
|
|
||||||
with lock_path.open("w", encoding="utf-8") as lock_file:
|
|
||||||
try:
|
|
||||||
fcntl.flock(
|
|
||||||
lock_file.fileno(),
|
|
||||||
fcntl.LOCK_EX | fcntl.LOCK_NB,
|
|
||||||
)
|
|
||||||
except BlockingIOError as exc:
|
|
||||||
raise SensorPlacementConflictError(
|
|
||||||
"当前项目已有监测点优化任务正在运行,请稍后重试"
|
|
||||||
) from exc
|
|
||||||
try:
|
|
||||||
yield inp_path
|
|
||||||
finally:
|
|
||||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
|
||||||
|
|
||||||
|
|
||||||
def _create_validated_placement(
|
|
||||||
name: str,
|
|
||||||
*,
|
|
||||||
run_name: str,
|
|
||||||
min_diameter: int,
|
|
||||||
created_by: str,
|
|
||||||
sensor_locations: list[str],
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
validate_sensor_placement_nodes(name, sensor_locations)
|
|
||||||
return create_sensor_placement(
|
|
||||||
name,
|
|
||||||
run_name=run_name,
|
|
||||||
min_diameter=min_diameter,
|
|
||||||
created_by=created_by,
|
|
||||||
sensor_locations=sensor_locations,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def pressure_sensor_placement_sensitivity(
|
|
||||||
name: str,
|
|
||||||
scheme_name: str,
|
|
||||||
sensor_number: int,
|
|
||||||
min_diameter: int,
|
|
||||||
username: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
基于改进灵敏度法进行压力监测点优化布置
|
|
||||||
:param name: 数据库名称
|
|
||||||
:param scheme_name: 监测优化布置方案名称
|
|
||||||
:param sensor_number: 传感器数目
|
|
||||||
:param min_diameter: 最小管径
|
|
||||||
:param username: 用户名
|
|
||||||
:return: 新建的监测点方案
|
|
||||||
"""
|
|
||||||
with _sensor_inp_lock(name):
|
|
||||||
sensor_location = sensitivity.get_ID(
|
|
||||||
name=name,
|
|
||||||
sensor_num=sensor_number,
|
|
||||||
min_diameter=min_diameter,
|
|
||||||
)
|
|
||||||
return _create_validated_placement(
|
|
||||||
name,
|
|
||||||
run_name=scheme_name,
|
|
||||||
min_diameter=min_diameter,
|
|
||||||
created_by=username,
|
|
||||||
sensor_locations=sensor_location,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# 2025/08/21
|
|
||||||
# 基于kmeans聚类法进行压力监测点优化布置
|
|
||||||
def pressure_sensor_placement_kmeans(
|
|
||||||
name: str,
|
|
||||||
scheme_name: str,
|
|
||||||
sensor_number: int,
|
|
||||||
min_diameter: int,
|
|
||||||
username: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
基于聚类法进行压力监测点优化布置
|
|
||||||
:param name: 数据库名称(注意,此处数据库名称也是inp文件名称,inp文件与pg库名要一样)
|
|
||||||
:param scheme_name: 监测优化布置方案名称
|
|
||||||
:param sensor_number: 传感器数目
|
|
||||||
:param min_diameter: 最小管径
|
|
||||||
:param username: 用户名
|
|
||||||
:return: 新建的监测点方案
|
|
||||||
"""
|
|
||||||
# dump_inp
|
|
||||||
with _sensor_inp_lock(name) as inp_path:
|
|
||||||
dump_inp(name, str(inp_path), "2")
|
|
||||||
sensor_location = kmeans_sensor.kmeans_sensor_placement(
|
|
||||||
name=name,
|
|
||||||
sensor_num=sensor_number,
|
|
||||||
min_diameter=min_diameter,
|
|
||||||
)
|
|
||||||
return _create_validated_placement(
|
|
||||||
name,
|
|
||||||
run_name=scheme_name,
|
|
||||||
min_diameter=min_diameter,
|
|
||||||
created_by=username,
|
|
||||||
sensor_locations=sensor_location,
|
|
||||||
)
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import wntr
|
|
||||||
import pandas as pd
|
|
||||||
import numpy as np
|
|
||||||
import matplotlib.pyplot as plt
|
|
||||||
import sklearn.cluster
|
|
||||||
import os
|
|
||||||
|
|
||||||
|
|
||||||
class QD_KMeans(object):
|
|
||||||
def __init__(self, wn, num_monitors):
|
|
||||||
# self.inp = inp
|
|
||||||
self.cluster_num = num_monitors # 聚类中心个数,也即测压点个数
|
|
||||||
self.wn = wn
|
|
||||||
self.monitor_nodes = []
|
|
||||||
self.coords = []
|
|
||||||
self.junction_nodes = {} # Added missing initialization
|
|
||||||
|
|
||||||
def get_junctions_coordinates(self):
|
|
||||||
|
|
||||||
for junction_name in self.wn.junction_name_list:
|
|
||||||
junction = self.wn.get_node(junction_name)
|
|
||||||
self.junction_nodes[junction_name] = junction.coordinates
|
|
||||||
self.coords.append(junction.coordinates)
|
|
||||||
|
|
||||||
# print(f"Total junctions: {self.junction_coordinates}")
|
|
||||||
|
|
||||||
def select_monitoring_points(self):
|
|
||||||
if not self.coords: # Add check if coordinates are collected
|
|
||||||
self.get_junctions_coordinates()
|
|
||||||
coords = np.array(self.coords)
|
|
||||||
coords_normalized = (coords - coords.min(axis=0)) / (
|
|
||||||
coords.max(axis=0) - coords.min(axis=0)
|
|
||||||
)
|
|
||||||
kmeans = sklearn.cluster.KMeans(n_clusters=self.cluster_num, random_state=42)
|
|
||||||
kmeans.fit(coords_normalized)
|
|
||||||
|
|
||||||
for center in kmeans.cluster_centers_:
|
|
||||||
distances = np.sum((coords_normalized - center) ** 2, axis=1)
|
|
||||||
nearest_node = self.wn.junction_name_list[np.argmin(distances)]
|
|
||||||
self.monitor_nodes.append(nearest_node)
|
|
||||||
|
|
||||||
return self.monitor_nodes
|
|
||||||
|
|
||||||
def visualize_network(self):
|
|
||||||
"""Visualize network with monitoring points"""
|
|
||||||
ax = wntr.graphics.plot_network(
|
|
||||||
self.wn,
|
|
||||||
node_attribute=self.monitor_nodes,
|
|
||||||
node_size=30,
|
|
||||||
title="Optimal sensor",
|
|
||||||
)
|
|
||||||
plt.show()
|
|
||||||
|
|
||||||
|
|
||||||
def kmeans_sensor_placement(name: str, sensor_num: int, min_diameter: int) -> list:
|
|
||||||
inp_name = f"./db_inp/{name}.db.inp"
|
|
||||||
wn = wntr.network.WaterNetworkModel(inp_name)
|
|
||||||
wn_cluster = QD_KMeans(wn, sensor_num)
|
|
||||||
|
|
||||||
# Select monitoring pointse
|
|
||||||
sensor_ids = wn_cluster.select_monitoring_points()
|
|
||||||
|
|
||||||
# wn_cluster.visualize_network()
|
|
||||||
|
|
||||||
return sensor_ids
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# sensorindex = get_ID(name='suzhouhe_2024_cloud_0817', sensor_num=30, min_diameter=500)
|
|
||||||
sensorindex = kmeans_sensor_placement(name="szh", sensor_num=50, min_diameter=300)
|
|
||||||
print(sensorindex)
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
from app.algorithms.simulation.scenarios import (
|
|
||||||
convert_to_local_unit,
|
|
||||||
burst_analysis,
|
|
||||||
valve_close_analysis,
|
|
||||||
flushing_analysis,
|
|
||||||
contaminant_simulation,
|
|
||||||
age_analysis,
|
|
||||||
pressure_regulation,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"convert_to_local_unit",
|
|
||||||
"burst_analysis",
|
|
||||||
"valve_close_analysis",
|
|
||||||
"flushing_analysis",
|
|
||||||
"contaminant_simulation",
|
|
||||||
"age_analysis",
|
|
||||||
"pressure_regulation",
|
|
||||||
]
|
|
||||||
@@ -1,867 +0,0 @@
|
|||||||
import numpy as np
|
|
||||||
from functools import wraps
|
|
||||||
from app.services.tjnetwork import (
|
|
||||||
ChangeSet,
|
|
||||||
get_pattern,
|
|
||||||
get_patterns,
|
|
||||||
get_pump,
|
|
||||||
get_reservoir,
|
|
||||||
get_status,
|
|
||||||
get_tank,
|
|
||||||
get_time,
|
|
||||||
read_all,
|
|
||||||
run_project,
|
|
||||||
set_pattern,
|
|
||||||
set_status,
|
|
||||||
set_tank,
|
|
||||||
set_time,
|
|
||||||
)
|
|
||||||
# from get_real_status import *
|
|
||||||
from datetime import datetime,timedelta
|
|
||||||
from math import modf
|
|
||||||
import json
|
|
||||||
import pytz
|
|
||||||
import requests
|
|
||||||
import time
|
|
||||||
import app.services.project_info as project_info
|
|
||||||
from app.native.wndb.core.projects import temporary_project_database
|
|
||||||
from app.services.time_api import parse_clock_duration_seconds
|
|
||||||
|
|
||||||
url_path = 'http://10.101.15.16:9000/loong' # 内网
|
|
||||||
# url_path = 'http://183.64.62.100:9057/loong' # 外网
|
|
||||||
url_real = url_path + '/api/mpoints/realValue'
|
|
||||||
url_hist = url_path + '/api/curves/data'
|
|
||||||
|
|
||||||
PATTERN_TIME_STEP=15.0
|
|
||||||
DN_900_ID='2498'
|
|
||||||
DN_500_ID='3854'
|
|
||||||
DN_1000_ID='3853'
|
|
||||||
H_RESSURE='2510'
|
|
||||||
L_PRESURE='2514'
|
|
||||||
H_TANK='4780'
|
|
||||||
L_TANK='4854'
|
|
||||||
|
|
||||||
H_REGION_1='SA_ZBBDJSCP000002'
|
|
||||||
H_REGION_2='' #to do
|
|
||||||
L_REGION_1='SA_ZBBDTJSC000001'
|
|
||||||
L_REGION_2='SA_R00003'
|
|
||||||
|
|
||||||
# reservoir basic height
|
|
||||||
RESERVOIR_BASIC_HEIGHT = float(250.35)
|
|
||||||
|
|
||||||
# regions
|
|
||||||
regions = ['hp', 'lp']
|
|
||||||
regions_demand_patterns = {'hp': ['DN900', 'DN500'], 'lp': ['DN1000']} # 出厂水量近似表示用水量
|
|
||||||
regions_patterns = {'hp': ['ChuanYiJiXiao', 'BeiQuanHuaYuan', 'ZhuangYuanFuDi', 'JingNingJiaYuan',
|
|
||||||
'308', 'JiaYinYuan', 'XinChengGuoJi', 'YiJingBeiChen', 'ZhongYangXinDu',
|
|
||||||
'XinHaiJiaYuan', 'DongFengJie', 'DingYaXinYu', 'ZiYunTai', 'XieMaGuangChang',
|
|
||||||
'YongJinFu', 'BianDianZhan', 'BeiNanDaDao', 'TianShengLiJie', 'XueYuanXiaoQu',
|
|
||||||
'YunHuaLu', 'GaoJiaQiao', 'LuZuoFuLuXiaDuan', 'TianRunCheng', 'CaoJiaBa',
|
|
||||||
'PuLingChang', 'QiLongXiaoQu', 'TuanXiao',
|
|
||||||
'TuanShanBaoZhongShiHua', 'XieMa', 'BeiWenQuanJiuHaoErQi', 'LaiYinHuSiQi',
|
|
||||||
'DN500', 'DN900'],
|
|
||||||
'lp': ['PanXiMingDu', 'WanKeJinYuHuaFuGaoCeng', 'KeJiXiao',
|
|
||||||
'LuGouQiao', 'LongJiangHuaYuan', 'LaoQiZhongDui', 'ShiYanCun', 'TianQiDaSha',
|
|
||||||
'TianShengPaiChuSuo', 'TianShengShangPin', 'JiaoTang', 'RenMinHuaYuan',
|
|
||||||
'TaiJiBinJiangYiQi', 'TianQiHuaYuan', 'TaiJiBinJiangErQi', '122Zhong',
|
|
||||||
'WanKeJinYuHuaFuYangFang', 'ChengBeiCaiShiKou', 'WenXingShe', 'YueLiangTianBBGJCZ',
|
|
||||||
'YueLiangTian', 'YueLiangTian200', 'ChengTaoChang', 'HuoCheZhan', 'LiangKu', 'QunXingLu',
|
|
||||||
'JiuYuanErTongYiYuan', 'TangDouHua', 'TaiJiBinJiangErQi(SanJi)',
|
|
||||||
'ZhangDouHua', 'JinYunXiaoQuDN400',
|
|
||||||
'DN1000']}
|
|
||||||
|
|
||||||
# nodes
|
|
||||||
monitor_single_patterns = ['ChuanYiJiXiao', 'BeiQuanHuaYuan', 'ZhuangYuanFuDi', 'JingNingJiaYuan',
|
|
||||||
'308', 'JiaYinYuan', 'XinChengGuoJi', 'YiJingBeiChen', 'ZhongYangXinDu',
|
|
||||||
'XinHaiJiaYuan', 'DongFengJie', 'DingYaXinYu', 'ZiYunTai', 'XieMaGuangChang',
|
|
||||||
'YongJinFu', 'PanXiMingDu', 'WanKeJinYuHuaFuGaoCeng', 'KeJiXiao',
|
|
||||||
'LuGouQiao', 'LongJiangHuaYuan', 'LaoQiZhongDui', 'ShiYanCun', 'TianQiDaSha',
|
|
||||||
'TianShengPaiChuSuo', 'TianShengShangPin', 'JiaoTang', 'RenMinHuaYuan',
|
|
||||||
'TaiJiBinJiangYiQi', 'TianQiHuaYuan', 'TaiJiBinJiangErQi', '122Zhong',
|
|
||||||
'WanKeJinYuHuaFuYangFang']
|
|
||||||
monitor_single_patterns_id = {'ChuanYiJiXiao': '7338', 'BeiQuanHuaYuan': '7315', 'ZhuangYuanFuDi': '7316',
|
|
||||||
'JingNingJiaYuan': '7528', '308': '8272', 'JiaYinYuan': '7304',
|
|
||||||
'XinChengGuoJi': '7325', 'YiJingBeiChen': '7328', 'ZhongYangXinDu': '7329',
|
|
||||||
'XinHaiJiaYuan': '9138', 'DongFengJie': '7302', 'DingYaXinYu': '7331',
|
|
||||||
'ZiYunTai': '7420,9059', 'XieMaGuangChang': '7326', 'YongJinFu': '9059',
|
|
||||||
'PanXiMingDu': '7320', 'WanKeJinYuHuaFuGaoCeng': '7419',
|
|
||||||
'KeJiXiao': '7305', 'LuGouQiao': '7306', 'LongJiangHuaYuan': '7318',
|
|
||||||
'LaoQiZhongDui': '9075', 'ShiYanCun': '7309', 'TianQiDaSha': '7323',
|
|
||||||
'TianShengPaiChuSuo': '7335', 'TianShengShangPin': '7324', 'JiaoTang': '7332',
|
|
||||||
'RenMinHuaYuan': '7322', 'TaiJiBinJiangYiQi': '7333', 'TianQiHuaYuan': '8235',
|
|
||||||
'TaiJiBinJiangErQi': '7334', '122Zhong': '7314', 'WanKeJinYuHuaFuYangFang': '7418'}
|
|
||||||
|
|
||||||
monitor_unity_patterns = ['BianDianZhan', 'BeiNanDaDao', 'TianShengLiJie', 'XueYuanXiaoQu',
|
|
||||||
'YunHuaLu', 'GaoJiaQiao', 'LuZuoFuLuXiaDuan', 'TianRunCheng',
|
|
||||||
'CaoJiaBa', 'PuLingChang', 'QiLongXiaoQu', 'TuanXiao',
|
|
||||||
'ChengBeiCaiShiKou', 'WenXingShe', 'YueLiangTianBBGJCZ',
|
|
||||||
'YueLiangTian', 'YueLiangTian200',
|
|
||||||
'ChengTaoChang', 'HuoCheZhan', 'LiangKu', 'QunXingLu',
|
|
||||||
'TuanShanBaoZhongShiHua', 'XieMa', 'BeiWenQuanJiuHaoErQi', 'LaiYinHuSiQi',
|
|
||||||
'JiuYuanErTongYiYuan', 'TangDouHua', 'TaiJiBinJiangErQi(SanJi)',
|
|
||||||
'ZhangDouHua', 'JinYunXiaoQuDN400',
|
|
||||||
'DN500', 'DN900', 'DN1000']
|
|
||||||
monitor_unity_patterns_id = {'BianDianZhan': '7339', 'BeiNanDaDao': '7319', 'TianShengLiJie': '8242',
|
|
||||||
'XueYuanXiaoQu': '7327', 'YunHuaLu': '7312', 'GaoJiaQiao': '7340',
|
|
||||||
'LuZuoFuLuXiaDuan': '7343', 'TianRunCheng': '7310', 'CaoJiaBa': '7300',
|
|
||||||
'PuLingChang': '7307', 'QiLongXiaoQu': '7321', 'TuanXiao': '8963',
|
|
||||||
'ChengBeiCaiShiKou': '7330', 'WenXingShe': '7311',
|
|
||||||
'YueLiangTianBBGJCZ': '7313', 'YueLiangTian': '7313', 'YueLiangTian200': '7313',
|
|
||||||
'ChengTaoChang': '7301', 'HuoCheZhan': '7303',
|
|
||||||
'LiangKu': '7296', 'QunXingLu': '7308',
|
|
||||||
'DN500': '3854', 'DN900': '2498', 'DN1000': '3853'}
|
|
||||||
monitor_patterns = monitor_single_patterns + monitor_unity_patterns
|
|
||||||
monitor_patterns_id = {**monitor_single_patterns_id, **monitor_unity_patterns_id}
|
|
||||||
# pumps
|
|
||||||
pumps_name = ['1#', '2#', '3#', '4#', '5#', '6#', '7#']
|
|
||||||
pumps = ['PU00000', 'PU00001', 'PU00002', 'PU00003', 'PU00004', 'PU00005', 'PU00006']
|
|
||||||
variable_frequency_pumps = ['PU00004', 'PU00005', 'PU00006']
|
|
||||||
pumps_id = {'PU00000': '2747', 'PU00001': '2776', 'PU00002': '2730', 'PU00003': '2787',
|
|
||||||
'PU00004': '2500', 'PU00005': '2502', 'PU00006': '2504'}
|
|
||||||
# reservoirs
|
|
||||||
reservoirs = ['ZBBDJSCP000002', 'R00003']
|
|
||||||
reservoirs_id = {'ZBBDJSCP000002': '2497', 'R00003': '2571'}
|
|
||||||
# tanks
|
|
||||||
tanks = ['ZBBDTJSC000002', 'ZBBDTJSC000001']
|
|
||||||
tanks_id = {'ZBBDTJSC000002': '4780', 'ZBBDTJSC000001': '9774'}
|
|
||||||
|
|
||||||
|
|
||||||
class DataLoader:
|
|
||||||
"""数据加载器"""
|
|
||||||
def __init__(self, project_name, start_time: datetime, end_time: datetime,
|
|
||||||
pumps_control: dict = None, tank_initial_level_control: dict = None,
|
|
||||||
region_demand_control: dict = None, downloading_prohibition: bool = False):
|
|
||||||
self.project_name = project_name # 数据库名
|
|
||||||
self.current_time = self.round_time(datetime.now(pytz.timezone('Asia/Shanghai')), 1) # 圆整至整分钟
|
|
||||||
self.current_round_time = self.round_time(self.current_time, int(PATTERN_TIME_STEP))
|
|
||||||
self.updating_data_flag = True \
|
|
||||||
if self.current_round_time == self.round_time(start_time, int(PATTERN_TIME_STEP)) \
|
|
||||||
else False # 判断是否从当前时刻开始模拟(是否更新最新监测数据)
|
|
||||||
self.downloading_prohibition = downloading_prohibition # 是否禁止下载数据(默认False: 允许下载)
|
|
||||||
self.updating_data_flag = False if self.downloading_prohibition else self.updating_data_flag
|
|
||||||
self.pattern_start_index = get_pattern_index(
|
|
||||||
self.round_time(start_time, int(PATTERN_TIME_STEP)).strftime("%Y-%m-%d %H:%M:%S")) # pattern起始索引
|
|
||||||
self.pattern_end_index = get_pattern_index(
|
|
||||||
self.round_time(end_time, int(PATTERN_TIME_STEP)).strftime("%Y-%m-%d %H:%M:%S")) # pattern结束索引
|
|
||||||
self.pattern_index_list = list(range(self.pattern_start_index, self.pattern_end_index + 1)) # pattern索引列表
|
|
||||||
self.download_id = self.get_download_id() # 数据下载接口id '7338,7315,7316,...'
|
|
||||||
self.current_time_download_data = dict(
|
|
||||||
zip(self.download_id.split(','),
|
|
||||||
[np.nan]*len(list(self.download_id.split(','))))
|
|
||||||
) # {id(str): value(float)}
|
|
||||||
self.current_time_download_data_flag = dict(
|
|
||||||
zip(self.download_id.split(','),
|
|
||||||
[False]*len(list(self.download_id.split(','))))
|
|
||||||
) # 下载数据是否具备实时性, {id(str): flag(bool)}
|
|
||||||
self.old_flow_data = self.init_dict_of_list(dict(
|
|
||||||
zip(monitor_patterns,
|
|
||||||
[[np.nan]] * (len(monitor_patterns)))
|
|
||||||
)) # {pattern_name(str): flow(float)}
|
|
||||||
self.old_pattern_factor = self.init_dict_of_list(dict(
|
|
||||||
zip(monitor_patterns,
|
|
||||||
[[np.nan]] * (len(monitor_patterns)))
|
|
||||||
)) # {pattern_name(str): [pattern_factor(float)]}
|
|
||||||
self.new_flow_data = self.init_dict_of_list(dict(
|
|
||||||
zip(monitor_patterns,
|
|
||||||
[[np.nan]] * (len(monitor_patterns)))
|
|
||||||
)) # {pattern_name(str): flow(float)}
|
|
||||||
self.new_pattern_factor = self.init_dict_of_list(dict(
|
|
||||||
zip(monitor_patterns,
|
|
||||||
[[np.nan]] * (len(monitor_patterns)))
|
|
||||||
)) # {pattern_name(str): [pattern_factor(float)]}
|
|
||||||
self.reservoir_data = dict(zip(reservoirs, [np.nan]*len(reservoirs))) # {reservoir_name(str): level(float)}
|
|
||||||
self.tank_data = dict(zip(tanks, [np.nan] * len(tanks))) # {tank_name(str): level(float)}
|
|
||||||
self.pump_data = self.init_dict_of_list(
|
|
||||||
dict(zip(pumps, [[np.nan]]*len(pumps)))) # {pump_name(str): [frequency(float)]}
|
|
||||||
self.pump_control = pumps_control # {pump_name(str): [frequency(float)]}
|
|
||||||
self.tank_initial_level_control = tank_initial_level_control # {tank_name(str): level(float)}
|
|
||||||
self.region_demand_current = dict(zip(regions, [0]*len(regions))) # {region_name(str): total_demand(float)}
|
|
||||||
self.region_demand_control = region_demand_control # {region_name(str): total_demand(float)}
|
|
||||||
self.region_demand_control_factor = dict(
|
|
||||||
zip(regions, [1]*len(regions))) # 区域流量控制系数(用于调整用水量), {region_name(str): factor(float)}
|
|
||||||
|
|
||||||
def load_data(self):
|
|
||||||
"""生成数据集"""
|
|
||||||
self.download_data() # 下载实时数据
|
|
||||||
self.get_old_pattern_and_flow() # 读取历史记录pattern信息
|
|
||||||
self.cal_demand_convert_factor() # 计算用水量转换系数(设定用水量时)
|
|
||||||
self.set_new_flow() # 设置'更新'流量
|
|
||||||
self.set_new_pattern_factor() # 设置'更新'pattern factors
|
|
||||||
self.set_reservoirs() # 设置清水池
|
|
||||||
self.set_tanks() # 设置调节池
|
|
||||||
self.set_pumps() # 设置水泵
|
|
||||||
return self.pattern_start_index
|
|
||||||
|
|
||||||
def download_data(self):
|
|
||||||
"""下载数据"""
|
|
||||||
if self.updating_data_flag is True:
|
|
||||||
print('{} -- Start downloading data.'.format(
|
|
||||||
datetime.now().strftime('%Y-%m-%d %H:%M:%S')))
|
|
||||||
data_wait_flag = True
|
|
||||||
while data_wait_flag:
|
|
||||||
try:
|
|
||||||
newest_data_time = self.download_real_data(self.download_id) # 获取实时数据
|
|
||||||
except Exception as e:
|
|
||||||
print('{}\nWaiting for real data.'.format(e))
|
|
||||||
time.sleep(1)
|
|
||||||
else:
|
|
||||||
print('{} -- Downloading data ok. Newest timestamp: {}.'.format(
|
|
||||||
datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
|
||||||
newest_data_time.strftime('%Y-%m-%d %H:%M:%S')))
|
|
||||||
data_wait_flag = False
|
|
||||||
|
|
||||||
def cal_current_region_demand(self):
|
|
||||||
"""计算区域当前用水量"""
|
|
||||||
if self.updating_data_flag is True:
|
|
||||||
for region in self.region_demand_current.keys():
|
|
||||||
total_demand = 0
|
|
||||||
for pipe in regions_demand_patterns[region]:
|
|
||||||
total_demand += self.current_time_download_data[monitor_patterns_id[pipe]] # 出厂流量
|
|
||||||
self.region_demand_current[region] = total_demand
|
|
||||||
|
|
||||||
def cal_history_region_demand(self, pattern_index_list):
|
|
||||||
"""计算区域历史用水量(对应记录的pattern)"""
|
|
||||||
old_demand = {}
|
|
||||||
for region in regions:
|
|
||||||
total_demand = 0
|
|
||||||
for pipe_pattern_name in regions_demand_patterns[region]:
|
|
||||||
old_flows, old_patterns = self.get_history_pattern_info(self.project_name, pipe_pattern_name)
|
|
||||||
for idx in pattern_index_list:
|
|
||||||
total_demand += old_flows[idx] / 4 # 15分钟水量
|
|
||||||
old_demand[region] = total_demand
|
|
||||||
return old_demand
|
|
||||||
|
|
||||||
def cal_demand_convert_factor(self):
|
|
||||||
"""计算用水量转换系数(设定用水量时)"""
|
|
||||||
self.cal_current_region_demand() # 计算区域当前时刻用水量
|
|
||||||
old_demand_moment = self.cal_history_region_demand([self.pattern_start_index]) # 计算区域目标时刻总用水量
|
|
||||||
old_demand_period = self.cal_history_region_demand(self.pattern_index_list) # 计算区域目标时段总用水量
|
|
||||||
for region in regions:
|
|
||||||
self.region_demand_control_factor[region] \
|
|
||||||
= (self.region_demand_current[region] / 4) / old_demand_moment[region] \
|
|
||||||
if self.updating_data_flag is True else 1
|
|
||||||
self.region_demand_control_factor[region] = self.region_demand_control[region] / old_demand_period[region] \
|
|
||||||
if (self.region_demand_control is not None) and (region in self.region_demand_control.keys()) \
|
|
||||||
else self.region_demand_control_factor[region]
|
|
||||||
|
|
||||||
def get_old_pattern_and_flow(self):
|
|
||||||
"""获取所有pattern的选定时段的历史记录的pattern和flow"""
|
|
||||||
for idx in monitor_patterns: # 遍历patterns
|
|
||||||
old_flows, old_patterns = self.get_history_pattern_info(self.project_name, idx)
|
|
||||||
for pattern_idx in self.pattern_index_list:
|
|
||||||
old_flow_data = old_flows[pattern_idx]
|
|
||||||
old_pattern_factor = old_patterns[pattern_idx]
|
|
||||||
if pattern_idx == self.pattern_start_index: # 起始时刻
|
|
||||||
self.old_flow_data[idx][0] = old_flow_data
|
|
||||||
self.old_pattern_factor[idx][0] = old_pattern_factor
|
|
||||||
else:
|
|
||||||
self.old_flow_data[idx].append(old_flow_data)
|
|
||||||
self.old_pattern_factor[idx].append(old_pattern_factor)
|
|
||||||
|
|
||||||
def set_new_flow(self):
|
|
||||||
"""计算模拟时段新流量(相较于历史记录)"""
|
|
||||||
for idx in self.new_flow_data.keys(): # 遍历patterns
|
|
||||||
region_name = None
|
|
||||||
for region in regions_patterns.keys():
|
|
||||||
if idx in regions_patterns[region]:
|
|
||||||
region_name = region # pattern所属分区
|
|
||||||
break
|
|
||||||
# 实时流量
|
|
||||||
if self.updating_data_flag is True:
|
|
||||||
if idx in monitor_unity_patterns[-3:]: # 出水管流量
|
|
||||||
self.new_flow_data[idx][0] = self.current_time_download_data[monitor_patterns_id[idx]]
|
|
||||||
else: # 其余流量
|
|
||||||
self.new_flow_data[idx][0] \
|
|
||||||
= self.region_demand_control_factor[region_name] * self.old_flow_data[idx][0]
|
|
||||||
# if idx == 'ZiYunTai':
|
|
||||||
# idx_a, idx_b = monitor_patterns_id[idx].split(',')
|
|
||||||
# self.new_flow_data[idx][0] \
|
|
||||||
# = self.current_time_download_data[idx_a] - self.current_time_download_data[idx_b]
|
|
||||||
# else:
|
|
||||||
# self.new_flow_data[idx][0] = self.current_time_download_data[monitor_patterns_id[idx]]
|
|
||||||
# for data_id in monitor_patterns_id[idx].split(','):
|
|
||||||
# if (self.current_time_download_data_flag[data_id] is False) \
|
|
||||||
# and (idx not in [pipe for pipe_list in regions_demand_patterns.values()
|
|
||||||
# for pipe in pipe_list]): # 无法获取实时数据
|
|
||||||
# self.new_flow_data[idx][0] \
|
|
||||||
# = self.region_demand_control_factor[region_name] * self.old_flow_data[idx][0]
|
|
||||||
# break
|
|
||||||
# 根据设定用水量修改新流量
|
|
||||||
if (self.region_demand_control is not None) \
|
|
||||||
and (region_name in self.region_demand_control.keys()):
|
|
||||||
for pattern_idx in self.pattern_index_list:
|
|
||||||
if pattern_idx == self.pattern_start_index: # 起始时刻
|
|
||||||
self.new_flow_data[idx][0] \
|
|
||||||
= self.region_demand_control_factor[region_name] * self.old_flow_data[idx][0]
|
|
||||||
else:
|
|
||||||
self.new_flow_data[idx].append(
|
|
||||||
self.region_demand_control_factor[region_name]
|
|
||||||
* self.old_flow_data[idx][self.pattern_index_list.index(pattern_idx)]
|
|
||||||
)
|
|
||||||
|
|
||||||
def set_new_pattern_factor(self):
|
|
||||||
"""更新计算选定时段(设定用水量)/时刻的pattern factor"""
|
|
||||||
pattern_index_list = self.pattern_index_list \
|
|
||||||
if self.region_demand_control is not None \
|
|
||||||
else [self.pattern_start_index]
|
|
||||||
for idx in monitor_patterns: # 遍历patterns
|
|
||||||
for pattern_idx in pattern_index_list: # 遍历需要修改的pattern(index)
|
|
||||||
pattern_idx_cls = pattern_index_list.index(pattern_idx) # 转换index(类表存储结构)
|
|
||||||
old_flow_data = self.old_flow_data[idx][pattern_idx_cls]
|
|
||||||
old_pattern_factor = self.old_pattern_factor[idx][pattern_idx_cls]
|
|
||||||
if pattern_idx_cls == 0: # 起始时刻
|
|
||||||
if idx in monitor_single_patterns:
|
|
||||||
if not np.isnan(self.new_flow_data[idx][0]):
|
|
||||||
self.new_pattern_factor[idx][0] = (self.new_flow_data[idx][0] * 1000 / 3600) # m3/h to L/s
|
|
||||||
if idx in monitor_unity_patterns:
|
|
||||||
if not np.isnan(self.new_flow_data[idx][0]):
|
|
||||||
self.new_pattern_factor[idx][0] \
|
|
||||||
= old_pattern_factor * self.new_flow_data[idx][0] / old_flow_data
|
|
||||||
else:
|
|
||||||
if idx in monitor_single_patterns:
|
|
||||||
if len(self.new_flow_data[idx]) > pattern_idx_cls:
|
|
||||||
self.new_pattern_factor[idx].append(
|
|
||||||
(self.new_flow_data[idx][pattern_idx_cls] * 1000 / 3600)) # m3/h to L/s
|
|
||||||
if idx in monitor_unity_patterns:
|
|
||||||
if len(self.new_flow_data[idx]) > pattern_idx_cls:
|
|
||||||
self.new_pattern_factor[idx].append(
|
|
||||||
old_pattern_factor
|
|
||||||
* self.new_flow_data[idx][pattern_idx_cls]
|
|
||||||
/ old_flow_data)
|
|
||||||
|
|
||||||
def set_reservoirs(self):
|
|
||||||
"""设置清水池"""
|
|
||||||
if self.updating_data_flag is True:
|
|
||||||
for idx in self.reservoir_data.keys():
|
|
||||||
if self.current_time_download_data_flag[reservoirs_id[idx]] is False: # 无法获取实时数据
|
|
||||||
print('There is no current data of reservoir: {}.'.format(idx))
|
|
||||||
else:
|
|
||||||
self.reservoir_data[idx] \
|
|
||||||
= self.current_time_download_data[reservoirs_id[idx]] + RESERVOIR_BASIC_HEIGHT
|
|
||||||
|
|
||||||
def set_tanks(self):
|
|
||||||
"""设置调节池"""
|
|
||||||
for idx in self.tank_data.keys():
|
|
||||||
if self.updating_data_flag is True:
|
|
||||||
if self.current_time_download_data_flag[tanks_id[idx]] is False: # 无法获取实时数据
|
|
||||||
print('There is no current data of tank: {}.'.format(idx))
|
|
||||||
else:
|
|
||||||
self.tank_data[idx] = self.current_time_download_data[tanks_id[idx]]
|
|
||||||
self.tank_data[idx] = self.tank_initial_level_control[idx] \
|
|
||||||
if (self.tank_initial_level_control is not None) and (idx in self.tank_initial_level_control) \
|
|
||||||
else self.tank_data[idx]
|
|
||||||
|
|
||||||
def set_pumps(self):
|
|
||||||
"""设置水泵"""
|
|
||||||
for idx in self.pump_data.keys():
|
|
||||||
if self.updating_data_flag is True:
|
|
||||||
if self.current_time_download_data_flag[pumps_id[idx]] is False: # 无法获取实时数据
|
|
||||||
print('There is no current data of pump: {}.'.format(idx))
|
|
||||||
if (self.pump_control is not None) and (idx in self.pump_control.keys()):
|
|
||||||
self.pump_data[idx] = self.pump_control[idx]
|
|
||||||
else:
|
|
||||||
self.pump_data[idx] = [self.current_time_download_data[pumps_id[idx]]]
|
|
||||||
if (self.pump_control is not None) and (idx in self.pump_control.keys()):
|
|
||||||
self.pump_data[idx] = self.pump_data[idx] + self.pump_control[idx] \
|
|
||||||
if len(self.pump_control[idx]) < len(self.pattern_index_list) \
|
|
||||||
else self.pump_control[idx] # 水泵设定
|
|
||||||
else:
|
|
||||||
if (self.pump_control is not None) and (idx in self.pump_control.keys()):
|
|
||||||
self.pump_data[idx] = self.pump_control[idx]
|
|
||||||
self.pump_data[idx] \
|
|
||||||
= list(np.array(self.pump_data[idx]) / 50) \
|
|
||||||
if idx in variable_frequency_pumps else self.pump_data[idx]
|
|
||||||
|
|
||||||
def set_valves(self):
|
|
||||||
"""设置阀门"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
def download_real_data(self, ids: str):
|
|
||||||
"""加载实时数据"""
|
|
||||||
# 数据接口的地址
|
|
||||||
global url_real
|
|
||||||
# 设置GET请求的参数
|
|
||||||
params = {'ids': ids}
|
|
||||||
# 发送GET请求获取数据
|
|
||||||
response = requests.get(url_real, params=params)
|
|
||||||
# 检查响应状态码,200表示请求成功
|
|
||||||
if response.status_code == 200:
|
|
||||||
newest_data_time = None # 下载记录数据的最新时间
|
|
||||||
# 解析响应的JSON数据
|
|
||||||
data = response.json()
|
|
||||||
for realValue in data: # 取出逐个id的数据
|
|
||||||
data_time = convert_utc_to_bj(realValue['datadt']) # datetime
|
|
||||||
self.current_time_download_data[str(realValue['id'])] \
|
|
||||||
= float(realValue['realValue']) # {id(str): value(float)}
|
|
||||||
if data_time > self.current_round_time.replace(tzinfo=None) - timedelta(minutes=5): # 下载数据为实时数据
|
|
||||||
self.current_time_download_data_flag[str(realValue['id'])] = True
|
|
||||||
if newest_data_time is None:
|
|
||||||
newest_data_time = data_time
|
|
||||||
else:
|
|
||||||
newest_data_time = data_time if data_time > newest_data_time else newest_data_time # 更新最新时间
|
|
||||||
if newest_data_time <= self.current_round_time.replace(tzinfo=None) - timedelta(minutes=5): # 最新记录时间早于当前时间
|
|
||||||
warning_text = 'There is no current data with newest timestamp: {}.'.format(
|
|
||||||
newest_data_time.strftime('%Y-%m-%d %H:%M:%S'))
|
|
||||||
delta_time = self.current_round_time.replace(tzinfo=None) - newest_data_time
|
|
||||||
if delta_time < timedelta(minutes=PATTERN_TIME_STEP): # 时间接近(可等待再次下载)
|
|
||||||
raise Exception(warning_text)
|
|
||||||
else:
|
|
||||||
print(warning_text)
|
|
||||||
self.updating_data_flag = False
|
|
||||||
else:
|
|
||||||
for idx in monitor_unity_patterns[-3:]: # 出水管流量
|
|
||||||
if self.current_time_download_data_flag[monitor_patterns_id[idx]] is False: # 无法获取出水管流量的实时数据
|
|
||||||
print('There is no current data of outflow: {}.'.format(idx))
|
|
||||||
self.updating_data_flag = False
|
|
||||||
if self.updating_data_flag is False:
|
|
||||||
print('Abandon updating data with downloaded data.')
|
|
||||||
return newest_data_time
|
|
||||||
else:
|
|
||||||
# 如果请求不成功,打印错误信息
|
|
||||||
print("请求失败,状态码:", response.status_code)
|
|
||||||
raise ConnectionError('Cannot download data.')
|
|
||||||
|
|
||||||
@ staticmethod
|
|
||||||
def init_dict_of_list(dict_of_list):
|
|
||||||
"""初始化值为列表的字典(重新生成列表地址, 防止指向同一列表)"""
|
|
||||||
for idx in dict_of_list.keys():
|
|
||||||
dict_of_list[idx] = dict_of_list[idx].copy()
|
|
||||||
return dict_of_list
|
|
||||||
|
|
||||||
@ staticmethod
|
|
||||||
def get_download_id():
|
|
||||||
"""生成下载数据项的id"""
|
|
||||||
# id_list = (list(monitor_single_patterns_id.values())
|
|
||||||
# + list(monitor_unity_patterns_id.values())
|
|
||||||
# + list(tanks_id.values())
|
|
||||||
# + list(reservoirs_id.values())
|
|
||||||
# + list(pumps_id.values()))
|
|
||||||
id_list = (list(monitor_unity_patterns_id.values())[-3:]
|
|
||||||
+ list(tanks_id.values())
|
|
||||||
+ list(reservoirs_id.values())
|
|
||||||
+ list(pumps_id.values()))
|
|
||||||
id_list = sorted(set(id_list), key=id_list.index)
|
|
||||||
if None in id_list:
|
|
||||||
id_list.remove(None)
|
|
||||||
return ','.join(id_list)
|
|
||||||
|
|
||||||
@ staticmethod
|
|
||||||
def get_history_pattern_info(project_name, pattern_name):
|
|
||||||
"""读取选定pattern的保存的历史pattern信息(flow, factor)"""
|
|
||||||
factors_list = []
|
|
||||||
flow_list = []
|
|
||||||
patterns_info = read_all(
|
|
||||||
project_name,
|
|
||||||
"select flow, factor from network.pattern_flow_samples "
|
|
||||||
"where pattern_id = %s order by sequence_no",
|
|
||||||
(pattern_name,),
|
|
||||||
)
|
|
||||||
for item in patterns_info:
|
|
||||||
flow_list.append(float(item['flow']))
|
|
||||||
factors_list.append(float(item['factor']))
|
|
||||||
return flow_list, factors_list
|
|
||||||
|
|
||||||
@ staticmethod
|
|
||||||
def judge_time(current_time, time_index_list):
|
|
||||||
"""时间判断"""
|
|
||||||
current_index \
|
|
||||||
= time_index_list.index(current_time) if (current_time in time_index_list) else None
|
|
||||||
return current_index
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_time_index_list(start_time: datetime, end_time: datetime, step: int):
|
|
||||||
"""生成时间索引"""
|
|
||||||
time_index_list = [] # 时间索引[str]
|
|
||||||
time_index = start_time
|
|
||||||
while time_index <= end_time:
|
|
||||||
time_index_list.append(time_index)
|
|
||||||
time_index += timedelta(minutes=step)
|
|
||||||
return time_index_list
|
|
||||||
|
|
||||||
@ staticmethod
|
|
||||||
def round_time(time_: datetime, interval=5):
|
|
||||||
"""时间向下取整到整n分钟(北京时间): 四舍六入五留双/向下取整"""
|
|
||||||
# return datetime.fromtimestamp(round(time_.timestamp() / (60 * interval)) * (60 * interval))
|
|
||||||
return datetime.fromtimestamp(int((time_.timestamp()) // (60 * interval)) * (60 * interval))
|
|
||||||
|
|
||||||
|
|
||||||
def convert_utc_to_bj(utc_time_str):
|
|
||||||
"""将utc时间(str)转换成北京时间(datetime)"""
|
|
||||||
# 解析UTC时间字符串为datetime对象
|
|
||||||
utc_time = datetime.strptime(utc_time_str, '%Y-%m-%dT%H:%M:%SZ')
|
|
||||||
# 设定UTC时区
|
|
||||||
utc_timezone = pytz.timezone('UTC')
|
|
||||||
# 转换为北京时间
|
|
||||||
beijing_timezone = pytz.timezone('Asia/Shanghai')
|
|
||||||
beijing_time = utc_time.replace(tzinfo=utc_timezone).astimezone(beijing_timezone).replace(tzinfo=None)
|
|
||||||
return beijing_time
|
|
||||||
|
|
||||||
|
|
||||||
def get_datetime(cur_datetime:str):
|
|
||||||
str_format = "%Y-%m-%d %H:%M:%S"
|
|
||||||
return datetime.strptime(cur_datetime, str_format)
|
|
||||||
|
|
||||||
|
|
||||||
def get_strftime(cur_datetime: datetime):
|
|
||||||
str_format = "%Y-%m-%d %H:%M:%S"
|
|
||||||
return cur_datetime.strftime(str_format)
|
|
||||||
|
|
||||||
|
|
||||||
def step_time(cur_datetime:str, step=5):
|
|
||||||
str_format="%Y-%m-%d %H:%M:%S"
|
|
||||||
dt=datetime.strptime(cur_datetime,str_format)
|
|
||||||
dt=dt+timedelta(minutes=step)
|
|
||||||
return datetime.strftime(dt,str_format)
|
|
||||||
|
|
||||||
|
|
||||||
def get_pattern_index(cur_datetime:str)->int:
|
|
||||||
str_format="%Y-%m-%d %H:%M:%S"
|
|
||||||
dt=datetime.strptime(cur_datetime,str_format)
|
|
||||||
hr=dt.hour
|
|
||||||
mnt=dt.minute
|
|
||||||
i=int((hr*60+mnt)/PATTERN_TIME_STEP)
|
|
||||||
return i
|
|
||||||
|
|
||||||
def get_pattern_index_str(cur_datetime:str)->str:
|
|
||||||
i=get_pattern_index(cur_datetime)
|
|
||||||
[minN,hrN]=modf(i*PATTERN_TIME_STEP/60)
|
|
||||||
minN_str=str(int(minN*60))
|
|
||||||
minN_str=minN_str.zfill(2)
|
|
||||||
hrN_str=str(int(hrN))
|
|
||||||
hrN_str=hrN_str.zfill(2)
|
|
||||||
str_i='{}:{}:00'.format(hrN_str,minN_str)
|
|
||||||
return str_i
|
|
||||||
|
|
||||||
|
|
||||||
def from_seconds_to_clock (secs: int)->str:
|
|
||||||
hrs=int(secs/3600)
|
|
||||||
minutes=int((secs-hrs*3600)/60)
|
|
||||||
seconds=(secs-hrs*3600-minutes*60)
|
|
||||||
hrs_str=str(hrs).zfill(2)
|
|
||||||
minutes_str=str(minutes).zfill(2)
|
|
||||||
seconds_str=str(seconds).zfill(2)
|
|
||||||
str_clock='{}:{}:{}'.format(hrs_str,minutes_str,seconds_str)
|
|
||||||
return str_clock
|
|
||||||
|
|
||||||
def from_clock_to_seconds (clock: str)->int:
|
|
||||||
str_format="%Y-%m-%d %H:%M:%S"
|
|
||||||
dt=datetime.strptime(clock,str_format)
|
|
||||||
hr=dt.hour
|
|
||||||
mnt=dt.minute
|
|
||||||
seconds=dt.second
|
|
||||||
return hr*3600+mnt*60+seconds
|
|
||||||
|
|
||||||
def from_clock_to_seconds_2 (clock: str)->int:
|
|
||||||
return parse_clock_duration_seconds(clock)
|
|
||||||
|
|
||||||
|
|
||||||
def from_clock_to_seconds_3 (clock: str)->int:
|
|
||||||
return parse_clock_duration_seconds(clock)
|
|
||||||
|
|
||||||
|
|
||||||
###convert datetimestring
|
|
||||||
##"XXXX-XX-XXT00:00:00Z" ->"XXXX-XX-XX 00:00:00"
|
|
||||||
def trim_time_flag(url_date_time:str)->str:
|
|
||||||
str_datetime=str.replace(url_date_time,'T',' ')
|
|
||||||
str_datetime=str.replace(str_datetime,'Z','')
|
|
||||||
return str_datetime
|
|
||||||
|
|
||||||
# 单时间步长模拟
|
|
||||||
def run_simulation(name:str,start_datetime:str,end_datetime:str=None, duration:int=900)->str:
|
|
||||||
#get_current_data(cur_datetime)
|
|
||||||
#extract the patternindex from datetime
|
|
||||||
#e.g. 0: the first time step for 00:00-00:14; 1: the second step for 00:15-00:30
|
|
||||||
start_datetime=trim_time_flag(start_datetime)
|
|
||||||
if(end_datetime!=None):
|
|
||||||
end_datetime=trim_time_flag(end_datetime)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## redistribute the basedemand according to the currentTotalQ and the base_totalQ
|
|
||||||
# step 1. get_real _data
|
|
||||||
if end_datetime==None or start_datetime==end_datetime:
|
|
||||||
end_datetime=step_time(start_datetime)
|
|
||||||
|
|
||||||
# # ids=['2498','3854','3853','2510','2514','4780','4854']
|
|
||||||
# # real_data=get_real_data(ids,start_datetime,end_datetime)
|
|
||||||
# print(datetime.now(pytz.timezone('Asia/Shanghai')).strftime("%Y-%m-%d %H:%M:%S")+"--获取实时数据完毕\n")
|
|
||||||
# #step 2. re-distribute the real q to base demand of the node region_sa by region_sa
|
|
||||||
# regions=get_all_service_area_ids(name)
|
|
||||||
# total_demands={}
|
|
||||||
# for region in regions:
|
|
||||||
# total_demands[region]=get_total_base_demand(name,region)
|
|
||||||
# region_demand_factor={}
|
|
||||||
# #Region_ID:SA_ZBBDJSCP000002 高区;SA_R00003+SA_ZBBDTJSC000001 低区
|
|
||||||
# H_region_real_demands=real_data[DN_900_ID][start_datetime]+real_data[DN_500_ID][start_datetime]
|
|
||||||
# L_region_real_demands=real_data[DN_1000_ID][start_datetime]
|
|
||||||
# factor_H_zone=H_region_real_demands/total_demands[H_REGION_1]/3.6 #3.6: m3/h->L/s
|
|
||||||
# factor_L_zone=L_region_real_demands/(total_demands[L_REGION_1]+total_demands[L_REGION_2])/3.6
|
|
||||||
# print(datetime.now(pytz.timezone('Asia/Shanghai')).strftime("%Y-%m-%d %H:%M:%S")+"--流量因子计算完毕完毕\n")
|
|
||||||
# for region in regions:
|
|
||||||
# region_nodes=get_nodes_in_region(name,region)
|
|
||||||
# factor=1
|
|
||||||
# if region==H_REGION_1 or H_REGION_2:
|
|
||||||
# factor=factor_H_zone
|
|
||||||
# else:
|
|
||||||
# factor=factor_L_zone
|
|
||||||
#
|
|
||||||
# for node in region_nodes:
|
|
||||||
# d=get_demand(name,node)
|
|
||||||
# for r in d['demands']:
|
|
||||||
# r['demand']=factor*r['demand']
|
|
||||||
# cs=ChangeSet()
|
|
||||||
# cs.append(d)
|
|
||||||
# set_demand(name,cs)
|
|
||||||
#
|
|
||||||
# #
|
|
||||||
# #
|
|
||||||
# print(datetime.now(pytz.timezone('Asia/Shanghai')).strftime("%Y-%m-%d %H:%M:%S")+"--节点流量重分配完毕\n")
|
|
||||||
|
|
||||||
#step 3. set pattern index to the current time,and set duration to 300 secs
|
|
||||||
#
|
|
||||||
str_pattern_start=get_pattern_index_str(start_datetime)
|
|
||||||
dic_time=get_time(name)
|
|
||||||
dic_time['PATTERN START']=str_pattern_start
|
|
||||||
|
|
||||||
if duration !=None:
|
|
||||||
dic_time['DURATION']=from_seconds_to_clock(duration)
|
|
||||||
else:
|
|
||||||
dic_time['DURATION']=dic_time['HYDRAULIC TIMESTEP']
|
|
||||||
cs=ChangeSet()
|
|
||||||
cs.operations.append(dic_time)
|
|
||||||
set_time(name,cs)
|
|
||||||
|
|
||||||
# step4. run simulation and save the result to name-time.out for download
|
|
||||||
#inp_file = 'inp\\'+name+'.inp'
|
|
||||||
#db_name=name
|
|
||||||
#dump_inp(db_name,inp_file,'2')
|
|
||||||
# result=run_inp(db_name)
|
|
||||||
result=run_project(name)
|
|
||||||
#json string format
|
|
||||||
# simulation_result, output, report
|
|
||||||
result_data=json.loads(result)
|
|
||||||
#print(result_data['simulation_result'])
|
|
||||||
print(datetime.now(pytz.timezone('Asia/Shanghai')).strftime("%Y-%m-%d %H:%M:%S")+'run finished successfully\n')
|
|
||||||
#print(result_data['report'])
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
# 在线模拟
|
|
||||||
def _clean_extended_simulation(func):
|
|
||||||
@wraps(func)
|
|
||||||
def wrapper(name: str, simulation_type: str, *args, **kwargs):
|
|
||||||
if simulation_type.upper() != "EXTENDED":
|
|
||||||
return func(name, simulation_type, *args, **kwargs)
|
|
||||||
with temporary_project_database(name, "extended_simulation") as temporary:
|
|
||||||
kwargs["_temporary_project"] = temporary
|
|
||||||
return func(name, simulation_type, *args, **kwargs)
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
|
|
||||||
@_clean_extended_simulation
|
|
||||||
def run_simulation_ex(name: str, simulation_type: str, start_datetime: str,
|
|
||||||
end_datetime: str = None, duration: int = 0,
|
|
||||||
pump_control: dict[str, list] = None, tank_initial_level_control: dict[str, float] = None,
|
|
||||||
region_demand_control: dict[str, float] = None, valve_control: dict[str, dict] = None,
|
|
||||||
downloading_prohibition: bool = False,
|
|
||||||
_temporary_project: str | None = None) -> str:
|
|
||||||
time_cost_start = time.perf_counter()
|
|
||||||
print('{} -- Hydraulic simulation started.'.format(
|
|
||||||
datetime.now(pytz.timezone('Asia/Shanghai')).strftime('%Y-%m-%d %H:%M:%S')))
|
|
||||||
|
|
||||||
if simulation_type.upper() == 'REALTIME': # 实时模拟(修改原数据库)
|
|
||||||
name_c = name
|
|
||||||
elif simulation_type.upper() == 'EXTENDED': # 扩展模拟(复制数据库)
|
|
||||||
if _temporary_project is None:
|
|
||||||
raise RuntimeError("Extended simulation isolation was not prepared")
|
|
||||||
name_c = _temporary_project
|
|
||||||
else:
|
|
||||||
raise Exception('Incorrect simulation type, choose in (realtime, extended)')
|
|
||||||
|
|
||||||
# 时间处理
|
|
||||||
# extract the pattern index from datetime
|
|
||||||
# e.g. 0: the first time step for 00:00-00:14; 1: the second step for 00:15-00:30
|
|
||||||
# start_datetime = get_strftime(convert_utc_to_bj(start_datetime))
|
|
||||||
start_datetime = trim_time_flag(start_datetime)
|
|
||||||
if end_datetime is not None:
|
|
||||||
# end_datetime = get_strftime(convert_utc_to_bj(end_datetime))
|
|
||||||
end_datetime = trim_time_flag(end_datetime)
|
|
||||||
|
|
||||||
# pump name转化/输入值规范化
|
|
||||||
if pump_control is not None:
|
|
||||||
for key in list(pump_control.keys()):
|
|
||||||
pump_control[key] = [pump_control[key]] if type(pump_control[key]) is not list else pump_control[key]
|
|
||||||
pump_control[pumps[pumps_name.index(key)]] = pump_control.pop(key)
|
|
||||||
|
|
||||||
# 重新分配节点(nodes)水量
|
|
||||||
# 1) (single)base_demand_new=1, pattern_new=real_data
|
|
||||||
# 2) (unity)base_demand_new=base_demand_old, pattern_new=factor*pattern_old(factor=flow_new/flow_old)
|
|
||||||
# 获取需水量数据
|
|
||||||
# a) 历史pattern对应水量(读取保存数据库)
|
|
||||||
# b) 实时水量(数据接口下载)
|
|
||||||
|
|
||||||
# 修改node demand = 1, pattern factor *= demand(monitor single patterns对应node)
|
|
||||||
# nodes = get_nodes(name_c) # nodes
|
|
||||||
# for node_name in nodes: # 遍历nodes
|
|
||||||
# demands_dict = get_demand(name_c, node_name) # {'demands':[{'demand':, 'pattern':}]}
|
|
||||||
# for demands in demands_dict['demands']:
|
|
||||||
# if (demands['pattern'] in monitor_single_patterns) and (demands['demand'] != 1): # 1)
|
|
||||||
# pattern = get_pattern(name_c, demands['pattern'])
|
|
||||||
# pattern['factors'] = list(demands['demand'] * np.array(pattern['factors'])) # 修改pattern
|
|
||||||
# cs = ChangeSet()
|
|
||||||
# cs.append(pattern)
|
|
||||||
# set_pattern(name_c, cs)
|
|
||||||
# demands_dict['demands'][
|
|
||||||
# demands_dict['demands'].index(demands)
|
|
||||||
# ]['demand'] = 1 # 修改demand
|
|
||||||
# cs = ChangeSet()
|
|
||||||
# cs.append(demands_dict)
|
|
||||||
# set_demand(name_c, cs)
|
|
||||||
|
|
||||||
start_time = get_datetime(start_datetime) # datetime
|
|
||||||
end_time = get_datetime(end_datetime) \
|
|
||||||
if end_datetime is not None \
|
|
||||||
else get_datetime(start_datetime) + timedelta(seconds=duration) # datetime
|
|
||||||
# modify_pattern_start_index = get_pattern_index(start_datetime) # 待修改pattern的起始索引(int)
|
|
||||||
|
|
||||||
dataset_loader = DataLoader(project_name=name_c,
|
|
||||||
start_time=start_time, end_time=end_time,
|
|
||||||
pumps_control=pump_control, tank_initial_level_control=tank_initial_level_control,
|
|
||||||
region_demand_control=region_demand_control,
|
|
||||||
downloading_prohibition=downloading_prohibition) # 实例化数据加载器
|
|
||||||
modify_index \
|
|
||||||
= dataset_loader.load_data() # 加载数据(index: 需要修改pattern的factor index, None: 无需修改除水泵和调节池外pattern)
|
|
||||||
new_patterns \
|
|
||||||
= dataset_loader.new_pattern_factor # {name: float,} pattern factor(实时: 更新, 其他: 保持/更新(设定用水量时))
|
|
||||||
tank_init_level = dataset_loader.tank_data # {name: float,} 调节池初始液位(实时: 更新, 其他: 保持/更新(设定液位时))
|
|
||||||
reservoir_level = dataset_loader.reservoir_data # {name: float,} 水库液位(实时: 更新, 其他: 保持)
|
|
||||||
pump_freq = dataset_loader.pump_data # {name: [float,]} 水泵频率(实时: 更新, 其他: 保持/更新(设定状态时))
|
|
||||||
print(datetime.now(pytz.timezone('Asia/Shanghai')).strftime("%Y-%m-%d %H:%M:%S") + " -- Loading data ok.\n")
|
|
||||||
|
|
||||||
pattern_name_list = get_patterns(name_c) # 所有pattern
|
|
||||||
# 修改node pattern/demand
|
|
||||||
# nodes = get_nodes(name_c) # nodes
|
|
||||||
# for node_name in nodes: # 遍历nodes
|
|
||||||
# demands_dict = get_demand(name_c, node_name) # {'demands':[{'demand':, 'pattern':}]}
|
|
||||||
# for demands in demands_dict['demands']:
|
|
||||||
# if demands['pattern'] in monitor_single_patterns: # 1)
|
|
||||||
# demands_dict['demands'][
|
|
||||||
# demands_dict['demands'].index(demands)
|
|
||||||
# ]['demand'] = 1 # 修改demand
|
|
||||||
# pattern = get_pattern(name_c, demands['pattern'])
|
|
||||||
# pattern['factors'][modify_index] = flow_new[demands['pattern']] # 修改pattern
|
|
||||||
# cs = ChangeSet()
|
|
||||||
# cs.append(pattern)
|
|
||||||
# set_pattern(name_c, cs)
|
|
||||||
# if demands['pattern'] in pattern_name_list:
|
|
||||||
# pattern_name_list.remove(demands['pattern']) # 移出待修改pattern列表
|
|
||||||
# else: # 2)
|
|
||||||
# continue
|
|
||||||
# cs = ChangeSet()
|
|
||||||
# cs.append(demands_dict)
|
|
||||||
# set_demand(name_c, cs)
|
|
||||||
for pattern_name in monitor_patterns: # 遍历patterns
|
|
||||||
if not np.isnan(new_patterns[pattern_name][0]):
|
|
||||||
pattern = get_pattern(name_c, pattern_name)
|
|
||||||
pattern['factors'][modify_index:
|
|
||||||
modify_index + len(new_patterns[pattern_name])] \
|
|
||||||
= new_patterns[pattern_name]
|
|
||||||
cs = ChangeSet()
|
|
||||||
cs.append(pattern)
|
|
||||||
set_pattern(name_c, cs)
|
|
||||||
if pattern_name in pattern_name_list:
|
|
||||||
pattern_name_list.remove(pattern_name) # 移出待修改pattern列表
|
|
||||||
|
|
||||||
# 修改清水池(reservoir)液位pattern
|
|
||||||
for reservoir_name in reservoirs: # 遍历reservoirs
|
|
||||||
if (not np.isnan(reservoir_level[reservoir_name])) and (reservoir_level[reservoir_name] != 0):
|
|
||||||
reservoir_pattern = get_pattern(name_c, get_reservoir(name_c, reservoir_name)['pattern'])
|
|
||||||
reservoir_pattern['factors'][modify_index] = reservoir_level[reservoir_name]
|
|
||||||
cs = ChangeSet()
|
|
||||||
cs.append(reservoir_pattern)
|
|
||||||
set_pattern(name_c, cs)
|
|
||||||
if reservoir_pattern['id'] in pattern_name_list:
|
|
||||||
pattern_name_list.remove(reservoir_pattern['id']) # 移出待修改pattern列表
|
|
||||||
|
|
||||||
# 修改调节池(tank)初始液位
|
|
||||||
for tank_name in tanks: # 遍历tanks
|
|
||||||
if (not np.isnan(tank_init_level[tank_name])) and (tank_init_level[tank_name] != 0):
|
|
||||||
tank = get_tank(name_c, tank_name)
|
|
||||||
tank['init_level'] = tank_init_level[tank_name]
|
|
||||||
cs = ChangeSet()
|
|
||||||
cs.append(tank)
|
|
||||||
set_tank(name_c, cs)
|
|
||||||
|
|
||||||
# 修改水泵(pump)pattern
|
|
||||||
for pump_name in pumps: # 遍历pumps
|
|
||||||
if not np.isnan(pump_freq[pump_name][0]):
|
|
||||||
pump_pattern = get_pattern(name_c, get_pump(name_c, pump_name)['pattern'])
|
|
||||||
pump_pattern['factors'][modify_index
|
|
||||||
:modify_index + len(pump_freq[pump_name])] \
|
|
||||||
= pump_freq[pump_name]
|
|
||||||
cs = ChangeSet()
|
|
||||||
cs.append(pump_pattern)
|
|
||||||
set_pattern(name_c, cs)
|
|
||||||
if pump_pattern['id'] in pattern_name_list:
|
|
||||||
pattern_name_list.remove(pump_pattern['id']) # 移出待修改pattern列表
|
|
||||||
# 修改阀门(valve)status和setting
|
|
||||||
if valve_control is not None:
|
|
||||||
for valve in valve_control.keys():
|
|
||||||
status = get_status(name_c, valve)
|
|
||||||
if 'status' in valve_control[valve].keys():
|
|
||||||
status['status'] = valve_control[valve]['status']
|
|
||||||
if 'setting' in valve_control[valve].keys():
|
|
||||||
status['setting'] = valve_control[valve]['setting']
|
|
||||||
if 'k' in valve_control[valve].keys():
|
|
||||||
valve_k = valve_control[valve]['k']
|
|
||||||
if valve_k == 0:
|
|
||||||
status['status'] = 'CLOSED'
|
|
||||||
else:
|
|
||||||
status['setting'] = 0.1036 * pow(valve_k, -3.105)
|
|
||||||
cs = ChangeSet()
|
|
||||||
cs.append(status)
|
|
||||||
set_status(name_c, cs)
|
|
||||||
|
|
||||||
print('Finish demands amending, unmodified patterns: {}.'.format(pattern_name_list))
|
|
||||||
|
|
||||||
# 修改时间信息
|
|
||||||
str_pattern_start = get_pattern_index_str(
|
|
||||||
DataLoader.round_time(start_time, int(PATTERN_TIME_STEP)).strftime("%Y-%m-%d %H:%M:%S"))
|
|
||||||
dic_time = get_time(name_c)
|
|
||||||
dic_time['PATTERN START'] = str_pattern_start
|
|
||||||
|
|
||||||
if duration is not None:
|
|
||||||
dic_time['DURATION'] = from_seconds_to_clock(duration)
|
|
||||||
else:
|
|
||||||
dic_time['DURATION'] = dic_time['HYDRAULIC TIMESTEP']
|
|
||||||
cs = ChangeSet()
|
|
||||||
cs.operations.append(dic_time)
|
|
||||||
set_time(name_c, cs)
|
|
||||||
|
|
||||||
# 运行并返回结果
|
|
||||||
result = run_project(name_c)
|
|
||||||
|
|
||||||
time_cost_end = time.perf_counter()
|
|
||||||
print('{} -- Hydraulic simulation finished, cost time: {:.2f} s.'.format(
|
|
||||||
datetime.now(pytz.timezone('Asia/Shanghai')).strftime('%Y-%m-%d %H:%M:%S'),
|
|
||||||
time_cost_end - time_cost_start))
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
# if get_current_data()==True:
|
|
||||||
# tQ=get_current_total_Q()
|
|
||||||
# print(f"the current tQ is {tQ}\n")
|
|
||||||
# data=get_hist_data(ids,conver_beingtime_to_ucttime('2024-04-10 15:05:00'),conver_beingtime_to_ucttime('2024-04-10 15:10:00'))
|
|
||||||
# read_inp("beibeizone","beibeizone-export_nochinese.inp")
|
|
||||||
# run_simulation("beibeizone","2024-04-01T08:00:00Z")
|
|
||||||
# read_inp('bb_server', 'model20_en.inp')
|
|
||||||
run_simulation_ex(
|
|
||||||
name=project_info.name, simulation_type='extended', start_datetime='2024-11-09T02:30:00Z',
|
|
||||||
# end_datetime='2024-05-30T16:00:00Z',
|
|
||||||
# duration=0,
|
|
||||||
# pump_control={'PU00006': [45, 40]}
|
|
||||||
# region_demand_control={'hp': 6000, 'lp': 2000}
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from app.algorithms.valve_isolation.topology_search import valve_isolation_analysis
|
||||||
|
|
||||||
|
__all__ = ["valve_isolation_analysis"]
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Topology-only valve isolation search."""
|
||||||
|
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
from typing import Any, Iterable
|
||||||
|
|
||||||
|
|
||||||
|
VALVE_LINK_TYPE = "valve"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_link_entry(link_entry: str) -> tuple[str, str, str, str]:
|
||||||
|
parts = link_entry.split(":", 3)
|
||||||
|
if len(parts) != 4:
|
||||||
|
raise ValueError(f"Invalid link entry format: {link_entry}")
|
||||||
|
return parts[0], parts[1], parts[2], parts[3]
|
||||||
|
|
||||||
|
|
||||||
|
def valve_isolation_analysis(
|
||||||
|
link_entries: Iterable[str],
|
||||||
|
accident_elements: str | list[str],
|
||||||
|
disabled_valves: list[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Determine boundary valves and affected nodes from a topology snapshot."""
|
||||||
|
|
||||||
|
disabled_valves_set = set(disabled_valves or [])
|
||||||
|
target_elements = (
|
||||||
|
[accident_elements]
|
||||||
|
if isinstance(accident_elements, str)
|
||||||
|
else accident_elements
|
||||||
|
)
|
||||||
|
|
||||||
|
pipe_adj: dict[str, set[str]] = defaultdict(set)
|
||||||
|
all_valves: dict[str, tuple[str, str]] = {}
|
||||||
|
link_lookup: dict[str, tuple[str, str, str]] = {}
|
||||||
|
node_set: set[str] = set()
|
||||||
|
for link_entry in link_entries:
|
||||||
|
link_id, link_type, node1, node2 = _parse_link_entry(link_entry)
|
||||||
|
link_type_name = str(link_type).lower()
|
||||||
|
link_lookup[link_id] = (node1, node2, link_type_name)
|
||||||
|
node_set.update((node1, node2))
|
||||||
|
if link_type_name == VALVE_LINK_TYPE:
|
||||||
|
all_valves[link_id] = (node1, node2)
|
||||||
|
else:
|
||||||
|
pipe_adj[node1].add(node2)
|
||||||
|
pipe_adj[node2].add(node1)
|
||||||
|
|
||||||
|
start_nodes: set[str] = set()
|
||||||
|
for element in target_elements:
|
||||||
|
if element in node_set:
|
||||||
|
start_nodes.add(element)
|
||||||
|
elif element in link_lookup:
|
||||||
|
node1, node2, _ = link_lookup[element]
|
||||||
|
start_nodes.update((node1, node2))
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Accident element {element} was not found in topology")
|
||||||
|
|
||||||
|
extra_adj: dict[str, list[str]] = defaultdict(list)
|
||||||
|
boundary_valves: dict[str, tuple[str, str]] = {}
|
||||||
|
for valve_id, (node1, node2) in all_valves.items():
|
||||||
|
if valve_id in disabled_valves_set:
|
||||||
|
extra_adj[node1].append(node2)
|
||||||
|
extra_adj[node2].append(node1)
|
||||||
|
else:
|
||||||
|
boundary_valves[valve_id] = (node1, node2)
|
||||||
|
|
||||||
|
affected_nodes: set[str] = set()
|
||||||
|
queue = deque(start_nodes)
|
||||||
|
while queue:
|
||||||
|
node = queue.popleft()
|
||||||
|
if node in affected_nodes:
|
||||||
|
continue
|
||||||
|
affected_nodes.add(node)
|
||||||
|
queue.extend(pipe_adj.get(node, set()) - affected_nodes)
|
||||||
|
queue.extend(
|
||||||
|
neighbor
|
||||||
|
for neighbor in extra_adj.get(node, ())
|
||||||
|
if neighbor not in affected_nodes
|
||||||
|
)
|
||||||
|
|
||||||
|
must_close_valves: list[str] = []
|
||||||
|
optional_valves: list[str] = []
|
||||||
|
for valve_id, (node1, node2) in boundary_valves.items():
|
||||||
|
node1_affected = node1 in affected_nodes
|
||||||
|
node2_affected = node2 in affected_nodes
|
||||||
|
if node1_affected and node2_affected:
|
||||||
|
optional_valves.append(valve_id)
|
||||||
|
elif node1_affected or node2_affected:
|
||||||
|
must_close_valves.append(valve_id)
|
||||||
|
|
||||||
|
must_close_valves.sort()
|
||||||
|
optional_valves.sort()
|
||||||
|
isolatable = bool(must_close_valves)
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"accident_elements": target_elements,
|
||||||
|
"disabled_valves": disabled_valves,
|
||||||
|
"affected_nodes": sorted(affected_nodes) if isolatable else [],
|
||||||
|
"affected_node_count": len(affected_nodes),
|
||||||
|
"must_close_valves": must_close_valves,
|
||||||
|
"optional_valves": optional_valves,
|
||||||
|
"isolatable": isolatable,
|
||||||
|
}
|
||||||
|
if len(target_elements) == 1:
|
||||||
|
result["accident_element"] = target_elements[0]
|
||||||
|
return result
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
"""Water-demand distribution algorithms."""
|
|
||||||
|
|
||||||
from .service import (
|
|
||||||
calculate_demand_to_network,
|
|
||||||
calculate_demand_to_nodes,
|
|
||||||
calculate_demand_to_region,
|
|
||||||
distribute_demand_to_nodes,
|
|
||||||
distribute_demand_to_region,
|
|
||||||
get_total_base_demand,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"calculate_demand_to_network",
|
|
||||||
"calculate_demand_to_nodes",
|
|
||||||
"calculate_demand_to_region",
|
|
||||||
"distribute_demand_to_nodes",
|
|
||||||
"distribute_demand_to_region",
|
|
||||||
"get_total_base_demand",
|
|
||||||
]
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
from app.native.wndb.commands.executor import execute_batch_command
|
|
||||||
from app.native.wndb.core.database import ChangeSet
|
|
||||||
from app.native.wndb.gis.region_geometry import Topology, get_nodes_in_region
|
|
||||||
from app.native.wndb.gis.network_views import (
|
|
||||||
get_junction_demands,
|
|
||||||
sum_junction_base_demand,
|
|
||||||
)
|
|
||||||
from app.native.wndb.model.elements import get_nodes
|
|
||||||
|
|
||||||
|
|
||||||
DISTRIBUTION_TYPE_ADD = 'ADD'
|
|
||||||
DISTRIBUTION_TYPE_OVERRIDE = 'OVERRIDE'
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_demand_to_nodes(name: str, demand: float, nodes: list[str]) -> dict[str, float]:
|
|
||||||
if len(nodes) == 0 or demand == 0.0:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
topology = Topology(name, nodes)
|
|
||||||
t_nodes = topology.nodes()
|
|
||||||
t_links = topology.links()
|
|
||||||
|
|
||||||
length_sum = 0.0
|
|
||||||
for value in t_links.values():
|
|
||||||
length_sum += abs(value['length'])
|
|
||||||
|
|
||||||
if length_sum <= 0.0:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
demand_per_length = demand / length_sum
|
|
||||||
|
|
||||||
result: dict[str, float] = {}
|
|
||||||
for node, value in t_nodes.items():
|
|
||||||
if value["type"] != "junction":
|
|
||||||
continue
|
|
||||||
demand_per_node = 0.0
|
|
||||||
for link in value['links']:
|
|
||||||
demand_per_node += abs(t_links[link]['length']) * demand_per_length * 0.5
|
|
||||||
result[node] = demand_per_node
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_demand_to_region(name: str, demand: float, region: str) -> dict[str, float]:
|
|
||||||
nodes = get_nodes_in_region(name, region)
|
|
||||||
return calculate_demand_to_nodes(name, demand, nodes)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_demand_to_network(name: str, demand: float) -> dict[str, float]:
|
|
||||||
nodes = get_nodes(name)
|
|
||||||
return calculate_demand_to_nodes(name, demand, nodes)
|
|
||||||
|
|
||||||
|
|
||||||
def distribute_demand_to_nodes(name: str, demand: float, nodes: list[str], type: str = DISTRIBUTION_TYPE_ADD) -> ChangeSet:
|
|
||||||
if len(nodes) == 0 or demand == 0.0:
|
|
||||||
return ChangeSet()
|
|
||||||
if type != DISTRIBUTION_TYPE_ADD and type != DISTRIBUTION_TYPE_OVERRIDE:
|
|
||||||
return ChangeSet()
|
|
||||||
|
|
||||||
topology = Topology(name, nodes)
|
|
||||||
t_nodes = topology.nodes()
|
|
||||||
t_links = topology.links()
|
|
||||||
|
|
||||||
length_sum = 0.0
|
|
||||||
for value in t_links.values():
|
|
||||||
length_sum += abs(value['length'])
|
|
||||||
|
|
||||||
if length_sum <= 0.0:
|
|
||||||
return ChangeSet()
|
|
||||||
|
|
||||||
demand_per_length = demand / length_sum
|
|
||||||
|
|
||||||
cs = ChangeSet()
|
|
||||||
demands_by_junction = get_junction_demands(
|
|
||||||
name,
|
|
||||||
[node for node, value in t_nodes.items() if value["type"] == "junction"],
|
|
||||||
)
|
|
||||||
|
|
||||||
for node, value in t_nodes.items():
|
|
||||||
if value["type"] != "junction":
|
|
||||||
continue
|
|
||||||
demand_per_node = 0.0
|
|
||||||
for link in value['links']:
|
|
||||||
demand_per_node += abs(t_links[link]['length']) * demand_per_length * 0.5
|
|
||||||
|
|
||||||
ds = demands_by_junction.get(node, [])
|
|
||||||
if len(ds) == 0:
|
|
||||||
ds = [{'demand': demand_per_node, 'pattern': None, 'category': None}]
|
|
||||||
elif type == DISTRIBUTION_TYPE_ADD:
|
|
||||||
ds[0]['demand'] += demand_per_node
|
|
||||||
else:
|
|
||||||
ds[0]['demand'] = demand_per_node
|
|
||||||
cs.update({'type': 'demand', 'junction': node, 'demands': ds})
|
|
||||||
|
|
||||||
return execute_batch_command(name, cs)
|
|
||||||
|
|
||||||
|
|
||||||
def distribute_demand_to_region(name: str, demand: float, region: str, type: str = DISTRIBUTION_TYPE_ADD) -> ChangeSet:
|
|
||||||
nodes = get_nodes_in_region(name, region)
|
|
||||||
return distribute_demand_to_nodes(name, demand, nodes, type)
|
|
||||||
|
|
||||||
def get_total_base_demand(name: str, region: str) -> float:
|
|
||||||
nodes = get_nodes_in_region(name, region)
|
|
||||||
return sum_junction_base_demand(name, nodes)
|
|
||||||
@@ -4,6 +4,7 @@ from uuid import UUID
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Body
|
from fastapi import APIRouter, Depends, HTTPException, Body
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
from app.auth.keycloak_dependencies import get_current_keycloak_username
|
from app.auth.keycloak_dependencies import get_current_keycloak_username
|
||||||
from app.services.burst_detection import (
|
from app.services.burst_detection import (
|
||||||
@@ -73,6 +74,10 @@ async def detect_burst(
|
|||||||
HTTPException: 当处理过程中发生错误时
|
HTTPException: 当处理过程中发生错误时
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return run_burst_detection(**data.model_dump(), username=username)
|
return await run_in_threadpool(
|
||||||
|
run_burst_detection,
|
||||||
|
**data.model_dump(),
|
||||||
|
username=username,
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc))
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from uuid import UUID
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Body
|
from fastapi import APIRouter, Depends, HTTPException, Body
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
from app.auth.keycloak_dependencies import get_current_keycloak_username
|
from app.auth.keycloak_dependencies import get_current_keycloak_username
|
||||||
from app.services.burst_location import (
|
from app.services.burst_location import (
|
||||||
@@ -63,6 +64,10 @@ async def locate_burst(
|
|||||||
HTTPException: 当数据类型或值不正确时
|
HTTPException: 当数据类型或值不正确时
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return run_burst_location_by_network(**data.model_dump(), username=username)
|
return await run_in_threadpool(
|
||||||
|
run_burst_location_by_network,
|
||||||
|
**data.model_dump(),
|
||||||
|
username=username,
|
||||||
|
)
|
||||||
except (TypeError, ValueError) as exc:
|
except (TypeError, ValueError) as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc))
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
|||||||
@@ -3,34 +3,49 @@ from typing import Any
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Body
|
from fastapi import APIRouter, Depends, HTTPException, Body
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
from app.auth.keycloak_dependencies import get_current_keycloak_username
|
from app.auth.keycloak_dependencies import get_current_keycloak_username
|
||||||
from app.services.leakage_identifier import (
|
from app.services.dma_leakage_estimation import (
|
||||||
run_leakage_identification,
|
run_leakage_identification,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
DEFAULT_N_WORKERS = max(1, min((os.cpu_count() or 1) - 1, 4))
|
DEFAULT_N_WORKERS = max(1, min((os.cpu_count() or 1) - 1, 4))
|
||||||
|
MAX_POPULATION_SIZE = 1_000
|
||||||
|
MAX_GENERATIONS = 1_000
|
||||||
|
MAX_DURATION_HOURS = 168
|
||||||
|
|
||||||
|
|
||||||
class LeakageIdentifyRequest(BaseModel):
|
class LeakageIdentifyRequest(BaseModel):
|
||||||
"""漏损识别请求模型"""
|
"""漏损识别请求模型"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
network: str = Field(..., description="管网名称(或数据库名称)")
|
network: str = Field(..., description="管网名称(或数据库名称)")
|
||||||
observed_pressure_data: str | dict[str, list[Any]] | list[dict[str, Any]] | None = Field(
|
observed_pressure_data: dict[str, list[Any]] | list[dict[str, Any]] | None = (
|
||||||
None, description="观测的压力数据"
|
Field(None, description="观测的压力数据;文件路径不属于公共 API 输入")
|
||||||
)
|
)
|
||||||
start_time: float = Field(0, description="起始时间(小时)")
|
start_time: float = Field(0, ge=0, description="起始时间(小时)")
|
||||||
duration: float = Field(24, description="持续时间(小时)")
|
duration: float = Field(
|
||||||
timestep: float = Field(5, description="时间步长(分钟)")
|
24, gt=0, le=MAX_DURATION_HOURS, description="持续时间(小时)"
|
||||||
q_sum: float = Field(0.2, description="总流量(m3/s)")
|
)
|
||||||
|
timestep: float = Field(5, gt=0, le=1440, description="时间步长(分钟)")
|
||||||
|
q_sum: float = Field(0.2, ge=0, description="总流量(m3/s)")
|
||||||
q_sum_unit: str = Field("m3/s", description="流量单位")
|
q_sum_unit: str = Field("m3/s", description="流量单位")
|
||||||
output_dir: str = Field("db_inp", description="输出目录")
|
pop_size: int = Field(
|
||||||
pop_size: int = Field(50, description="种群大小")
|
50, ge=2, le=MAX_POPULATION_SIZE, description="种群大小"
|
||||||
max_gen: int = Field(100, description="最大代数")
|
)
|
||||||
n_workers: int = Field(DEFAULT_N_WORKERS, description="工作线程数")
|
max_gen: int = Field(100, ge=1, le=MAX_GENERATIONS, description="最大代数")
|
||||||
|
n_workers: int = Field(
|
||||||
|
DEFAULT_N_WORKERS,
|
||||||
|
ge=1,
|
||||||
|
le=DEFAULT_N_WORKERS,
|
||||||
|
description="工作进程数",
|
||||||
|
)
|
||||||
output_flow_unit: str = Field("m3/s", description="输出流量单位")
|
output_flow_unit: str = Field("m3/s", description="输出流量单位")
|
||||||
dma_count: int | None = Field(None, description="DMA区域数量")
|
dma_count: int | None = Field(None, ge=1, description="DMA区域数量")
|
||||||
scada_start: datetime | None = Field(None, description="SCADA数据起始时间")
|
scada_start: datetime | None = Field(None, description="SCADA数据起始时间")
|
||||||
scada_end: datetime | None = Field(None, description="SCADA数据结束时间")
|
scada_end: datetime | None = Field(None, description="SCADA数据结束时间")
|
||||||
sensor_nodes: list[str] | None = Field(None, description="传感器节点列表")
|
sensor_nodes: list[str] | None = Field(None, description="传感器节点列表")
|
||||||
@@ -63,6 +78,10 @@ async def identify_leakage(
|
|||||||
HTTPException: 当处理过程中发生错误时
|
HTTPException: 当处理过程中发生错误时
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return run_leakage_identification(**data.model_dump(), username=username)
|
return await run_in_threadpool(
|
||||||
|
run_leakage_identification,
|
||||||
|
**data.model_dump(),
|
||||||
|
username=username,
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc))
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
|||||||
@@ -7,10 +7,6 @@ from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
|
|||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from starlette.concurrency import run_in_threadpool
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
from app.algorithms.sensor import (
|
|
||||||
pressure_sensor_placement_kmeans,
|
|
||||||
pressure_sensor_placement_sensitivity,
|
|
||||||
)
|
|
||||||
from app.auth.metadata_dependencies import get_current_metadata_user
|
from app.auth.metadata_dependencies import get_current_metadata_user
|
||||||
from app.auth.project_dependencies import (
|
from app.auth.project_dependencies import (
|
||||||
ProjectContext,
|
ProjectContext,
|
||||||
@@ -34,6 +30,8 @@ from app.services.sensor_placement import (
|
|||||||
get_sensor_placement_candidate,
|
get_sensor_placement_candidate,
|
||||||
get_sensor_placement_run,
|
get_sensor_placement_run,
|
||||||
list_sensor_placement_runs,
|
list_sensor_placement_runs,
|
||||||
|
optimize_sensor_placement_by_kmeans,
|
||||||
|
optimize_sensor_placement_by_sensitivity,
|
||||||
update_sensor_placement_run,
|
update_sensor_placement_run,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -136,18 +134,18 @@ async def optimize_sensor_placement_scheme(
|
|||||||
network = _project_network(payload.network, project_context)
|
network = _project_network(payload.network, project_context)
|
||||||
_require_project_write(project_context)
|
_require_project_write(project_context)
|
||||||
optimizer = (
|
optimizer = (
|
||||||
pressure_sensor_placement_sensitivity
|
optimize_sensor_placement_by_sensitivity
|
||||||
if payload.method == "sensitivity"
|
if payload.method == "sensitivity"
|
||||||
else pressure_sensor_placement_kmeans
|
else optimize_sensor_placement_by_kmeans
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
created = await run_in_threadpool(
|
created = await run_in_threadpool(
|
||||||
optimizer,
|
optimizer,
|
||||||
name=network,
|
project_code=network,
|
||||||
scheme_name=payload.run_name,
|
run_name=payload.run_name,
|
||||||
sensor_number=payload.sensor_count,
|
sensor_count=payload.sensor_count,
|
||||||
min_diameter=payload.min_diameter,
|
min_diameter=payload.min_diameter,
|
||||||
username=current_user.username,
|
created_by=current_user.username,
|
||||||
)
|
)
|
||||||
run = get_sensor_placement_run(network, created["run_id"])
|
run = get_sensor_placement_run(network, created["run_id"])
|
||||||
return {**run, "can_edit": True}
|
return {**run, "can_edit": True}
|
||||||
|
|||||||
@@ -1,33 +1,22 @@
|
|||||||
from typing import Any, List, Literal, Optional
|
from typing import Any, List, Literal, Optional
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import json
|
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||||
import threading
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path, Body
|
|
||||||
from fastapi.responses import PlainTextResponse
|
from fastapi.responses import PlainTextResponse
|
||||||
from app.auth.keycloak_dependencies import get_current_keycloak_username
|
from app.auth.keycloak_dependencies import get_current_keycloak_username
|
||||||
import app.services.simulation as simulation
|
import app.services.simulation as simulation
|
||||||
from app.services.tjnetwork import (
|
from app.services.tjnetwork import (
|
||||||
run_project,
|
run_project,
|
||||||
run_project_return_dict,
|
run_project_return_dict,
|
||||||
run_inp,
|
|
||||||
dump_output,
|
|
||||||
)
|
)
|
||||||
from app.algorithms.simulation.scenarios import (
|
from app.services.simulation_scenarios import (
|
||||||
burst_analysis,
|
burst_analysis,
|
||||||
valve_close_analysis,
|
valve_close_analysis,
|
||||||
flushing_analysis,
|
flushing_analysis,
|
||||||
contaminant_simulation,
|
contaminant_simulation,
|
||||||
age_analysis,
|
|
||||||
# scheduling_analysis,
|
|
||||||
pressure_regulation,
|
pressure_regulation,
|
||||||
)
|
)
|
||||||
from app.services.simulation_ops import (
|
|
||||||
project_management,
|
|
||||||
scheduling_simulation,
|
|
||||||
daily_scheduling_simulation,
|
|
||||||
)
|
|
||||||
from app.services.valve_isolation import analyze_valve_isolation
|
from app.services.valve_isolation import analyze_valve_isolation
|
||||||
from app.services.time_api import (
|
from app.domain.time import (
|
||||||
parse_aware_time,
|
parse_aware_time,
|
||||||
parse_clock_duration_seconds,
|
parse_clock_duration_seconds,
|
||||||
parse_utc_time,
|
parse_utc_time,
|
||||||
@@ -49,57 +38,13 @@ class RunSimulationManuallyByDate(BaseModel):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
class BurstAnalysis(BaseModel):
|
|
||||||
name: str = Field(..., description="管网名称(或数据库名称)")
|
|
||||||
modify_pattern_start_time: str = Field(..., description="模式修改开始时间 (ISO 8601)")
|
|
||||||
burst_ID: List[str] | str | None = Field(None, description="爆管节点/管段ID列表")
|
|
||||||
burst_size: List[float] | float | int | None = Field(None, description="爆管流量大小")
|
|
||||||
modify_total_duration: int = Field(900, description="模拟总时长 (秒)")
|
|
||||||
modify_fixed_pump_pattern: Optional[dict[str, list]] = Field(None, description="定速泵模式修改")
|
|
||||||
modify_variable_pump_pattern: Optional[dict[str, list]] = Field(None, description="变速泵模式修改")
|
|
||||||
modify_valve_opening: Optional[dict[str, float]] = Field(None, description="阀门开度修改")
|
|
||||||
scheme_name: Optional[str] = Field(None, description="方案名称")
|
|
||||||
|
|
||||||
|
|
||||||
class SchedulingAnalysis(BaseModel):
|
|
||||||
network: str = Field(..., description="管网名称(或数据库名称)")
|
|
||||||
start_time: str = Field(..., description="开始时间")
|
|
||||||
pump_control: dict = Field(..., description="泵控制策略")
|
|
||||||
tank_id: str = Field(..., description="水箱ID")
|
|
||||||
water_plant_output_id: str = Field(..., description="水厂出水ID")
|
|
||||||
time_delta: Optional[int] = Field(300, description="时间步长 (秒)")
|
|
||||||
|
|
||||||
|
|
||||||
class PressureRegulation(BaseModel):
|
class PressureRegulation(BaseModel):
|
||||||
network: str = Field(..., description="管网名称(或数据库名称)")
|
network: str = Field(..., description="管网名称(或数据库名称)")
|
||||||
start_time: str = Field(..., description="开始时间")
|
start_time: str = Field(..., description="开始时间")
|
||||||
pump_control: dict = Field(..., description="泵控制策略")
|
pump_control: dict = Field(..., description="泵控制策略")
|
||||||
tank_init_level: Optional[dict] = Field(None, description="水箱初始水位")
|
tank_init_level: Optional[dict] = Field(None, description="水箱初始水位")
|
||||||
duration: Optional[int] = Field(900, description="持续时间 (秒)")
|
duration: Optional[int] = Field(900, description="持续时间 (秒)")
|
||||||
scheme_name: Optional[str] = Field(None, description="方案名称")
|
scheme_name: str = Field(..., min_length=1, description="方案名称")
|
||||||
|
|
||||||
|
|
||||||
class ProjectManagement(BaseModel):
|
|
||||||
network: str = Field(..., description="管网名称(或数据库名称)")
|
|
||||||
start_time: str = Field(..., description="开始时间")
|
|
||||||
pump_control: dict = Field(..., description="泵控制策略")
|
|
||||||
tank_init_level: Optional[dict] = Field(None, description="水箱初始水位")
|
|
||||||
region_demand: Optional[dict] = Field(None, description="区域需水量控制")
|
|
||||||
|
|
||||||
|
|
||||||
class DailySchedulingAnalysis(BaseModel):
|
|
||||||
network: str = Field(..., description="管网名称(或数据库名称)")
|
|
||||||
start_time: str = Field(..., description="开始时间")
|
|
||||||
pump_control: dict = Field(..., description="泵控制策略")
|
|
||||||
reservoir_id: str = Field(..., description="水库ID")
|
|
||||||
tank_id: str = Field(..., description="水箱ID")
|
|
||||||
water_plant_output_id: str = Field(..., description="水厂出水ID")
|
|
||||||
time_delta: Optional[int] = Field(300, description="时间步长 (秒)")
|
|
||||||
|
|
||||||
|
|
||||||
class PumpFailureState(BaseModel):
|
|
||||||
time: str = Field(..., description="故障发生时间")
|
|
||||||
pump_status: dict = Field(..., description="泵状态字典")
|
|
||||||
|
|
||||||
|
|
||||||
def run_simulation_manually_by_date(
|
def run_simulation_manually_by_date(
|
||||||
@@ -161,41 +106,15 @@ def run_project_return_dict_endpoint(network: str = Query(..., description="管
|
|||||||
return run_project_return_dict(network)
|
return run_project_return_dict(network)
|
||||||
|
|
||||||
|
|
||||||
# put in inp folder, name without extension
|
|
||||||
@router.post("/inp-runs", summary="运行INP文件", description="运行指定INP文件格式的管网模型进行水力模拟。INP文件应该放在inp文件夹中,参数为文件名不含扩展名。")
|
|
||||||
def run_inp_endpoint(network: str = Query(..., description="inp文件名(不含扩展名)")) -> str:
|
|
||||||
"""
|
|
||||||
运行INP文件
|
|
||||||
|
|
||||||
- **network**: inp文件名(不含扩展名)
|
|
||||||
|
|
||||||
从inp文件夹中读取指定的INP文件并运行模拟。
|
|
||||||
"""
|
|
||||||
return run_inp(network)
|
|
||||||
|
|
||||||
|
|
||||||
# path is absolute path
|
|
||||||
@router.get("/outputs", summary="导出模拟输出", description="导出指定路径的模拟输出文件内容。参数应为绝对路径。")
|
|
||||||
def dump_output_endpoint(output: str = Query(..., description="模拟输出文件的绝对路径")) -> str:
|
|
||||||
"""
|
|
||||||
导出模拟输出
|
|
||||||
|
|
||||||
- **output**: 模拟输出文件的绝对路径
|
|
||||||
|
|
||||||
读取并返回指定路径的模拟输出内容。
|
|
||||||
"""
|
|
||||||
return dump_output(output)
|
|
||||||
|
|
||||||
|
|
||||||
# Analysis Endpoints
|
# Analysis Endpoints
|
||||||
@router.post("/burst-analyses", summary="爆管分析(高级)", description="高级版本的爆管分析,支持在指定时间点修改泵控制模式和阀门开度,以分析这些改变对爆管影响的作用。支持固定泵和变速泵的独立控制。")
|
@router.post("/burst-analyses", summary="爆管分析(高级)", description="高级版本的爆管分析,支持在指定时间点修改泵控制模式和阀门开度,以分析这些改变对爆管影响的作用。支持固定泵和变速泵的独立控制。")
|
||||||
def fastapi_burst_analysis(
|
def fastapi_burst_analysis(
|
||||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
modify_pattern_start_time: str = Query(..., description="模式修改开始时间(ISO 8601格式)"),
|
modify_pattern_start_time: str = Query(..., description="模式修改开始时间(ISO 8601格式)"),
|
||||||
burst_ID: list[str] = Query(..., description="爆管节点/管段ID列表"),
|
burst_ID: list[str] = Query(..., min_length=1, description="爆管节点/管段ID列表"),
|
||||||
burst_size: list[float] = Query(..., description="对应各爆管点的爆管流量大小列表(L/s)"),
|
burst_size: list[float] = Query(..., min_length=1, description="对应各爆管点的爆管流量大小列表(L/s)"),
|
||||||
modify_total_duration: int = Query(..., description="模拟总时长(秒)"),
|
modify_total_duration: int = Query(..., gt=0, description="模拟总时长(秒)"),
|
||||||
scheme_name: str = Query(..., description="分析方案名称"),
|
scheme_name: str = Query(..., min_length=1, description="分析方案名称"),
|
||||||
username: str = Depends(get_current_keycloak_username),
|
username: str = Depends(get_current_keycloak_username),
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -210,6 +129,11 @@ def fastapi_burst_analysis(
|
|||||||
|
|
||||||
支持在指定时间修改泵控制模式和阀门开度。
|
支持在指定时间修改泵控制模式和阀门开度。
|
||||||
"""
|
"""
|
||||||
|
if len(burst_ID) != len(burst_size):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail="burst_id 与 burst_size 的数量必须一致",
|
||||||
|
)
|
||||||
burst_analysis(
|
burst_analysis(
|
||||||
name=network,
|
name=network,
|
||||||
modify_pattern_start_time=modify_pattern_start_time,
|
modify_pattern_start_time=modify_pattern_start_time,
|
||||||
@@ -442,48 +366,6 @@ def fastapi_contaminant_simulation(
|
|||||||
return result or "success"
|
return result or "success"
|
||||||
|
|
||||||
|
|
||||||
@router.post("/water-age-analyses", response_class=PlainTextResponse, summary="水龄分析(高级)", description="高级版本的水龄分析,在指定时间点进行分析,支持自定义模拟持续时间。返回纯文本格式的分析结果。")
|
|
||||||
def fastapi_age_analysis(
|
|
||||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
|
||||||
start_time: str = Query(..., description="分析开始时间(ISO 8601格式)"),
|
|
||||||
duration: int = Query(..., description="模拟持续时间(秒)"),
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
水龄分析(高级版本)
|
|
||||||
|
|
||||||
- **network**: 管网名称(或数据库名称)
|
|
||||||
- **start_time**: 分析开始时间
|
|
||||||
- **duration**: 模拟持续时间(秒)
|
|
||||||
|
|
||||||
分析指定时间段内管网中各节点的水体停留时间。
|
|
||||||
"""
|
|
||||||
result = age_analysis(network, start_time, duration)
|
|
||||||
return result or "success"
|
|
||||||
|
|
||||||
|
|
||||||
# @router.get("/schedulinganalysis/")
|
|
||||||
# async def scheduling_analysis_endpoint(network: str):
|
|
||||||
# return scheduling_analysis(network)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/pressure-regulation-calculations", summary="压力调节(基础)", description="对管网的压力进行调节分析,通过控制泵的运行来维持目标节点的目标压力。此为基础版本。")
|
|
||||||
def pressure_regulation_endpoint(
|
|
||||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
|
||||||
target_node: str = Query(..., description="目标节点ID"),
|
|
||||||
target_pressure: float = Query(..., description="目标压力值(kPa)"),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
压力调节(基础版本)
|
|
||||||
|
|
||||||
- **network**: 管网名称(或数据库名称)
|
|
||||||
- **target_node**: 目标节点ID
|
|
||||||
- **target_pressure**: 目标压力值(kPa)
|
|
||||||
|
|
||||||
通过泵控制维持目标节点的压力。
|
|
||||||
"""
|
|
||||||
return pressure_regulation(network, target_node, target_pressure)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/pressure-regulation-analyses", summary="压力调节(高级)", description="高级版本的压力调节分析,通过JSON请求体提供详细的控制参数,包括固定泵和变速泵的独立控制、水箱初始水位等。")
|
@router.post("/pressure-regulation-analyses", summary="压力调节(高级)", description="高级版本的压力调节分析,通过JSON请求体提供详细的控制参数,包括固定泵和变速泵的独立控制、水箱初始水位等。")
|
||||||
def fastapi_pressure_regulation(data: PressureRegulation = Body(..., description="压力调节控制参数")) -> str:
|
def fastapi_pressure_regulation(data: PressureRegulation = Body(..., description="压力调节控制参数")) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -495,7 +377,7 @@ def fastapi_pressure_regulation(data: PressureRegulation = Body(..., description
|
|||||||
- **pump_control**: 泵控制策略字典
|
- **pump_control**: 泵控制策略字典
|
||||||
- **tank_init_level**: 水箱初始水位字典(可选)
|
- **tank_init_level**: 水箱初始水位字典(可选)
|
||||||
- **duration**: 模拟持续时间(秒,可选,默认900)
|
- **duration**: 模拟持续时间(秒,可选,默认900)
|
||||||
- **scheme_name**: 控制方案名称(可选)
|
- **scheme_name**: 控制方案名称
|
||||||
|
|
||||||
支持固定泵和变速泵的独立控制。
|
支持固定泵和变速泵的独立控制。
|
||||||
"""
|
"""
|
||||||
@@ -525,137 +407,6 @@ def fastapi_pressure_regulation(data: PressureRegulation = Body(..., description
|
|||||||
return "success"
|
return "success"
|
||||||
|
|
||||||
|
|
||||||
@router.post("/project-managements", summary="项目管理(高级)", description="高级版本的项目管理,通过JSON请求体提供详细的控制参数,包括泵控制策略、水箱初始水位和区域需水量控制。")
|
|
||||||
def fastapi_project_management(data: ProjectManagement = Body(..., description="项目管理控制参数")) -> str:
|
|
||||||
"""
|
|
||||||
项目管理(高级版本)
|
|
||||||
|
|
||||||
请求体参数:
|
|
||||||
- **network**: 管网名称(或数据库名称)
|
|
||||||
- **start_time**: 管理开始时间
|
|
||||||
- **pump_control**: 泵控制策略字典
|
|
||||||
- **tank_init_level**: 水箱初始水位字典(可选)
|
|
||||||
- **region_demand**: 区域需水量控制字典(可选)
|
|
||||||
|
|
||||||
支持多维度的项目管理。
|
|
||||||
"""
|
|
||||||
item = data.model_dump()
|
|
||||||
return project_management(
|
|
||||||
prj_name=item["network"],
|
|
||||||
start_datetime=item["start_time"],
|
|
||||||
pump_control=item["pump_control"],
|
|
||||||
tank_initial_level_control=item["tank_init_level"],
|
|
||||||
region_demand_control=item["region_demand"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# @router.get("/dailyschedulinganalysis/")
|
|
||||||
# async def daily_scheduling_analysis_endpoint(network: str):
|
|
||||||
# return daily_scheduling_analysis(network)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/scheduling-analyses", summary="排程分析", description="对管网的供水排程进行分析,优化泵的运行时间和出水流量,平衡水厂出水、水箱进出水,满足用户需求。")
|
|
||||||
def fastapi_scheduling_analysis(data: SchedulingAnalysis = Body(..., description="排程分析参数")) -> str:
|
|
||||||
"""
|
|
||||||
排程分析
|
|
||||||
|
|
||||||
请求体参数:
|
|
||||||
- **network**: 管网名称(或数据库名称)
|
|
||||||
- **start_time**: 分析开始时间
|
|
||||||
- **pump_control**: 泵控制策略字典
|
|
||||||
- **tank_id**: 水箱ID
|
|
||||||
- **water_plant_output_id**: 水厂出水ID
|
|
||||||
- **time_delta**: 时间步长(秒,可选,默认300)
|
|
||||||
|
|
||||||
用于优化供水排程。
|
|
||||||
"""
|
|
||||||
item = data.model_dump()
|
|
||||||
return scheduling_simulation(
|
|
||||||
item["network"],
|
|
||||||
item["start_time"],
|
|
||||||
item["pump_control"],
|
|
||||||
item["tank_id"],
|
|
||||||
item["water_plant_output_id"],
|
|
||||||
item["time_delta"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/daily-scheduling-analyses", summary="日排程分析", description="对管网的每日供水排程进行分析,优化水库、水厂、水箱和用户需求的协调,制定合理的每日排程方案。")
|
|
||||||
def fastapi_daily_scheduling_analysis(data: DailySchedulingAnalysis = Body(..., description="日排程分析参数")) -> str:
|
|
||||||
"""
|
|
||||||
日排程分析
|
|
||||||
|
|
||||||
请求体参数:
|
|
||||||
- **network**: 管网名称(或数据库名称)
|
|
||||||
- **start_time**: 分析开始时间
|
|
||||||
- **pump_control**: 泵控制策略字典
|
|
||||||
- **reservoir_id**: 水库ID
|
|
||||||
- **tank_id**: 水箱ID
|
|
||||||
- **water_plant_output_id**: 水厂出水ID
|
|
||||||
- **time_delta**: 时间步长(秒,可选,默认300)
|
|
||||||
|
|
||||||
用于制定每日供水排程方案。
|
|
||||||
"""
|
|
||||||
item = data.model_dump()
|
|
||||||
return daily_scheduling_simulation(
|
|
||||||
item["network"],
|
|
||||||
item["start_time"],
|
|
||||||
item["pump_control"],
|
|
||||||
item["reservoir_id"],
|
|
||||||
item["tank_id"],
|
|
||||||
item["water_plant_output_id"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# @router.get("/pumpfailure/")
|
|
||||||
# async def pump_failure_endpoint(network: str, pump_id: str, time: str):
|
|
||||||
# return pump_failure(network, pump_id, time)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/pump-failure-events", summary="泵故障管理", description="记录和管理泵的故障状态,包括故障发生时间和受影响的泵列表。系统将记录故障日志并更新泵状态。")
|
|
||||||
def fastapi_pump_failure(data: PumpFailureState = Body(..., description="泵故障状态信息")) -> str:
|
|
||||||
"""
|
|
||||||
泵故障管理
|
|
||||||
|
|
||||||
请求体参数:
|
|
||||||
- **time**: 故障发生时间
|
|
||||||
- **pump_status**: 泵状态字典,包含第一阶段和第二阶段泵的故障状态
|
|
||||||
|
|
||||||
系统将验证泵信息的有效性并更新故障状态文件。
|
|
||||||
"""
|
|
||||||
item = data.model_dump()
|
|
||||||
with open("./pump_failure_message.txt", "a", encoding="utf-8-sig") as f1:
|
|
||||||
f1.write("[{}] {}\n".format(datetime.now().strftime("%Y-%m-%d %H:%M:%S"), item))
|
|
||||||
with open("./pump_failure_status.txt", "r", encoding="utf-8-sig") as f2:
|
|
||||||
lines = f2.readlines()
|
|
||||||
first_stage_pump_status_dict = json.loads(json.dumps(eval(lines[0])))
|
|
||||||
second_stage_pump_status_dict = json.loads(json.dumps(eval(lines[-1])))
|
|
||||||
pump_status_dict = {
|
|
||||||
"first": first_stage_pump_status_dict,
|
|
||||||
"second": second_stage_pump_status_dict,
|
|
||||||
}
|
|
||||||
status_info = item.copy()
|
|
||||||
for pump_type in status_info["pump_status"].keys():
|
|
||||||
if pump_type in pump_status_dict.keys():
|
|
||||||
if all(
|
|
||||||
pump_id in pump_status_dict[pump_type].keys()
|
|
||||||
for pump_id in status_info["pump_status"][pump_type].keys()
|
|
||||||
):
|
|
||||||
for pump_id in status_info["pump_status"][pump_type].keys():
|
|
||||||
pump_status_dict[pump_type][pump_id] = int(
|
|
||||||
status_info["pump_status"][pump_type][pump_id]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return json.dumps("ERROR: Wrong Pump ID")
|
|
||||||
else:
|
|
||||||
return json.dumps("ERROR: Wrong Pump Type")
|
|
||||||
with open("./pump_failure_status.txt", "w", encoding="utf-8-sig") as f2_:
|
|
||||||
f2_.write(
|
|
||||||
"{}\n{}".format(pump_status_dict["first"], pump_status_dict["second"])
|
|
||||||
)
|
|
||||||
return json.dumps("SUCCESS")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/simulation-runs", summary="手动运行日期指定模拟", description="根据指定的开始时间和持续时间,手动运行水力模拟。开始时间必须是显式带时区的 ISO 8601 / RFC3339 时间。")
|
@router.post("/simulation-runs", summary="手动运行日期指定模拟", description="根据指定的开始时间和持续时间,手动运行水力模拟。开始时间必须是显式带时区的 ISO 8601 / RFC3339 时间。")
|
||||||
def fastapi_run_simulation_manually_by_date(
|
def fastapi_run_simulation_manually_by_date(
|
||||||
data: RunSimulationManuallyByDate = Body(..., description="模拟运行参数"),
|
data: RunSimulationManuallyByDate = Body(..., description="模拟运行参数"),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from datetime import datetime
|
|||||||
from psycopg import AsyncConnection
|
from psycopg import AsyncConnection
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from app.infra.db.timescaledb.composite_queries import CompositeQueries
|
from app.services.timeseries_analysis import TimeseriesAnalysisService
|
||||||
from .dependencies import get_timescale_connection, get_postgres_connection
|
from .dependencies import get_timescale_connection, get_postgres_connection
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -46,7 +46,7 @@ async def get_scada_associated_simulation_data(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if run_id is not None:
|
if run_id is not None:
|
||||||
result = await CompositeQueries.get_scada_associated_analysis_simulation_data(
|
result = await TimeseriesAnalysisService.get_scada_associated_analysis_simulation_data(
|
||||||
timescale_conn,
|
timescale_conn,
|
||||||
postgres_conn,
|
postgres_conn,
|
||||||
device_ids_list,
|
device_ids_list,
|
||||||
@@ -56,7 +56,7 @@ async def get_scada_associated_simulation_data(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
result = (
|
result = (
|
||||||
await CompositeQueries.get_scada_associated_realtime_simulation_data(
|
await TimeseriesAnalysisService.get_scada_associated_realtime_simulation_data(
|
||||||
timescale_conn,
|
timescale_conn,
|
||||||
postgres_conn,
|
postgres_conn,
|
||||||
device_ids_list,
|
device_ids_list,
|
||||||
@@ -116,7 +116,7 @@ async def get_feature_simulation_data(
|
|||||||
raise HTTPException(status_code=400, detail="feature_infos cannot be empty")
|
raise HTTPException(status_code=400, detail="feature_infos cannot be empty")
|
||||||
|
|
||||||
if run_id is not None:
|
if run_id is not None:
|
||||||
result = await CompositeQueries.get_analysis_simulation_data(
|
result = await TimeseriesAnalysisService.get_analysis_simulation_data(
|
||||||
timescale_conn,
|
timescale_conn,
|
||||||
feature_infos_list,
|
feature_infos_list,
|
||||||
start_time,
|
start_time,
|
||||||
@@ -124,7 +124,7 @@ async def get_feature_simulation_data(
|
|||||||
run_id,
|
run_id,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
result = await CompositeQueries.get_realtime_simulation_data(
|
result = await TimeseriesAnalysisService.get_realtime_simulation_data(
|
||||||
timescale_conn,
|
timescale_conn,
|
||||||
feature_infos_list,
|
feature_infos_list,
|
||||||
start_time,
|
start_time,
|
||||||
@@ -168,7 +168,7 @@ async def get_element_associated_scada_data(
|
|||||||
HTTPException: 当查询参数无效时返回400错误,未找到关联数据返回404错误
|
HTTPException: 当查询参数无效时返回400错误,未找到关联数据返回404错误
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
result = await CompositeQueries.get_element_associated_scada_data(
|
result = await TimeseriesAnalysisService.get_element_associated_scada_data(
|
||||||
timescale_conn, postgres_conn, element_id, start_time, end_time, use_cleaned
|
timescale_conn, postgres_conn, element_id, start_time, end_time, use_cleaned
|
||||||
)
|
)
|
||||||
if result is None:
|
if result is None:
|
||||||
@@ -216,7 +216,7 @@ async def clean_scada_data(
|
|||||||
if device_ids
|
if device_ids
|
||||||
else []
|
else []
|
||||||
)
|
)
|
||||||
return await CompositeQueries.clean_scada_data(
|
return await TimeseriesAnalysisService.clean_scada_data(
|
||||||
timescale_conn, postgres_conn, device_ids_list, start_time, end_time
|
timescale_conn, postgres_conn, device_ids_list, start_time, end_time
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -246,7 +246,7 @@ async def predict_pipeline_health(
|
|||||||
HTTPException: 当模型文件不存在返回404错误,其他错误返回400或500错误
|
HTTPException: 当模型文件不存在返回404错误,其他错误返回400或500错误
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return await CompositeQueries.predict_pipeline_health(
|
return await TimeseriesAnalysisService.predict_pipeline_health(
|
||||||
timescale_conn, postgres_conn, query_time
|
timescale_conn, postgres_conn, query_time
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Read repositories for project network assets."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from psycopg import AsyncConnection
|
||||||
|
|
||||||
|
|
||||||
|
class NetworkAssetRepository:
|
||||||
|
@staticmethod
|
||||||
|
async def get_pipes_by_ids(
|
||||||
|
conn: AsyncConnection,
|
||||||
|
pipe_ids: list[str],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
if not pipe_ids:
|
||||||
|
return []
|
||||||
|
async with conn.cursor() as cur:
|
||||||
|
await cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, diameter, start_node_id AS node1,
|
||||||
|
end_node_id AS node2
|
||||||
|
FROM gis.pipes
|
||||||
|
WHERE id = ANY(%s)
|
||||||
|
ORDER BY id
|
||||||
|
""",
|
||||||
|
(pipe_ids,),
|
||||||
|
)
|
||||||
|
return await cur.fetchall()
|
||||||
@@ -1 +1 @@
|
|||||||
from .composite_queries import CompositeQueries
|
"""TimescaleDB repositories and connection infrastructure."""
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ 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.analysis import AnalysisResultsRepository
|
||||||
from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository
|
from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository
|
||||||
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
|
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
|
||||||
from app.services.time_api import parse_utc_time
|
from app.domain.time import parse_utc_time
|
||||||
|
|
||||||
|
|
||||||
class InternalStorage:
|
class InternalStorage:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from uuid import UUID
|
|||||||
|
|
||||||
from psycopg import AsyncConnection, Connection, sql
|
from psycopg import AsyncConnection, Connection, sql
|
||||||
|
|
||||||
from app.services.time_api import parse_utc_time
|
from app.domain.time import parse_utc_time
|
||||||
|
|
||||||
|
|
||||||
class AnalysisResultsRepository:
|
class AnalysisResultsRepository:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from typing import List, Any, Dict
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from psycopg import AsyncConnection, Connection, sql
|
from psycopg import AsyncConnection, Connection, sql
|
||||||
from app.services.time_api import parse_utc_time
|
from app.domain.time import parse_utc_time
|
||||||
|
|
||||||
|
|
||||||
class RealtimeRepository:
|
class RealtimeRepository:
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ from uuid import UUID
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
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.infra.db.timescaledb.internal_queries import InternalQueries
|
||||||
from app.services.scheme_management import (
|
from app.services.scheme_management import (
|
||||||
store_scheme_info,
|
store_scheme_info,
|
||||||
)
|
)
|
||||||
from app.services.tjnetwork import get_all_scada_info
|
from app.domain.time import parse_utc_time, utc_now
|
||||||
from app.services.time_api import parse_utc_time, utc_now
|
|
||||||
|
|
||||||
|
|
||||||
TARGET_DAY_COUNT = 15
|
TARGET_DAY_COUNT = 15
|
||||||
@@ -178,7 +178,7 @@ def run_burst_detection(
|
|||||||
observed_input = observed_pressure_data
|
observed_input = observed_pressure_data
|
||||||
observed_source = "request_payload"
|
observed_source = "request_payload"
|
||||||
|
|
||||||
detector = BurstDetector(
|
detector = PressureAnomalyDetector(
|
||||||
mu=mu,
|
mu=mu,
|
||||||
points_per_day=points_per_day,
|
points_per_day=points_per_day,
|
||||||
iforest_params=iforest_params,
|
iforest_params=iforest_params,
|
||||||
|
|||||||
@@ -7,14 +7,15 @@ from uuid import UUID
|
|||||||
|
|
||||||
import pandas as pd
|
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.infra.db.timescaledb.internal_queries import InternalQueries
|
||||||
|
from app.native.wndb.inp.exporter import dump_inp
|
||||||
from app.services.scheme_management import (
|
from app.services.scheme_management import (
|
||||||
get_analysis_run,
|
get_analysis_run,
|
||||||
store_scheme_info,
|
store_scheme_info,
|
||||||
)
|
)
|
||||||
from app.services.tjnetwork import dump_inp, get_all_scada_info
|
from app.domain.time import parse_utc_time, utc_now
|
||||||
from app.services.time_api import parse_utc_time, utc_now
|
|
||||||
|
|
||||||
SeriesInput = pd.Series | dict[str, Any] | list[dict[str, Any]]
|
SeriesInput = pd.Series | dict[str, Any] | list[dict[str, Any]]
|
||||||
FLOW_SCADA_TYPES = {"pipe_flow", "flow", "demand"}
|
FLOW_SCADA_TYPES = {"pipe_flow", "flow", "demand"}
|
||||||
|
|||||||
@@ -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
|
import os
|
||||||
from collections import deque
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
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.infra.db.timescaledb.internal_queries import InternalQueries
|
||||||
from app.services.scheme_management import store_analysis_run_with_result
|
from app.infra.db.postgresql.scada import get_all_scada_info
|
||||||
from app.services.tjnetwork import (
|
from app.native.wndb.gis.network_views import (
|
||||||
dump_inp,
|
|
||||||
get_all_scada_info,
|
|
||||||
get_network_link_nodes,
|
get_network_link_nodes,
|
||||||
get_network_node_coords,
|
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))
|
DEFAULT_N_WORKERS = max(1, min((os.cpu_count() or 1) - 1, 4))
|
||||||
|
|
||||||
@@ -26,14 +25,13 @@ def run_leakage_identification(
|
|||||||
network: str,
|
network: str,
|
||||||
username: str,
|
username: str,
|
||||||
observed_pressure_data: (
|
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,
|
) = None,
|
||||||
start_time: float = 0,
|
start_time: float = 0,
|
||||||
duration: float = 24,
|
duration: float = 24,
|
||||||
timestep: float = 5,
|
timestep: float = 5,
|
||||||
q_sum: float = 0.2,
|
q_sum: float = 0.2,
|
||||||
q_sum_unit: str = "m3/s",
|
q_sum_unit: str = "m3/s",
|
||||||
output_dir: str = "db_inp",
|
|
||||||
pop_size: int = 50,
|
pop_size: int = 50,
|
||||||
max_gen: int = 100,
|
max_gen: int = 100,
|
||||||
n_workers: int = DEFAULT_N_WORKERS,
|
n_workers: int = DEFAULT_N_WORKERS,
|
||||||
@@ -44,7 +42,6 @@ def run_leakage_identification(
|
|||||||
sensor_nodes: list[str] | None = None,
|
sensor_nodes: list[str] | None = None,
|
||||||
scheme_name: str | None = None,
|
scheme_name: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
|
||||||
inp_path = _prepare_leakage_inp(network)
|
inp_path = _prepare_leakage_inp(network)
|
||||||
|
|
||||||
selected_sensor_nodes = (
|
selected_sensor_nodes = (
|
||||||
@@ -75,8 +72,8 @@ def run_leakage_identification(
|
|||||||
)
|
)
|
||||||
observed_df = observed_pressure_data
|
observed_df = observed_pressure_data
|
||||||
|
|
||||||
q_sum_m3s = LeakageIdentifier._flow_to_m3s(q_sum, q_sum_unit)
|
q_sum_m3s = DmaLeakageOptimizer._flow_to_m3s(q_sum, q_sum_unit)
|
||||||
identifier = LeakageIdentifier(
|
identifier = DmaLeakageOptimizer(
|
||||||
inp_path=inp_path,
|
inp_path=inp_path,
|
||||||
sensor_nodes=selected_sensor_nodes,
|
sensor_nodes=selected_sensor_nodes,
|
||||||
area_map=area_map,
|
area_map=area_map,
|
||||||
@@ -87,7 +84,6 @@ def run_leakage_identification(
|
|||||||
)
|
)
|
||||||
result_df = identifier.run_identification(
|
result_df = identifier.run_identification(
|
||||||
observed_pressure_data=observed_df,
|
observed_pressure_data=observed_df,
|
||||||
output_dir=output_dir,
|
|
||||||
pop_size=pop_size,
|
pop_size=pop_size,
|
||||||
max_gen=max_gen,
|
max_gen=max_gen,
|
||||||
n_workers=n_workers,
|
n_workers=n_workers,
|
||||||
@@ -188,171 +184,15 @@ def _build_area_map_by_topology(
|
|||||||
network: str, sensor_nodes: list[str], dma_count: int | None
|
network: str, sensor_nodes: list[str], dma_count: int | None
|
||||||
) -> tuple[dict[str, str], list[dict[str, Any]], dict[str, dict[str, float]]]:
|
) -> tuple[dict[str, str], list[dict[str, Any]], dict[str, dict[str, float]]]:
|
||||||
node_coords = get_network_node_coords(network)
|
node_coords = get_network_node_coords(network)
|
||||||
all_nodes = list(node_coords.keys())
|
area_map, areas = build_dma_partitions(
|
||||||
if not all_nodes:
|
sensor_nodes,
|
||||||
raise ValueError("管网中未获取到可分区节点。")
|
node_coords,
|
||||||
|
get_network_link_nodes(network),
|
||||||
available_sensors = [node for node in sensor_nodes if node in node_coords]
|
dma_count,
|
||||||
if not available_sensors:
|
|
||||||
raise ValueError("无可用压力传感器,无法生成虚拟分区。")
|
|
||||||
area_count = _resolve_dma_count(dma_count, available_sensors, all_nodes)
|
|
||||||
sensor_area_map = _cluster_sensors_to_areas(
|
|
||||||
available_sensors, node_coords, area_count
|
|
||||||
)
|
)
|
||||||
adjacency = _build_adjacency(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
|
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]]:
|
def _build_area_node_map(area_map: dict[str, str]) -> dict[str, list[str]]:
|
||||||
area_node_map: dict[str, list[str]] = {}
|
area_node_map: dict[str, list[str]] = {}
|
||||||
for node_id, area_id in area_map.items():
|
for node_id, area_id in area_map.items():
|
||||||
@@ -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:
|
def network_update(file_path: str, project_code: str) -> None:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from uuid import UUID, uuid4
|
|||||||
|
|
||||||
from app.infra.db.postgresql.analysis import AnalysisRepository
|
from app.infra.db.postgresql.analysis import AnalysisRepository
|
||||||
from app.native.wndb.core.connection import project_connection, project_transaction
|
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(
|
def store_scheme_info(
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
from contextlib import contextmanager
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import fcntl
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@@ -8,8 +11,12 @@ from openpyxl.styles import Alignment, Font, PatternFill
|
|||||||
from openpyxl.worksheet.worksheet import Worksheet
|
from openpyxl.worksheet.worksheet import Worksheet
|
||||||
from openpyxl.utils import get_column_letter
|
from openpyxl.utils import get_column_letter
|
||||||
from pyproj import Transformer
|
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.infra.db.postgresql import sensor_placement as sensor_placement_repository
|
||||||
|
from app.native.wndb.inp.exporter import dump_inp
|
||||||
|
|
||||||
|
|
||||||
class SensorPlacementNotFoundError(LookupError):
|
class SensorPlacementNotFoundError(LookupError):
|
||||||
@@ -24,6 +31,107 @@ class SensorPlacementConflictError(RuntimeError):
|
|||||||
pass
|
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)
|
_to_wgs84 = Transformer.from_crs("EPSG:3857", "EPSG:4326", always_xy=True)
|
||||||
_STATUS_LABELS = {
|
_STATUS_LABELS = {
|
||||||
"current": "当前方案",
|
"current": "当前方案",
|
||||||
|
|||||||
+13
-26
@@ -1,41 +1,28 @@
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from app.services.tjnetwork import (
|
from app.infra.epanet import run_project
|
||||||
ChangeSet,
|
from app.native.wndb.core.database import ChangeSet
|
||||||
get_demand,
|
from app.native.wndb.model.demands import get_demand, set_demand
|
||||||
get_option,
|
from app.native.wndb.model.options import get_option
|
||||||
get_pattern,
|
from app.native.wndb.model.patterns import get_pattern, set_pattern
|
||||||
get_pump,
|
from app.native.wndb.model.pumps import get_pump
|
||||||
get_reservoir,
|
from app.native.wndb.model.reservoirs import get_reservoir
|
||||||
get_status,
|
from app.native.wndb.model.status import get_status, set_status
|
||||||
get_tank,
|
from app.native.wndb.model.tanks import get_tank, set_tank
|
||||||
get_time,
|
from app.native.wndb.model.times import get_time, set_time
|
||||||
read_all,
|
|
||||||
run_project,
|
|
||||||
set_demand,
|
|
||||||
set_pattern,
|
|
||||||
set_status,
|
|
||||||
set_tank,
|
|
||||||
set_time,
|
|
||||||
)
|
|
||||||
|
|
||||||
# from get_real_status import *
|
# from get_real_status import *
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime
|
||||||
from math import modf
|
from math import modf
|
||||||
import os
|
|
||||||
import json
|
import json
|
||||||
import pytz
|
import pytz
|
||||||
import requests
|
|
||||||
import time
|
import time
|
||||||
from typing import Optional, Tuple
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
import typing
|
|
||||||
import logging
|
import logging
|
||||||
import app.services.project_info as project_info
|
|
||||||
from app.infra.db.postgresql.scada import (
|
from app.infra.db.postgresql.scada import (
|
||||||
ScadaElementMappings,
|
ScadaElementMappings,
|
||||||
load_realtime_element_mappings,
|
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.connection import project_transaction
|
||||||
from app.native.wndb.core.database import refresh_materialized_views_after_commit
|
from app.native.wndb.core.database import refresh_materialized_views_after_commit
|
||||||
from app.infra.db.timescaledb.internal_queries import (
|
from app.infra.db.timescaledb.internal_queries import (
|
||||||
@@ -479,7 +466,7 @@ def run_simulation(
|
|||||||
cs = ChangeSet()
|
cs = ChangeSet()
|
||||||
cs.append(pump_pattern)
|
cs.append(pump_pattern)
|
||||||
set_pattern(name_c, cs)
|
set_pattern(name_c, cs)
|
||||||
# 显式阀门控制沿用 run_simulation_ex 的处理顺序和覆盖规则。
|
# 显式阀门控制优先于旧的开度参数。
|
||||||
if valve_control is not None:
|
if valve_control is not None:
|
||||||
_apply_valve_control(name_c, valve_control)
|
_apply_valve_control(name_c, valve_control)
|
||||||
# 保留原开度参数逻辑,兼容现有方案调用。
|
# 保留原开度参数逻辑,兼容现有方案调用。
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -8,33 +8,27 @@ from math import pi, sqrt
|
|||||||
import pytz
|
import pytz
|
||||||
|
|
||||||
import app.services.simulation as simulation
|
import app.services.simulation as simulation
|
||||||
from app.algorithms.simulation.runner import (
|
|
||||||
run_simulation_ex,
|
|
||||||
from_clock_to_seconds_2,
|
|
||||||
)
|
|
||||||
from app.native.wndb.core.projects import temporary_project_database
|
from app.native.wndb.core.projects import temporary_project_database
|
||||||
from app.services.tjnetwork import (
|
from app.native.wndb.core.database import ChangeSet
|
||||||
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_DEMAND_MODEL_PDA,
|
||||||
OPTION_QUALITY_CHEMICAL,
|
OPTION_QUALITY_CHEMICAL,
|
||||||
SOURCE_TYPE_SETPOINT,
|
|
||||||
add_pattern,
|
|
||||||
add_source,
|
|
||||||
get_demand,
|
|
||||||
get_emitter,
|
|
||||||
get_node_links,
|
|
||||||
get_option,
|
get_option,
|
||||||
get_pattern,
|
|
||||||
get_pipe,
|
|
||||||
get_source,
|
|
||||||
get_time,
|
|
||||||
is_junction,
|
|
||||||
set_demand,
|
|
||||||
set_emitter,
|
|
||||||
set_option,
|
set_option,
|
||||||
set_source,
|
|
||||||
set_time,
|
|
||||||
)
|
)
|
||||||
|
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 _isolated_analysis(purpose: str):
|
||||||
@@ -283,7 +277,7 @@ def valve_close_analysis(
|
|||||||
modify_pattern_start_time=modify_pattern_start_time,
|
modify_pattern_start_time=modify_pattern_start_time,
|
||||||
modify_total_duration=modify_total_duration,
|
modify_total_duration=modify_total_duration,
|
||||||
modify_valve_opening=modify_valve_opening,
|
modify_valve_opening=modify_valve_opening,
|
||||||
scheme_type="valve_close_Analysis",
|
scheme_type="valve_close_analysis",
|
||||||
scheme_name=scheme_name,
|
scheme_name=scheme_name,
|
||||||
result_db_name=name,
|
result_db_name=name,
|
||||||
)
|
)
|
||||||
@@ -371,7 +365,7 @@ def flushing_analysis(
|
|||||||
# 新建 pattern
|
# 新建 pattern
|
||||||
time_option = get_time(new_name)
|
time_option = get_time(new_name)
|
||||||
hydraulic_step = time_option["HYDRAULIC TIMESTEP"]
|
hydraulic_step = time_option["HYDRAULIC TIMESTEP"]
|
||||||
secs = from_clock_to_seconds_2(hydraulic_step)
|
secs = parse_clock_duration_seconds(hydraulic_step)
|
||||||
cs_pattern = ChangeSet()
|
cs_pattern = ChangeSet()
|
||||||
pt = {}
|
pt = {}
|
||||||
factors = []
|
factors = []
|
||||||
@@ -511,7 +505,7 @@ def contaminant_simulation(
|
|||||||
set_time(new_name, cs) # set QUALITY TIMESTEP
|
set_time(new_name, cs) # set QUALITY TIMESTEP
|
||||||
time_option = get_time(new_name)
|
time_option = get_time(new_name)
|
||||||
hydraulic_step = time_option["HYDRAULIC TIMESTEP"]
|
hydraulic_step = time_option["HYDRAULIC TIMESTEP"]
|
||||||
secs = from_clock_to_seconds_2(hydraulic_step)
|
secs = parse_clock_duration_seconds(hydraulic_step)
|
||||||
operation_step = 0
|
operation_step = 0
|
||||||
# step 1. set duration
|
# step 1. set duration
|
||||||
if modify_total_duration == None:
|
if modify_total_duration == None:
|
||||||
@@ -590,51 +584,6 @@ def contaminant_simulation(
|
|||||||
# execute_undo(name)
|
# execute_undo(name)
|
||||||
|
|
||||||
|
|
||||||
############################################################
|
|
||||||
# age analysis 05 ***水龄模拟目前还没和实时模拟打通,不确定是否需要,先不要使用***
|
|
||||||
############################################################
|
|
||||||
|
|
||||||
|
|
||||||
def age_analysis(
|
|
||||||
name: str, modify_pattern_start_time: str, modify_total_duration: int = 900
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
水龄模拟
|
|
||||||
:param name: 模型名称,数据库中对应的名字
|
|
||||||
:param modify_pattern_start_time: 模拟开始时间,格式为'2024-11-25T09:00:00+08:00'
|
|
||||||
:param modify_total_duration: 模拟总历时,秒
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
print(
|
|
||||||
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
+ " -- Start Analysis."
|
|
||||||
)
|
|
||||||
with temporary_project_database(name, "age_analysis") as new_name:
|
|
||||||
result = run_simulation_ex(
|
|
||||||
new_name,
|
|
||||||
"realtime",
|
|
||||||
modify_pattern_start_time,
|
|
||||||
duration=modify_total_duration,
|
|
||||||
downloading_prohibition=True,
|
|
||||||
)
|
|
||||||
simulation_result = json.loads(result)
|
|
||||||
output_data = simulation_result.get("output")
|
|
||||||
if not isinstance(output_data, dict):
|
|
||||||
raise RuntimeError("run_simulation_ex did not return JSON output content")
|
|
||||||
|
|
||||||
nodes_age = []
|
|
||||||
node_result = output_data.get("node_results") or []
|
|
||||||
for node in node_result:
|
|
||||||
nodes_age.append(node["result"][-1]["quality"])
|
|
||||||
links_age = []
|
|
||||||
link_result = output_data.get("link_results") or []
|
|
||||||
for link in link_result:
|
|
||||||
links_age.append(link["result"][-1]["quality"])
|
|
||||||
age_result = {"nodes": nodes_age, "links": links_age}
|
|
||||||
# age_result = {'nodes': nodes_age, 'links': links_age, 'nodeIDs': node_name, 'linkIDs': link_name}
|
|
||||||
return json.dumps(age_result)
|
|
||||||
|
|
||||||
|
|
||||||
############################################################
|
############################################################
|
||||||
# pressure regulation 06
|
# pressure regulation 06
|
||||||
############################################################
|
############################################################
|
||||||
+15
-20
@@ -7,16 +7,19 @@ import pandas as pd
|
|||||||
from psycopg import AsyncConnection
|
from psycopg import AsyncConnection
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from app.algorithms.cleaning.flow import clean_flow_data_df_kf
|
from app.algorithms.pipe_health_prediction.survival_predictor import (
|
||||||
from app.algorithms.cleaning.pressure import clean_pressure_data_df_km
|
PipeHealthSurvivalPredictor,
|
||||||
from app.algorithms.health.analyzer import PipelineHealthAnalyzer
|
)
|
||||||
|
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.postgresql.scada import ScadaInfoRepository
|
||||||
from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository
|
from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository
|
||||||
from app.infra.db.timescaledb.repositories.analysis import AnalysisResultsRepository
|
from app.infra.db.timescaledb.repositories.analysis import AnalysisResultsRepository
|
||||||
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
|
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
|
||||||
|
|
||||||
|
|
||||||
class CompositeQueries:
|
class TimeseriesAnalysisService:
|
||||||
"""
|
"""
|
||||||
复合查询类,提供跨表查询功能
|
复合查询类,提供跨表查询功能
|
||||||
"""
|
"""
|
||||||
@@ -55,7 +58,7 @@ class CompositeQueries:
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: 当 SCADA 设备未找到或字段无效时
|
ValueError: 当 SCADA 设备未找到或字段无效时
|
||||||
"""
|
"""
|
||||||
scada_by_id = await CompositeQueries._get_project_scada_index(postgres_conn)
|
scada_by_id = await TimeseriesAnalysisService._get_project_scada_index(postgres_conn)
|
||||||
link_devices: dict[str, str] = {}
|
link_devices: dict[str, str] = {}
|
||||||
node_devices: dict[str, str] = {}
|
node_devices: dict[str, str] = {}
|
||||||
for device_id in device_ids:
|
for device_id in device_ids:
|
||||||
@@ -118,7 +121,7 @@ class CompositeQueries:
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: 当 SCADA 设备未找到或字段无效时
|
ValueError: 当 SCADA 设备未找到或字段无效时
|
||||||
"""
|
"""
|
||||||
scada_by_id = await CompositeQueries._get_project_scada_index(postgres_conn)
|
scada_by_id = await TimeseriesAnalysisService._get_project_scada_index(postgres_conn)
|
||||||
link_devices: dict[str, str] = {}
|
link_devices: dict[str, str] = {}
|
||||||
node_devices: dict[str, str] = {}
|
node_devices: dict[str, str] = {}
|
||||||
for device_id in device_ids:
|
for device_id in device_ids:
|
||||||
@@ -293,7 +296,7 @@ class CompositeQueries:
|
|||||||
ValueError: 当元素类型无效时
|
ValueError: 当元素类型无效时
|
||||||
"""
|
"""
|
||||||
|
|
||||||
scada_by_id = await CompositeQueries._get_project_scada_index(postgres_conn)
|
scada_by_id = await TimeseriesAnalysisService._get_project_scada_index(postgres_conn)
|
||||||
associated_scada = next(
|
associated_scada = next(
|
||||||
(
|
(
|
||||||
scada
|
scada
|
||||||
@@ -342,7 +345,7 @@ class CompositeQueries:
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: 当前项目没有可清洗设备或指定时间范围内没有监测数据
|
ValueError: 当前项目没有可清洗设备或指定时间范围内没有监测数据
|
||||||
"""
|
"""
|
||||||
scada_by_id = await CompositeQueries._get_project_scada_index(postgres_conn)
|
scada_by_id = await TimeseriesAnalysisService._get_project_scada_index(postgres_conn)
|
||||||
supported_types = {"pressure", "pipe_flow", "flow"}
|
supported_types = {"pressure", "pipe_flow", "flow"}
|
||||||
|
|
||||||
if device_ids:
|
if device_ids:
|
||||||
@@ -509,17 +512,9 @@ class CompositeQueries:
|
|||||||
valid_link_ids = list(velocity_data.keys())
|
valid_link_ids = list(velocity_data.keys())
|
||||||
|
|
||||||
# GIS 物化视图是低频更新管网的查询面;只读取本次有结果的管道。
|
# GIS 物化视图是低频更新管网的查询面;只读取本次有结果的管道。
|
||||||
async with postgres_conn.cursor() as cur:
|
all_links = await NetworkAssetRepository.get_pipes_by_ids(
|
||||||
await cur.execute(
|
postgres_conn, valid_link_ids
|
||||||
"""
|
|
||||||
SELECT id, diameter, start_node_id AS node1,
|
|
||||||
end_node_id AS node2
|
|
||||||
FROM gis.pipes
|
|
||||||
WHERE id = ANY(%s)
|
|
||||||
""",
|
|
||||||
(valid_link_ids,),
|
|
||||||
)
|
)
|
||||||
all_links = await cur.fetchall()
|
|
||||||
|
|
||||||
# 转换为字典以快速查找
|
# 转换为字典以快速查找
|
||||||
links_dict = {str(link["id"]): link for link in all_links}
|
links_dict = {str(link["id"]): link for link in all_links}
|
||||||
@@ -597,8 +592,8 @@ class CompositeQueries:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# 7. 使用PipelineHealthAnalyzer进行预测
|
# 7. 使用生存模型进行预测
|
||||||
analyzer = PipelineHealthAnalyzer()
|
analyzer = PipeHealthSurvivalPredictor()
|
||||||
survival_functions = analyzer.predict_survival(data)
|
survival_functions = analyzer.predict_survival(data)
|
||||||
# 8. 组合结果
|
# 8. 组合结果
|
||||||
results = []
|
results = []
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
"""Application-facing water-network service boundary.
|
"""Application-facing water-network service boundary.
|
||||||
|
|
||||||
The native WNDB package is organized by responsibility. This module only
|
The native WNDB package is organized by responsibility. This transitional
|
||||||
combines operations that need more than one native module and re-exports the
|
facade re-exports the operations still consumed by HTTP endpoints. Application
|
||||||
small set of operations used by HTTP and algorithm services.
|
services and algorithms depend on their explicit native/repository modules.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import app.infra.epanet as epanet
|
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_network,
|
||||||
calculate_demand_to_nodes,
|
calculate_demand_to_nodes,
|
||||||
calculate_demand_to_region,
|
calculate_demand_to_region,
|
||||||
@@ -234,10 +234,6 @@ def run_inp(name: str) -> str:
|
|||||||
return epanet.run_inp(name)
|
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]:
|
def get_node_properties(name: str, node_id: str) -> dict[str, Any]:
|
||||||
if is_junction(name, node_id):
|
if is_junction(name, node_id):
|
||||||
return get_junction(name, node_id)
|
return get_junction(name, node_id)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from typing import Any
|
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(
|
def analyze_valve_isolation(
|
||||||
@@ -8,4 +9,5 @@ def analyze_valve_isolation(
|
|||||||
accident_element: str | list[str],
|
accident_element: str | list[str],
|
||||||
disabled_valves: list[str] = None,
|
disabled_valves: list[str] = None,
|
||||||
) -> dict[str, Any]:
|
) -> 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)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
This module is reserved for general utility functions and helpers.
|
This module is reserved for general utility functions and helpers.
|
||||||
|
|
||||||
Currently, specific utilities are often implemented within their respective service modules
|
Currently, specific utilities are often implemented within their respective service modules
|
||||||
(e.g., time handling in `app.services.time_api`) or core modules.
|
(e.g., time handling in `app.domain.time`) or core modules.
|
||||||
|
|
||||||
Future expansion may include:
|
Future expansion may include:
|
||||||
- Date and time manipulation helpers
|
- Date and time manipulation helpers
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"contracts": {
|
"contracts": {
|
||||||
"server": {
|
"server": {
|
||||||
"file": "server-v1.openapi.json",
|
"file": "server-v1.openapi.json",
|
||||||
"sha256": "d0364fb08c6f18fac2ea9c9980ef21115fc01f110cd10f25f90d97d0bc0e6367"
|
"sha256": "b6640fa87909c9a8a8747e9003ace35ea6a209f99f6e1dc15cb0bd5c313dc7c3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-1070
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
# 监测点灵敏度算法优化说明
|
# 监测点灵敏度算法优化说明
|
||||||
|
|
||||||
本文记录压力监测点布置算法的改造方案、数学含义、复杂度变化和本地验证结果。实现位于 `app/algorithms/sensor/sensitivity.py`,对外兼容入口仍为 `get_ID(name, sensor_num, min_diameter)`。
|
本文记录压力监测点布置算法的改造方案、数学含义、复杂度变化和本地验证结果。纯计算实现位于 `app/algorithms/pressure_sensor_placement/sensitivity_placement.py`;`app/services/sensor_placement.py` 负责从池化的项目数据库导出 INP、加载模型、校验并保存结果。
|
||||||
|
|
||||||
## 改造目标
|
## 改造目标
|
||||||
|
|
||||||
|
|||||||
@@ -135,12 +135,12 @@ flowchart LR
|
|||||||
|
|
||||||
| 职责 | 当前路径 | 原因 |
|
| 职责 | 当前路径 | 原因 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 用水量分配计算 | `app/algorithms/water_demand/` | 属于业务算法,WNDB 只提供节点、需求和区域查询 |
|
| 用水量分配计算 | `app/algorithms/demand_allocation/` | 只接收普通拓扑数据;数据库装载位于 `app/services/demand_allocation.py` |
|
||||||
| SCADA 资产查询 | `app/infra/db/postgresql/scada_assets.py` | 对应 `asset.scada_devices` 的 PostgreSQL 仓储 |
|
| SCADA 资产查询 | `app/infra/db/postgresql/scada.py` | 对应 `asset.scada_devices` 的 PostgreSQL 仓储 |
|
||||||
| 测压点选址结果 | `app/infra/db/postgresql/sensor_placement.py` | 对应 `analysis.runs` 和 `analysis.results` 的仓储 |
|
| 测压点选址结果 | `app/infra/db/postgresql/sensor_placement.py` | 对应 `analysis.runs` 和 `analysis.results` 的仓储 |
|
||||||
| 服务组合入口 | `app/services/tjnetwork.py` | 组合 WNDB、EPANET 和业务仓储,供接口与算法层调用 |
|
| 网络 API 门面 | `app/services/tjnetwork.py` | 暂时组合 WNDB 与 EPANET 供 HTTP 接口调用;算法层不再依赖该门面 |
|
||||||
|
|
||||||
`tjnetwork.py` 从约 995 行缩减到约 320 行。它不再通过 WNDB 根包获得全部函数,只显式导入当前服务使用的能力。过时的 `scripts/test_tjnetwork.py` 依赖已移除的 operation、snapshot、DMA 和旧 SCADA API,已经一并删除。
|
`tjnetwork.py` 从约 995 行缩减到约 285 行。它不再通过 WNDB 根包获得全部函数,只显式导入当前接口使用的能力。过时的 `scripts/test_tjnetwork.py` 依赖已移除的 operation、snapshot、DMA 和旧 SCADA API,已经一并删除。
|
||||||
|
|
||||||
### HTTP 执行边界
|
### HTTP 执行边界
|
||||||
|
|
||||||
@@ -152,8 +152,8 @@ WNDB 当前使用同步 `psycopg` 连接池。`network/`、`components/` 和同
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
API[HTTP 接口与业务服务]
|
API[HTTP 接口]
|
||||||
SERVICE[services.tjnetwork]
|
SERVICE[应用服务]
|
||||||
ALGORITHM[业务算法]
|
ALGORITHM[业务算法]
|
||||||
REPOSITORY[PostgreSQL 业务仓储]
|
REPOSITORY[PostgreSQL 业务仓储]
|
||||||
COMMANDS[wndb.commands]
|
COMMANDS[wndb.commands]
|
||||||
@@ -164,8 +164,8 @@ flowchart TD
|
|||||||
DB[(项目业务数据库)]
|
DB[(项目业务数据库)]
|
||||||
|
|
||||||
API --> SERVICE
|
API --> SERVICE
|
||||||
API --> ALGORITHM
|
|
||||||
API --> REPOSITORY
|
API --> REPOSITORY
|
||||||
|
SERVICE --> ALGORITHM
|
||||||
SERVICE --> COMMANDS
|
SERVICE --> COMMANDS
|
||||||
SERVICE --> INP
|
SERVICE --> INP
|
||||||
SERVICE --> MODEL
|
SERVICE --> MODEL
|
||||||
@@ -187,13 +187,15 @@ flowchart TD
|
|||||||
|
|
||||||
WNDB 根包不再作为依赖汇聚点。上层若只需要管道查询,应直接依赖 `model.pipes`;需要区域几何时依赖 `gis.region_geometry`;需要项目连接时依赖 `core.connection`。
|
WNDB 根包不再作为依赖汇聚点。上层若只需要管道查询,应直接依赖 `model.pipes`;需要区域几何时依赖 `gis.region_geometry`;需要项目连接时依赖 `core.connection`。
|
||||||
|
|
||||||
|
`app/algorithms` 已按业务能力改为 `burst_detection`、`burst_localization`、`scada_cleaning`、`pipe_health_prediction`、`valve_isolation`、`dma_leakage_estimation`、`pressure_sensor_placement` 和 `demand_allocation`。自动化架构测试禁止算法层反向依赖 service、infra、native 或 API,也禁止 infra 依赖 service/algorithm。少量只做仓储转发的 HTTP 接口仍直接依赖 repository,后续按业务聚合需要迁入应用服务,不为机械包装而增加空壳层。
|
||||||
|
|
||||||
## 仍需留意的文件规模
|
## 仍需留意的文件规模
|
||||||
|
|
||||||
`importer.py` 和 `region_geometry.py` 行数较多,但函数仍围绕单一职责。只有在继续增加 INP 格式或区域算法时,才需要分别拆出版本转换器或边界算法模块。目前没有必要为了控制文件行数继续分层。
|
`importer.py` 和 `region_geometry.py` 行数较多,但函数仍围绕单一职责。只有在继续增加 INP 格式或区域算法时,才需要分别拆出版本转换器或边界算法模块。目前没有必要为了控制文件行数继续分层。
|
||||||
|
|
||||||
## 验证结果
|
## 验证结果
|
||||||
|
|
||||||
- 本地 conda 环境单元、鉴权和 API 测试:286 项通过,2 项按条件跳过。
|
- 本地 conda 环境单元、鉴权和 API 测试:313 项通过,2 项按条件跳过。
|
||||||
- 一次性实库从 `tjwater_v2_template` 创建后,通过 INP 暂存解析和事务替换得到 11 个节点、13 条连接、11 条坐标及 9 条 junction 物化视图记录,验证后已完整删除。
|
- 一次性实库从 `tjwater_v2_template` 创建后,通过 INP 暂存解析和事务替换得到 11 个节点、13 条连接、11 条坐标及 9 条 junction 物化视图记录,验证后已完整删除。
|
||||||
- `tjwater_v2` 统一视图覆盖 87,907 个节点和 91,054 条链路,与六个来源物化视图的合计数量一致。实测完整节点读取约 0.17 秒、完整链路读取约 0.10 秒、完整拓扑两次批量查询约 1.12 秒;耗时仅作为当前环境基线,不作为固定性能承诺。
|
- `tjwater_v2` 统一视图覆盖 87,907 个节点和 91,054 条链路,与六个来源物化视图的合计数量一致。实测完整节点读取约 0.17 秒、完整链路读取约 0.10 秒、完整拓扑两次批量查询约 1.12 秒;耗时仅作为当前环境基线,不作为固定性能承诺。
|
||||||
- `tjwater_v2` 真实数据库测试:11 项通过,覆盖业务库和时序库并发借用、失效连接自动重建、临时库模型/SCADA/视图完整克隆与清理、嵌套事务回滚、分析运行生命周期、恶意标识符转义、明细表复合主键、统一 GIS 查询视图,以及 WNDB pattern 增删改、级联解除需求关联和整体回滚。
|
- `tjwater_v2` 真实数据库测试:11 项通过,覆盖业务库和时序库并发借用、失效连接自动重建、临时库模型/SCADA/视图完整克隆与清理、嵌套事务回滚、分析运行生命周期、恶意标识符转义、明细表复合主键、统一 GIS 查询视图,以及 WNDB pattern 增删改、级联解除需求关联和整体回滚。
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.api.v1.endpoints import burst_detection, burst_location
|
||||||
|
|
||||||
|
|
||||||
|
def _client(module) -> TestClient:
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(module.router, prefix="/api/v1")
|
||||||
|
app.dependency_overrides[module.get_current_keycloak_username] = lambda: "tester"
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_burst_detection_runs_service_outside_event_loop(monkeypatch):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_threadpool(func, **kwargs):
|
||||||
|
captured["func"] = func
|
||||||
|
captured["kwargs"] = kwargs
|
||||||
|
return {"status": "completed"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(burst_detection, "run_in_threadpool", fake_threadpool)
|
||||||
|
response = _client(burst_detection).post(
|
||||||
|
"/api/v1/burst-detections",
|
||||||
|
json={"network": "demo", "observed_pressure_data": {"S1": [1.0]}},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert captured["func"] is burst_detection.run_burst_detection
|
||||||
|
assert captured["kwargs"]["username"] == "tester"
|
||||||
|
|
||||||
|
|
||||||
|
def test_burst_location_runs_service_outside_event_loop(monkeypatch):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_threadpool(func, **kwargs):
|
||||||
|
captured["func"] = func
|
||||||
|
captured["kwargs"] = kwargs
|
||||||
|
return {"status": "completed"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(burst_location, "run_in_threadpool", fake_threadpool)
|
||||||
|
response = _client(burst_location).post(
|
||||||
|
"/api/v1/burst-locations",
|
||||||
|
json={
|
||||||
|
"network": "demo",
|
||||||
|
"burst_leakage": 0.1,
|
||||||
|
"burst_pressure": {"S1": 1.0},
|
||||||
|
"normal_pressure": {"S1": 1.1},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert captured["func"] is burst_location.run_burst_location_by_network
|
||||||
|
assert captured["kwargs"]["username"] == "tester"
|
||||||
@@ -33,3 +33,58 @@ def test_identify_leakage_success(monkeypatch):
|
|||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["area_count"] == 0
|
assert response.json()["area_count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_identify_leakage_rejects_server_file_paths():
|
||||||
|
client = _build_client()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/leakage-identifications",
|
||||||
|
json={
|
||||||
|
"network": "demo",
|
||||||
|
"observed_pressure_data": "/etc/passwd",
|
||||||
|
"output_dir": "/tmp/leakage-results",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_identify_leakage_rejects_unbounded_workload():
|
||||||
|
client = _build_client()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/leakage-identifications",
|
||||||
|
json={
|
||||||
|
"network": "demo",
|
||||||
|
"observed_pressure_data": {"S1": [1.0]},
|
||||||
|
"pop_size": 1_000_000,
|
||||||
|
"max_gen": 1_000_000,
|
||||||
|
"n_workers": 10_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_identify_leakage_runs_service_outside_event_loop(monkeypatch):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_threadpool(func, **kwargs):
|
||||||
|
captured["func"] = func
|
||||||
|
captured["kwargs"] = kwargs
|
||||||
|
return {"rows": [], "area_count": 0}
|
||||||
|
|
||||||
|
monkeypatch.setattr(leakage_endpoint, "run_in_threadpool", fake_threadpool)
|
||||||
|
client = _build_client()
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/leakage-identifications",
|
||||||
|
json={
|
||||||
|
"network": "demo",
|
||||||
|
"observed_pressure_data": {"S1": [1.0]},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert captured["func"] is leakage_endpoint.run_leakage_identification
|
||||||
|
assert captured["kwargs"]["username"] == "tester"
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ def test_optimize_returns_analysis_run(monkeypatch):
|
|||||||
captured = {}
|
captured = {}
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
endpoint,
|
endpoint,
|
||||||
"pressure_sensor_placement_kmeans",
|
"optimize_sensor_placement_by_kmeans",
|
||||||
lambda **kwargs: captured.update(kwargs) or {"run_id": RUN_ID},
|
lambda **kwargs: captured.update(kwargs) or {"run_id": RUN_ID},
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(endpoint, "get_sensor_placement_run", lambda *_: _run())
|
monkeypatch.setattr(endpoint, "get_sensor_placement_run", lambda *_: _run())
|
||||||
@@ -74,7 +74,7 @@ def test_optimize_returns_analysis_run(monkeypatch):
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["run_id"] == str(RUN_ID)
|
assert response.json()["run_id"] == str(RUN_ID)
|
||||||
assert captured["username"] == "alice"
|
assert captured["created_by"] == "alice"
|
||||||
|
|
||||||
|
|
||||||
def test_optimize_rejects_project_mismatch(monkeypatch):
|
def test_optimize_rejects_project_mismatch(monkeypatch):
|
||||||
|
|||||||
@@ -25,15 +25,6 @@ def _load_simulation_module(monkeypatch):
|
|||||||
seconds = parts[2] if len(parts) == 3 else 0
|
seconds = parts[2] if len(parts) == 3 else 0
|
||||||
return hours * 3600 + minutes * 60 + seconds
|
return hours * 3600 + minutes * 60 + seconds
|
||||||
|
|
||||||
install_stub(
|
|
||||||
monkeypatch,
|
|
||||||
"app.services.time_api",
|
|
||||||
{
|
|
||||||
"parse_aware_time": parse_aware_time,
|
|
||||||
"parse_clock_duration_seconds": parse_clock_duration_seconds,
|
|
||||||
"parse_utc_time": parse_utc_time,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
install_stub(
|
install_stub(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
"app.services.simulation",
|
"app.services.simulation",
|
||||||
@@ -62,42 +53,22 @@ def _load_simulation_module(monkeypatch):
|
|||||||
"dump_output": lambda output: f"dump::{output}",
|
"dump_output": lambda output: f"dump::{output}",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
install_stub(monkeypatch, "app.algorithms", package=True)
|
|
||||||
install_stub(monkeypatch, "app.algorithms.simulation", package=True)
|
|
||||||
install_stub(
|
install_stub(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
"app.algorithms.simulation.scenarios",
|
"app.services.simulation_scenarios",
|
||||||
{
|
{
|
||||||
"burst_analysis": lambda *args, **kwargs: "burst",
|
"burst_analysis": lambda *args, **kwargs: "burst",
|
||||||
"valve_close_analysis": lambda *args, **kwargs: "valve",
|
"valve_close_analysis": lambda *args, **kwargs: "valve",
|
||||||
"flushing_analysis": lambda *args, **kwargs: "flush",
|
"flushing_analysis": lambda *args, **kwargs: "flush",
|
||||||
"contaminant_simulation": lambda *args, **kwargs: "contaminant",
|
"contaminant_simulation": lambda *args, **kwargs: "contaminant",
|
||||||
"age_analysis": lambda *args, **kwargs: "age",
|
|
||||||
"pressure_regulation": lambda *args, **kwargs: "pressure",
|
"pressure_regulation": lambda *args, **kwargs: "pressure",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
install_stub(
|
|
||||||
monkeypatch,
|
|
||||||
"app.algorithms.sensor",
|
|
||||||
{
|
|
||||||
"pressure_sensor_placement_sensitivity": lambda *args, **kwargs: [],
|
|
||||||
"pressure_sensor_placement_kmeans": lambda *args, **kwargs: [],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
install_stub(
|
install_stub(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
"app.services.network_import",
|
"app.services.network_import",
|
||||||
{"network_update": lambda *args, **kwargs: "updated"},
|
{"network_update": lambda *args, **kwargs: "updated"},
|
||||||
)
|
)
|
||||||
install_stub(
|
|
||||||
monkeypatch,
|
|
||||||
"app.services.simulation_ops",
|
|
||||||
{
|
|
||||||
"project_management": lambda *args, **kwargs: "managed",
|
|
||||||
"scheduling_simulation": lambda *args, **kwargs: "scheduled",
|
|
||||||
"daily_scheduling_simulation": lambda *args, **kwargs: "daily",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
install_stub(
|
install_stub(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
"app.services.valve_isolation",
|
"app.services.valve_isolation",
|
||||||
@@ -126,78 +97,52 @@ def test_run_project_endpoint_returns_plain_text(monkeypatch):
|
|||||||
assert response.text == "report::demo"
|
assert response.text == "report::demo"
|
||||||
|
|
||||||
|
|
||||||
def test_scheduling_analysis_maps_request_body(monkeypatch):
|
def test_removed_legacy_simulation_workflows_are_not_exposed(monkeypatch):
|
||||||
module = _load_simulation_module(monkeypatch)
|
module = _load_simulation_module(monkeypatch)
|
||||||
captured = {}
|
|
||||||
|
|
||||||
def fake_schedule(network, start_time, pump_control, tank_id, water_plant_output_id, time_delta):
|
|
||||||
captured["args"] = (
|
|
||||||
network,
|
|
||||||
start_time,
|
|
||||||
pump_control,
|
|
||||||
tank_id,
|
|
||||||
water_plant_output_id,
|
|
||||||
time_delta,
|
|
||||||
)
|
|
||||||
return "scheduled"
|
|
||||||
|
|
||||||
monkeypatch.setattr(module, "scheduling_simulation", fake_schedule)
|
|
||||||
client = TestClient(build_test_app(module.router, "/api/v1"))
|
client = TestClient(build_test_app(module.router, "/api/v1"))
|
||||||
|
|
||||||
response = client.post(
|
for path in (
|
||||||
"/api/v1/scheduling-analyses",
|
|
||||||
json={
|
|
||||||
"network": "demo",
|
|
||||||
"start_time": "2025-01-01T08:00:00+08:00",
|
|
||||||
"pump_control": {"P1": [1, 0, 1]},
|
|
||||||
"tank_id": "T1",
|
|
||||||
"water_plant_output_id": "R1",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == "scheduled"
|
|
||||||
assert captured["args"] == (
|
|
||||||
"demo",
|
|
||||||
"2025-01-01T08:00:00+08:00",
|
|
||||||
{"P1": [1, 0, 1]},
|
|
||||||
"T1",
|
|
||||||
"R1",
|
|
||||||
300,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_project_management_maps_named_arguments(monkeypatch):
|
|
||||||
module = _load_simulation_module(monkeypatch)
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
def fake_project_management(**kwargs):
|
|
||||||
captured.update(kwargs)
|
|
||||||
return "managed"
|
|
||||||
|
|
||||||
monkeypatch.setattr(module, "project_management", fake_project_management)
|
|
||||||
client = TestClient(build_test_app(module.router, "/api/v1"))
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1/project-managements",
|
"/api/v1/project-managements",
|
||||||
json={
|
"/api/v1/scheduling-analyses",
|
||||||
|
"/api/v1/daily-scheduling-analyses",
|
||||||
|
"/api/v1/water-age-analyses",
|
||||||
|
"/api/v1/inp-runs",
|
||||||
|
"/api/v1/outputs",
|
||||||
|
"/api/v1/pump-failure-events",
|
||||||
|
):
|
||||||
|
assert client.post(path).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_removed_basic_pressure_regulation_is_not_exposed(monkeypatch):
|
||||||
|
module = _load_simulation_module(monkeypatch)
|
||||||
|
client = TestClient(build_test_app(module.router, "/api/v1"))
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/pressure-regulation-calculations",
|
||||||
|
params={
|
||||||
"network": "demo",
|
"network": "demo",
|
||||||
"start_time": "2025-01-01T08:00:00+08:00",
|
"target_node": "J1",
|
||||||
"pump_control": {"P1": [1]},
|
"target_pressure": 50,
|
||||||
"tank_init_level": {"T1": 10.0},
|
|
||||||
"region_demand": {"R1": 20.0},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 404
|
||||||
assert response.json() == "managed"
|
|
||||||
assert captured == {
|
|
||||||
"prj_name": "demo",
|
def test_pressure_regulation_requires_scheme_name(monkeypatch):
|
||||||
"start_datetime": "2025-01-01T08:00:00+08:00",
|
module = _load_simulation_module(monkeypatch)
|
||||||
"pump_control": {"P1": [1]},
|
client = TestClient(build_test_app(module.router, "/api/v1"))
|
||||||
"tank_initial_level_control": {"T1": 10.0},
|
|
||||||
"region_demand_control": {"R1": 20.0},
|
response = client.post(
|
||||||
}
|
"/api/v1/pressure-regulation-analyses",
|
||||||
|
json={
|
||||||
|
"network": "demo",
|
||||||
|
"start_time": "2025-01-02T03:04:05+08:00",
|
||||||
|
"pump_control": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
def test_run_simulation_manually_by_date_uses_utc_aware_timestamps(monkeypatch):
|
def test_run_simulation_manually_by_date_uses_utc_aware_timestamps(monkeypatch):
|
||||||
@@ -353,6 +298,34 @@ def test_burst_endpoint_passes_current_username(monkeypatch):
|
|||||||
assert captured["username"] == "alice"
|
assert captured["username"] == "alice"
|
||||||
|
|
||||||
|
|
||||||
|
def test_burst_endpoint_rejects_mismatched_pipe_and_size_counts(monkeypatch):
|
||||||
|
module = _load_simulation_module(monkeypatch)
|
||||||
|
called = False
|
||||||
|
|
||||||
|
def fake_burst_analysis(**kwargs):
|
||||||
|
nonlocal called
|
||||||
|
called = True
|
||||||
|
|
||||||
|
monkeypatch.setattr(module, "burst_analysis", fake_burst_analysis)
|
||||||
|
client = _build_authenticated_client(module)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/burst-analyses",
|
||||||
|
params=[
|
||||||
|
("network", "demo"),
|
||||||
|
("modify_pattern_start_time", "2025-01-02T03:04:05+08:00"),
|
||||||
|
("burst_ID", "P1"),
|
||||||
|
("burst_ID", "P2"),
|
||||||
|
("burst_size", "10.0"),
|
||||||
|
("modify_total_duration", "900"),
|
||||||
|
("scheme_name", "burst_case_01"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert called is False
|
||||||
|
|
||||||
|
|
||||||
def test_flushing_endpoint_passes_required_scheme_name(monkeypatch):
|
def test_flushing_endpoint_passes_required_scheme_name(monkeypatch):
|
||||||
module = _load_simulation_module(monkeypatch)
|
module = _load_simulation_module(monkeypatch)
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
import json
|
|
||||||
from contextlib import contextmanager
|
|
||||||
|
|
||||||
from tests.conftest import install_stub, load_module_from_path
|
|
||||||
|
|
||||||
|
|
||||||
def _load_scenarios_module(monkeypatch):
|
|
||||||
install_stub(monkeypatch, "app.services", package=True)
|
|
||||||
install_stub(monkeypatch, "app.algorithms", package=True)
|
|
||||||
install_stub(monkeypatch, "app.algorithms.simulation", package=True)
|
|
||||||
install_stub(monkeypatch, "app.services.simulation", {})
|
|
||||||
install_stub(
|
|
||||||
monkeypatch,
|
|
||||||
"app.algorithms.simulation.runner",
|
|
||||||
{
|
|
||||||
"run_simulation_ex": lambda *args, **kwargs: json.dumps(
|
|
||||||
{"output": {"node_results": [], "link_results": []}}
|
|
||||||
),
|
|
||||||
"from_clock_to_seconds_2": lambda value: value,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
install_stub(monkeypatch, "app.services.scheme_management", {"store_scheme_info": lambda *args, **kwargs: None})
|
|
||||||
install_stub(
|
|
||||||
monkeypatch,
|
|
||||||
"app.services.tjnetwork",
|
|
||||||
{
|
|
||||||
"ChangeSet": type("ChangeSet", (), {}),
|
|
||||||
"OPTION_DEMAND_MODEL_PDA": "OPTION_DEMAND_MODEL_PDA",
|
|
||||||
"OPTION_QUALITY_CHEMICAL": "OPTION_QUALITY_CHEMICAL",
|
|
||||||
"SOURCE_TYPE_SETPOINT": "SOURCE_TYPE_SETPOINT",
|
|
||||||
"add_pattern": lambda *args, **kwargs: None,
|
|
||||||
"add_source": lambda *args, **kwargs: None,
|
|
||||||
"copy_project": lambda *args, **kwargs: None,
|
|
||||||
"delete_project": lambda *args, **kwargs: None,
|
|
||||||
"get_demand": lambda *args, **kwargs: None,
|
|
||||||
"get_emitter": lambda *args, **kwargs: None,
|
|
||||||
"get_node_links": lambda *args, **kwargs: None,
|
|
||||||
"get_option": lambda *args, **kwargs: None,
|
|
||||||
"get_pattern": lambda *args, **kwargs: None,
|
|
||||||
"get_pipe": lambda *args, **kwargs: None,
|
|
||||||
"get_source": lambda *args, **kwargs: None,
|
|
||||||
"get_time": lambda *args, **kwargs: None,
|
|
||||||
"have_project": lambda *args, **kwargs: False,
|
|
||||||
"is_junction": lambda *args, **kwargs: False,
|
|
||||||
"set_demand": lambda *args, **kwargs: None,
|
|
||||||
"set_emitter": lambda *args, **kwargs: None,
|
|
||||||
"set_option": lambda *args, **kwargs: None,
|
|
||||||
"set_source": lambda *args, **kwargs: None,
|
|
||||||
"set_time": lambda *args, **kwargs: None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return load_module_from_path(
|
|
||||||
"tests_age_analysis_scenarios_module",
|
|
||||||
"app/algorithms/simulation/scenarios.py",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_age_analysis_passes_duration_by_keyword(monkeypatch):
|
|
||||||
module = _load_scenarios_module(monkeypatch)
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def fake_temporary_project(project, purpose):
|
|
||||||
yield f"{purpose}_{project}_run"
|
|
||||||
|
|
||||||
monkeypatch.setattr(module, "temporary_project_database", fake_temporary_project)
|
|
||||||
|
|
||||||
def fake_run_simulation_ex(*args, **kwargs):
|
|
||||||
captured["args"] = args
|
|
||||||
captured["kwargs"] = kwargs
|
|
||||||
return json.dumps({"output": {"node_results": [], "link_results": []}})
|
|
||||||
|
|
||||||
monkeypatch.setattr(module, "run_simulation_ex", fake_run_simulation_ex)
|
|
||||||
|
|
||||||
module.age_analysis("demo", "2026-06-03T07:00:00+08:00", 300)
|
|
||||||
|
|
||||||
assert captured["args"] == (
|
|
||||||
"age_analysis_demo_run",
|
|
||||||
"realtime",
|
|
||||||
"2026-06-03T07:00:00+08:00",
|
|
||||||
)
|
|
||||||
assert captured["kwargs"] == {
|
|
||||||
"duration": 300,
|
|
||||||
"downloading_prohibition": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_isolated_analysis_cleans_database_after_early_return(monkeypatch):
|
|
||||||
module = _load_scenarios_module(monkeypatch)
|
|
||||||
lifecycle: list[tuple[str, str]] = []
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def fake_temporary_project(project, purpose):
|
|
||||||
lifecycle.append(("create", purpose))
|
|
||||||
try:
|
|
||||||
yield "isolated_run"
|
|
||||||
finally:
|
|
||||||
lifecycle.append(("delete", purpose))
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
module, "temporary_project_database", fake_temporary_project
|
|
||||||
)
|
|
||||||
|
|
||||||
@module._isolated_analysis("probe")
|
|
||||||
def return_early(name, *, _temporary_project=None):
|
|
||||||
assert name == "demo"
|
|
||||||
assert _temporary_project == "isolated_run"
|
|
||||||
return "done"
|
|
||||||
|
|
||||||
assert return_early("demo") == "done"
|
|
||||||
assert lifecycle == [("create", "probe"), ("delete", "probe")]
|
|
||||||
@@ -19,38 +19,38 @@ def _empty_scada_mappings(simulation):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_run_simulation_exposes_explicit_valve_control():
|
def test_run_simulation_accepts_explicit_valve_control():
|
||||||
from app.services import simulation
|
from app.services import simulation
|
||||||
|
|
||||||
assert "valve_control" in inspect.signature(simulation.run_simulation).parameters
|
assert "valve_control" in inspect.signature(simulation.run_simulation).parameters
|
||||||
|
|
||||||
|
|
||||||
def test_extended_runner_cleans_temporary_database_after_failure(monkeypatch):
|
def test_valve_close_analysis_uses_normalized_scheme_type(monkeypatch):
|
||||||
from app.algorithms.simulation import runner
|
from app.services import simulation_scenarios
|
||||||
|
|
||||||
lifecycle: list[tuple[str, str]] = []
|
captured = {}
|
||||||
|
monkeypatch.setattr(simulation_scenarios, "get_option", lambda _name: {})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
simulation_scenarios,
|
||||||
|
"set_option",
|
||||||
|
lambda _name, _changes: None,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
simulation_scenarios.simulation,
|
||||||
|
"run_simulation",
|
||||||
|
lambda **kwargs: captured.update(kwargs),
|
||||||
|
)
|
||||||
|
|
||||||
@contextmanager
|
simulation_scenarios.valve_close_analysis.__wrapped__(
|
||||||
def temporary_project(project: str, purpose: str):
|
name="demo",
|
||||||
lifecycle.append(("create", project))
|
modify_pattern_start_time="2026-01-01T00:00:00+08:00",
|
||||||
try:
|
modify_valve_opening={"V1": 0.0},
|
||||||
yield "isolated_project"
|
scheme_name="valve_case",
|
||||||
finally:
|
_temporary_project="temporary_demo",
|
||||||
lifecycle.append(("delete", project))
|
)
|
||||||
|
|
||||||
monkeypatch.setattr(runner, "temporary_project_database", temporary_project)
|
assert captured["scheme_type"] == "valve_close_analysis"
|
||||||
|
assert captured["result_db_name"] == "demo"
|
||||||
@runner._clean_extended_simulation
|
|
||||||
def fail(name, simulation_type, *, _temporary_project=None):
|
|
||||||
assert name == "demo"
|
|
||||||
assert simulation_type == "extended"
|
|
||||||
assert _temporary_project == "isolated_project"
|
|
||||||
raise RuntimeError("simulation failed")
|
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match="simulation failed"):
|
|
||||||
fail("demo", "extended")
|
|
||||||
|
|
||||||
assert lifecycle == [("create", "demo"), ("delete", "demo")]
|
|
||||||
|
|
||||||
|
|
||||||
def test_apply_valve_control_matches_runner_semantics(monkeypatch):
|
def test_apply_valve_control_matches_runner_semantics(monkeypatch):
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Executable dependency rules for the service/algorithm/database layers."""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
APP_ROOT = Path(__file__).parents[2] / "app"
|
||||||
|
|
||||||
|
|
||||||
|
def _app_imports(path: Path) -> set[str]:
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
|
imports: set[str] = set()
|
||||||
|
package_parts = ("app", *path.relative_to(APP_ROOT).parent.parts)
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.Import):
|
||||||
|
imports.update(alias.name for alias in node.names if alias.name.startswith("app."))
|
||||||
|
elif isinstance(node, ast.ImportFrom):
|
||||||
|
if node.level:
|
||||||
|
parent_count = node.level - 1
|
||||||
|
if parent_count >= len(package_parts):
|
||||||
|
continue
|
||||||
|
base_parts = package_parts[: len(package_parts) - parent_count]
|
||||||
|
if node.module:
|
||||||
|
imports.add(".".join((*base_parts, *node.module.split("."))))
|
||||||
|
else:
|
||||||
|
imports.update(
|
||||||
|
".".join((*base_parts, alias.name.split(".")[0]))
|
||||||
|
for alias in node.names
|
||||||
|
)
|
||||||
|
elif node.module and node.module.startswith("app."):
|
||||||
|
imports.add(node.module)
|
||||||
|
return imports
|
||||||
|
|
||||||
|
|
||||||
|
def _forbidden_imports(root: Path, prefixes: tuple[str, ...]) -> list[str]:
|
||||||
|
violations: list[str] = []
|
||||||
|
for path in sorted(root.rglob("*.py")):
|
||||||
|
for module in sorted(_app_imports(path)):
|
||||||
|
if module.startswith(prefixes):
|
||||||
|
violations.append(f"{path.relative_to(APP_ROOT)} -> {module}")
|
||||||
|
return violations
|
||||||
|
|
||||||
|
|
||||||
|
def test_algorithms_are_independent_of_application_and_io_layers():
|
||||||
|
violations = _forbidden_imports(
|
||||||
|
APP_ROOT / "algorithms",
|
||||||
|
("app.api", "app.auth", "app.infra", "app.native", "app.services"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert violations == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_infrastructure_does_not_depend_on_application_or_algorithms():
|
||||||
|
violations = _forbidden_imports(
|
||||||
|
APP_ROOT / "infra",
|
||||||
|
("app.api", "app.algorithms", "app.services"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert violations == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_services_do_not_execute_sql_directly():
|
||||||
|
violations = []
|
||||||
|
for path in sorted((APP_ROOT / "services").rglob("*.py")):
|
||||||
|
source = path.read_text(encoding="utf-8")
|
||||||
|
if ".cursor(" in source or ".execute(" in source:
|
||||||
|
violations.append(str(path.relative_to(APP_ROOT)))
|
||||||
|
|
||||||
|
assert violations == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_services_do_not_depend_on_the_http_network_facade():
|
||||||
|
violations = _forbidden_imports(
|
||||||
|
APP_ROOT / "services",
|
||||||
|
("app.services.tjnetwork",),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert violations == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_algorithm_packages_use_explicit_business_names():
|
||||||
|
legacy_names = {
|
||||||
|
"burst_location",
|
||||||
|
"cleaning",
|
||||||
|
"health",
|
||||||
|
"isolation",
|
||||||
|
"leakage",
|
||||||
|
"sensor",
|
||||||
|
"simulation",
|
||||||
|
"water_demand",
|
||||||
|
}
|
||||||
|
present_names = {
|
||||||
|
path.name
|
||||||
|
for path in (APP_ROOT / "algorithms").iterdir()
|
||||||
|
if path.is_dir() and any(path.glob("*.py"))
|
||||||
|
}
|
||||||
|
|
||||||
|
assert present_names.isdisjoint(legacy_names)
|
||||||
|
|
||||||
|
|
||||||
|
def test_application_code_does_not_open_direct_psycopg_connections():
|
||||||
|
violations = []
|
||||||
|
direct_calls = ("psycopg.connect(", "Connection.connect(", "AsyncConnection.connect(")
|
||||||
|
for path in sorted(APP_ROOT.rglob("*.py")):
|
||||||
|
source = path.read_text(encoding="utf-8")
|
||||||
|
if any(call in source for call in direct_calls):
|
||||||
|
violations.append(str(path.relative_to(APP_ROOT)))
|
||||||
|
|
||||||
|
assert violations == []
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.algorithms.demand_allocation import allocate_demand_by_pipe_length
|
||||||
|
|
||||||
|
|
||||||
|
def test_allocate_demand_by_incident_pipe_length():
|
||||||
|
nodes = {
|
||||||
|
"J1": {"type": "junction", "links": ["P1"]},
|
||||||
|
"J2": {"type": "junction", "links": ["P1", "P2"]},
|
||||||
|
"R1": {"type": "reservoir", "links": ["P2"]},
|
||||||
|
}
|
||||||
|
links = {"P1": {"length": 100.0}, "P2": {"length": 300.0}}
|
||||||
|
|
||||||
|
result = allocate_demand_by_pipe_length(80.0, nodes, links)
|
||||||
|
|
||||||
|
assert result == {"J1": pytest.approx(10.0), "J2": pytest.approx(40.0)}
|
||||||
|
|
||||||
|
|
||||||
|
def test_allocate_demand_returns_empty_for_zero_length_topology():
|
||||||
|
result = allocate_demand_by_pipe_length(
|
||||||
|
80.0,
|
||||||
|
{"J1": {"type": "junction", "links": ["P1"]}},
|
||||||
|
{"P1": {"length": 0.0}},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.algorithms.dma_leakage_estimation.genetic_optimizer import DmaLeakageOptimizer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("unit", "expected"),
|
||||||
|
[
|
||||||
|
("m3/s", 1.0),
|
||||||
|
("m³/s", 1.0),
|
||||||
|
("m3/h", 3600.0),
|
||||||
|
("m³/h", 3600.0),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_dma_leakage_optimizer_accepts_display_flow_units(unit, expected):
|
||||||
|
assert DmaLeakageOptimizer._flow_from_m3s(1.0, unit) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_dma_leakage_optimizer_accepts_display_flow_units_for_input():
|
||||||
|
assert DmaLeakageOptimizer._flow_to_m3s(3600.0, "m³/h") == 1.0
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.algorithms.dma_leakage_estimation.topology_partitioning import (
|
||||||
|
build_dma_partitions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_partition_assigns_nodes_to_nearest_connected_sensor():
|
||||||
|
coordinates = {
|
||||||
|
"A": {"x": 0.0, "y": 0.0},
|
||||||
|
"B": {"x": 1.0, "y": 0.0},
|
||||||
|
"C": {"x": 2.0, "y": 0.0},
|
||||||
|
"D": {"x": 3.0, "y": 0.0},
|
||||||
|
}
|
||||||
|
links = ["P1:pipe:A:B", "P2:pipe:B:C", "P3:pipe:C:D"]
|
||||||
|
|
||||||
|
area_map, areas = build_dma_partitions(
|
||||||
|
["A", "D"], coordinates, links, dma_count=2
|
||||||
|
)
|
||||||
|
|
||||||
|
assert area_map == {"A": "1", "B": "1", "C": "2", "D": "2"}
|
||||||
|
assert [area["node_count"] for area in areas] == [2, 2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_partition_rejects_more_areas_than_sensors():
|
||||||
|
with pytest.raises(ValueError, match="传感器数量"):
|
||||||
|
build_dma_partitions(
|
||||||
|
["A"],
|
||||||
|
{"A": {"x": 0.0, "y": 0.0}, "B": {"x": 1.0, "y": 0.0}},
|
||||||
|
["P1:pipe:A:B"],
|
||||||
|
dma_count=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_partition_preserves_requested_area_count_for_coincident_sensors():
|
||||||
|
coordinates = {
|
||||||
|
"A": {"x": 0.0, "y": 0.0},
|
||||||
|
"B": {"x": 0.0, "y": 0.0},
|
||||||
|
"C": {"x": 0.0, "y": 0.0},
|
||||||
|
}
|
||||||
|
|
||||||
|
area_map, areas = build_dma_partitions(
|
||||||
|
["A", "B", "C"],
|
||||||
|
coordinates,
|
||||||
|
["P1:pipe:A:B", "P2:pipe:B:C"],
|
||||||
|
dma_count=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert set(area_map.values()) == {"1", "2"}
|
||||||
|
assert [area["area_id"] for area in areas] == ["1", "2"]
|
||||||
@@ -5,11 +5,9 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
def _load_time_api_module():
|
def _load_time_module():
|
||||||
module_path = (
|
module_path = Path(__file__).resolve().parents[2] / "app" / "domain" / "time.py"
|
||||||
Path(__file__).resolve().parents[2] / "app" / "services" / "time_api.py"
|
spec = importlib.util.spec_from_file_location("tests_domain_time", module_path)
|
||||||
)
|
|
||||||
spec = importlib.util.spec_from_file_location("tests_time_api_under_test", module_path)
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
module = importlib.util.module_from_spec(spec)
|
||||||
assert spec and spec.loader
|
assert spec and spec.loader
|
||||||
spec.loader.exec_module(module)
|
spec.loader.exec_module(module)
|
||||||
@@ -17,28 +15,28 @@ def _load_time_api_module():
|
|||||||
|
|
||||||
|
|
||||||
def test_parse_utc_time_rejects_naive_datetimes():
|
def test_parse_utc_time_rejects_naive_datetimes():
|
||||||
module = _load_time_api_module()
|
module = _load_time_module()
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="timezone information"):
|
with pytest.raises(ValueError, match="timezone information"):
|
||||||
module.parse_utc_time("2025-01-01T08:00:00")
|
module.parse_utc_time("2025-01-01T08:00:00")
|
||||||
|
|
||||||
|
|
||||||
def test_parse_utc_time_normalizes_offset_datetime_to_utc():
|
def test_parse_utc_time_normalizes_offset_datetime_to_utc():
|
||||||
module = _load_time_api_module()
|
module = _load_time_module()
|
||||||
result = module.parse_utc_time("2025-01-01T08:00:00+08:00")
|
result = module.parse_utc_time("2025-01-01T08:00:00+08:00")
|
||||||
|
|
||||||
assert result == datetime(2025, 1, 1, 0, 0, tzinfo=timezone.utc)
|
assert result == datetime(2025, 1, 1, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def test_extract_date_keeps_original_offset_calendar_day():
|
def test_extract_date_keeps_original_offset_calendar_day():
|
||||||
module = _load_time_api_module()
|
module = _load_time_module()
|
||||||
result = module.extract_date("2025-01-01T00:30:00+08:00")
|
result = module.extract_date("2025-01-01T00:30:00+08:00")
|
||||||
|
|
||||||
assert result == date(2025, 1, 1)
|
assert result == date(2025, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
def test_utc_now_returns_timezone_aware_utc_datetime():
|
def test_utc_now_returns_timezone_aware_utc_datetime():
|
||||||
module = _load_time_api_module()
|
module = _load_time_module()
|
||||||
result = module.utc_now()
|
result = module.utc_now()
|
||||||
|
|
||||||
assert result.tzinfo == timezone.utc
|
assert result.tzinfo == timezone.utc
|
||||||
@@ -58,14 +56,14 @@ def test_utc_now_returns_timezone_aware_utc_datetime():
|
|||||||
def test_parse_clock_duration_seconds_accepts_epanet_clock_formats(
|
def test_parse_clock_duration_seconds_accepts_epanet_clock_formats(
|
||||||
clock, expected_seconds
|
clock, expected_seconds
|
||||||
):
|
):
|
||||||
module = _load_time_api_module()
|
module = _load_time_module()
|
||||||
|
|
||||||
assert module.parse_clock_duration_seconds(clock) == expected_seconds
|
assert module.parse_clock_duration_seconds(clock) == expected_seconds
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("clock", ["bad", "1:60", "1:00:60", "-1:00"])
|
@pytest.mark.parametrize("clock", ["bad", "1:60", "1:00:60", "-1:00"])
|
||||||
def test_parse_clock_duration_seconds_rejects_invalid_clock_formats(clock):
|
def test_parse_clock_duration_seconds_rejects_invalid_clock_formats(clock):
|
||||||
module = _load_time_api_module()
|
module = _load_time_module()
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
module.parse_clock_duration_seconds(clock)
|
module.parse_clock_duration_seconds(clock)
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from app.algorithms.leakage.identifier import LeakageIdentifier
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("unit", "expected"),
|
|
||||||
[
|
|
||||||
("m3/s", 1.0),
|
|
||||||
("m³/s", 1.0),
|
|
||||||
("m3/h", 3600.0),
|
|
||||||
("m³/h", 3600.0),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_leakage_identifier_accepts_display_flow_units(unit, expected):
|
|
||||||
assert LeakageIdentifier._flow_from_m3s(1.0, unit) == expected
|
|
||||||
|
|
||||||
|
|
||||||
def test_leakage_identifier_accepts_display_flow_units_for_input():
|
|
||||||
assert LeakageIdentifier._flow_to_m3s(3600.0, "m³/h") == 1.0
|
|
||||||
+6
-6
@@ -1,13 +1,13 @@
|
|||||||
"""
|
"""Pipe health survival predictor tests."""
|
||||||
tests.unit.test_pipeline_health_analyzer 的 Docstring
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def test_pipeline_health_analyzer():
|
def test_pipe_health_survival_predictor():
|
||||||
from app.algorithms.health.analyzer import PipelineHealthAnalyzer
|
from app.algorithms.pipe_health_prediction.survival_predictor import (
|
||||||
|
PipeHealthSurvivalPredictor,
|
||||||
|
)
|
||||||
|
|
||||||
# 初始化分析器,假设模型文件路径为'models/rsf_model.joblib'
|
# 初始化分析器,假设模型文件路径为'models/rsf_model.joblib'
|
||||||
analyzer = PipelineHealthAnalyzer()
|
analyzer = PipeHealthSurvivalPredictor()
|
||||||
# 创建示例输入数据(9个样本)
|
# 创建示例输入数据(9个样本)
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import time
|
import time
|
||||||
@@ -4,7 +4,7 @@ import numpy as np
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.algorithms.cleaning import pressure as pressure_cleaning
|
from app.algorithms.scada_cleaning import pressure_series as pressure_cleaning
|
||||||
|
|
||||||
|
|
||||||
DATA_DIR = Path(__file__).resolve().parents[3] / "data"
|
DATA_DIR = Path(__file__).resolve().parents[3] / "data"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from unittest.mock import AsyncMock
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from app.api.v1.endpoints import project_data
|
from app.api.v1.endpoints import project_data
|
||||||
from app.infra.db.timescaledb import composite_queries
|
from app.services import timeseries_analysis as composite_queries
|
||||||
|
|
||||||
|
|
||||||
PROJECT_SCADA = {
|
PROJECT_SCADA = {
|
||||||
@@ -48,7 +48,7 @@ def test_realtime_scada_simulation_uses_current_project_metadata(monkeypatch):
|
|||||||
)
|
)
|
||||||
|
|
||||||
result = asyncio.run(
|
result = asyncio.run(
|
||||||
composite_queries.CompositeQueries.get_scada_associated_realtime_simulation_data(
|
composite_queries.TimeseriesAnalysisService.get_scada_associated_realtime_simulation_data(
|
||||||
object(),
|
object(),
|
||||||
object(),
|
object(),
|
||||||
[PROJECT_SCADA["device_id"]],
|
[PROJECT_SCADA["device_id"]],
|
||||||
@@ -82,7 +82,7 @@ def test_analysis_scada_simulation_uses_current_project_metadata(monkeypatch):
|
|||||||
)
|
)
|
||||||
|
|
||||||
result = asyncio.run(
|
result = asyncio.run(
|
||||||
composite_queries.CompositeQueries.get_scada_associated_analysis_simulation_data(
|
composite_queries.TimeseriesAnalysisService.get_scada_associated_analysis_simulation_data(
|
||||||
object(),
|
object(),
|
||||||
object(),
|
object(),
|
||||||
[PROJECT_SCADA["device_id"]],
|
[PROJECT_SCADA["device_id"]],
|
||||||
@@ -114,7 +114,7 @@ def test_element_scada_query_uses_current_project_metadata(monkeypatch):
|
|||||||
)
|
)
|
||||||
|
|
||||||
result = asyncio.run(
|
result = asyncio.run(
|
||||||
composite_queries.CompositeQueries.get_element_associated_scada_data(
|
composite_queries.TimeseriesAnalysisService.get_element_associated_scada_data(
|
||||||
object(),
|
object(),
|
||||||
object(),
|
object(),
|
||||||
"J1",
|
"J1",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import pytest
|
|||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from app.api.v1.endpoints.timeseries import composite as composite_endpoint
|
from app.api.v1.endpoints.timeseries import composite as composite_endpoint
|
||||||
from app.infra.db.timescaledb import composite_queries
|
from app.services import timeseries_analysis as composite_queries
|
||||||
|
|
||||||
|
|
||||||
class _FakeTimescaleConnection:
|
class _FakeTimescaleConnection:
|
||||||
@@ -57,7 +57,7 @@ def test_clean_scada_uses_current_project_metadata(monkeypatch):
|
|||||||
)
|
)
|
||||||
|
|
||||||
result = asyncio.run(
|
result = asyncio.run(
|
||||||
composite_queries.CompositeQueries.clean_scada_data(
|
composite_queries.TimeseriesAnalysisService.clean_scada_data(
|
||||||
_FakeTimescaleConnection(),
|
_FakeTimescaleConnection(),
|
||||||
object(),
|
object(),
|
||||||
["fengyang-pressure-1"],
|
["fengyang-pressure-1"],
|
||||||
@@ -85,7 +85,7 @@ def test_clean_scada_rejects_devices_missing_from_project_metadata(monkeypatch):
|
|||||||
|
|
||||||
with pytest.raises(ValueError, match="缺少元数据"):
|
with pytest.raises(ValueError, match="缺少元数据"):
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
composite_queries.CompositeQueries.clean_scada_data(
|
composite_queries.TimeseriesAnalysisService.clean_scada_data(
|
||||||
_FakeTimescaleConnection(),
|
_FakeTimescaleConnection(),
|
||||||
_FakeTimescaleConnection(),
|
_FakeTimescaleConnection(),
|
||||||
["fengyang-pressure-1"],
|
["fengyang-pressure-1"],
|
||||||
@@ -132,7 +132,7 @@ def test_clean_scada_rejects_zero_database_updates(monkeypatch):
|
|||||||
|
|
||||||
with pytest.raises(ValueError, match="未产生任何数据库更新"):
|
with pytest.raises(ValueError, match="未产生任何数据库更新"):
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
composite_queries.CompositeQueries.clean_scada_data(
|
composite_queries.TimeseriesAnalysisService.clean_scada_data(
|
||||||
_FakeTimescaleConnection(),
|
_FakeTimescaleConnection(),
|
||||||
object(),
|
object(),
|
||||||
["fengyang-pressure-1"],
|
["fengyang-pressure-1"],
|
||||||
@@ -176,7 +176,7 @@ def test_clean_scada_propagates_write_failures(monkeypatch):
|
|||||||
|
|
||||||
with pytest.raises(RuntimeError, match="database write failed"):
|
with pytest.raises(RuntimeError, match="database write failed"):
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
composite_queries.CompositeQueries.clean_scada_data(
|
composite_queries.TimeseriesAnalysisService.clean_scada_data(
|
||||||
_FakeTimescaleConnection(),
|
_FakeTimescaleConnection(),
|
||||||
object(),
|
object(),
|
||||||
["fengyang-pressure-1"],
|
["fengyang-pressure-1"],
|
||||||
@@ -188,7 +188,7 @@ def test_clean_scada_propagates_write_failures(monkeypatch):
|
|||||||
|
|
||||||
def test_clean_scada_endpoint_returns_http_400_for_validation_error(monkeypatch):
|
def test_clean_scada_endpoint_returns_http_400_for_validation_error(monkeypatch):
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
composite_endpoint.CompositeQueries,
|
composite_endpoint.TimeseriesAnalysisService,
|
||||||
"clean_scada_data",
|
"clean_scada_data",
|
||||||
AsyncMock(side_effect=ValueError("当前项目没有可清洗的 SCADA 设备")),
|
AsyncMock(side_effect=ValueError("当前项目没有可清洗的 SCADA 设备")),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.algorithms.pressure_sensor_placement.kmeans_placement import (
|
||||||
|
optimize_sensor_placement,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeNetwork:
|
||||||
|
junction_name_list = ["J1", "J2", "J3"]
|
||||||
|
pipe_name_list = ["P1", "P2"]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._nodes = {
|
||||||
|
"J1": SimpleNamespace(coordinates=(0.0, 0.0)),
|
||||||
|
"J2": SimpleNamespace(coordinates=(1.0, 0.0)),
|
||||||
|
"J3": SimpleNamespace(coordinates=(2.0, 0.0)),
|
||||||
|
}
|
||||||
|
self._links = {
|
||||||
|
"P1": SimpleNamespace(
|
||||||
|
diameter=0.4, start_node_name="J1", end_node_name="J2"
|
||||||
|
),
|
||||||
|
"P2": SimpleNamespace(
|
||||||
|
diameter=0.1, start_node_name="J2", end_node_name="J3"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_node(self, node_id):
|
||||||
|
return self._nodes[node_id]
|
||||||
|
|
||||||
|
def get_link(self, link_id):
|
||||||
|
return self._links[link_id]
|
||||||
|
|
||||||
|
|
||||||
|
class DuplicateNearestNodeNetwork:
|
||||||
|
junction_name_list = ["J1", "J2", "J3", "J4"]
|
||||||
|
pipe_name_list = ["P1", "P2", "P3"]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._nodes = {
|
||||||
|
"J1": SimpleNamespace(
|
||||||
|
coordinates=(-0.2967811321994103, 0.4867937179527957)
|
||||||
|
),
|
||||||
|
"J2": SimpleNamespace(
|
||||||
|
coordinates=(1.8589649622870763, -1.0070372175769569)
|
||||||
|
),
|
||||||
|
"J3": SimpleNamespace(
|
||||||
|
coordinates=(-1.4882734126175363, -1.6220731365802619)
|
||||||
|
),
|
||||||
|
"J4": SimpleNamespace(
|
||||||
|
coordinates=(1.203441836600899, 2.2320704059758474)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
self._links = {
|
||||||
|
"P1": SimpleNamespace(
|
||||||
|
diameter=0.4, start_node_name="J1", end_node_name="J2"
|
||||||
|
),
|
||||||
|
"P2": SimpleNamespace(
|
||||||
|
diameter=0.4, start_node_name="J2", end_node_name="J3"
|
||||||
|
),
|
||||||
|
"P3": SimpleNamespace(
|
||||||
|
diameter=0.4, start_node_name="J3", end_node_name="J4"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_node(self, node_id):
|
||||||
|
return self._nodes[node_id]
|
||||||
|
|
||||||
|
def get_link(self, link_id):
|
||||||
|
return self._links[link_id]
|
||||||
|
|
||||||
|
|
||||||
|
def test_kmeans_filters_candidates_by_minimum_pipe_diameter():
|
||||||
|
selected = optimize_sensor_placement(
|
||||||
|
FakeNetwork(), sensor_count=2, min_diameter_mm=300
|
||||||
|
)
|
||||||
|
|
||||||
|
assert set(selected) == {"J1", "J2"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_kmeans_rejects_sensor_count_above_eligible_candidates():
|
||||||
|
with pytest.raises(ValueError, match="候选节点数量"):
|
||||||
|
optimize_sensor_placement(
|
||||||
|
FakeNetwork(), sensor_count=3, min_diameter_mm=300
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_kmeans_returns_unique_monitoring_nodes():
|
||||||
|
selected = optimize_sensor_placement(
|
||||||
|
DuplicateNearestNodeNetwork(), sensor_count=2, min_diameter_mm=300
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(selected) == 2
|
||||||
|
assert len(set(selected)) == 2
|
||||||
@@ -6,7 +6,7 @@ import wntr
|
|||||||
from scipy.sparse import csr_matrix, isspmatrix_csr
|
from scipy.sparse import csr_matrix, isspmatrix_csr
|
||||||
from scipy.sparse.csgraph import dijkstra
|
from scipy.sparse.csgraph import dijkstra
|
||||||
|
|
||||||
from app.algorithms.sensor import sensitivity
|
from app.algorithms.pressure_sensor_placement import sensitivity_placement as sensitivity
|
||||||
|
|
||||||
|
|
||||||
def _build_test_network() -> wntr.network.WaterNetworkModel:
|
def _build_test_network() -> wntr.network.WaterNetworkModel:
|
||||||
|
|||||||
@@ -1,26 +1,10 @@
|
|||||||
from collections import defaultdict
|
from app.algorithms.valve_isolation import topology_search as valve
|
||||||
|
|
||||||
from app.algorithms.isolation import valve
|
|
||||||
|
|
||||||
|
|
||||||
def test_non_isolatable_omits_affected_node_ids_but_keeps_count(monkeypatch):
|
def test_non_isolatable_omits_affected_node_ids_but_keeps_count():
|
||||||
pipe_adj = defaultdict(
|
links = ["P-1:pipe:A:B", "P-2:pipe:B:C", "V-optional:valve:A:C"]
|
||||||
set,
|
|
||||||
{
|
|
||||||
"A": {"B"},
|
|
||||||
"B": {"A", "C"},
|
|
||||||
"C": {"B"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
topology = (
|
|
||||||
pipe_adj,
|
|
||||||
{"V-optional": ("A", "C")},
|
|
||||||
{"P-1": ("A", "B", "pipe")},
|
|
||||||
{"A", "B", "C"},
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(valve, "_get_network_topology", lambda _network: topology)
|
|
||||||
|
|
||||||
result = valve.valve_isolation_analysis("demo", "P-1")
|
result = valve.valve_isolation_analysis(links, "P-1")
|
||||||
|
|
||||||
assert result["isolatable"] is False
|
assert result["isolatable"] is False
|
||||||
assert result["affected_node_count"] == 3
|
assert result["affected_node_count"] == 3
|
||||||
@@ -28,17 +12,10 @@ def test_non_isolatable_omits_affected_node_ids_but_keeps_count(monkeypatch):
|
|||||||
assert result["optional_valves"] == ["V-optional"]
|
assert result["optional_valves"] == ["V-optional"]
|
||||||
|
|
||||||
|
|
||||||
def test_isolatable_keeps_affected_node_ids_and_count(monkeypatch):
|
def test_isolatable_keeps_affected_node_ids_and_count():
|
||||||
pipe_adj = defaultdict(set, {"A": {"B"}, "B": {"A"}})
|
links = ["P-1:pipe:A:B", "V-close:valve:B:C"]
|
||||||
topology = (
|
|
||||||
pipe_adj,
|
|
||||||
{"V-close": ("B", "C")},
|
|
||||||
{"P-1": ("A", "B", "pipe")},
|
|
||||||
{"A", "B", "C"},
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(valve, "_get_network_topology", lambda _network: topology)
|
|
||||||
|
|
||||||
result = valve.valve_isolation_analysis("demo", "P-1")
|
result = valve.valve_isolation_analysis(links, "P-1")
|
||||||
|
|
||||||
assert result["isolatable"] is True
|
assert result["isolatable"] is True
|
||||||
assert result["affected_node_count"] == 2
|
assert result["affected_node_count"] == 2
|
||||||
@@ -46,21 +23,14 @@ def test_isolatable_keeps_affected_node_ids_and_count(monkeypatch):
|
|||||||
assert result["must_close_valves"] == ["V-close"]
|
assert result["must_close_valves"] == ["V-close"]
|
||||||
|
|
||||||
|
|
||||||
def test_disabled_valve_expands_affected_area_before_counting(monkeypatch):
|
def test_disabled_valve_expands_affected_area_before_counting():
|
||||||
pipe_adj = defaultdict(set, {"A": {"B"}, "B": {"A"}})
|
links = [
|
||||||
topology = (
|
"P-1:pipe:A:B",
|
||||||
pipe_adj,
|
"V-disabled:valve:B:C",
|
||||||
{
|
"V-close:valve:C:D",
|
||||||
"V-disabled": ("B", "C"),
|
]
|
||||||
"V-close": ("C", "D"),
|
|
||||||
},
|
|
||||||
{"P-1": ("A", "B", "pipe")},
|
|
||||||
{"A", "B", "C", "D"},
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(valve, "_get_network_topology", lambda _network: topology)
|
|
||||||
|
|
||||||
result = valve.valve_isolation_analysis(
|
result = valve.valve_isolation_analysis(
|
||||||
"demo",
|
links,
|
||||||
"P-1",
|
"P-1",
|
||||||
disabled_valves=["V-disabled"],
|
disabled_valves=["V-disabled"],
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user