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 @@
|
||||
# backend module
|
||||
@@ -0,0 +1,332 @@
|
||||
"""Repeated-solve helper for batch analyses (fire flow, criticality, VSP,
|
||||
calibration ...).
|
||||
|
||||
`Network` opens one INP with the EPANET 2.3 DLL and keeps it open, so an
|
||||
analysis can change values (demands, statuses, settings, controls) and
|
||||
re-solve hydraulics many times without re-reading the file. Results are
|
||||
returned at the reporting periods only ([TIMES] REPORT START / REPORT STEP),
|
||||
the same periods the main /api/analyze endpoint reports, as numpy arrays.
|
||||
|
||||
Runs inside the API process pool (one Network per call, never shared).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import os
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .epanet import EN, EpanetAPI, EpanetException
|
||||
|
||||
_api: EpanetAPI | None = None
|
||||
|
||||
|
||||
def get_api() -> EpanetAPI:
|
||||
"""One DLL handle per process (ctypes handles must not cross processes)."""
|
||||
global _api
|
||||
if _api is None:
|
||||
_api = EpanetAPI()
|
||||
return _api
|
||||
|
||||
|
||||
class BatchError(Exception):
|
||||
"""User-facing input problem (unknown ID, bad parameter)."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class SimResult:
|
||||
times: list[int]
|
||||
nodes: dict[int, np.ndarray] = field(default_factory=dict) # prop -> [period, node]
|
||||
links: dict[int, np.ndarray] = field(default_factory=dict) # prop -> [period, link]
|
||||
warnings: list[tuple[int, int]] = field(default_factory=list) # (time, code)
|
||||
|
||||
|
||||
def _decode(raw: bytes) -> str:
|
||||
try:
|
||||
return raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return raw.decode("cp949", errors="replace")
|
||||
|
||||
|
||||
class Network:
|
||||
def __init__(self, inp_bytes: bytes):
|
||||
self.api = get_api()
|
||||
self.lib = self.api.lib
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
inp = os.path.join(self._tmp.name, "n.inp")
|
||||
with open(inp, "wb") as f:
|
||||
f.write(inp_bytes)
|
||||
self.ph = self.api.EN_Project()
|
||||
self.lib.EN_createproject(ctypes.byref(self.ph))
|
||||
self._opened = False
|
||||
try:
|
||||
self._opened = True
|
||||
self.lib.EN_open(self.ph, inp.encode(), os.path.join(self._tmp.name, "n.rpt").encode(), b"")
|
||||
except EpanetException as e:
|
||||
self.close()
|
||||
raise BatchError(str(e)) from e
|
||||
|
||||
n, l = ctypes.c_int(), ctypes.c_int()
|
||||
self.lib.EN_getcount(self.ph, EN.NODECOUNT, ctypes.byref(n))
|
||||
self.lib.EN_getcount(self.ph, EN.LINKCOUNT, ctypes.byref(l))
|
||||
self.node_count, self.link_count = n.value, l.value
|
||||
buf = ctypes.create_string_buffer(256)
|
||||
t = ctypes.c_int()
|
||||
self.node_ids: list[str] = []
|
||||
self.node_types: list[int] = []
|
||||
for i in range(1, self.node_count + 1):
|
||||
self.lib.EN_getnodeid(self.ph, i, buf)
|
||||
self.node_ids.append(_decode(buf.value))
|
||||
self.lib.EN_getnodetype(self.ph, i, ctypes.byref(t))
|
||||
self.node_types.append(t.value)
|
||||
self.link_ids: list[str] = []
|
||||
self.link_types: list[int] = []
|
||||
self.link_nodes: list[tuple[int, int]] = []
|
||||
a, b = ctypes.c_int(), ctypes.c_int()
|
||||
for i in range(1, self.link_count + 1):
|
||||
self.lib.EN_getlinkid(self.ph, i, buf)
|
||||
self.link_ids.append(_decode(buf.value))
|
||||
self.lib.EN_getlinktype(self.ph, i, ctypes.byref(t))
|
||||
self.link_types.append(t.value)
|
||||
self.lib.EN_getlinknodes(self.ph, i, ctypes.byref(a), ctypes.byref(b))
|
||||
self.link_nodes.append((a.value, b.value))
|
||||
self._node_index = {nid: i + 1 for i, nid in enumerate(self.node_ids)}
|
||||
self._link_index = {lid: i + 1 for i, lid in enumerate(self.link_ids)}
|
||||
|
||||
self.duration = self.time_param(EN.DURATION)
|
||||
self.hyd_step = self.time_param(EN.HYDSTEP)
|
||||
self.report_step = max(1, self.time_param(EN.REPORTSTEP))
|
||||
self.report_start = self.time_param(EN.REPORTSTART)
|
||||
self.pattern_step = max(1, self.time_param(EN.PATTERNSTEP))
|
||||
|
||||
# ---- lifecycle ----
|
||||
def close(self) -> None:
|
||||
if getattr(self, "ph", None) is None:
|
||||
return
|
||||
try:
|
||||
if self._opened:
|
||||
self.lib.EN_close(self.ph)
|
||||
except EpanetException:
|
||||
pass
|
||||
try:
|
||||
self.lib.EN_deleteproject(self.ph)
|
||||
except EpanetException:
|
||||
pass
|
||||
self.ph = None
|
||||
self._tmp.cleanup()
|
||||
|
||||
def __enter__(self) -> "Network":
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc) -> None:
|
||||
self.close()
|
||||
|
||||
# ---- lookups ----
|
||||
def node_index(self, node_id: str) -> int:
|
||||
try:
|
||||
return self._node_index[node_id]
|
||||
except KeyError:
|
||||
raise BatchError(f"절점 {node_id}가 모델에 없습니다.") from None
|
||||
|
||||
def link_index(self, link_id: str) -> int:
|
||||
try:
|
||||
return self._link_index[link_id]
|
||||
except KeyError:
|
||||
raise BatchError(f"링크 {link_id}가 모델에 없습니다.") from None
|
||||
|
||||
def junction_indices(self) -> list[int]:
|
||||
return [i + 1 for i, t in enumerate(self.node_types) if t == EN.JUNCTION]
|
||||
|
||||
# ---- values ----
|
||||
def time_param(self, code: int) -> int:
|
||||
v = ctypes.c_long()
|
||||
self.lib.EN_gettimeparam(self.ph, code, ctypes.byref(v))
|
||||
return v.value
|
||||
|
||||
def set_time_param(self, code: int, value: int) -> None:
|
||||
self.lib.EN_settimeparam(self.ph, code, int(value))
|
||||
|
||||
def get_node(self, index: int, prop: int) -> float:
|
||||
v = ctypes.c_double()
|
||||
self.lib.EN_getnodevalue(self.ph, index, prop, ctypes.byref(v))
|
||||
return v.value
|
||||
|
||||
def set_node(self, index: int, prop: int, value: float) -> None:
|
||||
self.lib.EN_setnodevalue(self.ph, index, prop, float(value))
|
||||
|
||||
def get_link(self, index: int, prop: int) -> float:
|
||||
v = ctypes.c_double()
|
||||
self.lib.EN_getlinkvalue(self.ph, index, prop, ctypes.byref(v))
|
||||
return v.value
|
||||
|
||||
def set_link(self, index: int, prop: int, value: float) -> None:
|
||||
self.lib.EN_setlinkvalue(self.ph, index, prop, float(value))
|
||||
|
||||
def add_pattern(self, pid: str, values: list[float]) -> None:
|
||||
"""Create the pattern, or overwrite its values if the ID already exists."""
|
||||
idx = ctypes.c_int()
|
||||
try:
|
||||
self.lib.EN_getpatternindex(self.ph, pid.encode(), ctypes.byref(idx))
|
||||
except EpanetException:
|
||||
self.lib.EN_addpattern(self.ph, pid.encode())
|
||||
self.lib.EN_getpatternindex(self.ph, pid.encode(), ctypes.byref(idx))
|
||||
arr = (ctypes.c_double * len(values))(*values)
|
||||
self.lib.EN_setpattern(self.ph, idx.value, arr, len(values))
|
||||
|
||||
def window_pattern(self, pid: str, start_s: int, end_s: int) -> None:
|
||||
"""Pattern that is 1 on [start, end) and 0 elsewhere, over the duration."""
|
||||
# EPANET uses period (t + PATTERN START) // PATTERN STEP, so period k
|
||||
# starts at simulation time k * step - pattern_start.
|
||||
pstart = self.time_param(EN.PATTERNSTART)
|
||||
n = max(1, -(-(max(self.duration, 1) + pstart) // self.pattern_step) + 1)
|
||||
vals = []
|
||||
for k in range(n):
|
||||
t0 = k * self.pattern_step - pstart
|
||||
vals.append(1.0 if start_s <= t0 < end_s else 0.0)
|
||||
self.add_pattern(pid, vals)
|
||||
|
||||
def add_demand(self, index: int, base: float, pattern: str = "", name: str = "") -> int:
|
||||
"""Adds a demand category; returns its 1-based category index."""
|
||||
self.lib.EN_adddemand(self.ph, index, float(base), pattern.encode(), name.encode())
|
||||
k = ctypes.c_int()
|
||||
self.lib.EN_getnumdemands(self.ph, index, ctypes.byref(k))
|
||||
return k.value
|
||||
|
||||
def delete_demand(self, index: int, category: int) -> None:
|
||||
self.lib.EN_deletedemand(self.ph, index, category)
|
||||
|
||||
def add_timer_control(self, link_index: int, setting: float, at_s: int) -> int:
|
||||
"""setting: 0 = CLOSED, 1 = OPEN (pipes) or a speed/valve setting."""
|
||||
out = ctypes.c_int()
|
||||
self.lib.EN_addcontrol(self.ph, EN.TIMER, link_index, float(setting), 0, float(at_s), ctypes.byref(out))
|
||||
return out.value
|
||||
|
||||
def delete_control(self, index: int) -> None:
|
||||
self.lib.EN_deletecontrol(self.ph, index)
|
||||
|
||||
# ---- solve ----
|
||||
def run_quality(self, nodes: list[int] | None = None) -> tuple[list[int], np.ndarray]:
|
||||
"""Quality run on hydraulics saved by EN_solveH; QUALITY at report periods [period, node]."""
|
||||
node_idx = nodes if nodes is not None else list(range(1, self.node_count + 1))
|
||||
t, tstep = ctypes.c_long(), ctypes.c_long(1)
|
||||
v = ctypes.c_double()
|
||||
times: list[int] = []
|
||||
rows: list[list[float]] = []
|
||||
ph, get_n = self.ph, self.lib.EN_getnodevalue
|
||||
self.lib.EN_openQ(ph)
|
||||
try:
|
||||
self.lib.EN_initQ(ph, EN.NOSAVE)
|
||||
while True:
|
||||
self.lib.EN_runQ(ph, ctypes.byref(t))
|
||||
if self.is_report_time(t.value) and (not times or times[-1] != t.value):
|
||||
times.append(t.value)
|
||||
row = []
|
||||
for i in node_idx:
|
||||
get_n(ph, i, EN.QUALITY, ctypes.byref(v))
|
||||
row.append(v.value)
|
||||
rows.append(row)
|
||||
self.lib.EN_nextQ(ph, ctypes.byref(tstep))
|
||||
if tstep.value <= 0:
|
||||
break
|
||||
finally:
|
||||
self.lib.EN_closeQ(ph)
|
||||
return times, np.asarray(rows, dtype=float).reshape(len(times), len(node_idx))
|
||||
|
||||
def pattern_value(self, pattern_index: int, t: int, pattern_start: int = 0) -> float:
|
||||
"""Multiplier of a pattern at simulation time t (1.0 without a pattern)."""
|
||||
if pattern_index <= 0:
|
||||
return 1.0
|
||||
n = ctypes.c_int()
|
||||
self.lib.EN_getpatternlen(self.ph, pattern_index, ctypes.byref(n))
|
||||
if n.value <= 0:
|
||||
return 1.0
|
||||
period = ((t + pattern_start) // self.pattern_step) % n.value
|
||||
v = ctypes.c_double()
|
||||
self.lib.EN_getpatternvalue(self.ph, pattern_index, period + 1, ctypes.byref(v))
|
||||
return v.value
|
||||
|
||||
def option(self, code: int) -> float:
|
||||
v = ctypes.c_double()
|
||||
self.lib.EN_getoption(self.ph, code, ctypes.byref(v))
|
||||
return v.value
|
||||
|
||||
def is_report_time(self, t: int) -> bool:
|
||||
return t >= self.report_start and (t - self.report_start) % self.report_step == 0
|
||||
|
||||
def simulate(
|
||||
self,
|
||||
node_props: tuple[int, ...] = (EN.PRESSURE,),
|
||||
link_props: tuple[int, ...] = (),
|
||||
nodes: list[int] | None = None,
|
||||
links: list[int] | None = None,
|
||||
max_periods: int = 20000,
|
||||
) -> SimResult:
|
||||
"""Run an EPS and record the given properties at reporting periods.
|
||||
|
||||
`nodes`/`links` are 1-based indices (default: all). Arrays are
|
||||
[period, element] in the order of the given index lists.
|
||||
"""
|
||||
node_idx = nodes if nodes is not None else list(range(1, self.node_count + 1))
|
||||
link_idx = links if links is not None else list(range(1, self.link_count + 1))
|
||||
rows_n: dict[int, list[list[float]]] = {p: [] for p in node_props}
|
||||
rows_l: dict[int, list[list[float]]] = {p: [] for p in link_props}
|
||||
times: list[int] = []
|
||||
warnings: list[tuple[int, int]] = []
|
||||
t, tstep = ctypes.c_long(), ctypes.c_long(1)
|
||||
v = ctypes.c_double()
|
||||
get_n, get_l, ph = self.lib.EN_getnodevalue, self.lib.EN_getlinkvalue, self.ph
|
||||
self.lib.EN_openH(ph)
|
||||
try:
|
||||
self.lib.EN_initH(ph, EN.NOSAVE)
|
||||
while True:
|
||||
code = self.lib.EN_runH(ph, ctypes.byref(t))
|
||||
if 0 < code <= 10:
|
||||
warnings.append((t.value, code))
|
||||
if self.is_report_time(t.value):
|
||||
times.append(t.value)
|
||||
for p in node_props:
|
||||
row = []
|
||||
for i in node_idx:
|
||||
get_n(ph, i, p, ctypes.byref(v))
|
||||
row.append(v.value)
|
||||
rows_n[p].append(row)
|
||||
for p in link_props:
|
||||
row = []
|
||||
for i in link_idx:
|
||||
get_l(ph, i, p, ctypes.byref(v))
|
||||
row.append(v.value)
|
||||
rows_l[p].append(row)
|
||||
if len(times) > max_periods:
|
||||
raise BatchError(f"보고 시간대가 {max_periods}개를 넘습니다.")
|
||||
self.lib.EN_nextH(ph, ctypes.byref(tstep))
|
||||
if tstep.value <= 0:
|
||||
break
|
||||
finally:
|
||||
self.lib.EN_closeH(ph)
|
||||
return SimResult(
|
||||
times=times,
|
||||
nodes={p: np.asarray(r, dtype=float).reshape(len(times), len(node_idx)) for p, r in rows_n.items()},
|
||||
links={p: np.asarray(r, dtype=float).reshape(len(times), len(link_idx)) for p, r in rows_l.items()},
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def hhmm(seconds: int) -> str:
|
||||
return f"{seconds // 3600}:{(seconds % 3600) // 60:02d}"
|
||||
|
||||
|
||||
def parse_time(value, default: int = 0) -> int:
|
||||
"""Seconds from a number of hours, 'h:mm', or None."""
|
||||
if value is None or value == "":
|
||||
return default
|
||||
if isinstance(value, (int, float)):
|
||||
return int(round(float(value) * 3600))
|
||||
s = str(value).strip()
|
||||
if ":" in s:
|
||||
parts = [float(x) for x in s.split(":")]
|
||||
while len(parts) < 3:
|
||||
parts.append(0.0)
|
||||
return int(round(parts[0] * 3600 + parts[1] * 60 + parts[2]))
|
||||
return int(round(float(s) * 3600))
|
||||
@@ -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",
|
||||
)
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Compact wire format for /api/analyze results (``?format=columnar``).
|
||||
|
||||
The object format repeats every key per node/link and writes each float as
|
||||
~18 characters of JSON (9.5 MB for a 2,000-node, 25-step network). Here the
|
||||
time series are packed per variable into one little-endian float64 array per
|
||||
element kind, base64-encoded (~10.7 bytes per value), so full double precision
|
||||
is kept -- the Native-vs-WASM comparison tolerances (rel 1e-8) still apply.
|
||||
|
||||
Layout of each packed series: element-major, i.e. element ``i``'s values are
|
||||
``data[i * steps:(i + 1) * steps]`` in the order of ``ids``.
|
||||
src/api.ts (decodeColumnarResult) turns it back into the object format.
|
||||
"""
|
||||
import base64
|
||||
import sys
|
||||
from array import array
|
||||
|
||||
FORMAT = "spipe-columnar-1"
|
||||
NODE_SERIES = ("demand", "head", "pressure", "quality")
|
||||
LINK_SERIES = ("flow", "velocity", "headloss", "quality")
|
||||
PUMP_FIELDS = ("energy", "efficiency", "totalKwh", "peakKw", "avgKw", "utilization")
|
||||
|
||||
|
||||
def _pack(items: dict, ids: list, key: str, steps: int):
|
||||
"""Packed base64 series, or None when any element lacks a full series
|
||||
(e.g. no water-quality run) -- the decoder then leaves it empty."""
|
||||
flat = array("d")
|
||||
for element_id in ids:
|
||||
series = items[element_id]["timeSeries"].get(key) or []
|
||||
if len(series) != steps:
|
||||
return None
|
||||
flat.extend(series)
|
||||
if sys.byteorder != "little":
|
||||
flat.byteswap()
|
||||
return base64.b64encode(flat.tobytes()).decode("ascii")
|
||||
|
||||
|
||||
def to_columnar(result: dict) -> dict:
|
||||
steps = len(result.get("timeSteps") or [])
|
||||
nodes, links = result["nodes"], result["links"]
|
||||
node_ids, link_ids = list(nodes), list(links)
|
||||
|
||||
# Pump series and the pump's scalar fields (initial energy/efficiency, kWh
|
||||
# totals) use distinct keys -- they share names in the object format.
|
||||
pumps = {
|
||||
lid: {
|
||||
"energySeries": links[lid]["timeSeries"].get("energy") or [],
|
||||
"efficiencySeries": links[lid]["timeSeries"].get("efficiency") or [],
|
||||
**{f: links[lid].get(f) for f in PUMP_FIELDS if f in links[lid]},
|
||||
}
|
||||
for lid in link_ids
|
||||
if "totalKwh" in links[lid]
|
||||
}
|
||||
|
||||
return {
|
||||
"format": FORMAT,
|
||||
**{k: v for k, v in result.items() if k not in ("nodes", "links")},
|
||||
"nodes": {
|
||||
"ids": node_ids,
|
||||
"types": [nodes[i]["type"] for i in node_ids],
|
||||
"coordinates": [nodes[i]["coordinates"] for i in node_ids],
|
||||
"series": {k: _pack(nodes, node_ids, k, steps) for k in NODE_SERIES},
|
||||
},
|
||||
"links": {
|
||||
"ids": link_ids,
|
||||
"types": [links[i]["type"] for i in link_ids],
|
||||
"fromNodes": [links[i]["fromNode"] for i in link_ids],
|
||||
"toNodes": [links[i]["toNode"] for i in link_ids],
|
||||
"vertices": [links[i]["vertices"] for i in link_ids],
|
||||
"series": {k: _pack(links, link_ids, k, steps) for k in LINK_SERIES},
|
||||
"pumps": pumps,
|
||||
},
|
||||
}
|
||||
+432
@@ -0,0 +1,432 @@
|
||||
"""DEM (digital elevation model) store: upload once, preview, sample, build from points.
|
||||
|
||||
Rasters are kept under SPIPE_DEM_DIR (default: <repo>/data/dem) as GeoTIFF,
|
||||
keyed by a random id; only the newest SPIPE_DEM_KEEP files are retained.
|
||||
Elevations are returned in the raster's own units (normally metres).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
import warnings
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import rasterio
|
||||
import shapely
|
||||
import shapely.geometry
|
||||
from rasterio.enums import Resampling
|
||||
from rasterio.io import MemoryFile
|
||||
from rasterio.transform import from_origin
|
||||
from pyproj import CRS, Transformer
|
||||
|
||||
from .batch import BatchError
|
||||
|
||||
DEM_DIR = Path(os.environ.get("SPIPE_DEM_DIR", Path(__file__).resolve().parents[1] / "data" / "dem"))
|
||||
DEM_KEEP = int(os.environ.get("SPIPE_DEM_KEEP", "8"))
|
||||
RASTER_EXTENSIONS = (".tif", ".tiff", ".asc", ".img", ".dem", ".bil", ".flt", ".hgt", ".vrt")
|
||||
MAX_CELLS_FROM_POINTS = 25_000_000
|
||||
_ID_OK = set("0123456789abcdef")
|
||||
|
||||
|
||||
def _path(dem_id: str) -> Path:
|
||||
if len(dem_id) != 32 or not set(dem_id) <= _ID_OK:
|
||||
raise BatchError("DEM id가 올바르지 않습니다.")
|
||||
p = DEM_DIR / f"{dem_id}.tif"
|
||||
if not p.exists():
|
||||
raise BatchError("DEM을 찾을 수 없습니다. 다시 불러오세요 (서버가 오래된 DEM을 정리했을 수 있습니다).")
|
||||
return p
|
||||
|
||||
|
||||
def _trim() -> None:
|
||||
files = sorted(DEM_DIR.glob("*.tif"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
for old in files[DEM_KEEP:]:
|
||||
old.unlink(missing_ok=True)
|
||||
old.with_suffix(".json").unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _crs_of(user: str | None) -> CRS:
|
||||
if not user:
|
||||
raise BatchError("좌표계가 필요합니다 (로컬 좌표계에서는 DEM을 쓸 수 없습니다).")
|
||||
try:
|
||||
return CRS.from_user_input(user)
|
||||
except Exception as e: # pyproj raises several types
|
||||
raise BatchError(f"좌표계를 읽을 수 없습니다: {user[:60]}") from e
|
||||
|
||||
|
||||
def _info(dem_id: str, src, name: str) -> dict:
|
||||
if src.crs is None:
|
||||
raise BatchError("DEM에 좌표계 정보가 없습니다 (.prj 또는 GeoTIFF 좌표계를 넣어 주세요).")
|
||||
band = src.read(1, masked=True, out_shape=(min(src.height, 1024), min(src.width, 1024)), resampling=Resampling.nearest)
|
||||
to_wgs = Transformer.from_crs(src.crs, "EPSG:4326", always_xy=True)
|
||||
l, b, r, t = src.bounds
|
||||
corners = [list(to_wgs.transform(x, y)) for x, y in ((l, t), (r, t), (r, b), (l, b))]
|
||||
return {
|
||||
"id": dem_id,
|
||||
"name": name,
|
||||
"crs": src.crs.to_string(),
|
||||
"width": src.width,
|
||||
"height": src.height,
|
||||
"resolution": [abs(src.res[0]), abs(src.res[1])],
|
||||
"bounds": [l, b, r, t],
|
||||
"corners": corners,
|
||||
"min": float(band.min()) if band.count() else None,
|
||||
"max": float(band.max()) if band.count() else None,
|
||||
"nodata": src.nodata,
|
||||
}
|
||||
|
||||
|
||||
def _store(src_path: str, name: str) -> dict:
|
||||
DEM_DIR.mkdir(parents=True, exist_ok=True)
|
||||
dem_id = uuid.uuid4().hex
|
||||
out = DEM_DIR / f"{dem_id}.tif"
|
||||
try:
|
||||
with rasterio.open(src_path) as src:
|
||||
if src.count < 1:
|
||||
raise BatchError("래스터 밴드가 없습니다.")
|
||||
profile = src.profile.copy()
|
||||
profile.update(driver="GTiff", count=1, compress="deflate", tiled=True, blockxsize=256, blockysize=256, BIGTIFF="IF_SAFER")
|
||||
if src.width < 256 or src.height < 256:
|
||||
profile.update(tiled=False)
|
||||
profile.pop("blockxsize", None)
|
||||
profile.pop("blockysize", None)
|
||||
with rasterio.open(out, "w", **profile) as dst:
|
||||
for _, window in src.block_windows(1):
|
||||
dst.write(src.read(1, window=window), 1, window=window)
|
||||
with rasterio.open(out) as src:
|
||||
info = _info(dem_id, src, name)
|
||||
except rasterio.errors.RasterioIOError as e:
|
||||
out.unlink(missing_ok=True)
|
||||
raise BatchError(f"래스터를 읽을 수 없습니다: {e}") from e
|
||||
except BatchError:
|
||||
out.unlink(missing_ok=True)
|
||||
raise
|
||||
out.with_suffix(".json").write_text(json.dumps(info, ensure_ascii=False), encoding="utf-8")
|
||||
_trim()
|
||||
return info
|
||||
|
||||
|
||||
def import_dem(upload_path: str, filename: str) -> dict:
|
||||
"""Store an uploaded raster (or a zip holding one, e.g. ASC + PRJ) as a DEM."""
|
||||
ext = os.path.splitext(filename.lower())[1]
|
||||
if ext == ".zip":
|
||||
try:
|
||||
with zipfile.ZipFile(upload_path) as z:
|
||||
names = [n for n in z.namelist() if os.path.splitext(n.lower())[1] in RASTER_EXTENSIONS and not n.startswith("__MACOSX")]
|
||||
except zipfile.BadZipFile as e:
|
||||
raise BatchError("zip 파일이 손상되었습니다.") from e
|
||||
if not names:
|
||||
raise BatchError(f"zip 안에 래스터({', '.join(RASTER_EXTENSIONS)})가 없습니다.")
|
||||
return _store("/vsizip/" + upload_path.replace("\\", "/") + "/" + names[0], filename)
|
||||
if ext not in RASTER_EXTENSIONS:
|
||||
raise BatchError(f"지원하지 않는 형식입니다: {ext} (GeoTIFF, ASC, IMG 또는 이를 담은 zip)")
|
||||
# GDAL picks the driver from the extension; keep it on the temp copy.
|
||||
named = upload_path + ext
|
||||
shutil.copyfile(upload_path, named)
|
||||
try:
|
||||
return _store(named, filename)
|
||||
finally:
|
||||
os.remove(named)
|
||||
|
||||
|
||||
def dem_info(dem_id: str) -> dict:
|
||||
p = _path(dem_id)
|
||||
return json.loads(p.with_suffix(".json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def preview_png(dem_id: str, max_size: int = 1600) -> bytes:
|
||||
"""Hillshaded colour relief, transparent where there is no data."""
|
||||
with rasterio.open(_path(dem_id)) as src:
|
||||
k = max(1, int(np.ceil(max(src.width, src.height) / max_size)))
|
||||
h, w = max(1, src.height // k), max(1, src.width // k)
|
||||
z = src.read(1, masked=True, out_shape=(h, w), resampling=Resampling.bilinear).astype("float64")
|
||||
cell = abs(src.res[0]) * src.width / w
|
||||
geographic = src.crs and src.crs.is_geographic
|
||||
if geographic:
|
||||
cell *= 111_000
|
||||
mask = np.ma.getmaskarray(z)
|
||||
zf = z.filled(np.nan)
|
||||
lo, hi = np.nanpercentile(zf, [2, 98]) if np.isfinite(zf).any() else (0.0, 1.0)
|
||||
t = np.clip((zf - lo) / max(hi - lo, 1e-9), 0, 1)
|
||||
# Green -> yellow -> brown -> white elevation ramp.
|
||||
stops = np.array([[0.0, 64, 140, 80], [0.35, 190, 200, 110], [0.7, 160, 110, 60], [1.0, 245, 245, 240]])
|
||||
rgb = np.stack([np.interp(np.nan_to_num(t), stops[:, 0], stops[:, c]) for c in (1, 2, 3)])
|
||||
gy, gx = np.gradient(np.nan_to_num(zf, nan=float(np.nanmean(zf)) if np.isfinite(zf).any() else 0.0), cell)
|
||||
slope = np.arctan(np.hypot(gx, gy))
|
||||
aspect = np.arctan2(-gx, gy)
|
||||
az, alt = np.radians(315), np.radians(45)
|
||||
shade = np.clip(np.sin(alt) * np.cos(slope) + np.cos(alt) * np.sin(slope) * np.cos(az - aspect), 0, 1)
|
||||
rgb = rgb * (0.55 + 0.45 * shade)
|
||||
alpha = np.where(mask | ~np.isfinite(zf), 0, 255)
|
||||
img = np.concatenate([rgb, alpha[None]]).clip(0, 255).astype("uint8")
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", rasterio.errors.NotGeoreferencedWarning) # a plain image
|
||||
with MemoryFile() as mem:
|
||||
with mem.open(driver="PNG", width=w, height=h, count=4, dtype="uint8") as dst:
|
||||
dst.write(img)
|
||||
return mem.read()
|
||||
|
||||
|
||||
def sample(dem_id: str, points: list, points_crs: str | None) -> list:
|
||||
"""Bilinear DEM values at [x, y] points given in `points_crs` (None outside / no data)."""
|
||||
if not points:
|
||||
return []
|
||||
pts = np.asarray(points, dtype="float64").reshape(-1, 2)
|
||||
with rasterio.open(_path(dem_id)) as src:
|
||||
tr = Transformer.from_crs(_crs_of(points_crs), src.crs, always_xy=True)
|
||||
xs, ys = tr.transform(pts[:, 0], pts[:, 1])
|
||||
inv = ~src.transform
|
||||
xs, ys = np.asarray(xs), np.asarray(ys)
|
||||
cols, rows = inv.a * xs + inv.b * ys + inv.c, inv.d * xs + inv.e * ys + inv.f
|
||||
# pixel centres sit at +0.5
|
||||
cols, rows = np.asarray(cols) - 0.5, np.asarray(rows) - 0.5
|
||||
ok = np.isfinite(cols) & np.isfinite(rows) & (cols >= -0.5) & (rows >= -0.5) & (cols <= src.width - 0.5) & (rows <= src.height - 0.5)
|
||||
out: list = [None] * len(pts)
|
||||
if not ok.any():
|
||||
return out
|
||||
c0 = int(max(0, np.floor(cols[ok].min()))); c1 = int(min(src.width - 1, np.floor(cols[ok].max()) + 1))
|
||||
r0 = int(max(0, np.floor(rows[ok].min()))); r1 = int(min(src.height - 1, np.floor(rows[ok].max()) + 1))
|
||||
win = rasterio.windows.Window(c0, r0, c1 - c0 + 1, r1 - r0 + 1)
|
||||
if win.width * win.height > 400_000_000:
|
||||
raise BatchError("지점들이 너무 넓게 퍼져 있습니다. 나눠서 실행하세요.")
|
||||
z = src.read(1, window=win, masked=True).astype("float64").filled(np.nan)
|
||||
for k in np.where(ok)[0]:
|
||||
c, r = cols[k] - c0, rows[k] - r0
|
||||
ci, ri = int(np.clip(np.floor(c), 0, z.shape[1] - 1)), int(np.clip(np.floor(r), 0, z.shape[0] - 1))
|
||||
cj, rj = min(ci + 1, z.shape[1] - 1), min(ri + 1, z.shape[0] - 1)
|
||||
fc, fr = float(np.clip(c - ci, 0, 1)), float(np.clip(r - ri, 0, 1))
|
||||
q = np.array([z[ri, ci], z[ri, cj], z[rj, ci], z[rj, cj]])
|
||||
wts = np.array([(1 - fc) * (1 - fr), fc * (1 - fr), (1 - fc) * fr, fc * fr])
|
||||
good = np.isfinite(q) & (wts > 0)
|
||||
if good.any():
|
||||
out[k] = round(float(np.sum(q[good] * wts[good]) / np.sum(wts[good])), 3)
|
||||
elif np.isfinite(q[0]):
|
||||
out[k] = round(float(q[0]), 3)
|
||||
return out
|
||||
|
||||
|
||||
def from_points(points: list, points_crs: str | None, cell: float, name: str = "점 표고 DEM", target_crs: str | None = None) -> dict:
|
||||
"""TIN-style DEM: linear interpolation over the Delaunay triangulation of [x, y, z] points."""
|
||||
from scipy.interpolate import LinearNDInterpolator
|
||||
|
||||
pts = np.asarray(points, dtype="float64").reshape(-1, 3)
|
||||
pts = pts[np.all(np.isfinite(pts), axis=1)]
|
||||
if len(pts) < 3:
|
||||
raise BatchError("표고 점이 3개 이상 필요합니다.")
|
||||
crs = _crs_of(points_crs)
|
||||
if target_crs:
|
||||
# e.g. GIS points arrive in WGS84; grid them in the model's plane CRS.
|
||||
dst = _crs_of(target_crs)
|
||||
x, y = Transformer.from_crs(crs, dst, always_xy=True).transform(pts[:, 0], pts[:, 1])
|
||||
pts = np.column_stack([x, y, pts[:, 2]])
|
||||
crs = dst
|
||||
if crs.is_geographic:
|
||||
raise BatchError("경위도 좌표계 점으로는 만들 수 없습니다. 평면 좌표계를 쓰세요.")
|
||||
if not (cell > 0):
|
||||
raise BatchError("격자 크기는 0보다 커야 합니다.")
|
||||
x0, y0 = pts[:, 0].min(), pts[:, 1].min()
|
||||
x1, y1 = pts[:, 0].max(), pts[:, 1].max()
|
||||
w, h = int(np.ceil((x1 - x0) / cell)) + 1, int(np.ceil((y1 - y0) / cell)) + 1
|
||||
if w * h > MAX_CELLS_FROM_POINTS:
|
||||
raise BatchError(f"격자가 너무 큽니다 ({w}x{h}). 격자 크기를 키우세요.")
|
||||
try:
|
||||
interp = LinearNDInterpolator(pts[:, :2], pts[:, 2])
|
||||
except Exception as e: # Qhull errors on collinear input
|
||||
raise BatchError("점들이 한 줄 위에 있어 삼각망을 만들 수 없습니다.") from e
|
||||
gx = x0 + (np.arange(w) + 0.5) * cell
|
||||
gy = y1 - (np.arange(h) + 0.5) * cell
|
||||
xx, yy = np.meshgrid(gx, gy)
|
||||
z = interp(xx, yy).astype("float32")
|
||||
nodata = -9999.0
|
||||
z[~np.isfinite(z)] = nodata
|
||||
DEM_DIR.mkdir(parents=True, exist_ok=True)
|
||||
tmp = DEM_DIR / f"tmp_{uuid.uuid4().hex}.tif"
|
||||
with rasterio.open(tmp, "w", driver="GTiff", width=w, height=h, count=1, dtype="float32", crs=crs.to_wkt(),
|
||||
transform=from_origin(x0, y1, cell, cell), nodata=nodata, compress="deflate") as dst:
|
||||
dst.write(z, 1)
|
||||
try:
|
||||
return _store(str(tmp), name)
|
||||
finally:
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def terrain_tile(dem_id: str, z: int, x: int, y: int) -> bytes:
|
||||
"""Web-mercator XYZ tile of the DEM in Mapbox/MapLibre 'terrarium' encoding
|
||||
(elevation = R*256 + G + B/256 - 32768)."""
|
||||
from rasterio.warp import reproject
|
||||
|
||||
if not (0 <= z <= 18 and 0 <= x < 2 ** z and 0 <= y < 2 ** z):
|
||||
raise BatchError("타일 번호가 올바르지 않습니다.")
|
||||
size = 256
|
||||
world = 20037508.342789244
|
||||
span = 2 * world / 2 ** z
|
||||
x0, y1 = -world + x * span, world - y * span
|
||||
dst = np.full((size, size), np.nan, dtype="float32")
|
||||
with rasterio.open(_path(dem_id)) as src:
|
||||
reproject(
|
||||
source=rasterio.band(src, 1), destination=dst,
|
||||
dst_transform=from_origin(x0, y1, span / size, span / size), dst_crs="EPSG:3857",
|
||||
dst_nodata=np.nan, resampling=Resampling.bilinear,
|
||||
)
|
||||
# Outside the DEM: its lowest value, so the terrain does not drop to sea level in a cliff.
|
||||
fill = float(np.nanmin(dst)) if np.isfinite(dst).any() else float(dem_info(dem_id).get("min") or 0.0)
|
||||
v = np.nan_to_num(dst, nan=fill).astype("float64") + 32768.0
|
||||
r = np.floor(v / 256)
|
||||
g = np.floor(v - r * 256)
|
||||
b = np.floor((v - r * 256 - g) * 256)
|
||||
img = np.stack([r, g, b]).clip(0, 255).astype("uint8")
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", rasterio.errors.NotGeoreferencedWarning)
|
||||
with MemoryFile() as mem:
|
||||
with mem.open(driver="PNG", width=size, height=size, count=3, dtype="uint8") as d:
|
||||
d.write(img)
|
||||
return mem.read()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- hydrology (유역·하천)
|
||||
|
||||
MAX_HYDRO_CELLS = 1_000_000
|
||||
_D8 = [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]
|
||||
|
||||
|
||||
def _hydro_grid(dem_id: str):
|
||||
"""DEM (downsampled to <= MAX_HYDRO_CELLS) -> D8 receivers and flow accumulation
|
||||
on a priority-flood filled surface (pits and flats drain)."""
|
||||
import heapq
|
||||
|
||||
with rasterio.open(_path(dem_id)) as src:
|
||||
k = max(1, int(np.ceil(np.sqrt(src.width * src.height / MAX_HYDRO_CELLS))))
|
||||
h, w = max(2, src.height // k), max(2, src.width // k)
|
||||
z = src.read(1, masked=True, out_shape=(h, w), resampling=Resampling.average).astype("float64")
|
||||
transform = src.transform @ src.transform.scale(src.width / w, src.height / h)
|
||||
crs = src.crs
|
||||
if crs.is_geographic:
|
||||
raise BatchError("유역 분석에는 평면 좌표계 DEM이 필요합니다.")
|
||||
valid = ~np.ma.getmaskarray(z)
|
||||
filled = np.where(valid, z.filled(np.nan), np.inf)
|
||||
done = ~valid.copy()
|
||||
heap: list = []
|
||||
edge = np.zeros_like(valid)
|
||||
edge[0, :] = edge[-1, :] = edge[:, 0] = edge[:, -1] = True
|
||||
pad = np.pad(~valid, 1, constant_values=True)
|
||||
near_nodata = np.zeros_like(valid)
|
||||
for dr, dc in _D8:
|
||||
near_nodata |= pad[1 + dr:1 + dr + h, 1 + dc:1 + dc + w]
|
||||
for r, c in zip(*np.where(valid & (edge | near_nodata))):
|
||||
heapq.heappush(heap, (filled[r, c], int(r), int(c)))
|
||||
done[r, c] = True
|
||||
eps = 1e-4
|
||||
order = []
|
||||
while heap:
|
||||
e, r, c = heapq.heappop(heap)
|
||||
order.append(r * w + c)
|
||||
for dr, dc in _D8:
|
||||
rr, cc = r + dr, c + dc
|
||||
if 0 <= rr < h and 0 <= cc < w and not done[rr, cc]:
|
||||
done[rr, cc] = True
|
||||
filled[rr, cc] = max(filled[rr, cc], e + eps)
|
||||
heapq.heappush(heap, (filled[rr, cc], rr, cc))
|
||||
n = h * w
|
||||
flat = filled.ravel()
|
||||
recv = np.full(n, -1, dtype=np.int64)
|
||||
best = np.zeros(n)
|
||||
cell = abs(transform.a)
|
||||
rows, cols = np.divmod(np.arange(n), w)
|
||||
for dr, dc in _D8:
|
||||
rr, cc = rows + dr, cols + dc
|
||||
ok = (rr >= 0) & (rr < h) & (cc >= 0) & (cc < w)
|
||||
nb = np.where(ok, rr * w + cc, 0)
|
||||
with np.errstate(invalid="ignore"):
|
||||
drop = np.where(ok, (flat - flat[nb]) / (cell * np.hypot(dr, dc)), -np.inf)
|
||||
drop[~np.isfinite(drop)] = -np.inf
|
||||
take = drop > best
|
||||
best[take] = drop[take]
|
||||
recv[take] = nb[take]
|
||||
acc = valid.ravel().astype("float64")
|
||||
for i in reversed(order): # from the highest cell down
|
||||
j = recv[i]
|
||||
if j >= 0:
|
||||
acc[j] += acc[i]
|
||||
return {"h": h, "w": w, "recv": recv, "acc": acc, "transform": transform, "crs": crs, "cell_area": abs(transform.a * transform.e)}
|
||||
|
||||
|
||||
def streams(dem_id: str, min_area_km2: float) -> dict:
|
||||
"""Stream lines (WGS84 GeoJSON): cells draining at least `min_area_km2`."""
|
||||
g = _hydro_grid(dem_id)
|
||||
thr = max(2.0, min_area_km2 * 1e6 / g["cell_area"])
|
||||
w, tr = g["w"], g["transform"]
|
||||
idx = np.where((g["acc"] >= thr) & (g["recv"] >= 0))[0]
|
||||
if len(idx) > 300_000:
|
||||
raise BatchError("하천 셀이 너무 많습니다. 최소 유역 면적을 키우세요.")
|
||||
if not len(idx):
|
||||
return {"type": "FeatureCollection", "features": []}
|
||||
|
||||
def xy(i):
|
||||
r, c = divmod(int(i), w)
|
||||
return tr @ (c + 0.5, r + 0.5)
|
||||
|
||||
segs = np.array([shapely.LineString([xy(i), xy(g["recv"][i])]) for i in idx], dtype=object)
|
||||
merged = shapely.get_parts(shapely.line_merge(shapely.union_all(segs)))
|
||||
to_wgs = Transformer.from_crs(g["crs"], "EPSG:4326", always_xy=True)
|
||||
pts = shapely.points([xy(i) for i in idx])
|
||||
tree = shapely.STRtree(pts)
|
||||
acc_at = g["acc"][idx]
|
||||
feats = []
|
||||
for line in merged:
|
||||
near = tree.query(line, predicate="dwithin", distance=abs(tr.a) * 0.6)
|
||||
area = float(acc_at[near].max() * g["cell_area"] / 1e6) if len(near) else 0.0
|
||||
coords = np.asarray(line.coords)
|
||||
x, y = to_wgs.transform(coords[:, 0], coords[:, 1])
|
||||
feats.append({"type": "Feature", "properties": {"area_km2": round(area, 3), "length_m": round(float(line.length), 1)},
|
||||
"geometry": {"type": "LineString", "coordinates": [[float(a), float(b)] for a, b in zip(x, y)]}})
|
||||
return {"type": "FeatureCollection", "features": feats}
|
||||
|
||||
|
||||
def watershed(dem_id: str, points: list, points_crs: str | None, snap_m: float = 50.0) -> dict:
|
||||
"""Catchment polygon upstream of each pour point (snapped to the largest flow within snap_m)."""
|
||||
from rasterio import features as rfeatures
|
||||
|
||||
g = _hydro_grid(dem_id)
|
||||
h, w, tr = g["h"], g["w"], g["transform"]
|
||||
to_dem = Transformer.from_crs(_crs_of(points_crs), g["crs"], always_xy=True)
|
||||
to_wgs = Transformer.from_crs(g["crs"], "EPSG:4326", always_xy=True)
|
||||
order = np.argsort(g["recv"], kind="stable")
|
||||
recv_sorted = g["recv"][order]
|
||||
inv = ~tr
|
||||
rad = max(1, int(round(snap_m / abs(tr.a))))
|
||||
acc2 = g["acc"].reshape(h, w)
|
||||
feats = []
|
||||
for k, p in enumerate(points):
|
||||
x, y = to_dem.transform(float(p[0]), float(p[1]))
|
||||
c = inv.a * x + inv.b * y + inv.c
|
||||
r = inv.d * x + inv.e * y + inv.f
|
||||
r, c = int(r), int(c)
|
||||
if not (0 <= r < h and 0 <= c < w):
|
||||
continue
|
||||
r0, r1, c0, c1 = max(0, r - rad), min(h, r + rad + 1), max(0, c - rad), min(w, c + rad + 1)
|
||||
win = acc2[r0:r1, c0:c1]
|
||||
rr, cc = np.unravel_index(int(np.argmax(win)), win.shape)
|
||||
start = (r0 + rr) * w + (c0 + cc)
|
||||
mask = np.zeros(h * w, dtype=bool)
|
||||
mask[start] = True
|
||||
stack = [start]
|
||||
while stack:
|
||||
j = stack.pop()
|
||||
lo = np.searchsorted(recv_sorted, j, "left")
|
||||
hi = np.searchsorted(recv_sorted, j, "right")
|
||||
for i in order[lo:hi]:
|
||||
if not mask[i]:
|
||||
mask[i] = True
|
||||
stack.append(int(i))
|
||||
m2 = mask.reshape(h, w)
|
||||
polys = [shapely.geometry.shape(geom) for geom, val in rfeatures.shapes(m2.astype("uint8"), mask=m2, transform=tr) if val == 1]
|
||||
if not polys:
|
||||
continue
|
||||
poly = shapely.union_all(np.array(polys, dtype=object))
|
||||
poly = shapely.transform(poly, lambda a: np.column_stack(to_wgs.transform(a[:, 0], a[:, 1])))
|
||||
feats.append({"type": "Feature", "properties": {"point": k, "area_km2": round(float(mask.sum() * g["cell_area"] / 1e6), 4)},
|
||||
"geometry": shapely.geometry.mapping(poly)})
|
||||
return {"type": "FeatureCollection", "features": feats}
|
||||
@@ -0,0 +1,266 @@
|
||||
import ctypes
|
||||
import os
|
||||
|
||||
|
||||
class EN:
|
||||
# EN_CountType
|
||||
NODECOUNT = 0
|
||||
LINKCOUNT = 2
|
||||
|
||||
# EN_NodeProperty
|
||||
ELEVATION = 0
|
||||
BASEDEMAND = 1
|
||||
EMITTER = 3
|
||||
TANKLEVEL = 8
|
||||
DEMAND = 9
|
||||
HEAD = 10
|
||||
PRESSURE = 11
|
||||
QUALITY = 12
|
||||
# EN_LinkProperty
|
||||
DIAMETER = 0
|
||||
LENGTH = 1
|
||||
ROUGHNESS = 2
|
||||
INITSTATUS = 4
|
||||
INITSETTING = 5
|
||||
KWALL = 7
|
||||
STATUS = 11
|
||||
SETTING = 12
|
||||
FLOW = 8
|
||||
VELOCITY = 9
|
||||
HEADLOSS = 10
|
||||
ENERGY = 13
|
||||
LINKQUAL = 14
|
||||
PUMP_EFFIC = 17
|
||||
# EN_InitHydOption
|
||||
NOSAVE = 0
|
||||
SAVE = 1
|
||||
|
||||
# EN_Option
|
||||
PRESS_UNITS = 25
|
||||
|
||||
# EN_TimeParameter
|
||||
DURATION = 0
|
||||
HYDSTEP = 1
|
||||
REPORTSTEP = 5
|
||||
REPORTSTART = 6
|
||||
|
||||
# EN_TimeParameter (cont.)
|
||||
PATTERNSTEP = 3
|
||||
PATTERNSTART = 4
|
||||
|
||||
# EN_ControlType
|
||||
TIMER = 2
|
||||
|
||||
# EN_QualityType / quality time step
|
||||
QUAL_TRACE = 3
|
||||
QUALSTEP = 2
|
||||
|
||||
# EN_Option (energy)
|
||||
GLOBALEFFIC = 8
|
||||
GLOBALPRICE = 9
|
||||
GLOBALPATTERN = 10
|
||||
DEMANDCHARGE = 11
|
||||
|
||||
# EN_LinkProperty (pump energy)
|
||||
PUMP_ECOST = 21
|
||||
PUMP_EPAT = 22
|
||||
|
||||
# EN_NodeType / EN_LinkType
|
||||
JUNCTION = 0
|
||||
RESERVOIR = 1
|
||||
TANK = 2
|
||||
CVPIPE = 0
|
||||
PIPE = 1
|
||||
PUMP = 2
|
||||
|
||||
FLOW_UNITS = {
|
||||
0: "CFS", 1: "GPM", 2: "MGD", 3: "IMGD", 4: "AFD",
|
||||
5: "LPS", 6: "LPM", 7: "MLD", 8: "CMH", 9: "CMD", 10: "CMS"
|
||||
}
|
||||
|
||||
PRESS_UNITS = {
|
||||
0: "psi", 1: "kPa", 2: "m", 3: "bar", 4: "ft"
|
||||
}
|
||||
QUAL_TYPES = {
|
||||
0: "NONE", 1: "CHEMICAL", 2: "AGE", 3: "TRACE"
|
||||
}
|
||||
|
||||
|
||||
class EpanetException(Exception):
|
||||
pass
|
||||
|
||||
class EpanetAPI:
|
||||
def __init__(self):
|
||||
library_name = "epanet2.dll" if os.name == "nt" else "libepanet2.so"
|
||||
dll_path = os.environ.get("EPANET_LIBRARY_PATH") or os.path.join(os.path.dirname(os.path.dirname(__file__)), "64bit", library_name)
|
||||
if not os.path.exists(dll_path):
|
||||
raise RuntimeError(f"DLL not found: {dll_path}")
|
||||
|
||||
self.lib = ctypes.CDLL(dll_path)
|
||||
self.EN_Project = ctypes.c_void_p
|
||||
self._setup()
|
||||
|
||||
def _setup(self):
|
||||
def _errcheck(result, func, args):
|
||||
if result > 10:
|
||||
err_msg = ctypes.create_string_buffer(256)
|
||||
self.lib.EN_geterror(result, err_msg, 256)
|
||||
raise EpanetException(f"EPANET Error {result}: {err_msg.value.decode('utf-8').strip()}")
|
||||
return result
|
||||
|
||||
self.lib.EN_geterror.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int]
|
||||
self.lib.EN_geterror.restype = ctypes.c_int
|
||||
|
||||
self.lib.EN_getversion.argtypes = [ctypes.POINTER(ctypes.c_int)]
|
||||
self.lib.EN_getversion.restype = ctypes.c_int
|
||||
self.lib.EN_getversion.errcheck = _errcheck
|
||||
|
||||
self.lib.EN_createproject.argtypes = [ctypes.POINTER(self.EN_Project)]
|
||||
self.lib.EN_createproject.restype = ctypes.c_int
|
||||
self.lib.EN_createproject.errcheck = _errcheck
|
||||
|
||||
self.lib.EN_deleteproject.argtypes = [self.EN_Project]
|
||||
self.lib.EN_deleteproject.restype = ctypes.c_int
|
||||
self.lib.EN_deleteproject.errcheck = _errcheck
|
||||
|
||||
self.lib.EN_open.argtypes = [self.EN_Project, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p]
|
||||
self.lib.EN_open.restype = ctypes.c_int
|
||||
self.lib.EN_open.errcheck = _errcheck
|
||||
|
||||
self.lib.EN_close.argtypes = [self.EN_Project]
|
||||
self.lib.EN_close.restype = ctypes.c_int
|
||||
self.lib.EN_close.errcheck = _errcheck
|
||||
|
||||
self.lib.EN_getcount.argtypes = [self.EN_Project, ctypes.c_int, ctypes.POINTER(ctypes.c_int)]
|
||||
self.lib.EN_getcount.restype = ctypes.c_int
|
||||
self.lib.EN_getcount.errcheck = _errcheck
|
||||
|
||||
self.lib.EN_getnodeid.argtypes = [self.EN_Project, ctypes.c_int, ctypes.c_char_p]
|
||||
self.lib.EN_getnodeid.restype = ctypes.c_int
|
||||
self.lib.EN_getnodeid.errcheck = _errcheck
|
||||
|
||||
self.lib.EN_getnodetype.argtypes = [self.EN_Project, ctypes.c_int, ctypes.POINTER(ctypes.c_int)]
|
||||
self.lib.EN_getnodetype.restype = ctypes.c_int
|
||||
self.lib.EN_getnodetype.errcheck = _errcheck
|
||||
|
||||
self.lib.EN_getnodevalue.argtypes = [self.EN_Project, ctypes.c_int, ctypes.c_int, ctypes.POINTER(ctypes.c_double)]
|
||||
self.lib.EN_getnodevalue.restype = ctypes.c_int
|
||||
self.lib.EN_getnodevalue.errcheck = _errcheck
|
||||
|
||||
self.lib.EN_getcoord.argtypes = [self.EN_Project, ctypes.c_int, ctypes.POINTER(ctypes.c_double), ctypes.POINTER(ctypes.c_double)]
|
||||
self.lib.EN_getcoord.restype = ctypes.c_int
|
||||
self.lib.EN_getcoord.errcheck = _errcheck
|
||||
|
||||
self.lib.EN_getlinkid.argtypes = [self.EN_Project, ctypes.c_int, ctypes.c_char_p]
|
||||
self.lib.EN_getlinkid.restype = ctypes.c_int
|
||||
self.lib.EN_getlinkid.errcheck = _errcheck
|
||||
|
||||
self.lib.EN_getlinktype.argtypes = [self.EN_Project, ctypes.c_int, ctypes.POINTER(ctypes.c_int)]
|
||||
self.lib.EN_getlinktype.restype = ctypes.c_int
|
||||
self.lib.EN_getlinktype.errcheck = _errcheck
|
||||
|
||||
self.lib.EN_getlinknodes.argtypes = [self.EN_Project, ctypes.c_int, ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int)]
|
||||
self.lib.EN_getlinknodes.restype = ctypes.c_int
|
||||
self.lib.EN_getlinknodes.errcheck = _errcheck
|
||||
|
||||
self.lib.EN_getlinkvalue.argtypes = [self.EN_Project, ctypes.c_int, ctypes.c_int, ctypes.POINTER(ctypes.c_double)]
|
||||
self.lib.EN_getlinkvalue.restype = ctypes.c_int
|
||||
self.lib.EN_getlinkvalue.errcheck = _errcheck
|
||||
|
||||
self.lib.EN_getvertexcount.argtypes = [self.EN_Project, ctypes.c_int, ctypes.POINTER(ctypes.c_int)]
|
||||
self.lib.EN_getvertexcount.restype = ctypes.c_int
|
||||
self.lib.EN_getvertexcount.errcheck = _errcheck
|
||||
|
||||
self.lib.EN_getvertex.argtypes = [self.EN_Project, ctypes.c_int, ctypes.c_int, ctypes.POINTER(ctypes.c_double), ctypes.POINTER(ctypes.c_double)]
|
||||
self.lib.EN_getvertex.restype = ctypes.c_int
|
||||
self.lib.EN_getvertex.errcheck = _errcheck
|
||||
|
||||
self.lib.EN_getflowunits.argtypes = [self.EN_Project, ctypes.POINTER(ctypes.c_int)]
|
||||
self.lib.EN_getflowunits.restype = ctypes.c_int
|
||||
self.lib.EN_getflowunits.errcheck = _errcheck
|
||||
|
||||
self.lib.EN_getoption.argtypes = [self.EN_Project, ctypes.c_int, ctypes.POINTER(ctypes.c_double)]
|
||||
self.lib.EN_getoption.restype = ctypes.c_int
|
||||
self.lib.EN_getoption.errcheck = _errcheck
|
||||
|
||||
self.lib.EN_gettimeparam.argtypes = [self.EN_Project, ctypes.c_int, ctypes.POINTER(ctypes.c_long)]
|
||||
self.lib.EN_gettimeparam.restype = ctypes.c_int
|
||||
self.lib.EN_gettimeparam.errcheck = _errcheck
|
||||
|
||||
self.lib.EN_openH.argtypes = [self.EN_Project]
|
||||
self.lib.EN_openH.restype = ctypes.c_int
|
||||
self.lib.EN_openH.errcheck = _errcheck
|
||||
|
||||
self.lib.EN_initH.argtypes = [self.EN_Project, ctypes.c_int]
|
||||
self.lib.EN_initH.restype = ctypes.c_int
|
||||
self.lib.EN_initH.errcheck = _errcheck
|
||||
|
||||
self.lib.EN_runH.argtypes = [self.EN_Project, ctypes.POINTER(ctypes.c_long)]
|
||||
self.lib.EN_runH.restype = ctypes.c_int
|
||||
self.lib.EN_runH.errcheck = _errcheck
|
||||
|
||||
|
||||
self.lib.EN_nextH.argtypes = [self.EN_Project, ctypes.POINTER(ctypes.c_long)]
|
||||
self.lib.EN_nextH.restype = ctypes.c_int
|
||||
self.lib.EN_nextH.errcheck = _errcheck
|
||||
self.lib.EN_closeH.argtypes = [self.EN_Project]
|
||||
self.lib.EN_closeH.restype = ctypes.c_int
|
||||
self.lib.EN_closeH.errcheck = _errcheck
|
||||
|
||||
# Water Quality Analysis Bindings
|
||||
self.lib.EN_openQ.argtypes = [self.EN_Project]
|
||||
self.lib.EN_openQ.restype = ctypes.c_int
|
||||
self.lib.EN_openQ.errcheck = _errcheck
|
||||
|
||||
self.lib.EN_initQ.argtypes = [self.EN_Project, ctypes.c_int]
|
||||
self.lib.EN_initQ.restype = ctypes.c_int
|
||||
self.lib.EN_initQ.errcheck = _errcheck
|
||||
|
||||
self.lib.EN_runQ.argtypes = [self.EN_Project, ctypes.POINTER(ctypes.c_long)]
|
||||
self.lib.EN_runQ.restype = ctypes.c_int
|
||||
self.lib.EN_runQ.errcheck = _errcheck
|
||||
|
||||
self.lib.EN_nextQ.argtypes = [self.EN_Project, ctypes.POINTER(ctypes.c_long)]
|
||||
self.lib.EN_nextQ.restype = ctypes.c_int
|
||||
self.lib.EN_nextQ.errcheck = _errcheck
|
||||
|
||||
self.lib.EN_closeQ.argtypes = [self.EN_Project]
|
||||
self.lib.EN_closeQ.restype = ctypes.c_int
|
||||
self.lib.EN_closeQ.errcheck = _errcheck
|
||||
|
||||
self.lib.EN_getqualinfo.argtypes = [
|
||||
self.EN_Project,
|
||||
ctypes.POINTER(ctypes.c_int),
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_char_p,
|
||||
ctypes.POINTER(ctypes.c_int),
|
||||
]
|
||||
self.lib.EN_getqualinfo.restype = ctypes.c_int
|
||||
self.lib.EN_getqualinfo.errcheck = _errcheck
|
||||
# ---- Bindings used by batch analyses (backend/batch.py) ----
|
||||
P, I, D, L, S = self.EN_Project, ctypes.c_int, ctypes.c_double, ctypes.c_long, ctypes.c_char_p
|
||||
|
||||
def bind(name, argtypes):
|
||||
fn = getattr(self.lib, name)
|
||||
fn.argtypes = argtypes
|
||||
fn.restype = ctypes.c_int
|
||||
fn.errcheck = _errcheck
|
||||
|
||||
bind("EN_getnodeindex", [P, S, ctypes.POINTER(I)])
|
||||
bind("EN_getlinkindex", [P, S, ctypes.POINTER(I)])
|
||||
bind("EN_setnodevalue", [P, I, I, D])
|
||||
bind("EN_setlinkvalue", [P, I, I, D])
|
||||
bind("EN_settimeparam", [P, I, L])
|
||||
bind("EN_addpattern", [P, S])
|
||||
bind("EN_getpatternindex", [P, S, ctypes.POINTER(I)])
|
||||
bind("EN_setpattern", [P, I, ctypes.POINTER(D), I])
|
||||
bind("EN_adddemand", [P, I, D, S, S])
|
||||
bind("EN_getnumdemands", [P, I, ctypes.POINTER(I)])
|
||||
bind("EN_deletedemand", [P, I, I])
|
||||
bind("EN_addcontrol", [P, I, I, D, I, D, ctypes.POINTER(I)])
|
||||
bind("EN_deletecontrol", [P, I])
|
||||
bind("EN_solveH", [P])
|
||||
bind("EN_setqualtype", [P, I, S, S, S])
|
||||
bind("EN_getpatternlen", [P, I, ctypes.POINTER(I)])
|
||||
bind("EN_getpatternvalue", [P, I, I, ctypes.POINTER(D)])
|
||||
bind("EN_getcontrol", [P, I, ctypes.POINTER(I), ctypes.POINTER(I), ctypes.POINTER(D), ctypes.POINTER(I), ctypes.POINTER(D)])
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Vector GIS input via GDAL (pyogrio): any supported format -> GeoJSON in WGS84.
|
||||
|
||||
Shapefiles must arrive zipped (they are several files); everything else is a
|
||||
single file. Files are read straight from a temp copy (zip through GDAL's
|
||||
/vsizip/, never extracted), so archive paths cannot escape the temp dir.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import numpy as np
|
||||
import pyogrio
|
||||
import shapely
|
||||
from pyogrio import raw as ogr_raw
|
||||
from pyproj import CRS, Transformer
|
||||
from shapely.geometry import mapping
|
||||
|
||||
from .batch import BatchError
|
||||
|
||||
VECTOR_EXTENSIONS = {
|
||||
".zip": "ESRI Shapefile (zip)",
|
||||
".geojson": "GeoJSON",
|
||||
".json": "GeoJSON",
|
||||
".gpkg": "GeoPackage",
|
||||
".dxf": "DXF",
|
||||
".dgn": "MicroStation DGN",
|
||||
".mif": "MapInfo MIF",
|
||||
".tab": "MapInfo TAB",
|
||||
".gml": "GML",
|
||||
".kml": "KML",
|
||||
".e00": "ArcInfo E00",
|
||||
".csv": "CSV (WKT/X,Y)",
|
||||
}
|
||||
MAX_FEATURES = 200_000
|
||||
|
||||
|
||||
def _gdal_path(path: str, ext: str) -> str:
|
||||
if ext == ".zip":
|
||||
return "/vsizip/" + path.replace("\\", "/")
|
||||
return path
|
||||
|
||||
|
||||
def _clean(v):
|
||||
if isinstance(v, (np.floating, float)):
|
||||
f = float(v)
|
||||
return None if f != f or f in (float("inf"), float("-inf")) else f
|
||||
if isinstance(v, np.integer):
|
||||
return int(v)
|
||||
if isinstance(v, bytes):
|
||||
try:
|
||||
return v.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return v.decode("cp949", errors="replace")
|
||||
if hasattr(v, "isoformat"):
|
||||
return v.isoformat()
|
||||
return v
|
||||
|
||||
|
||||
def read_vector(contents: bytes, filename: str, layer: str | None = None, src_epsg: int | None = None, default_crs: str | None = None) -> dict:
|
||||
"""`default_crs` applies only when the file itself carries no CRS (DXF, bare CSV ...)."""
|
||||
ext = os.path.splitext(filename.lower())[1]
|
||||
if ext not in VECTOR_EXTENSIONS:
|
||||
raise BatchError(f"지원하지 않는 형식입니다: {ext or filename}. 지원: {', '.join(sorted(VECTOR_EXTENSIONS))}")
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "input" + ext)
|
||||
with open(path, "wb") as f:
|
||||
f.write(contents)
|
||||
src = _gdal_path(path, ext)
|
||||
try:
|
||||
layers = [str(row[0]) for row in pyogrio.list_layers(src)]
|
||||
except Exception as e: # GDAL raises its own error types
|
||||
raise BatchError(f"파일을 읽을 수 없습니다: {e}") from e
|
||||
if not layers:
|
||||
raise BatchError("파일에 레이어가 없습니다.")
|
||||
name = layer if layer in layers else layers[0]
|
||||
try:
|
||||
info = pyogrio.read_info(src, layer=name, encoding=None)
|
||||
meta, _fids, geometry, field_data = ogr_raw.read(src, layer=name, max_features=MAX_FEATURES + 1)
|
||||
except Exception as e:
|
||||
raise BatchError(f"레이어 {name}을(를) 읽을 수 없습니다: {e}") from e
|
||||
|
||||
count = len(geometry)
|
||||
if count > MAX_FEATURES:
|
||||
raise BatchError(f"도형이 {MAX_FEATURES:,}개를 넘습니다.")
|
||||
|
||||
crs_text = meta.get("crs") or info.get("crs")
|
||||
assumed = False
|
||||
src_crs = CRS.from_epsg(src_epsg) if src_epsg else (CRS.from_user_input(crs_text) if crs_text else None)
|
||||
if src_crs is None and default_crs:
|
||||
try:
|
||||
src_crs = CRS.from_user_input(default_crs)
|
||||
assumed = True
|
||||
except Exception as e:
|
||||
raise BatchError(f"기본 좌표계를 읽을 수 없습니다: {default_crs[:60]}") from e
|
||||
src_code = src_crs.to_epsg() if src_crs else None
|
||||
|
||||
geoms = shapely.from_wkb(np.array(geometry, dtype=object))
|
||||
if src_crs is not None and not src_crs.equals(CRS.from_epsg(4326)):
|
||||
tr = Transformer.from_crs(src_crs, CRS.from_epsg(4326), always_xy=True)
|
||||
geoms = shapely.transform(geoms, lambda xy: np.column_stack(tr.transform(xy[:, 0], xy[:, 1])))
|
||||
fields = list(meta.get("fields", []))
|
||||
features = []
|
||||
for i in range(count):
|
||||
g = geoms[i]
|
||||
props = {fields[k]: _clean(field_data[k][i]) for k in range(len(fields))}
|
||||
features.append({"type": "Feature", "geometry": mapping(g) if g is not None else None, "properties": props})
|
||||
|
||||
return {
|
||||
"layers": layers,
|
||||
"layer": name,
|
||||
"crs": f"EPSG:{src_code}" if src_code else (src_crs.to_string() if src_crs else None),
|
||||
"reprojected": src_crs is not None,
|
||||
"crsAssumed": assumed,
|
||||
"geometryType": meta.get("geometry_type"),
|
||||
"count": count,
|
||||
"fields": fields,
|
||||
"featureCollection": {"type": "FeatureCollection", "features": features},
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
"""GIS layer operations: export in any CRS/format, geometry ops, Voronoi, grids.
|
||||
|
||||
Layers travel as GeoJSON FeatureCollections in WGS84 (what the map holds).
|
||||
Metric operations run in a planar CRS given by the caller (the model's CRS),
|
||||
and results come back in WGS84.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
import numpy as np
|
||||
import shapely
|
||||
from pyogrio import raw as ogr_raw
|
||||
from pyproj import CRS, Transformer
|
||||
from shapely.geometry import mapping, shape
|
||||
|
||||
from .batch import BatchError
|
||||
|
||||
EXPORT_FORMATS = {
|
||||
"shp": ("ESRI Shapefile", ".shp"),
|
||||
"gpkg": ("GPKG", ".gpkg"),
|
||||
"dxf": ("DXF", ".dxf"),
|
||||
"geojson": ("GeoJSON", ".geojson"),
|
||||
"kml": ("KML", ".kml"),
|
||||
}
|
||||
WGS84 = CRS.from_epsg(4326)
|
||||
MAX_FEATURES = 200_000
|
||||
MAX_GRID_CELLS = 200_000
|
||||
|
||||
|
||||
def _crs(user: str | None, what: str = "좌표계") -> CRS:
|
||||
if not user:
|
||||
raise BatchError(f"{what}가 필요합니다.")
|
||||
try:
|
||||
return CRS.from_user_input(user)
|
||||
except Exception as e:
|
||||
raise BatchError(f"{what}를 읽을 수 없습니다: {str(user)[:60]}") from e
|
||||
|
||||
|
||||
def _planar(user: str | None) -> CRS:
|
||||
c = _crs(user, "평면 좌표계")
|
||||
if c.is_geographic:
|
||||
raise BatchError("거리 계산에는 평면 좌표계(예: EPSG:5186)가 필요합니다.")
|
||||
return c
|
||||
|
||||
|
||||
def _project(geoms: np.ndarray, src: CRS, dst: CRS) -> np.ndarray:
|
||||
if src.equals(dst):
|
||||
return geoms
|
||||
tr = Transformer.from_crs(src, dst, always_xy=True)
|
||||
return shapely.transform(geoms, lambda xy: np.column_stack(tr.transform(xy[:, 0], xy[:, 1])))
|
||||
|
||||
|
||||
def _read_fc(fc: dict) -> tuple[np.ndarray, list[dict]]:
|
||||
feats = (fc or {}).get("features") or []
|
||||
if len(feats) > MAX_FEATURES:
|
||||
raise BatchError(f"도형이 {MAX_FEATURES:,}개를 넘습니다.")
|
||||
geoms, props = [], []
|
||||
for f in feats:
|
||||
g = f.get("geometry")
|
||||
try:
|
||||
geoms.append(shape(g) if g else None)
|
||||
except Exception:
|
||||
geoms.append(None)
|
||||
props.append(dict(f.get("properties") or {}))
|
||||
return np.array(geoms, dtype=object), props
|
||||
|
||||
|
||||
def _to_fc(geoms, props: list[dict]) -> dict:
|
||||
out = []
|
||||
for g, p in zip(geoms, props):
|
||||
if g is None or g.is_empty:
|
||||
continue
|
||||
out.append({"type": "Feature", "geometry": mapping(g), "properties": p})
|
||||
return {"type": "FeatureCollection", "features": out}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- export
|
||||
|
||||
|
||||
def _field_arrays(props: list[dict], fmt: str):
|
||||
names: list[str] = []
|
||||
for p in props:
|
||||
for k in p:
|
||||
if k not in names:
|
||||
names.append(k)
|
||||
if fmt == "shp":
|
||||
# dBASE: 10-char unique names
|
||||
seen, short = set(), []
|
||||
for n in names:
|
||||
base = str(n)[:10] or "F"
|
||||
s, k = base, 1
|
||||
while s in seen:
|
||||
k += 1
|
||||
s = f"{base[:10 - len(str(k))]}{k}"
|
||||
seen.add(s)
|
||||
short.append(s)
|
||||
else:
|
||||
short = [str(n) for n in names]
|
||||
arrays = []
|
||||
for n in names:
|
||||
vals = [p.get(n) for p in props]
|
||||
nums = [v for v in vals if v is not None and v != ""]
|
||||
if nums and all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in nums):
|
||||
arrays.append(np.array([float(v) if v is not None and v != "" else np.nan for v in vals], dtype="float64"))
|
||||
else:
|
||||
arrays.append(np.array(["" if v is None else str(v) for v in vals], dtype=object))
|
||||
return short, arrays
|
||||
|
||||
|
||||
def write_vector(fc: dict, target_crs: str | None, fmt: str, name: str) -> tuple[bytes, str, str]:
|
||||
"""FeatureCollection (WGS84) -> file bytes in `fmt` and `target_crs` (shapefile zipped)."""
|
||||
if fmt not in EXPORT_FORMATS:
|
||||
raise BatchError(f"지원하지 않는 형식입니다: {fmt}")
|
||||
driver, ext = EXPORT_FORMATS[fmt]
|
||||
geoms, props = _read_fc(fc)
|
||||
keep = [i for i, g in enumerate(geoms) if g is not None and not g.is_empty]
|
||||
if not keep:
|
||||
raise BatchError("내보낼 도형이 없습니다.")
|
||||
geoms, props = geoms[keep], [props[i] for i in keep]
|
||||
dst = _crs(target_crs) if target_crs else WGS84
|
||||
if fmt == "kml":
|
||||
dst = WGS84
|
||||
geoms = _project(geoms, WGS84, dst)
|
||||
safe = "".join(ch for ch in (name or "layer") if ch.isalnum() or ch in "-_ ").strip() or "layer"
|
||||
kinds = {g.geom_type.replace("Multi", "") for g in geoms}
|
||||
if fmt == "shp" and len(kinds) > 1:
|
||||
raise BatchError("Shapefile은 한 가지 도형(점/선/면)만 담을 수 있습니다. GeoPackage로 내보내세요.")
|
||||
geometry_type = "Unknown" if len(kinds) > 1 else ("Multi" + kinds.pop() if any(g.geom_type.startswith("Multi") for g in geoms) else geoms[0].geom_type)
|
||||
# Promote singles to multi when the layer mixes them (pyogrio needs one type).
|
||||
if geometry_type.startswith("Multi"):
|
||||
geoms = np.array([g if g.geom_type.startswith("Multi") else getattr(shapely, geometry_type)([g]) for g in geoms], dtype=object)
|
||||
fields, arrays = _field_arrays(props, fmt) if fmt != "dxf" else ([], [])
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, safe + ext)
|
||||
kwargs = {"encoding": "UTF-8"} if fmt == "shp" else {}
|
||||
ogr_raw.write(path, shapely.to_wkb(geoms), arrays, fields, driver=driver, geometry_type=geometry_type,
|
||||
crs=dst.to_wkt() if fmt != "dxf" else None, **kwargs)
|
||||
if fmt == "shp":
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
for fn in sorted(os.listdir(tmp)):
|
||||
z.write(os.path.join(tmp, fn), fn)
|
||||
return buf.getvalue(), safe + ".zip", "application/zip"
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
media = {"gpkg": "application/geopackage+sqlite3", "dxf": "application/dxf", "geojson": "application/geo+json", "kml": "application/vnd.google-earth.kml+xml"}[fmt]
|
||||
return data, safe + ext, media
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- operations
|
||||
|
||||
|
||||
def reproject_fc(fc: dict, src: str, dst: str) -> dict:
|
||||
"""Coordinates that were read as `src` but are really `dst`: fix the layer (WGS84 in/out)."""
|
||||
a, b = _crs(src, "원래 좌표계"), _crs(dst, "새 좌표계")
|
||||
geoms, props = _read_fc(fc)
|
||||
raw = _project(geoms, WGS84, a) # back to the numbers in the file
|
||||
return _to_fc(_project(raw, b, WGS84), props)
|
||||
|
||||
|
||||
def vector_op(op: str, params: dict) -> dict:
|
||||
planar = _planar(params.get("crs"))
|
||||
if op == "voronoi":
|
||||
pts = np.asarray(params.get("points") or [], dtype="float64").reshape(-1, 2) # in planar CRS
|
||||
ids = [str(i) for i in params.get("ids") or []]
|
||||
if len(pts) < 3 or len(ids) != len(pts):
|
||||
raise BatchError("점이 3개 이상 필요합니다.")
|
||||
margin = float(params.get("margin") or 0)
|
||||
hull = shapely.MultiPoint(pts).convex_hull
|
||||
clip = hull.buffer(margin if margin > 0 else max(1.0, 0.05 * np.sqrt(max(hull.area, 1.0))))
|
||||
cells = shapely.get_parts(shapely.voronoi_polygons(shapely.MultiPoint(pts), extend_to=clip.envelope))
|
||||
tree = shapely.STRtree(shapely.points(pts))
|
||||
out_g, out_p = [], []
|
||||
for c in cells:
|
||||
hit = tree.query(c, predicate="contains")
|
||||
if len(hit) == 0:
|
||||
continue
|
||||
g = c.intersection(clip)
|
||||
if g.is_empty:
|
||||
continue
|
||||
k = int(hit[0])
|
||||
out_g.append(g)
|
||||
out_p.append({"node": ids[k], "area_m2": round(float(g.area), 2)})
|
||||
return _to_fc(_project(np.array(out_g, dtype=object), planar, WGS84), out_p)
|
||||
|
||||
if op == "grid":
|
||||
b = params.get("bounds") # planar [minx, miny, maxx, maxy]
|
||||
cell = float(params.get("cell") or 0)
|
||||
if not b or cell <= 0:
|
||||
raise BatchError("범위와 격자 크기가 필요합니다.")
|
||||
x0, y0, x1, y1 = map(float, b)
|
||||
nx, ny = int(np.ceil((x1 - x0) / cell)), int(np.ceil((y1 - y0) / cell))
|
||||
if nx * ny > MAX_GRID_CELLS:
|
||||
raise BatchError(f"격자가 {nx * ny:,}칸입니다. 격자 크기를 키우세요 (최대 {MAX_GRID_CELLS:,}).")
|
||||
gs, ps = [], []
|
||||
for j in range(ny):
|
||||
for i in range(nx):
|
||||
gs.append(shapely.box(x0 + i * cell, y0 + j * cell, x0 + (i + 1) * cell, y0 + (j + 1) * cell))
|
||||
ps.append({"col": i, "row": j, "cell": f"{i}_{j}"})
|
||||
return _to_fc(_project(np.array(gs, dtype=object), planar, WGS84), ps)
|
||||
|
||||
geoms, props = _read_fc(params.get("layer") or {})
|
||||
if not len(geoms):
|
||||
raise BatchError("대상 레이어가 비어 있습니다.")
|
||||
g = _project(geoms, WGS84, planar)
|
||||
if op == "buffer":
|
||||
d = float(params.get("distance") or 0)
|
||||
if d == 0:
|
||||
raise BatchError("버퍼 거리를 입력하세요.")
|
||||
res = shapely.buffer(g, d)
|
||||
return _to_fc(_project(res, planar, WGS84), props)
|
||||
if op == "dissolve":
|
||||
field = params.get("field")
|
||||
groups: dict = {}
|
||||
for geom, p in zip(g, props):
|
||||
if geom is None:
|
||||
continue
|
||||
groups.setdefault(p.get(field) if field else None, []).append(geom)
|
||||
out_g = [shapely.union_all(v) for v in groups.values()]
|
||||
out_p = [{field: k, "count": len(v)} if field else {"count": len(v)} for k, v in groups.items()]
|
||||
return _to_fc(_project(np.array(out_g, dtype=object), planar, WGS84), out_p)
|
||||
if op in ("intersection", "difference", "clip"):
|
||||
other_g, _ = _read_fc(params.get("other") or {})
|
||||
if not len(other_g):
|
||||
raise BatchError("두 번째 레이어가 비어 있습니다.")
|
||||
mask = shapely.union_all(_project(other_g, WGS84, planar))
|
||||
res = shapely.difference(g, mask) if op == "difference" else shapely.intersection(g, mask)
|
||||
return _to_fc(_project(res, planar, WGS84), props)
|
||||
raise BatchError(f"알 수 없는 연산입니다: {op}")
|
||||
+724
@@ -0,0 +1,724 @@
|
||||
"""Batch analysis jobs exposed as POST /api/jobs/{name}.
|
||||
|
||||
Each job takes the INP bytes and a params dict and returns a JSON-able dict.
|
||||
Jobs run in the API process pool; raise BatchError for input problems
|
||||
(reported as HTTP 400). Register new jobs in JOBS at the bottom.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
import ctypes
|
||||
|
||||
from .batch import BatchError, Network, hhmm, parse_time
|
||||
from .epanet import EN, EpanetException
|
||||
|
||||
|
||||
def job_summary(inp: bytes, params: dict) -> dict:
|
||||
"""Framework smoke job: min/max pressure per reporting period."""
|
||||
with Network(inp) as net:
|
||||
junctions = net.junction_indices()
|
||||
res = net.simulate(node_props=(EN.PRESSURE,), nodes=junctions)
|
||||
p = res.nodes[EN.PRESSURE]
|
||||
return {
|
||||
"times": res.times,
|
||||
"labels": [hhmm(t) for t in res.times],
|
||||
"minPressure": p.min(axis=1).round(4).tolist() if p.size else [],
|
||||
"maxPressure": p.max(axis=1).round(4).tolist() if p.size else [],
|
||||
"junctions": len(junctions),
|
||||
"warnings": [{"time": t, "code": c} for t, c in res.warnings],
|
||||
}
|
||||
|
||||
|
||||
def _window_rows(times: list[int], start: int, end: int) -> np.ndarray:
|
||||
"""Reporting periods inside [start, end); falls back to the one nearest start."""
|
||||
t = np.asarray(times)
|
||||
rows = np.where((t >= start) & (t < end))[0]
|
||||
if rows.size == 0:
|
||||
rows = np.array([int(np.argmin(np.abs(t - start)))])
|
||||
return rows
|
||||
|
||||
|
||||
def job_fireflow(inp: bytes, params: dict) -> dict:
|
||||
"""소화전 모의: extra demand at hydrant nodes for a time window.
|
||||
|
||||
params: nodes [ids], flow (model flow units, per hydrant), start/end (h or h:mm),
|
||||
residual (model pressure units, optional: also find the available flow),
|
||||
threshold (optional: count junctions whose window-min pressure drops below it).
|
||||
"""
|
||||
ids = [str(x) for x in params.get("nodes") or []]
|
||||
if not ids:
|
||||
raise BatchError("소화전 절점을 하나 이상 지정하세요.")
|
||||
flow = float(params.get("flow") or 0)
|
||||
if flow <= 0:
|
||||
raise BatchError("소화 유량은 0보다 커야 합니다.")
|
||||
with Network(inp) as net:
|
||||
start = parse_time(params.get("start"), 0)
|
||||
end = parse_time(params.get("end"), max(net.duration, net.report_step))
|
||||
if end <= start:
|
||||
end = start + net.report_step
|
||||
hyd = [net.node_index(i) for i in ids]
|
||||
junctions = net.junction_indices()
|
||||
base = net.simulate(nodes=junctions)
|
||||
rows = _window_rows(base.times, start, end)
|
||||
base_min = base.nodes[EN.PRESSURE][rows].min(axis=0)
|
||||
|
||||
net.window_pattern("SPIPE_FIRE", start, end)
|
||||
cats = [net.add_demand(h, flow, "SPIPE_FIRE", "fire") for h in hyd]
|
||||
fire = net.simulate(nodes=junctions)
|
||||
fire_min = fire.nodes[EN.PRESSURE][rows].min(axis=0)
|
||||
for h, c in zip(hyd, cats):
|
||||
net.delete_demand(h, c)
|
||||
|
||||
col = {j: k for k, j in enumerate(junctions)}
|
||||
# Compare the two runs period by period: tank/pump controls can shift
|
||||
# *when* the daily minimum happens, so "min before vs min after" may
|
||||
# even show a rise. Report each node at its largest drop instead.
|
||||
bw = base.nodes[EN.PRESSURE][rows]
|
||||
fw = fire.nodes[EN.PRESSURE][rows]
|
||||
drop = fw - bw
|
||||
worst = np.argmin(drop, axis=0)
|
||||
cols = np.arange(len(junctions))
|
||||
diff = drop[worst, cols]
|
||||
order = np.argsort(diff)
|
||||
threshold = params.get("threshold")
|
||||
out_nodes = [
|
||||
{
|
||||
"id": net.node_ids[junctions[k] - 1],
|
||||
"basePressure": round(float(bw[worst[k], k]), 3),
|
||||
"firePressure": round(float(fw[worst[k], k]), 3),
|
||||
"diff": round(float(diff[k]), 3),
|
||||
"time": hhmm(base.times[rows[worst[k]]]),
|
||||
}
|
||||
for k in order
|
||||
if abs(diff[k]) > 1e-4
|
||||
]
|
||||
|
||||
residual = params.get("residual")
|
||||
hydrants = []
|
||||
for nid, h in zip(ids, hyd):
|
||||
entry = {"id": nid}
|
||||
if h in col:
|
||||
k = col[h]
|
||||
# Hydrant: window minimum before/after (what the residual criterion is about).
|
||||
entry.update(basePressure=round(float(base_min[k]), 3), firePressure=round(float(fire_min[k]), 3))
|
||||
if residual not in (None, "") and h in col:
|
||||
entry["available"] = _available_fire_flow(net, h, col[h], junctions, rows, start, end, float(residual), flow)
|
||||
hydrants.append(entry)
|
||||
|
||||
return {
|
||||
"window": [start, end],
|
||||
"windowLabel": f"{hhmm(start)}~{hhmm(end)}",
|
||||
"flow": flow,
|
||||
"hydrants": hydrants,
|
||||
"nodes": out_nodes,
|
||||
"belowThreshold": int(np.sum(fire_min < float(threshold))) if threshold not in (None, "") else None,
|
||||
"belowThresholdBefore": int(np.sum(base_min < float(threshold))) if threshold not in (None, "") else None,
|
||||
"warnings": [{"time": t, "code": c} for t, c in fire.warnings],
|
||||
}
|
||||
|
||||
|
||||
def _available_fire_flow(net, h, col, junctions, rows, start, end, residual, hint) -> float:
|
||||
"""Largest extra flow at node h keeping its window-min pressure >= residual (bisection)."""
|
||||
def min_p(q: float) -> float:
|
||||
net.window_pattern("SPIPE_AFF", start, end)
|
||||
c = net.add_demand(h, q, "SPIPE_AFF", "aff")
|
||||
try:
|
||||
r = net.simulate(nodes=[h])
|
||||
finally:
|
||||
net.delete_demand(h, c)
|
||||
return float(r.nodes[EN.PRESSURE][rows, 0].min())
|
||||
|
||||
if min_p(0.0) < residual:
|
||||
return 0.0
|
||||
lo, hi = 0.0, max(hint, 1e-3)
|
||||
for _ in range(30): # grow until the residual is violated
|
||||
if min_p(hi) < residual:
|
||||
break
|
||||
lo, hi = hi, hi * 2
|
||||
else:
|
||||
return round(hi, 3)
|
||||
for _ in range(25):
|
||||
mid = (lo + hi) / 2
|
||||
if min_p(mid) >= residual:
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid
|
||||
if hi - lo <= max(1e-3, 1e-3 * hi):
|
||||
break
|
||||
return round(lo, 3)
|
||||
|
||||
|
||||
def _window(net: Network, params: dict) -> tuple[int, int]:
|
||||
start = parse_time(params.get("start"), 0)
|
||||
end = parse_time(params.get("end"), max(net.duration, net.report_step))
|
||||
return start, (end if end > start else start + net.report_step)
|
||||
|
||||
|
||||
MAX_CRITICALITY_LINKS = 4000
|
||||
|
||||
|
||||
def job_criticality(inp: bytes, params: dict) -> dict:
|
||||
"""관로 중요도: close each pipe in turn and measure who loses pressure.
|
||||
|
||||
params: links [ids] (default all pipes), threshold (model pressure units),
|
||||
start/end (window, h). A junction is "affected" when its window-min pressure
|
||||
falls below the threshold while it was at/above it in the base run.
|
||||
"""
|
||||
threshold = float(params.get("threshold") or 0)
|
||||
with Network(inp) as net:
|
||||
start, end = _window(net, params)
|
||||
ids = [str(x) for x in params.get("links") or []]
|
||||
if ids:
|
||||
links = [net.link_index(i) for i in ids]
|
||||
else:
|
||||
links = [k + 1 for k, t in enumerate(net.link_types) if t in (EN.CVPIPE, EN.PIPE)]
|
||||
if not links:
|
||||
raise BatchError("분석할 관로가 없습니다.")
|
||||
if len(links) > MAX_CRITICALITY_LINKS:
|
||||
raise BatchError(f"관로가 {len(links)}개입니다. {MAX_CRITICALITY_LINKS}개 이하로 선택해 나눠 실행하세요.")
|
||||
junctions = net.junction_indices()
|
||||
base = net.simulate(node_props=(EN.PRESSURE, EN.DEMAND), nodes=junctions)
|
||||
rows = _window_rows(base.times, start, end)
|
||||
bmin = base.nodes[EN.PRESSURE][rows].min(axis=0)
|
||||
bdem = base.nodes[EN.DEMAND][rows].mean(axis=0)
|
||||
ok_before = bmin >= threshold
|
||||
out = []
|
||||
for li in links:
|
||||
orig = net.get_link(li, EN.INITSTATUS)
|
||||
net.set_link(li, EN.INITSTATUS, 0)
|
||||
entry = {"id": net.link_ids[li - 1]}
|
||||
try:
|
||||
r = net.simulate(nodes=junctions)
|
||||
pmin = r.nodes[EN.PRESSURE][_window_rows(r.times, start, end)].min(axis=0)
|
||||
affected = np.where((pmin < threshold) & ok_before)[0]
|
||||
drop = bmin - pmin
|
||||
order = affected[np.argsort(-drop[affected])]
|
||||
entry.update(
|
||||
affectedNodes=int(affected.size),
|
||||
affectedDemand=round(float(np.sum(np.maximum(bdem[affected], 0))), 4),
|
||||
minPressure=round(float(pmin.min()), 3),
|
||||
maxDrop=round(float(drop.max()), 3),
|
||||
nodes=[net.node_ids[junctions[k] - 1] for k in order[:200]],
|
||||
warning=any(c in (3, 6) for _, c in r.warnings),
|
||||
disconnected=bool(pmin.min() < -1000),
|
||||
)
|
||||
except (EpanetException, BatchError) as e:
|
||||
entry.update(error=str(e), affectedNodes=0, affectedDemand=0.0, minPressure=None, maxDrop=None, nodes=[])
|
||||
finally:
|
||||
net.set_link(li, EN.INITSTATUS, orig)
|
||||
out.append(entry)
|
||||
out.sort(key=lambda e: (-e["affectedDemand"], -e["affectedNodes"], -(e["maxDrop"] or 0)))
|
||||
return {
|
||||
"window": [start, end],
|
||||
"windowLabel": f"{hhmm(start)}~{hhmm(end)}",
|
||||
"threshold": threshold,
|
||||
"belowBefore": int(np.sum(~ok_before)),
|
||||
"totalDemand": round(float(np.sum(np.maximum(bdem, 0))), 4),
|
||||
"links": out,
|
||||
}
|
||||
|
||||
|
||||
def job_servicearea(inp: bytes, params: dict) -> dict:
|
||||
"""급수 구역: source tracing (EPANET TRACE) from each reservoir (tanks optional).
|
||||
|
||||
Returns each node's average share (%) of water from each source over the
|
||||
analysis window (default: the last 24 h of the run; a run shorter than 24 h
|
||||
is extended to 72 h so traced water can reach the far ends).
|
||||
"""
|
||||
with Network(inp) as net:
|
||||
notes = []
|
||||
if net.duration < 72 * 3600:
|
||||
# Traced water starts at 0 everywhere; give it time to reach the far ends.
|
||||
net.set_time_param(EN.DURATION, 72 * 3600)
|
||||
notes.append(f"해석 기간({net.duration // 3600}시간)을 72시간으로 늘려 추적하고 마지막 24시간을 평균했습니다.")
|
||||
net.duration = 72 * 3600
|
||||
# Default: reservoirs only. A traced tank counts water that merely passed
|
||||
# through it, so tank shares overlap the reservoir shares (sum > 100%).
|
||||
kinds = (EN.RESERVOIR, EN.TANK) if params.get("includeTanks") else (EN.RESERVOIR,)
|
||||
sources = [str(x) for x in params.get("sources") or []] or [
|
||||
nid for nid, t in zip(net.node_ids, net.node_types) if t in kinds
|
||||
] or [nid for nid, t in zip(net.node_ids, net.node_types) if t == EN.TANK]
|
||||
if not sources:
|
||||
raise BatchError("배수지·탱크가 없습니다.")
|
||||
if len(sources) > 30:
|
||||
raise BatchError("수원이 30개를 넘습니다. 추적할 수원을 골라 주세요.")
|
||||
for sid in sources:
|
||||
net.node_index(sid)
|
||||
start = parse_time(params.get("start"), max(0, net.duration - 24 * 3600))
|
||||
end = parse_time(params.get("end"), net.duration + 1)
|
||||
net.lib.EN_solveH(net.ph)
|
||||
shares: dict[str, np.ndarray] = {}
|
||||
for sid in sources:
|
||||
net.lib.EN_setqualtype(net.ph, EN.QUAL_TRACE, b"", b"", sid.encode("utf-8"))
|
||||
times, q = net.run_quality()
|
||||
rows = _window_rows(times, start, end)
|
||||
shares[sid] = np.clip(q[rows].mean(axis=0), 0, 100)
|
||||
mat = np.vstack([shares[s] for s in sources]) # [source, node]
|
||||
dom = np.argmax(mat, axis=0)
|
||||
nodes = {}
|
||||
for k, nid in enumerate(net.node_ids):
|
||||
col = mat[:, k]
|
||||
if col.max() < 0.5:
|
||||
nodes[nid] = {"source": None, "share": 0.0, "shares": {}}
|
||||
continue
|
||||
nodes[nid] = {
|
||||
"source": sources[dom[k]],
|
||||
"share": round(float(col[dom[k]]), 1),
|
||||
"shares": {s: round(float(v), 1) for s, v in zip(sources, col) if v >= 0.5},
|
||||
}
|
||||
summary = []
|
||||
for sid in sources:
|
||||
mine = [nid for nid in net.node_ids if nodes[nid]["source"] == sid]
|
||||
summary.append({"source": sid, "nodes": len(mine), "mixed": sum(1 for nid in mine if nodes[nid]["share"] < 80)})
|
||||
return {
|
||||
"sources": sources,
|
||||
"window": [start, end],
|
||||
"windowLabel": f"{hhmm(start)}~{hhmm(end)}",
|
||||
"nodes": nodes,
|
||||
"summary": summary,
|
||||
"unreached": sum(1 for v in nodes.values() if v["source"] is None),
|
||||
"notes": notes,
|
||||
}
|
||||
|
||||
|
||||
def job_vsp(inp: bytes, params: dict) -> dict:
|
||||
"""VSP: pump speed giving a target pressure at a control node (bisection).
|
||||
|
||||
Each requested hour is solved as a snapshot (DURATION 0, patterns shifted to
|
||||
that hour, tanks at their initial levels). params: pump, node, target
|
||||
(model pressure units), hours [list] (default 0..23), minSpeed, maxSpeed.
|
||||
"""
|
||||
pump_id, node_id = str(params.get("pump") or ""), str(params.get("node") or "")
|
||||
target = float(params.get("target") or 0)
|
||||
lo_s, hi_s = float(params.get("minSpeed") or 0.5), float(params.get("maxSpeed") or 1.2)
|
||||
if not (0 < lo_s < hi_s):
|
||||
raise BatchError("속도 범위가 올바르지 않습니다 (0 < 최소 < 최대).")
|
||||
hours = [float(h) for h in (params.get("hours") or list(range(24)))][:168]
|
||||
with Network(inp) as net:
|
||||
pi = net.link_index(pump_id)
|
||||
if net.link_types[pi - 1] != EN.PUMP:
|
||||
raise BatchError(f"{pump_id}는 펌프가 아닙니다.")
|
||||
ni = net.node_index(node_id)
|
||||
ps0 = net.time_param(EN.PATTERNSTART)
|
||||
net.set_time_param(EN.DURATION, 0)
|
||||
|
||||
def solve(speed: float) -> tuple[float, float, float]:
|
||||
net.set_link(pi, EN.INITSTATUS, 1)
|
||||
net.set_link(pi, EN.INITSETTING, speed)
|
||||
net.lib.EN_solveH(net.ph)
|
||||
return net.get_node(ni, EN.PRESSURE), net.get_link(pi, EN.FLOW), net.get_link(pi, EN.ENERGY)
|
||||
|
||||
out = []
|
||||
for h in hours:
|
||||
net.set_time_param(EN.PATTERNSTART, ps0 + int(h * 3600))
|
||||
try:
|
||||
p_hi = solve(hi_s)
|
||||
p_lo = solve(lo_s)
|
||||
if p_hi[0] < target:
|
||||
speed, res, status = hi_s, p_hi, "max"
|
||||
elif p_lo[0] >= target:
|
||||
speed, res, status = lo_s, p_lo, "min"
|
||||
else:
|
||||
a, b = lo_s, hi_s
|
||||
for _ in range(30):
|
||||
m = (a + b) / 2
|
||||
if solve(m)[0] >= target:
|
||||
b = m
|
||||
else:
|
||||
a = m
|
||||
if b - a < 1e-4:
|
||||
break
|
||||
speed, res, status = b, solve(b), "ok"
|
||||
out.append({"hour": h, "speed": round(speed, 4), "pressure": round(res[0], 3),
|
||||
"flow": round(res[1], 4), "power": round(res[2], 3), "status": status})
|
||||
except EpanetException as e:
|
||||
out.append({"hour": h, "error": str(e)})
|
||||
return {"pump": pump_id, "node": node_id, "target": target, "rows": out,
|
||||
"note": "시간별 정상상태 해석입니다 (탱크는 초기 수위, 그 시각의 시간 제어만 반영)."}
|
||||
|
||||
|
||||
# m3/s per model flow unit, by EN_getflowunits code
|
||||
_M3S = {0: 0.0283168, 1: 6.30902e-5, 2: 0.0438126, 3: 0.0526168, 4: 0.0142764, 5: 1e-3,
|
||||
6: 1e-3 / 60, 7: 1e3 / 86400, 8: 1 / 3600, 9: 1 / 86400, 10: 1.0}
|
||||
|
||||
|
||||
def job_energy(inp: bytes, params: dict) -> dict:
|
||||
"""에너지 보고서: per-pump utilisation, efficiency, kWh, kWh/m3, peak kW and cost,
|
||||
integrated over every hydraulic step (prices from [ENERGY] incl. patterns)."""
|
||||
with Network(inp) as net:
|
||||
pumps = [k + 1 for k, t in enumerate(net.link_types) if t == EN.PUMP]
|
||||
if not pumps:
|
||||
raise BatchError("펌프가 없습니다.")
|
||||
fu = ctypes.c_int()
|
||||
net.lib.EN_getflowunits(net.ph, ctypes.byref(fu))
|
||||
to_m3s = _M3S.get(fu.value, 1.0)
|
||||
g_price = net.option(EN.GLOBALPRICE)
|
||||
g_pat = int(net.option(EN.GLOBALPATTERN))
|
||||
d_charge = net.option(EN.DEMANDCHARGE)
|
||||
g_eff = net.option(EN.GLOBALEFFIC)
|
||||
ps0 = net.time_param(EN.PATTERNSTART)
|
||||
price = {p: (net.get_link(p, EN.PUMP_ECOST) or g_price) for p in pumps}
|
||||
pat = {p: (int(net.get_link(p, EN.PUMP_EPAT)) or g_pat) for p in pumps}
|
||||
acc = {p: {"on": 0.0, "kwh": 0.0, "m3": 0.0, "effw": 0.0, "peak": 0.0, "cost": 0.0} for p in pumps}
|
||||
peak_total = 0.0
|
||||
total_s = 0.0
|
||||
t, tstep = ctypes.c_long(), ctypes.c_long(1)
|
||||
steady = net.duration == 0
|
||||
net.lib.EN_openH(net.ph)
|
||||
try:
|
||||
net.lib.EN_initH(net.ph, EN.NOSAVE)
|
||||
while True:
|
||||
net.lib.EN_runH(net.ph, ctypes.byref(t))
|
||||
now = t.value
|
||||
# Values at `now` hold until the next hydraulic time.
|
||||
vals = [(p, abs(net.get_link(p, EN.FLOW)), max(0.0, net.get_link(p, EN.ENERGY)), net.get_link(p, EN.PUMP_EFFIC)) for p in pumps]
|
||||
net.lib.EN_nextH(net.ph, ctypes.byref(tstep))
|
||||
dt = 3600.0 if steady else float(tstep.value)
|
||||
kw_total = 0.0
|
||||
for p, q, kw, eff in vals:
|
||||
a = acc[p]
|
||||
if q > 1e-9 and kw > 0:
|
||||
a["on"] += dt
|
||||
a["effw"] += eff * dt
|
||||
a["kwh"] += kw * dt / 3600
|
||||
a["m3"] += q * to_m3s * dt
|
||||
a["peak"] = max(a["peak"], kw)
|
||||
a["cost"] += kw * dt / 3600 * price[p] * net.pattern_value(pat[p], now, ps0)
|
||||
kw_total += kw
|
||||
peak_total = max(peak_total, kw_total)
|
||||
total_s += dt
|
||||
if tstep.value <= 0:
|
||||
break
|
||||
finally:
|
||||
net.lib.EN_closeH(net.ph)
|
||||
total_s = max(total_s, 1.0)
|
||||
rows = []
|
||||
for p in pumps:
|
||||
a = acc[p]
|
||||
rows.append({
|
||||
"id": net.link_ids[p - 1],
|
||||
"utilization": round(100 * a["on"] / total_s, 2),
|
||||
"efficiency": round(100 * a["effw"] / a["on"], 2) if a["on"] else None, # DLL gives a fraction
|
||||
"kwh": round(a["kwh"], 3),
|
||||
"kwhPerM3": round(a["kwh"] / a["m3"], 5) if a["m3"] > 0 else None,
|
||||
"volume": round(a["m3"], 3),
|
||||
"avgKw": round(a["kwh"] * 3600 / a["on"], 3) if a["on"] else 0.0,
|
||||
"peakKw": round(a["peak"], 3),
|
||||
"cost": round(a["cost"], 3),
|
||||
"costPerDay": round(a["cost"] * 86400 / total_s, 3),
|
||||
})
|
||||
return {
|
||||
"pumps": rows,
|
||||
"hours": round(total_s / 3600, 3),
|
||||
"globalPrice": g_price,
|
||||
"globalEfficiency": g_eff,
|
||||
"demandCharge": d_charge,
|
||||
"peakKw": round(peak_total, 3),
|
||||
"demandCost": round(peak_total * d_charge, 3),
|
||||
"totalCost": round(sum(r["cost"] for r in rows) + peak_total * d_charge, 3),
|
||||
"steady": steady,
|
||||
}
|
||||
|
||||
|
||||
PARAM_PROPS = {"pressure": ("node", EN.PRESSURE), "head": ("node", EN.HEAD), "demand": ("node", EN.DEMAND),
|
||||
"quality": ("node", EN.QUALITY), "flow": ("link", EN.FLOW), "velocity": ("link", EN.VELOCITY)}
|
||||
|
||||
|
||||
def job_calibrate(inp: bytes, params: dict) -> dict:
|
||||
"""자동 보정: per-group pipe roughness multipliers ('roughness') or wall
|
||||
coefficients ('wall') fitted to observations by bounded least squares.
|
||||
|
||||
params: mode, param, observations [{location, time (s), value (model units)}],
|
||||
groups {name: [pipe ids]}, bounds [lo, hi], maxEvaluations.
|
||||
"""
|
||||
from scipy.optimize import least_squares
|
||||
|
||||
mode = params.get("mode") or "roughness"
|
||||
param = params.get("param") or "pressure"
|
||||
if param not in PARAM_PROPS:
|
||||
raise BatchError(f"보정할 수 없는 항목입니다: {param}")
|
||||
kind, prop = PARAM_PROPS[param]
|
||||
if mode == "wall" and param != "quality":
|
||||
raise BatchError("관벽 반응계수 보정에는 수질(잔류염소) 실측값이 필요합니다.")
|
||||
if mode == "roughness" and param == "quality":
|
||||
raise BatchError("조도 보정에는 수압·수두·유량 실측값을 쓰세요.")
|
||||
obs = [o for o in params.get("observations") or [] if o.get("location") is not None and o.get("value") is not None]
|
||||
if len(obs) < 2:
|
||||
raise BatchError("실측값이 2개 이상 필요합니다.")
|
||||
groups = {str(k): [str(i) for i in v] for k, v in (params.get("groups") or {}).items() if v}
|
||||
if not groups:
|
||||
raise BatchError("보정할 관로 그룹이 없습니다.")
|
||||
if len(groups) > 30:
|
||||
raise BatchError("그룹이 30개를 넘습니다. 묶음을 줄이세요.")
|
||||
lo, hi = (float(b) for b in (params.get("bounds") or ((0.5, 1.5) if mode == "roughness" else (-5.0, 0.0))))
|
||||
if not lo < hi:
|
||||
raise BatchError("범위가 올바르지 않습니다.")
|
||||
max_eval = int(params.get("maxEvaluations") or 200)
|
||||
|
||||
with Network(inp) as net:
|
||||
locs = sorted({str(o["location"]) for o in obs})
|
||||
index = {}
|
||||
for loc in locs:
|
||||
index[loc] = net.node_index(loc) if kind == "node" else net.link_index(loc)
|
||||
cols = {loc: k for k, loc in enumerate(locs)}
|
||||
o_col = np.array([cols[str(o["location"])] for o in obs])
|
||||
o_t = np.array([float(o["time"]) for o in obs])
|
||||
o_v = np.array([float(o["value"]) for o in obs])
|
||||
g_names = list(groups)
|
||||
g_links = [[net.link_index(i) for i in groups[g]] for g in g_names]
|
||||
if mode == "wall":
|
||||
qt = ctypes.c_int(); tr = ctypes.c_int()
|
||||
net.lib.EN_getqualinfo(net.ph, ctypes.byref(qt), ctypes.create_string_buffer(64), ctypes.create_string_buffer(64), ctypes.byref(tr))
|
||||
if qt.value != 1:
|
||||
raise BatchError("모델의 수질 해석이 화학물질(CHEMICAL)이 아닙니다. 모델 설정에서 수질 모드를 바꾸세요.")
|
||||
base = [np.array([net.get_link(li, EN.KWALL) for li in links]) for links in g_links]
|
||||
x0 = np.array([float(np.clip(np.mean(b) if len(b) else (lo + hi) / 2, lo, hi)) for b in base])
|
||||
net.lib.EN_solveH(net.ph) # wall reactions do not change hydraulics
|
||||
else:
|
||||
base = [np.array([net.get_link(li, EN.ROUGHNESS) for li in links]) for links in g_links]
|
||||
x0 = np.clip(np.ones(len(g_names)), lo, hi)
|
||||
|
||||
def apply(x):
|
||||
for links, b, v in zip(g_links, base, x):
|
||||
for li, bv in zip(links, b):
|
||||
net.set_link(li, EN.KWALL if mode == "wall" else EN.ROUGHNESS, v if mode == "wall" else bv * v)
|
||||
|
||||
def simulate_values() -> np.ndarray:
|
||||
if mode == "wall":
|
||||
times, q = net.run_quality(nodes=[index[l] for l in locs])
|
||||
times = np.asarray(times, dtype=float)
|
||||
mat = q
|
||||
else:
|
||||
r = net.simulate(node_props=(prop,) if kind == "node" else (), link_props=(prop,) if kind == "link" else (),
|
||||
nodes=[index[l] for l in locs] if kind == "node" else [], links=[index[l] for l in locs] if kind == "link" else [])
|
||||
times = np.asarray(r.times, dtype=float)
|
||||
mat = (r.nodes if kind == "node" else r.links)[prop]
|
||||
if param == "flow":
|
||||
mat = np.abs(mat)
|
||||
out = np.empty(len(obs))
|
||||
for c in range(len(locs)):
|
||||
sel = o_col == c
|
||||
out[sel] = np.interp(o_t[sel], times, mat[:, c]) if len(times) > 1 else mat[0, c]
|
||||
return out
|
||||
|
||||
evals = {"n": 0}
|
||||
|
||||
def residuals(x):
|
||||
evals["n"] += 1
|
||||
apply(x)
|
||||
try:
|
||||
return simulate_values() - o_v
|
||||
except (EpanetException, BatchError):
|
||||
return np.full(len(obs), 1e3)
|
||||
|
||||
r0 = residuals(x0)
|
||||
fit = least_squares(residuals, x0, bounds=(np.full(len(x0), lo), np.full(len(x0), hi)),
|
||||
diff_step=0.02 if mode == "roughness" else 0.05, max_nfev=max_eval, xtol=1e-4, ftol=1e-6)
|
||||
r1 = residuals(fit.x)
|
||||
apply(x0) # leave the network as it was (not strictly needed)
|
||||
rmse = lambda r: float(np.sqrt(np.mean(r ** 2)))
|
||||
out_groups = []
|
||||
for name, links, b, v in zip(g_names, g_links, base, fit.x):
|
||||
out_groups.append({
|
||||
"name": name, "pipes": len(links),
|
||||
"value": round(float(v), 4),
|
||||
"meanBefore": round(float(np.mean(b)), 4) if len(b) else None,
|
||||
"meanAfter": round(float(np.mean(b * v)) if mode == "roughness" else float(v), 4) if len(b) else None,
|
||||
"atBound": bool(abs(v - lo) < 1e-6 or abs(v - hi) < 1e-6),
|
||||
})
|
||||
per_loc = []
|
||||
for c, loc in enumerate(locs):
|
||||
sel = o_col == c
|
||||
per_loc.append({"location": loc, "n": int(sel.sum()), "before": round(rmse(r0[sel]), 4), "after": round(rmse(r1[sel]), 4)})
|
||||
return {
|
||||
"mode": mode, "param": param, "groups": out_groups, "locations": per_loc,
|
||||
"rmseBefore": round(rmse(r0), 4), "rmseAfter": round(rmse(r1), 4),
|
||||
"evaluations": evals["n"], "converged": bool(fit.success), "message": str(fit.message),
|
||||
"observations": len(obs),
|
||||
}
|
||||
|
||||
|
||||
def _pump_controls(net: Network, pump_idx: set) -> list[int]:
|
||||
"""1-based indices of simple controls acting on the given pumps."""
|
||||
n = ctypes.c_int()
|
||||
net.lib.EN_getcount(net.ph, 5, ctypes.byref(n))
|
||||
out = []
|
||||
t, li, ni = ctypes.c_int(), ctypes.c_int(), ctypes.c_int()
|
||||
st, lv = ctypes.c_double(), ctypes.c_double()
|
||||
for k in range(1, n.value + 1):
|
||||
net.lib.EN_getcontrol(net.ph, k, ctypes.byref(t), ctypes.byref(li), ctypes.byref(st), ctypes.byref(ni), ctypes.byref(lv))
|
||||
if li.value in pump_idx:
|
||||
out.append(k)
|
||||
return out
|
||||
|
||||
|
||||
def job_pumpschedule(inp: bytes, params: dict) -> dict:
|
||||
"""펌프 운전 최적화: hourly (pattern-step) on/off schedules that cut energy cost
|
||||
while keeping junction pressure >= minPressure and tanks back to their start
|
||||
level at the end. Existing simple controls on those pumps are ignored.
|
||||
|
||||
params: pumps [ids] (default all), minPressure (model units), maxEvaluations,
|
||||
tankTolerance (fraction of tank range allowed below the start level at the end).
|
||||
"""
|
||||
min_p = float(params.get("minPressure") or 0)
|
||||
budget = int(params.get("maxEvaluations") or 400)
|
||||
tank_tol = float(params.get("tankTolerance") if params.get("tankTolerance") is not None else 0.02)
|
||||
with Network(inp) as net:
|
||||
all_pumps = [k + 1 for k, t in enumerate(net.link_types) if t == EN.PUMP]
|
||||
pumps = [net.link_index(str(p)) for p in params.get("pumps") or []] or all_pumps
|
||||
if not pumps:
|
||||
raise BatchError("펌프가 없습니다.")
|
||||
if net.duration < net.pattern_step:
|
||||
raise BatchError("연장 기간 해석(EPS) 모델이어야 합니다.")
|
||||
rules = ctypes.c_int()
|
||||
net.lib.EN_getcount(net.ph, 6, ctypes.byref(rules))
|
||||
controls = _pump_controls(net, set(pumps))
|
||||
slot = net.pattern_step
|
||||
n_slots = int(np.ceil(net.duration / slot))
|
||||
if n_slots > 168:
|
||||
raise BatchError("시간 구간이 168개를 넘습니다 (패턴 간격을 늘리세요).")
|
||||
junctions = net.junction_indices()
|
||||
tanks = [k + 1 for k, t in enumerate(net.node_types) if t == EN.TANK]
|
||||
t_min = np.array([net.get_node(t, 20) for t in tanks])
|
||||
t_max = np.array([net.get_node(t, 21) for t in tanks])
|
||||
t_init = np.array([net.get_node(t, EN.TANKLEVEL) for t in tanks]) # initial level
|
||||
t_elev = np.array([net.get_node(t, EN.ELEVATION) for t in tanks])
|
||||
ps0 = net.time_param(EN.PATTERNSTART)
|
||||
g_price = net.option(EN.GLOBALPRICE)
|
||||
g_pat = int(net.option(EN.GLOBALPATTERN))
|
||||
price = {p: (net.get_link(p, EN.PUMP_ECOST) or g_price or 1.0) for p in pumps}
|
||||
ppat = {p: (int(net.get_link(p, EN.PUMP_EPAT)) or g_pat) for p in pumps}
|
||||
priced = bool(g_price or any(net.get_link(p, EN.PUMP_ECOST) for p in pumps))
|
||||
|
||||
def evaluate(sched: dict | None) -> dict:
|
||||
t, tstep = ctypes.c_long(), ctypes.c_long(1)
|
||||
cost = kwh = 0.0
|
||||
pmin = np.inf
|
||||
level_end = t_init.copy()
|
||||
net.lib.EN_openH(net.ph)
|
||||
try:
|
||||
net.lib.EN_initH(net.ph, EN.NOSAVE)
|
||||
while True:
|
||||
net.lib.EN_runH(net.ph, ctypes.byref(t))
|
||||
now = t.value
|
||||
kw = [max(0.0, net.get_link(p, EN.ENERGY)) for p in pumps]
|
||||
if net.is_report_time(now) and junctions:
|
||||
pmin = min(pmin, min(net.get_node(j, EN.PRESSURE) for j in junctions))
|
||||
if tanks:
|
||||
# EN_TANKLEVEL reads back the *initial* level; the current one is head - bottom.
|
||||
level_end = np.array([net.get_node(tk, EN.HEAD) for tk in tanks]) - t_elev
|
||||
net.lib.EN_nextH(net.ph, ctypes.byref(tstep))
|
||||
dt = float(tstep.value)
|
||||
for p, k in zip(pumps, kw):
|
||||
kwh += k * dt / 3600
|
||||
cost += k * dt / 3600 * price[p] * net.pattern_value(ppat[p], now, ps0)
|
||||
if tstep.value <= 0:
|
||||
break
|
||||
finally:
|
||||
net.lib.EN_closeH(net.ph)
|
||||
short = np.maximum(0.0, (t_init - tank_tol * (t_max - t_min)) - level_end) if tanks else np.zeros(0)
|
||||
violation = max(0.0, min_p - pmin) + float(np.sum(short))
|
||||
return {"cost": cost, "kwh": kwh, "minPressure": float(pmin), "violation": violation, "tankEnd": level_end.tolist()}
|
||||
|
||||
base = evaluate(None)
|
||||
# Baseline schedule = what the existing controls did (pump on in each slot?).
|
||||
status = {p: np.zeros(n_slots) for p in pumps}
|
||||
r = net.simulate(node_props=(), link_props=(EN.FLOW,), nodes=[], links=pumps)
|
||||
for c, p in enumerate(pumps):
|
||||
for k, tt in enumerate(r.times):
|
||||
s = min(n_slots - 1, int(tt // slot))
|
||||
if abs(r.links[EN.FLOW][k, c]) > 1e-9:
|
||||
status[p][s] = 1
|
||||
# Take control of the pumps: drop their simple controls, drive them by a pattern.
|
||||
for k in sorted(controls, reverse=True):
|
||||
net.delete_control(k)
|
||||
for p in pumps:
|
||||
pid = f"SPSCH{p}"
|
||||
net.add_pattern(pid, [1.0] * n_slots)
|
||||
net.set_link(p, 15, float(_pattern_index(net, pid)))
|
||||
net.set_link(p, EN.INITSTATUS, 1)
|
||||
evals = {"n": 0}
|
||||
|
||||
def run(sched):
|
||||
evals["n"] += 1
|
||||
for p in pumps:
|
||||
net.add_pattern(f"SPSCH{p}", [float(v) for v in sched[p]])
|
||||
return evaluate(sched)
|
||||
|
||||
cur = {p: status[p].copy() for p in pumps}
|
||||
cur_res = run(cur)
|
||||
if cur_res["violation"] > 1e-6:
|
||||
# Pattern replay of the control schedule is not feasible (e.g. sub-slot switching): start from all on.
|
||||
cur = {p: np.ones(n_slots) for p in pumps}
|
||||
cur_res = run(cur)
|
||||
start_res = cur_res
|
||||
slot_price = {p: np.array([price[p] * net.pattern_value(ppat[p], k * slot, ps0) for k in range(n_slots)]) for p in pumps}
|
||||
|
||||
improved = True
|
||||
while improved and evals["n"] < budget:
|
||||
improved = False
|
||||
# 1) switch off the most expensive running slots
|
||||
cand = sorted(((slot_price[p][k], p, k) for p in pumps for k in range(n_slots) if cur[p][k] > 0), reverse=True)
|
||||
for _, p, k in cand:
|
||||
if evals["n"] >= budget:
|
||||
break
|
||||
trial = {q: cur[q].copy() for q in pumps}
|
||||
trial[p][k] = 0
|
||||
res = run(trial)
|
||||
if res["violation"] <= 1e-6 and res["cost"] < cur_res["cost"] - 1e-9:
|
||||
cur, cur_res, improved = trial, res, True
|
||||
# 2) move running time from expensive to cheap slots
|
||||
for p in pumps:
|
||||
on = [k for k in range(n_slots) if cur[p][k] > 0]
|
||||
off = [k for k in range(n_slots) if cur[p][k] == 0]
|
||||
for k_on in sorted(on, key=lambda k: -slot_price[p][k]):
|
||||
for k_off in sorted(off, key=lambda k: slot_price[p][k]):
|
||||
if evals["n"] >= budget or slot_price[p][k_off] >= slot_price[p][k_on]:
|
||||
break
|
||||
trial = {q: cur[q].copy() for q in pumps}
|
||||
trial[p][k_on], trial[p][k_off] = 0, 1
|
||||
res = run(trial)
|
||||
if res["violation"] <= 1e-6 and res["cost"] < cur_res["cost"] - 1e-9:
|
||||
cur, cur_res, improved = trial, res, True
|
||||
off.remove(k_off)
|
||||
off.append(k_on)
|
||||
break
|
||||
return {
|
||||
"pumps": [net.link_ids[p - 1] for p in pumps],
|
||||
"slotSeconds": slot,
|
||||
"schedules": {net.link_ids[p - 1]: [int(v) for v in cur[p]] for p in pumps},
|
||||
"before": {k: round(v, 3) if isinstance(v, float) else v for k, v in base.items()},
|
||||
"start": {k: round(v, 3) if isinstance(v, float) else v for k, v in start_res.items()},
|
||||
"after": {k: round(v, 3) if isinstance(v, float) else v for k, v in cur_res.items()},
|
||||
"priced": priced,
|
||||
"evaluations": evals["n"],
|
||||
"removedControls": len(controls),
|
||||
"rules": rules.value,
|
||||
"feasible": cur_res["violation"] <= 1e-6,
|
||||
}
|
||||
|
||||
|
||||
def _pattern_index(net: Network, pid: str) -> int:
|
||||
idx = ctypes.c_int()
|
||||
net.lib.EN_getpatternindex(net.ph, pid.encode(), ctypes.byref(idx))
|
||||
return idx.value
|
||||
|
||||
|
||||
JOBS = {
|
||||
"summary": job_summary,
|
||||
"fireflow": job_fireflow,
|
||||
"criticality": job_criticality,
|
||||
"servicearea": job_servicearea,
|
||||
"vsp": job_vsp,
|
||||
"energy": job_energy,
|
||||
"calibrate": job_calibrate,
|
||||
"pumpschedule": job_pumpschedule,
|
||||
}
|
||||
|
||||
|
||||
def get_job(name: str):
|
||||
fn = JOBS.get(name)
|
||||
if fn is None:
|
||||
raise BatchError(f"알 수 없는 분석입니다: {name}")
|
||||
return fn
|
||||
+743
@@ -0,0 +1,743 @@
|
||||
import asyncio
|
||||
import ctypes
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
|
||||
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
|
||||
from .batch import BatchError
|
||||
from .columnar import to_columnar
|
||||
from .gisio import read_vector
|
||||
from . import dem as dem_store
|
||||
from . import gisops
|
||||
from urllib.parse import quote
|
||||
from .jobs import get_job
|
||||
from .epanet import EN, EpanetAPI, EpanetException, FLOW_UNITS, PRESS_UNITS, QUAL_TYPES
|
||||
|
||||
logger = logging.getLogger("spipe")
|
||||
|
||||
# Limits (overridable per deployment). The upload limit must match nginx's
|
||||
# client_max_body_size; the period/cell caps bound CPU time and response size
|
||||
# for inputs like DURATION 10000 hrs + HYDRAULIC TIMESTEP 1 sec.
|
||||
MAX_UPLOAD_BYTES = int(float(os.environ.get("SPIPE_MAX_UPLOAD_MB", "20")) * 1024 * 1024)
|
||||
# DEM rasters are much larger than INP/GIS files; streamed to disk, never held in memory.
|
||||
MAX_DEM_BYTES = int(float(os.environ.get("SPIPE_MAX_DEM_MB", "300")) * 1024 * 1024)
|
||||
ANALYZE_TIMEOUT_S = float(os.environ.get("SPIPE_ANALYZE_TIMEOUT_S", "120"))
|
||||
MAX_PERIODS = int(os.environ.get("SPIPE_MAX_PERIODS", "10000"))
|
||||
MAX_RESULT_CELLS = int(os.environ.get("SPIPE_MAX_RESULT_CELLS", "50000000"))
|
||||
# Batch jobs (criticality, optimisation ...) solve the network many times.
|
||||
JOB_TIMEOUT_S = float(os.environ.get("SPIPE_JOB_TIMEOUT_S", "900"))
|
||||
WORKERS = int(os.environ.get("SPIPE_WORKERS", str(max(1, min(4, (os.cpu_count() or 2) - 1)))))
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
api = EpanetAPI()
|
||||
|
||||
# Analyses run in separate processes: the ctypes loop is CPU-bound and
|
||||
# synchronous, so running it on the event loop froze every other request
|
||||
# (health checks waited up to 16 s behind one large network).
|
||||
_pool = None
|
||||
|
||||
|
||||
def _get_pool() -> ProcessPoolExecutor:
|
||||
global _pool
|
||||
if _pool is None:
|
||||
_pool = ProcessPoolExecutor(max_workers=WORKERS)
|
||||
return _pool
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def reject_oversized_body(request: Request, call_next):
|
||||
# Starlette spools the whole multipart body before the handler runs, so
|
||||
# check the declared size first instead of after receiving it all.
|
||||
# scope["path"] is the routed path; request.url is rebuilt from the Host
|
||||
# header, so a malformed Host could make the path check miss (GHSA-86qp).
|
||||
length = request.headers.get("content-length")
|
||||
path = request.scope.get("path", "")
|
||||
if (path == "/api/analyze" or path.startswith(("/api/jobs/", "/api/gis/"))) and length and length.isdigit() and int(length) > MAX_UPLOAD_BYTES + 64 * 1024:
|
||||
return JSONResponse(status_code=413, content={"detail": f"File size exceeds {MAX_UPLOAD_BYTES // (1024 * 1024)} MiB limit"})
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
def run_job_bytes(name: str, contents: bytes, params: dict) -> dict:
|
||||
"""Process-pool entry point for /api/jobs/{name}; same envelope as analyze_bytes."""
|
||||
try:
|
||||
result = get_job(name)(contents, params)
|
||||
return {"json": json.dumps(result, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode("utf-8")}
|
||||
except BatchError as e:
|
||||
return {"error": (400, str(e))}
|
||||
except EpanetException as e:
|
||||
return {"error": (422, str(e))}
|
||||
except ValueError as e:
|
||||
return {"error": (500, f"Result contains non-finite values: {e}")}
|
||||
|
||||
|
||||
@app.post("/api/jobs/{name}")
|
||||
async def run_job(name: str, file: UploadFile = File(...), params: str = Form("{}")):
|
||||
"""Batch analysis over one INP. `params` is a JSON object (see backend/jobs.py)."""
|
||||
if not (file.filename or "").lower().endswith(".inp"):
|
||||
raise HTTPException(status_code=400, detail="File must be an .inp file")
|
||||
try:
|
||||
parsed = json.loads(params or "{}")
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="params must be a JSON object")
|
||||
contents = await file.read(MAX_UPLOAD_BYTES + 1)
|
||||
if len(contents) > MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(status_code=413, detail=f"File size exceeds {MAX_UPLOAD_BYTES // (1024 * 1024)} MiB limit")
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
outcome = await asyncio.wait_for(
|
||||
loop.run_in_executor(_get_pool(), run_job_bytes, name, contents, parsed),
|
||||
timeout=JOB_TIMEOUT_S,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
raise HTTPException(status_code=504, detail=f"Analysis exceeded {JOB_TIMEOUT_S:.0f} s time limit")
|
||||
if "error" in outcome:
|
||||
status, detail = outcome["error"]
|
||||
raise HTTPException(status_code=status, detail=detail)
|
||||
return Response(content=outcome["json"], media_type="application/json")
|
||||
|
||||
|
||||
def read_vector_bytes(contents: bytes, filename: str, layer: str | None, src_epsg: int | None, default_crs: str | None = None) -> dict:
|
||||
try:
|
||||
result = read_vector(contents, filename, layer, src_epsg, default_crs)
|
||||
return {"json": json.dumps(result, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode("utf-8")}
|
||||
except BatchError as e:
|
||||
return {"error": (400, str(e))}
|
||||
|
||||
|
||||
@app.post("/api/gis/vector")
|
||||
async def gis_vector(file: UploadFile = File(...), layer: str = Form(""), srcEpsg: str = Form(""), defaultCrs: str = Form("")):
|
||||
"""Any GDAL vector format (zipped SHP, DXF, DGN, MIF, GML, KML, GPKG, E00 ...) -> GeoJSON (WGS84)."""
|
||||
src_epsg = None
|
||||
if srcEpsg.strip():
|
||||
if not srcEpsg.strip().isdigit():
|
||||
raise HTTPException(status_code=400, detail="srcEpsg must be an EPSG number")
|
||||
src_epsg = int(srcEpsg)
|
||||
contents = await file.read(MAX_UPLOAD_BYTES + 1)
|
||||
if len(contents) > MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(status_code=413, detail=f"File size exceeds {MAX_UPLOAD_BYTES // (1024 * 1024)} MiB limit")
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
outcome = await asyncio.wait_for(
|
||||
loop.run_in_executor(_get_pool(), read_vector_bytes, contents, file.filename or "", layer or None, src_epsg, defaultCrs or None),
|
||||
timeout=ANALYZE_TIMEOUT_S,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
raise HTTPException(status_code=504, detail="GIS conversion timed out")
|
||||
if "error" in outcome:
|
||||
status, detail = outcome["error"]
|
||||
raise HTTPException(status_code=status, detail=detail)
|
||||
return Response(content=outcome["json"], media_type="application/json")
|
||||
|
||||
|
||||
def _dem_call(fn, *args):
|
||||
"""Run a DEM operation; BatchError -> 400 (pickle-safe tuple for the pool)."""
|
||||
try:
|
||||
return {"ok": fn(*args)}
|
||||
except dem_store.BatchError as e:
|
||||
return {"error": (400, str(e))}
|
||||
|
||||
|
||||
async def _dem_run(fn, *args, timeout: float = 300):
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
outcome = await asyncio.wait_for(loop.run_in_executor(None, _dem_call, fn, *args), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
raise HTTPException(status_code=504, detail="DEM processing timed out")
|
||||
if "error" in outcome:
|
||||
status, detail = outcome["error"]
|
||||
raise HTTPException(status_code=status, detail=detail)
|
||||
return outcome["ok"]
|
||||
|
||||
|
||||
@app.post("/api/dem")
|
||||
async def dem_upload(file: UploadFile = File(...)):
|
||||
"""Store a DEM raster (GeoTIFF / ASC / IMG, or a zip holding one) and return its metadata."""
|
||||
fd, tmp = tempfile.mkstemp(prefix="spipe_dem_")
|
||||
size = 0
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
while chunk := await file.read(1024 * 1024):
|
||||
size += len(chunk)
|
||||
if size > MAX_DEM_BYTES:
|
||||
raise HTTPException(status_code=413, detail=f"DEM exceeds {MAX_DEM_BYTES // (1024 * 1024)} MiB limit")
|
||||
f.write(chunk)
|
||||
return await _dem_run(dem_store.import_dem, tmp, file.filename or "dem.tif")
|
||||
finally:
|
||||
try:
|
||||
os.remove(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
@app.get("/api/dem/{dem_id}")
|
||||
async def dem_get(dem_id: str):
|
||||
return await _dem_run(dem_store.dem_info, dem_id)
|
||||
|
||||
|
||||
@app.get("/api/dem/{dem_id}/preview.png")
|
||||
async def dem_preview(dem_id: str):
|
||||
png = await _dem_run(dem_store.preview_png, dem_id)
|
||||
return Response(content=png, media_type="image/png", headers={"Cache-Control": "private, max-age=86400"})
|
||||
|
||||
|
||||
@app.get("/api/dem/{dem_id}/terrain/{z}/{x}/{y}.png")
|
||||
async def dem_terrain(dem_id: str, z: int, x: int, y: int):
|
||||
"""Terrarium-encoded terrain tiles for the 3D view."""
|
||||
png = await _dem_run(dem_store.terrain_tile, dem_id, z, x, y, timeout=60)
|
||||
return Response(content=png, media_type="image/png", headers={"Cache-Control": "private, max-age=86400"})
|
||||
|
||||
|
||||
@app.post("/api/dem/{dem_id}/sample")
|
||||
async def dem_sample(dem_id: str, request: Request):
|
||||
"""Body: {"points": [[x, y], ...], "crs": "<EPSG or proj4 of the points>"}."""
|
||||
body = await request.json()
|
||||
points = body.get("points") or []
|
||||
if len(points) > 500_000:
|
||||
raise HTTPException(status_code=400, detail="too many points")
|
||||
return {"values": await _dem_run(dem_store.sample, dem_id, points, body.get("crs"))}
|
||||
|
||||
|
||||
@app.post("/api/dem/{dem_id}/hydro")
|
||||
async def dem_hydro(dem_id: str, request: Request):
|
||||
"""Body: {"op": "streams", "minAreaKm2": 0.1} or {"op": "watershed", "points": [[x, y]], "crs": "...", "snap": m}."""
|
||||
body = await request.json()
|
||||
if body.get("op") == "streams":
|
||||
return await _dem_run(dem_store.streams, dem_id, float(body.get("minAreaKm2") or 0.1), timeout=600)
|
||||
if body.get("op") == "watershed":
|
||||
return await _dem_run(dem_store.watershed, dem_id, body.get("points") or [], body.get("crs"), float(body.get("snap") or 50), timeout=600)
|
||||
raise HTTPException(status_code=400, detail="op must be streams or watershed")
|
||||
|
||||
|
||||
@app.post("/api/dem/from-points")
|
||||
async def dem_from_points(request: Request):
|
||||
"""Body: {"points": [[x, y, z], ...], "crs": "...", "cell": metres, "name": "..."} -> new DEM."""
|
||||
body = await request.json()
|
||||
points = body.get("points") or []
|
||||
if len(points) > 2_000_000:
|
||||
raise HTTPException(status_code=400, detail="too many points")
|
||||
return await _dem_run(dem_store.from_points, points, body.get("crs"), float(body.get("cell") or 0), str(body.get("name") or "점 표고 DEM"), body.get("targetCrs"))
|
||||
|
||||
|
||||
@app.post("/api/gis/export")
|
||||
async def gis_export(request: Request):
|
||||
"""Body: {"featureCollection": {...WGS84}, "crs": "<target>", "format": "shp|gpkg|dxf|geojson|kml", "name": "..."}."""
|
||||
body = await request.json()
|
||||
data, filename, media = await _dem_run(gisops.write_vector, body.get("featureCollection"), body.get("crs"), str(body.get("format") or "geojson"), str(body.get("name") or "layer"))
|
||||
return Response(content=data, media_type=media, headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"})
|
||||
|
||||
|
||||
@app.post("/api/gis/op")
|
||||
async def gis_op(request: Request):
|
||||
"""Body: {"op": "buffer|dissolve|intersection|clip|difference|voronoi|grid", "crs": "<planar>", ...}."""
|
||||
body = await request.json()
|
||||
return await _dem_run(gisops.vector_op, str(body.get("op") or ""), body)
|
||||
|
||||
|
||||
@app.post("/api/gis/reproject")
|
||||
async def gis_reproject(request: Request):
|
||||
"""Body: {"featureCollection": {...}, "from": "<CRS it was read as>", "to": "<actual CRS>"}."""
|
||||
body = await request.json()
|
||||
return await _dem_run(gisops.reproject_fc, body.get("featureCollection"), body.get("from"), body.get("to"))
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
def analyze_bytes(contents: bytes, columnar: bool = False) -> dict:
|
||||
"""Process-pool entry point. Returns {"json": bytes} or {"error": (status, detail)}
|
||||
because HTTPException does not survive pickling back to the parent."""
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
inp_path = os.path.join(tmpdir, "network.inp")
|
||||
rpt_path = os.path.join(tmpdir, "network.rpt")
|
||||
out_path = os.path.join(tmpdir, "network.out")
|
||||
with open(inp_path, "wb") as f:
|
||||
f.write(contents)
|
||||
result = run_epanet(inp_path, rpt_path, out_path)
|
||||
if columnar:
|
||||
result = to_columnar(result)
|
||||
# Serialise here, in the worker: encoding a 10 MB result on the event
|
||||
# loop stalled other requests by ~0.5 s per response.
|
||||
body = json.dumps(result, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode("utf-8")
|
||||
return {"json": body}
|
||||
except HTTPException as e:
|
||||
return {"error": (e.status_code, e.detail)}
|
||||
except ValueError as e: # NaN/Inf in results
|
||||
return {"error": (500, f"Result contains non-finite values: {e}")}
|
||||
|
||||
|
||||
@app.post("/api/analyze")
|
||||
async def analyze(file: UploadFile = File(...), format: str = "object"):
|
||||
"""Full EPS analysis. ``format=columnar`` returns the compact packed time
|
||||
series (backend/columnar.py); the default keeps the per-element objects."""
|
||||
if not (file.filename or "").lower().endswith(".inp"):
|
||||
raise HTTPException(status_code=400, detail="File must be an .inp file")
|
||||
|
||||
contents = await file.read(MAX_UPLOAD_BYTES + 1)
|
||||
if len(contents) > MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(status_code=413, detail=f"File size exceeds {MAX_UPLOAD_BYTES // (1024 * 1024)} MiB limit")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
outcome = await asyncio.wait_for(
|
||||
loop.run_in_executor(_get_pool(), analyze_bytes, contents, format == "columnar"),
|
||||
timeout=ANALYZE_TIMEOUT_S,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
raise HTTPException(status_code=504, detail=f"Analysis exceeded {ANALYZE_TIMEOUT_S:.0f} s time limit")
|
||||
if "error" in outcome:
|
||||
status, detail = outcome["error"]
|
||||
raise HTTPException(status_code=status, detail=detail)
|
||||
return Response(content=outcome["json"], media_type="application/json")
|
||||
|
||||
|
||||
def decode_id(raw_bytes: bytes) -> str:
|
||||
try:
|
||||
return raw_bytes.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
try:
|
||||
return raw_bytes.decode('cp949')
|
||||
except UnicodeDecodeError:
|
||||
return raw_bytes.decode('latin1', errors='replace')
|
||||
|
||||
def read_report_errors(rpt_path: str, limit: int = 10) -> list:
|
||||
"""Return the 'Error NNN: ...' lines EPANET wrote to the report file."""
|
||||
if not os.path.exists(rpt_path):
|
||||
return []
|
||||
try:
|
||||
with open(rpt_path, "r", encoding="utf-8", errors="replace") as rf:
|
||||
lines = [ln.strip() for ln in rf if re.match(r"\s*Error \d+", ln)]
|
||||
except OSError:
|
||||
return []
|
||||
return lines[:limit]
|
||||
|
||||
def run_epanet(inp_path: str, rpt_path: str, out_path: str):
|
||||
ph = api.EN_Project()
|
||||
|
||||
# 1. Version check
|
||||
version = ctypes.c_int()
|
||||
api.lib.EN_getversion(ctypes.byref(version))
|
||||
if version.value != 20305:
|
||||
raise HTTPException(status_code=500, detail=f"Unsupported EPANET version: {version.value}, expected 20305")
|
||||
|
||||
ph_created = False
|
||||
ph_opened = False
|
||||
hydraulics_opened = False
|
||||
|
||||
try:
|
||||
api.lib.EN_createproject(ctypes.byref(ph))
|
||||
ph_created = True
|
||||
|
||||
# open -- a failed EN_open still leaves the input/report files open, so
|
||||
# EN_close must run in the finally block either way (EN_runproject does
|
||||
# the same); otherwise Windows can't delete the temp dir afterwards.
|
||||
ph_opened = True
|
||||
api.lib.EN_open(ph, inp_path.encode('utf-8'), rpt_path.encode('utf-8'), out_path.encode('utf-8'))
|
||||
|
||||
# get units
|
||||
flow_unit = ctypes.c_int()
|
||||
api.lib.EN_getflowunits(ph, ctypes.byref(flow_unit))
|
||||
f_unit_str = FLOW_UNITS.get(flow_unit.value, "Unknown")
|
||||
|
||||
press_unit = ctypes.c_double()
|
||||
api.lib.EN_getoption(ph, EN.PRESS_UNITS, ctypes.byref(press_unit))
|
||||
p_unit_str = PRESS_UNITS.get(int(press_unit.value), "Unknown")
|
||||
|
||||
# get counts
|
||||
node_count = ctypes.c_int()
|
||||
api.lib.EN_getcount(ph, EN.NODECOUNT, ctypes.byref(node_count))
|
||||
|
||||
link_count = ctypes.c_int()
|
||||
api.lib.EN_getcount(ph, EN.LINKCOUNT, ctypes.byref(link_count))
|
||||
|
||||
# Reject resource-exhausting inputs before solving (a few-KB INP with
|
||||
# DURATION 10000 hrs / HYDRAULIC TIMESTEP 1 sec asks for 36M periods).
|
||||
duration, hstep = ctypes.c_long(), ctypes.c_long()
|
||||
api.lib.EN_gettimeparam(ph, EN.DURATION, ctypes.byref(duration))
|
||||
api.lib.EN_gettimeparam(ph, EN.HYDSTEP, ctypes.byref(hstep))
|
||||
report_step, report_start = ctypes.c_long(), ctypes.c_long()
|
||||
api.lib.EN_gettimeparam(ph, EN.REPORTSTEP, ctypes.byref(report_step))
|
||||
api.lib.EN_gettimeparam(ph, EN.REPORTSTART, ctypes.byref(report_start))
|
||||
planned_periods = duration.value // max(1, hstep.value) + 1
|
||||
planned_cells = planned_periods * (node_count.value + link_count.value)
|
||||
if planned_periods > MAX_PERIODS or planned_cells > MAX_RESULT_CELLS:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=(
|
||||
f"Simulation too large: {planned_periods} hydraulic periods x "
|
||||
f"{node_count.value + link_count.value} elements "
|
||||
f"(limits {MAX_PERIODS} periods, {MAX_RESULT_CELLS} result values). "
|
||||
"Increase HYDRAULIC TIMESTEP or shorten DURATION."
|
||||
),
|
||||
)
|
||||
|
||||
# extract node metadata
|
||||
nodes = {}
|
||||
node_id_map = {}
|
||||
for i in range(1, node_count.value + 1):
|
||||
node_id = ctypes.create_string_buffer(256)
|
||||
api.lib.EN_getnodeid(ph, i, node_id)
|
||||
nid = decode_id(node_id.value)
|
||||
node_id_map[i] = nid
|
||||
|
||||
node_type = ctypes.c_int()
|
||||
api.lib.EN_getnodetype(ph, i, ctypes.byref(node_type))
|
||||
ntype = node_type.value
|
||||
|
||||
x, y = ctypes.c_double(), ctypes.c_double()
|
||||
coords = None
|
||||
try:
|
||||
api.lib.EN_getcoord(ph, i, ctypes.byref(x), ctypes.byref(y))
|
||||
coords = {"x": x.value, "y": y.value}
|
||||
except EpanetException:
|
||||
pass
|
||||
|
||||
nodes[nid] = {
|
||||
"id": nid,
|
||||
"type": ntype,
|
||||
"coordinates": coords,
|
||||
}
|
||||
|
||||
# extract link metadata
|
||||
links = {}
|
||||
link_id_map = {}
|
||||
for i in range(1, link_count.value + 1):
|
||||
link_id = ctypes.create_string_buffer(256)
|
||||
api.lib.EN_getlinkid(ph, i, link_id)
|
||||
lid = decode_id(link_id.value)
|
||||
link_id_map[i] = lid
|
||||
|
||||
link_type = ctypes.c_int()
|
||||
api.lib.EN_getlinktype(ph, i, ctypes.byref(link_type))
|
||||
ltype = link_type.value
|
||||
|
||||
n1, n2 = ctypes.c_int(), ctypes.c_int()
|
||||
api.lib.EN_getlinknodes(ph, i, ctypes.byref(n1), ctypes.byref(n2))
|
||||
|
||||
n1_id = ctypes.create_string_buffer(256)
|
||||
api.lib.EN_getnodeid(ph, n1.value, n1_id)
|
||||
|
||||
n2_id = ctypes.create_string_buffer(256)
|
||||
api.lib.EN_getnodeid(ph, n2.value, n2_id)
|
||||
|
||||
v_count = ctypes.c_int()
|
||||
api.lib.EN_getvertexcount(ph, i, ctypes.byref(v_count))
|
||||
|
||||
vertices = []
|
||||
for j in range(1, v_count.value + 1):
|
||||
vx, vy = ctypes.c_double(), ctypes.c_double()
|
||||
api.lib.EN_getvertex(ph, i, j, ctypes.byref(vx), ctypes.byref(vy))
|
||||
vertices.append({"x": vx.value, "y": vy.value})
|
||||
|
||||
links[lid] = {
|
||||
"id": lid,
|
||||
"type": ltype,
|
||||
"fromNode": decode_id(n1_id.value),
|
||||
"toNode": decode_id(n2_id.value),
|
||||
"vertices": vertices,
|
||||
}
|
||||
|
||||
# 3. Open hydraulics with EN_SAVE=1 (Save to hyd file so water quality can use it)
|
||||
api.lib.EN_openH(ph)
|
||||
hydraulics_opened = True
|
||||
api.lib.EN_initH(ph, EN.SAVE)
|
||||
|
||||
time_steps = []
|
||||
node_series = {nid: {"demand": [], "head": [], "pressure": [], "quality": []} for nid in nodes}
|
||||
link_series = {lid: {"flow": [], "velocity": [], "headloss": [], "quality": [], "energy": [], "efficiency": []} for lid in links}
|
||||
|
||||
t = ctypes.c_long()
|
||||
tstep = ctypes.c_long(1)
|
||||
|
||||
engine_warnings = []
|
||||
while True:
|
||||
# Warning codes 1-6 (e.g. 1 unbalanced, 6 negative pressures) are
|
||||
# returned here, not raised; they only reach the report file when
|
||||
# [REPORT] MESSAGES is on, so collect them directly.
|
||||
code = api.lib.EN_runH(ph, ctypes.byref(t))
|
||||
if 0 < code <= 10:
|
||||
engine_warnings.append({"time": t.value, "code": code})
|
||||
time_steps.append(t.value)
|
||||
# Control/tank events add steps beyond DURATION/HYDSTEP; keep the cap hard.
|
||||
if len(time_steps) > MAX_PERIODS * 2:
|
||||
raise HTTPException(status_code=413, detail=f"Simulation produced more than {MAX_PERIODS * 2} hydraulic periods")
|
||||
|
||||
for i in range(1, node_count.value + 1):
|
||||
nid = node_id_map[i]
|
||||
d, h, p = ctypes.c_double(), ctypes.c_double(), ctypes.c_double()
|
||||
api.lib.EN_getnodevalue(ph, i, EN.DEMAND, ctypes.byref(d))
|
||||
api.lib.EN_getnodevalue(ph, i, EN.HEAD, ctypes.byref(h))
|
||||
api.lib.EN_getnodevalue(ph, i, EN.PRESSURE, ctypes.byref(p))
|
||||
node_series[nid]["demand"].append(d.value)
|
||||
node_series[nid]["head"].append(h.value)
|
||||
node_series[nid]["pressure"].append(p.value)
|
||||
|
||||
for i in range(1, link_count.value + 1):
|
||||
lid = link_id_map[i]
|
||||
f, v, hl = ctypes.c_double(), ctypes.c_double(), ctypes.c_double()
|
||||
api.lib.EN_getlinkvalue(ph, i, EN.FLOW, ctypes.byref(f))
|
||||
api.lib.EN_getlinkvalue(ph, i, EN.VELOCITY, ctypes.byref(v))
|
||||
api.lib.EN_getlinkvalue(ph, i, EN.HEADLOSS, ctypes.byref(hl))
|
||||
link_series[lid]["flow"].append(f.value)
|
||||
link_series[lid]["velocity"].append(v.value)
|
||||
link_series[lid]["headloss"].append(hl.value)
|
||||
|
||||
if links[lid]["type"] == EN.PUMP:
|
||||
e_val, ef_val = ctypes.c_double(), ctypes.c_double()
|
||||
api.lib.EN_getlinkvalue(ph, i, EN.ENERGY, ctypes.byref(e_val))
|
||||
api.lib.EN_getlinkvalue(ph, i, EN.PUMP_EFFIC, ctypes.byref(ef_val))
|
||||
link_series[lid]["energy"].append(round(e_val.value, 2))
|
||||
# EN_PUMP_EFFIC is a decimal ratio (0.75 for 75%), convert to percent
|
||||
link_series[lid]["efficiency"].append(round(ef_val.value * 100, 1))
|
||||
api.lib.EN_nextH(ph, ctypes.byref(tstep))
|
||||
if tstep.value <= 0:
|
||||
break
|
||||
|
||||
# Close hydraulics before running quality
|
||||
api.lib.EN_closeH(ph)
|
||||
hydraulics_opened = False
|
||||
|
||||
# 4. Check and run Water Quality Simulation (Chlorine/Fluoride decay, Water Age, Chemical Tracer)
|
||||
qual_type = ctypes.c_int(0)
|
||||
trace_node = ctypes.c_int(0)
|
||||
chem_name = ctypes.create_string_buffer(256)
|
||||
chem_units = ctypes.create_string_buffer(256)
|
||||
quality_info = None
|
||||
quality_failed = False
|
||||
quality_error_message = None
|
||||
try:
|
||||
api.lib.EN_getqualinfo(ph, ctypes.byref(qual_type), chem_name, chem_units, ctypes.byref(trace_node))
|
||||
q_val = qual_type.value
|
||||
if q_val > 0:
|
||||
quality_info = {
|
||||
"qualType": q_val,
|
||||
"typeName": QUAL_TYPES.get(q_val, "CHEMICAL"),
|
||||
"chemName": chem_name.value.decode("utf-8", errors="ignore").strip() or ("Age" if q_val == 2 else "Quality"),
|
||||
"chemUnits": chem_units.value.decode("utf-8", errors="ignore").strip() or ("hours" if q_val == 2 else "mg/L"),
|
||||
"traceNode": node_id_map.get(trace_node.value, "") if trace_node.value > 0 else "",
|
||||
"status": "success",
|
||||
}
|
||||
except Exception as e:
|
||||
quality_failed = True
|
||||
quality_error_message = str(e)
|
||||
|
||||
if quality_info and quality_info["qualType"] > 0:
|
||||
quality_opened = False
|
||||
try:
|
||||
api.lib.EN_openQ(ph)
|
||||
quality_opened = True
|
||||
api.lib.EN_initQ(ph, 0)
|
||||
tQ = ctypes.c_long()
|
||||
tstepQ = ctypes.c_long(1)
|
||||
|
||||
while True:
|
||||
api.lib.EN_runQ(ph, ctypes.byref(tQ))
|
||||
for i in range(1, node_count.value + 1):
|
||||
nid = node_id_map[i]
|
||||
qv = ctypes.c_double()
|
||||
api.lib.EN_getnodevalue(ph, i, EN.QUALITY, ctypes.byref(qv))
|
||||
node_series[nid]["quality"].append(round(qv.value, 4))
|
||||
|
||||
for i in range(1, link_count.value + 1):
|
||||
lid = link_id_map[i]
|
||||
lqv = ctypes.c_double()
|
||||
api.lib.EN_getlinkvalue(ph, i, EN.LINKQUAL, ctypes.byref(lqv))
|
||||
link_series[lid]["quality"].append(round(lqv.value, 4))
|
||||
|
||||
api.lib.EN_nextQ(ph, ctypes.byref(tstepQ))
|
||||
if tstepQ.value <= 0:
|
||||
break
|
||||
except Exception as e:
|
||||
quality_failed = True
|
||||
quality_error_message = str(e)
|
||||
quality_info["status"] = "failed"
|
||||
quality_info["errorMessage"] = quality_error_message
|
||||
finally:
|
||||
if quality_opened:
|
||||
try:
|
||||
api.lib.EN_closeQ(ph)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Assign initial values and full timeSeries
|
||||
num_steps = len(time_steps)
|
||||
has_valid_quality = (
|
||||
quality_info is not None
|
||||
and quality_info.get("status") == "success"
|
||||
and not quality_failed
|
||||
)
|
||||
|
||||
for nid in nodes:
|
||||
if not has_valid_quality or len(node_series[nid]["quality"]) != num_steps:
|
||||
node_series[nid]["quality"] = []
|
||||
nodes[nid]["demand"] = node_series[nid]["demand"][0] if node_series[nid]["demand"] else 0.0
|
||||
nodes[nid]["head"] = node_series[nid]["head"][0] if node_series[nid]["head"] else 0.0
|
||||
nodes[nid]["pressure"] = node_series[nid]["pressure"][0] if node_series[nid]["pressure"] else 0.0
|
||||
nodes[nid]["quality"] = node_series[nid]["quality"][0] if has_valid_quality and node_series[nid]["quality"] else None
|
||||
nodes[nid]["timeSeries"] = node_series[nid]
|
||||
|
||||
for lid in links:
|
||||
if not has_valid_quality or len(link_series[lid]["quality"]) != num_steps:
|
||||
link_series[lid]["quality"] = []
|
||||
links[lid]["flow"] = link_series[lid]["flow"][0] if link_series[lid]["flow"] else 0.0
|
||||
links[lid]["velocity"] = link_series[lid]["velocity"][0] if link_series[lid]["velocity"] else 0.0
|
||||
links[lid]["headloss"] = link_series[lid]["headloss"][0] if link_series[lid]["headloss"] else 0.0
|
||||
links[lid]["quality"] = link_series[lid]["quality"][0] if has_valid_quality and link_series[lid]["quality"] else None
|
||||
links[lid]["timeSeries"] = link_series[lid]
|
||||
|
||||
# 5. Compute Pump Energy Metrics (kWh, peak kW, average efficiency, utilization %)
|
||||
pump_lids = [lid for lid, link in links.items() if link["type"] == EN.PUMP]
|
||||
pump_energy_map = {}
|
||||
total_duration_sec = (time_steps[-1] - time_steps[0]) if len(time_steps) > 1 else 0
|
||||
|
||||
for lid in pump_lids:
|
||||
link = links[lid]
|
||||
energies = link_series[lid].get("energy", [])
|
||||
effics = link_series[lid].get("efficiency", [])
|
||||
active_effics = [ef for ef in effics if ef > 0]
|
||||
|
||||
# dt-weighted kWh integration (excluding terminal reading which has no following duration)
|
||||
pump_kwh = 0.0
|
||||
pump_active_sec = 0.0
|
||||
if len(time_steps) > 1:
|
||||
for i in range(len(time_steps) - 1):
|
||||
dt_hours = max(0.0, float(time_steps[i + 1] - time_steps[i])) / 3600.0
|
||||
kw = energies[i] if i < len(energies) else 0.0
|
||||
pump_kwh += kw * dt_hours
|
||||
if kw > 0:
|
||||
pump_active_sec += max(0.0, float(time_steps[i + 1] - time_steps[i]))
|
||||
elif energies:
|
||||
pump_kwh = float(energies[0])
|
||||
if energies[0] > 0:
|
||||
pump_active_sec = 3600.0
|
||||
|
||||
total_kwh = round(pump_kwh, 2)
|
||||
peak_kw = round(max(energies, default=0.0), 2)
|
||||
# Time-weighted (hydraulic steps are not uniform: control/tank events
|
||||
# insert short steps), i.e. kWh over hours actually running.
|
||||
avg_kw = round(pump_kwh / (pump_active_sec / 3600.0), 2) if pump_active_sec > 0 else 0.0
|
||||
avg_effic = round(sum(active_effics) / len(active_effics), 1) if active_effics else 0.0
|
||||
utilization = round((pump_active_sec / total_duration_sec) * 100.0, 1) if total_duration_sec > 0 else (100.0 if energies and energies[0] > 0 else 0.0)
|
||||
|
||||
link["energy"] = energies[0] if energies else 0.0
|
||||
link["efficiency"] = effics[0] if effics else 0.0
|
||||
link["totalKwh"] = total_kwh
|
||||
link["peakKw"] = peak_kw
|
||||
link["avgKw"] = avg_kw
|
||||
link["utilization"] = utilization
|
||||
|
||||
pump_energy_map[lid] = {
|
||||
"id": lid,
|
||||
"fromNode": link["fromNode"],
|
||||
"toNode": link["toNode"],
|
||||
"totalKwh": total_kwh,
|
||||
"peakKw": peak_kw,
|
||||
"avgKw": avg_kw,
|
||||
"avgEfficiency": avg_effic,
|
||||
"utilization": utilization,
|
||||
}
|
||||
|
||||
# System peak kW is the maximum over all time steps of the simultaneous sum of pump kWs
|
||||
system_peak_kw = 0.0
|
||||
if pump_lids and time_steps:
|
||||
for i in range(len(time_steps)):
|
||||
step_sum = sum(link_series[lid]["energy"][i] for lid in pump_lids if i < len(link_series[lid]["energy"]))
|
||||
if step_sum > system_peak_kw:
|
||||
system_peak_kw = step_sum
|
||||
|
||||
energy_summary = {
|
||||
"pumps": pump_energy_map,
|
||||
"totalKwh": round(sum(p["totalKwh"] for p in pump_energy_map.values()), 2),
|
||||
"peakKw": round(system_peak_kw, 2),
|
||||
} if pump_energy_map else None
|
||||
|
||||
if ph_opened:
|
||||
try:
|
||||
api.lib.EN_close(ph)
|
||||
ph_opened = False
|
||||
except Exception:
|
||||
pass
|
||||
report_text = ""
|
||||
if os.path.exists(rpt_path):
|
||||
try:
|
||||
with open(rpt_path, "r", encoding="utf-8", errors="ignore") as rf:
|
||||
report_text = rf.read()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"engine": {
|
||||
"runtime": "native",
|
||||
"version": "2.3.5"
|
||||
},
|
||||
"timeSeconds": time_steps[0] if time_steps else 0,
|
||||
"timeSteps": time_steps,
|
||||
"units": {
|
||||
"flow": f_unit_str,
|
||||
"pressure": p_unit_str
|
||||
},
|
||||
"nodes": nodes,
|
||||
"links": links,
|
||||
"reportText": report_text,
|
||||
"qualityInfo": quality_info,
|
||||
"energySummary": energy_summary,
|
||||
"engineWarnings": engine_warnings,
|
||||
"reportStart": report_start.value,
|
||||
"reportStep": report_step.value,
|
||||
}
|
||||
except EpanetException as e:
|
||||
# The report file usually names the root cause (e.g. "Error 227: invalid
|
||||
# head curve") that the API code alone (e.g. 110) hides. Close first so
|
||||
# the report is flushed and readable.
|
||||
if hydraulics_opened:
|
||||
try:
|
||||
api.lib.EN_closeH(ph)
|
||||
except Exception:
|
||||
pass
|
||||
hydraulics_opened = False
|
||||
if ph_opened:
|
||||
try:
|
||||
api.lib.EN_close(ph)
|
||||
except Exception:
|
||||
pass
|
||||
ph_opened = False
|
||||
detail = str(e)
|
||||
causes = read_report_errors(rpt_path)
|
||||
if causes:
|
||||
detail += " | " + " / ".join(causes)
|
||||
raise HTTPException(status_code=500, detail=detail)
|
||||
finally:
|
||||
if hydraulics_opened:
|
||||
try:
|
||||
api.lib.EN_closeH(ph)
|
||||
except Exception:
|
||||
pass
|
||||
if ph_opened:
|
||||
try:
|
||||
api.lib.EN_close(ph)
|
||||
except Exception:
|
||||
pass
|
||||
if ph_created:
|
||||
try:
|
||||
api.lib.EN_deleteproject(ph)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,12 @@
|
||||
# API server (also used by deploy/Dockerfile). Keep pins in this one file.
|
||||
fastapi==0.143.1
|
||||
starlette==1.8.0
|
||||
python-multipart==0.0.32
|
||||
uvicorn==0.54.0
|
||||
numpy==2.5.3
|
||||
# GIS / raster (GDAL is bundled in these wheels)
|
||||
pyogrio==0.13.0
|
||||
rasterio==1.5.2
|
||||
pyproj==3.8.0
|
||||
shapely==2.2.0
|
||||
scipy==1.18.1
|
||||
@@ -0,0 +1,8 @@
|
||||
-r requirements-api.txt
|
||||
|
||||
# blocksys (상수도 블록시스템 자동 분할 도구)
|
||||
wntr==1.5.0
|
||||
networkx==3.6.1
|
||||
scikit-learn==1.9.0
|
||||
pandas==3.0.5
|
||||
pyyaml==6.0.3
|
||||
Whitespace-only changes.
@@ -0,0 +1,155 @@
|
||||
"""HTTP-layer tests for backend/main.py (FastAPI TestClient + real EPANET DLL).
|
||||
|
||||
Run from the repo root: .venv/Scripts/python -m pytest backend/tests -q
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend import main
|
||||
from backend.main import app
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
PUBLIC = REPO / "public"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
with TestClient(app, raise_server_exceptions=False) as c:
|
||||
yield c
|
||||
|
||||
|
||||
def post(client, name: str, data: bytes):
|
||||
return client.post("/api/analyze", files={"file": (name, data, "text/plain")})
|
||||
|
||||
|
||||
SIMPLE = b"""[JUNCTIONS]
|
||||
J1 10 5
|
||||
J2 12 3
|
||||
[RESERVOIRS]
|
||||
R1 50
|
||||
[PIPES]
|
||||
P1 R1 J1 1000 200 100
|
||||
P2 J1 J2 1000 150 100
|
||||
[OPTIONS]
|
||||
Units LPS
|
||||
[END]
|
||||
"""
|
||||
|
||||
|
||||
def test_health(client):
|
||||
r = client.get("/api/health")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"status": "ok"}
|
||||
|
||||
|
||||
def test_net1_runs(client):
|
||||
r = post(client, "Net1.inp", (PUBLIC / "Net1.inp").read_bytes())
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert len(body["nodes"]) == 11 and len(body["links"]) == 13
|
||||
assert body["units"] == {"flow": "GPM", "pressure": "psi"}
|
||||
assert body["reportStep"] > 0
|
||||
assert body["engineWarnings"] == []
|
||||
|
||||
|
||||
def test_wrong_extension_rejected(client):
|
||||
assert post(client, "a.txt", SIMPLE).status_code == 400
|
||||
|
||||
|
||||
def test_oversized_upload_rejected(client):
|
||||
r = post(client, "big.inp", b"x" * (main.MAX_UPLOAD_BYTES + 1))
|
||||
assert r.status_code == 413
|
||||
|
||||
|
||||
def test_early_size_check_not_bypassed_by_bad_host(client):
|
||||
# request.url is rebuilt from Host; the early 413 must not depend on it.
|
||||
big = str(main.MAX_UPLOAD_BYTES + 10 * 1024 * 1024)
|
||||
for host in ("localhost", "bad host:abc", "evil.example/../x"):
|
||||
r = client.post("/api/analyze", headers={"content-length": big, "host": host}, content=b"")
|
||||
assert r.status_code == 413, (host, r.status_code)
|
||||
|
||||
|
||||
def test_invalid_inp_returns_json_with_root_cause(client):
|
||||
# Previously: EN_open failure leaked the .rpt handle, cleanup raised
|
||||
# PermissionError on Windows, and the client got plain-text 500.
|
||||
r = post(client, "bad.inp", b"[JUNCTIONS]\n J1 abc\n[END]\n")
|
||||
assert r.status_code == 500
|
||||
assert r.headers["content-type"].startswith("application/json")
|
||||
detail = r.json()["detail"]
|
||||
assert "Error 200" in detail
|
||||
assert "Error 202" in detail # root cause pulled from the report file
|
||||
|
||||
|
||||
def test_warning_codes_collected_even_with_messages_no(client):
|
||||
# J1 sits above the reservoir head -> negative pressure (warning 6).
|
||||
inp = SIMPLE.replace(b" J1 10 5", b" J1 100 5").replace(
|
||||
b"[OPTIONS]", b"[REPORT]\n Messages No\n[OPTIONS]"
|
||||
)
|
||||
r = post(client, "neg.inp", inp)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert "WARNING" not in body["reportText"]
|
||||
assert any(w["code"] == 6 for w in body["engineWarnings"])
|
||||
|
||||
|
||||
def test_step_amplification_rejected_fast(client):
|
||||
inp = SIMPLE.replace(
|
||||
b"[OPTIONS]", b"[TIMES]\n Duration 10000:00\n Hydraulic Timestep 0:00:01\n[OPTIONS]"
|
||||
)
|
||||
r = post(client, "dos.inp", inp)
|
||||
assert r.status_code == 413
|
||||
assert "Simulation too large" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_columnar_format_carries_identical_values(client):
|
||||
import base64
|
||||
from array import array
|
||||
|
||||
data = (PUBLIC / "Net1.inp").read_bytes() # pump + chlorine: every series kind
|
||||
obj = client.post("/api/analyze", files={"file": ("Net1.inp", data, "text/plain")}).json()
|
||||
col = client.post("/api/analyze?format=columnar", files={"file": ("Net1.inp", data, "text/plain")}).json()
|
||||
assert col["format"] == "spipe-columnar-1"
|
||||
steps = len(obj["timeSteps"])
|
||||
for side, keys in (("nodes", ("demand", "head", "pressure", "quality")), ("links", ("flow", "velocity", "headloss", "quality"))):
|
||||
ids = col[side]["ids"]
|
||||
assert ids == list(obj[side])
|
||||
for key in keys:
|
||||
flat = array("d", base64.b64decode(col[side]["series"][key]))
|
||||
for i, element_id in enumerate(ids):
|
||||
assert list(flat[i * steps:(i + 1) * steps]) == obj[side][element_id]["timeSeries"][key]
|
||||
pump = col["links"]["pumps"]["9"]
|
||||
assert pump["energySeries"] == obj["links"]["9"]["timeSeries"]["energy"]
|
||||
assert pump["totalKwh"] == obj["links"]["9"]["totalKwh"]
|
||||
|
||||
|
||||
def test_overlong_utf8_id_is_engine_error(client):
|
||||
# 12 Korean chars = 36 UTF-8 bytes > EPANET MAXID (31 bytes) -> Error 252.
|
||||
long_id = "상리배수지유출관로제일번".encode("utf-8")
|
||||
inp = SIMPLE.replace(b" J2 12 3", b" " + long_id + b" 12 3").replace(b"J1 J2", b"J1 " + long_id)
|
||||
r = post(client, "longid.inp", inp)
|
||||
assert r.status_code == 500
|
||||
assert "252" in r.json()["detail"]
|
||||
|
||||
|
||||
NET1 = (PUBLIC / "Net1.inp").read_bytes()
|
||||
|
||||
|
||||
def post_job(client, name, inp=SIMPLE, params="{}"):
|
||||
return client.post(f"/api/jobs/{name}", files={"file": ("n.inp", inp, "text/plain")}, data={"params": params})
|
||||
|
||||
|
||||
def test_job_summary_runs(client):
|
||||
r = post_job(client, "summary", NET1)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["junctions"] == 9
|
||||
assert len(body["times"]) == len(body["minPressure"]) == 25
|
||||
assert all(lo <= hi for lo, hi in zip(body["minPressure"], body["maxPressure"]))
|
||||
|
||||
|
||||
def test_job_unknown_and_bad_params(client):
|
||||
assert post_job(client, "nope").status_code == 400
|
||||
assert post_job(client, "summary", params="[1]").status_code == 400
|
||||
assert post_job(client, "summary", params="not json").status_code == 400
|
||||
@@ -0,0 +1,87 @@
|
||||
"""backend/batch.py against the real EPANET DLL."""
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from backend.batch import Network, parse_time
|
||||
from backend.epanet import EN
|
||||
|
||||
NET1 = (Path(__file__).resolve().parents[2] / "public" / "Net1.inp").read_bytes()
|
||||
|
||||
|
||||
def test_open_lookup_and_baseline():
|
||||
with Network(NET1) as net:
|
||||
assert net.node_count == 11 and net.link_count == 13
|
||||
assert net.node_ids[net.node_index("22") - 1] == "22"
|
||||
res = net.simulate(node_props=(EN.PRESSURE,), link_props=(EN.FLOW,))
|
||||
assert len(res.times) == 25 and res.times[0] == 0 and res.times[-1] == 24 * 3600
|
||||
assert res.nodes[EN.PRESSURE].shape == (25, 11)
|
||||
assert res.links[EN.FLOW].shape == (25, 13)
|
||||
|
||||
|
||||
def test_windowed_extra_demand_only_acts_in_window():
|
||||
with Network(NET1) as net:
|
||||
j = net.node_index("22")
|
||||
base = net.simulate(nodes=[j]).nodes[EN.PRESSURE][:, 0]
|
||||
net.window_pattern("FIREWIN", 6 * 3600, 9 * 3600)
|
||||
cat = net.add_demand(j, 500.0, "FIREWIN", "fire")
|
||||
fire = net.simulate(nodes=[j]).nodes[EN.PRESSURE][:, 0]
|
||||
hours = np.arange(25)
|
||||
inside = (hours >= 6) & (hours < 9)
|
||||
assert np.all(fire[inside] < base[inside] - 1.0)
|
||||
# Tank levels carry the draw-down a little past the window; before it, nothing changes.
|
||||
assert np.allclose(fire[hours < 6], base[hours < 6], atol=1e-6)
|
||||
net.delete_demand(j, cat)
|
||||
again = net.simulate(nodes=[j]).nodes[EN.PRESSURE][:, 0]
|
||||
assert np.allclose(again, base, atol=1e-6)
|
||||
|
||||
|
||||
def test_timer_control_closes_pipe_in_window():
|
||||
with Network(NET1) as net:
|
||||
p = net.link_index("12")
|
||||
c1 = net.add_timer_control(p, 0, 3 * 3600)
|
||||
c2 = net.add_timer_control(p, 1, 5 * 3600)
|
||||
flow = net.simulate(node_props=(), link_props=(EN.FLOW,), links=[p]).links[EN.FLOW][:, 0]
|
||||
assert abs(flow[3]) < 1e-6 and abs(flow[4]) < 1e-6
|
||||
assert abs(flow[2]) > 1e-3 and abs(flow[6]) > 1e-3
|
||||
net.delete_control(c2)
|
||||
net.delete_control(c1)
|
||||
|
||||
|
||||
def test_parse_time():
|
||||
assert parse_time("6:30") == 6 * 3600 + 1800
|
||||
assert parse_time(2.5) == 9000
|
||||
assert parse_time(None, 7) == 7
|
||||
|
||||
|
||||
def test_fireflow_job_and_available_flow():
|
||||
from backend.jobs import job_fireflow
|
||||
|
||||
r = job_fireflow(NET1, {"nodes": ["22"], "flow": 500, "start": 6, "end": 9, "residual": 20, "threshold": 50})
|
||||
assert r["windowLabel"] == "6:00~9:00"
|
||||
h = r["hydrants"][0]
|
||||
assert h["firePressure"] < h["basePressure"] - 1
|
||||
assert r["nodes"][0]["diff"] <= r["nodes"][-1]["diff"] # most affected first
|
||||
# available flow keeps the hydrant at (just above) the residual pressure
|
||||
q = h["available"]
|
||||
assert q > 0
|
||||
with Network(NET1) as net:
|
||||
j = net.node_index("22")
|
||||
net.window_pattern("W", 6 * 3600, 9 * 3600)
|
||||
c = net.add_demand(j, q, "W", "x")
|
||||
p = net.simulate(nodes=[j]).nodes[EN.PRESSURE][6:9, 0].min()
|
||||
assert p >= 20 - 1e-6
|
||||
net.delete_demand(j, c)
|
||||
net.add_demand(j, q * 1.02, "W", "x")
|
||||
assert net.simulate(nodes=[j]).nodes[EN.PRESSURE][6:9, 0].min() < 20 + 0.5
|
||||
|
||||
|
||||
def test_fireflow_rejects_bad_input():
|
||||
import pytest
|
||||
from backend.batch import BatchError
|
||||
from backend.jobs import job_fireflow
|
||||
|
||||
with pytest.raises(BatchError):
|
||||
job_fireflow(NET1, {"nodes": [], "flow": 10})
|
||||
with pytest.raises(BatchError):
|
||||
job_fireflow(NET1, {"nodes": ["NOPE"], "flow": 10})
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Automatic calibration (roughness / wall coefficient) and pump scheduling jobs, on Net1 with synthetic truth."""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.batch import BatchError, Network
|
||||
from backend.epanet import EN
|
||||
from backend.jobs import job_calibrate, job_pumpschedule
|
||||
|
||||
NET1 = (Path(__file__).resolve().parents[2] / "public" / "Net1.inp").read_text()
|
||||
PIPES = [l.split()[0] for l in re.search(r"\[PIPES\](.*?)\[", NET1, re.S).group(1).splitlines() if l.strip() and not l.strip().startswith(";")]
|
||||
|
||||
|
||||
def synthetic(factor=None, wall=None, prop=EN.PRESSURE, locs=("22", "31", "12", "23"), groups=None):
|
||||
with Network(NET1.encode()) as n:
|
||||
idx = [n.node_index(l) for l in locs]
|
||||
for pid in PIPES:
|
||||
li = n.link_index(pid)
|
||||
f = factor(pid) if callable(factor) else factor
|
||||
if f:
|
||||
n.set_link(li, EN.ROUGHNESS, n.get_link(li, EN.ROUGHNESS) * f)
|
||||
if wall is not None:
|
||||
n.set_link(li, EN.KWALL, wall)
|
||||
if prop == EN.QUALITY:
|
||||
n.lib.EN_solveH(n.ph)
|
||||
times, mat = n.run_quality(nodes=idx)
|
||||
else:
|
||||
r = n.simulate(node_props=(prop,), nodes=idx)
|
||||
times, mat = r.times, r.nodes[prop]
|
||||
return [{"location": l, "time": t, "value": float(mat[k, c])} for k, t in enumerate(times) if k % 2 == 0 for c, l in enumerate(locs)]
|
||||
|
||||
|
||||
def test_roughness_recovers_two_group_truth():
|
||||
big = {"10", "11", "12", "111", "110"}
|
||||
obs = synthetic(factor=lambda p: 0.75 if p in big else 1.1, locs=("10", "11", "12", "13", "21", "22", "23", "31", "32"))
|
||||
groups = {"main": sorted(big), "rest": [p for p in PIPES if p not in big]}
|
||||
r = job_calibrate(NET1.encode(), {"mode": "roughness", "param": "pressure", "observations": obs, "groups": groups, "bounds": [0.5, 1.5]})
|
||||
got = {g["name"]: g["value"] for g in r["groups"]}
|
||||
assert got["main"] == pytest.approx(0.75, abs=0.02) and got["rest"] == pytest.approx(1.1, abs=0.05)
|
||||
assert r["rmseAfter"] < 0.05 * r["rmseBefore"]
|
||||
|
||||
|
||||
def test_wall_coefficient_from_chlorine():
|
||||
obs = synthetic(wall=-0.4, prop=EN.QUALITY)
|
||||
r = job_calibrate(NET1.encode(), {"mode": "wall", "param": "quality", "observations": obs, "groups": {"all": PIPES}, "bounds": [-3, 0]})
|
||||
assert r["groups"][0]["value"] == pytest.approx(-0.4, abs=0.01) and r["groups"][0]["meanBefore"] == -1.0
|
||||
|
||||
|
||||
def test_calibrate_input_errors():
|
||||
with pytest.raises(BatchError):
|
||||
job_calibrate(NET1.encode(), {"mode": "wall", "param": "pressure", "observations": synthetic(), "groups": {"a": PIPES}})
|
||||
with pytest.raises(BatchError):
|
||||
job_calibrate(NET1.encode(), {"observations": [], "groups": {"a": PIPES}})
|
||||
with pytest.raises(BatchError):
|
||||
job_calibrate(NET1.encode(), {"observations": synthetic(), "groups": {}})
|
||||
|
||||
|
||||
def test_pump_schedule_keeps_constraints_and_reports_controls():
|
||||
priced = NET1.replace("[PATTERNS]", "[PATTERNS]\n PRICE 0.5 0.5 0.5 1.5 1.5 1.5 1.5 1.5 1.5 1.5 1.5 0.5")
|
||||
priced = re.sub(r"Global Price\s+0\.0", "Global Price 0.1\n Global Pattern PRICE", priced)
|
||||
r = job_pumpschedule(priced.encode(), {"minPressure": 20})
|
||||
assert r["pumps"] == ["9"] and r["removedControls"] == 2 and r["slotSeconds"] == 7200
|
||||
assert len(r["schedules"]["9"]) == 12 and r["feasible"]
|
||||
assert r["after"]["violation"] == 0 and r["after"]["minPressure"] >= 20
|
||||
assert r["after"]["tankEnd"][0] >= 120 - 0.02 * 50 - 1e-6
|
||||
# never worse than the feasible starting schedule
|
||||
assert r["after"]["cost"] <= r["start"]["cost"] + 1e-9
|
||||
# Net1's own controls leave the tank ~4.6 ft lower after a day: reported, not hidden.
|
||||
assert r["before"]["violation"] > 0
|
||||
@@ -0,0 +1,131 @@
|
||||
"""DEM store: import (GeoTIFF, zipped ASC), bilinear sampling across CRSs, preview, TIN from points, HTTP."""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import rasterio
|
||||
from fastapi.testclient import TestClient
|
||||
from pyproj import CRS, Transformer
|
||||
from rasterio.transform import from_origin
|
||||
|
||||
from backend import dem as dem_store
|
||||
from backend.batch import BatchError
|
||||
from backend.main import app
|
||||
|
||||
EPSG = "EPSG:5186"
|
||||
X0, Y0, CELL, W, H = 200_000.0, 550_000.0, 10.0, 60, 40 # top-left corner
|
||||
|
||||
|
||||
def plane(x, y):
|
||||
return 100 + 0.01 * (x - X0) - 0.02 * (Y0 - y)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def store(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(dem_store, "DEM_DIR", tmp_path / "dem")
|
||||
|
||||
|
||||
def make_tif(path):
|
||||
xs = X0 + (np.arange(W) + 0.5) * CELL
|
||||
ys = Y0 - (np.arange(H) + 0.5) * CELL
|
||||
z = plane(*np.meshgrid(xs, ys)).astype("float32")
|
||||
z[0, 0] = -9999
|
||||
with rasterio.open(path, "w", driver="GTiff", width=W, height=H, count=1, dtype="float32", crs=EPSG,
|
||||
transform=from_origin(X0, Y0, CELL, CELL), nodata=-9999) as dst:
|
||||
dst.write(z, 1)
|
||||
|
||||
|
||||
def test_import_sample_is_bilinear_and_handles_outside(tmp_path):
|
||||
src = tmp_path / "up.bin"
|
||||
make_tif(tmp_path / "a.tif")
|
||||
src.write_bytes((tmp_path / "a.tif").read_bytes())
|
||||
info = dem_store.import_dem(str(src), "a.tif")
|
||||
assert info["width"] == W and info["resolution"] == [CELL, CELL] and len(info["corners"]) == 4
|
||||
pts = [[X0 + 123.4, Y0 - 77.7], [X0 + 300, Y0 - 200], [X0 - 50, Y0 - 50]]
|
||||
vals = dem_store.sample(info["id"], pts, EPSG)
|
||||
assert vals[0] == pytest.approx(plane(*pts[0]), abs=1e-3)
|
||||
assert vals[1] == pytest.approx(plane(*pts[1]), abs=1e-3)
|
||||
assert vals[2] is None
|
||||
# Same point given in WGS84 lands on the same value.
|
||||
lon, lat = Transformer.from_crs(EPSG, "EPSG:4326", always_xy=True).transform(*pts[0])
|
||||
assert dem_store.sample(info["id"], [[lon, lat]], "EPSG:4326")[0] == pytest.approx(vals[0], abs=1e-3)
|
||||
assert dem_store.preview_png(info["id"])[:8] == b"\x89PNG\r\n\x1a\n"
|
||||
assert dem_store.dem_info(info["id"])["id"] == info["id"]
|
||||
|
||||
|
||||
def test_zipped_asc_with_prj(tmp_path):
|
||||
xs = X0 + (np.arange(W) + 0.5) * CELL
|
||||
ys = Y0 - (np.arange(H) + 0.5) * CELL
|
||||
z = plane(*np.meshgrid(xs, ys))
|
||||
asc = f"ncols {W}\nnrows {H}\nxllcorner {X0}\nyllcorner {Y0 - H * CELL}\ncellsize {CELL}\nNODATA_value -9999\n"
|
||||
asc += "\n".join(" ".join(f"{v:.4f}" for v in row) for row in z)
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("dem/ground.asc", asc)
|
||||
zf.writestr("dem/ground.prj", CRS.from_user_input(EPSG).to_wkt("WKT1_ESRI"))
|
||||
up = tmp_path / "up.zip"
|
||||
up.write_bytes(buf.getvalue())
|
||||
info = dem_store.import_dem(str(up), "ground.zip")
|
||||
p = [X0 + 250, Y0 - 150]
|
||||
assert dem_store.sample(info["id"], [p], EPSG)[0] == pytest.approx(plane(*p), abs=1e-3)
|
||||
|
||||
|
||||
def test_errors(tmp_path):
|
||||
with pytest.raises(BatchError):
|
||||
dem_store.dem_info("0" * 32)
|
||||
with pytest.raises(BatchError):
|
||||
dem_store.dem_info("../../etc")
|
||||
bad = tmp_path / "x.bin"
|
||||
bad.write_bytes(b"not a raster")
|
||||
with pytest.raises(BatchError):
|
||||
dem_store.import_dem(str(bad), "x.tif")
|
||||
with pytest.raises(BatchError):
|
||||
dem_store.import_dem(str(bad), "x.docx")
|
||||
|
||||
|
||||
def test_tin_from_points_reproduces_a_plane():
|
||||
rng = np.random.default_rng(1)
|
||||
xy = np.column_stack([X0 + rng.uniform(0, 500, 300), Y0 - rng.uniform(0, 300, 300)])
|
||||
pts = [[x, y, plane(x, y)] for x, y in xy]
|
||||
pts += [[X0, Y0, plane(X0, Y0)], [X0 + 500, Y0, plane(X0 + 500, Y0)], [X0, Y0 - 300, plane(X0, Y0 - 300)], [X0 + 500, Y0 - 300, plane(X0 + 500, Y0 - 300)]]
|
||||
info = dem_store.from_points(pts, EPSG, 5.0)
|
||||
q = [X0 + 211, Y0 - 133]
|
||||
assert dem_store.sample(info["id"], [q], EPSG)[0] == pytest.approx(plane(*q), abs=0.05)
|
||||
with pytest.raises(BatchError):
|
||||
dem_store.from_points([[0, 0, 1], [1, 1, 2], [2, 2, 3]], EPSG, 1.0) # collinear
|
||||
|
||||
|
||||
def test_http_roundtrip(tmp_path):
|
||||
make_tif(tmp_path / "a.tif")
|
||||
with TestClient(app) as c:
|
||||
r = c.post("/api/dem", files={"file": ("a.tif", (tmp_path / "a.tif").read_bytes(), "image/tiff")})
|
||||
assert r.status_code == 200, r.text
|
||||
dem_id = r.json()["id"]
|
||||
assert c.get(f"/api/dem/{dem_id}/preview.png").headers["content-type"] == "image/png"
|
||||
s = c.post(f"/api/dem/{dem_id}/sample", json={"points": [[X0 + 100, Y0 - 100]], "crs": EPSG})
|
||||
assert s.json()["values"][0] == pytest.approx(plane(X0 + 100, Y0 - 100), abs=1e-3)
|
||||
assert c.get("/api/dem/" + "f" * 32).status_code == 400
|
||||
assert c.post("/api/dem", files={"file": ("a.txt", b"x", "text/plain")}).status_code == 400
|
||||
|
||||
|
||||
def test_terrain_tile_terrarium(tmp_path):
|
||||
make_tif(tmp_path / "a.tif")
|
||||
up = tmp_path / "up.bin"
|
||||
up.write_bytes((tmp_path / "a.tif").read_bytes())
|
||||
info = dem_store.import_dem(str(up), "a.tif")
|
||||
import math
|
||||
lon, lat = Transformer.from_crs(EPSG, "EPSG:4326", always_xy=True).transform(X0 + 300, Y0 - 200)
|
||||
z = 15
|
||||
n = 2 ** z
|
||||
fx = (lon + 180) / 360 * n
|
||||
fy = (1 - math.asinh(math.tan(math.radians(lat))) / math.pi) / 2 * n
|
||||
xt, yt = int(fx), int(fy)
|
||||
png = dem_store.terrain_tile(info["id"], z, xt, yt)
|
||||
with rasterio.MemoryFile(png) as mem, mem.open() as ds:
|
||||
rgb = ds.read().astype(float)
|
||||
elev = rgb[0] * 256 + rgb[1] + rgb[2] / 256 - 32768
|
||||
px, py = int((fx - xt) * 256), int((fy - yt) * 256)
|
||||
assert elev[py, px] == pytest.approx(plane(X0 + 300, Y0 - 200), abs=0.2)
|
||||
with pytest.raises(BatchError):
|
||||
dem_store.terrain_tile(info["id"], 3, 99, 0)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Vector GIS import (/api/gis/vector) with real GDAL files written on the fly."""
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import shapely
|
||||
from fastapi.testclient import TestClient
|
||||
from pyogrio import raw as ogr_raw
|
||||
|
||||
from backend.main import app
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
with TestClient(app, raise_server_exceptions=False) as c:
|
||||
yield c
|
||||
|
||||
|
||||
def _write(path, driver, crs="EPSG:5187"):
|
||||
geoms = [shapely.LineString([(200000, 300000), (200100, 300050)]), shapely.LineString([(200100, 300050), (200200, 300000)])]
|
||||
with_fields = driver != "DXF" # DXF has a fixed schema (Layer, EntityHandle ...)
|
||||
ogr_raw.write(
|
||||
path,
|
||||
geometry=np.array(shapely.to_wkb(geoms), dtype=object),
|
||||
field_data=[np.array([150, 200], dtype=np.int32), np.array(["DCIP", "PE"], dtype=object)] if with_fields else [],
|
||||
fields=["DIA", "MAT"] if with_fields else [],
|
||||
geometry_type="LineString",
|
||||
crs=crs,
|
||||
driver=driver,
|
||||
)
|
||||
|
||||
|
||||
def test_zipped_shapefile_reprojected_to_wgs84(client):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
shp = os.path.join(tmp, "pipes.shp")
|
||||
_write(shp, "ESRI Shapefile")
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as z:
|
||||
for f in os.listdir(tmp):
|
||||
z.write(os.path.join(tmp, f), f)
|
||||
r = client.post("/api/gis/vector", files={"file": ("pipes.zip", buf.getvalue(), "application/zip")})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["crs"] == "EPSG:5187" and body["count"] == 2
|
||||
feat = body["featureCollection"]["features"][0]
|
||||
assert feat["properties"] == {"DIA": 150, "MAT": "DCIP"}
|
||||
lon, lat = feat["geometry"]["coordinates"][0]
|
||||
assert 127 < lon < 132 and 33 < lat < 39 # East Belt -> somewhere in Korea
|
||||
|
||||
|
||||
def test_dxf_without_crs_needs_src_epsg(client):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
dxf = os.path.join(tmp, "pipes.dxf")
|
||||
_write(dxf, "DXF", crs=None)
|
||||
data = open(dxf, "rb").read()
|
||||
raw = client.post("/api/gis/vector", files={"file": ("pipes.dxf", data, "application/dxf")}).json()
|
||||
assert raw["reprojected"] is False and raw["count"] >= 2
|
||||
fixed = client.post("/api/gis/vector", files={"file": ("pipes.dxf", data, "application/dxf")}, data={"srcEpsg": "5187"}).json()
|
||||
lon, lat = fixed["featureCollection"]["features"][0]["geometry"]["coordinates"][0]
|
||||
assert 127 < lon < 132 and 33 < lat < 39
|
||||
|
||||
|
||||
def test_rejects_unknown_extension_and_bad_epsg(client):
|
||||
assert client.post("/api/gis/vector", files={"file": ("a.exe", b"MZ", "application/octet-stream")}).status_code == 400
|
||||
assert client.post("/api/gis/vector", files={"file": ("a.dxf", b"0", "x")}, data={"srcEpsg": "abc"}).status_code == 400
|
||||
assert client.post("/api/gis/vector", files={"file": ("broken.dxf", b"not a dxf", "x")}).status_code == 400
|
||||
@@ -0,0 +1,111 @@
|
||||
"""GIS layer export / reprojection / geometry operations (/api/gis/export, /op, /reproject)."""
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
import numpy as np
|
||||
import pyogrio
|
||||
import pytest
|
||||
import shapely
|
||||
from fastapi.testclient import TestClient
|
||||
from pyproj import Transformer
|
||||
|
||||
from backend import gisops
|
||||
from backend.batch import BatchError
|
||||
from backend.main import app
|
||||
|
||||
EPSG = "EPSG:5186"
|
||||
TO_WGS = Transformer.from_crs(EPSG, "EPSG:4326", always_xy=True)
|
||||
|
||||
|
||||
def wgs(x, y):
|
||||
return list(TO_WGS.transform(x, y))
|
||||
|
||||
|
||||
def fc(features):
|
||||
return {"type": "FeatureCollection", "features": features}
|
||||
|
||||
|
||||
SQUARE = fc([{"type": "Feature", "properties": {"name": "블록A", "dia": 150}, "geometry": {"type": "Polygon", "coordinates": [[wgs(200000, 500000), wgs(200100, 500000), wgs(200100, 500100), wgs(200000, 500100), wgs(200000, 500000)]]}}])
|
||||
LINES = fc([
|
||||
{"type": "Feature", "properties": {"id": "P1", "dia": 150}, "geometry": {"type": "LineString", "coordinates": [wgs(200000, 500000), wgs(200200, 500000)]}},
|
||||
{"type": "Feature", "properties": {"id": "P2", "dia": None}, "geometry": {"type": "LineString", "coordinates": [wgs(200200, 500000), wgs(200200, 500200)]}},
|
||||
])
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
with TestClient(app, raise_server_exceptions=False) as c:
|
||||
yield c
|
||||
|
||||
|
||||
def _area_m2(feature_collection):
|
||||
from shapely.geometry import shape
|
||||
g = gisops._project(np.array([shape(f["geometry"]) for f in feature_collection["features"]], dtype=object), gisops.WGS84, gisops.CRS.from_user_input(EPSG))
|
||||
return float(sum(x.area for x in g))
|
||||
|
||||
|
||||
def test_export_shapefile_in_target_crs_with_korean_fields():
|
||||
data, name, media = gisops.write_vector(LINES, EPSG, "shp", "관로")
|
||||
assert name == "관로.zip" and media == "application/zip"
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
zipfile.ZipFile(io.BytesIO(data)).extractall(tmp)
|
||||
shp = [f for f in os.listdir(tmp) if f.endswith(".shp")][0]
|
||||
df_info = pyogrio.read_info(os.path.join(tmp, shp))
|
||||
assert "5186" in df_info["crs"] and df_info["features"] == 2
|
||||
meta, _, geom, fields = pyogrio.raw.read(os.path.join(tmp, shp))
|
||||
x0 = shapely.from_wkb(geom[0]).coords[0][0]
|
||||
assert x0 == pytest.approx(200000, abs=0.01)
|
||||
assert list(meta["fields"]) == ["id", "dia"]
|
||||
|
||||
|
||||
def test_export_gpkg_dxf_and_mixed_shapefile_refused():
|
||||
gpkg, name, _ = gisops.write_vector(SQUARE, EPSG, "gpkg", "blocks")
|
||||
assert name == "blocks.gpkg" and gpkg[:15] == b"SQLite format 3"
|
||||
dxf, _, _ = gisops.write_vector(LINES, EPSG, "dxf", "lines")
|
||||
assert b"SECTION" in dxf
|
||||
mixed = fc(SQUARE["features"] + LINES["features"])
|
||||
with pytest.raises(BatchError):
|
||||
gisops.write_vector(mixed, EPSG, "shp", "x")
|
||||
|
||||
|
||||
def test_reproject_fixes_a_layer_read_in_the_wrong_crs():
|
||||
# Numbers really in EPSG:5186 but read as EPSG:5187: reassign -> lands on the 5186 position.
|
||||
wrong = Transformer.from_crs("EPSG:5187", "EPSG:4326", always_xy=True)
|
||||
bad = fc([{"type": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": list(wrong.transform(200000, 500000))}}])
|
||||
fixed = gisops.reproject_fc(bad, "EPSG:5187", EPSG)
|
||||
assert fixed["features"][0]["geometry"]["coordinates"] == pytest.approx(wgs(200000, 500000), abs=1e-8)
|
||||
|
||||
|
||||
def test_buffer_clip_difference_dissolve():
|
||||
buf = gisops.vector_op("buffer", {"crs": EPSG, "layer": LINES, "distance": 10})
|
||||
assert len(buf["features"]) == 2 and buf["features"][0]["properties"]["id"] == "P1"
|
||||
# P1 buffered: 200 x 20 rectangle + two half-discs of r=10
|
||||
from shapely.geometry import shape
|
||||
a = gisops._project(np.array([shape(buf["features"][0]["geometry"])], dtype=object), gisops.WGS84, gisops.CRS.from_user_input(EPSG))[0].area
|
||||
assert a == pytest.approx(200 * 20 + np.pi * 100, rel=0.01)
|
||||
clipped = gisops.vector_op("clip", {"crs": EPSG, "layer": LINES, "other": SQUARE})
|
||||
assert len(clipped["features"]) == 1 # only P1 crosses the square
|
||||
diff = gisops.vector_op("difference", {"crs": EPSG, "layer": SQUARE, "other": buf})
|
||||
assert _area_m2(diff) < 100 * 100
|
||||
dis = gisops.vector_op("dissolve", {"crs": EPSG, "layer": buf, "field": None})
|
||||
assert len(dis["features"]) == 1 and dis["features"][0]["properties"]["count"] == 2
|
||||
|
||||
|
||||
def test_voronoi_and_grid():
|
||||
pts = [[200000, 500000], [200100, 500000], [200000, 500100], [200100, 500100], [200050, 500050]]
|
||||
v = gisops.vector_op("voronoi", {"crs": EPSG, "points": pts, "ids": ["A", "B", "C", "D", "E"]})
|
||||
assert sorted(f["properties"]["node"] for f in v["features"]) == ["A", "B", "C", "D", "E"]
|
||||
g = gisops.vector_op("grid", {"crs": EPSG, "bounds": [200000, 500000, 200100, 500050], "cell": 25})
|
||||
assert len(g["features"]) == 8
|
||||
with pytest.raises(BatchError):
|
||||
gisops.vector_op("grid", {"crs": "EPSG:4326", "bounds": [0, 0, 1, 1], "cell": 1})
|
||||
|
||||
|
||||
def test_http(client):
|
||||
r = client.post("/api/gis/export", json={"featureCollection": LINES, "crs": EPSG, "format": "geojson", "name": "관로"})
|
||||
assert r.status_code == 200 and "attachment" in r.headers["content-disposition"]
|
||||
r = client.post("/api/gis/op", json={"op": "buffer", "crs": EPSG, "layer": LINES, "distance": 5})
|
||||
assert r.status_code == 200 and len(r.json()["features"]) == 2
|
||||
assert client.post("/api/gis/op", json={"op": "nope", "crs": EPSG, "layer": LINES}).status_code == 400
|
||||
@@ -0,0 +1,59 @@
|
||||
"""DEM hydrology: streams follow a synthetic valley; the outlet's catchment is the whole valley."""
|
||||
import numpy as np
|
||||
import pytest
|
||||
import rasterio
|
||||
from pyproj import Transformer
|
||||
from rasterio.transform import from_origin
|
||||
from shapely.geometry import shape
|
||||
|
||||
from backend import dem as dem_store
|
||||
|
||||
EPSG = "EPSG:5186"
|
||||
X0, Y1, CELL, W, H = 200_000.0, 550_000.0, 10.0, 81, 120 # valley axis at column 40
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def store(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(dem_store, "DEM_DIR", tmp_path / "dem")
|
||||
|
||||
|
||||
def valley(tmp_path):
|
||||
xs = X0 + (np.arange(W) + 0.5) * CELL
|
||||
ys = Y1 - (np.arange(H) + 0.5) * CELL
|
||||
xx, yy = np.meshgrid(xs, ys)
|
||||
axis = X0 + 40.5 * CELL
|
||||
z = 100 + 0.05 * np.abs(xx - axis) + 0.01 * (yy - (Y1 - H * CELL)) # V valley draining south
|
||||
z[30, 40] -= 3 # a pit on the axis must not stop the flow
|
||||
p = tmp_path / "v.tif"
|
||||
with rasterio.open(p, "w", driver="GTiff", width=W, height=H, count=1, dtype="float32", crs=EPSG,
|
||||
transform=from_origin(X0, Y1, CELL, CELL)) as d:
|
||||
d.write(z.astype("float32"), 1)
|
||||
up = tmp_path / "up.bin"
|
||||
up.write_bytes(p.read_bytes())
|
||||
return dem_store.import_dem(str(up), "v.tif")
|
||||
|
||||
|
||||
def test_streams_follow_the_axis_through_a_pit(tmp_path):
|
||||
info = valley(tmp_path)
|
||||
fc = dem_store.streams(info["id"], 0.05) # 0.05 km2 = 500 cells
|
||||
assert fc["features"]
|
||||
to_xy = Transformer.from_crs("EPSG:4326", EPSG, always_xy=True)
|
||||
axis = X0 + 40.5 * CELL
|
||||
longest = max(fc["features"], key=lambda f: f["properties"]["length_m"])
|
||||
xs = [to_xy.transform(*c)[0] for c in longest["geometry"]["coordinates"]]
|
||||
assert max(abs(x - axis) for x in xs) < CELL * 1.5
|
||||
assert longest["properties"]["length_m"] > 0.5 * H * CELL
|
||||
assert longest["properties"]["area_km2"] == pytest.approx(W * H * CELL * CELL / 1e6, rel=0.1)
|
||||
|
||||
|
||||
def test_watershed_of_the_outlet_is_the_whole_valley(tmp_path):
|
||||
info = valley(tmp_path)
|
||||
outlet = [X0 + 40.5 * CELL, Y1 - (H - 1) * CELL]
|
||||
fc = dem_store.watershed(info["id"], [outlet], EPSG, snap_m=30)
|
||||
(f,) = fc["features"]
|
||||
assert f["properties"]["area_km2"] == pytest.approx(W * H * CELL * CELL / 1e6, rel=0.05)
|
||||
assert shape(f["geometry"]).is_valid
|
||||
# A point halfway up drains only the upper part.
|
||||
mid = [X0 + 40.5 * CELL, Y1 - (H / 2) * CELL]
|
||||
half = dem_store.watershed(info["id"], [mid], EPSG, snap_m=30)["features"][0]["properties"]["area_km2"]
|
||||
assert 0.3 * f["properties"]["area_km2"] < half < 0.7 * f["properties"]["area_km2"]
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Criticality, service area (trace), VSP and energy jobs against the real DLL."""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.batch import BatchError
|
||||
from backend.jobs import job_criticality, job_energy, job_servicearea, job_vsp
|
||||
|
||||
PUBLIC = Path(__file__).resolve().parents[2] / "public"
|
||||
NET1 = (PUBLIC / "Net1.inp").read_bytes()
|
||||
NET3 = (PUBLIC / "Net3.inp").read_bytes()
|
||||
|
||||
|
||||
def test_criticality_ranks_the_supply_main_first():
|
||||
r = job_criticality(NET1, {"threshold": 40})
|
||||
assert len(r["links"]) == 12 # pipes only, the pump is skipped
|
||||
top = r["links"][0]
|
||||
# Pipe 10 is the only path from the source: closing it cuts off every junction.
|
||||
assert top["id"] == "10" and top["affectedNodes"] == 8 and top["disconnected"]
|
||||
loops = [e for e in r["links"] if e["id"] in ("111", "112", "121", "122")]
|
||||
assert all(e["affectedNodes"] == 0 for e in loops)
|
||||
|
||||
|
||||
def test_criticality_subset_and_limits():
|
||||
r = job_criticality(NET1, {"threshold": 40, "links": ["111"]})
|
||||
assert [e["id"] for e in r["links"]] == ["111"]
|
||||
with pytest.raises(BatchError):
|
||||
job_criticality(NET1, {"links": ["nope"]})
|
||||
|
||||
|
||||
def test_service_area_shares_sum_to_at_most_100():
|
||||
r = job_servicearea(NET3, {})
|
||||
assert r["sources"] == ["River", "Lake"]
|
||||
assert r["unreached"] == 0 and r["notes"] # 24 h run extended to 72 h
|
||||
assert max(sum(v["shares"].values()) for v in r["nodes"].values()) <= 100.5
|
||||
assert r["nodes"]["River"]["source"] == "River" and r["nodes"]["Lake"]["source"] == "Lake"
|
||||
|
||||
|
||||
def test_vsp_hits_the_target_or_reports_the_bound():
|
||||
r = job_vsp(NET1, {"pump": "9", "node": "31", "target": 125, "hours": [0, 18], "maxSpeed": 1.5})
|
||||
by_hour = {row["hour"]: row for row in r["rows"]}
|
||||
assert by_hour[0]["status"] == "max" and by_hour[0]["pressure"] < 125
|
||||
assert by_hour[18]["status"] == "ok" and abs(by_hour[18]["pressure"] - 125) < 0.05
|
||||
assert 1.0 < by_hour[18]["speed"] < 1.5
|
||||
with pytest.raises(BatchError):
|
||||
job_vsp(NET1, {"pump": "10", "node": "31", "target": 100}) # a pipe
|
||||
|
||||
|
||||
def test_energy_report_matches_epanet_energy_section():
|
||||
r = job_energy(NET1, {})
|
||||
(p,) = r["pumps"]
|
||||
# EPANET's own Net1 energy report: 57.71 % utilisation, 75 % efficiency, ~96 kW average.
|
||||
assert p["utilization"] == pytest.approx(57.71, abs=0.05)
|
||||
assert p["efficiency"] == pytest.approx(75.0)
|
||||
assert p["avgKw"] == pytest.approx(96.25, abs=0.5)
|
||||
assert p["kwhPerM3"] > 0 and r["totalCost"] == 0.0 # Net1 has no price
|
||||
priced = re.sub(rb"Global Price\s+0\.0", b"Global Price 0.1", NET1)
|
||||
assert priced != NET1
|
||||
assert job_energy(priced, {})["pumps"][0]["cost"] == pytest.approx(p["kwh"] * 0.1, rel=1e-3)
|
||||
Reference in new issue
Block a user