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

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

No files matched your search

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