// Threshold-ordering validator for the pumpingStation basin + control + // safety config. Pure: returns the issues array, never logs or throws. // The caller decides what to do (warn, surface to status badge, fail tests). // // Invariants enforced (level-space, bottom → top): // 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight // dryRunLevel ≤ minLevel ≤ startLevel ≤ inflowLevel < maxLevel ≤ highVolumeSafetyLevel < overflowLevel // // dryRunLevel and highVolumeSafetyLevel are DERIVED from safety percentages. // The validator recomputes them so a config that places minLevel below the // effective dry-run trigger (a no-op control band) is caught here. /** * Derived safety thresholds + reference levels. Exposed so the editor / * status badge / FlowAggregator can read the same values without * recomputing them. */ function computeSafetyPoints(basin, safety = {}) { const dryRunPct = Number(safety.dryRunThresholdPercent) || 0; const rawHighPct = safety.highVolumeSafetyThresholdPercent ?? safety.overfillThresholdPercent; // When neither high-volume nor overfill pct is supplied, use 100 % so // the validator's `maxLevel <= highVolumeSafetyLevel` check is a no-op // (the basin can't physically exceed overflow anyway). Tests pin this. const highPct = Number(rawHighPct); const effectiveHighPct = Number.isFinite(highPct) ? highPct : 100; const minVol = Number(basin?.minVol) || 0; const maxVolAtOverflow = Number(basin?.maxVolAtOverflow) || 0; const dryRunSafetyVol = minVol * (1 + dryRunPct / 100); const highVolumeSafetyVol = maxVolAtOverflow * (effectiveHighPct / 100); const refLowLevel = basin?.minHeightBasedOn === 'inlet' ? Number(basin?.inflowLevel) : Number(basin?.outflowLevel); const dryRunLevel = Number.isFinite(refLowLevel) ? refLowLevel * (1 + dryRunPct / 100) : Number.NaN; const overflowLevel = Number(basin?.overflowLevel) || 0; const highVolumeSafetyLevel = overflowLevel * (effectiveHighPct / 100); return { dryRunSafetyVol, dryRunLevel, highVolumeSafetyVol, highVolumeSafetyLevel, // Back-compat alias — pre-basin-docs name. overfillVol: highVolumeSafetyVol, }; } /** * @param {object} basin - BasinGeometry instance OR plain {inflowLevel, outflowLevel, overflowLevel, heightBasin, minHeightBasedOn} * @param {object} levelbased - config.control.levelbased ({ minLevel, startLevel, maxLevel }) * @param {object} safety - config.safety ({ dryRunThresholdPercent, highVolumeSafetyThresholdPercent | overfillThresholdPercent }) * @returns {Array<{aName, a, op, bName, b, msg}>} */ function validateThresholdOrdering(basin, levelbased, safety) { const lvl = levelbased || {}; const points = computeSafetyPoints(basin, safety); const { dryRunLevel, highVolumeSafetyLevel } = points; const checks = [ ['outflowLevel', basin.outflowLevel, '<', 'inflowLevel', basin.inflowLevel], ['inflowLevel', basin.inflowLevel, '<', 'overflowLevel', basin.overflowLevel], ['overflowLevel', basin.overflowLevel, '<=', 'basinHeight', basin.heightBasin], ['dryRunLevel', dryRunLevel, '<=', 'minLevel', lvl.minLevel], ['minLevel', lvl.minLevel, '<=', 'startLevel', lvl.startLevel], ['startLevel', lvl.startLevel, '<=', 'inflowLevel', basin.inflowLevel], ['startLevel', lvl.startLevel, '<', 'maxLevel', lvl.maxLevel], ['maxLevel', lvl.maxLevel, '<=', 'highVolumeSafetyLevel', highVolumeSafetyLevel], ]; const issues = []; for (const [aName, a, op, bName, b] of checks) { if (!Number.isFinite(a) || !Number.isFinite(b)) continue; const ok = op === '<' ? a < b : a <= b; if (!ok) { issues.push({ aName, a, op, bName, b, msg: `Threshold invariant violated: ${aName} (${a}) must be ${op} ${bName} (${b})`, }); } } return issues; } module.exports = { validateThresholdOrdering, computeSafetyPoints };