Spaces:
Sleeping
Sleeping
| """ | |
| TSNet Transient Simulator β FastAPI Backend | |
| =========================================== | |
| Designed for use as a Custom GPT Action endpoint. | |
| Deployed on Hugging Face Spaces (port 7860). | |
| Endpoints: | |
| GET / β API info | |
| GET /health β Health check (for GPT Action verification) | |
| POST /simulate β Run MOC transient simulation | |
| POST /network-info β Inspect .inp file without simulating | |
| """ | |
| import sys | |
| import os | |
| import tempfile | |
| import traceback | |
| # Ensure bundled tsnet package is importable | |
| sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) | |
| from fastapi import FastAPI, HTTPException | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from pydantic import BaseModel, Field | |
| from typing import Optional, Literal | |
| import numpy as np | |
| # ββ TSNet imports βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| try: | |
| from tsnet.network.model import TransientModel | |
| from tsnet.simulation import MOCSimulator, Initializer | |
| TSNET_OK = True | |
| except Exception as e: | |
| TSNET_OK = False | |
| TSNET_ERR = str(e) | |
| # ββ App βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app = FastAPI( | |
| title="TSNet Transient Simulator API", | |
| description="Water-hammer MOC simulation API for Custom GPT Actions", | |
| version="1.0.0", | |
| ) | |
| # Allow GPT Action calls (OpenAI origin) | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # ββ Request / Response models βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class SimRequest(BaseModel): | |
| inp_content: str = Field( | |
| ..., | |
| description="Full EPANET .inp file content as a plain string" | |
| ) | |
| wave_speed: float = Field( | |
| 1200.0, ge=100.0, le=2000.0, | |
| description="Acoustic wave speed in m/s (100β2000)" | |
| ) | |
| duration: float = Field( | |
| 10.0, ge=0.5, le=300.0, | |
| description="Simulation duration in seconds" | |
| ) | |
| dt: float = Field( | |
| 0.01, ge=0.001, le=1.0, | |
| description="Time step in seconds (must satisfy CFL: dt β€ L_min/2a)" | |
| ) | |
| engine: Literal["DD", "PDD"] = Field( | |
| "DD", | |
| description="Hydraulic engine: DD = demand-driven, PDD = pressure-dependent" | |
| ) | |
| friction: Literal["steady", "quasi-steady", "unsteady"] = Field( | |
| "steady", | |
| description="Friction model" | |
| ) | |
| event_type: Optional[Literal[ | |
| "valve_closure", "valve_opening", "pump_shutoff", "none" | |
| ]] = Field( | |
| "none", | |
| description="Transient event type" | |
| ) | |
| element_id: Optional[str] = Field( | |
| None, | |
| description="Valve or pump element name from the .inp file" | |
| ) | |
| tc: float = Field(0.0, ge=0.0, description="Event duration in seconds") | |
| ts: float = Field(2.0, ge=0.0, description="Event start time in seconds") | |
| se: float = Field(0.0, ge=0.0, le=1.0, | |
| description="Final open fraction (0=closed, 1=open)") | |
| m: float = Field(1.0, ge=0.1, description="Shape constant (1=linear)") | |
| class NetworkInfoRequest(BaseModel): | |
| inp_content: str = Field(..., description="EPANET .inp file content") | |
| # ββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def write_temp_inp(content: str) -> str: | |
| tf = tempfile.NamedTemporaryFile( | |
| delete=False, suffix=".inp", mode="w", encoding="utf-8" | |
| ) | |
| tf.write(content) | |
| tf.close() | |
| return tf.name | |
| def safe_float(v) -> float: | |
| f = float(v) | |
| return f if np.isfinite(f) else 0.0 | |
| # ββ Routes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def root(): | |
| return { | |
| "name": "TSNet Transient Simulator API", | |
| "version": "1.0.0", | |
| "tsnet_available": TSNET_OK, | |
| "endpoints": { | |
| "GET /health": "Health check", | |
| "POST /simulate": "Run MOC transient simulation", | |
| "POST /network-info": "Inspect .inp file without simulating", | |
| }, | |
| "docs": "/docs", | |
| } | |
| def health(): | |
| if not TSNET_OK: | |
| raise HTTPException(status_code=503, | |
| detail=f"TSNet unavailable: {TSNET_ERR}") | |
| return {"status": "healthy", "tsnet": "ok"} | |
| def network_info(req: NetworkInfoRequest): | |
| """ | |
| Parse an EPANET .inp file and return network metadata. | |
| Use this before /simulate to discover element names. | |
| """ | |
| if not TSNET_OK: | |
| raise HTTPException(503, f"TSNet unavailable: {TSNET_ERR}") | |
| inp_path = write_temp_inp(req.inp_content) | |
| try: | |
| tm = TransientModel(inp_path) | |
| return { | |
| "num_junctions": tm.num_junctions, | |
| "num_pipes": tm.num_pipes, | |
| "num_valves": tm.num_valves, | |
| "num_pumps": tm.num_pumps, | |
| "num_reservoirs": tm.num_reservoirs, | |
| "pipe_ids": [n for n, _ in tm.pipes()], | |
| "valve_ids": [n for n, _ in tm.valves()], | |
| "pump_ids": [n for n, _ in tm.pumps()], | |
| "node_ids": [n for n, _ in tm.nodes()], | |
| } | |
| except Exception as e: | |
| raise HTTPException(400, f"Failed to parse .inp file: {e}") | |
| finally: | |
| os.unlink(inp_path) | |
| def simulate(req: SimRequest): | |
| """ | |
| Run a full MOC water-hammer simulation. | |
| Returns pressure heads, velocities, and flow-rates at all nodes and pipes, | |
| plus a surge summary suitable for engineering interpretation. | |
| """ | |
| if not TSNET_OK: | |
| raise HTTPException(503, f"TSNet unavailable: {TSNET_ERR}") | |
| inp_path = write_temp_inp(req.inp_content) | |
| try: | |
| # ββ Load & configure βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| tm = TransientModel(inp_path) | |
| tm.set_wavespeed(req.wave_speed) | |
| tm.set_time(req.duration, req.dt) | |
| # ββ Apply transient event ββββββββββββββββββββββββββββββββββββββββββββ | |
| rule = [req.tc, req.ts, req.se, req.m] | |
| if req.event_type == "valve_closure": | |
| if not req.element_id: | |
| raise HTTPException(400, "element_id required for valve_closure") | |
| tm.valve_closure(req.element_id, rule) | |
| elif req.event_type == "valve_opening": | |
| if not req.element_id: | |
| raise HTTPException(400, "element_id required for valve_opening") | |
| tm.valve_opening(req.element_id, rule) | |
| elif req.event_type == "pump_shutoff": | |
| if not req.element_id: | |
| raise HTTPException(400, "element_id required for pump_shutoff") | |
| if req.tc <= 0: | |
| raise HTTPException(400, "tc must be > 0 for pump_shutoff") | |
| tm.pump_shut_off(req.element_id, rule) | |
| # ββ Initialise & simulate ββββββββββββββββββββββββββββββββββββββββββββ | |
| tm = Initializer(tm, 0.0, engine=req.engine) | |
| rpath = os.path.join(tempfile.gettempdir(), "tsnet_api_result") | |
| tm_r = MOCSimulator(tm, rpath, req.friction) | |
| tt = tm_r.simulation_timestamps | |
| dt_actual = safe_float(tt[1] - tt[0]) if len(tt) > 1 else req.dt | |
| # ββ Build results ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| node_results = {} | |
| surge_summary = [] | |
| for name, node in tm_r.nodes(): | |
| h = node._head | |
| if h is None: | |
| continue | |
| h0 = safe_float(h[0]) | |
| hmax = safe_float(h.max()) | |
| hmin = safe_float(h.min()) | |
| surge = round(hmax - h0, 3) | |
| node_results[name] = { | |
| "initial_head_m": round(h0, 3), | |
| "max_head_m": round(hmax, 3), | |
| "min_head_m": round(hmin, 3), | |
| "surge_m": surge, | |
| "head_timeseries": [round(safe_float(v), 3) for v in h], | |
| } | |
| if abs(surge) > 0.01: | |
| surge_summary.append({ | |
| "node": name, | |
| "surge_m": surge, | |
| "max_head_m": round(hmax, 3), | |
| }) | |
| pipe_results = {} | |
| for name, pipe in tm_r.pipes(): | |
| vs = pipe.start_node_velocity | |
| ve = pipe.end_node_velocity | |
| qs = pipe.start_node_flowrate | |
| pipe_results[name] = { | |
| "start_velocity_initial_ms": round(safe_float(vs[0]), 4), | |
| "start_velocity_max_ms": round(safe_float(vs.max()), 4), | |
| "start_velocity_min_ms": round(safe_float(vs.min()), 4), | |
| "end_velocity_initial_ms": round(safe_float(ve[0]), 4), | |
| "start_flowrate_initial_m3s": round(safe_float(qs[0]), 6), | |
| "start_flowrate_max_m3s": round(safe_float(qs.max()), 6), | |
| } | |
| # Sort surge summary by magnitude descending | |
| surge_summary.sort(key=lambda x: abs(x["surge_m"]), reverse=True) | |
| return { | |
| "status": "ok", | |
| "simulation": { | |
| "duration_s": req.duration, | |
| "dt_actual_s": round(dt_actual, 5), | |
| "num_steps": len(tt), | |
| "wave_speed_ms": req.wave_speed, | |
| "event_type": req.event_type, | |
| "element_id": req.element_id, | |
| "friction": req.friction, | |
| }, | |
| "surge_summary": surge_summary, | |
| "nodes": node_results, | |
| "pipes": pipe_results, | |
| } | |
| except HTTPException: | |
| raise | |
| except ValueError as e: | |
| raise HTTPException(400, f"Simulation parameter error: {e}") | |
| except Exception as e: | |
| raise HTTPException(500, f"Simulation failed: {e}\n{traceback.format_exc()}") | |
| finally: | |
| os.unlink(inp_path) | |