도면 출력 추가: 수리 종단면도, 관망도(색인도·부분도·수압표), 범례·색상 선택, 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>
This commit is contained in:
SPipe EngineeringandClaude Opus 5.5 committed 2026-11-06 15:03:21 +09:00
1 parent 14f07181db
commit e3f386addf
33 files changed
+3454 -482

No files matched your search

+76 -15
View File
@@ -27,12 +27,24 @@ 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"))
# Server-provided DEMs (e.g. the national 90 m DEM), registered once with
# `python -m backend.dem register <raster|zip> <name> <label>`; never trimmed.
BASE_DIR = Path(os.environ.get("SPIPE_BASE_DEM_DIR", Path(__file__).resolve().parents[1] / "data" / "base_dem"))
_NAME_OK = set("abcdefghijklmnopqrstuvwxyz0123456789_-")
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 dem_id.startswith("base-"):
name = dem_id[5:]
if not name or not set(name) <= _NAME_OK:
raise BatchError("DEM id가 올바르지 않습니다.")
p = BASE_DIR / f"{name}.tif"
if not p.exists():
raise BatchError("서버 기본 DEM이 없습니다.")
return p
if len(dem_id) != 32 or not set(dem_id) <= _ID_OK:
raise BatchError("DEM id가 올바르지 않습니다.")
p = DEM_DIR / f"{dem_id}.tif"
@@ -79,10 +91,12 @@ def _info(dem_id: str, src, name: str) -> dict:
}
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"
def _store(src_path: str, name: str, base: str | None = None) -> dict:
"""Copy a raster into the store as a tiled GeoTIFF (base: a permanent server DEM)."""
folder = BASE_DIR if base else DEM_DIR
folder.mkdir(parents=True, exist_ok=True)
dem_id = f"base-{base}" if base else uuid.uuid4().hex
out = folder / f"{base if base else dem_id}.tif"
try:
with rasterio.open(src_path) as src:
if src.count < 1:
@@ -105,10 +119,36 @@ def _store(src_path: str, name: str) -> dict:
out.unlink(missing_ok=True)
raise
out.with_suffix(".json").write_text(json.dumps(info, ensure_ascii=False), encoding="utf-8")
_trim()
if not base:
_trim()
return info
def register_base(src: str, name: str, label: str) -> dict:
"""Register a raster (or a zip holding one) as a permanent server DEM."""
if not name or not set(name) <= _NAME_OK:
raise BatchError("이름은 영문 소문자·숫자·-·_ 만 쓸 수 있습니다.")
if src.lower().endswith(".zip"):
with zipfile.ZipFile(src) as z:
names = [n for n in z.namelist() if os.path.splitext(n.lower())[1] in RASTER_EXTENSIONS]
if not names:
raise BatchError("zip 안에 래스터가 없습니다.")
src = "/vsizip/" + os.path.abspath(src).replace("\\", "/") + "/" + names[0]
return _store(src, label, base=name)
def list_base() -> list[dict]:
if not BASE_DIR.exists():
return []
out = []
for j in sorted(BASE_DIR.glob("*.json")):
try:
out.append(json.loads(j.read_text(encoding="utf-8")))
except (OSError, ValueError):
continue
return out
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]
@@ -292,16 +332,27 @@ 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):
def _hydro_grid(dem_id: str, bbox: list | None = None, bbox_crs: str | None = None):
"""DEM (downsampled to <= MAX_HYDRO_CELLS) -> D8 receivers and flow accumulation
on a priority-flood filled surface (pits and flats drain)."""
on a priority-flood filled surface (pits and flats drain). `bbox` [x0, y0, x1, y1]
in `bbox_crs` limits the area (needed for country-wide DEMs)."""
import heapq
from rasterio.windows import Window, from_bounds
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)
win = Window(0, 0, src.width, src.height)
if bbox:
tr = Transformer.from_crs(_crs_of(bbox_crs), src.crs, always_xy=True)
xs, ys = tr.transform([bbox[0], bbox[2], bbox[0], bbox[2]], [bbox[1], bbox[1], bbox[3], bbox[3]])
win = from_bounds(min(xs), min(ys), max(xs), max(ys), src.transform).round_offsets().round_lengths()
win = win.intersection(Window(0, 0, src.width, src.height))
elif src.width * src.height > 16 * MAX_HYDRO_CELLS:
raise BatchError("DEM이 너무 넓습니다. 관망 범위로 잘라서 계산하세요.")
k = max(1, int(np.ceil(np.sqrt(win.width * win.height / MAX_HYDRO_CELLS))))
h, w = max(2, int(win.height) // k), max(2, int(win.width) // k)
z = src.read(1, window=win, masked=True, out_shape=(h, w), resampling=Resampling.average).astype("float64")
wt = src.window_transform(win)
transform = wt @ wt.scale(win.width / w, win.height / h)
crs = src.crs
if crs.is_geographic:
raise BatchError("유역 분석에는 평면 좌표계 DEM이 필요합니다.")
@@ -353,9 +404,9 @@ def _hydro_grid(dem_id: str):
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:
def streams(dem_id: str, min_area_km2: float, bbox: list | None = None, bbox_crs: str | None = None) -> dict:
"""Stream lines (WGS84 GeoJSON): cells draining at least `min_area_km2`."""
g = _hydro_grid(dem_id)
g = _hydro_grid(dem_id, bbox, bbox_crs)
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]
@@ -385,11 +436,11 @@ def streams(dem_id: str, min_area_km2: float) -> dict:
return {"type": "FeatureCollection", "features": feats}
def watershed(dem_id: str, points: list, points_crs: str | None, snap_m: float = 50.0) -> dict:
def watershed(dem_id: str, points: list, points_crs: str | None, snap_m: float = 50.0, bbox: list | None = None) -> 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)
g = _hydro_grid(dem_id, bbox, points_crs)
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)
@@ -430,3 +481,13 @@ def watershed(dem_id: str, points: list, points_crs: str | None, snap_m: float =
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}
if __name__ == "__main__":
import sys
if len(sys.argv) == 5 and sys.argv[1] == "register":
info = register_base(sys.argv[2], sys.argv[3], sys.argv[4])
print(f"registered {info['id']}: {info['name']} {info['width']}x{info['height']} res {info['resolution']} z {info['min']}~{info['max']}")
else:
print("usage: python -m backend.dem register <raster|zip> <name> <label>")
+134
View File
@@ -0,0 +1,134 @@
"""Drawing sheets (src/drawing.ts model) -> DXF R2018 via ezdxf.
The browser sends the same item list it renders as SVG: lines, texts, circles
and solid fills in millimetres (origin bottom-left). R2018 stores text as
UTF-8, so Korean needs no code page; widths become lineweights, colours true
colours, fills solid hatches. Each item keeps its layer.
"""
from __future__ import annotations
import io
import re
import ezdxf
from ezdxf import colors
from ezdxf.enums import TextEntityAlignment
from .batch import BatchError
MAX_ITEMS = 400_000
_LINEWEIGHTS = [0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, 140, 158, 200, 211]
_FONTS = {"ulleungM": ("HY울릉도M.TTF", "HY울릉도M"), "malgun": ("malgun.ttf", "Malgun Gothic")}
_HEX = re.compile(r"^#([0-9a-fA-F]{6})$")
_ALIGN = {
("start", "bottom"): TextEntityAlignment.LEFT, ("middle", "bottom"): TextEntityAlignment.CENTER, ("end", "bottom"): TextEntityAlignment.RIGHT,
("start", "middle"): TextEntityAlignment.MIDDLE_LEFT, ("middle", "middle"): TextEntityAlignment.MIDDLE_CENTER, ("end", "middle"): TextEntityAlignment.MIDDLE_RIGHT,
("start", "top"): TextEntityAlignment.TOP_LEFT, ("middle", "top"): TextEntityAlignment.TOP_CENTER, ("end", "top"): TextEntityAlignment.TOP_RIGHT,
}
def _rgb(color: str | None) -> int | None:
m = _HEX.match(color or "")
return colors.rgb2int(tuple(int(m.group(1)[i:i + 2], 16) for i in (0, 2, 4))) if m else None
def _lineweight(width_mm: float | None) -> int:
w = (width_mm or 0.25) * 100
return min(_LINEWEIGHTS[1:], key=lambda v: abs(v - w))
def _pts(raw) -> list[tuple[float, float]]:
try:
return [(float(p[0]), float(p[1])) for p in raw]
except (TypeError, ValueError, IndexError) as e:
raise BatchError("도면 좌표가 올바르지 않습니다.") from e
def drawing_to_dxf(drawing: dict) -> bytes:
items = drawing.get("items") or []
if not isinstance(items, list) or not items:
raise BatchError("도면이 비어 있습니다.")
if len(items) > MAX_ITEMS:
raise BatchError("도면 요소가 너무 많습니다.")
doc = ezdxf.new("R2018", setup=True) # standard linetypes incl. DASHED / DOT
doc.units = ezdxf.units.MM
doc.header["$LWDISPLAY"] = 1
# Text style: the sheet's font (HY울릉도M by default) by file and family
# name; CAD substitutes when the font is not installed on that PC.
font_file, family = _FONTS.get(str(drawing.get("font") or ""), _FONTS["malgun"])
style = doc.styles.add("MALGUN", font=font_file)
style.set_extended_font_data(family=family)
msp = doc.modelspace()
layers: set[str] = set()
def attrs(it: dict, color: str | None) -> dict:
layer = str(it.get("layer") or "0")[:255]
if layer not in layers:
doc.layers.add(layer)
layers.add(layer)
a = {"layer": layer}
m = _HEX.match(color or "")
if m and max(int(m.group(1)[i:i + 2], 16) for i in (0, 2, 4)) < 64:
a["color"] = 7 # near-black -> white/black by CAD background (AutoCAD model space is dark)
elif m:
a["true_color"] = _rgb(color)
return a
def is_white(color: str | None) -> bool:
m = _HEX.match(color or "")
return bool(m) and min(int(m.group(1)[i:i + 2], 16) for i in (0, 2, 4)) > 240
for it in items:
kind = it.get("kind")
if kind == "line":
pts = _pts(it.get("pts") or [])
if len(pts) < 2:
continue
a = attrs(it, it.get("color") or "#111111")
a["lineweight"] = _lineweight(it.get("width"))
if it.get("dash") == "dashed":
a["linetype"] = "DASHED"
elif it.get("dash") == "dotted":
a["linetype"] = "DOT"
pl = msp.add_lwpolyline(pts, close=bool(it.get("closed")), dxfattribs=a)
if a.get("linetype"):
pl.dxf.ltscale = max(0.05, (it.get("width") or 0.25) * 1.2)
elif kind == "fill":
pts = _pts(it.get("pts") or [])
# White boxes only mask lines behind labels on paper; in CAD they hide geometry.
if len(pts) < 3 or is_white(it.get("fill")):
continue
a = attrs(it, it.get("fill"))
h = msp.add_hatch(dxfattribs=a)
h.paths.add_polyline_path(pts, is_closed=True)
op = float(it.get("opacity") if it.get("opacity") is not None else 1)
if op < 1:
h.transparency = max(0.0, min(1.0, 1 - op))
elif kind == "circle":
x, y, r = float(it["x"]), float(it["y"]), float(it["r"])
if it.get("fill") and it.get("fill") != "none":
h = msp.add_hatch(dxfattribs=attrs(it, it.get("fill")))
h.paths.add_edge_path().add_arc((x, y), r, 0, 360)
a = attrs(it, it.get("color") or "#111111")
a["lineweight"] = _lineweight(it.get("width") or 0.2)
msp.add_circle((x, y), r, dxfattribs=a)
elif kind == "text":
text = str(it.get("text") or "")
if not text:
continue
a = attrs(it, it.get("color") or "#111111")
a.update(style="MALGUN", height=float(it.get("size") or 2.5) * 0.8, rotation=float(it.get("rotate") or 0))
t = msp.add_text(text, dxfattribs=a)
t.set_placement((float(it["x"]), float(it["y"])), align=_ALIGN.get((it.get("anchor") or "start", it.get("vAnchor") or "bottom"), TextEntityAlignment.LEFT))
# Open zoomed to the sheet (extents + initial model-space view).
w, h = float(drawing.get("width") or 420), float(drawing.get("height") or 297)
doc.set_modelspace_vport(height=h * 1.05, center=(w / 2, h / 2))
buf = io.StringIO()
doc.write(buf)
# R2018 text is UTF-8 and AutoCAD ignores $DWGCODEPAGE, but GDAL-based readers
# (QGIS etc.) decode with it; ezdxf always writes ANSI_1252 on save.
out = buf.getvalue().replace("$DWGCODEPAGE\n 3\nANSI_1252\n", "$DWGCODEPAGE\n 3\nANSI_65001\n", 1)
# ezdxf writes "unknown" extents (+-1e20); give CAD the sheet so zoom-extents works.
out = out.replace("$EXTMIN\n 10\n1e+20\n 20\n1e+20\n 30\n1e+20\n", "$EXTMIN\n 10\n0.0\n 20\n0.0\n 30\n0.0\n", 1)
out = out.replace("$EXTMAX\n 10\n-1e+20\n 20\n-1e+20\n 30\n-1e+20\n", f"$EXTMAX\n 10\n{w}\n 20\n{h}\n 30\n0.0\n", 1)
return out.encode("utf-8")
+48 -2
View File
@@ -16,6 +16,7 @@ from .columnar import to_columnar
from .gisio import read_vector
from . import dem as dem_store
from . import gisops
from .drawing_dxf import drawing_to_dxf
from urllib.parse import quote
from .jobs import get_job
from .epanet import EN, EpanetAPI, EpanetException, FLOW_UNITS, PRESS_UNITS, QUAL_TYPES
@@ -186,6 +187,12 @@ async def dem_upload(file: UploadFile = File(...)):
pass
@app.get("/api/dem-base")
async def dem_base():
"""Server-provided DEMs (registered with `python -m backend.dem register ...`)."""
return await _dem_run(dem_store.list_base)
@app.get("/api/dem/{dem_id}")
async def dem_get(dem_id: str):
return await _dem_run(dem_store.dem_info, dem_id)
@@ -219,9 +226,9 @@ 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)
return await _dem_run(dem_store.streams, dem_id, float(body.get("minAreaKm2") or 0.1), body.get("bbox"), body.get("crs"), 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)
return await _dem_run(dem_store.watershed, dem_id, body.get("points") or [], body.get("crs"), float(body.get("snap") or 50), body.get("bbox"), timeout=600)
raise HTTPException(status_code=400, detail="op must be streams or watershed")
@@ -257,6 +264,45 @@ async def gis_reproject(request: Request):
return await _dem_run(gisops.reproject_fc, body.get("featureCollection"), body.get("from"), body.get("to"))
_UESC = re.compile(r"\\U\+([0-9A-Fa-f]{4})")
_CRLF = "\r\n"
def dxf_to_cp949(text: str) -> bytes:
"""R12 DXF from the browser (non-ASCII written as backslash-U+XXXX) -> Korean
code page (ANSI_949), which Korean CAD reads; characters outside CP949 stay escaped."""
def unesc(m: re.Match) -> str:
ch = chr(int(m.group(1), 16))
try:
ch.encode("cp949")
return ch
except UnicodeEncodeError:
return m.group(0)
out = _UESC.sub(unesc, text)
ver = _CRLF.join(["$ACADVER", "1", "AC1009", ""])
out = out.replace(ver, ver + _CRLF.join(["9", "$DWGCODEPAGE", "3", "ANSI_949", ""]), 1)
return out.encode("cp949", errors="replace")
@app.post("/api/drawing/dxf")
async def drawing_dxf(request: Request):
"""Body: {"drawing": {...items}, "name": ...} -> DXF R2018 (ezdxf, UTF-8 text);
legacy {"dxf": "<R12 DXF text>"} -> CP949 R12."""
body = await request.json()
name = str(body.get("name") or "drawing") + ".dxf"
if isinstance(body.get("drawing"), dict):
data = await _dem_run(drawing_to_dxf, body["drawing"], timeout=120)
return Response(content=data, media_type="application/dxf",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(name)}"})
text = str(body.get("dxf") or "")
if not text.startswith("0" + _CRLF + "SECTION") or len(text) > 50_000_000:
raise HTTPException(status_code=400, detail="DXF가 올바르지 않습니다.")
name = str(body.get("name") or "drawing") + ".dxf"
return Response(content=dxf_to_cp949(text), media_type="application/dxf",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(name)}"})
@app.get("/api/health")
def health():
return {"status": "ok"}
+2
View File
@@ -10,3 +10,5 @@ rasterio==1.5.2
pyproj==3.8.0
shapely==2.2.0
scipy==1.18.1
# Drawing output (DXF R2018: Unicode text, lineweights, hatches)
ezdxf==1.4.5
+35
View File
@@ -0,0 +1,35 @@
"""/api/drawing/dxf: browser R12 DXF (Unicode escapes) -> Korean code page (ANSI_949)."""
import os
import tempfile
import pyogrio
from fastapi.testclient import TestClient
from backend.main import app, dxf_to_cp949
DXF = "\r\n".join([
"0", "SECTION", "2", "HEADER", "9", "$ACADVER", "1", "AC1009", "0", "ENDSEC",
"0", "SECTION", "2", "ENTITIES",
"0", "TEXT", "8", "TITLE", "10", "1", "20", "2", "30", "0", "40", "2.5", "1", r"\U+C218\U+B9AC \U+C885\U+B2E8\U+BA74\U+B3C4 \U+1F600",
"0", "ENDSEC", "0", "EOF", "",
])
def test_conversion_reads_back_as_korean():
data = dxf_to_cp949(DXF)
assert b"$DWGCODEPAGE\r\n3\r\nANSI_949" in data
with tempfile.TemporaryDirectory() as tmp:
p = os.path.join(tmp, "t.dxf")
open(p, "wb").write(data)
meta, _, _, fields = pyogrio.raw.read(p)
text = fields[list(meta["fields"]).index("Text")][0]
assert text.startswith("수리 종단면도")
assert r"\U+1F600" in data.decode("cp949") # outside CP949: kept escaped
def test_endpoint():
with TestClient(app) as c:
r = c.post("/api/drawing/dxf", json={"dxf": DXF, "name": "수리 종단면도"})
assert r.status_code == 200 and "attachment" in r.headers["content-disposition"]
assert "수리".encode("cp949") in r.content
assert c.post("/api/drawing/dxf", json={"dxf": "nope"}).status_code == 400
+56
View File
@@ -0,0 +1,56 @@
"""Drawing sheet -> DXF R2018 (ezdxf): Unicode text, layers, lineweights, hatches."""
import io
import os
import tempfile
import ezdxf
import pyogrio
import pytest
from fastapi.testclient import TestClient
from backend.batch import BatchError
from backend.drawing_dxf import drawing_to_dxf
from backend.main import app
DRAWING = {
"width": 420, "height": 297, "title": "t",
"items": [
{"kind": "line", "layer": "FRAME", "pts": [[0, 0], [420, 0], [420, 297], [0, 297]], "closed": True, "width": 0.7},
{"kind": "line", "layer": "HGL_1", "pts": [[50, 200], [300, 150]], "width": 0.45, "color": "#1d4ed8", "dash": "dashed"},
{"kind": "fill", "layer": "LEGEND", "pts": [[10, 10], [30, 10], [30, 20], [10, 20]], "fill": "#ffffff", "opacity": 0.9},
{"kind": "circle", "layer": "NODE", "x": 100, "y": 100, "r": 1.2, "fill": "#dc2626", "color": "#dc2626"},
{"kind": "text", "layer": "TITLE", "x": 210, "y": 280, "text": "송수관로 수리 종단면도 ○ ‰ m³", "size": 7, "anchor": "middle", "vAnchor": "middle", "bold": True},
{"kind": "text", "layer": "TABLE", "x": 60, "y": 60, "text": "J-101", "size": 2, "rotate": 90, "anchor": "middle", "vAnchor": "middle"},
],
}
def test_r2018_with_unicode_layers_and_styles():
data = drawing_to_dxf(DRAWING)
doc = ezdxf.read(io.StringIO(data.decode("utf-8")))
assert doc.dxfversion == "AC1032" and doc.units == ezdxf.units.MM
msp = doc.modelspace()
texts = [e.dxf.text for e in msp.query("TEXT")]
assert "송수관로 수리 종단면도 ○ ‰ m³" in texts
hgl = msp.query('LWPOLYLINE[layer=="HGL_1"]')[0]
assert hgl.dxf.linetype == "DASHED" and hgl.dxf.lineweight == 40 and hgl.rgb == (29, 78, 216)
assert len(msp.query("HATCH")) == 1 and len(msp.query("CIRCLE")) == 1 # white mask box dropped
assert {"FRAME", "HGL_1", "NODE", "TITLE", "TABLE"} <= {l.dxf.name for l in doc.layers}
rotated = [e for e in msp.query("TEXT") if e.dxf.text == "J-101"][0]
assert rotated.dxf.rotation == 90
# GDAL (what GIS tools use) reads the Korean text back too.
with tempfile.TemporaryDirectory() as tmp:
p = os.path.join(tmp, "d.dxf")
open(p, "wb").write(data)
meta, _, _, fields = pyogrio.raw.read(p)
gtexts = [t for t in fields[list(meta["fields"]).index("Text")] if t]
assert any("송수관로" in t for t in gtexts)
def test_errors_and_endpoint():
with pytest.raises(BatchError):
drawing_to_dxf({"items": []})
with TestClient(app) as c:
r = c.post("/api/drawing/dxf", json={"drawing": DRAWING, "name": "종단면도"})
assert r.status_code == 200 and b"AC1032" in r.content
assert "송수관로".encode("utf-8") in r.content
+78
View File
@@ -0,0 +1,78 @@
"""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