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:
@@ -303,14 +303,17 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
|
||||
assert.equal(ps.percControl, 0);
|
||||
});
|
||||
|
||||
await t.test('shift enabled: arming when level crosses shiftLevel; foot moves to startLevel', async () => {
|
||||
await t.test('shift enabled: arming on % threshold + hold-then-ramp on draining', async () => {
|
||||
// Geometry: inflow=3, max=4 → up curve goes 0%@3 to 100%@4.
|
||||
// shiftArmPercent=80 ⇒ arms when up curve ≥ 80 % i.e. level ≥ 3.8.
|
||||
// shiftLevel=3.5 ⇒ held output starts ramping down at this level.
|
||||
const ps = new PumpingStation(makeConfig({
|
||||
control: {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased']),
|
||||
levelbased: {
|
||||
minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9,
|
||||
enableShiftedRamp: true, shiftLevel: 3.5,
|
||||
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -319,25 +322,65 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
|
||||
turnOffAllMachines: () => {},
|
||||
handleInput: async () => {},
|
||||
};
|
||||
// Below shiftLevel: not yet armed → foot=inflowLevel, level 2.5 is in hold zone → 0%.
|
||||
ps.calibratePredictedLevel(2.5);
|
||||
await ps._controlLevelBased();
|
||||
// Filling at level=3.5 ⇒ up curve = 50 %, below arm threshold ⇒ not armed.
|
||||
ps.calibratePredictedLevel(3.5);
|
||||
await ps._controlLevelBased('filling');
|
||||
assert.equal(ps._shiftArmed, false);
|
||||
assert.ok(Math.abs(ps.percControl - 50) < 1e-9);
|
||||
// Filling at level=3.85 ⇒ up curve = 85 % ≥ arm threshold ⇒ ARM.
|
||||
ps.calibratePredictedLevel(3.85);
|
||||
await ps._controlLevelBased('filling');
|
||||
assert.equal(ps._shiftArmed, true);
|
||||
assert.ok(Math.abs(ps.percControl - 85) < 1e-9); // still up curve while filling
|
||||
// Direction flips to draining at the same level ⇒ capture hold ≈ 85 %.
|
||||
await ps._controlLevelBased('draining');
|
||||
assert.ok(Math.abs(ps._shiftHoldValue - 85) < 1e-6);
|
||||
// While draining and level ≥ shiftLevel ⇒ output stays at hold (≈85 %).
|
||||
ps.calibratePredictedLevel(3.6);
|
||||
await ps._controlLevelBased('draining');
|
||||
assert.ok(Math.abs(ps.percControl - 85) < 1e-6);
|
||||
// Below shiftLevel: ramp [shift, hold] → [start, 0]. At level=2.75
|
||||
// (midpoint of [2, 3.5]), x=0.5, output ≈ 85 × 0.5 = 42.5 %.
|
||||
ps.calibratePredictedLevel(2.75);
|
||||
await ps._controlLevelBased('draining');
|
||||
assert.ok(Math.abs(ps.percControl - 42.5) < 1e-6);
|
||||
// Below startLevel ⇒ output 0 % AND disarm.
|
||||
ps.calibratePredictedLevel(1.9);
|
||||
await ps._controlLevelBased('draining');
|
||||
assert.equal(ps.percControl, 0);
|
||||
assert.equal(ps._shiftArmed, false);
|
||||
// Cross the shift trigger going up — at level >= shiftLevel, output saturates at 100%.
|
||||
ps.calibratePredictedLevel(3.6);
|
||||
await ps._controlLevelBased();
|
||||
assert.equal(ps._shiftHoldValue, null);
|
||||
});
|
||||
|
||||
await t.test('shift enabled: returning to filling clears hold; new hold captured on next drain', async () => {
|
||||
const ps = new PumpingStation(makeConfig({
|
||||
control: {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased']),
|
||||
levelbased: {
|
||||
minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9,
|
||||
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
|
||||
},
|
||||
},
|
||||
}));
|
||||
ps.machineGroups['mgc1'] = {
|
||||
config: { general: { name: 'mgc1' } },
|
||||
turnOffAllMachines: () => {},
|
||||
handleInput: async () => {},
|
||||
};
|
||||
ps.calibratePredictedLevel(3.85);
|
||||
await ps._controlLevelBased('filling');
|
||||
await ps._controlLevelBased('draining');
|
||||
assert.ok(Math.abs(ps._shiftHoldValue - 85) < 1e-6);
|
||||
// Direction back to filling ⇒ up curve, hold cleared, still armed.
|
||||
ps.calibratePredictedLevel(3.9);
|
||||
await ps._controlLevelBased('filling');
|
||||
assert.equal(ps._shiftHoldValue, null);
|
||||
assert.equal(ps._shiftArmed, true);
|
||||
assert.ok(ps.percControl >= 100 - 1e-9);
|
||||
// Drop to midpoint of shifted ramp [start=2 .. shiftLevel=3.5] → x=0.5 → 50%.
|
||||
ps.calibratePredictedLevel(2.75);
|
||||
await ps._controlLevelBased();
|
||||
assert.equal(ps._shiftArmed, true);
|
||||
assert.ok(Math.abs(ps.percControl - 50) < 1e-9);
|
||||
// Drop below startLevel → disarm.
|
||||
ps.calibratePredictedLevel(1.9);
|
||||
await ps._controlLevelBased();
|
||||
assert.equal(ps._shiftArmed, false);
|
||||
assert.ok(Math.abs(ps.percControl - 90) < 1e-6); // up curve at 3.9 = 90 %
|
||||
// Flip to draining again at higher level ⇒ new hold ≈ 90 %.
|
||||
await ps._controlLevelBased('draining');
|
||||
assert.ok(Math.abs(ps._shiftHoldValue - 90) < 1e-6);
|
||||
});
|
||||
|
||||
await t.test('log curve has fast early response', async () => {
|
||||
|
||||
Reference in New Issue
Block a user