- SPipe_server_2026-10-30.zip 내용을 풀어서 등록 (압축 파일은 올리지 않음) - README에 데모 v2 서버 업데이트 순서 추가, 저장소에 없는 문서 목차 정리 - 패치내역을 docs/로 이동, _worklog.md 추가 - 임시 파일(backend/temp.*)과 API 키가 든 파일은 제외
345 lines
12 KiB
TypeScript
345 lines
12 KiB
TypeScript
import {
|
|
CountType,
|
|
FlowUnits,
|
|
InitHydOption,
|
|
LinkProperty,
|
|
LinkType,
|
|
NodeProperty,
|
|
Option,
|
|
Project,
|
|
TimeParameter,
|
|
Workspace,
|
|
} from 'epanet-js';
|
|
import type { SimulationResult, NodeResult, LinkResult, PumpEnergyResult, PumpEnergySummary, QualityInfo } from './types';
|
|
|
|
import { computePumpEnergy } from './resultPostProcess';
|
|
|
|
// @ts-ignore
|
|
import { EpanetEngine } from 'epanet-js/engines/v2.3.5';
|
|
|
|
self.onmessage = async (e: MessageEvent<{ id: string; fileStr: string }>) => {
|
|
const { id, fileStr } = e.data;
|
|
try {
|
|
const workspace = new Workspace();
|
|
await workspace.loadModuleVersion(() => EpanetEngine());
|
|
|
|
if (workspace.version !== 20305) {
|
|
throw new Error(`EPANET 2.3.5가 필요합니다. 로드된 버전: ${workspace.version}`);
|
|
}
|
|
|
|
workspace.writeFile('network.inp', fileStr);
|
|
const project = new Project(workspace);
|
|
let networkOpened = false;
|
|
let hydraulicsOpened = false;
|
|
|
|
try {
|
|
// Mark opened first: a failed open still holds the files, so close() must run.
|
|
networkOpened = true;
|
|
project.open('network.inp', 'report.rpt', 'output.out');
|
|
|
|
const nodeCount = project.getCount(CountType.NodeCount);
|
|
const linkCount = project.getCount(CountType.LinkCount);
|
|
|
|
// 1. Collect node metadata
|
|
const nodes: Record<string, NodeResult> = {};
|
|
const nodeIndexToId: Record<number, string> = {};
|
|
|
|
for (let index = 1; index <= nodeCount; index += 1) {
|
|
const nodeId = project.getNodeId(index);
|
|
nodeIndexToId[index] = nodeId;
|
|
let coordinates: { x: number; y: number } | null = null;
|
|
try {
|
|
coordinates = project.getCoordinates(index);
|
|
} catch {
|
|
coordinates = null;
|
|
}
|
|
|
|
nodes[nodeId] = {
|
|
id: nodeId,
|
|
type: project.getNodeType(index),
|
|
coordinates,
|
|
demand: 0,
|
|
head: 0,
|
|
pressure: 0,
|
|
};
|
|
}
|
|
|
|
// 2. Collect link metadata
|
|
const links: Record<string, LinkResult> = {};
|
|
const linkIndexToId: Record<number, string> = {};
|
|
|
|
for (let index = 1; index <= linkCount; index += 1) {
|
|
const linkId = project.getLinkId(index);
|
|
linkIndexToId[index] = linkId;
|
|
const { node1, node2 } = project.getLinkNodes(index);
|
|
const vertices: Array<{ x: number; y: number }> = [];
|
|
|
|
for (
|
|
let vertexIndex = 1;
|
|
vertexIndex <= project.getVertexCount(index);
|
|
vertexIndex += 1
|
|
) {
|
|
vertices.push(project.getVertex(index, vertexIndex));
|
|
}
|
|
|
|
links[linkId] = {
|
|
id: linkId,
|
|
type: project.getLinkType(index),
|
|
fromNode: project.getNodeId(node1),
|
|
toNode: project.getNodeId(node2),
|
|
vertices,
|
|
flow: 0,
|
|
velocity: 0,
|
|
headloss: 0,
|
|
};
|
|
}
|
|
|
|
// 3. Open hydraulics and run full EPS loop (Save hydraulics for water quality)
|
|
project.openH();
|
|
hydraulicsOpened = true;
|
|
project.initH(InitHydOption.Save);
|
|
|
|
const timeSteps: number[] = [];
|
|
const nodeSeries: Record<string, { demand: number[]; head: number[]; pressure: number[]; quality: number[] }> = {};
|
|
const linkSeries: Record<string, { flow: number[]; velocity: number[]; headloss: number[]; quality: number[]; energy: number[]; efficiency: number[] }> = {};
|
|
|
|
for (const nid of Object.keys(nodes)) {
|
|
nodeSeries[nid] = { demand: [], head: [], pressure: [], quality: [] };
|
|
}
|
|
for (const lid of Object.keys(links)) {
|
|
linkSeries[lid] = { flow: [], velocity: [], headloss: [], quality: [], energy: [], efficiency: [] };
|
|
}
|
|
|
|
// epanet-js reports EN_runH warning codes (1-6) only via console.warn;
|
|
// capture them so they count even under [REPORT] MESSAGES NO.
|
|
const engineWarnings: Array<{ time: number; code: number }> = [];
|
|
const pendingWarningCodes: number[] = [];
|
|
const originalWarn = console.warn;
|
|
console.warn = (...args: unknown[]) => {
|
|
const match = /Warning (\d+)/.exec(String(args[0] ?? ''));
|
|
if (match) pendingWarningCodes.push(Number(match[1]));
|
|
originalWarn(...args);
|
|
};
|
|
|
|
let tstep = 1;
|
|
try {
|
|
while (tstep > 0) {
|
|
const currentTime = project.runH();
|
|
timeSteps.push(currentTime);
|
|
for (const code of pendingWarningCodes.splice(0)) {
|
|
engineWarnings.push({ time: currentTime, code });
|
|
}
|
|
|
|
// Bulk reads: one engine call per property instead of one per element.
|
|
const demands = project.getNodeValues(NodeProperty.Demand);
|
|
const heads = project.getNodeValues(NodeProperty.Head);
|
|
const pressures = project.getNodeValues(NodeProperty.Pressure);
|
|
for (let index = 1; index <= nodeCount; index += 1) {
|
|
const nid = nodeIndexToId[index];
|
|
nodeSeries[nid].demand.push(demands[index - 1]);
|
|
nodeSeries[nid].head.push(heads[index - 1]);
|
|
nodeSeries[nid].pressure.push(pressures[index - 1]);
|
|
}
|
|
|
|
const flows = project.getLinkValues(LinkProperty.Flow);
|
|
const velocities = project.getLinkValues(LinkProperty.Velocity);
|
|
const headlosses = project.getLinkValues(LinkProperty.Headloss);
|
|
for (let index = 1; index <= linkCount; index += 1) {
|
|
const lid = linkIndexToId[index];
|
|
linkSeries[lid].flow.push(flows[index - 1]);
|
|
linkSeries[lid].velocity.push(velocities[index - 1]);
|
|
linkSeries[lid].headloss.push(headlosses[index - 1]);
|
|
|
|
if (links[lid].type === LinkType.Pump) {
|
|
const eKw = project.getLinkValue(index, LinkProperty.Energy);
|
|
const effRatio = project.getLinkValue(index, LinkProperty.PumpEffic);
|
|
linkSeries[lid].energy.push(Math.round(eKw * 100) / 100);
|
|
linkSeries[lid].efficiency.push(Math.round(effRatio * 1000) / 10);
|
|
}
|
|
}
|
|
tstep = project.nextH();
|
|
}
|
|
} finally {
|
|
console.warn = originalWarn;
|
|
}
|
|
|
|
// Close hydraulics before water quality simulation
|
|
if (hydraulicsOpened) {
|
|
try {
|
|
project.closeH();
|
|
hydraulicsOpened = false;
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
// 4. Check and run Water Quality Simulation (Chemical decay, Water Age, Tracer)
|
|
let qualityInfo: QualityInfo | undefined = undefined;
|
|
let qualityOpened = false;
|
|
let qualityFailed = false;
|
|
let qualityErrorMessage: string | undefined = undefined;
|
|
try {
|
|
const qInfo = project.getQualityInfo();
|
|
if (qInfo && qInfo.qualType > 0) {
|
|
const typeNames = ['NONE', 'CHEMICAL', 'AGE', 'TRACE'] as const;
|
|
const qTypeIdx = qInfo.qualType as number;
|
|
qualityInfo = {
|
|
qualType: qTypeIdx,
|
|
typeName: typeNames[qTypeIdx] ?? 'CHEMICAL',
|
|
chemName: qInfo.chemName || (qTypeIdx === 2 ? 'Age' : 'Quality'),
|
|
chemUnits: qInfo.chemUnits || (qTypeIdx === 2 ? 'hours' : 'mg/L'),
|
|
traceNode: qInfo.traceNode > 0 ? (nodeIndexToId[qInfo.traceNode] ?? '') : undefined,
|
|
status: 'success',
|
|
};
|
|
|
|
project.openQ();
|
|
qualityOpened = true;
|
|
project.initQ(InitHydOption.NoSave);
|
|
|
|
let tstepQ = 1;
|
|
while (tstepQ > 0) {
|
|
project.runQ();
|
|
for (let index = 1; index <= nodeCount; index += 1) {
|
|
const nid = nodeIndexToId[index];
|
|
const qv = project.getNodeValue(index, NodeProperty.Quality);
|
|
nodeSeries[nid].quality.push(Math.round(qv * 10000) / 10000);
|
|
}
|
|
|
|
for (let index = 1; index <= linkCount; index += 1) {
|
|
const lid = linkIndexToId[index];
|
|
const lqv = project.getLinkValue(index, LinkProperty.LinkQual);
|
|
linkSeries[lid].quality.push(Math.round(lqv * 10000) / 10000);
|
|
}
|
|
tstepQ = project.nextQ();
|
|
}
|
|
}
|
|
} catch (err: unknown) {
|
|
qualityFailed = true;
|
|
qualityErrorMessage = err instanceof Error ? err.message : String(err);
|
|
if (qualityInfo) {
|
|
qualityInfo.status = 'failed';
|
|
qualityInfo.errorMessage = qualityErrorMessage;
|
|
}
|
|
} finally {
|
|
if (qualityOpened) {
|
|
try {
|
|
project.closeQ();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
|
|
// Assign initial values (t=0) and full timeSeries
|
|
const numSteps = timeSteps.length;
|
|
const hasValidQuality = qualityInfo && qualityInfo.status === 'success' && !qualityFailed;
|
|
|
|
for (const nid of Object.keys(nodes)) {
|
|
if (!hasValidQuality || nodeSeries[nid].quality.length !== numSteps) {
|
|
nodeSeries[nid].quality = [];
|
|
}
|
|
nodes[nid].demand = nodeSeries[nid].demand[0] ?? 0;
|
|
nodes[nid].head = nodeSeries[nid].head[0] ?? 0;
|
|
nodes[nid].pressure = nodeSeries[nid].pressure[0] ?? 0;
|
|
nodes[nid].quality = hasValidQuality ? (nodeSeries[nid].quality[0] ?? 0) : undefined;
|
|
nodes[nid].timeSeries = nodeSeries[nid];
|
|
}
|
|
|
|
for (const lid of Object.keys(links)) {
|
|
if (!hasValidQuality || linkSeries[lid].quality.length !== numSteps) {
|
|
linkSeries[lid].quality = [];
|
|
}
|
|
links[lid].flow = linkSeries[lid].flow[0] ?? 0;
|
|
links[lid].velocity = linkSeries[lid].velocity[0] ?? 0;
|
|
links[lid].headloss = linkSeries[lid].headloss[0] ?? 0;
|
|
links[lid].quality = hasValidQuality ? (linkSeries[lid].quality[0] ?? 0) : undefined;
|
|
links[lid].timeSeries = linkSeries[lid];
|
|
}
|
|
|
|
|
|
// 5. Pump energy metrics (shared definitions: resultPostProcess.ts)
|
|
const pumpSeries: Record<string, { energy: number[]; efficiency: number[] }> = {};
|
|
for (const lid of Object.keys(links)) {
|
|
if (links[lid].type === LinkType.Pump) pumpSeries[lid] = linkSeries[lid];
|
|
}
|
|
const { perPump, summary: energySummary } = computePumpEnergy(timeSteps, pumpSeries, links);
|
|
for (const [lid, metrics] of Object.entries(perPump)) {
|
|
links[lid].energy = linkSeries[lid].energy[0] ?? 0;
|
|
links[lid].efficiency = linkSeries[lid].efficiency[0] ?? 0;
|
|
links[lid].totalKwh = metrics.totalKwh;
|
|
links[lid].peakKw = metrics.peakKw;
|
|
links[lid].avgKw = metrics.avgKw;
|
|
links[lid].utilization = metrics.utilization;
|
|
}
|
|
const flowUnitIndex = project.getFlowUnits();
|
|
const reportStart = project.getTimeParameter(TimeParameter.ReportStart);
|
|
const reportStep = project.getTimeParameter(TimeParameter.ReportStep);
|
|
const pressureUnitIndex = project.getOption(25 as Option);
|
|
const pressureUnitNames = ['psi', 'kPa', 'm', 'bar', 'ft'];
|
|
|
|
// Close project to flush report
|
|
if (hydraulicsOpened) {
|
|
try {
|
|
project.closeH();
|
|
hydraulicsOpened = false;
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
if (networkOpened) {
|
|
try {
|
|
project.close();
|
|
networkOpened = false;
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
let reportText = '';
|
|
try {
|
|
reportText = workspace.readFile('report.rpt');
|
|
} catch {
|
|
reportText = '';
|
|
}
|
|
|
|
const result: SimulationResult = {
|
|
engine: { runtime: 'wasm', version: '2.3.5' },
|
|
timeSeconds: timeSteps[0] ?? 0,
|
|
timeSteps,
|
|
units: {
|
|
flow: FlowUnits[flowUnitIndex] ?? 'Unknown',
|
|
pressure: pressureUnitNames[pressureUnitIndex] ?? 'Unknown',
|
|
},
|
|
nodes,
|
|
links,
|
|
reportText,
|
|
qualityInfo,
|
|
energySummary,
|
|
engineWarnings,
|
|
reportStart,
|
|
reportStep,
|
|
};
|
|
|
|
self.postMessage({ id, result });
|
|
} finally {
|
|
if (hydraulicsOpened) {
|
|
try {
|
|
project.closeH();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
if (networkOpened) {
|
|
try {
|
|
project.close();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
} catch (error: unknown) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
self.postMessage({ id, error: message });
|
|
}
|
|
};
|