SPipe 데모 v2 (2026-10-30) 서버 배포본
- SPipe_server_2026-10-30.zip 내용을 풀어서 등록 (압축 파일은 올리지 않음) - README에 데모 v2 서버 업데이트 순서 추가, 저장소에 없는 문서 목차 정리 - 패치내역을 docs/로 이동, _worklog.md 추가 - 임시 파일(backend/temp.*)과 API 키가 든 파일은 제외
This commit is contained in:
commit
21ebb16fb7
181 files changed
+57207
No files matched your search
@@ -0,0 +1,11 @@
|
||||
"""상수도 블록시스템 자동 분할 도구.
|
||||
|
||||
「지방상수도 현대화사업 업무편람」(환경부, 2021) 2편 Ⅰ.8 블록시스템 구축계획 수립의
|
||||
구축절차 ①~④를 지원한다. 절차 ⑤(블록고립 현장조사)와 최종 경계 확정은 범위 밖이며
|
||||
사람이 판단한다.
|
||||
|
||||
현재 구현 범위(MVP, Step 1~6): 네트워크 로드, 기준 수리해석, 가중 그래프 구성,
|
||||
블록 분할, 경계밸브/유량계 산정, 분할안 수리적 검증까지.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,6 @@
|
||||
import sys
|
||||
|
||||
from .cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Step 7 — 경계 관로 추출 및 밸브/유량계 배정, Step 8 — 모델에 반영."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
|
||||
import networkx as nx
|
||||
import wntr
|
||||
|
||||
from .validate import BaselineResult
|
||||
|
||||
|
||||
@dataclass
|
||||
class BoundaryLink:
|
||||
pipe_id: str
|
||||
from_node: str
|
||||
to_node: str
|
||||
block_a: int
|
||||
block_b: int
|
||||
action: str # 'close' | 'meter'
|
||||
baseline_flow: float
|
||||
diameter: float
|
||||
|
||||
|
||||
def find_boundary_links(G: nx.Graph, labels: dict[str, int]) -> list[BoundaryLink]:
|
||||
"""경계 엣지 = 양 끝 노드의 블록 라벨이 다른 엣지. 병렬관은 개별 pipe_id로 전개."""
|
||||
boundary: list[BoundaryLink] = []
|
||||
for u, v, data in G.edges(data=True):
|
||||
if u not in labels or v not in labels:
|
||||
continue
|
||||
block_a, block_b = labels[u], labels[v]
|
||||
if block_a == block_b:
|
||||
continue
|
||||
for pipe_id in data.get("pipe_ids", []):
|
||||
boundary.append(
|
||||
BoundaryLink(
|
||||
pipe_id=pipe_id,
|
||||
from_node=u,
|
||||
to_node=v,
|
||||
block_a=block_a,
|
||||
block_b=block_b,
|
||||
action="close",
|
||||
baseline_flow=0.0,
|
||||
diameter=data.get("diameter", 0.0),
|
||||
)
|
||||
)
|
||||
return boundary
|
||||
|
||||
|
||||
def assign_valve_or_meter(
|
||||
boundary: list[BoundaryLink],
|
||||
baseline: BaselineResult,
|
||||
inlets_per_block: int = 1,
|
||||
) -> list[BoundaryLink]:
|
||||
"""블록별 유입 경로: baseline 유량 상위 inlets_per_block개는 유량계, 나머지는 차단."""
|
||||
for link in boundary:
|
||||
link.baseline_flow = baseline.link_flow_mean_abs.get(link.pipe_id, 0.0)
|
||||
|
||||
by_block: dict[int, list[BoundaryLink]] = {}
|
||||
for link in boundary:
|
||||
by_block.setdefault(link.block_a, []).append(link)
|
||||
by_block.setdefault(link.block_b, []).append(link)
|
||||
|
||||
meter_pipe_ids: set[str] = set()
|
||||
for links in by_block.values():
|
||||
ranked = sorted(links, key=lambda l: l.baseline_flow, reverse=True)
|
||||
meter_pipe_ids.update(l.pipe_id for l in ranked[:inlets_per_block])
|
||||
|
||||
for link in boundary:
|
||||
link.action = "meter" if link.pipe_id in meter_pipe_ids else "close"
|
||||
|
||||
return boundary
|
||||
|
||||
|
||||
def apply_blocks_to_model(
|
||||
wn: "wntr.network.WaterNetworkModel", boundary: list[BoundaryLink]
|
||||
) -> "wntr.network.WaterNetworkModel":
|
||||
wn_blocked = copy.deepcopy(wn)
|
||||
for link in boundary:
|
||||
if link.action == "close":
|
||||
wn_blocked.get_link(link.pipe_id).initial_status = "Closed"
|
||||
return wn_blocked
|
||||
@@ -0,0 +1,170 @@
|
||||
"""CLI: python -m blocksys run --inp network.inp --config config.yaml [--k K] --out ./result
|
||||
|
||||
MVP(Step 1~6) 범위: 로드 -> 기준 수리해석 -> 가중 그래프 -> 분할 -> 경계밸브/유량계 ->
|
||||
분할안 수리적 검증. balance/refine, score.py, report.html, valve_segment_analysis는
|
||||
아직 구현되지 않았다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import logging
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import wntr
|
||||
|
||||
from .boundary import BoundaryLink, apply_blocks_to_model, assign_valve_or_meter, find_boundary_links
|
||||
from .config import Config
|
||||
from .graph import apply_hard_constraints, build_weighted_graph
|
||||
from .io import load_network
|
||||
from .partition import partition
|
||||
from .validate import ValidationResult, baseline_simulation, hydraulic_validation
|
||||
|
||||
logger = logging.getLogger("blocksys")
|
||||
|
||||
DISCLAIMER = (
|
||||
"본 결과는 블록분할 검토 초안이며, 최종 블록 경계는 업무편람 구축절차 ⑤ "
|
||||
"블록고립 현장조사를 거쳐 확정한다."
|
||||
)
|
||||
|
||||
|
||||
def build_arg_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="blocksys")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
run = sub.add_parser("run", help="블록 분할 대안 생성 및 수리적 검증 (MVP: Step 1~6)")
|
||||
run.add_argument("--inp", required=True, type=Path, help="EPANET .inp 파일 경로")
|
||||
run.add_argument("--config", type=Path, default=None, help="config.yaml 경로 (생략 시 기본값)")
|
||||
run.add_argument("--connections", type=Path, default=None, help="connections.csv 경로 (선택)")
|
||||
run.add_argument("--k", type=int, default=None, help="단일 대안만 생성. 미지정 시 k_min~k_max 스윕")
|
||||
run.add_argument("--out", type=Path, default=Path("./result"), help="출력 디렉터리")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def _ensure_utf8_stdio() -> None:
|
||||
# Windows 콘솔 기본 코드페이지(cp949 등)에서 한글 로그가 깨지는 것을 방지.
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
stream.reconfigure(encoding="utf-8")
|
||||
except (AttributeError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def run_command(args: argparse.Namespace) -> int:
|
||||
_ensure_utf8_stdio()
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
||||
|
||||
cfg = Config.from_yaml(args.config) if args.config else Config()
|
||||
args.out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger.info("네트워크 로드: %s", args.inp)
|
||||
bundle = load_network(args.inp, args.connections)
|
||||
|
||||
logger.info("기준 수리해석(baseline) 실행 중...")
|
||||
baseline = baseline_simulation(bundle.wn, cfg)
|
||||
|
||||
Gw = build_weighted_graph(bundle.graph, baseline, cfg)
|
||||
Gw = apply_hard_constraints(Gw, cfg)
|
||||
|
||||
k_values = [args.k] if args.k is not None else list(range(cfg.partition.k_min, cfg.partition.k_max + 1))
|
||||
|
||||
summary_rows = []
|
||||
for k in k_values:
|
||||
logger.info("k=%d 대안 생성 중...", k)
|
||||
try:
|
||||
labels = partition(Gw, k, cfg)
|
||||
except ValueError as exc:
|
||||
logger.warning("k=%d 분할 실패: %s", k, exc)
|
||||
continue
|
||||
|
||||
boundary = find_boundary_links(Gw, labels)
|
||||
boundary = assign_valve_or_meter(boundary, baseline, inlets_per_block=cfg.boundary.inlets_per_block)
|
||||
wn_blocked = apply_blocks_to_model(bundle.wn, boundary)
|
||||
validation = hydraulic_validation(wn_blocked, baseline, cfg)
|
||||
|
||||
_write_boundary_csv(args.out / f"boundary_valves_k{k}.csv", boundary)
|
||||
wntr.network.io.write_inpfile(wn_blocked, str(args.out / f"alt_{k}.inp"))
|
||||
|
||||
summary_rows.append(_summarize(k, Gw, boundary, validation))
|
||||
logger.info(
|
||||
"k=%d: valve=%d meter=%d status=%s",
|
||||
k,
|
||||
sum(1 for b in boundary if b.action == "close"),
|
||||
sum(1 for b in boundary if b.action == "meter"),
|
||||
validation.status,
|
||||
)
|
||||
|
||||
_write_summary_csv(args.out / "validation.csv", summary_rows)
|
||||
|
||||
logger.info("완료. 결과 디렉터리: %s", args.out)
|
||||
logger.info(DISCLAIMER)
|
||||
return 0
|
||||
|
||||
|
||||
def _summarize(k: int, Gw, boundary: list[BoundaryLink], validation: ValidationResult) -> dict:
|
||||
boundary_length = sum(
|
||||
Gw[b.from_node][b.to_node].get("length", 0.0) for b in boundary if Gw.has_edge(b.from_node, b.to_node)
|
||||
)
|
||||
return {
|
||||
"k": k,
|
||||
"n_valves": sum(1 for b in boundary if b.action == "close"),
|
||||
"n_meters": sum(1 for b in boundary if b.action == "meter"),
|
||||
"boundary_pipe_length_m": round(boundary_length, 1),
|
||||
"min_pressure_m": _fmt(validation.min_pressure_m),
|
||||
"pressure_violations": len(validation.pressure_low_violations) + len(validation.pressure_high_violations),
|
||||
"isolated_nodes": len(validation.isolated_nodes),
|
||||
"max_water_age_h": _fmt(validation.max_water_age_h),
|
||||
"status": validation.status,
|
||||
"rejection_reason": validation.rejection_reason or "",
|
||||
}
|
||||
|
||||
|
||||
def _fmt(value: float) -> str:
|
||||
return "" if value is None or math.isnan(value) else round(value, 2)
|
||||
|
||||
|
||||
def _write_boundary_csv(path: Path, boundary: list[BoundaryLink]) -> None:
|
||||
fieldnames = ["pipe_id", "from_node", "to_node", "block_a", "block_b", "action", "baseline_flow", "diameter"]
|
||||
with path.open("w", newline="", encoding="utf-8-sig") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
for b in boundary:
|
||||
writer.writerow(
|
||||
{
|
||||
"pipe_id": b.pipe_id,
|
||||
"from_node": b.from_node,
|
||||
"to_node": b.to_node,
|
||||
"block_a": b.block_a,
|
||||
"block_b": b.block_b,
|
||||
"action": b.action,
|
||||
"baseline_flow": b.baseline_flow,
|
||||
"diameter": b.diameter,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _write_summary_csv(path: Path, rows: list[dict]) -> None:
|
||||
if not rows:
|
||||
logger.warning("유효한 대안이 없어 validation.csv를 생성하지 않았습니다")
|
||||
return
|
||||
with path.open("w", newline="", encoding="utf-8-sig") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_arg_parser()
|
||||
args = parser.parse_args(argv)
|
||||
if args.command == "run":
|
||||
return run_command(args)
|
||||
parser.print_help()
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,43 @@
|
||||
crs: EPSG:5186
|
||||
|
||||
partition:
|
||||
level: small # large | medium | small
|
||||
k_min: 4 # 블록 수 탐색 하한
|
||||
k_max: 20 # 상한
|
||||
method: spectral # spectral | louvain
|
||||
flow_weight_alpha: 0.7 # 엣지 가중치에서 유량 비중 (나머지는 관경)
|
||||
|
||||
constraints:
|
||||
connections_per_block:
|
||||
min: 500 # TODO: 편람에 수치 규정 없음. 사내 관행값 확정 필요
|
||||
max: 3000
|
||||
max_pipe_length_per_block: 30000 # m, TODO
|
||||
demand_per_block:
|
||||
max_cmd: null # m3/day, null이면 미적용
|
||||
elevation_band_m: 30 # 표고차 이 값 초과 시 경계 선호
|
||||
large_consumer_demand_lps: 5.0 # 이상이면 대수용가로 태깅, TODO
|
||||
large_consumer_policy: isolate # isolate | keep_together
|
||||
sources_in_separate_blocks: true
|
||||
|
||||
hydraulics:
|
||||
duration_hours: 24
|
||||
timestep_minutes: 60
|
||||
min_pressure_m: 15.0 # TODO: 수도법·상수도설계기준 확인 후 확정
|
||||
max_pressure_m: 71.0 # TODO
|
||||
max_water_age_hours: 48 # TODO
|
||||
allow_isolated_nodes: false
|
||||
|
||||
refine:
|
||||
max_iterations: 20
|
||||
moves_per_iteration: 50
|
||||
|
||||
boundary:
|
||||
inlets_per_block: 1 # TODO: 통상 1개, 예비 포함 2개
|
||||
|
||||
scoring:
|
||||
weights:
|
||||
n_valves: 0.25
|
||||
n_meters: 0.15
|
||||
pressure_violations: 0.30
|
||||
max_water_age: 0.15
|
||||
boundary_pipe_length: 0.15
|
||||
@@ -0,0 +1,114 @@
|
||||
"""설정파일(config.yaml) 스키마와 로더.
|
||||
|
||||
사내 관행값·발주처 기준이 필요한 TODO 항목은 §9 표를 참고. 전부 이 모듈의
|
||||
기본값으로 노출되어 있으며, config.yaml로 덮어쓴다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConnectionsPerBlock:
|
||||
min: int = 500 # TODO: 편람에 수치 규정 없음. 사내 관행값 확정 필요
|
||||
max: int = 3000
|
||||
|
||||
|
||||
@dataclass
|
||||
class DemandPerBlock:
|
||||
max_cmd: Optional[float] = None # m3/day, null이면 미적용
|
||||
|
||||
|
||||
@dataclass
|
||||
class PartitionConfig:
|
||||
level: str = "small" # large | medium | small
|
||||
k_min: int = 4
|
||||
k_max: int = 20
|
||||
method: str = "spectral" # spectral | louvain
|
||||
flow_weight_alpha: float = 0.7
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConstraintsConfig:
|
||||
connections_per_block: ConnectionsPerBlock = field(default_factory=ConnectionsPerBlock)
|
||||
max_pipe_length_per_block: float = 30000.0 # m, TODO
|
||||
demand_per_block: DemandPerBlock = field(default_factory=DemandPerBlock)
|
||||
elevation_band_m: float = 30.0
|
||||
large_consumer_demand_lps: float = 5.0 # TODO
|
||||
large_consumer_policy: str = "isolate" # isolate | keep_together
|
||||
sources_in_separate_blocks: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class HydraulicsConfig:
|
||||
duration_hours: int = 24
|
||||
timestep_minutes: int = 60
|
||||
min_pressure_m: float = 15.0 # TODO: 수도법 시행규칙 / 상수도설계기준 확인 후 확정
|
||||
max_pressure_m: float = 71.0 # TODO
|
||||
max_water_age_hours: float = 48.0 # TODO
|
||||
allow_isolated_nodes: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class RefineConfig:
|
||||
max_iterations: int = 20
|
||||
moves_per_iteration: int = 50
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScoringWeights:
|
||||
n_valves: float = 0.25
|
||||
n_meters: float = 0.15
|
||||
pressure_violations: float = 0.30
|
||||
max_water_age: float = 0.15
|
||||
boundary_pipe_length: float = 0.15
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScoringConfig:
|
||||
weights: ScoringWeights = field(default_factory=ScoringWeights)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BoundaryConfig:
|
||||
# 블록당 유입지점(유량계) 목표 개수. 편람에 수치 없음, 통상 1개 예비 포함 2개. TODO
|
||||
inlets_per_block: int = 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
crs: str = "EPSG:5186"
|
||||
partition: PartitionConfig = field(default_factory=PartitionConfig)
|
||||
constraints: ConstraintsConfig = field(default_factory=ConstraintsConfig)
|
||||
hydraulics: HydraulicsConfig = field(default_factory=HydraulicsConfig)
|
||||
refine: RefineConfig = field(default_factory=RefineConfig)
|
||||
scoring: ScoringConfig = field(default_factory=ScoringConfig)
|
||||
boundary: BoundaryConfig = field(default_factory=BoundaryConfig)
|
||||
|
||||
@classmethod
|
||||
def from_yaml(cls, path: str | Path) -> "Config":
|
||||
raw = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
|
||||
return cls.from_dict(raw)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, raw: dict[str, Any]) -> "Config":
|
||||
cfg = cls()
|
||||
_apply(cfg, raw)
|
||||
return cfg
|
||||
|
||||
|
||||
def _apply(instance: Any, data: dict[str, Any]) -> None:
|
||||
for key, value in data.items():
|
||||
if not hasattr(instance, key):
|
||||
continue
|
||||
current = getattr(instance, key)
|
||||
if dataclasses.is_dataclass(current) and isinstance(value, dict):
|
||||
_apply(current, value)
|
||||
else:
|
||||
setattr(instance, key, value)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Step 3 — 가중 그래프 구성, Step 4 — 하드 제약(강한 선호)을 가중치에 반영."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import networkx as nx
|
||||
|
||||
from .config import Config
|
||||
from .validate import BaselineResult
|
||||
|
||||
|
||||
def build_weighted_graph(G: nx.Graph, baseline: BaselineResult, cfg: Config) -> nx.Graph:
|
||||
"""엣지 가중치 = '이 관로를 자르기 싫은 정도'. 클수록 절단 회피."""
|
||||
Gw = G.copy()
|
||||
alpha = cfg.partition.flow_weight_alpha
|
||||
|
||||
flows: dict[tuple, float] = {}
|
||||
for u, v, data in Gw.edges(data=True):
|
||||
pipe_ids = data.get("pipe_ids", [])
|
||||
flows[(u, v)] = max((baseline.link_flow_mean_abs.get(pid, 0.0) for pid in pipe_ids), default=0.0)
|
||||
|
||||
max_flow = max(flows.values(), default=0.0)
|
||||
max_diameter = max((data.get("diameter", 0.0) for _, _, data in Gw.edges(data=True)), default=0.0)
|
||||
|
||||
for u, v, data in Gw.edges(data=True):
|
||||
q_norm = (flows[(u, v)] / max_flow) if max_flow > 0 else 0.0
|
||||
d_norm = (data.get("diameter", 0.0) / max_diameter) if max_diameter > 0 else 0.0
|
||||
w = alpha * q_norm + (1 - alpha) * d_norm
|
||||
data["w"] = float(min(max(w, 1e-6), 1.0))
|
||||
|
||||
return Gw
|
||||
|
||||
|
||||
def apply_hard_constraints(Gw: nx.Graph, cfg: Config) -> nx.Graph:
|
||||
c = cfg.constraints
|
||||
|
||||
# 1. 표고대 분리
|
||||
for u, v, data in Gw.edges(data=True):
|
||||
eu = Gw.nodes[u].get("elevation", 0.0)
|
||||
ev = Gw.nodes[v].get("elevation", 0.0)
|
||||
if abs(eu - ev) > c.elevation_band_m:
|
||||
data["w"] *= 0.1
|
||||
|
||||
# 2. 대수용가 (base_demand는 SI[m3/s], 기준값은 L/s로 주어짐)
|
||||
threshold_m3s = c.large_consumer_demand_lps / 1000.0
|
||||
large_consumers = {
|
||||
n for n, ndata in Gw.nodes(data=True) if ndata.get("base_demand", 0.0) >= threshold_m3s
|
||||
}
|
||||
for n in Gw.nodes:
|
||||
Gw.nodes[n]["is_large_consumer"] = n in large_consumers
|
||||
|
||||
factor = 0.1 if c.large_consumer_policy == "isolate" else 5.0
|
||||
for n in large_consumers:
|
||||
for _, _, data in Gw.edges(n, data=True):
|
||||
data["w"] *= factor
|
||||
|
||||
# 3. 수원 분리: Reservoir/Tank 노드쌍 간 최단경로(연장 기준) 상 엣지
|
||||
if c.sources_in_separate_blocks:
|
||||
sources = [n for n, ndata in Gw.nodes(data=True) if ndata.get("node_type") in ("Reservoir", "Tank")]
|
||||
for i, s1 in enumerate(sources):
|
||||
for s2 in sources[i + 1 :]:
|
||||
if not nx.has_path(Gw, s1, s2):
|
||||
continue
|
||||
path = nx.shortest_path(Gw, s1, s2, weight="length")
|
||||
for a, b in zip(path[:-1], path[1:]):
|
||||
Gw[a][b]["w"] *= 0.5
|
||||
|
||||
for _, _, data in Gw.edges(data=True):
|
||||
data["w"] = float(min(max(data["w"], 1e-6), 1.0))
|
||||
|
||||
return Gw
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Step 1 — EPANET 모델 로드 및 단순 그래프 변환."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import networkx as nx
|
||||
import pandas as pd
|
||||
import wntr
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NetworkBundle:
|
||||
wn: "wntr.network.WaterNetworkModel"
|
||||
graph: nx.Graph
|
||||
isolated_nodes: list[str]
|
||||
connectivity_warning: Optional[str] = None
|
||||
|
||||
|
||||
def load_network(inp_path: str | Path, connections_csv: Optional[str | Path] = None) -> NetworkBundle:
|
||||
wn = wntr.network.WaterNetworkModel(str(inp_path))
|
||||
|
||||
G = _build_simple_graph(wn)
|
||||
_inject_node_attributes(wn, G)
|
||||
_load_connections(G, connections_csv)
|
||||
|
||||
isolated = [n for n in G.nodes if G.degree(n) == 0]
|
||||
if isolated:
|
||||
logger.warning("%d개 노드가 어떤 관로에도 연결되어 있지 않습니다: %s", len(isolated), isolated[:10])
|
||||
|
||||
connectivity_warning = None
|
||||
if G.number_of_nodes() > 0 and not nx.is_connected(G):
|
||||
n_components = nx.number_connected_components(G)
|
||||
connectivity_warning = f"네트워크가 {n_components}개의 연결되지 않은 구성요소로 나뉘어 있습니다"
|
||||
logger.warning(connectivity_warning)
|
||||
|
||||
return NetworkBundle(wn=wn, graph=G, isolated_nodes=isolated, connectivity_warning=connectivity_warning)
|
||||
|
||||
|
||||
def _build_simple_graph(wn: "wntr.network.WaterNetworkModel") -> nx.Graph:
|
||||
multi = wn.to_graph() # MultiDiGraph, 노드 좌표 등 기본 속성 포함
|
||||
|
||||
G = nx.Graph()
|
||||
G.add_nodes_from(multi.nodes)
|
||||
|
||||
grouped: dict[tuple[str, str], list[str]] = {}
|
||||
for u, v, key in multi.edges(keys=True):
|
||||
pair = (u, v) if u <= v else (v, u)
|
||||
grouped.setdefault(pair, []).append(key)
|
||||
|
||||
for (u, v), link_names in grouped.items():
|
||||
lengths, diameters, roughness = [], [], []
|
||||
for name in link_names:
|
||||
link = wn.get_link(name)
|
||||
lengths.append(getattr(link, "length", 0.0) or 0.0)
|
||||
diameters.append(getattr(link, "diameter", 0.0) or 0.0)
|
||||
roughness.append(getattr(link, "roughness", 0.0) or 0.0)
|
||||
|
||||
if len(link_names) == 1:
|
||||
length, diameter, rough = lengths[0], diameters[0], roughness[0]
|
||||
else:
|
||||
# 병렬관 등가화: 단면적(D^2) 합산 기준 등가관경, 연장은 최단 경로 기준 최소값.
|
||||
# 실제 수리해석은 원본 wn(EPANET 엔진)으로 수행하므로 여기서는 분할 가중치용 근사치.
|
||||
length = min(lengths) if lengths else 0.0
|
||||
diameter = (sum(d**2 for d in diameters)) ** 0.5 if diameters else 0.0
|
||||
rough = max(roughness) if roughness else 0.0
|
||||
|
||||
G.add_edge(u, v, length=length, diameter=diameter, roughness=rough, pipe_ids=list(link_names))
|
||||
|
||||
return G
|
||||
|
||||
|
||||
def _inject_node_attributes(wn: "wntr.network.WaterNetworkModel", G: nx.Graph) -> None:
|
||||
for node_name in G.nodes:
|
||||
node = wn.get_node(node_name)
|
||||
|
||||
elevation = getattr(node, "elevation", None)
|
||||
if elevation is None:
|
||||
elevation = getattr(node, "base_head", 0.0) # Reservoir
|
||||
|
||||
base_demand = 0.0
|
||||
for demand in getattr(node, "demand_timeseries_list", []) or []:
|
||||
base_demand += demand.base_value
|
||||
|
||||
G.nodes[node_name]["elevation"] = float(elevation or 0.0)
|
||||
G.nodes[node_name]["base_demand"] = float(base_demand)
|
||||
G.nodes[node_name]["coordinates"] = node.coordinates
|
||||
G.nodes[node_name]["node_type"] = type(node).__name__
|
||||
G.nodes[node_name]["connections"] = None
|
||||
G.nodes[node_name]["is_large_consumer_csv"] = False
|
||||
|
||||
|
||||
def _load_connections(G: nx.Graph, connections_csv: Optional[str | Path]) -> None:
|
||||
if not connections_csv:
|
||||
logger.warning(
|
||||
"connections.csv가 제공되지 않았습니다. 급수전수 기반 제약"
|
||||
"(constraints.connections_per_block)은 이번 실행에서 평가되지 않습니다."
|
||||
)
|
||||
return
|
||||
|
||||
df = pd.read_csv(connections_csv, dtype={"node_id": str})
|
||||
missing = {"node_id", "connections"} - set(df.columns)
|
||||
if missing:
|
||||
raise ValueError(f"connections.csv에 필수 컬럼이 없습니다: {sorted(missing)}")
|
||||
|
||||
unknown = 0
|
||||
for _, row in df.iterrows():
|
||||
node_id = str(row["node_id"])
|
||||
if node_id not in G.nodes:
|
||||
unknown += 1
|
||||
continue
|
||||
G.nodes[node_id]["connections"] = float(row["connections"])
|
||||
if "is_large_consumer" in df.columns and not pd.isna(row["is_large_consumer"]):
|
||||
G.nodes[node_id]["is_large_consumer_csv"] = bool(row["is_large_consumer"])
|
||||
|
||||
if unknown:
|
||||
logger.warning("connections.csv의 %d개 행이 네트워크 노드와 일치하지 않아 무시되었습니다", unknown)
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Step 5 — 블록 분할 (MVP: partition만. balance_check/refine은 범위 밖)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import networkx as nx
|
||||
import numpy as np
|
||||
from sklearn.cluster import SpectralClustering
|
||||
|
||||
from .config import Config
|
||||
|
||||
|
||||
def partition(Gw: nx.Graph, k: int, cfg: Config) -> dict[str, int]:
|
||||
if k < 1:
|
||||
raise ValueError(f"k는 1 이상이어야 합니다: {k}")
|
||||
if Gw.number_of_nodes() < k:
|
||||
raise ValueError(f"노드 수({Gw.number_of_nodes()})가 k({k})보다 적습니다")
|
||||
|
||||
method = cfg.partition.method
|
||||
if method == "spectral":
|
||||
return _partition_spectral(Gw, k)
|
||||
if method == "louvain":
|
||||
return _partition_louvain(Gw, k)
|
||||
raise ValueError(f"알 수 없는 분할 방법: {method}")
|
||||
|
||||
|
||||
def _partition_spectral(Gw: nx.Graph, k: int) -> dict[str, int]:
|
||||
nodes = list(Gw.nodes)
|
||||
A = nx.to_numpy_array(Gw, nodelist=nodes, weight="w")
|
||||
np.fill_diagonal(A, 0.0)
|
||||
|
||||
model = SpectralClustering(
|
||||
n_clusters=k,
|
||||
affinity="precomputed",
|
||||
assign_labels="kmeans",
|
||||
random_state=0,
|
||||
)
|
||||
labels = model.fit_predict(A)
|
||||
return {n: int(label) for n, label in zip(nodes, labels)}
|
||||
|
||||
|
||||
def _partition_louvain(Gw: nx.Graph, k: int, max_steps: int = 30) -> dict[str, int]:
|
||||
lo, hi = 0.01, 10.0
|
||||
best_communities: list[set] | None = None
|
||||
best_diff = None
|
||||
|
||||
for _ in range(max_steps):
|
||||
r = (lo + hi) / 2
|
||||
communities = nx.community.louvain_communities(Gw, weight="w", resolution=r, seed=0)
|
||||
n_found = len(communities)
|
||||
diff = abs(n_found - k)
|
||||
if best_diff is None or diff < best_diff:
|
||||
best_diff, best_communities = diff, communities
|
||||
if n_found == k:
|
||||
break
|
||||
if n_found < k:
|
||||
lo = r
|
||||
else:
|
||||
hi = r
|
||||
|
||||
labels: dict[str, int] = {}
|
||||
for i, community in enumerate(best_communities or []):
|
||||
for n in community:
|
||||
labels[n] = i
|
||||
return labels
|
||||
Whitespace-only changes.
@@ -0,0 +1,37 @@
|
||||
"""공통 픽스처. wntr 동봉 Net3.inp를 개발용 데이터로 사용 (§7)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import wntr
|
||||
|
||||
from blocksys.config import Config
|
||||
from blocksys.graph import apply_hard_constraints, build_weighted_graph
|
||||
from blocksys.io import load_network
|
||||
from blocksys.validate import baseline_simulation
|
||||
|
||||
NET3_PATH = wntr.library.model_library.get_filepath("Net3")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def cfg() -> Config:
|
||||
c = Config()
|
||||
c.hydraulics.duration_hours = 6 # 테스트 속도를 위해 24 -> 6시간으로 단축
|
||||
c.hydraulics.timestep_minutes = 60
|
||||
return c
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def bundle():
|
||||
return load_network(NET3_PATH)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def baseline(bundle, cfg):
|
||||
return baseline_simulation(bundle.wn, cfg)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def weighted_graph(bundle, baseline, cfg):
|
||||
Gw = build_weighted_graph(bundle.graph, baseline, cfg)
|
||||
return apply_hard_constraints(Gw, cfg)
|
||||
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from blocksys.boundary import apply_blocks_to_model, assign_valve_or_meter, find_boundary_links
|
||||
from blocksys.partition import partition
|
||||
|
||||
|
||||
def test_boundary_matches_differing_labels(weighted_graph, cfg):
|
||||
labels = partition(weighted_graph, 6, cfg)
|
||||
boundary = find_boundary_links(weighted_graph, labels)
|
||||
|
||||
expected_pairs = {
|
||||
(u, v)
|
||||
for u, v in weighted_graph.edges
|
||||
if u in labels and v in labels and labels[u] != labels[v]
|
||||
}
|
||||
actual_pairs = {(b.from_node, b.to_node) for b in boundary}
|
||||
assert actual_pairs == expected_pairs
|
||||
|
||||
|
||||
def test_each_boundary_link_has_a_real_pipe_id(weighted_graph, bundle, cfg):
|
||||
labels = partition(weighted_graph, 6, cfg)
|
||||
boundary = find_boundary_links(weighted_graph, labels)
|
||||
for b in boundary:
|
||||
assert b.pipe_id in bundle.wn.link_name_list
|
||||
|
||||
|
||||
def test_assign_valve_or_meter_gives_every_block_an_inlet(weighted_graph, baseline, cfg):
|
||||
labels = partition(weighted_graph, 6, cfg)
|
||||
boundary = find_boundary_links(weighted_graph, labels)
|
||||
boundary = assign_valve_or_meter(boundary, baseline, inlets_per_block=cfg.boundary.inlets_per_block)
|
||||
|
||||
assert {b.action for b in boundary} <= {"close", "meter"}
|
||||
|
||||
blocks_touched = {b.block_a for b in boundary} | {b.block_b for b in boundary}
|
||||
for block in blocks_touched:
|
||||
links = [b for b in boundary if b.block_a == block or b.block_b == block]
|
||||
assert any(b.action == "meter" for b in links)
|
||||
|
||||
|
||||
def test_apply_blocks_to_model_only_closes_marked_links(weighted_graph, bundle, baseline, cfg):
|
||||
labels = partition(weighted_graph, 6, cfg)
|
||||
boundary = find_boundary_links(weighted_graph, labels)
|
||||
boundary = assign_valve_or_meter(boundary, baseline, inlets_per_block=cfg.boundary.inlets_per_block)
|
||||
|
||||
already_closed = {
|
||||
name for name, link in bundle.wn.links() if str(link.initial_status) == "Closed"
|
||||
}
|
||||
wn_blocked = apply_blocks_to_model(bundle.wn, boundary)
|
||||
|
||||
newly_closed_expected = {b.pipe_id for b in boundary if b.action == "close"}
|
||||
closed_actual = {
|
||||
name for name, link in wn_blocked.links() if str(link.initial_status) == "Closed"
|
||||
}
|
||||
assert closed_actual == already_closed | newly_closed_expected
|
||||
# 원본 모델은 변경되지 않아야 한다
|
||||
assert all(
|
||||
str(bundle.wn.get_link(pid).initial_status) != "Closed"
|
||||
for pid in newly_closed_expected - already_closed
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_node_preservation(bundle, weighted_graph):
|
||||
assert set(weighted_graph.nodes) == set(bundle.graph.nodes)
|
||||
|
||||
|
||||
def test_edge_preservation(bundle, weighted_graph):
|
||||
assert weighted_graph.number_of_edges() == bundle.graph.number_of_edges()
|
||||
|
||||
|
||||
def test_weight_range(weighted_graph):
|
||||
weights = [d["w"] for _, _, d in weighted_graph.edges(data=True)]
|
||||
assert weights, "가중치가 부여된 엣지가 없습니다"
|
||||
assert all(0.0 < w <= 1.0 for w in weights)
|
||||
|
||||
|
||||
def test_large_consumer_edges_suppressed(bundle, weighted_graph, baseline, cfg):
|
||||
from blocksys.graph import build_weighted_graph
|
||||
|
||||
large_nodes = [n for n, d in weighted_graph.nodes(data=True) if d.get("is_large_consumer")]
|
||||
if not large_nodes:
|
||||
return # Net3 기본 설정(5 L/s)에는 대수용가가 없을 수 있음
|
||||
|
||||
plain = build_weighted_graph(bundle.graph, baseline, cfg)
|
||||
# 대수용가 인접 엣지는 제약 반영 전보다 가중치가 낮아야 한다(isolate 기본 정책)
|
||||
for n in large_nodes:
|
||||
for u, v, d in weighted_graph.edges(n, data=True):
|
||||
assert d["w"] <= plain[u][v]["w"]
|
||||
|
||||
|
||||
def test_reproducibility(bundle, baseline, cfg):
|
||||
from blocksys.graph import apply_hard_constraints, build_weighted_graph
|
||||
|
||||
g1 = apply_hard_constraints(build_weighted_graph(bundle.graph, baseline, cfg), cfg)
|
||||
g2 = apply_hard_constraints(build_weighted_graph(bundle.graph, baseline, cfg), cfg)
|
||||
|
||||
for u, v, d in g1.edges(data=True):
|
||||
assert g2[u][v]["w"] == d["w"]
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import networkx as nx
|
||||
import pytest
|
||||
|
||||
from blocksys.partition import partition
|
||||
|
||||
K_VALUES = [4, 6, 8]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("k", K_VALUES)
|
||||
def test_node_preservation(weighted_graph, cfg, k):
|
||||
labels = partition(weighted_graph, k, cfg)
|
||||
assert set(labels.keys()) == set(weighted_graph.nodes)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("k", K_VALUES)
|
||||
def test_block_connectivity(weighted_graph, cfg, k):
|
||||
labels = partition(weighted_graph, k, cfg)
|
||||
blocks: dict[int, list] = {}
|
||||
for n, label in labels.items():
|
||||
blocks.setdefault(label, []).append(n)
|
||||
|
||||
for label, nodes in blocks.items():
|
||||
sub = weighted_graph.subgraph(nodes)
|
||||
assert nx.is_connected(sub), f"block {label} is not a single connected component"
|
||||
|
||||
|
||||
def test_reproducibility(weighted_graph, cfg):
|
||||
labels1 = partition(weighted_graph, 6, cfg)
|
||||
labels2 = partition(weighted_graph, 6, cfg)
|
||||
assert labels1 == labels2
|
||||
|
||||
|
||||
def test_k_too_large_raises(weighted_graph, cfg):
|
||||
with pytest.raises(ValueError):
|
||||
partition(weighted_graph, weighted_graph.number_of_nodes() + 1, cfg)
|
||||
|
||||
|
||||
def test_louvain_method(weighted_graph, cfg):
|
||||
cfg.partition.method = "louvain"
|
||||
try:
|
||||
labels = partition(weighted_graph, 6, cfg)
|
||||
assert set(labels.keys()) == set(weighted_graph.nodes)
|
||||
finally:
|
||||
cfg.partition.method = "spectral"
|
||||
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from blocksys.boundary import BoundaryLink, apply_blocks_to_model
|
||||
from blocksys.validate import hydraulic_validation
|
||||
|
||||
|
||||
def test_baseline_simulation_has_all_nodes(bundle, baseline):
|
||||
assert set(baseline.node_pressure_mean.keys()) == set(bundle.wn.node_name_list)
|
||||
assert set(baseline.link_flow_mean_abs.keys()) == set(bundle.wn.link_name_list)
|
||||
|
||||
|
||||
def test_baseline_pressures_are_finite(baseline):
|
||||
assert all(math.isfinite(v) for v in baseline.node_pressure_mean.values())
|
||||
|
||||
|
||||
def test_hydraulic_validation_runs_on_unmodified_network(bundle, baseline, cfg):
|
||||
result = hydraulic_validation(bundle.wn, baseline, cfg)
|
||||
assert result.status in ("ok", "violated")
|
||||
assert not result.isolated_nodes
|
||||
|
||||
|
||||
def test_isolation_is_detected_and_rejected(bundle, baseline, cfg):
|
||||
graph = bundle.graph
|
||||
source_names = set(bundle.wn.reservoir_name_list) | set(bundle.wn.tank_name_list)
|
||||
|
||||
leaf = next(n for n in graph.nodes if graph.degree(n) == 1 and n not in source_names)
|
||||
_, neighbor, edge_data = next(iter(graph.edges(leaf, data=True)))
|
||||
pipe_id = edge_data["pipe_ids"][0]
|
||||
|
||||
boundary = [
|
||||
BoundaryLink(
|
||||
pipe_id=pipe_id,
|
||||
from_node=leaf,
|
||||
to_node=neighbor,
|
||||
block_a=0,
|
||||
block_b=1,
|
||||
action="close",
|
||||
baseline_flow=0.0,
|
||||
diameter=0.0,
|
||||
)
|
||||
]
|
||||
wn_blocked = apply_blocks_to_model(bundle.wn, boundary)
|
||||
|
||||
result = hydraulic_validation(wn_blocked, baseline, cfg)
|
||||
assert leaf in result.isolated_nodes
|
||||
assert result.status == "rejected"
|
||||
assert result.rejection_reason
|
||||
|
||||
|
||||
def test_allow_isolated_nodes_still_simulates(bundle, baseline, cfg):
|
||||
graph = bundle.graph
|
||||
source_names = set(bundle.wn.reservoir_name_list) | set(bundle.wn.tank_name_list)
|
||||
leaf = next(n for n in graph.nodes if graph.degree(n) == 1 and n not in source_names)
|
||||
_, neighbor, edge_data = next(iter(graph.edges(leaf, data=True)))
|
||||
pipe_id = edge_data["pipe_ids"][0]
|
||||
|
||||
boundary = [
|
||||
BoundaryLink(
|
||||
pipe_id=pipe_id, from_node=leaf, to_node=neighbor, block_a=0, block_b=1,
|
||||
action="close", baseline_flow=0.0, diameter=0.0,
|
||||
)
|
||||
]
|
||||
wn_blocked = apply_blocks_to_model(bundle.wn, boundary)
|
||||
|
||||
cfg.hydraulics.allow_isolated_nodes = True
|
||||
try:
|
||||
result = hydraulic_validation(wn_blocked, baseline, cfg)
|
||||
assert leaf in result.isolated_nodes
|
||||
assert result.status == "violated"
|
||||
finally:
|
||||
cfg.hydraulics.allow_isolated_nodes = False
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Step 2 — 기준 수리해석, Step 9 — 분할안 수리적 검증."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
import networkx as nx
|
||||
import wntr
|
||||
|
||||
from .config import Config
|
||||
|
||||
|
||||
def _run_epanet(wn):
|
||||
"""Run wntr's EPANET simulator in a private temp dir.
|
||||
|
||||
EpanetSimulator writes temp.inp/temp.rpt/temp.bin into the *current
|
||||
directory* by default, which left stray files in backend/ on every run.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="spipe_blocksys_") as tmp:
|
||||
return wntr.sim.EpanetSimulator(wn).run_sim(file_prefix=os.path.join(tmp, "sim"))
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BaselineResult:
|
||||
node_pressure_mean: dict[str, float]
|
||||
node_pressure_min: dict[str, float]
|
||||
link_flow_mean_abs: dict[str, float]
|
||||
link_flow_max_abs: dict[str, float]
|
||||
node_demand_mean: dict[str, float]
|
||||
node_age_max: dict[str, float] # hours
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
pressure_low_violations: list[str] = field(default_factory=list)
|
||||
pressure_high_violations: list[str] = field(default_factory=list)
|
||||
isolated_nodes: list[str] = field(default_factory=list)
|
||||
water_age_violations: list[str] = field(default_factory=list)
|
||||
min_pressure_m: float = math.nan
|
||||
max_water_age_h: float = math.nan
|
||||
pressure_drop: dict[str, float] = field(default_factory=dict)
|
||||
status: str = "ok" # ok | violated | rejected
|
||||
rejection_reason: Optional[str] = None
|
||||
|
||||
@property
|
||||
def is_ok(self) -> bool:
|
||||
return self.status == "ok"
|
||||
|
||||
|
||||
def _configure_time(wn: "wntr.network.WaterNetworkModel", cfg: Config) -> None:
|
||||
duration = cfg.hydraulics.duration_hours * 3600
|
||||
step = cfg.hydraulics.timestep_minutes * 60
|
||||
wn.options.time.duration = duration
|
||||
wn.options.time.hydraulic_timestep = step
|
||||
wn.options.time.report_timestep = step
|
||||
wn.options.time.quality_timestep = min(wn.options.time.quality_timestep, step)
|
||||
|
||||
|
||||
def baseline_simulation(wn: "wntr.network.WaterNetworkModel", cfg: Config) -> BaselineResult:
|
||||
wn_sim = copy.deepcopy(wn)
|
||||
_configure_time(wn_sim, cfg)
|
||||
|
||||
results = _run_epanet(wn_sim)
|
||||
pressure = results.node["pressure"]
|
||||
flow = results.link["flowrate"].abs()
|
||||
demand = results.node["demand"]
|
||||
|
||||
return BaselineResult(
|
||||
node_pressure_mean=pressure.mean().to_dict(),
|
||||
node_pressure_min=pressure.min().to_dict(),
|
||||
link_flow_mean_abs=flow.mean().to_dict(),
|
||||
link_flow_max_abs=flow.max().to_dict(),
|
||||
node_demand_mean=demand.mean().to_dict(),
|
||||
node_age_max=_run_water_age(wn, cfg),
|
||||
)
|
||||
|
||||
|
||||
def _run_water_age(wn: "wntr.network.WaterNetworkModel", cfg: Config) -> dict[str, float]:
|
||||
wn_age = copy.deepcopy(wn)
|
||||
_configure_time(wn_age, cfg)
|
||||
wn_age.options.quality.parameter = "AGE"
|
||||
|
||||
results = _run_epanet(wn_age)
|
||||
age_hours = results.node["quality"] / 3600.0 # EPANET AGE는 초 단위(SI)로 보고됨
|
||||
return age_hours.max().to_dict()
|
||||
|
||||
|
||||
def _open_link_graph(wn: "wntr.network.WaterNetworkModel") -> nx.Graph:
|
||||
G = nx.Graph()
|
||||
G.add_nodes_from(wn.node_name_list)
|
||||
for _, link in wn.links():
|
||||
if link.initial_status == wntr.network.LinkStatus.Closed:
|
||||
continue
|
||||
G.add_edge(link.start_node_name, link.end_node_name)
|
||||
return G
|
||||
|
||||
|
||||
def _find_isolated_nodes(G: nx.Graph, source_names: list[str]) -> list[str]:
|
||||
sources = [s for s in source_names if s in G]
|
||||
if not sources:
|
||||
return []
|
||||
reachable: set[str] = set()
|
||||
for source in sources:
|
||||
reachable |= nx.node_connected_component(G, source)
|
||||
return sorted(n for n in G.nodes if n not in reachable)
|
||||
|
||||
|
||||
def hydraulic_validation(
|
||||
wn_blocked: "wntr.network.WaterNetworkModel",
|
||||
baseline: BaselineResult,
|
||||
cfg: Config,
|
||||
) -> ValidationResult:
|
||||
source_names = list(wn_blocked.reservoir_name_list) + list(wn_blocked.tank_name_list)
|
||||
isolated = _find_isolated_nodes(_open_link_graph(wn_blocked), source_names)
|
||||
|
||||
if isolated and not cfg.hydraulics.allow_isolated_nodes:
|
||||
return ValidationResult(
|
||||
isolated_nodes=isolated,
|
||||
status="rejected",
|
||||
rejection_reason=f"{len(isolated)}개 노드가 수원으로부터 고립되었습니다 (구축절차 ④ 위반)",
|
||||
)
|
||||
|
||||
try:
|
||||
wn_sim = copy.deepcopy(wn_blocked)
|
||||
_configure_time(wn_sim, cfg)
|
||||
results = _run_epanet(wn_sim)
|
||||
pressure = results.node["pressure"]
|
||||
age_max = _run_water_age(wn_blocked, cfg)
|
||||
except Exception as exc: # EPANET 엔진 수렴 실패 등
|
||||
logger.warning("수리해석 실패: %s", exc)
|
||||
return ValidationResult(
|
||||
isolated_nodes=isolated,
|
||||
status="rejected",
|
||||
rejection_reason=f"수리해석 실패: {exc}",
|
||||
)
|
||||
|
||||
pressure_min = pressure.min()
|
||||
pressure_max = pressure.max()
|
||||
|
||||
pressure_low = sorted(pressure_min[pressure_min < cfg.hydraulics.min_pressure_m].index)
|
||||
pressure_high = sorted(pressure_max[pressure_max > cfg.hydraulics.max_pressure_m].index)
|
||||
age_violations = sorted(n for n, v in age_max.items() if v > cfg.hydraulics.max_water_age_hours)
|
||||
|
||||
pressure_min_dict = pressure_min.to_dict()
|
||||
pressure_drop = {
|
||||
n: baseline.node_pressure_min[n] - pressure_min_dict[n]
|
||||
for n in pressure_min_dict
|
||||
if n in baseline.node_pressure_min
|
||||
}
|
||||
|
||||
violated = bool(pressure_low or pressure_high or age_violations or (isolated and cfg.hydraulics.allow_isolated_nodes))
|
||||
|
||||
return ValidationResult(
|
||||
pressure_low_violations=pressure_low,
|
||||
pressure_high_violations=pressure_high,
|
||||
isolated_nodes=isolated,
|
||||
water_age_violations=age_violations,
|
||||
min_pressure_m=float(pressure_min.min()) if len(pressure_min) else math.nan,
|
||||
max_water_age_h=max(age_max.values()) if age_max else math.nan,
|
||||
pressure_drop=pressure_drop,
|
||||
status="violated" if violated else "ok",
|
||||
)
|
||||
Reference in new issue
Block a user