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:
znetsixe
2026-05-11 16:19:55 +02:00
40 changed files with 3035 additions and 555 deletions

View File

@@ -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 };