Hold-then-ramp shift semantics + shiftArmPercent + e2e tests
Runtime (specificClass.js):
- Replace the "shift left both ramp ends" geometry with a true
hold-then-ramp hysteresis driven by output %, not level:
• Up-curve % crosses shiftArmPercent on the way up → ARM.
• Filling→draining transition while armed → capture the up-curve %
at that moment as _shiftHoldValue.
• Draining + level ≥ shiftLevel → output stays at _shiftHoldValue
(horizontal hold, matching the dashed segment in the SVG).
• Draining + level in [start, shift] → output ramps holdValue → 0 %
along the same curve shape (linear or log) as the up curve.
• Draining + level < startLevel → 0 % AND disarm.
• Returning to filling clears holdValue, stays armed; next drain
transition captures a fresh hold so bouncing fills rearm cleanly.
• Disarm only when level ≤ startLevel.
- New _curveShape(x) helper for shared linear/log shaping.
- Removed legacy _levelBasedRampStart / _levelBasedRampTop /
_updateShiftArmed in favour of the inline state machine.
Adapter (nodeClass.js):
- Pipe shiftArmPercent through to control.levelbased.
Editor (pumpingStation.html + src/editor/):
- Add shiftArmPercent input row (% with unit) to the mode side panel
(only shown when shifted ramp is enabled). Default 95 %.
- Add the horizontal arming-% line + label inside the mode SVG —
this is the "% Threshold triggering shifted ramp down" line from
the original drawing that had been missing.
- Redraw the shifted-down curve to match the SVG geometry literally:
100 % flat from maxLevel → shiftLevel, then ramp shiftLevel →
startLevel down to 0 %, OFF below startLevel. Preview shows the
worst-case envelope (hold = 100 %); runtime hold is captured live.
- Validation extended: 0 < shiftArmPercent ≤ 100; ordering rules
preserved (start < shift ≤ max etc.).
- Auto-default shiftArmPercent to 95 when shift is enabled and the
current value is missing or out of range.
Dashboard example (examples/basic-dashboard.flow.json):
- Parser now reads `level.predicted.atequipment.default` etc. The
MeasurementContainer flatten format includes the implicit 'default'
childId; consumers must include it. Comment in the parser points
at the documenting source in generalFunctions.
Tests:
- test/basic: replace old level-armed-shift tests with two new ones
that exercise the hold-then-ramp arming, capture, hold, ramp-down,
disarm, and the bounce case (filling→draining→filling→draining
captures a fresh hold each time).
- test/integration/shifted-ramp-end-to-end.test.js: new file. Drives
Q_IN/Q_OUT through the full runtime tick with a controllable clock,
asserting the same hysteresis path the dashboard exercises.
- test/integration/basic-dashboard-flow.test.js: fixture keys updated
to the .default-suffixed form so they match the real flatten output.
56/56 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,8 @@
|
||||
const shiftEnabled = !!document.getElementById('node-input-enableShiftedRamp')?.checked;
|
||||
const shiftRaw = fNum('shiftLevel');
|
||||
const shift = Number.isFinite(shiftRaw) && shiftRaw > 0 ? Math.min(shiftRaw, max ?? shiftRaw) : null;
|
||||
const armRaw = fNum('shiftArmPercent');
|
||||
const armPct = Number.isFinite(armRaw) ? Math.max(0, Math.min(100, armRaw)) : 95;
|
||||
const curveType = document.getElementById('node-input-levelCurveType')?.value || 'linear';
|
||||
const factorRaw = parseFloat(document.getElementById('node-input-logCurveFactor')?.value);
|
||||
const factor = Number.isFinite(factorRaw) && factorRaw > 0 ? factorRaw : 9;
|
||||
@@ -83,14 +85,40 @@
|
||||
return pts.join(' ');
|
||||
};
|
||||
|
||||
// Up curve: same as before.
|
||||
const up = document.getElementById('ps-mode-curve-up');
|
||||
const down = document.getElementById('ps-mode-curve-down');
|
||||
const downLabel = document.getElementById('ps-mode-curve-down-label');
|
||||
if (up) up.setAttribute('points', buildPath(start, inlet, max));
|
||||
|
||||
// Shifted-DOWN curve (only when shift enabled): represents the
|
||||
// worst-case held-then-ramp path drawn for hold=100 % (the SVG
|
||||
// ideal). Geometry: 100 % flat from levelMax back to shiftLevel,
|
||||
// then linear/log ramp from (shiftLevel, 100 %) down to
|
||||
// (startLevel, 0 %), then OFF below startLevel.
|
||||
// Real runtime hold value depends on where direction flips, so the
|
||||
// preview shows the maximum extent.
|
||||
const buildShiftedDown = () => {
|
||||
if (![start, shift].every(Number.isFinite) || shift <= start) return '';
|
||||
const pts = [];
|
||||
// OFF baseline far-left to startLevel
|
||||
pts.push(`${xFor(levelMin)},${yForPct(yOffPct)}`);
|
||||
pts.push(`${xFor(start)},${yForPct(yOffPct)}`);
|
||||
// Jump 0 % at startLevel
|
||||
pts.push(`${xFor(start)},${yForPct(0)}`);
|
||||
// Ramp start→shift = 0..100 % (peak hold = 100 % for this preview)
|
||||
for (let i = 0; i <= 24; i++) {
|
||||
const t = i / 24;
|
||||
const lvl = start + t * (shift - start);
|
||||
pts.push(`${xFor(lvl)},${yForPct(scale(t) * 100)}`);
|
||||
}
|
||||
// Held at 100 % from shift → far-right
|
||||
pts.push(`${xFor(levelMax)},${yForPct(100)}`);
|
||||
return pts.join(' ');
|
||||
};
|
||||
if (down) {
|
||||
if (shiftEnabled) {
|
||||
const shiftedTop = Number.isFinite(shift) && shift > start ? shift : max;
|
||||
down.setAttribute('points', buildPath(start, start, shiftedTop));
|
||||
down.setAttribute('points', buildShiftedDown());
|
||||
down.style.display = '';
|
||||
if (downLabel) downLabel.style.display = '';
|
||||
} else {
|
||||
@@ -100,6 +128,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Horizontal arming-% line — only meaningful when shift enabled.
|
||||
const armLine = document.getElementById('ps-mode-line-armPercent');
|
||||
const armLabel = document.getElementById('ps-mode-label-armPercent');
|
||||
if (armLine && armLabel) {
|
||||
if (shiftEnabled) {
|
||||
const yArm = yForPct(armPct);
|
||||
armLine.setAttribute('y1', yArm);
|
||||
armLine.setAttribute('y2', yArm);
|
||||
armLabel.setAttribute('y', yArm - 2);
|
||||
armLabel.textContent = `arm ${Math.round(armPct)}%`;
|
||||
armLine.style.display = '';
|
||||
armLabel.style.display = '';
|
||||
} else {
|
||||
armLine.style.display = 'none';
|
||||
armLabel.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Vertical level markers + axis labels.
|
||||
[
|
||||
['dryRunLevel', dryRun],
|
||||
@@ -167,6 +213,8 @@
|
||||
if (curveLabel) curveLabel.textContent = curveType === 'log' ? 'log curve: fast early response' : 'linear curve';
|
||||
const shiftRow = document.getElementById('ps-shiftLevel-row');
|
||||
if (shiftRow) shiftRow.style.display = shiftEnabled ? '' : 'none';
|
||||
const armRow = document.getElementById('ps-shiftArmPercent-row');
|
||||
if (armRow) armRow.style.display = shiftEnabled ? '' : 'none';
|
||||
const logRow = document.getElementById('ps-log-factor-row');
|
||||
if (logRow) logRow.style.display = curveType === 'log' ? '' : 'none';
|
||||
|
||||
@@ -179,6 +227,15 @@
|
||||
shiftInput.value = (max * 0.9).toFixed(2);
|
||||
}
|
||||
}
|
||||
// Auto-default shiftArmPercent to 95 % when shift is enabled and the
|
||||
// current value is missing / out of [0, 100].
|
||||
const armInput = document.getElementById('node-input-shiftArmPercent');
|
||||
if (shiftEnabled && armInput) {
|
||||
const cur = parseFloat(armInput.value);
|
||||
if (!Number.isFinite(cur) || cur < 0 || cur > 100) {
|
||||
armInput.value = 95;
|
||||
}
|
||||
}
|
||||
|
||||
// Validation: ordering constraints.
|
||||
const issues = [];
|
||||
@@ -200,6 +257,9 @@
|
||||
} else {
|
||||
issues.push('shiftLevel is required when shifted ramp is enabled');
|
||||
}
|
||||
const armVal = Number(armInput?.value);
|
||||
if (!Number.isFinite(armVal) || armVal <= 0 || armVal > 100)
|
||||
issues.push('shiftArmPercent must be in (0, 100]');
|
||||
}
|
||||
const warnBox = document.getElementById('ps-mode-validation');
|
||||
if (warnBox) {
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
ns.setNumberField('node-input-maxLevel', node.maxLevel);
|
||||
ns.setNumberField('node-input-logCurveFactor', node.logCurveFactor);
|
||||
ns.setNumberField('node-input-shiftLevel', node.shiftLevel);
|
||||
ns.setNumberField('node-input-shiftArmPercent', Number.isFinite(node.shiftArmPercent) ? node.shiftArmPercent : 95);
|
||||
ns.setNumberField('node-input-flowSetpoint', node.flowSetpoint);
|
||||
ns.setNumberField('node-input-flowDeadband', node.flowDeadband);
|
||||
|
||||
@@ -87,7 +88,8 @@
|
||||
// so the mode preview must redraw when either of those change.
|
||||
['startLevel', 'maxLevel', 'inflowLevel', 'outflowLevel', 'overflowLevel',
|
||||
'dryRunThresholdPercent',
|
||||
'levelCurveType', 'logCurveFactor', 'enableShiftedRamp', 'shiftLevel'],
|
||||
'levelCurveType', 'logCurveFactor', 'enableShiftedRamp', 'shiftLevel',
|
||||
'shiftArmPercent'],
|
||||
ns.modePreview.redraw
|
||||
);
|
||||
|
||||
|
||||
@@ -55,6 +55,8 @@
|
||||
node.enableShiftedRamp = !!document.getElementById('node-input-enableShiftedRamp')?.checked;
|
||||
const shiftLevelVal = parseNum('node-input-shiftLevel');
|
||||
node.shiftLevel = Number.isFinite(shiftLevelVal) ? shiftLevelVal : 0;
|
||||
const armPctVal = parseNum('node-input-shiftArmPercent');
|
||||
node.shiftArmPercent = Number.isFinite(armPctVal) ? armPctVal : 95;
|
||||
const flowSetpoint = parseNum('node-input-flowSetpoint');
|
||||
const flowDeadband = parseNum('node-input-flowDeadband');
|
||||
if (Number.isFinite(flowSetpoint)) node.flowSetpoint = flowSetpoint;
|
||||
|
||||
@@ -70,7 +70,8 @@ class nodeClass {
|
||||
curveType: uiConfig.levelCurveType || uiConfig.curveType,
|
||||
logCurveFactor: uiConfig.logCurveFactor,
|
||||
enableShiftedRamp: uiConfig.enableShiftedRamp,
|
||||
shiftLevel: uiConfig.shiftLevel
|
||||
shiftLevel: uiConfig.shiftLevel,
|
||||
shiftArmPercent: uiConfig.shiftArmPercent
|
||||
}
|
||||
},
|
||||
safety:{
|
||||
|
||||
@@ -105,13 +105,19 @@ class PumpingStation {
|
||||
// levelbased mode. Exposed in getOutput() for dashboards.
|
||||
this.percControl = 0;
|
||||
|
||||
// --- Level-armed hysteresis state ---
|
||||
// _shiftArmed flips true when level rises past shiftLevel (with
|
||||
// enableShiftedRamp). While armed, the demand ramp's lower foot
|
||||
// is startLevel instead of inflowLevel — so on the way down the
|
||||
// pumps stay aggressive until level falls below startLevel, at
|
||||
// which point the arm clears.
|
||||
// --- Level-armed hysteresis state (see _controlLevelBased) ---
|
||||
// _shiftArmed: true once up-curve output % crosses shiftArmPercent on
|
||||
// the way up. Cleared when level drops to startLevel.
|
||||
// _shiftHoldValue: captured on every filling→draining transition while
|
||||
// armed. The output stays at this value while level drops from the
|
||||
// flip point to shiftLevel; below shiftLevel it ramps to 0 % at
|
||||
// startLevel (linear or log shape).
|
||||
// _lastDirection: tracks the previous tick's direction so we can
|
||||
// detect filling→draining transitions. We don't update it on
|
||||
// 'steady' ticks so transitions through the dead-band are preserved.
|
||||
this._shiftArmed = false;
|
||||
this._shiftHoldValue = null;
|
||||
this._lastDirection = null;
|
||||
|
||||
// --- Flow dead-band ---
|
||||
// flowThreshold (m3/s) prevents control actions on noise.
|
||||
@@ -339,8 +345,9 @@ class PumpingStation {
|
||||
}
|
||||
}
|
||||
|
||||
async _controlLevelBased(_direction) {
|
||||
const { startLevel, minLevel } = this.config.control.levelbased;
|
||||
async _controlLevelBased(direction) {
|
||||
const cfg = this.config.control.levelbased;
|
||||
const { startLevel, minLevel } = cfg;
|
||||
const levelUnit = this.measurements.getUnit('level');
|
||||
|
||||
const level = this._pickVariant('level', this.levelVariants, 'atequipment', levelUnit);
|
||||
@@ -349,49 +356,121 @@ class PumpingStation {
|
||||
return;
|
||||
}
|
||||
|
||||
// Level-based pump control via MGC (see wiki/modes/levelbased.md).
|
||||
// Level-based pump control via MGC. See wiki/modes/levelbased.md.
|
||||
//
|
||||
// Always:
|
||||
// level < minLevel → STOP (unconditional MGC shutdown)
|
||||
// level < startLevel → 0 % (pumps held off)
|
||||
// level in [startLevel..rampStart] → 0 % (HOLD zone)
|
||||
// level in [rampStart..maxLevel] → 0..100 % (linear or log curve)
|
||||
// level > maxLevel → ≥100 % (MGC clamps internally)
|
||||
// level < inflowLevel → 0 % (HOLD zone, pumps idle)
|
||||
// level in [inflow..max] → up curve 0..100 % (linear or log)
|
||||
// level > maxLevel → 100 % (MGC clamps internally)
|
||||
//
|
||||
// With enableShiftedRamp:
|
||||
// rampStart = inflowLevel by default
|
||||
// when level rises past shiftLevel → arm → rampStart = startLevel
|
||||
// when level drops below startLevel → disarm → rampStart = inflowLevel
|
||||
// Without enableShiftedRamp: rampStart = inflowLevel always.
|
||||
// With enableShiftedRamp (hysteresis):
|
||||
// When up-curve % rises past shiftArmPercent → ARMED.
|
||||
// On the next filling→draining transition while armed → capture
|
||||
// hold = current up-curve %.
|
||||
// While armed AND draining:
|
||||
// level >= shiftLevel → output = hold (held)
|
||||
// level in [start..shift] → output ramps hold→0 % over the range
|
||||
// level < startLevel → output = 0 %
|
||||
// While armed AND filling/steady → output = up curve (resets hold).
|
||||
// Disarms only when level <= startLevel.
|
||||
|
||||
if (level < minLevel) {
|
||||
this.percControl = 0;
|
||||
this._shiftHoldValue = null;
|
||||
this._shiftArmed = false;
|
||||
this._lastDirection = direction;
|
||||
Object.values(this.machineGroups).forEach((group) => group.turnOffAllMachines());
|
||||
return;
|
||||
}
|
||||
|
||||
this._updateShiftArmed(level);
|
||||
const rampStartLevel = this._levelBasedRampStart();
|
||||
const rampTopLevel = this._levelBasedRampTop();
|
||||
// Up-curve value (always defined; foot=inflowLevel, top=maxLevel).
|
||||
const upPct = this._scaleLevelToFlowPercent(level, this.basin?.inflowLevel ?? startLevel, cfg.maxLevel);
|
||||
|
||||
// HOLD/MINIMUM DEMAND — below the active ramp start, command 0 %
|
||||
// without latching dry-run. Dry-run remains the safety layer's job.
|
||||
if (level < rampStartLevel) {
|
||||
this.percControl = 0;
|
||||
await this._applyMachineGroupLevelControl(0);
|
||||
return;
|
||||
// Update arming flag.
|
||||
if (cfg.enableShiftedRamp) {
|
||||
const armPct = Number.isFinite(cfg.shiftArmPercent) ? cfg.shiftArmPercent : 95;
|
||||
if (!this._shiftArmed && upPct >= armPct) {
|
||||
this._shiftArmed = true;
|
||||
this.logger.debug(`Shift armed: upPct=${upPct} >= ${armPct}`);
|
||||
}
|
||||
} else {
|
||||
this._shiftArmed = false;
|
||||
}
|
||||
if (level <= startLevel) {
|
||||
this._shiftArmed = false;
|
||||
this._shiftHoldValue = null;
|
||||
}
|
||||
|
||||
// Capture hold on filling→draining transition while armed.
|
||||
if (cfg.enableShiftedRamp && this._shiftArmed) {
|
||||
if (this._lastDirection !== 'draining' && direction === 'draining') {
|
||||
this._shiftHoldValue = upPct;
|
||||
this.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.
|
||||
this._shiftHoldValue = null;
|
||||
}
|
||||
}
|
||||
if (direction === 'filling' || direction === 'draining') {
|
||||
this._lastDirection = direction;
|
||||
}
|
||||
|
||||
// Compute output.
|
||||
let percControl;
|
||||
const inDrainingHold = cfg.enableShiftedRamp && this._shiftArmed
|
||||
&& direction === 'draining' && this._shiftHoldValue != null;
|
||||
|
||||
if (!inDrainingHold) {
|
||||
// Up curve: 0 % below inflow, scaled inflow..max → 0..100, saturates above max.
|
||||
if (level < (this.basin?.inflowLevel ?? startLevel)) {
|
||||
percControl = 0;
|
||||
} else {
|
||||
percControl = Math.max(0, upPct);
|
||||
}
|
||||
} else {
|
||||
const hold = this._shiftHoldValue;
|
||||
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 from (shiftLevel, hold) down to (startLevel, 0).
|
||||
// Use the same curve shape (linear/log) as the up curve, scaled to
|
||||
// peak at hold% at level=shiftLevel.
|
||||
const x = (level - startLevel) / (shift - startLevel);
|
||||
const shaped = this._curveShape(x);
|
||||
percControl = Math.max(0, hold * shaped);
|
||||
} else {
|
||||
percControl = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// RUN — above rampStartLevel, compute demand and forward to MGC.
|
||||
// _scaleLevelToFlowPercent maps [rampStartLevel..rampTopLevel] → [0..100].
|
||||
// Above rampTopLevel demand saturates at 100 %.
|
||||
const rawPercControl = this._scaleLevelToFlowPercent(level, rampStartLevel, rampTopLevel);
|
||||
const percControl = Math.max(0, rawPercControl);
|
||||
this.percControl = percControl;
|
||||
this.logger.debug(`Level-based control: level=${level} armed=${this._shiftArmed} foot=${rampStartLevel} top=${rampTopLevel} percControl=${percControl}`);
|
||||
this.logger.debug(
|
||||
`Level-based: level=${level} dir=${direction} armed=${this._shiftArmed} hold=${this._shiftHoldValue} pct=${percControl}`
|
||||
);
|
||||
|
||||
await this._applyMachineGroupLevelControl(percControl);
|
||||
}
|
||||
|
||||
// Apply the configured curve shape to a normalized x in [0,1].
|
||||
// Returns shaped value in [0,1]. Linear by default; log when curveType
|
||||
// is 'log' (with logCurveFactor).
|
||||
_curveShape(x) {
|
||||
const { curveType = 'linear', logCurveFactor = 9 } = this.config.control.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;
|
||||
}
|
||||
|
||||
_controlFlowBased() {
|
||||
// placeholder for flow-based logic
|
||||
}
|
||||
@@ -528,43 +607,9 @@ class PumpingStation {
|
||||
return null;
|
||||
}
|
||||
|
||||
_levelBasedRampStart() {
|
||||
const { startLevel, enableShiftedRamp } = this.config.control.levelbased;
|
||||
const inflowLevel = this.basin?.inflowLevel;
|
||||
if (enableShiftedRamp && this._shiftArmed) return startLevel;
|
||||
if (Number.isFinite(inflowLevel)) return inflowLevel;
|
||||
return startLevel;
|
||||
}
|
||||
|
||||
_levelBasedRampTop() {
|
||||
// Returns the upper level at which demand saturates at 100 %.
|
||||
// While the shift is armed, top moves left from maxLevel to shiftLevel
|
||||
// so output reaches 100 % earlier and stays at 100 % until level
|
||||
// falls back through shiftLevel on the way down.
|
||||
const { maxLevel, enableShiftedRamp, shiftLevel } = this.config.control.levelbased;
|
||||
if (enableShiftedRamp && this._shiftArmed
|
||||
&& Number.isFinite(shiftLevel) && shiftLevel > 0
|
||||
&& shiftLevel <= maxLevel) {
|
||||
return shiftLevel;
|
||||
}
|
||||
return maxLevel;
|
||||
}
|
||||
|
||||
_updateShiftArmed(level) {
|
||||
const { enableShiftedRamp, shiftLevel, startLevel } = this.config.control.levelbased;
|
||||
if (!enableShiftedRamp) {
|
||||
this._shiftArmed = false;
|
||||
return;
|
||||
}
|
||||
const trigger = Number.isFinite(shiftLevel) && shiftLevel > 0 ? shiftLevel : null;
|
||||
if (!this._shiftArmed && trigger != null && level >= trigger) {
|
||||
this._shiftArmed = true;
|
||||
this.logger.debug(`Shift armed at level=${level} (shiftLevel=${trigger})`);
|
||||
} else if (this._shiftArmed && Number.isFinite(startLevel) && level < startLevel) {
|
||||
this._shiftArmed = false;
|
||||
this.logger.debug(`Shift disarmed at level=${level} (startLevel=${startLevel})`);
|
||||
}
|
||||
}
|
||||
// (legacy _levelBasedRampStart/_levelBasedRampTop/_updateShiftArmed
|
||||
// helpers were removed in favour of the inline state machine in
|
||||
// _controlLevelBased — see that method's doc block.)
|
||||
|
||||
_scaleLevelToFlowPercent(level, rampStartLevel, rampTopLevel) {
|
||||
const { maxLevel, curveType = 'linear', logCurveFactor = 9 } = this.config.control.levelbased;
|
||||
|
||||
Reference in New Issue
Block a user