import type { SimulationResult, ComparisonResult, ComparisonVariable, ToleranceConfig, ToleranceViolation, } from './types'; export const DEFAULT_TOLERANCES: Record = { 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 = { 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, flowUnit: string, maxAbsFlow: number ): Record { 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 | undefined)?.[kind]; const values = series ?? [(item as unknown as Record)[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, maxRelativeDiffs: Record, maxSignificantRelativeDiffs: Record, nearZeroCounts: Record, toleranceViolations: ToleranceViolation[], tolerances: Record, 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> ): ComparisonResult { const unitAware = flowScaledTolerances( DEFAULT_TOLERANCES, server.units?.flow || wasm.units?.flow || 'LPS', Math.max(maxAbsSeriesValue(server, 'flow'), maxAbsSeriesValue(server, 'demand')) ); const tolerances: Record = { ...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; const maxRelativeDiffs = {} as Record; const maxSignificantRelativeDiffs = {} as Record; const nearZeroCounts = {} as Record; 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(); wasmSteps.forEach((t, idx) => wasmIndexByTime.set(t, idx)); const serverTimeSet = new Set(serverSteps); const missingTimestamps = new Set(); // 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, }; }