Files
SPipe/src/worker.ts
T
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

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 });
}
};