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

@@ -8,13 +8,13 @@ const strategies = {
[manual.name]: manual,
};
function dispatch(mode, ctx, controlState) {
function dispatch(mode, ctx, controlState, direction) {
const s = strategies[mode];
if (!s) {
ctx.logger?.warn?.(`Unsupported control mode: ${mode}`);
return Promise.resolve();
}
return s.run(ctx, controlState);
return s.run(ctx, controlState, direction);
}
module.exports = { strategies, dispatch, manual };

View File

@@ -1,13 +1,46 @@
const { interpolation } = require('generalFunctions');
// Level-based control strategy.
//
// Ported from basin-docs `_controlLevelBased` into the refactored
// strategy module. Concerns kept here:
// 1. minLevel hard-stop (unconditional MGC shutdown).
// 2. stopLevel Schmitt-trigger hysteresis — pumps stay engaged
// through the dead band [stopLevel, startLevel] emitting a small
// keep-alive demand so MGC keeps a single pump draining the basin.
// 3. Up-curve mapping — level mapped to demand 0..100 % across
// [inflowLevel, maxLevel] using linear or log shape.
// 4. Shifted-ramp hysteresis — when the up-curve crosses
// shiftArmPercent the strategy ARMS; on the next filling→draining
// flip it captures the up-curve value as `hold`; while draining
// the output stays at `hold` until level falls to shiftLevel, then
// ramps `hold → 0 %` over [shiftLevel, startLevel]. Disarms when
// level reaches startLevel.
//
// Hysteresis flags live on the host (specificClass instance) — the
// strategy reads/writes via ctx.host so the same flags survive across
// ticks regardless of how often the context view is rebuilt.
const _interp = new interpolation();
// Apply the configured curve shape to a normalized x in [0, 1].
// Linear by default; log when curveType is 'log'.
function _curveShape(x, levelbased) {
const { curveType = 'linear', logCurveFactor = 9 } = levelbased || {};
const clamped = Math.max(0, Math.min(1, x));
if (curveType === 'log') {
const factor = Number.isFinite(Number(logCurveFactor)) && Number(logCurveFactor) > 0
? Number(logCurveFactor) : 9;
return Math.log1p(factor * clamped) / Math.log1p(factor);
}
return clamped;
}
// Maps [startLevel..maxLevel] → [0..100]. Outside the range,
// interpolate_lin_single_point clamps to o_min / o_max.
function _scaleLevelToFlowPercent(level, levelbased, logger) {
const { startLevel, maxLevel } = levelbased;
logger?.debug?.(`Scaling startLevel=${startLevel} maxLevel=${maxLevel}`);
return _interp.interpolate_lin_single_point(level, startLevel, maxLevel, 0, 100);
// Map level to demand % across [rampFoot, rampTop]. Returns 0 below the
// foot, 100 above the top. Curve type controlled by levelbased.curveType.
function _scaleLevelToFlowPercent(level, rampFoot, rampTop, levelbased) {
if (!Number.isFinite(level) || !Number.isFinite(rampFoot) || !Number.isFinite(rampTop)) return 0;
if (rampTop <= rampFoot) return level >= rampTop ? 100 : 0;
if (level <= rampFoot) return 0;
if (level >= rampTop) return 100;
const x = (level - rampFoot) / (rampTop - rampFoot);
return 100 * _curveShape(x, levelbased);
}
async function _applyMachineGroupLevelControl(machineGroups, percentControl, logger) {
@@ -48,9 +81,10 @@ function _pickVariant(measurements, type, variants, position, unit) {
return null;
}
async function run(ctx, controlState) {
const { measurements, config, logger, machineGroups, levelVariants } = ctx;
const { startLevel, minLevel } = config.control.levelbased;
async function run(ctx, controlState, direction) {
const { measurements, config, logger, machineGroups, basin, levelVariants, host } = ctx;
const cfg = config.control.levelbased || {};
const { startLevel, minLevel, maxLevel } = cfg;
const levelUnit = measurements.getUnit('level');
const variants = levelVariants || ['measured', 'predicted'];
@@ -60,24 +94,123 @@ async function run(ctx, controlState) {
return;
}
// Three-zone level control:
// level < minLevel → STOP (unconditional MGC shutdown)
// minLevel ≤ level < startLevel → DEAD ZONE (no-op)
// level ≥ startLevel → RUN (linear ramp → MGC)
// 1. minLevel hard-stop — unconditional MGC shutdown.
if (level < minLevel) {
controlState.percControl = 0;
if (host) {
host._shiftHoldValue = null;
host._shiftArmed = false;
host._stopHystRunning = false;
host._lastDirection = direction;
}
Object.values(machineGroups || {}).forEach((group) => group.turnOffAllMachines());
return;
}
if (level < startLevel) {
// 2. stopLevel hysteresis (Schmitt trigger).
// Requires an explicit positive stopLevel — configManager merges null
// defaults to 0 otherwise, which would activate the hysteresis on every
// config that omitted it.
const stopLvl = Number(cfg.stopLevel);
const stopThresholdActive = cfg.stopLevel != null && Number.isFinite(stopLvl)
&& stopLvl > 0 && stopLvl < maxLevel;
if (stopThresholdActive && level <= stopLvl) {
controlState.percControl = 0;
if (host) {
host._stopHystRunning = false;
host._lastDirection = direction;
}
Object.values(machineGroups || {}).forEach((group) => group.turnOffAllMachines());
return;
}
if (host) {
if (stopThresholdActive) {
if (!host._stopHystRunning && level >= startLevel) host._stopHystRunning = true;
} else {
host._stopHystRunning = level >= startLevel;
}
}
// 3. Up-curve mapping. Foot stays at inflowLevel (the basin's
// gravity-feed point): demand is 0 % in [startLevel, inflowLevel]
// (the hold zone) and scales 0..100 % across [inflowLevel, maxLevel].
const rampFoot = basin?.inflowLevel ?? cfg.inflowLevel ?? startLevel;
const upPct = _scaleLevelToFlowPercent(level, rampFoot, maxLevel, cfg);
// 4. Shifted-ramp arming.
if (host) {
if (cfg.enableShiftedRamp) {
const armPct = Number.isFinite(cfg.shiftArmPercent) ? cfg.shiftArmPercent : 95;
if (!host._shiftArmed && upPct >= armPct) {
host._shiftArmed = true;
logger?.debug?.(`Shift armed: upPct=${upPct} >= ${armPct}`);
}
} else {
host._shiftArmed = false;
}
if (level <= startLevel) {
host._shiftArmed = false;
host._shiftHoldValue = null;
}
// Capture hold on filling→draining transition while armed.
if (cfg.enableShiftedRamp && host._shiftArmed) {
if (host._lastDirection !== 'draining' && direction === 'draining') {
host._shiftHoldValue = upPct;
logger?.debug?.(`Shift hold captured: ${upPct} % at level=${level}`);
} else if (direction === 'filling') {
// Returning to filling clears any captured hold; the next drain
// transition will recapture from the up curve.
host._shiftHoldValue = null;
}
}
if (direction === 'filling' || direction === 'draining') {
host._lastDirection = direction;
}
}
// Compute output.
const shiftArmed = !!host?._shiftArmed;
const shiftHold = host?._shiftHoldValue;
const inDrainingHold = cfg.enableShiftedRamp && shiftArmed
&& direction === 'draining' && shiftHold != null;
let percControl;
if (!inDrainingHold) {
if (level < rampFoot) {
// While engaged via stopLevel hysteresis AND inside the dead band
// [stopLevel, startLevel], emit a small keep-alive so MGC keeps a
// single pump running.
if (stopThresholdActive && host?._stopHystRunning && level < startLevel) {
const keepAlive = Number.isFinite(Number(cfg.deadZoneKeepAlivePercent))
? Number(cfg.deadZoneKeepAlivePercent) : 1;
percControl = Math.max(0, keepAlive);
} else {
percControl = 0;
}
} else {
percControl = Math.max(0, upPct);
}
} else {
const hold = shiftHold;
const shift = cfg.shiftLevel;
if (!Number.isFinite(shift) || shift <= startLevel) {
// Bad config — fall back to up curve.
percControl = Math.max(0, upPct);
} else if (level >= shift) {
percControl = hold;
} else if (level > startLevel) {
// Ramp [shift, hold] → [start, 0] using the same curve shape.
const x = (level - startLevel) / (shift - startLevel);
percControl = Math.max(0, hold * _curveShape(x, cfg));
} else {
percControl = 0;
}
}
const rawPercControl = _scaleLevelToFlowPercent(level, config.control.levelbased, logger);
const percControl = Math.max(0, rawPercControl);
controlState.percControl = percControl;
logger?.debug?.(`Level-based control: level=${level} percControl=${percControl}`);
logger?.debug?.(
`Level-based: level=${level} dir=${direction} armed=${shiftArmed} hold=${shiftHold} pct=${percControl}`
);
await _applyMachineGroupLevelControl(machineGroups, percControl, logger);
}
@@ -85,8 +218,8 @@ async function run(ctx, controlState) {
module.exports = {
name: 'levelbased',
run,
// Exposed for future reuse / tests; not part of the strategy contract.
_scaleLevelToFlowPercent,
_curveShape,
_applyMachineGroupLevelControl,
_applyMachineLevelControl,
};