SPipe 데모 v2 (2026-10-30) 서버 배포본
- SPipe_server_2026-10-30.zip 내용을 풀어서 등록 (압축 파일은 올리지 않음) - README에 데모 v2 서버 업데이트 순서 추가, 저장소에 없는 문서 목차 정리 - 패치내역을 docs/로 이동, _worklog.md 추가 - 임시 파일(backend/temp.*)과 API 키가 든 파일은 제외
This commit is contained in:
commit
21ebb16fb7
181 files changed
+57207
No files matched your search
@@ -0,0 +1,195 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user