Decision 2026-05-11: 'highVolumeSafetyLevel' is canonical. The legacy 'overfillLevel' name is gone from computeSafetyPoints + the validator issue tuple. 'overfillVol' parallel alias kept (out of scope for this task; flagged for follow-up). 130/130 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
89 lines
4.0 KiB
JavaScript
89 lines
4.0 KiB
JavaScript
// 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 };
|