export interface ConvergenceInspection { status: string; color: string; note: string; hasWarning: boolean; } export interface EngineIssueTarget { type: 'node' | 'link'; id: string; } export interface EngineIssue { message: string; severity: 'error' | 'warning'; target: EngineIssueTarget | null; } const ENGINE_ISSUE_LINE_PATTERN = /WARNING|Error\s+\d+|disconnected|unbalanced|cannot deliver|negative pressure/i; /** * Extracts individual warning/error lines from an EPANET .rpt report body and * links each one to the specific node/link it concerns, when identifiable -- * so "왜 실패했는지" doesn't require manually reading the raw report text and * cross-referencing IDs by hand. Rather than trying to match every phrasing * EPANET might use for a message ("Node %s disconnected", "Pump %s shut * down", etc, which vary across engine versions), this scans each flagged * line's own tokens against the model's actual node/link IDs -- so it finds * the referenced object regardless of the surrounding sentence, at the cost * of occasionally missing a reference that isn't a whole token (rare in * EPANET's report format, which always writes IDs as standalone words). */ export function extractEngineIssues( reportText: string | undefined | null, nodeIds: Iterable, linkIds: Iterable ): EngineIssue[] { if (!reportText) return []; const nodeIdSet = new Set(nodeIds); const linkIdSet = new Set(linkIds); if (nodeIdSet.size === 0 && linkIdSet.size === 0) return []; const issues: EngineIssue[] = []; for (const rawLine of reportText.split(/\r?\n/)) { const line = rawLine.trim(); if (!line || !ENGINE_ISSUE_LINE_PATTERN.test(line)) continue; let target: EngineIssueTarget | null = null; for (const token of line.split(/[\s,;:()]+/)) { if (!token) continue; if (nodeIdSet.has(token)) { target = { type: 'node', id: token }; break; } if (linkIdSet.has(token)) { target = { type: 'link', id: token }; break; } } issues.push({ message: line, severity: /Error\s+\d+/i.test(line) ? 'error' : 'warning', target, }); } return issues; } export interface EngineWarning { time: number; code: number; } // EPANET warning codes returned by EN_runH (src/errors.dat, "WARNING" block). const WARNING_TEXT: Record = { 1: 'System hydraulically unbalanced (수리방정식 미수렴)', 2: 'Pumps cannot deliver head or flow (펌프 양정/유량 부족)', 3: 'Valves cannot deliver flow or pressure (밸브 제어 불가)', 4: 'Pumps/valves cannot deliver (펌프·밸브 제어 불가)', 5: 'Network disconnected (관망 단절)', 6: 'Negative pressures (음압 발생)', }; // Pressures this far from any real network (in psi, m, kPa, bar or ft) only // come out of a singular/disconnected solve, never from a physical answer. const IMPLAUSIBLE_PRESSURE = 1e5; function formatClock(seconds: number): string { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); return `${h}:${String(m).padStart(2, '0')}`; } /** * Turns warning codes collected directly from the engine (not from the report * text, which loses them under "[REPORT] MESSAGES NO") into report-style lines. */ export function engineWarningLines(warnings: EngineWarning[] | undefined | null): string[] { if (!warnings || warnings.length === 0) return []; return warnings.map((w) => `WARNING ${w.code}: ${WARNING_TEXT[w.code] ?? 'Engine warning'} at ${formatClock(w.time)} hrs.`); } export function findImplausiblePressureNodes( nodes: Record | undefined ): string[] { if (!nodes) return []; const ids: string[] = []; for (const [id, node] of Object.entries(nodes)) { const series = node.timeSeries?.pressure ?? [node.pressure ?? 0]; if (series.some((p) => Math.abs(p) > IMPLAUSIBLE_PRESSURE)) ids.push(id); } return ids; } /** * Verdict for a whole simulation result: the report text, plus the warning * codes the engine returned directly, plus a physical-plausibility check on * pressures. Prefer this over inspectConvergence whenever a result is at hand. */ export function inspectSimulation( result: { reportText?: string; engineWarnings?: EngineWarning[]; nodes?: Record; } | null | undefined ): ConvergenceInspection { if (!result) return inspectConvergence(null); const base = inspectConvergence(result.reportText); if (base.hasWarning) return base; const warningLines = engineWarningLines(result.engineWarnings); const implausible = findImplausiblePressureNodes(result.nodes); if (warningLines.length > 0 || implausible.length > 0) { const parts: string[] = []; if (warningLines.length > 0) parts.push(`엔진 경고 ${warningLines.length}건 (보고서에는 기록되지 않음)`); if (implausible.length > 0) parts.push(`비물리적 압력 절점 ${implausible.length}개 (${implausible.slice(0, 5).join(', ')}${implausible.length > 5 ? ' 외' : ''})`); return { status: '경고/불균형 발생 (엔진 로그 확인 필요)', color: '#dc2626', note: parts.join(' · '), hasWarning: true, }; } return base; } /** * Inspects an EPANET .rpt report body for genuine engine warnings/errors. * Never hardcodes "정상 수렴" (normal convergence) - the verdict always derives * from the actual report text produced by the engine for the current analysis run. */ export function inspectConvergence(reportText?: string | null): ConvergenceInspection { if (!reportText || reportText.trim().length === 0) { return { status: '해석 미실행 (확인 불가)', color: '#64748b', note: '엔진 보고서가 생성되지 않았습니다.', hasWarning: false, }; } const hasWarning = reportText.includes('WARNING') || reportText.includes('unbalanced') || reportText.includes('System unbalanced') || reportText.includes('Negative pressures') || reportText.includes('cannot be met') || /Error\s+\d+/.test(reportText); if (hasWarning) { return { status: '경고/불균형 발생 (엔진 로그 확인 필요)', color: '#dc2626', note: '엔진 보고서에 경고 또는 오류가 포함되어 있습니다.', hasWarning: true, }; } if (reportText.length > 50 && (reportText.includes('Hydraulic Status') || reportText.includes('Analysis begun'))) { return { status: '수리방정식 정상 수렴 확인', color: '#16a34a', note: '엔진 보고서에 경고 없이 해석이 완료되었습니다.', hasWarning: false, }; } return { status: '해석 완료 (상세 로그 참조)', color: '#2563eb', note: '보고서 내용이 짧아 수렴 여부를 완전히 판별할 수 없습니다. 상세 로그를 확인하세요.', hasWarning: false, }; }