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
+743
@@ -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
|
||||
Reference in new issue
Block a user