- SPipe_server_2026-10-30.zip 내용을 풀어서 등록 (압축 파일은 올리지 않음) - README에 데모 v2 서버 업데이트 순서 추가, 저장소에 없는 문서 목차 정리 - 패치내역을 docs/로 이동, _worklog.md 추가 - 임시 파일(backend/temp.*)과 API 키가 든 파일은 제외
196 lines
7.1 KiB
TypeScript
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,
|
|
};
|
|
}
|