- SPipe_server_2026-10-30.zip 내용을 풀어서 등록 (압축 파일은 올리지 않음) - README에 데모 v2 서버 업데이트 순서 추가, 저장소에 없는 문서 목차 정리 - 패치내역을 docs/로 이동, _worklog.md 추가 - 임시 파일(backend/temp.*)과 API 키가 든 파일은 제외
743 lines
33 KiB
Python
743 lines
33 KiB
Python
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 |