SPipe 데모 v2 (2026-10-30) 서버 배포본

- SPipe_server_2026-10-30.zip 내용을 풀어서 등록 (압축 파일은 올리지 않음)
- README에 데모 v2 서버 업데이트 순서 추가, 저장소에 없는 문서 목차 정리
- 패치내역을 docs/로 이동, _worklog.md 추가
- 임시 파일(backend/temp.*)과 API 키가 든 파일은 제외
This commit is contained in:
bjkim1 committed 2026-10-30 11:00:46 +09:00
commit 21ebb16fb7
181 files changed
+57207

No files matched your search

+330
View File
@@ -0,0 +1,330 @@
import type {
SimulationResult,
ComparisonResult,
ComparisonVariable,
ToleranceConfig,
ToleranceViolation,
} from './types';
export const DEFAULT_TOLERANCES: Record<ComparisonVariable, ToleranceConfig> = {
head: { absTol: 1e-4, relTol: 1e-8 },
pressure: { absTol: 1e-4, relTol: 1e-8 },
demand: { absTol: 1e-3, relTol: 1e-8 },
flow: { absTol: 1e-3, relTol: 1e-8 },
velocity: { absTol: 1e-5, relTol: 1e-8 },
headloss: { absTol: 1e-5, relTol: 1e-8 },
quality: { absTol: 1e-3, relTol: 1e-8 },
};
// How many model flow units make up 1 LPS. The default flow/demand absTol
// (1e-3) is meant in LPS; applied verbatim to e.g. CMD it was 86x stricter,
// so near-zero flows in real CMD networks failed on solver round-off alone.
const FLOW_UNITS_PER_LPS: Record<string, number> = {
LPS: 1, LPM: 60, CMH: 3.6, CMD: 86.4, MLD: 0.0864, CMS: 0.001,
CFS: 0.0353147, GPM: 15.8503, MGD: 0.0228245, IMGD: 0.0190053, AFD: 0.0700456,
};
function flowScaledTolerances(
base: Record<ComparisonVariable, ToleranceConfig>,
flowUnit: string,
maxAbsFlow: number
): Record<ComparisonVariable, ToleranceConfig> {
const perLps = FLOW_UNITS_PER_LPS[flowUnit.toUpperCase().trim()] ?? 1;
// Also never demand more than ~1e-6 of the largest flow in the network.
const scaled = (tol: ToleranceConfig): ToleranceConfig => ({
...tol,
absTol: Math.max(tol.absTol * perLps, 1e-6 * maxAbsFlow),
});
return { ...base, flow: scaled(base.flow), demand: scaled(base.demand) };
}
function maxAbsSeriesValue(result: SimulationResult, kind: 'flow' | 'demand'): number {
let max = 0;
const items = kind === 'flow' ? Object.values(result.links) : Object.values(result.nodes);
for (const item of items) {
const series = (item.timeSeries as Record<string, number[] | undefined> | undefined)?.[kind];
const values = series ?? [(item as unknown as Record<string, number>)[kind] ?? 0];
for (const v of values) if (Number.isFinite(v) && Math.abs(v) > max) max = Math.abs(v);
}
return max;
}
function checkPoint(
variable: ComparisonVariable,
id: string,
time: number,
valServer: number | undefined | null,
valWasm: number | undefined | null,
maxDiffs: Record<ComparisonVariable, number>,
maxRelativeDiffs: Record<ComparisonVariable, number>,
maxSignificantRelativeDiffs: Record<ComparisonVariable, number>,
nearZeroCounts: Record<ComparisonVariable, number>,
toleranceViolations: ToleranceViolation[],
tolerances: Record<ComparisonVariable, ToleranceConfig>,
missingIds: string[]
) {
if (
valServer === undefined ||
valServer === null ||
valWasm === undefined ||
valWasm === null
) {
missingIds.push(`MissingValue:${variable}:${id}@${time}s`);
toleranceViolations.push({
variable,
id,
time,
diff: Number.POSITIVE_INFINITY,
tolerance: 0,
});
return;
}
if (!Number.isFinite(valServer) || !Number.isFinite(valWasm)) {
missingIds.push(`NonFiniteValue:${variable}:${id}@${time}s`);
toleranceViolations.push({
variable,
id,
time,
diff: Number.NaN,
tolerance: 0,
});
return;
}
const diff = Math.abs(valServer - valWasm);
const scale = Math.max(Math.abs(valServer), Math.abs(valWasm));
const relDiff = scale === 0 ? 0 : diff / scale;
const tol = tolerances[variable];
const allowed = tol.absTol + tol.relTol * scale;
if (diff > maxDiffs[variable]) {
maxDiffs[variable] = diff;
}
if (relDiff > maxRelativeDiffs[variable]) {
maxRelativeDiffs[variable] = relDiff;
}
// Denominator near-zero check: when scale <= absTol, relative error can be
// mathematically exaggerated (e.g. 1e-6 diff on a 1e-6 flow gives 100% relative error,
// yet is well within hydraulic tolerance).
if (scale <= tol.absTol) {
nearZeroCounts[variable]++;
} else {
if (relDiff > maxSignificantRelativeDiffs[variable]) {
maxSignificantRelativeDiffs[variable] = relDiff;
}
}
if (diff > allowed) {
toleranceViolations.push({
variable,
id,
time,
diff,
tolerance: allowed,
});
}
}
/**
* Computes the Native vs WASM comparison for a simulation result pair.
*
* Evaluation is strictly based on:
* allowedTolerance = absTol + relTol * max(|a|, |b|)
*
* Relative error is computed across all points, but points where the denominator
* is near zero (scale <= absTol) are counted separately so that zero-flow/velocity
* mathematical artifacts (e.g. 1.0~1.64 relative diff on 1e-6 flow) do not mislead users.
*/
export function computeComparison(
server: SimulationResult,
wasm: SimulationResult,
customTolerances?: Partial<Record<ComparisonVariable, ToleranceConfig>>
): ComparisonResult {
const unitAware = flowScaledTolerances(
DEFAULT_TOLERANCES,
server.units?.flow || wasm.units?.flow || 'LPS',
Math.max(maxAbsSeriesValue(server, 'flow'), maxAbsSeriesValue(server, 'demand'))
);
const tolerances: Record<ComparisonVariable, ToleranceConfig> = {
...unitAware,
...customTolerances,
};
// When BOTH engines' quality runs failed there is nothing to compare: report
// that once (qualityInfo.status) instead of one MissingValue per node/step.
const bothQualityFailed =
server.qualityInfo?.status === 'failed' && wasm.qualityInfo?.status === 'failed';
const hasQualitySimulation = !bothQualityFailed && Boolean(
(server.qualityInfo && server.qualityInfo.qualType > 0) ||
(wasm.qualityInfo && wasm.qualityInfo.qualType > 0)
);
const variables: ComparisonVariable[] = hasQualitySimulation
? ['demand', 'head', 'pressure', 'flow', 'velocity', 'headloss', 'quality']
: ['demand', 'head', 'pressure', 'flow', 'velocity', 'headloss'];
const maxDiffs = {} as Record<ComparisonVariable, number>;
const maxRelativeDiffs = {} as Record<ComparisonVariable, number>;
const maxSignificantRelativeDiffs = {} as Record<ComparisonVariable, number>;
const nearZeroCounts = {} as Record<ComparisonVariable, number>;
for (const v of variables) {
maxDiffs[v] = 0;
maxRelativeDiffs[v] = 0;
maxSignificantRelativeDiffs[v] = 0;
nearZeroCounts[v] = 0;
}
const toleranceViolations: ToleranceViolation[] = [];
const missingIds: string[] = [];
// 0. Strict unit metadata match
const serverFlow = (server.units?.flow || '').toUpperCase().trim();
const wasmFlow = (wasm.units?.flow || '').toUpperCase().trim();
if (serverFlow && wasmFlow && serverFlow !== wasmFlow) {
missingIds.push(`UnitMismatch:flow(${serverFlow}!=${wasmFlow})`);
}
const serverPress = (server.units?.pressure || '').toUpperCase().trim();
const wasmPress = (wasm.units?.pressure || '').toUpperCase().trim();
if (serverPress && wasmPress && serverPress !== wasmPress) {
missingIds.push(`UnitMismatch:pressure(${serverPress}!=${wasmPress})`);
}
const serverQInfo = server.qualityInfo;
const wasmQInfo = wasm.qualityInfo;
if (serverQInfo && wasmQInfo) {
if (serverQInfo.qualType !== wasmQInfo.qualType) {
missingIds.push(`QualityMismatch:qualType(${serverQInfo.qualType}!=${wasmQInfo.qualType})`);
}
const sqUnits = (serverQInfo.chemUnits || '').trim().toUpperCase();
const wqUnits = (wasmQInfo.chemUnits || '').trim().toUpperCase();
if (sqUnits && wqUnits && sqUnits !== wqUnits) {
missingIds.push(`QualityMismatch:chemUnits(${sqUnits}!=${wqUnits})`);
}
}
const serverSteps = server.timeSteps || [0];
const wasmSteps = wasm.timeSteps || [0];
const wasmIndexByTime = new Map<number, number>();
wasmSteps.forEach((t, idx) => wasmIndexByTime.set(t, idx));
const serverTimeSet = new Set(serverSteps);
const missingTimestamps = new Set<number>();
// Compare Nodes
for (const id in server.nodes) {
if (!wasm.nodes[id]) {
missingIds.push(`Node:${id}`);
continue;
}
const s = server.nodes[id];
const w = wasm.nodes[id];
if (s.timeSeries && w.timeSeries) {
for (let si = 0; si < serverSteps.length; si++) {
const t = serverSteps[si];
const wi = wasmIndexByTime.get(t);
if (wi === undefined) {
missingTimestamps.add(t);
continue;
}
const sd = si < s.timeSeries.demand.length ? s.timeSeries.demand[si] : undefined;
const wd = wi < w.timeSeries.demand.length ? w.timeSeries.demand[wi] : undefined;
const sh = si < s.timeSeries.head.length ? s.timeSeries.head[si] : undefined;
const wh = wi < w.timeSeries.head.length ? w.timeSeries.head[wi] : undefined;
const sp = si < s.timeSeries.pressure.length ? s.timeSeries.pressure[si] : undefined;
const wp = wi < w.timeSeries.pressure.length ? w.timeSeries.pressure[wi] : undefined;
checkPoint('demand', id, t, sd, wd, maxDiffs, maxRelativeDiffs, maxSignificantRelativeDiffs, nearZeroCounts, toleranceViolations, tolerances, missingIds);
checkPoint('head', id, t, sh, wh, maxDiffs, maxRelativeDiffs, maxSignificantRelativeDiffs, nearZeroCounts, toleranceViolations, tolerances, missingIds);
checkPoint('pressure', id, t, sp, wp, maxDiffs, maxRelativeDiffs, maxSignificantRelativeDiffs, nearZeroCounts, toleranceViolations, tolerances, missingIds);
if (hasQualitySimulation) {
const sq = s.timeSeries.quality ? (si < s.timeSeries.quality.length ? s.timeSeries.quality[si] : undefined) : s.quality;
const wq = w.timeSeries.quality ? (wi < w.timeSeries.quality.length ? w.timeSeries.quality[wi] : undefined) : w.quality;
checkPoint('quality', id, t, sq, wq, maxDiffs, maxRelativeDiffs, maxSignificantRelativeDiffs, nearZeroCounts, toleranceViolations, tolerances, missingIds);
}
}
} else {
checkPoint('demand', id, server.timeSeconds, s.demand, w.demand, maxDiffs, maxRelativeDiffs, maxSignificantRelativeDiffs, nearZeroCounts, toleranceViolations, tolerances, missingIds);
checkPoint('head', id, server.timeSeconds, s.head, w.head, maxDiffs, maxRelativeDiffs, maxSignificantRelativeDiffs, nearZeroCounts, toleranceViolations, tolerances, missingIds);
checkPoint('pressure', id, server.timeSeconds, s.pressure, w.pressure, maxDiffs, maxRelativeDiffs, maxSignificantRelativeDiffs, nearZeroCounts, toleranceViolations, tolerances, missingIds);
if (hasQualitySimulation) {
checkPoint('quality', id, server.timeSeconds, s.quality, w.quality, maxDiffs, maxRelativeDiffs, maxSignificantRelativeDiffs, nearZeroCounts, toleranceViolations, tolerances, missingIds);
}
}
}
for (const id in wasm.nodes) {
if (!server.nodes[id] && !missingIds.includes(`Node:${id}`)) missingIds.push(`Node:${id}`);
}
// Compare Links
for (const id in server.links) {
if (!wasm.links[id]) {
missingIds.push(`Link:${id}`);
continue;
}
const s = server.links[id];
const w = wasm.links[id];
if (s.timeSeries && w.timeSeries) {
for (let si = 0; si < serverSteps.length; si++) {
const t = serverSteps[si];
const wi = wasmIndexByTime.get(t);
if (wi === undefined) {
missingTimestamps.add(t);
continue;
}
const sf = si < s.timeSeries.flow.length ? s.timeSeries.flow[si] : undefined;
const wf = wi < w.timeSeries.flow.length ? w.timeSeries.flow[wi] : undefined;
const sv = si < s.timeSeries.velocity.length ? s.timeSeries.velocity[si] : undefined;
const wv = wi < w.timeSeries.velocity.length ? w.timeSeries.velocity[wi] : undefined;
const shl = si < s.timeSeries.headloss.length ? s.timeSeries.headloss[si] : undefined;
const whl = wi < w.timeSeries.headloss.length ? w.timeSeries.headloss[wi] : undefined;
checkPoint('flow', id, t, sf, wf, maxDiffs, maxRelativeDiffs, maxSignificantRelativeDiffs, nearZeroCounts, toleranceViolations, tolerances, missingIds);
checkPoint('velocity', id, t, sv, wv, maxDiffs, maxRelativeDiffs, maxSignificantRelativeDiffs, nearZeroCounts, toleranceViolations, tolerances, missingIds);
checkPoint('headloss', id, t, shl, whl, maxDiffs, maxRelativeDiffs, maxSignificantRelativeDiffs, nearZeroCounts, toleranceViolations, tolerances, missingIds);
if (hasQualitySimulation) {
const slq = s.timeSeries.quality ? (si < s.timeSeries.quality.length ? s.timeSeries.quality[si] : undefined) : s.quality;
const wlq = w.timeSeries.quality ? (wi < w.timeSeries.quality.length ? w.timeSeries.quality[wi] : undefined) : w.quality;
checkPoint('quality', id, t, slq, wlq, maxDiffs, maxRelativeDiffs, maxSignificantRelativeDiffs, nearZeroCounts, toleranceViolations, tolerances, missingIds);
}
}
} else {
checkPoint('flow', id, server.timeSeconds, s.flow, w.flow, maxDiffs, maxRelativeDiffs, maxSignificantRelativeDiffs, nearZeroCounts, toleranceViolations, tolerances, missingIds);
checkPoint('velocity', id, server.timeSeconds, s.velocity, w.velocity, maxDiffs, maxRelativeDiffs, maxSignificantRelativeDiffs, nearZeroCounts, toleranceViolations, tolerances, missingIds);
checkPoint('headloss', id, server.timeSeconds, s.headloss, w.headloss, maxDiffs, maxRelativeDiffs, maxSignificantRelativeDiffs, nearZeroCounts, toleranceViolations, tolerances, missingIds);
if (hasQualitySimulation) {
checkPoint('quality', id, server.timeSeconds, s.quality, w.quality, maxDiffs, maxRelativeDiffs, maxSignificantRelativeDiffs, nearZeroCounts, toleranceViolations, tolerances, missingIds);
}
}
}
for (const id in wasm.links) {
if (!server.links[id] && !missingIds.includes(`Link:${id}`)) missingIds.push(`Link:${id}`);
}
// Missing Timestamps check
for (const t of wasmSteps) {
if (!serverTimeSet.has(t)) missingTimestamps.add(t);
}
for (const t of missingTimestamps) {
missingIds.push(`Timestamp:${t}s`);
}
const passed = missingIds.length === 0 && toleranceViolations.length === 0;
return {
passed,
maxDiffs,
maxRelativeDiffs,
maxSignificantRelativeDiffs,
nearZeroCounts,
toleranceViolations,
missingIds,
};
}