Merge origin/basin-docs-update: per-mode SVG + stopLevel hysteresis + shifted ramp
Reconciles the 7-commit basin-docs-update feature branch (which never
landed on main before the platform refactor) with the post-refactor
architecture on development. Each basin-docs feature ported into the
relevant concern module:
control/levelBased.js
- stopLevel Schmitt-trigger + dead-band keep-alive
- Shifted ramp (arm % → hold @ 100% → ramp down to shiftLevel)
- Linear vs log up-curve (curveType + logCurveFactor)
measurement/flowAggregator.js
- Predicted-volume overflow clamp + spill flow stream
- Cumulative overflowVolume + underflowVolume
- Hard floor at 0 + dry-run-on-transition handling
basin/thresholdValidator.js
- computeSafetyPoints exposes dryRunLevel + highVolumeSafetyLevel
- startLevel ≤ inflowLevel invariant added
measurement/calibration.js + commands/
- Manual q_out path (set.outflow / q_out alias)
safety/safetyController.js
- Accepts both legacy + new high-volume threshold names
UI:
pumpingStation.html — restored the side-panel + SVG mode-preview block,
added defaults for stopLevel/shiftLevel/shiftArmPercent/levelCurveType/
logCurveFactor/enableShiftedRamp.
src/editor/* — basin-docs' 7-file modular editor (replaces single
src/editor.js, which is deleted).
pumpingStation.js — admin endpoint serves editor/:file.
Tests: 130/130 pass (125 basic + 5 integration). Two basin-docs test
files added: nodeClass-config.test.js, basic-dashboard-flow.test.js,
shifted-ramp-end-to-end.test.js. One pre-refactor control-levelBased
test adapted to match basin-docs canonical "no-shutdown in dead zone"
behaviour.
Human-review items (see commit context):
- rampFoot = inflowLevel (matches basin-docs test); basin-docs source
used rampFoot = startLevel. Domain owner: confirm intent.
- Naming kept dual (overfillLevel + highVolumeSafetyLevel).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,34 +4,71 @@
|
||||
//
|
||||
// Invariants enforced (level-space, bottom → top):
|
||||
// 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight
|
||||
// dryRunLevel ≤ minLevel ≤ startLevel < maxLevel ≤ overfillLevel
|
||||
// dryRunLevel ≤ minLevel ≤ startLevel ≤ inflowLevel < maxLevel ≤ highVolumeSafetyLevel < overflowLevel
|
||||
//
|
||||
// dryRunLevel and overfillLevel are DERIVED from safety percentages — the
|
||||
// validator recomputes them so a config that places minLevel below the
|
||||
// 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 <= overfillLevel` 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.
|
||||
overfillLevel: highVolumeSafetyLevel,
|
||||
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, overfillThresholdPercent })
|
||||
* @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 sfy = safety || {};
|
||||
|
||||
const dryRunPct = Number(sfy.dryRunThresholdPercent) || 0;
|
||||
const overfillPct = Number(sfy.overfillThresholdPercent) || 100;
|
||||
const refLowLevel = basin.minHeightBasedOn === 'inlet' ? basin.inflowLevel : basin.outflowLevel;
|
||||
const dryRunLevel = refLowLevel * (1 + dryRunPct / 100);
|
||||
const overfillLevel = basin.overflowLevel * (overfillPct / 100);
|
||||
const points = computeSafetyPoints(basin, safety);
|
||||
const { dryRunLevel, overfillLevel } = points;
|
||||
|
||||
// basin-docs added `startLevel <= inflowLevel` and `inflowLevel <
|
||||
// maxLevel`; HEAD had only the `startLevel < maxLevel` and
|
||||
// `maxLevel <= overfillLevel` checks. We keep the `overfillLevel`
|
||||
// name (rather than basin-docs's `highVolumeSafetyLevel`) for
|
||||
// back-compat with consumers reading issue.bName.
|
||||
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, '<=', 'overfillLevel', overfillLevel],
|
||||
];
|
||||
@@ -54,4 +91,4 @@ function validateThresholdOrdering(basin, levelbased, safety) {
|
||||
return issues;
|
||||
}
|
||||
|
||||
module.exports = { validateThresholdOrdering };
|
||||
module.exports = { validateThresholdOrdering, computeSafetyPoints };
|
||||
|
||||
Reference in New Issue
Block a user