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
+724
@@ -0,0 +1,724 @@
|
||||
"""Batch analysis jobs exposed as POST /api/jobs/{name}.
|
||||
|
||||
Each job takes the INP bytes and a params dict and returns a JSON-able dict.
|
||||
Jobs run in the API process pool; raise BatchError for input problems
|
||||
(reported as HTTP 400). Register new jobs in JOBS at the bottom.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
import ctypes
|
||||
|
||||
from .batch import BatchError, Network, hhmm, parse_time
|
||||
from .epanet import EN, EpanetException
|
||||
|
||||
|
||||
def job_summary(inp: bytes, params: dict) -> dict:
|
||||
"""Framework smoke job: min/max pressure per reporting period."""
|
||||
with Network(inp) as net:
|
||||
junctions = net.junction_indices()
|
||||
res = net.simulate(node_props=(EN.PRESSURE,), nodes=junctions)
|
||||
p = res.nodes[EN.PRESSURE]
|
||||
return {
|
||||
"times": res.times,
|
||||
"labels": [hhmm(t) for t in res.times],
|
||||
"minPressure": p.min(axis=1).round(4).tolist() if p.size else [],
|
||||
"maxPressure": p.max(axis=1).round(4).tolist() if p.size else [],
|
||||
"junctions": len(junctions),
|
||||
"warnings": [{"time": t, "code": c} for t, c in res.warnings],
|
||||
}
|
||||
|
||||
|
||||
def _window_rows(times: list[int], start: int, end: int) -> np.ndarray:
|
||||
"""Reporting periods inside [start, end); falls back to the one nearest start."""
|
||||
t = np.asarray(times)
|
||||
rows = np.where((t >= start) & (t < end))[0]
|
||||
if rows.size == 0:
|
||||
rows = np.array([int(np.argmin(np.abs(t - start)))])
|
||||
return rows
|
||||
|
||||
|
||||
def job_fireflow(inp: bytes, params: dict) -> dict:
|
||||
"""소화전 모의: extra demand at hydrant nodes for a time window.
|
||||
|
||||
params: nodes [ids], flow (model flow units, per hydrant), start/end (h or h:mm),
|
||||
residual (model pressure units, optional: also find the available flow),
|
||||
threshold (optional: count junctions whose window-min pressure drops below it).
|
||||
"""
|
||||
ids = [str(x) for x in params.get("nodes") or []]
|
||||
if not ids:
|
||||
raise BatchError("소화전 절점을 하나 이상 지정하세요.")
|
||||
flow = float(params.get("flow") or 0)
|
||||
if flow <= 0:
|
||||
raise BatchError("소화 유량은 0보다 커야 합니다.")
|
||||
with Network(inp) as net:
|
||||
start = parse_time(params.get("start"), 0)
|
||||
end = parse_time(params.get("end"), max(net.duration, net.report_step))
|
||||
if end <= start:
|
||||
end = start + net.report_step
|
||||
hyd = [net.node_index(i) for i in ids]
|
||||
junctions = net.junction_indices()
|
||||
base = net.simulate(nodes=junctions)
|
||||
rows = _window_rows(base.times, start, end)
|
||||
base_min = base.nodes[EN.PRESSURE][rows].min(axis=0)
|
||||
|
||||
net.window_pattern("SPIPE_FIRE", start, end)
|
||||
cats = [net.add_demand(h, flow, "SPIPE_FIRE", "fire") for h in hyd]
|
||||
fire = net.simulate(nodes=junctions)
|
||||
fire_min = fire.nodes[EN.PRESSURE][rows].min(axis=0)
|
||||
for h, c in zip(hyd, cats):
|
||||
net.delete_demand(h, c)
|
||||
|
||||
col = {j: k for k, j in enumerate(junctions)}
|
||||
# Compare the two runs period by period: tank/pump controls can shift
|
||||
# *when* the daily minimum happens, so "min before vs min after" may
|
||||
# even show a rise. Report each node at its largest drop instead.
|
||||
bw = base.nodes[EN.PRESSURE][rows]
|
||||
fw = fire.nodes[EN.PRESSURE][rows]
|
||||
drop = fw - bw
|
||||
worst = np.argmin(drop, axis=0)
|
||||
cols = np.arange(len(junctions))
|
||||
diff = drop[worst, cols]
|
||||
order = np.argsort(diff)
|
||||
threshold = params.get("threshold")
|
||||
out_nodes = [
|
||||
{
|
||||
"id": net.node_ids[junctions[k] - 1],
|
||||
"basePressure": round(float(bw[worst[k], k]), 3),
|
||||
"firePressure": round(float(fw[worst[k], k]), 3),
|
||||
"diff": round(float(diff[k]), 3),
|
||||
"time": hhmm(base.times[rows[worst[k]]]),
|
||||
}
|
||||
for k in order
|
||||
if abs(diff[k]) > 1e-4
|
||||
]
|
||||
|
||||
residual = params.get("residual")
|
||||
hydrants = []
|
||||
for nid, h in zip(ids, hyd):
|
||||
entry = {"id": nid}
|
||||
if h in col:
|
||||
k = col[h]
|
||||
# Hydrant: window minimum before/after (what the residual criterion is about).
|
||||
entry.update(basePressure=round(float(base_min[k]), 3), firePressure=round(float(fire_min[k]), 3))
|
||||
if residual not in (None, "") and h in col:
|
||||
entry["available"] = _available_fire_flow(net, h, col[h], junctions, rows, start, end, float(residual), flow)
|
||||
hydrants.append(entry)
|
||||
|
||||
return {
|
||||
"window": [start, end],
|
||||
"windowLabel": f"{hhmm(start)}~{hhmm(end)}",
|
||||
"flow": flow,
|
||||
"hydrants": hydrants,
|
||||
"nodes": out_nodes,
|
||||
"belowThreshold": int(np.sum(fire_min < float(threshold))) if threshold not in (None, "") else None,
|
||||
"belowThresholdBefore": int(np.sum(base_min < float(threshold))) if threshold not in (None, "") else None,
|
||||
"warnings": [{"time": t, "code": c} for t, c in fire.warnings],
|
||||
}
|
||||
|
||||
|
||||
def _available_fire_flow(net, h, col, junctions, rows, start, end, residual, hint) -> float:
|
||||
"""Largest extra flow at node h keeping its window-min pressure >= residual (bisection)."""
|
||||
def min_p(q: float) -> float:
|
||||
net.window_pattern("SPIPE_AFF", start, end)
|
||||
c = net.add_demand(h, q, "SPIPE_AFF", "aff")
|
||||
try:
|
||||
r = net.simulate(nodes=[h])
|
||||
finally:
|
||||
net.delete_demand(h, c)
|
||||
return float(r.nodes[EN.PRESSURE][rows, 0].min())
|
||||
|
||||
if min_p(0.0) < residual:
|
||||
return 0.0
|
||||
lo, hi = 0.0, max(hint, 1e-3)
|
||||
for _ in range(30): # grow until the residual is violated
|
||||
if min_p(hi) < residual:
|
||||
break
|
||||
lo, hi = hi, hi * 2
|
||||
else:
|
||||
return round(hi, 3)
|
||||
for _ in range(25):
|
||||
mid = (lo + hi) / 2
|
||||
if min_p(mid) >= residual:
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid
|
||||
if hi - lo <= max(1e-3, 1e-3 * hi):
|
||||
break
|
||||
return round(lo, 3)
|
||||
|
||||
|
||||
def _window(net: Network, params: dict) -> tuple[int, int]:
|
||||
start = parse_time(params.get("start"), 0)
|
||||
end = parse_time(params.get("end"), max(net.duration, net.report_step))
|
||||
return start, (end if end > start else start + net.report_step)
|
||||
|
||||
|
||||
MAX_CRITICALITY_LINKS = 4000
|
||||
|
||||
|
||||
def job_criticality(inp: bytes, params: dict) -> dict:
|
||||
"""관로 중요도: close each pipe in turn and measure who loses pressure.
|
||||
|
||||
params: links [ids] (default all pipes), threshold (model pressure units),
|
||||
start/end (window, h). A junction is "affected" when its window-min pressure
|
||||
falls below the threshold while it was at/above it in the base run.
|
||||
"""
|
||||
threshold = float(params.get("threshold") or 0)
|
||||
with Network(inp) as net:
|
||||
start, end = _window(net, params)
|
||||
ids = [str(x) for x in params.get("links") or []]
|
||||
if ids:
|
||||
links = [net.link_index(i) for i in ids]
|
||||
else:
|
||||
links = [k + 1 for k, t in enumerate(net.link_types) if t in (EN.CVPIPE, EN.PIPE)]
|
||||
if not links:
|
||||
raise BatchError("분석할 관로가 없습니다.")
|
||||
if len(links) > MAX_CRITICALITY_LINKS:
|
||||
raise BatchError(f"관로가 {len(links)}개입니다. {MAX_CRITICALITY_LINKS}개 이하로 선택해 나눠 실행하세요.")
|
||||
junctions = net.junction_indices()
|
||||
base = net.simulate(node_props=(EN.PRESSURE, EN.DEMAND), nodes=junctions)
|
||||
rows = _window_rows(base.times, start, end)
|
||||
bmin = base.nodes[EN.PRESSURE][rows].min(axis=0)
|
||||
bdem = base.nodes[EN.DEMAND][rows].mean(axis=0)
|
||||
ok_before = bmin >= threshold
|
||||
out = []
|
||||
for li in links:
|
||||
orig = net.get_link(li, EN.INITSTATUS)
|
||||
net.set_link(li, EN.INITSTATUS, 0)
|
||||
entry = {"id": net.link_ids[li - 1]}
|
||||
try:
|
||||
r = net.simulate(nodes=junctions)
|
||||
pmin = r.nodes[EN.PRESSURE][_window_rows(r.times, start, end)].min(axis=0)
|
||||
affected = np.where((pmin < threshold) & ok_before)[0]
|
||||
drop = bmin - pmin
|
||||
order = affected[np.argsort(-drop[affected])]
|
||||
entry.update(
|
||||
affectedNodes=int(affected.size),
|
||||
affectedDemand=round(float(np.sum(np.maximum(bdem[affected], 0))), 4),
|
||||
minPressure=round(float(pmin.min()), 3),
|
||||
maxDrop=round(float(drop.max()), 3),
|
||||
nodes=[net.node_ids[junctions[k] - 1] for k in order[:200]],
|
||||
warning=any(c in (3, 6) for _, c in r.warnings),
|
||||
disconnected=bool(pmin.min() < -1000),
|
||||
)
|
||||
except (EpanetException, BatchError) as e:
|
||||
entry.update(error=str(e), affectedNodes=0, affectedDemand=0.0, minPressure=None, maxDrop=None, nodes=[])
|
||||
finally:
|
||||
net.set_link(li, EN.INITSTATUS, orig)
|
||||
out.append(entry)
|
||||
out.sort(key=lambda e: (-e["affectedDemand"], -e["affectedNodes"], -(e["maxDrop"] or 0)))
|
||||
return {
|
||||
"window": [start, end],
|
||||
"windowLabel": f"{hhmm(start)}~{hhmm(end)}",
|
||||
"threshold": threshold,
|
||||
"belowBefore": int(np.sum(~ok_before)),
|
||||
"totalDemand": round(float(np.sum(np.maximum(bdem, 0))), 4),
|
||||
"links": out,
|
||||
}
|
||||
|
||||
|
||||
def job_servicearea(inp: bytes, params: dict) -> dict:
|
||||
"""급수 구역: source tracing (EPANET TRACE) from each reservoir (tanks optional).
|
||||
|
||||
Returns each node's average share (%) of water from each source over the
|
||||
analysis window (default: the last 24 h of the run; a run shorter than 24 h
|
||||
is extended to 72 h so traced water can reach the far ends).
|
||||
"""
|
||||
with Network(inp) as net:
|
||||
notes = []
|
||||
if net.duration < 72 * 3600:
|
||||
# Traced water starts at 0 everywhere; give it time to reach the far ends.
|
||||
net.set_time_param(EN.DURATION, 72 * 3600)
|
||||
notes.append(f"해석 기간({net.duration // 3600}시간)을 72시간으로 늘려 추적하고 마지막 24시간을 평균했습니다.")
|
||||
net.duration = 72 * 3600
|
||||
# Default: reservoirs only. A traced tank counts water that merely passed
|
||||
# through it, so tank shares overlap the reservoir shares (sum > 100%).
|
||||
kinds = (EN.RESERVOIR, EN.TANK) if params.get("includeTanks") else (EN.RESERVOIR,)
|
||||
sources = [str(x) for x in params.get("sources") or []] or [
|
||||
nid for nid, t in zip(net.node_ids, net.node_types) if t in kinds
|
||||
] or [nid for nid, t in zip(net.node_ids, net.node_types) if t == EN.TANK]
|
||||
if not sources:
|
||||
raise BatchError("배수지·탱크가 없습니다.")
|
||||
if len(sources) > 30:
|
||||
raise BatchError("수원이 30개를 넘습니다. 추적할 수원을 골라 주세요.")
|
||||
for sid in sources:
|
||||
net.node_index(sid)
|
||||
start = parse_time(params.get("start"), max(0, net.duration - 24 * 3600))
|
||||
end = parse_time(params.get("end"), net.duration + 1)
|
||||
net.lib.EN_solveH(net.ph)
|
||||
shares: dict[str, np.ndarray] = {}
|
||||
for sid in sources:
|
||||
net.lib.EN_setqualtype(net.ph, EN.QUAL_TRACE, b"", b"", sid.encode("utf-8"))
|
||||
times, q = net.run_quality()
|
||||
rows = _window_rows(times, start, end)
|
||||
shares[sid] = np.clip(q[rows].mean(axis=0), 0, 100)
|
||||
mat = np.vstack([shares[s] for s in sources]) # [source, node]
|
||||
dom = np.argmax(mat, axis=0)
|
||||
nodes = {}
|
||||
for k, nid in enumerate(net.node_ids):
|
||||
col = mat[:, k]
|
||||
if col.max() < 0.5:
|
||||
nodes[nid] = {"source": None, "share": 0.0, "shares": {}}
|
||||
continue
|
||||
nodes[nid] = {
|
||||
"source": sources[dom[k]],
|
||||
"share": round(float(col[dom[k]]), 1),
|
||||
"shares": {s: round(float(v), 1) for s, v in zip(sources, col) if v >= 0.5},
|
||||
}
|
||||
summary = []
|
||||
for sid in sources:
|
||||
mine = [nid for nid in net.node_ids if nodes[nid]["source"] == sid]
|
||||
summary.append({"source": sid, "nodes": len(mine), "mixed": sum(1 for nid in mine if nodes[nid]["share"] < 80)})
|
||||
return {
|
||||
"sources": sources,
|
||||
"window": [start, end],
|
||||
"windowLabel": f"{hhmm(start)}~{hhmm(end)}",
|
||||
"nodes": nodes,
|
||||
"summary": summary,
|
||||
"unreached": sum(1 for v in nodes.values() if v["source"] is None),
|
||||
"notes": notes,
|
||||
}
|
||||
|
||||
|
||||
def job_vsp(inp: bytes, params: dict) -> dict:
|
||||
"""VSP: pump speed giving a target pressure at a control node (bisection).
|
||||
|
||||
Each requested hour is solved as a snapshot (DURATION 0, patterns shifted to
|
||||
that hour, tanks at their initial levels). params: pump, node, target
|
||||
(model pressure units), hours [list] (default 0..23), minSpeed, maxSpeed.
|
||||
"""
|
||||
pump_id, node_id = str(params.get("pump") or ""), str(params.get("node") or "")
|
||||
target = float(params.get("target") or 0)
|
||||
lo_s, hi_s = float(params.get("minSpeed") or 0.5), float(params.get("maxSpeed") or 1.2)
|
||||
if not (0 < lo_s < hi_s):
|
||||
raise BatchError("속도 범위가 올바르지 않습니다 (0 < 최소 < 최대).")
|
||||
hours = [float(h) for h in (params.get("hours") or list(range(24)))][:168]
|
||||
with Network(inp) as net:
|
||||
pi = net.link_index(pump_id)
|
||||
if net.link_types[pi - 1] != EN.PUMP:
|
||||
raise BatchError(f"{pump_id}는 펌프가 아닙니다.")
|
||||
ni = net.node_index(node_id)
|
||||
ps0 = net.time_param(EN.PATTERNSTART)
|
||||
net.set_time_param(EN.DURATION, 0)
|
||||
|
||||
def solve(speed: float) -> tuple[float, float, float]:
|
||||
net.set_link(pi, EN.INITSTATUS, 1)
|
||||
net.set_link(pi, EN.INITSETTING, speed)
|
||||
net.lib.EN_solveH(net.ph)
|
||||
return net.get_node(ni, EN.PRESSURE), net.get_link(pi, EN.FLOW), net.get_link(pi, EN.ENERGY)
|
||||
|
||||
out = []
|
||||
for h in hours:
|
||||
net.set_time_param(EN.PATTERNSTART, ps0 + int(h * 3600))
|
||||
try:
|
||||
p_hi = solve(hi_s)
|
||||
p_lo = solve(lo_s)
|
||||
if p_hi[0] < target:
|
||||
speed, res, status = hi_s, p_hi, "max"
|
||||
elif p_lo[0] >= target:
|
||||
speed, res, status = lo_s, p_lo, "min"
|
||||
else:
|
||||
a, b = lo_s, hi_s
|
||||
for _ in range(30):
|
||||
m = (a + b) / 2
|
||||
if solve(m)[0] >= target:
|
||||
b = m
|
||||
else:
|
||||
a = m
|
||||
if b - a < 1e-4:
|
||||
break
|
||||
speed, res, status = b, solve(b), "ok"
|
||||
out.append({"hour": h, "speed": round(speed, 4), "pressure": round(res[0], 3),
|
||||
"flow": round(res[1], 4), "power": round(res[2], 3), "status": status})
|
||||
except EpanetException as e:
|
||||
out.append({"hour": h, "error": str(e)})
|
||||
return {"pump": pump_id, "node": node_id, "target": target, "rows": out,
|
||||
"note": "시간별 정상상태 해석입니다 (탱크는 초기 수위, 그 시각의 시간 제어만 반영)."}
|
||||
|
||||
|
||||
# m3/s per model flow unit, by EN_getflowunits code
|
||||
_M3S = {0: 0.0283168, 1: 6.30902e-5, 2: 0.0438126, 3: 0.0526168, 4: 0.0142764, 5: 1e-3,
|
||||
6: 1e-3 / 60, 7: 1e3 / 86400, 8: 1 / 3600, 9: 1 / 86400, 10: 1.0}
|
||||
|
||||
|
||||
def job_energy(inp: bytes, params: dict) -> dict:
|
||||
"""에너지 보고서: per-pump utilisation, efficiency, kWh, kWh/m3, peak kW and cost,
|
||||
integrated over every hydraulic step (prices from [ENERGY] incl. patterns)."""
|
||||
with Network(inp) as net:
|
||||
pumps = [k + 1 for k, t in enumerate(net.link_types) if t == EN.PUMP]
|
||||
if not pumps:
|
||||
raise BatchError("펌프가 없습니다.")
|
||||
fu = ctypes.c_int()
|
||||
net.lib.EN_getflowunits(net.ph, ctypes.byref(fu))
|
||||
to_m3s = _M3S.get(fu.value, 1.0)
|
||||
g_price = net.option(EN.GLOBALPRICE)
|
||||
g_pat = int(net.option(EN.GLOBALPATTERN))
|
||||
d_charge = net.option(EN.DEMANDCHARGE)
|
||||
g_eff = net.option(EN.GLOBALEFFIC)
|
||||
ps0 = net.time_param(EN.PATTERNSTART)
|
||||
price = {p: (net.get_link(p, EN.PUMP_ECOST) or g_price) for p in pumps}
|
||||
pat = {p: (int(net.get_link(p, EN.PUMP_EPAT)) or g_pat) for p in pumps}
|
||||
acc = {p: {"on": 0.0, "kwh": 0.0, "m3": 0.0, "effw": 0.0, "peak": 0.0, "cost": 0.0} for p in pumps}
|
||||
peak_total = 0.0
|
||||
total_s = 0.0
|
||||
t, tstep = ctypes.c_long(), ctypes.c_long(1)
|
||||
steady = net.duration == 0
|
||||
net.lib.EN_openH(net.ph)
|
||||
try:
|
||||
net.lib.EN_initH(net.ph, EN.NOSAVE)
|
||||
while True:
|
||||
net.lib.EN_runH(net.ph, ctypes.byref(t))
|
||||
now = t.value
|
||||
# Values at `now` hold until the next hydraulic time.
|
||||
vals = [(p, abs(net.get_link(p, EN.FLOW)), max(0.0, net.get_link(p, EN.ENERGY)), net.get_link(p, EN.PUMP_EFFIC)) for p in pumps]
|
||||
net.lib.EN_nextH(net.ph, ctypes.byref(tstep))
|
||||
dt = 3600.0 if steady else float(tstep.value)
|
||||
kw_total = 0.0
|
||||
for p, q, kw, eff in vals:
|
||||
a = acc[p]
|
||||
if q > 1e-9 and kw > 0:
|
||||
a["on"] += dt
|
||||
a["effw"] += eff * dt
|
||||
a["kwh"] += kw * dt / 3600
|
||||
a["m3"] += q * to_m3s * dt
|
||||
a["peak"] = max(a["peak"], kw)
|
||||
a["cost"] += kw * dt / 3600 * price[p] * net.pattern_value(pat[p], now, ps0)
|
||||
kw_total += kw
|
||||
peak_total = max(peak_total, kw_total)
|
||||
total_s += dt
|
||||
if tstep.value <= 0:
|
||||
break
|
||||
finally:
|
||||
net.lib.EN_closeH(net.ph)
|
||||
total_s = max(total_s, 1.0)
|
||||
rows = []
|
||||
for p in pumps:
|
||||
a = acc[p]
|
||||
rows.append({
|
||||
"id": net.link_ids[p - 1],
|
||||
"utilization": round(100 * a["on"] / total_s, 2),
|
||||
"efficiency": round(100 * a["effw"] / a["on"], 2) if a["on"] else None, # DLL gives a fraction
|
||||
"kwh": round(a["kwh"], 3),
|
||||
"kwhPerM3": round(a["kwh"] / a["m3"], 5) if a["m3"] > 0 else None,
|
||||
"volume": round(a["m3"], 3),
|
||||
"avgKw": round(a["kwh"] * 3600 / a["on"], 3) if a["on"] else 0.0,
|
||||
"peakKw": round(a["peak"], 3),
|
||||
"cost": round(a["cost"], 3),
|
||||
"costPerDay": round(a["cost"] * 86400 / total_s, 3),
|
||||
})
|
||||
return {
|
||||
"pumps": rows,
|
||||
"hours": round(total_s / 3600, 3),
|
||||
"globalPrice": g_price,
|
||||
"globalEfficiency": g_eff,
|
||||
"demandCharge": d_charge,
|
||||
"peakKw": round(peak_total, 3),
|
||||
"demandCost": round(peak_total * d_charge, 3),
|
||||
"totalCost": round(sum(r["cost"] for r in rows) + peak_total * d_charge, 3),
|
||||
"steady": steady,
|
||||
}
|
||||
|
||||
|
||||
PARAM_PROPS = {"pressure": ("node", EN.PRESSURE), "head": ("node", EN.HEAD), "demand": ("node", EN.DEMAND),
|
||||
"quality": ("node", EN.QUALITY), "flow": ("link", EN.FLOW), "velocity": ("link", EN.VELOCITY)}
|
||||
|
||||
|
||||
def job_calibrate(inp: bytes, params: dict) -> dict:
|
||||
"""자동 보정: per-group pipe roughness multipliers ('roughness') or wall
|
||||
coefficients ('wall') fitted to observations by bounded least squares.
|
||||
|
||||
params: mode, param, observations [{location, time (s), value (model units)}],
|
||||
groups {name: [pipe ids]}, bounds [lo, hi], maxEvaluations.
|
||||
"""
|
||||
from scipy.optimize import least_squares
|
||||
|
||||
mode = params.get("mode") or "roughness"
|
||||
param = params.get("param") or "pressure"
|
||||
if param not in PARAM_PROPS:
|
||||
raise BatchError(f"보정할 수 없는 항목입니다: {param}")
|
||||
kind, prop = PARAM_PROPS[param]
|
||||
if mode == "wall" and param != "quality":
|
||||
raise BatchError("관벽 반응계수 보정에는 수질(잔류염소) 실측값이 필요합니다.")
|
||||
if mode == "roughness" and param == "quality":
|
||||
raise BatchError("조도 보정에는 수압·수두·유량 실측값을 쓰세요.")
|
||||
obs = [o for o in params.get("observations") or [] if o.get("location") is not None and o.get("value") is not None]
|
||||
if len(obs) < 2:
|
||||
raise BatchError("실측값이 2개 이상 필요합니다.")
|
||||
groups = {str(k): [str(i) for i in v] for k, v in (params.get("groups") or {}).items() if v}
|
||||
if not groups:
|
||||
raise BatchError("보정할 관로 그룹이 없습니다.")
|
||||
if len(groups) > 30:
|
||||
raise BatchError("그룹이 30개를 넘습니다. 묶음을 줄이세요.")
|
||||
lo, hi = (float(b) for b in (params.get("bounds") or ((0.5, 1.5) if mode == "roughness" else (-5.0, 0.0))))
|
||||
if not lo < hi:
|
||||
raise BatchError("범위가 올바르지 않습니다.")
|
||||
max_eval = int(params.get("maxEvaluations") or 200)
|
||||
|
||||
with Network(inp) as net:
|
||||
locs = sorted({str(o["location"]) for o in obs})
|
||||
index = {}
|
||||
for loc in locs:
|
||||
index[loc] = net.node_index(loc) if kind == "node" else net.link_index(loc)
|
||||
cols = {loc: k for k, loc in enumerate(locs)}
|
||||
o_col = np.array([cols[str(o["location"])] for o in obs])
|
||||
o_t = np.array([float(o["time"]) for o in obs])
|
||||
o_v = np.array([float(o["value"]) for o in obs])
|
||||
g_names = list(groups)
|
||||
g_links = [[net.link_index(i) for i in groups[g]] for g in g_names]
|
||||
if mode == "wall":
|
||||
qt = ctypes.c_int(); tr = ctypes.c_int()
|
||||
net.lib.EN_getqualinfo(net.ph, ctypes.byref(qt), ctypes.create_string_buffer(64), ctypes.create_string_buffer(64), ctypes.byref(tr))
|
||||
if qt.value != 1:
|
||||
raise BatchError("모델의 수질 해석이 화학물질(CHEMICAL)이 아닙니다. 모델 설정에서 수질 모드를 바꾸세요.")
|
||||
base = [np.array([net.get_link(li, EN.KWALL) for li in links]) for links in g_links]
|
||||
x0 = np.array([float(np.clip(np.mean(b) if len(b) else (lo + hi) / 2, lo, hi)) for b in base])
|
||||
net.lib.EN_solveH(net.ph) # wall reactions do not change hydraulics
|
||||
else:
|
||||
base = [np.array([net.get_link(li, EN.ROUGHNESS) for li in links]) for links in g_links]
|
||||
x0 = np.clip(np.ones(len(g_names)), lo, hi)
|
||||
|
||||
def apply(x):
|
||||
for links, b, v in zip(g_links, base, x):
|
||||
for li, bv in zip(links, b):
|
||||
net.set_link(li, EN.KWALL if mode == "wall" else EN.ROUGHNESS, v if mode == "wall" else bv * v)
|
||||
|
||||
def simulate_values() -> np.ndarray:
|
||||
if mode == "wall":
|
||||
times, q = net.run_quality(nodes=[index[l] for l in locs])
|
||||
times = np.asarray(times, dtype=float)
|
||||
mat = q
|
||||
else:
|
||||
r = net.simulate(node_props=(prop,) if kind == "node" else (), link_props=(prop,) if kind == "link" else (),
|
||||
nodes=[index[l] for l in locs] if kind == "node" else [], links=[index[l] for l in locs] if kind == "link" else [])
|
||||
times = np.asarray(r.times, dtype=float)
|
||||
mat = (r.nodes if kind == "node" else r.links)[prop]
|
||||
if param == "flow":
|
||||
mat = np.abs(mat)
|
||||
out = np.empty(len(obs))
|
||||
for c in range(len(locs)):
|
||||
sel = o_col == c
|
||||
out[sel] = np.interp(o_t[sel], times, mat[:, c]) if len(times) > 1 else mat[0, c]
|
||||
return out
|
||||
|
||||
evals = {"n": 0}
|
||||
|
||||
def residuals(x):
|
||||
evals["n"] += 1
|
||||
apply(x)
|
||||
try:
|
||||
return simulate_values() - o_v
|
||||
except (EpanetException, BatchError):
|
||||
return np.full(len(obs), 1e3)
|
||||
|
||||
r0 = residuals(x0)
|
||||
fit = least_squares(residuals, x0, bounds=(np.full(len(x0), lo), np.full(len(x0), hi)),
|
||||
diff_step=0.02 if mode == "roughness" else 0.05, max_nfev=max_eval, xtol=1e-4, ftol=1e-6)
|
||||
r1 = residuals(fit.x)
|
||||
apply(x0) # leave the network as it was (not strictly needed)
|
||||
rmse = lambda r: float(np.sqrt(np.mean(r ** 2)))
|
||||
out_groups = []
|
||||
for name, links, b, v in zip(g_names, g_links, base, fit.x):
|
||||
out_groups.append({
|
||||
"name": name, "pipes": len(links),
|
||||
"value": round(float(v), 4),
|
||||
"meanBefore": round(float(np.mean(b)), 4) if len(b) else None,
|
||||
"meanAfter": round(float(np.mean(b * v)) if mode == "roughness" else float(v), 4) if len(b) else None,
|
||||
"atBound": bool(abs(v - lo) < 1e-6 or abs(v - hi) < 1e-6),
|
||||
})
|
||||
per_loc = []
|
||||
for c, loc in enumerate(locs):
|
||||
sel = o_col == c
|
||||
per_loc.append({"location": loc, "n": int(sel.sum()), "before": round(rmse(r0[sel]), 4), "after": round(rmse(r1[sel]), 4)})
|
||||
return {
|
||||
"mode": mode, "param": param, "groups": out_groups, "locations": per_loc,
|
||||
"rmseBefore": round(rmse(r0), 4), "rmseAfter": round(rmse(r1), 4),
|
||||
"evaluations": evals["n"], "converged": bool(fit.success), "message": str(fit.message),
|
||||
"observations": len(obs),
|
||||
}
|
||||
|
||||
|
||||
def _pump_controls(net: Network, pump_idx: set) -> list[int]:
|
||||
"""1-based indices of simple controls acting on the given pumps."""
|
||||
n = ctypes.c_int()
|
||||
net.lib.EN_getcount(net.ph, 5, ctypes.byref(n))
|
||||
out = []
|
||||
t, li, ni = ctypes.c_int(), ctypes.c_int(), ctypes.c_int()
|
||||
st, lv = ctypes.c_double(), ctypes.c_double()
|
||||
for k in range(1, n.value + 1):
|
||||
net.lib.EN_getcontrol(net.ph, k, ctypes.byref(t), ctypes.byref(li), ctypes.byref(st), ctypes.byref(ni), ctypes.byref(lv))
|
||||
if li.value in pump_idx:
|
||||
out.append(k)
|
||||
return out
|
||||
|
||||
|
||||
def job_pumpschedule(inp: bytes, params: dict) -> dict:
|
||||
"""펌프 운전 최적화: hourly (pattern-step) on/off schedules that cut energy cost
|
||||
while keeping junction pressure >= minPressure and tanks back to their start
|
||||
level at the end. Existing simple controls on those pumps are ignored.
|
||||
|
||||
params: pumps [ids] (default all), minPressure (model units), maxEvaluations,
|
||||
tankTolerance (fraction of tank range allowed below the start level at the end).
|
||||
"""
|
||||
min_p = float(params.get("minPressure") or 0)
|
||||
budget = int(params.get("maxEvaluations") or 400)
|
||||
tank_tol = float(params.get("tankTolerance") if params.get("tankTolerance") is not None else 0.02)
|
||||
with Network(inp) as net:
|
||||
all_pumps = [k + 1 for k, t in enumerate(net.link_types) if t == EN.PUMP]
|
||||
pumps = [net.link_index(str(p)) for p in params.get("pumps") or []] or all_pumps
|
||||
if not pumps:
|
||||
raise BatchError("펌프가 없습니다.")
|
||||
if net.duration < net.pattern_step:
|
||||
raise BatchError("연장 기간 해석(EPS) 모델이어야 합니다.")
|
||||
rules = ctypes.c_int()
|
||||
net.lib.EN_getcount(net.ph, 6, ctypes.byref(rules))
|
||||
controls = _pump_controls(net, set(pumps))
|
||||
slot = net.pattern_step
|
||||
n_slots = int(np.ceil(net.duration / slot))
|
||||
if n_slots > 168:
|
||||
raise BatchError("시간 구간이 168개를 넘습니다 (패턴 간격을 늘리세요).")
|
||||
junctions = net.junction_indices()
|
||||
tanks = [k + 1 for k, t in enumerate(net.node_types) if t == EN.TANK]
|
||||
t_min = np.array([net.get_node(t, 20) for t in tanks])
|
||||
t_max = np.array([net.get_node(t, 21) for t in tanks])
|
||||
t_init = np.array([net.get_node(t, EN.TANKLEVEL) for t in tanks]) # initial level
|
||||
t_elev = np.array([net.get_node(t, EN.ELEVATION) for t in tanks])
|
||||
ps0 = net.time_param(EN.PATTERNSTART)
|
||||
g_price = net.option(EN.GLOBALPRICE)
|
||||
g_pat = int(net.option(EN.GLOBALPATTERN))
|
||||
price = {p: (net.get_link(p, EN.PUMP_ECOST) or g_price or 1.0) for p in pumps}
|
||||
ppat = {p: (int(net.get_link(p, EN.PUMP_EPAT)) or g_pat) for p in pumps}
|
||||
priced = bool(g_price or any(net.get_link(p, EN.PUMP_ECOST) for p in pumps))
|
||||
|
||||
def evaluate(sched: dict | None) -> dict:
|
||||
t, tstep = ctypes.c_long(), ctypes.c_long(1)
|
||||
cost = kwh = 0.0
|
||||
pmin = np.inf
|
||||
level_end = t_init.copy()
|
||||
net.lib.EN_openH(net.ph)
|
||||
try:
|
||||
net.lib.EN_initH(net.ph, EN.NOSAVE)
|
||||
while True:
|
||||
net.lib.EN_runH(net.ph, ctypes.byref(t))
|
||||
now = t.value
|
||||
kw = [max(0.0, net.get_link(p, EN.ENERGY)) for p in pumps]
|
||||
if net.is_report_time(now) and junctions:
|
||||
pmin = min(pmin, min(net.get_node(j, EN.PRESSURE) for j in junctions))
|
||||
if tanks:
|
||||
# EN_TANKLEVEL reads back the *initial* level; the current one is head - bottom.
|
||||
level_end = np.array([net.get_node(tk, EN.HEAD) for tk in tanks]) - t_elev
|
||||
net.lib.EN_nextH(net.ph, ctypes.byref(tstep))
|
||||
dt = float(tstep.value)
|
||||
for p, k in zip(pumps, kw):
|
||||
kwh += k * dt / 3600
|
||||
cost += k * dt / 3600 * price[p] * net.pattern_value(ppat[p], now, ps0)
|
||||
if tstep.value <= 0:
|
||||
break
|
||||
finally:
|
||||
net.lib.EN_closeH(net.ph)
|
||||
short = np.maximum(0.0, (t_init - tank_tol * (t_max - t_min)) - level_end) if tanks else np.zeros(0)
|
||||
violation = max(0.0, min_p - pmin) + float(np.sum(short))
|
||||
return {"cost": cost, "kwh": kwh, "minPressure": float(pmin), "violation": violation, "tankEnd": level_end.tolist()}
|
||||
|
||||
base = evaluate(None)
|
||||
# Baseline schedule = what the existing controls did (pump on in each slot?).
|
||||
status = {p: np.zeros(n_slots) for p in pumps}
|
||||
r = net.simulate(node_props=(), link_props=(EN.FLOW,), nodes=[], links=pumps)
|
||||
for c, p in enumerate(pumps):
|
||||
for k, tt in enumerate(r.times):
|
||||
s = min(n_slots - 1, int(tt // slot))
|
||||
if abs(r.links[EN.FLOW][k, c]) > 1e-9:
|
||||
status[p][s] = 1
|
||||
# Take control of the pumps: drop their simple controls, drive them by a pattern.
|
||||
for k in sorted(controls, reverse=True):
|
||||
net.delete_control(k)
|
||||
for p in pumps:
|
||||
pid = f"SPSCH{p}"
|
||||
net.add_pattern(pid, [1.0] * n_slots)
|
||||
net.set_link(p, 15, float(_pattern_index(net, pid)))
|
||||
net.set_link(p, EN.INITSTATUS, 1)
|
||||
evals = {"n": 0}
|
||||
|
||||
def run(sched):
|
||||
evals["n"] += 1
|
||||
for p in pumps:
|
||||
net.add_pattern(f"SPSCH{p}", [float(v) for v in sched[p]])
|
||||
return evaluate(sched)
|
||||
|
||||
cur = {p: status[p].copy() for p in pumps}
|
||||
cur_res = run(cur)
|
||||
if cur_res["violation"] > 1e-6:
|
||||
# Pattern replay of the control schedule is not feasible (e.g. sub-slot switching): start from all on.
|
||||
cur = {p: np.ones(n_slots) for p in pumps}
|
||||
cur_res = run(cur)
|
||||
start_res = cur_res
|
||||
slot_price = {p: np.array([price[p] * net.pattern_value(ppat[p], k * slot, ps0) for k in range(n_slots)]) for p in pumps}
|
||||
|
||||
improved = True
|
||||
while improved and evals["n"] < budget:
|
||||
improved = False
|
||||
# 1) switch off the most expensive running slots
|
||||
cand = sorted(((slot_price[p][k], p, k) for p in pumps for k in range(n_slots) if cur[p][k] > 0), reverse=True)
|
||||
for _, p, k in cand:
|
||||
if evals["n"] >= budget:
|
||||
break
|
||||
trial = {q: cur[q].copy() for q in pumps}
|
||||
trial[p][k] = 0
|
||||
res = run(trial)
|
||||
if res["violation"] <= 1e-6 and res["cost"] < cur_res["cost"] - 1e-9:
|
||||
cur, cur_res, improved = trial, res, True
|
||||
# 2) move running time from expensive to cheap slots
|
||||
for p in pumps:
|
||||
on = [k for k in range(n_slots) if cur[p][k] > 0]
|
||||
off = [k for k in range(n_slots) if cur[p][k] == 0]
|
||||
for k_on in sorted(on, key=lambda k: -slot_price[p][k]):
|
||||
for k_off in sorted(off, key=lambda k: slot_price[p][k]):
|
||||
if evals["n"] >= budget or slot_price[p][k_off] >= slot_price[p][k_on]:
|
||||
break
|
||||
trial = {q: cur[q].copy() for q in pumps}
|
||||
trial[p][k_on], trial[p][k_off] = 0, 1
|
||||
res = run(trial)
|
||||
if res["violation"] <= 1e-6 and res["cost"] < cur_res["cost"] - 1e-9:
|
||||
cur, cur_res, improved = trial, res, True
|
||||
off.remove(k_off)
|
||||
off.append(k_on)
|
||||
break
|
||||
return {
|
||||
"pumps": [net.link_ids[p - 1] for p in pumps],
|
||||
"slotSeconds": slot,
|
||||
"schedules": {net.link_ids[p - 1]: [int(v) for v in cur[p]] for p in pumps},
|
||||
"before": {k: round(v, 3) if isinstance(v, float) else v for k, v in base.items()},
|
||||
"start": {k: round(v, 3) if isinstance(v, float) else v for k, v in start_res.items()},
|
||||
"after": {k: round(v, 3) if isinstance(v, float) else v for k, v in cur_res.items()},
|
||||
"priced": priced,
|
||||
"evaluations": evals["n"],
|
||||
"removedControls": len(controls),
|
||||
"rules": rules.value,
|
||||
"feasible": cur_res["violation"] <= 1e-6,
|
||||
}
|
||||
|
||||
|
||||
def _pattern_index(net: Network, pid: str) -> int:
|
||||
idx = ctypes.c_int()
|
||||
net.lib.EN_getpatternindex(net.ph, pid.encode(), ctypes.byref(idx))
|
||||
return idx.value
|
||||
|
||||
|
||||
JOBS = {
|
||||
"summary": job_summary,
|
||||
"fireflow": job_fireflow,
|
||||
"criticality": job_criticality,
|
||||
"servicearea": job_servicearea,
|
||||
"vsp": job_vsp,
|
||||
"energy": job_energy,
|
||||
"calibrate": job_calibrate,
|
||||
"pumpschedule": job_pumpschedule,
|
||||
}
|
||||
|
||||
|
||||
def get_job(name: str):
|
||||
fn = JOBS.get(name)
|
||||
if fn is None:
|
||||
raise BatchError(f"알 수 없는 분석입니다: {name}")
|
||||
return fn
|
||||
Reference in new issue
Block a user