Files
bjkim1 21ebb16fb7 SPipe 데모 v2 (2026-10-30) 서버 배포본
- SPipe_server_2026-10-30.zip 내용을 풀어서 등록 (압축 파일은 올리지 않음)
- README에 데모 v2 서버 업데이트 순서 추가, 저장소에 없는 문서 목차 정리
- 패치내역을 docs/로 이동, _worklog.md 추가
- 임시 파일(backend/temp.*)과 API 키가 든 파일은 제외
2026-10-30 11:00:46 +09:00

725 lines
34 KiB
Python

"""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