Files
pumpingStation/test/basic/control-levelBased.basic.test.js

131 lines
4.4 KiB
JavaScript
Raw Normal View History

// Unit tests for the level-based control strategy.
// Run with: node --test test/basic/control-levelBased.basic.test.js
const test = require('node:test');
const assert = require('node:assert/strict');
const levelBased = require('../../src/control/levelBased');
function makeMeasurements(levelMeters) {
// Minimal MeasurementContainer stand-in. The strategy only calls
// getUnit('level') and a chain ending in getCurrentValue(unit).
const chain = {
type() { return chain; },
variant() { return chain; },
position() { return chain; },
getCurrentValue() {
return Number.isFinite(levelMeters) ? levelMeters : null;
},
};
return {
getUnit: () => 'm',
type: () => chain,
};
}
function makeGroup(name) {
const calls = { handleInput: [], turnOff: 0 };
return {
config: { general: { name } },
handleInput: async (...args) => { calls.handleInput.push(args); },
turnOffAllMachines: () => { calls.turnOff += 1; },
_calls: calls,
};
}
function makeCtx(levelMeters, opts = {}) {
const groups = {
a: makeGroup('A'),
b: makeGroup('B'),
c: makeGroup('C'),
};
return {
measurements: makeMeasurements(levelMeters),
config: {
control: { levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, ...(opts.levelbased || {}) } },
},
logger: { warn: () => {}, debug: () => {}, info: () => {}, error: () => {} },
machineGroups: groups,
machines: {},
levelVariants: ['measured', 'predicted'],
};
}
test('level < minLevel → STOP: turnOffAllMachines on every group, percControl = 0', async () => {
const ctx = makeCtx(0.5);
const state = { percControl: 42 };
await levelBased.run(ctx, state);
assert.equal(state.percControl, 0);
for (const g of Object.values(ctx.machineGroups)) {
assert.equal(g._calls.turnOff, 1, 'turnOffAllMachines called once per group');
assert.equal(g._calls.handleInput.length, 0, 'no demand sent in stop zone');
}
});
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>
2026-05-11 16:19:55 +02:00
// basin-docs behavior: between minLevel and the active ramp foot, demand
// is commanded to 0 % (not "unchanged"). MGC still receives the command;
// only the explicit minLevel hard-stop path skips handleInput.
test('minLevel ≤ level < ramp foot → commands 0 % without shutdown', async () => {
const ctx = makeCtx(1.5);
const state = { percControl: 17 };
await levelBased.run(ctx, state);
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>
2026-05-11 16:19:55 +02:00
assert.equal(state.percControl, 0, 'percControl driven to 0 in the hold zone');
for (const g of Object.values(ctx.machineGroups)) {
assert.equal(g._calls.turnOff, 0);
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>
2026-05-11 16:19:55 +02:00
assert.equal(g._calls.handleInput.length, 1, 'one demand=0 forward per group');
assert.deepEqual(g._calls.handleInput[0], ['parent', 0]);
}
});
test('level == startLevel → percControl == 0 (lower edge of ramp)', async () => {
const ctx = makeCtx(2);
const state = { percControl: null };
await levelBased.run(ctx, state);
assert.equal(state.percControl, 0);
});
test('level == maxLevel → percControl == 100 (upper edge of ramp)', async () => {
const ctx = makeCtx(4);
const state = { percControl: null };
await levelBased.run(ctx, state);
assert.equal(state.percControl, 100);
});
test('level above maxLevel → percControl clamped at 100 (interpolation limit_input behaviour)', async () => {
const ctx = makeCtx(10);
const state = { percControl: null };
await levelBased.run(ctx, state);
// interpolate_lin_single_point clamps via limit_input(o_min, o_max).
assert.equal(state.percControl, 100);
});
test('percControl forwarded to every group via handleInput("parent", percControl)', async () => {
const ctx = makeCtx(3); // halfway between startLevel=2 and maxLevel=4 → 50%
const state = { percControl: null };
await levelBased.run(ctx, state);
assert.equal(state.percControl, 50);
for (const g of Object.values(ctx.machineGroups)) {
assert.equal(g._calls.handleInput.length, 1, 'one forward per group');
assert.deepEqual(g._calls.handleInput[0], ['parent', 50]);
assert.equal(g._calls.turnOff, 0);
}
});
test('no valid level → warns and returns without mutating percControl or calling groups', async () => {
const ctx = makeCtx(NaN);
let warned = false;
ctx.logger.warn = () => { warned = true; };
const state = { percControl: 7 };
await levelBased.run(ctx, state);
assert.equal(warned, true);
assert.equal(state.percControl, 7);
for (const g of Object.values(ctx.machineGroups)) {
assert.equal(g._calls.turnOff, 0);
assert.equal(g._calls.handleInput.length, 0);
}
});