"""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())