Files
SPipe/src/convergence.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

196 lines
7.1 KiB
TypeScript

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<string>,
linkIds: Iterable<string>
): 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<number, string> = {
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<string, { type?: number; timeSeries?: { pressure?: number[] }; pressure?: number }> | 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<string, { type?: number; timeSeries?: { pressure?: number[] }; pressure?: number }>;
} | 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,
};
}