SPipe 데모 v2 (2026-10-30) 서버 배포본

- SPipe_server_2026-10-30.zip 내용을 풀어서 등록 (압축 파일은 올리지 않음)
- README에 데모 v2 서버 업데이트 순서 추가, 저장소에 없는 문서 목차 정리
- 패치내역을 docs/로 이동, _worklog.md 추가
- 임시 파일(backend/temp.*)과 API 키가 든 파일은 제외
This commit is contained in:
bjkim1 committed 2026-10-30 11:00:46 +09:00
commit 21ebb16fb7
181 files changed
+57207

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
"""상수도 블록시스템 자동 분할 도구.
「지방상수도 현대화사업 업무편람」(환경부, 2021) 2편 Ⅰ.8 블록시스템 구축계획 수립의
구축절차 ①~④를 지원한다. 절차 ⑤(블록고립 현장조사)와 최종 경계 확정은 범위 밖이며
사람이 판단한다.
현재 구현 범위(MVP, Step 1~6): 네트워크 로드, 기준 수리해석, 가중 그래프 구성,
블록 분할, 경계밸브/유량계 산정, 분할안 수리적 검증까지.
"""
__version__ = "0.1.0"
+6
View File
@@ -0,0 +1,6 @@
import sys
from .cli import main
if __name__ == "__main__":
sys.exit(main())
+83
View File
@@ -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
+170
View File
@@ -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())
+43
View File
@@ -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
+114
View File
@@ -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)
+70
View File
@@ -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
+122
View File
@@ -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)
+64
View File
@@ -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
View File
Whitespace-only changes.
+37
View File
@@ -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)
+59
View File
@@ -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
)
+39
View File
@@ -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"]
+46
View File
@@ -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"
+73
View File
@@ -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
+171
View File
@@ -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",
)