""" hydraulic_core.py Shared deterministic hydraulic teaching functions used by both: - server.py -> MCP surface - rest_api.py -> REST / OpenAPI surface Units: - MKS display: L/s, m, mm - FPS display: gpm, ft, in Internal calculations: - flow: m3/s - length/head: m - diameter: m """ from __future__ import annotations import math import time import uuid from dataclasses import dataclass, asdict from typing import Any, Dict, List, Optional HW_EXP = 1.852 M3S_TO_LPS = 1000.0 LPS_TO_M3S = 0.001 M3S_TO_GPM = 15850.323141489 GPM_TO_M3S = 1.0 / M3S_TO_GPM M_TO_FT = 3.280839895 FT_TO_M = 1.0 / M_TO_FT M_TO_IN = 39.37007874 IN_TO_M = 1.0 / M_TO_IN MM_TO_M = 0.001 M_HEAD_TO_PSI = 1.421970206 PSI_TO_M_HEAD = 1.0 / M_HEAD_TO_PSI def unit_mode(unit_system: str = "MKS / L/s") -> str: return "FPS" if isinstance(unit_system, str) and "FPS" in unit_system else "MKS" def labels(unit_system: str = "MKS / L/s") -> Dict[str, str]: if unit_mode(unit_system) == "FPS": return {"flow": "gpm", "length": "ft", "diameter": "in", "head": "ft", "pressure": "psi"} return {"flow": "L/s", "length": "m", "diameter": "mm", "head": "m", "pressure": "m"} def flow_to_m3s(x: float, unit_system: str = "MKS / L/s") -> float: return float(x) * (GPM_TO_M3S if unit_mode(unit_system) == "FPS" else LPS_TO_M3S) def m3s_to_flow(x: float, unit_system: str = "MKS / L/s") -> float: return float(x) * (M3S_TO_GPM if unit_mode(unit_system) == "FPS" else M3S_TO_LPS) def length_to_m(x: float, unit_system: str = "MKS / L/s") -> float: return float(x) * (FT_TO_M if unit_mode(unit_system) == "FPS" else 1.0) def m_to_length(x: float, unit_system: str = "MKS / L/s") -> float: return float(x) * (M_TO_FT if unit_mode(unit_system) == "FPS" else 1.0) def head_to_m(x: float, unit_system: str = "MKS / L/s") -> float: return length_to_m(x, unit_system) def m_to_head(x: float, unit_system: str = "MKS / L/s") -> float: return m_to_length(x, unit_system) def diameter_to_m(x: float, unit_system: str = "MKS / L/s") -> float: return float(x) * (IN_TO_M if unit_mode(unit_system) == "FPS" else MM_TO_M) def pressure_head_to_display(x_m: float, unit_system: str = "MKS / L/s") -> float: return float(x_m) * (M_HEAD_TO_PSI if unit_mode(unit_system) == "FPS" else 1.0) def hw_resistance(length_m: float, diameter_m: float, c_hw: float) -> float: if length_m <= 0 or diameter_m <= 0 or c_hw <= 0: raise ValueError("Pipe length, diameter, and Hazen-Williams C must be positive.") return 10.67 * length_m / ((float(c_hw) ** HW_EXP) * (diameter_m ** 4.871)) def signed_headloss(q_m3s: float, r: float) -> float: if abs(q_m3s) < 1e-15: return 0.0 return math.copysign(r * abs(q_m3s) ** HW_EXP, q_m3s) def flow_from_headloss(delta_h_m: float, r: float) -> float: if abs(delta_h_m) < 1e-15: return 0.0 return math.copysign((abs(delta_h_m) / r) ** (1.0 / HW_EXP), delta_h_m) @dataclass class NetworkSession: session_id: str created_at: float last_used: float inp_text: str title: str units: str counts: Dict[str, int] SESSIONS: Dict[str, NetworkSession] = {} SESSION_TTL_SECONDS = 3600 def _expire_sessions() -> None: now = time.time() expired = [sid for sid, s in SESSIONS.items() if now - s.last_used > SESSION_TTL_SECONDS] for sid in expired: SESSIONS.pop(sid, None) def _section_counts(inp_text: str) -> Dict[str, int]: counts: Dict[str, int] = {} current = None for raw in inp_text.splitlines(): line = raw.strip() if not line or line.startswith(";"): continue if line.startswith("[") and line.endswith("]"): current = line.upper() counts[current] = 0 elif current: counts[current] += 1 return counts def _detect_units(inp_text: str) -> str: for raw in inp_text.splitlines(): line = raw.strip().upper() if line.startswith("UNITS"): if "GPM" in line: return "GPM" if "LPS" in line: return "LPS" return line.split()[-1] return "UNKNOWN" def load_network(inp_text: str, title: str = "uploaded_network") -> Dict[str, Any]: """Create a session from EPANET INP text.""" _expire_sessions() sid = str(uuid.uuid4()) session = NetworkSession( session_id=sid, created_at=time.time(), last_used=time.time(), inp_text=str(inp_text), title=title, units=_detect_units(inp_text), counts=_section_counts(inp_text), ) SESSIONS[sid] = session return { "session_id": sid, "title": title, "units": session.units, "counts": session.counts, "message": "Network session loaded. Use this session_id with analysis tools.", } def get_session(session_id: str) -> NetworkSession: _expire_sessions() if session_id not in SESSIONS: raise KeyError(f"Unknown or expired session_id: {session_id}") SESSIONS[session_id].last_used = time.time() return SESSIONS[session_id] def close_session(session_id: str) -> Dict[str, Any]: existed = session_id in SESSIONS SESSIONS.pop(session_id, None) return {"session_id": session_id, "closed": existed} def network_summary(session_id: str) -> Dict[str, Any]: s = get_session(session_id) return { "session_id": s.session_id, "title": s.title, "units": s.units, "counts": s.counts, "age_seconds": round(time.time() - s.created_at, 3), "note": "This summary is parsed from INP text. Full EPANET/WNTR simulation can be wired in a later engine layer.", } def solve_single_pipe(unit_system: str, length: float, diameter: float, c_hw: float, target_headloss: float, initial_flow: float, max_iter: int = 25, tolerance: float = 1e-4) -> Dict[str, Any]: L = length_to_m(length, unit_system) D = diameter_to_m(diameter, unit_system) target = head_to_m(target_headloss, unit_system) q = abs(flow_to_m3s(initial_flow, unit_system)) tol = head_to_m(tolerance, unit_system) r = hw_resistance(L, D, c_hw) rows = [] for i in range(1, int(max_iter) + 1): hf = r * abs(q) ** HW_EXP residual = hf - target derivative = HW_EXP * r * max(abs(q), 1e-12) ** (HW_EXP - 1.0) correction = residual / derivative if derivative else 0.0 q_new = max(q - correction, 1e-12) rows.append({ "iteration": i, "flow": m3s_to_flow(q, unit_system), "headloss": m_to_head(hf, unit_system), "residual": m_to_head(residual, unit_system), "correction_flow": m3s_to_flow(correction, unit_system), "updated_flow": m3s_to_flow(q_new, unit_system), }) q = q_new if abs(residual) <= tol: break lab = labels(unit_system) return { "unit_system": unit_system, "units": lab, "final_flow": m3s_to_flow(q, unit_system), "final_headloss": m_to_head(r * abs(q) ** HW_EXP, unit_system), "iterations": rows, } def solve_hardy_cross_loop(unit_system: str, flows: List[float], lengths: List[float], diameters: List[float], c_values: List[float], max_iter: int = 25, tolerance: float = 1e-5) -> Dict[str, Any]: n = len(flows) if not (len(lengths) == len(diameters) == len(c_values) == n): raise ValueError("flows, lengths, diameters, and c_values must have equal length.") q = [flow_to_m3s(v, unit_system) for v in flows] r = [hw_resistance(length_to_m(lengths[i], unit_system), diameter_to_m(diameters[i], unit_system), c_values[i]) for i in range(n)] tol = abs(flow_to_m3s(tolerance, unit_system)) summary = [] details = [] for it in range(1, int(max_iter) + 1): hs = [signed_headloss(q[i], r[i]) for i in range(n)] denom_terms = [HW_EXP * r[i] * max(abs(q[i]), 1e-12) ** (HW_EXP - 1.0) for i in range(n)] sum_h = sum(hs) denom = sum(denom_terms) dq = -sum_h / denom if denom else 0.0 q_after = [v + dq for v in q] summary.append({ "iteration": it, "sum_headloss": m_to_head(sum_h, unit_system), "correction": m3s_to_flow(dq, unit_system), "abs_correction": abs(m3s_to_flow(dq, unit_system)), }) for i in range(n): details.append({ "iteration": it, "pipe": f"P{i+1}", "flow_before": m3s_to_flow(q[i], unit_system), "signed_headloss": m_to_head(hs[i], unit_system), "flow_after": m3s_to_flow(q_after[i], unit_system), "direction": "same as assumed" if q_after[i] >= 0 else "opposite to assumed", }) q = q_after if abs(dq) <= tol: break return { "unit_system": unit_system, "units": labels(unit_system), "final_flows": [{"pipe": f"P{i+1}", "flow": m3s_to_flow(q[i], unit_system), "direction": "same as assumed" if q[i] >= 0 else "opposite to assumed"} for i in range(n)], "iterations": summary, "details": details, } def solve_two_loop_hardy_cross(unit_system: str, flows: List[float], common_length: float, common_diameter: float, c_hw: float = 120.0, max_iter: int = 25, tolerance: float = 1e-5) -> Dict[str, Any]: if len(flows) != 7: raise ValueError("Two-loop teaching network requires 7 initial pipe flows.") q = [flow_to_m3s(v, unit_system) for v in flows] r = [hw_resistance(length_to_m(common_length, unit_system), diameter_to_m(common_diameter, unit_system), c_hw) for _ in range(7)] loop1, s1 = [0, 1, 2, 3], [1, 1, 1, 1] loop2, s2 = [4, 5, 6, 1], [1, 1, 1, -1] tol = abs(flow_to_m3s(tolerance, unit_system)) iterations = [] for it in range(1, int(max_iter) + 1): def corr(indices, signs): ql = [signs[j] * q[idx] for j, idx in enumerate(indices)] hl = [signed_headloss(ql[j], r[indices[j]]) for j in range(len(indices))] den = sum(HW_EXP * r[indices[j]] * max(abs(ql[j]), 1e-12) ** (HW_EXP - 1.0) for j in range(len(indices))) return sum(hl), (-sum(hl) / den if den else 0.0) sh1, dq1 = corr(loop1, s1) sh2, dq2 = corr(loop2, s2) for j, idx in enumerate(loop1): q[idx] += s1[j] * dq1 for j, idx in enumerate(loop2): q[idx] += s2[j] * dq2 iterations.append({ "iteration": it, "loop1_sum_headloss": m_to_head(sh1, unit_system), "loop1_correction": m3s_to_flow(dq1, unit_system), "loop2_sum_headloss": m_to_head(sh2, unit_system), "loop2_correction": m3s_to_flow(dq2, unit_system), "max_abs_correction": max(abs(m3s_to_flow(dq1, unit_system)), abs(m3s_to_flow(dq2, unit_system))), }) if max(abs(dq1), abs(dq2)) <= tol: break return { "unit_system": unit_system, "units": labels(unit_system), "final_flows": [{"pipe": f"P{i+1}", "flow": m3s_to_flow(q[i], unit_system), "shared_pipe": i == 1, "direction": "same as assumed" if q[i] >= 0 else "opposite to assumed"} for i in range(7)], "iterations": iterations, } def solve_three_reservoir(unit_system: str, reservoir_heads: List[float], demand: float, initial_head: float, lengths: List[float], diameters: List[float], c_values: List[float], max_iter: int = 25, tolerance: float = 1e-4) -> Dict[str, Any]: H = [head_to_m(v, unit_system) for v in reservoir_heads] HJ = head_to_m(initial_head, unit_system) demand_m3s = flow_to_m3s(demand, unit_system) tol = abs(flow_to_m3s(tolerance, unit_system)) r = [hw_resistance(length_to_m(lengths[i], unit_system), diameter_to_m(diameters[i], unit_system), c_values[i]) for i in range(3)] rows = [] def residual_at(hj): qs = [flow_from_headloss(H[i] - hj, r[i]) for i in range(3)] return sum(qs) - demand_m3s, qs for it in range(1, int(max_iter) + 1): res, qs = residual_at(HJ) dH = 1e-4 res_plus, _ = residual_at(HJ + dH) deriv = (res_plus - res) / dH corr = -res / deriv if abs(deriv) > 1e-12 else 0.0 corr = max(min(corr, 20.0), -20.0) rows.append({ "iteration": it, "junction_head": m_to_head(HJ, unit_system), "q1": m3s_to_flow(qs[0], unit_system), "q2": m3s_to_flow(qs[1], unit_system), "q3": m3s_to_flow(qs[2], unit_system), "continuity_residual": m3s_to_flow(res, unit_system), "head_correction": m_to_head(corr, unit_system), "updated_junction_head": m_to_head(HJ + corr, unit_system), }) HJ += corr if abs(res) <= tol: break res, qs = residual_at(HJ) return { "unit_system": unit_system, "units": labels(unit_system), "junction_head": m_to_head(HJ, unit_system), "flows": [{"pipe": f"P{i+1}", "flow": m3s_to_flow(qs[i], unit_system), "meaning": "reservoir supplies junction" if qs[i] >= 0 else "junction supplies reservoir"} for i in range(3)], "continuity_residual": m3s_to_flow(res, unit_system), "iterations": rows, } def solve_pdd_demand(unit_system: str, required_demand: float, available_pressure: float, minimum_pressure: float, required_pressure: float, exponent: float = 0.5) -> Dict[str, Any]: req = flow_to_m3s(required_demand, unit_system) p = head_to_m(available_pressure, unit_system) pmin = head_to_m(minimum_pressure, unit_system) preq = head_to_m(required_pressure, unit_system) if p <= pmin: ratio = 0.0 elif p >= preq: ratio = 1.0 else: ratio = ((p - pmin) / max(preq - pmin, 1e-12)) ** float(exponent) delivered = req * ratio return { "unit_system": unit_system, "units": labels(unit_system), "satisfaction_ratio": ratio, "required_demand": required_demand, "delivered_demand": m3s_to_flow(delivered, unit_system), "unserved_demand": m3s_to_flow(req - delivered, unit_system), } def simulate_tank_eps(unit_system: str, diameter: float, initial_level: float, min_level: float, max_level: float, timestep_hr: float, inflows: List[float], outflows: List[float]) -> Dict[str, Any]: D = length_to_m(diameter, unit_system) level = length_to_m(initial_level, unit_system) min_l = length_to_m(min_level, unit_system) max_l = length_to_m(max_level, unit_system) area = math.pi * D**2 / 4 n = max(len(inflows), len(outflows)) rows = [] for i in range(n): qin = flow_to_m3s(inflows[i % len(inflows)], unit_system) qout = flow_to_m3s(outflows[i % len(outflows)], unit_system) start = level end_unclipped = start + (qin - qout) * float(timestep_hr) * 3600.0 / area end = min(max(end_unclipped, min_l), max_l) rows.append({ "step": i + 1, "start_level": m_to_head(start, unit_system), "end_level": m_to_head(end, unit_system), "inflow": m3s_to_flow(qin, unit_system), "outflow": m3s_to_flow(qout, unit_system), "net_flow": m3s_to_flow(qin - qout, unit_system), "clipped": abs(end - end_unclipped) > 1e-9, }) level = end return {"unit_system": unit_system, "units": labels(unit_system), "final_level": m_to_head(level, unit_system), "time_series": rows} def evaluate_valve_behavior(unit_system: str, valve_type: str, upstream_head: float, setting: float, flow: float, diameter: float, minor_loss_k: float = 0.0) -> Dict[str, Any]: H_up = head_to_m(upstream_head, unit_system) H_set = head_to_m(setting, unit_system) q = flow_to_m3s(flow, unit_system) D = diameter_to_m(diameter, unit_system) area = math.pi * D**2 / 4 v = q / area if area else 0.0 h_minor = float(minor_loss_k) * v**2 / (2 * 9.80665) vt = valve_type.upper() if vt == "PRV": H_down = min(H_up - h_minor, H_set) status = "active/throttling" if H_up > H_set else "open" elif vt == "PSV": H_down = H_up - h_minor status = "active/sustaining upstream" if H_up <= H_set else "open" elif vt == "FCV": H_down = H_up - h_minor status = "active/flow controlled if hydraulically feasible" elif vt == "TCV": H_down = H_up - h_minor status = "active/minor-loss control" else: status = "open" if H_up > H_set else "closed/reverse prevented" if "closed" in status: q = 0.0 H_down = H_set else: H_down = H_up - h_minor return { "unit_system": unit_system, "units": labels(unit_system), "valve_type": valve_type, "status": status, "upstream_head": upstream_head, "downstream_head_estimate": m_to_head(H_down, unit_system), "headloss": m_to_head(H_up - H_down, unit_system), "flow": m3s_to_flow(q, unit_system), "teaching_note": "This isolates valve logic. Full network behavior should be validated in EPANET/WNTR.", } def solve_pump_operating_point(unit_system: str, shutoff_head: float, design_flow: float, static_head: float, pump_curve_k: float, system_curve_k: float) -> Dict[str, Any]: qmax = max(float(design_flow) * 1.8, 1e-9) q_values = [qmax * i / 200 for i in range(201)] pump_h = [float(shutoff_head) - float(pump_curve_k) * q**2 for q in q_values] sys_h = [float(static_head) + float(system_curve_k) * q**2 for q in q_values] idx = min(range(len(q_values)), key=lambda i: abs(pump_h[i] - sys_h[i])) return { "unit_system": unit_system, "units": labels(unit_system), "operating_flow": q_values[idx], "operating_head": pump_h[idx], "curve_table": [{"flow": q_values[i], "pump_head": pump_h[i], "system_head": sys_h[i]} for i in range(0, len(q_values), 10)], } def pressure_zone_analysis(unit_system: str, source_head: float, prv_setting: float, node_elevations: List[float], demand_multiplier: float = 1.0, min_pressure: float = 14.0, max_pressure: float = 56.0) -> Dict[str, Any]: H_source = head_to_m(source_head, unit_system) H_zone = min(H_source, head_to_m(prv_setting, unit_system)) min_p_m = head_to_m(min_pressure, unit_system) max_p_m = head_to_m(max_pressure, unit_system) rows = [] for i, elev in enumerate(node_elevations, start=1): e_m = length_to_m(elev, unit_system) p_head = H_zone - e_m if p_head < min_p_m: cat = "low pressure" elif p_head > max_p_m: cat = "high pressure / leakage risk" else: cat = "acceptable" rows.append({"node": f"J{i}", "elevation": elev, "pressure": pressure_head_to_display(p_head, unit_system), "category": cat}) return { "unit_system": unit_system, "units": labels(unit_system), "zone_hgl": m_to_head(H_zone, unit_system), "prv_status": "active" if H_source > H_zone else "open/source limited", "nodes": rows, "teaching_note": "Pressure zone behavior depends on source head, control setting, elevation, and demands.", } def leakage_nrw_analysis(unit_system: str, average_pressure: float, authorized_demand: float, leakage_coefficient: float, pressure_exponent: float = 1.0) -> Dict[str, Any]: p = max(float(average_pressure), 0.0) leakage = float(leakage_coefficient) * (p ** float(pressure_exponent)) total = float(authorized_demand) + leakage return { "unit_system": unit_system, "units": labels(unit_system), "authorized_demand": authorized_demand, "leakage_flow": leakage, "zone_inflow": total, "nrw_percent": 100.0 * leakage / total if total > 0 else 0.0, "teaching_note": "EPANET emitter nodes can be used to validate pressure-dependent leakage behavior.", } def water_age_analysis(unit_system: str, pipe_volume: float, tank_volume: float, demand: float, dead_end_factor: float = 2.0) -> Dict[str, Any]: # display volumes are assumed m3 in MKS and gallons in FPS if unit_mode(unit_system) == "FPS": volume_m3 = (float(pipe_volume) + float(tank_volume)) / 264.1720524 else: volume_m3 = float(pipe_volume) + float(tank_volume) q = max(flow_to_m3s(demand, unit_system), 1e-12) base_age_hr = volume_m3 / q / 3600.0 return { "unit_system": unit_system, "units": labels(unit_system), "average_age_hours": base_age_hr, "dead_end_age_hours": base_age_hr * float(dead_end_factor), "teaching_note": "Low flow, large storage, and dead ends increase water age. Validate with EPANET AGE analysis.", } def chlorine_decay_analysis(initial_chlorine_mg_l: float, bulk_decay_per_day: float, travel_time_hours: float, wall_decay_factor: float = 0.0) -> Dict[str, Any]: k_hr = (float(bulk_decay_per_day) + float(wall_decay_factor)) / 24.0 c = float(initial_chlorine_mg_l) * math.exp(-k_hr * float(travel_time_hours)) return { "initial_chlorine_mg_l": initial_chlorine_mg_l, "travel_time_hours": travel_time_hours, "final_chlorine_mg_l": c, "decay_fraction": 1.0 - c / max(float(initial_chlorine_mg_l), 1e-12), "teaching_note": "This first-order teaching model can be compared with EPANET chemical analysis.", } def generate_epanet_validation_inp(case: str = "three_reservoir_mks") -> Dict[str, Any]: case = str(case).lower() if "fps" in case: text = """[TITLE] Three-Reservoir Validation Model - FPS / GPM [OPTIONS] UNITS GPM HEADLOSS H-W [JUNCTIONS] J 328.084 317.006 [RESERVOIRS] R1 459.318 R2 393.701 R3 344.488 [PIPES] P1 R1 J 1640.42 11.811 120 0 Open P2 R2 J 2296.59 9.843 120 0 Open P3 R3 J 1968.50 9.843 120 0 Open [END] """ else: text = """[TITLE] Three-Reservoir Validation Model - MKS / LPS [OPTIONS] UNITS LPS HEADLOSS H-W [JUNCTIONS] J 100 20 [RESERVOIRS] R1 140 R2 120 R3 105 [PIPES] P1 R1 J 500 300 120 0 Open P2 R2 J 700 250 120 0 Open P3 R3 J 600 250 120 0 Open [END] """ return {"case": case, "filename": f"{case}.inp", "inp_text": text}