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 = {}; const nodeIndexToId: Record = {}; 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 = {}; const linkIndexToId: Record = {}; 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 = {}; const linkSeries: Record = {}; 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 = {}; 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 }); } };