Files
pumpingStation/test/integration/shifted-ramp-end-to-end.test.js

220 lines
9.2 KiB
JavaScript
Raw Normal View History

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>
2026-05-06 11:46:46 +02:00
// 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: {
fix(levelBased): drop hold zone, route through MGC.setDemand, add holdLevel + integrator variant pick; slim npm pack levelBased ramp + engagement: - Ramp foot is now max(startLevel, holdLevel) — was max(startLevel, inflowLevel). inflowLevel is basin geometry, not a control setpoint; the implicit hold zone it created was causing pumps to "start at inflowLevel" instead of startLevel. - New optional `holdLevel` config (defaults to startLevel = no hold band). When raised, pumps engage at startLevel and hold at 0 % = MGC flow.min across [startLevel, holdLevel], then ramp 0..100 % to maxLevel. - Engagement decided in run() (not in `_applyMachineGroupLevelControl`): rising-edge hysteresis arming gates a clean turnOff early-return. Once armed, the helper always forwards setDemand(pct, '%') — 0 % legitimately means "engaged at min flow", no more soft-turnOff at the boundary. - Disengagement paths (minLevel hard-stop, stopLevel falling-edge, pre-arming idle) now all clear the shifted-ramp hysteresis state too. - Threshold validator drops the startLevel ≤ inflowLevel rule; adds startLevel ≤ holdLevel < maxLevel (only checked when holdLevel is explicitly set, so default-null doesn't false-flag). MGC unit math: - Replace direct group.handleInput(percent) with group.setDemand(pct, '%') in _applyMachineGroupLevelControl. The percent → m³/s resolution now lives in MGC.setDemand (committed separately in the MGC submodule). FlowAggregator variant picking: - New _pickFlowSum() helper mirrors selectBestNetFlow's variant precedence (measured first, then predicted) and resolves each side independently. Realistic mixed case — real measured upstream sensor + predicted pump outflow — now feeds the predicted-volume integrator. Was reading only `flow.predicted.*` so a real upstream sensor (which writes `flow.measured.*`) never moved the level. Editor: - New `holdLevel` and `deadZoneKeepAlivePercent` defaults + side-panel input rows in the levelbased mode preview. - Add the missing `ps-mode-line-holdLevel` SVG marker (was declared in the side-panel coupling but the SVG element didn't exist, so the dashed line never rendered). - Relax stopLevel marker gate so it renders for any non-negative typed value — start/stop ordering is the ribbon's job, not the marker's (was hiding the line whenever startLevel was momentarily smaller). - Add holdLevel to the marker loop in mode-preview so changes track. - Add stopLevel + holdLevel + maxLevel to all three bindRedraw lists (basin-diagram, mode-preview, bounds.apply) so the SVG, validation ribbon, and HTML5 min/max attrs update on every edit. - Initialise stopLevel + holdLevel + deadZoneKeepAlivePercent inputs in oneditprepare so reopening the editor shows the saved values. - nodeClass passes holdLevel + deadZoneKeepAlivePercent into the domain config. Tests: - New test/basic/_probe_upstream_emit.test.js: confirms the parent surfaces flow.measured.upstream.* on Port 0 after a measurement child write — pins the previously-invisible measured variant flow. - flowAggregator.basic.test.js: two new regression cases — measured inflow when predicted side is empty, and the measured-in / predicted-out mixed case. - control-levelBased.basic.test.js: new cases for the holdLevel hold band, the [stopLevel, startLevel] keep-alive, the engagement gate, and the "0 % at startLevel = setDemand" contract. - specificClass.test.js: zone tests adjusted to the new ramp foot. Shifted-ramp tests pin holdLevel = 3 explicitly so their legacy arithmetic (ramp foot at inflowLevel) stays self-consistent. - shifted-ramp-end-to-end.test.js: same holdLevel pin for the same reason. Packaging: - Add .gitignore + .npmignore so the published tarball drops the wiki/, simulations/, test/, tools/, .claude/ etc. The pack went from 1.5 MB (72 files) to ~57 KB (30 files). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:36:29 +02:00
// holdLevel pins the ramp foot at 3 to preserve the original geometry
// (up curve 0 %@3 → 100 %@4). New default would put the foot at
// startLevel=2; this test specifically exercises shifted-ramp arming
// behaviour, not the ramp-foot semantic itself.
minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4,
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>
2026-05-06 11:46:46 +02:00
curveType: 'linear', logCurveFactor: 9,
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
},
},
safety: {
enableDryRunProtection: false, enableOverfillProtection: false,
dryRunThresholdPercent: 2, highVolumeSafetyThresholdPercent: 98,
overfillThresholdPercent: 98, timeleftToFullOrEmptyThresholdSeconds: 0,
},
};
}
// machineGroups is a registry-backed getter (declareChildGetter) — inject
// the fake MGC via the real child-registration handshake so the registry
// stays the source of truth across configure() and tick().
function registerMockGroup(ps, id, demands) {
const mock = {
config: {
general: { id, name: id },
functionality: { softwareType: 'machinegroup', positionVsParent: 'atEquipment' },
asset: { category: 'controller' },
},
measurements: {
emitter: { on: () => {} },
setChildId: () => {}, setChildName: () => {}, setParentRef: () => {},
},
handleInput: async (_src, d) => { demands.push(d); },
turnOffAllMachines: () => {},
};
ps.childRegistrationUtils.registerChild(mock, 'atEquipment');
return mock;
}
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>
2026-05-06 11:46:46 +02:00
// 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 = [];
registerMockGroup(ps, 'mgc1', demands);
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>
2026-05-06 11:46:46 +02:00
// 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();
}
});