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:
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user