Files
SPipe/backend/tests/test_wntr_crosscheck.py
SPipe EngineeringandClaude Opus 5.5 e3f386addf 도면 출력 추가: 수리 종단면도, 관망도(색인도·부분도·수압표), 범례·색상 선택, DXF(AutoCAD 2018)
- 데모 v2 이후 개발 PC의 변경을 옮김 (이 저장소의 이름 변경·README 정리는 유지)
- 서버 기본 DEM, DEM 등고선, 속성 테이블 범위 선택, WNTR 교차 검증 시험 포함
- 새 라이브러리: ezdxf(Python), maplibre-contour(npm)
- 변경 내역: docs/SPipe_패치내역_도면출력_2026-11-06.txt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 15:03:21 +09:00

79 lines
3.1 KiB
Python

"""Cross-check SPipe's server analyses against USEPA WNTR (an independent EPANET
front end). Skipped when wntr is not installed (it is a dev-only dependency).
WNTR works in SI (m, m³/s); Net1/Net3 are US units (psi, GPM), so values are
converted before comparing. WNTR bundles its own EPANET build, so small
numerical differences are allowed.
"""
import os
import tempfile
from pathlib import Path
import numpy as np
import pytest
from backend.batch import Network
from backend.epanet import EN
from backend.jobs import job_criticality, job_fireflow
wntr = pytest.importorskip("wntr")
PUBLIC = Path(__file__).resolve().parents[2] / "public"
PSI_TO_M = 0.3048 / 0.4333 # EPANET's own psi <-> ft of water factor
GPM_TO_M3S = 6.30902e-5
def wntr_pressure(wn) -> "object":
with tempfile.TemporaryDirectory() as tmp:
res = wntr.sim.EpanetSimulator(wn).run_sim(file_prefix=os.path.join(tmp, "w"))
return res.node["pressure"]
def spipe_pressure(inp: bytes) -> dict:
with Network(inp) as n:
js = n.junction_indices()
r = n.simulate(node_props=(EN.PRESSURE,), nodes=js)
return {n.node_ids[j - 1]: r.nodes[EN.PRESSURE][:, k] * PSI_TO_M for k, j in enumerate(js)}, r.times
@pytest.mark.parametrize("name", ["Net1.inp", "Net3.inp"])
def test_baseline_pressures_match(name):
path = PUBLIC / name
ours, times = spipe_pressure(path.read_bytes())
p = wntr_pressure(wntr.network.WaterNetworkModel(str(path)))
diffs = []
for nid, series in ours.items():
w = p[nid].reindex(times).to_numpy()
diffs.append(np.nanmax(np.abs(series - w)))
assert max(diffs) < 0.01, f"max pressure difference {max(diffs):.3f} m"
def test_pipe_criticality_matches_closing_pipes_in_wntr():
path = PUBLIC / "Net1.inp"
ours = {e["id"]: e for e in job_criticality(path.read_bytes(), {"threshold": 40})["links"]}
checked = 0
for pid, e in ours.items():
if e.get("disconnected") or e.get("minPressure") is None:
continue # isolated nodes: both engines report meaningless negative heads
wn = wntr.network.WaterNetworkModel(str(path))
wn.get_link(pid).initial_status = wntr.network.LinkStatus.Closed
p = wntr_pressure(wn)
junctions = [n for n, _ in wn.junctions()]
wmin = float(p[junctions].min().min())
assert abs(e["minPressure"] * PSI_TO_M - wmin) < 0.01, f"pipe {pid}: {e['minPressure'] * PSI_TO_M:.2f} vs {wmin:.2f} m"
checked += 1
assert checked >= 10
def test_fire_flow_pressure_drop_matches_extra_demand_in_wntr():
path = PUBLIC / "Net1.inp"
flow_gpm = 500.0
r = job_fireflow(path.read_bytes(), {"nodes": ["22"], "flow": flow_gpm, "start": 0, "end": 24})
ours = r["hydrants"][0]["firePressure"] * PSI_TO_M
wn = wntr.network.WaterNetworkModel(str(path))
# A demand without a pattern would get Net1's default pattern (up to 1.6x); fire flow is constant.
wn.add_pattern("fire_const", [1.0])
wn.get_node("22").add_demand(flow_gpm * GPM_TO_M3S, "fire_const", "fire")
p = wntr_pressure(wn)
assert abs(ours - float(p["22"].min())) < 0.01