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:
94
test/integration/basic-dashboard-flow.test.js
Normal file
94
test/integration/basic-dashboard-flow.test.js
Normal file
@@ -0,0 +1,94 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
function loadDashboardFlow() {
|
||||
const flowPath = path.join(__dirname, '../../examples/basic-dashboard.flow.json');
|
||||
return JSON.parse(fs.readFileSync(flowPath, 'utf8'));
|
||||
}
|
||||
|
||||
function makeContextStub() {
|
||||
const store = {};
|
||||
return {
|
||||
get(key) {
|
||||
return store[key];
|
||||
},
|
||||
set(key, value) {
|
||||
store[key] = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('basic dashboard flow contains the pumpingStation node and trend widgets', () => {
|
||||
const flow = loadDashboardFlow();
|
||||
const ps = flow.find((n) => n.id === 'ps_node_basic');
|
||||
const parser = flow.find((n) => n.id === 'ps_parse_output');
|
||||
const levelChart = flow.find((n) => n.id === 'ps_chart_level');
|
||||
const demandChart = flow.find((n) => n.id === 'ps_chart_demand');
|
||||
|
||||
assert.ok(ps, 'ps_node_basic should exist');
|
||||
assert.equal(ps.type, 'pumpingStation');
|
||||
assert.equal(ps.controlMode, 'levelbased');
|
||||
assert.equal(ps.levelCurveType, 'linear');
|
||||
assert.equal(ps.inletPipeDiameter, 0.4);
|
||||
assert.equal(ps.outletPipeDiameter, 0.3);
|
||||
assert.ok(parser, 'ps_parse_output should exist');
|
||||
assert.equal(parser.outputs, 6);
|
||||
assert.equal(levelChart.type, 'ui-chart');
|
||||
assert.equal(demandChart.type, 'ui-chart');
|
||||
});
|
||||
|
||||
test('basic dashboard parser routes process fields to charts and state text', () => {
|
||||
const flow = loadDashboardFlow();
|
||||
const parser = flow.find((n) => n.id === 'ps_parse_output');
|
||||
assert.ok(parser, 'ps_parse_output should exist');
|
||||
|
||||
const func = new Function('msg', 'context', 'node', parser.func);
|
||||
const context = makeContextStub();
|
||||
const node = { send() {} };
|
||||
|
||||
// Flatten format is `${type}.${variant}.${position}.${childId}`. When the
|
||||
// runtime writes without an explicit .child(), childId='default'. Mirror
|
||||
// the real shape here. (See generalFunctions/src/measurements/
|
||||
// MeasurementContainer.js getFlattenedOutput.)
|
||||
const out = func({
|
||||
payload: {
|
||||
'level.predicted.atequipment.default': 3.25,
|
||||
'volume.predicted.atequipment.default': 32.5,
|
||||
'netFlowRate.predicted.atequipment.default': 0.003,
|
||||
percControl: 25,
|
||||
direction: 'filling',
|
||||
safetyState: 'normal',
|
||||
isOverflowing: false,
|
||||
timeleft: 400,
|
||||
},
|
||||
}, context, node);
|
||||
|
||||
assert.ok(Array.isArray(out));
|
||||
assert.equal(out.length, 6);
|
||||
assert.equal(out[0].topic, 'level');
|
||||
assert.equal(out[0].payload, 3.25);
|
||||
assert.equal(out[1].topic, 'volume');
|
||||
assert.equal(out[1].payload, 32.5);
|
||||
assert.equal(out[2].topic, 'demand');
|
||||
assert.equal(out[2].payload, 25);
|
||||
assert.equal(out[3].topic, 'net_flow');
|
||||
assert.equal(out[3].payload, 0.003);
|
||||
assert.match(out[4].payload, /normal/);
|
||||
assert.match(out[5].payload, /level=3.25 m/);
|
||||
});
|
||||
|
||||
test('basic dashboard parser keeps previous values when process output sends only changed fields', () => {
|
||||
const flow = loadDashboardFlow();
|
||||
const parser = flow.find((n) => n.id === 'ps_parse_output');
|
||||
const func = new Function('msg', 'context', 'node', parser.func);
|
||||
const context = makeContextStub();
|
||||
const node = { send() {} };
|
||||
|
||||
func({ payload: { 'level.predicted.atequipment.default': 3.1, percControl: 10 } }, context, node);
|
||||
const out = func({ payload: { percControl: 20 } }, context, node);
|
||||
|
||||
assert.equal(out[0].payload, 3.1);
|
||||
assert.equal(out[2].payload, 20);
|
||||
});
|
||||
198
test/integration/shifted-ramp-end-to-end.test.js
Normal file
198
test/integration/shifted-ramp-end-to-end.test.js
Normal file
@@ -0,0 +1,198 @@
|
||||
// End-to-end test for the level-armed hysteresis (shifted ramp) cycle.
|
||||
// Drives a full fill→arm→drain cycle through the same code path the
|
||||
// dashboard exercises (manual Q_IN / Q_OUT + tick), and asserts the
|
||||
// hold-then-ramp output behaviour.
|
||||
//
|
||||
// Run with: node --test test/integration/shifted-ramp-end-to-end.test.js
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const PumpingStation = require('../../src/specificClass');
|
||||
|
||||
const SURFACE_AREA = 10; // basin volume / height = 50/5
|
||||
const TICK_MS = 1000; // simulate 1 s per tick
|
||||
|
||||
function makeConfig() {
|
||||
return {
|
||||
general: {
|
||||
name: 'TestPS',
|
||||
id: 'ps-e2e',
|
||||
unit: 'm3/h',
|
||||
logging: { enabled: false, logLevel: 'error' },
|
||||
flowThreshold: 1e-4,
|
||||
},
|
||||
functionality: {
|
||||
softwareType: 'pumpingStation',
|
||||
role: 'stationcontroller',
|
||||
positionVsParent: 'atEquipment',
|
||||
},
|
||||
basin: {
|
||||
volume: 50, height: 5,
|
||||
inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5,
|
||||
inletPipeDiameter: 0.4, outletPipeDiameter: 0.3,
|
||||
},
|
||||
hydraulics: { refHeight: 'NAP', basinBottomRef: 0, minHeightBasedOn: 'outlet' },
|
||||
control: {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased', 'manual']),
|
||||
levelbased: {
|
||||
minLevel: 1, startLevel: 2, maxLevel: 4,
|
||||
curveType: 'linear', logCurveFactor: 9,
|
||||
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
|
||||
},
|
||||
},
|
||||
safety: {
|
||||
enableDryRunProtection: false, enableOverfillProtection: false,
|
||||
dryRunThresholdPercent: 2, highVolumeSafetyThresholdPercent: 98,
|
||||
overfillThresholdPercent: 98, timeleftToFullOrEmptyThresholdSeconds: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Build a PS with a fake MGC that captures every demand sent to it,
|
||||
// and a clock we control so _updatePredictedVolume integrates over a
|
||||
// known dt regardless of wall-clock.
|
||||
function buildHarness() {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
const demands = [];
|
||||
ps.machineGroups['mgc1'] = {
|
||||
config: { general: { name: 'mgc1' } },
|
||||
turnOffAllMachines: () => {},
|
||||
handleInput: async (_src, d) => { demands.push(d); },
|
||||
};
|
||||
// Seed level at startLevel so the run begins idle.
|
||||
ps.calibratePredictedLevel(2.0);
|
||||
// Override Date.now via a controllable clock that advances `step()`.
|
||||
let now = ps._predictedFlowState.lastTimestamp || 0;
|
||||
ps._fakeNow = () => now;
|
||||
ps._fakeAdvance = (ms) => { now += ms; };
|
||||
// Patch global Date.now JUST inside the scope of these tests.
|
||||
const realNow = Date.now;
|
||||
Date.now = ps._fakeNow;
|
||||
// Restore on completion.
|
||||
ps._restore = () => { Date.now = realNow; };
|
||||
return { ps, demands };
|
||||
}
|
||||
|
||||
async function step(ps, qIn, qOut) {
|
||||
// Apply the manual Q_IN / Q_OUT (mirroring the dashboard's q_in / q_out
|
||||
// topic handlers in nodeClass.js), advance time, then tick once.
|
||||
if (Number.isFinite(qIn)) ps.setManualInflow(qIn, Date.now(), 'm3/s');
|
||||
if (Number.isFinite(qOut)) ps.setManualOutflow(qOut, Date.now(), 'm3/s');
|
||||
ps._fakeAdvance(TICK_MS);
|
||||
ps.tick();
|
||||
}
|
||||
|
||||
function levelOf(ps) {
|
||||
return ps.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m');
|
||||
}
|
||||
|
||||
test('shifted ramp e2e: arm → hold → ramp-down → disarm', async () => {
|
||||
const { ps } = buildHarness();
|
||||
try {
|
||||
// ─── PHASE A: fill from start (2.0) up past the arm point ──────────
|
||||
// Q_IN = 0.05 m3/s, Q_OUT = 0 → net = 0.05 m3/s. Level rises by
|
||||
// 0.05/SURFACE_AREA = 0.005 m per second.
|
||||
let armedAt = null;
|
||||
for (let i = 0; i < 600 && levelOf(ps) < 3.95; i++) {
|
||||
await step(ps, 0.05, 0);
|
||||
if (!armedAt && ps._shiftArmed) armedAt = { level: levelOf(ps), pct: ps.percControl };
|
||||
}
|
||||
assert.ok(armedAt, 'shift should arm during fill');
|
||||
// Should arm right around level=3.8 (up curve = 80 %). Allow ±0.05 m
|
||||
// jitter for time-discretization.
|
||||
assert.ok(Math.abs(armedAt.level - 3.8) < 0.05,
|
||||
`expected arm near level=3.8, got ${armedAt.level}`);
|
||||
assert.ok(armedAt.pct >= 80 - 1e-6,
|
||||
`at arm point output should be ≥ shiftArmPercent, got ${armedAt.pct}`);
|
||||
|
||||
// While still filling and armed, output should track the up curve
|
||||
// (not jump to 100 %). At level ~ 3.95, up curve = 95 %.
|
||||
const fillingPct = ps.percControl;
|
||||
assert.ok(fillingPct < 100 + 1e-6 && fillingPct >= 80 - 1e-6,
|
||||
`filling-armed output should still be on up curve, got ${fillingPct}`);
|
||||
// No hold captured yet (still filling).
|
||||
assert.equal(ps._shiftHoldValue, null);
|
||||
|
||||
// ─── PHASE B: flip to draining ─────────────────────────────────────
|
||||
// First drain tick captures the hold. We need direction='draining' as
|
||||
// determined by _selectBestNetFlow → so q_in - q_out must be negative
|
||||
// by more than the dead-band (1e-4).
|
||||
await step(ps, 0, 0.05); // net = -0.05
|
||||
assert.equal(ps.state.direction, 'draining');
|
||||
// Hold captured = up curve at the level when direction flipped. The
|
||||
// captured value is recorded BEFORE this drain tick lowered the level
|
||||
// further, so it should match the last filling tick's output (within
|
||||
// the per-tick step size 0.5 % ~ 0.005 m × 100 / 1 m).
|
||||
assert.ok(ps._shiftHoldValue >= 80 - 1e-6,
|
||||
`hold should be at least the arm threshold, got ${ps._shiftHoldValue}`);
|
||||
const hold = ps._shiftHoldValue;
|
||||
|
||||
// ─── PHASE C: drain while level still ≥ shiftLevel — output HELD ───
|
||||
// Drain until level just above shiftLevel=3.5. Output stays = hold.
|
||||
let held = true;
|
||||
for (let i = 0; i < 200 && levelOf(ps) > 3.51; i++) {
|
||||
await step(ps, 0, 0.05);
|
||||
if (Math.abs(ps.percControl - hold) > 1e-6) { held = false; break; }
|
||||
}
|
||||
assert.ok(held, 'output should HOLD at the captured value while level > shiftLevel');
|
||||
assert.ok(Math.abs(ps.percControl - hold) < 1e-6,
|
||||
`still expected hold=${hold}, got ${ps.percControl}`);
|
||||
|
||||
// ─── PHASE D: drain past shiftLevel — output ramps hold→0 ──────────
|
||||
// Drain until clearly below shiftLevel (level ≤ 3.45). Output should drop.
|
||||
while (levelOf(ps) > 3.45) await step(ps, 0, 0.05);
|
||||
const justBelow = ps.percControl;
|
||||
assert.ok(justBelow < hold,
|
||||
`output should start dropping below shiftLevel, got ${justBelow} vs hold ${hold}`);
|
||||
// Ramp midpoint: level=2.75 (midway in [2, 3.5]). Output ≈ hold × 0.5.
|
||||
while (levelOf(ps) > 2.78 && levelOf(ps) > 2.0) await step(ps, 0, 0.05);
|
||||
const mid = ps.percControl;
|
||||
assert.ok(Math.abs(mid - hold * 0.5) < hold * 0.05,
|
||||
`at level≈2.75 expected ≈ hold/2 (${hold * 0.5}), got ${mid}`);
|
||||
|
||||
// ─── PHASE E: level drops to startLevel — DISARM, output 0 ─────────
|
||||
while (levelOf(ps) > 1.95) await step(ps, 0, 0.05);
|
||||
assert.equal(ps._shiftArmed, false, 'should disarm when level reaches startLevel');
|
||||
assert.equal(ps._shiftHoldValue, null);
|
||||
assert.equal(ps.percControl, 0);
|
||||
} finally {
|
||||
ps._restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('shifted ramp e2e: bounce — fill, drain a bit, refill, drain — captures fresh hold', async () => {
|
||||
const { ps } = buildHarness();
|
||||
try {
|
||||
// Fill to arm + some headroom.
|
||||
while (levelOf(ps) < 3.85) await step(ps, 0.05, 0);
|
||||
assert.equal(ps._shiftArmed, true);
|
||||
|
||||
// First drain transition → hold #1.
|
||||
await step(ps, 0, 0.05);
|
||||
const hold1 = ps._shiftHoldValue;
|
||||
assert.ok(hold1 >= 80 - 1e-6);
|
||||
|
||||
// Drain a tiny bit (level still > shiftLevel) → output stays at hold1.
|
||||
for (let i = 0; i < 5; i++) await step(ps, 0, 0.05);
|
||||
assert.ok(Math.abs(ps.percControl - hold1) < 1e-6);
|
||||
|
||||
// Flip back to filling at higher rate; up curve resumes; hold cleared.
|
||||
await step(ps, 0.05, 0);
|
||||
assert.equal(ps._shiftHoldValue, null);
|
||||
assert.equal(ps._shiftArmed, true, 'should stay armed across the bounce');
|
||||
|
||||
// Fill higher than before (output goes higher).
|
||||
while (levelOf(ps) < 3.95) await step(ps, 0.05, 0);
|
||||
const fillingPct = ps.percControl;
|
||||
assert.ok(fillingPct > hold1, `bounce should rise above first hold; got ${fillingPct} vs ${hold1}`);
|
||||
|
||||
// Drain again → fresh hold #2 = current up curve %.
|
||||
await step(ps, 0, 0.05);
|
||||
const hold2 = ps._shiftHoldValue;
|
||||
assert.ok(hold2 > hold1, `second hold (${hold2}) should be > first (${hold1})`);
|
||||
} finally {
|
||||
ps._restore();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user