SPipe 데모 v2 (2026-10-30) 서버 배포본
- SPipe_server_2026-10-30.zip 내용을 풀어서 등록 (압축 파일은 올리지 않음) - README에 데모 v2 서버 업데이트 순서 추가, 저장소에 없는 문서 목차 정리 - 패치내역을 docs/로 이동, _worklog.md 추가 - 임시 파일(backend/temp.*)과 API 키가 든 파일은 제외
This commit is contained in:
commit
21ebb16fb7
181 files changed
+57207
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,155 @@
|
||||
"""HTTP-layer tests for backend/main.py (FastAPI TestClient + real EPANET DLL).
|
||||
|
||||
Run from the repo root: .venv/Scripts/python -m pytest backend/tests -q
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend import main
|
||||
from backend.main import app
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
PUBLIC = REPO / "public"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
with TestClient(app, raise_server_exceptions=False) as c:
|
||||
yield c
|
||||
|
||||
|
||||
def post(client, name: str, data: bytes):
|
||||
return client.post("/api/analyze", files={"file": (name, data, "text/plain")})
|
||||
|
||||
|
||||
SIMPLE = b"""[JUNCTIONS]
|
||||
J1 10 5
|
||||
J2 12 3
|
||||
[RESERVOIRS]
|
||||
R1 50
|
||||
[PIPES]
|
||||
P1 R1 J1 1000 200 100
|
||||
P2 J1 J2 1000 150 100
|
||||
[OPTIONS]
|
||||
Units LPS
|
||||
[END]
|
||||
"""
|
||||
|
||||
|
||||
def test_health(client):
|
||||
r = client.get("/api/health")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"status": "ok"}
|
||||
|
||||
|
||||
def test_net1_runs(client):
|
||||
r = post(client, "Net1.inp", (PUBLIC / "Net1.inp").read_bytes())
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert len(body["nodes"]) == 11 and len(body["links"]) == 13
|
||||
assert body["units"] == {"flow": "GPM", "pressure": "psi"}
|
||||
assert body["reportStep"] > 0
|
||||
assert body["engineWarnings"] == []
|
||||
|
||||
|
||||
def test_wrong_extension_rejected(client):
|
||||
assert post(client, "a.txt", SIMPLE).status_code == 400
|
||||
|
||||
|
||||
def test_oversized_upload_rejected(client):
|
||||
r = post(client, "big.inp", b"x" * (main.MAX_UPLOAD_BYTES + 1))
|
||||
assert r.status_code == 413
|
||||
|
||||
|
||||
def test_early_size_check_not_bypassed_by_bad_host(client):
|
||||
# request.url is rebuilt from Host; the early 413 must not depend on it.
|
||||
big = str(main.MAX_UPLOAD_BYTES + 10 * 1024 * 1024)
|
||||
for host in ("localhost", "bad host:abc", "evil.example/../x"):
|
||||
r = client.post("/api/analyze", headers={"content-length": big, "host": host}, content=b"")
|
||||
assert r.status_code == 413, (host, r.status_code)
|
||||
|
||||
|
||||
def test_invalid_inp_returns_json_with_root_cause(client):
|
||||
# Previously: EN_open failure leaked the .rpt handle, cleanup raised
|
||||
# PermissionError on Windows, and the client got plain-text 500.
|
||||
r = post(client, "bad.inp", b"[JUNCTIONS]\n J1 abc\n[END]\n")
|
||||
assert r.status_code == 500
|
||||
assert r.headers["content-type"].startswith("application/json")
|
||||
detail = r.json()["detail"]
|
||||
assert "Error 200" in detail
|
||||
assert "Error 202" in detail # root cause pulled from the report file
|
||||
|
||||
|
||||
def test_warning_codes_collected_even_with_messages_no(client):
|
||||
# J1 sits above the reservoir head -> negative pressure (warning 6).
|
||||
inp = SIMPLE.replace(b" J1 10 5", b" J1 100 5").replace(
|
||||
b"[OPTIONS]", b"[REPORT]\n Messages No\n[OPTIONS]"
|
||||
)
|
||||
r = post(client, "neg.inp", inp)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert "WARNING" not in body["reportText"]
|
||||
assert any(w["code"] == 6 for w in body["engineWarnings"])
|
||||
|
||||
|
||||
def test_step_amplification_rejected_fast(client):
|
||||
inp = SIMPLE.replace(
|
||||
b"[OPTIONS]", b"[TIMES]\n Duration 10000:00\n Hydraulic Timestep 0:00:01\n[OPTIONS]"
|
||||
)
|
||||
r = post(client, "dos.inp", inp)
|
||||
assert r.status_code == 413
|
||||
assert "Simulation too large" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_columnar_format_carries_identical_values(client):
|
||||
import base64
|
||||
from array import array
|
||||
|
||||
data = (PUBLIC / "Net1.inp").read_bytes() # pump + chlorine: every series kind
|
||||
obj = client.post("/api/analyze", files={"file": ("Net1.inp", data, "text/plain")}).json()
|
||||
col = client.post("/api/analyze?format=columnar", files={"file": ("Net1.inp", data, "text/plain")}).json()
|
||||
assert col["format"] == "spipe-columnar-1"
|
||||
steps = len(obj["timeSteps"])
|
||||
for side, keys in (("nodes", ("demand", "head", "pressure", "quality")), ("links", ("flow", "velocity", "headloss", "quality"))):
|
||||
ids = col[side]["ids"]
|
||||
assert ids == list(obj[side])
|
||||
for key in keys:
|
||||
flat = array("d", base64.b64decode(col[side]["series"][key]))
|
||||
for i, element_id in enumerate(ids):
|
||||
assert list(flat[i * steps:(i + 1) * steps]) == obj[side][element_id]["timeSeries"][key]
|
||||
pump = col["links"]["pumps"]["9"]
|
||||
assert pump["energySeries"] == obj["links"]["9"]["timeSeries"]["energy"]
|
||||
assert pump["totalKwh"] == obj["links"]["9"]["totalKwh"]
|
||||
|
||||
|
||||
def test_overlong_utf8_id_is_engine_error(client):
|
||||
# 12 Korean chars = 36 UTF-8 bytes > EPANET MAXID (31 bytes) -> Error 252.
|
||||
long_id = "상리배수지유출관로제일번".encode("utf-8")
|
||||
inp = SIMPLE.replace(b" J2 12 3", b" " + long_id + b" 12 3").replace(b"J1 J2", b"J1 " + long_id)
|
||||
r = post(client, "longid.inp", inp)
|
||||
assert r.status_code == 500
|
||||
assert "252" in r.json()["detail"]
|
||||
|
||||
|
||||
NET1 = (PUBLIC / "Net1.inp").read_bytes()
|
||||
|
||||
|
||||
def post_job(client, name, inp=SIMPLE, params="{}"):
|
||||
return client.post(f"/api/jobs/{name}", files={"file": ("n.inp", inp, "text/plain")}, data={"params": params})
|
||||
|
||||
|
||||
def test_job_summary_runs(client):
|
||||
r = post_job(client, "summary", NET1)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["junctions"] == 9
|
||||
assert len(body["times"]) == len(body["minPressure"]) == 25
|
||||
assert all(lo <= hi for lo, hi in zip(body["minPressure"], body["maxPressure"]))
|
||||
|
||||
|
||||
def test_job_unknown_and_bad_params(client):
|
||||
assert post_job(client, "nope").status_code == 400
|
||||
assert post_job(client, "summary", params="[1]").status_code == 400
|
||||
assert post_job(client, "summary", params="not json").status_code == 400
|
||||
@@ -0,0 +1,87 @@
|
||||
"""backend/batch.py against the real EPANET DLL."""
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from backend.batch import Network, parse_time
|
||||
from backend.epanet import EN
|
||||
|
||||
NET1 = (Path(__file__).resolve().parents[2] / "public" / "Net1.inp").read_bytes()
|
||||
|
||||
|
||||
def test_open_lookup_and_baseline():
|
||||
with Network(NET1) as net:
|
||||
assert net.node_count == 11 and net.link_count == 13
|
||||
assert net.node_ids[net.node_index("22") - 1] == "22"
|
||||
res = net.simulate(node_props=(EN.PRESSURE,), link_props=(EN.FLOW,))
|
||||
assert len(res.times) == 25 and res.times[0] == 0 and res.times[-1] == 24 * 3600
|
||||
assert res.nodes[EN.PRESSURE].shape == (25, 11)
|
||||
assert res.links[EN.FLOW].shape == (25, 13)
|
||||
|
||||
|
||||
def test_windowed_extra_demand_only_acts_in_window():
|
||||
with Network(NET1) as net:
|
||||
j = net.node_index("22")
|
||||
base = net.simulate(nodes=[j]).nodes[EN.PRESSURE][:, 0]
|
||||
net.window_pattern("FIREWIN", 6 * 3600, 9 * 3600)
|
||||
cat = net.add_demand(j, 500.0, "FIREWIN", "fire")
|
||||
fire = net.simulate(nodes=[j]).nodes[EN.PRESSURE][:, 0]
|
||||
hours = np.arange(25)
|
||||
inside = (hours >= 6) & (hours < 9)
|
||||
assert np.all(fire[inside] < base[inside] - 1.0)
|
||||
# Tank levels carry the draw-down a little past the window; before it, nothing changes.
|
||||
assert np.allclose(fire[hours < 6], base[hours < 6], atol=1e-6)
|
||||
net.delete_demand(j, cat)
|
||||
again = net.simulate(nodes=[j]).nodes[EN.PRESSURE][:, 0]
|
||||
assert np.allclose(again, base, atol=1e-6)
|
||||
|
||||
|
||||
def test_timer_control_closes_pipe_in_window():
|
||||
with Network(NET1) as net:
|
||||
p = net.link_index("12")
|
||||
c1 = net.add_timer_control(p, 0, 3 * 3600)
|
||||
c2 = net.add_timer_control(p, 1, 5 * 3600)
|
||||
flow = net.simulate(node_props=(), link_props=(EN.FLOW,), links=[p]).links[EN.FLOW][:, 0]
|
||||
assert abs(flow[3]) < 1e-6 and abs(flow[4]) < 1e-6
|
||||
assert abs(flow[2]) > 1e-3 and abs(flow[6]) > 1e-3
|
||||
net.delete_control(c2)
|
||||
net.delete_control(c1)
|
||||
|
||||
|
||||
def test_parse_time():
|
||||
assert parse_time("6:30") == 6 * 3600 + 1800
|
||||
assert parse_time(2.5) == 9000
|
||||
assert parse_time(None, 7) == 7
|
||||
|
||||
|
||||
def test_fireflow_job_and_available_flow():
|
||||
from backend.jobs import job_fireflow
|
||||
|
||||
r = job_fireflow(NET1, {"nodes": ["22"], "flow": 500, "start": 6, "end": 9, "residual": 20, "threshold": 50})
|
||||
assert r["windowLabel"] == "6:00~9:00"
|
||||
h = r["hydrants"][0]
|
||||
assert h["firePressure"] < h["basePressure"] - 1
|
||||
assert r["nodes"][0]["diff"] <= r["nodes"][-1]["diff"] # most affected first
|
||||
# available flow keeps the hydrant at (just above) the residual pressure
|
||||
q = h["available"]
|
||||
assert q > 0
|
||||
with Network(NET1) as net:
|
||||
j = net.node_index("22")
|
||||
net.window_pattern("W", 6 * 3600, 9 * 3600)
|
||||
c = net.add_demand(j, q, "W", "x")
|
||||
p = net.simulate(nodes=[j]).nodes[EN.PRESSURE][6:9, 0].min()
|
||||
assert p >= 20 - 1e-6
|
||||
net.delete_demand(j, c)
|
||||
net.add_demand(j, q * 1.02, "W", "x")
|
||||
assert net.simulate(nodes=[j]).nodes[EN.PRESSURE][6:9, 0].min() < 20 + 0.5
|
||||
|
||||
|
||||
def test_fireflow_rejects_bad_input():
|
||||
import pytest
|
||||
from backend.batch import BatchError
|
||||
from backend.jobs import job_fireflow
|
||||
|
||||
with pytest.raises(BatchError):
|
||||
job_fireflow(NET1, {"nodes": [], "flow": 10})
|
||||
with pytest.raises(BatchError):
|
||||
job_fireflow(NET1, {"nodes": ["NOPE"], "flow": 10})
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Automatic calibration (roughness / wall coefficient) and pump scheduling jobs, on Net1 with synthetic truth."""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.batch import BatchError, Network
|
||||
from backend.epanet import EN
|
||||
from backend.jobs import job_calibrate, job_pumpschedule
|
||||
|
||||
NET1 = (Path(__file__).resolve().parents[2] / "public" / "Net1.inp").read_text()
|
||||
PIPES = [l.split()[0] for l in re.search(r"\[PIPES\](.*?)\[", NET1, re.S).group(1).splitlines() if l.strip() and not l.strip().startswith(";")]
|
||||
|
||||
|
||||
def synthetic(factor=None, wall=None, prop=EN.PRESSURE, locs=("22", "31", "12", "23"), groups=None):
|
||||
with Network(NET1.encode()) as n:
|
||||
idx = [n.node_index(l) for l in locs]
|
||||
for pid in PIPES:
|
||||
li = n.link_index(pid)
|
||||
f = factor(pid) if callable(factor) else factor
|
||||
if f:
|
||||
n.set_link(li, EN.ROUGHNESS, n.get_link(li, EN.ROUGHNESS) * f)
|
||||
if wall is not None:
|
||||
n.set_link(li, EN.KWALL, wall)
|
||||
if prop == EN.QUALITY:
|
||||
n.lib.EN_solveH(n.ph)
|
||||
times, mat = n.run_quality(nodes=idx)
|
||||
else:
|
||||
r = n.simulate(node_props=(prop,), nodes=idx)
|
||||
times, mat = r.times, r.nodes[prop]
|
||||
return [{"location": l, "time": t, "value": float(mat[k, c])} for k, t in enumerate(times) if k % 2 == 0 for c, l in enumerate(locs)]
|
||||
|
||||
|
||||
def test_roughness_recovers_two_group_truth():
|
||||
big = {"10", "11", "12", "111", "110"}
|
||||
obs = synthetic(factor=lambda p: 0.75 if p in big else 1.1, locs=("10", "11", "12", "13", "21", "22", "23", "31", "32"))
|
||||
groups = {"main": sorted(big), "rest": [p for p in PIPES if p not in big]}
|
||||
r = job_calibrate(NET1.encode(), {"mode": "roughness", "param": "pressure", "observations": obs, "groups": groups, "bounds": [0.5, 1.5]})
|
||||
got = {g["name"]: g["value"] for g in r["groups"]}
|
||||
assert got["main"] == pytest.approx(0.75, abs=0.02) and got["rest"] == pytest.approx(1.1, abs=0.05)
|
||||
assert r["rmseAfter"] < 0.05 * r["rmseBefore"]
|
||||
|
||||
|
||||
def test_wall_coefficient_from_chlorine():
|
||||
obs = synthetic(wall=-0.4, prop=EN.QUALITY)
|
||||
r = job_calibrate(NET1.encode(), {"mode": "wall", "param": "quality", "observations": obs, "groups": {"all": PIPES}, "bounds": [-3, 0]})
|
||||
assert r["groups"][0]["value"] == pytest.approx(-0.4, abs=0.01) and r["groups"][0]["meanBefore"] == -1.0
|
||||
|
||||
|
||||
def test_calibrate_input_errors():
|
||||
with pytest.raises(BatchError):
|
||||
job_calibrate(NET1.encode(), {"mode": "wall", "param": "pressure", "observations": synthetic(), "groups": {"a": PIPES}})
|
||||
with pytest.raises(BatchError):
|
||||
job_calibrate(NET1.encode(), {"observations": [], "groups": {"a": PIPES}})
|
||||
with pytest.raises(BatchError):
|
||||
job_calibrate(NET1.encode(), {"observations": synthetic(), "groups": {}})
|
||||
|
||||
|
||||
def test_pump_schedule_keeps_constraints_and_reports_controls():
|
||||
priced = NET1.replace("[PATTERNS]", "[PATTERNS]\n PRICE 0.5 0.5 0.5 1.5 1.5 1.5 1.5 1.5 1.5 1.5 1.5 0.5")
|
||||
priced = re.sub(r"Global Price\s+0\.0", "Global Price 0.1\n Global Pattern PRICE", priced)
|
||||
r = job_pumpschedule(priced.encode(), {"minPressure": 20})
|
||||
assert r["pumps"] == ["9"] and r["removedControls"] == 2 and r["slotSeconds"] == 7200
|
||||
assert len(r["schedules"]["9"]) == 12 and r["feasible"]
|
||||
assert r["after"]["violation"] == 0 and r["after"]["minPressure"] >= 20
|
||||
assert r["after"]["tankEnd"][0] >= 120 - 0.02 * 50 - 1e-6
|
||||
# never worse than the feasible starting schedule
|
||||
assert r["after"]["cost"] <= r["start"]["cost"] + 1e-9
|
||||
# Net1's own controls leave the tank ~4.6 ft lower after a day: reported, not hidden.
|
||||
assert r["before"]["violation"] > 0
|
||||
@@ -0,0 +1,131 @@
|
||||
"""DEM store: import (GeoTIFF, zipped ASC), bilinear sampling across CRSs, preview, TIN from points, HTTP."""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import rasterio
|
||||
from fastapi.testclient import TestClient
|
||||
from pyproj import CRS, Transformer
|
||||
from rasterio.transform import from_origin
|
||||
|
||||
from backend import dem as dem_store
|
||||
from backend.batch import BatchError
|
||||
from backend.main import app
|
||||
|
||||
EPSG = "EPSG:5186"
|
||||
X0, Y0, CELL, W, H = 200_000.0, 550_000.0, 10.0, 60, 40 # top-left corner
|
||||
|
||||
|
||||
def plane(x, y):
|
||||
return 100 + 0.01 * (x - X0) - 0.02 * (Y0 - y)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def store(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(dem_store, "DEM_DIR", tmp_path / "dem")
|
||||
|
||||
|
||||
def make_tif(path):
|
||||
xs = X0 + (np.arange(W) + 0.5) * CELL
|
||||
ys = Y0 - (np.arange(H) + 0.5) * CELL
|
||||
z = plane(*np.meshgrid(xs, ys)).astype("float32")
|
||||
z[0, 0] = -9999
|
||||
with rasterio.open(path, "w", driver="GTiff", width=W, height=H, count=1, dtype="float32", crs=EPSG,
|
||||
transform=from_origin(X0, Y0, CELL, CELL), nodata=-9999) as dst:
|
||||
dst.write(z, 1)
|
||||
|
||||
|
||||
def test_import_sample_is_bilinear_and_handles_outside(tmp_path):
|
||||
src = tmp_path / "up.bin"
|
||||
make_tif(tmp_path / "a.tif")
|
||||
src.write_bytes((tmp_path / "a.tif").read_bytes())
|
||||
info = dem_store.import_dem(str(src), "a.tif")
|
||||
assert info["width"] == W and info["resolution"] == [CELL, CELL] and len(info["corners"]) == 4
|
||||
pts = [[X0 + 123.4, Y0 - 77.7], [X0 + 300, Y0 - 200], [X0 - 50, Y0 - 50]]
|
||||
vals = dem_store.sample(info["id"], pts, EPSG)
|
||||
assert vals[0] == pytest.approx(plane(*pts[0]), abs=1e-3)
|
||||
assert vals[1] == pytest.approx(plane(*pts[1]), abs=1e-3)
|
||||
assert vals[2] is None
|
||||
# Same point given in WGS84 lands on the same value.
|
||||
lon, lat = Transformer.from_crs(EPSG, "EPSG:4326", always_xy=True).transform(*pts[0])
|
||||
assert dem_store.sample(info["id"], [[lon, lat]], "EPSG:4326")[0] == pytest.approx(vals[0], abs=1e-3)
|
||||
assert dem_store.preview_png(info["id"])[:8] == b"\x89PNG\r\n\x1a\n"
|
||||
assert dem_store.dem_info(info["id"])["id"] == info["id"]
|
||||
|
||||
|
||||
def test_zipped_asc_with_prj(tmp_path):
|
||||
xs = X0 + (np.arange(W) + 0.5) * CELL
|
||||
ys = Y0 - (np.arange(H) + 0.5) * CELL
|
||||
z = plane(*np.meshgrid(xs, ys))
|
||||
asc = f"ncols {W}\nnrows {H}\nxllcorner {X0}\nyllcorner {Y0 - H * CELL}\ncellsize {CELL}\nNODATA_value -9999\n"
|
||||
asc += "\n".join(" ".join(f"{v:.4f}" for v in row) for row in z)
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("dem/ground.asc", asc)
|
||||
zf.writestr("dem/ground.prj", CRS.from_user_input(EPSG).to_wkt("WKT1_ESRI"))
|
||||
up = tmp_path / "up.zip"
|
||||
up.write_bytes(buf.getvalue())
|
||||
info = dem_store.import_dem(str(up), "ground.zip")
|
||||
p = [X0 + 250, Y0 - 150]
|
||||
assert dem_store.sample(info["id"], [p], EPSG)[0] == pytest.approx(plane(*p), abs=1e-3)
|
||||
|
||||
|
||||
def test_errors(tmp_path):
|
||||
with pytest.raises(BatchError):
|
||||
dem_store.dem_info("0" * 32)
|
||||
with pytest.raises(BatchError):
|
||||
dem_store.dem_info("../../etc")
|
||||
bad = tmp_path / "x.bin"
|
||||
bad.write_bytes(b"not a raster")
|
||||
with pytest.raises(BatchError):
|
||||
dem_store.import_dem(str(bad), "x.tif")
|
||||
with pytest.raises(BatchError):
|
||||
dem_store.import_dem(str(bad), "x.docx")
|
||||
|
||||
|
||||
def test_tin_from_points_reproduces_a_plane():
|
||||
rng = np.random.default_rng(1)
|
||||
xy = np.column_stack([X0 + rng.uniform(0, 500, 300), Y0 - rng.uniform(0, 300, 300)])
|
||||
pts = [[x, y, plane(x, y)] for x, y in xy]
|
||||
pts += [[X0, Y0, plane(X0, Y0)], [X0 + 500, Y0, plane(X0 + 500, Y0)], [X0, Y0 - 300, plane(X0, Y0 - 300)], [X0 + 500, Y0 - 300, plane(X0 + 500, Y0 - 300)]]
|
||||
info = dem_store.from_points(pts, EPSG, 5.0)
|
||||
q = [X0 + 211, Y0 - 133]
|
||||
assert dem_store.sample(info["id"], [q], EPSG)[0] == pytest.approx(plane(*q), abs=0.05)
|
||||
with pytest.raises(BatchError):
|
||||
dem_store.from_points([[0, 0, 1], [1, 1, 2], [2, 2, 3]], EPSG, 1.0) # collinear
|
||||
|
||||
|
||||
def test_http_roundtrip(tmp_path):
|
||||
make_tif(tmp_path / "a.tif")
|
||||
with TestClient(app) as c:
|
||||
r = c.post("/api/dem", files={"file": ("a.tif", (tmp_path / "a.tif").read_bytes(), "image/tiff")})
|
||||
assert r.status_code == 200, r.text
|
||||
dem_id = r.json()["id"]
|
||||
assert c.get(f"/api/dem/{dem_id}/preview.png").headers["content-type"] == "image/png"
|
||||
s = c.post(f"/api/dem/{dem_id}/sample", json={"points": [[X0 + 100, Y0 - 100]], "crs": EPSG})
|
||||
assert s.json()["values"][0] == pytest.approx(plane(X0 + 100, Y0 - 100), abs=1e-3)
|
||||
assert c.get("/api/dem/" + "f" * 32).status_code == 400
|
||||
assert c.post("/api/dem", files={"file": ("a.txt", b"x", "text/plain")}).status_code == 400
|
||||
|
||||
|
||||
def test_terrain_tile_terrarium(tmp_path):
|
||||
make_tif(tmp_path / "a.tif")
|
||||
up = tmp_path / "up.bin"
|
||||
up.write_bytes((tmp_path / "a.tif").read_bytes())
|
||||
info = dem_store.import_dem(str(up), "a.tif")
|
||||
import math
|
||||
lon, lat = Transformer.from_crs(EPSG, "EPSG:4326", always_xy=True).transform(X0 + 300, Y0 - 200)
|
||||
z = 15
|
||||
n = 2 ** z
|
||||
fx = (lon + 180) / 360 * n
|
||||
fy = (1 - math.asinh(math.tan(math.radians(lat))) / math.pi) / 2 * n
|
||||
xt, yt = int(fx), int(fy)
|
||||
png = dem_store.terrain_tile(info["id"], z, xt, yt)
|
||||
with rasterio.MemoryFile(png) as mem, mem.open() as ds:
|
||||
rgb = ds.read().astype(float)
|
||||
elev = rgb[0] * 256 + rgb[1] + rgb[2] / 256 - 32768
|
||||
px, py = int((fx - xt) * 256), int((fy - yt) * 256)
|
||||
assert elev[py, px] == pytest.approx(plane(X0 + 300, Y0 - 200), abs=0.2)
|
||||
with pytest.raises(BatchError):
|
||||
dem_store.terrain_tile(info["id"], 3, 99, 0)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Vector GIS import (/api/gis/vector) with real GDAL files written on the fly."""
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import shapely
|
||||
from fastapi.testclient import TestClient
|
||||
from pyogrio import raw as ogr_raw
|
||||
|
||||
from backend.main import app
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
with TestClient(app, raise_server_exceptions=False) as c:
|
||||
yield c
|
||||
|
||||
|
||||
def _write(path, driver, crs="EPSG:5187"):
|
||||
geoms = [shapely.LineString([(200000, 300000), (200100, 300050)]), shapely.LineString([(200100, 300050), (200200, 300000)])]
|
||||
with_fields = driver != "DXF" # DXF has a fixed schema (Layer, EntityHandle ...)
|
||||
ogr_raw.write(
|
||||
path,
|
||||
geometry=np.array(shapely.to_wkb(geoms), dtype=object),
|
||||
field_data=[np.array([150, 200], dtype=np.int32), np.array(["DCIP", "PE"], dtype=object)] if with_fields else [],
|
||||
fields=["DIA", "MAT"] if with_fields else [],
|
||||
geometry_type="LineString",
|
||||
crs=crs,
|
||||
driver=driver,
|
||||
)
|
||||
|
||||
|
||||
def test_zipped_shapefile_reprojected_to_wgs84(client):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
shp = os.path.join(tmp, "pipes.shp")
|
||||
_write(shp, "ESRI Shapefile")
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as z:
|
||||
for f in os.listdir(tmp):
|
||||
z.write(os.path.join(tmp, f), f)
|
||||
r = client.post("/api/gis/vector", files={"file": ("pipes.zip", buf.getvalue(), "application/zip")})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["crs"] == "EPSG:5187" and body["count"] == 2
|
||||
feat = body["featureCollection"]["features"][0]
|
||||
assert feat["properties"] == {"DIA": 150, "MAT": "DCIP"}
|
||||
lon, lat = feat["geometry"]["coordinates"][0]
|
||||
assert 127 < lon < 132 and 33 < lat < 39 # East Belt -> somewhere in Korea
|
||||
|
||||
|
||||
def test_dxf_without_crs_needs_src_epsg(client):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
dxf = os.path.join(tmp, "pipes.dxf")
|
||||
_write(dxf, "DXF", crs=None)
|
||||
data = open(dxf, "rb").read()
|
||||
raw = client.post("/api/gis/vector", files={"file": ("pipes.dxf", data, "application/dxf")}).json()
|
||||
assert raw["reprojected"] is False and raw["count"] >= 2
|
||||
fixed = client.post("/api/gis/vector", files={"file": ("pipes.dxf", data, "application/dxf")}, data={"srcEpsg": "5187"}).json()
|
||||
lon, lat = fixed["featureCollection"]["features"][0]["geometry"]["coordinates"][0]
|
||||
assert 127 < lon < 132 and 33 < lat < 39
|
||||
|
||||
|
||||
def test_rejects_unknown_extension_and_bad_epsg(client):
|
||||
assert client.post("/api/gis/vector", files={"file": ("a.exe", b"MZ", "application/octet-stream")}).status_code == 400
|
||||
assert client.post("/api/gis/vector", files={"file": ("a.dxf", b"0", "x")}, data={"srcEpsg": "abc"}).status_code == 400
|
||||
assert client.post("/api/gis/vector", files={"file": ("broken.dxf", b"not a dxf", "x")}).status_code == 400
|
||||
@@ -0,0 +1,111 @@
|
||||
"""GIS layer export / reprojection / geometry operations (/api/gis/export, /op, /reproject)."""
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
import numpy as np
|
||||
import pyogrio
|
||||
import pytest
|
||||
import shapely
|
||||
from fastapi.testclient import TestClient
|
||||
from pyproj import Transformer
|
||||
|
||||
from backend import gisops
|
||||
from backend.batch import BatchError
|
||||
from backend.main import app
|
||||
|
||||
EPSG = "EPSG:5186"
|
||||
TO_WGS = Transformer.from_crs(EPSG, "EPSG:4326", always_xy=True)
|
||||
|
||||
|
||||
def wgs(x, y):
|
||||
return list(TO_WGS.transform(x, y))
|
||||
|
||||
|
||||
def fc(features):
|
||||
return {"type": "FeatureCollection", "features": features}
|
||||
|
||||
|
||||
SQUARE = fc([{"type": "Feature", "properties": {"name": "블록A", "dia": 150}, "geometry": {"type": "Polygon", "coordinates": [[wgs(200000, 500000), wgs(200100, 500000), wgs(200100, 500100), wgs(200000, 500100), wgs(200000, 500000)]]}}])
|
||||
LINES = fc([
|
||||
{"type": "Feature", "properties": {"id": "P1", "dia": 150}, "geometry": {"type": "LineString", "coordinates": [wgs(200000, 500000), wgs(200200, 500000)]}},
|
||||
{"type": "Feature", "properties": {"id": "P2", "dia": None}, "geometry": {"type": "LineString", "coordinates": [wgs(200200, 500000), wgs(200200, 500200)]}},
|
||||
])
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
with TestClient(app, raise_server_exceptions=False) as c:
|
||||
yield c
|
||||
|
||||
|
||||
def _area_m2(feature_collection):
|
||||
from shapely.geometry import shape
|
||||
g = gisops._project(np.array([shape(f["geometry"]) for f in feature_collection["features"]], dtype=object), gisops.WGS84, gisops.CRS.from_user_input(EPSG))
|
||||
return float(sum(x.area for x in g))
|
||||
|
||||
|
||||
def test_export_shapefile_in_target_crs_with_korean_fields():
|
||||
data, name, media = gisops.write_vector(LINES, EPSG, "shp", "관로")
|
||||
assert name == "관로.zip" and media == "application/zip"
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
zipfile.ZipFile(io.BytesIO(data)).extractall(tmp)
|
||||
shp = [f for f in os.listdir(tmp) if f.endswith(".shp")][0]
|
||||
df_info = pyogrio.read_info(os.path.join(tmp, shp))
|
||||
assert "5186" in df_info["crs"] and df_info["features"] == 2
|
||||
meta, _, geom, fields = pyogrio.raw.read(os.path.join(tmp, shp))
|
||||
x0 = shapely.from_wkb(geom[0]).coords[0][0]
|
||||
assert x0 == pytest.approx(200000, abs=0.01)
|
||||
assert list(meta["fields"]) == ["id", "dia"]
|
||||
|
||||
|
||||
def test_export_gpkg_dxf_and_mixed_shapefile_refused():
|
||||
gpkg, name, _ = gisops.write_vector(SQUARE, EPSG, "gpkg", "blocks")
|
||||
assert name == "blocks.gpkg" and gpkg[:15] == b"SQLite format 3"
|
||||
dxf, _, _ = gisops.write_vector(LINES, EPSG, "dxf", "lines")
|
||||
assert b"SECTION" in dxf
|
||||
mixed = fc(SQUARE["features"] + LINES["features"])
|
||||
with pytest.raises(BatchError):
|
||||
gisops.write_vector(mixed, EPSG, "shp", "x")
|
||||
|
||||
|
||||
def test_reproject_fixes_a_layer_read_in_the_wrong_crs():
|
||||
# Numbers really in EPSG:5186 but read as EPSG:5187: reassign -> lands on the 5186 position.
|
||||
wrong = Transformer.from_crs("EPSG:5187", "EPSG:4326", always_xy=True)
|
||||
bad = fc([{"type": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": list(wrong.transform(200000, 500000))}}])
|
||||
fixed = gisops.reproject_fc(bad, "EPSG:5187", EPSG)
|
||||
assert fixed["features"][0]["geometry"]["coordinates"] == pytest.approx(wgs(200000, 500000), abs=1e-8)
|
||||
|
||||
|
||||
def test_buffer_clip_difference_dissolve():
|
||||
buf = gisops.vector_op("buffer", {"crs": EPSG, "layer": LINES, "distance": 10})
|
||||
assert len(buf["features"]) == 2 and buf["features"][0]["properties"]["id"] == "P1"
|
||||
# P1 buffered: 200 x 20 rectangle + two half-discs of r=10
|
||||
from shapely.geometry import shape
|
||||
a = gisops._project(np.array([shape(buf["features"][0]["geometry"])], dtype=object), gisops.WGS84, gisops.CRS.from_user_input(EPSG))[0].area
|
||||
assert a == pytest.approx(200 * 20 + np.pi * 100, rel=0.01)
|
||||
clipped = gisops.vector_op("clip", {"crs": EPSG, "layer": LINES, "other": SQUARE})
|
||||
assert len(clipped["features"]) == 1 # only P1 crosses the square
|
||||
diff = gisops.vector_op("difference", {"crs": EPSG, "layer": SQUARE, "other": buf})
|
||||
assert _area_m2(diff) < 100 * 100
|
||||
dis = gisops.vector_op("dissolve", {"crs": EPSG, "layer": buf, "field": None})
|
||||
assert len(dis["features"]) == 1 and dis["features"][0]["properties"]["count"] == 2
|
||||
|
||||
|
||||
def test_voronoi_and_grid():
|
||||
pts = [[200000, 500000], [200100, 500000], [200000, 500100], [200100, 500100], [200050, 500050]]
|
||||
v = gisops.vector_op("voronoi", {"crs": EPSG, "points": pts, "ids": ["A", "B", "C", "D", "E"]})
|
||||
assert sorted(f["properties"]["node"] for f in v["features"]) == ["A", "B", "C", "D", "E"]
|
||||
g = gisops.vector_op("grid", {"crs": EPSG, "bounds": [200000, 500000, 200100, 500050], "cell": 25})
|
||||
assert len(g["features"]) == 8
|
||||
with pytest.raises(BatchError):
|
||||
gisops.vector_op("grid", {"crs": "EPSG:4326", "bounds": [0, 0, 1, 1], "cell": 1})
|
||||
|
||||
|
||||
def test_http(client):
|
||||
r = client.post("/api/gis/export", json={"featureCollection": LINES, "crs": EPSG, "format": "geojson", "name": "관로"})
|
||||
assert r.status_code == 200 and "attachment" in r.headers["content-disposition"]
|
||||
r = client.post("/api/gis/op", json={"op": "buffer", "crs": EPSG, "layer": LINES, "distance": 5})
|
||||
assert r.status_code == 200 and len(r.json()["features"]) == 2
|
||||
assert client.post("/api/gis/op", json={"op": "nope", "crs": EPSG, "layer": LINES}).status_code == 400
|
||||
@@ -0,0 +1,59 @@
|
||||
"""DEM hydrology: streams follow a synthetic valley; the outlet's catchment is the whole valley."""
|
||||
import numpy as np
|
||||
import pytest
|
||||
import rasterio
|
||||
from pyproj import Transformer
|
||||
from rasterio.transform import from_origin
|
||||
from shapely.geometry import shape
|
||||
|
||||
from backend import dem as dem_store
|
||||
|
||||
EPSG = "EPSG:5186"
|
||||
X0, Y1, CELL, W, H = 200_000.0, 550_000.0, 10.0, 81, 120 # valley axis at column 40
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def store(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(dem_store, "DEM_DIR", tmp_path / "dem")
|
||||
|
||||
|
||||
def valley(tmp_path):
|
||||
xs = X0 + (np.arange(W) + 0.5) * CELL
|
||||
ys = Y1 - (np.arange(H) + 0.5) * CELL
|
||||
xx, yy = np.meshgrid(xs, ys)
|
||||
axis = X0 + 40.5 * CELL
|
||||
z = 100 + 0.05 * np.abs(xx - axis) + 0.01 * (yy - (Y1 - H * CELL)) # V valley draining south
|
||||
z[30, 40] -= 3 # a pit on the axis must not stop the flow
|
||||
p = tmp_path / "v.tif"
|
||||
with rasterio.open(p, "w", driver="GTiff", width=W, height=H, count=1, dtype="float32", crs=EPSG,
|
||||
transform=from_origin(X0, Y1, CELL, CELL)) as d:
|
||||
d.write(z.astype("float32"), 1)
|
||||
up = tmp_path / "up.bin"
|
||||
up.write_bytes(p.read_bytes())
|
||||
return dem_store.import_dem(str(up), "v.tif")
|
||||
|
||||
|
||||
def test_streams_follow_the_axis_through_a_pit(tmp_path):
|
||||
info = valley(tmp_path)
|
||||
fc = dem_store.streams(info["id"], 0.05) # 0.05 km2 = 500 cells
|
||||
assert fc["features"]
|
||||
to_xy = Transformer.from_crs("EPSG:4326", EPSG, always_xy=True)
|
||||
axis = X0 + 40.5 * CELL
|
||||
longest = max(fc["features"], key=lambda f: f["properties"]["length_m"])
|
||||
xs = [to_xy.transform(*c)[0] for c in longest["geometry"]["coordinates"]]
|
||||
assert max(abs(x - axis) for x in xs) < CELL * 1.5
|
||||
assert longest["properties"]["length_m"] > 0.5 * H * CELL
|
||||
assert longest["properties"]["area_km2"] == pytest.approx(W * H * CELL * CELL / 1e6, rel=0.1)
|
||||
|
||||
|
||||
def test_watershed_of_the_outlet_is_the_whole_valley(tmp_path):
|
||||
info = valley(tmp_path)
|
||||
outlet = [X0 + 40.5 * CELL, Y1 - (H - 1) * CELL]
|
||||
fc = dem_store.watershed(info["id"], [outlet], EPSG, snap_m=30)
|
||||
(f,) = fc["features"]
|
||||
assert f["properties"]["area_km2"] == pytest.approx(W * H * CELL * CELL / 1e6, rel=0.05)
|
||||
assert shape(f["geometry"]).is_valid
|
||||
# A point halfway up drains only the upper part.
|
||||
mid = [X0 + 40.5 * CELL, Y1 - (H / 2) * CELL]
|
||||
half = dem_store.watershed(info["id"], [mid], EPSG, snap_m=30)["features"][0]["properties"]["area_km2"]
|
||||
assert 0.3 * f["properties"]["area_km2"] < half < 0.7 * f["properties"]["area_km2"]
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Criticality, service area (trace), VSP and energy jobs against the real DLL."""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.batch import BatchError
|
||||
from backend.jobs import job_criticality, job_energy, job_servicearea, job_vsp
|
||||
|
||||
PUBLIC = Path(__file__).resolve().parents[2] / "public"
|
||||
NET1 = (PUBLIC / "Net1.inp").read_bytes()
|
||||
NET3 = (PUBLIC / "Net3.inp").read_bytes()
|
||||
|
||||
|
||||
def test_criticality_ranks_the_supply_main_first():
|
||||
r = job_criticality(NET1, {"threshold": 40})
|
||||
assert len(r["links"]) == 12 # pipes only, the pump is skipped
|
||||
top = r["links"][0]
|
||||
# Pipe 10 is the only path from the source: closing it cuts off every junction.
|
||||
assert top["id"] == "10" and top["affectedNodes"] == 8 and top["disconnected"]
|
||||
loops = [e for e in r["links"] if e["id"] in ("111", "112", "121", "122")]
|
||||
assert all(e["affectedNodes"] == 0 for e in loops)
|
||||
|
||||
|
||||
def test_criticality_subset_and_limits():
|
||||
r = job_criticality(NET1, {"threshold": 40, "links": ["111"]})
|
||||
assert [e["id"] for e in r["links"]] == ["111"]
|
||||
with pytest.raises(BatchError):
|
||||
job_criticality(NET1, {"links": ["nope"]})
|
||||
|
||||
|
||||
def test_service_area_shares_sum_to_at_most_100():
|
||||
r = job_servicearea(NET3, {})
|
||||
assert r["sources"] == ["River", "Lake"]
|
||||
assert r["unreached"] == 0 and r["notes"] # 24 h run extended to 72 h
|
||||
assert max(sum(v["shares"].values()) for v in r["nodes"].values()) <= 100.5
|
||||
assert r["nodes"]["River"]["source"] == "River" and r["nodes"]["Lake"]["source"] == "Lake"
|
||||
|
||||
|
||||
def test_vsp_hits_the_target_or_reports_the_bound():
|
||||
r = job_vsp(NET1, {"pump": "9", "node": "31", "target": 125, "hours": [0, 18], "maxSpeed": 1.5})
|
||||
by_hour = {row["hour"]: row for row in r["rows"]}
|
||||
assert by_hour[0]["status"] == "max" and by_hour[0]["pressure"] < 125
|
||||
assert by_hour[18]["status"] == "ok" and abs(by_hour[18]["pressure"] - 125) < 0.05
|
||||
assert 1.0 < by_hour[18]["speed"] < 1.5
|
||||
with pytest.raises(BatchError):
|
||||
job_vsp(NET1, {"pump": "10", "node": "31", "target": 100}) # a pipe
|
||||
|
||||
|
||||
def test_energy_report_matches_epanet_energy_section():
|
||||
r = job_energy(NET1, {})
|
||||
(p,) = r["pumps"]
|
||||
# EPANET's own Net1 energy report: 57.71 % utilisation, 75 % efficiency, ~96 kW average.
|
||||
assert p["utilization"] == pytest.approx(57.71, abs=0.05)
|
||||
assert p["efficiency"] == pytest.approx(75.0)
|
||||
assert p["avgKw"] == pytest.approx(96.25, abs=0.5)
|
||||
assert p["kwhPerM3"] > 0 and r["totalCost"] == 0.0 # Net1 has no price
|
||||
priced = re.sub(rb"Global Price\s+0\.0", b"Global Price 0.1", NET1)
|
||||
assert priced != NET1
|
||||
assert job_energy(priced, {})["pumps"][0]["cost"] == pytest.approx(p["kwh"] * 0.1, rel=1e-3)
|
||||
Reference in new issue
Block a user