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

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)