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>
185 lines
7.2 KiB
JavaScript
185 lines
7.2 KiB
JavaScript
// 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 = { setDemand: [], handleInput: [], turnOff: 0 };
|
|
return {
|
|
config: { general: { name } },
|
|
setDemand: async (value, unit) => { calls.setDemand.push([value, unit]); },
|
|
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.setDemand.length, 0, 'no demand sent in stop zone');
|
|
}
|
|
});
|
|
|
|
// Pre-engagement: pumps haven't reached startLevel yet, so the rising-edge
|
|
// hysteresis gate hasn't armed. Explicit turnOff (NOT a setDemand(0)), so
|
|
// MGC doesn't kick a pump on at flow.min before the gate is ever passed.
|
|
test('minLevel ≤ level < startLevel (not yet armed) → explicit turnOff', async () => {
|
|
const ctx = makeCtx(1.5);
|
|
const state = { percControl: 17 };
|
|
await levelBased.run(ctx, state);
|
|
|
|
assert.equal(state.percControl, 0, 'percControl held at 0 before engagement');
|
|
for (const g of Object.values(ctx.machineGroups)) {
|
|
assert.equal(g._calls.turnOff, 1, 'engagement gate calls turnOff');
|
|
assert.equal(g._calls.setDemand.length, 0, 'no setDemand before engagement');
|
|
}
|
|
});
|
|
|
|
test('level == startLevel → percControl == 0 dispatched as setDemand (0 % = min flow, NOT off)', async () => {
|
|
const ctx = makeCtx(2);
|
|
const state = { percControl: null };
|
|
await levelBased.run(ctx, state);
|
|
assert.equal(state.percControl, 0);
|
|
// Critical: at startLevel pumps are engaged at min flow, NOT turned off.
|
|
// The bug we're fixing: the previous soft-turnOff at pct≤0 stopped pumps
|
|
// at this boundary even though the hysteresis was armed.
|
|
for (const g of Object.values(ctx.machineGroups)) {
|
|
assert.equal(g._calls.turnOff, 0, 'do not turnOff at startLevel');
|
|
assert.equal(g._calls.setDemand.length, 1, 'forward 0 % to MGC');
|
|
assert.deepEqual(g._calls.setDemand[0], [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 setDemand(pct, "%")', 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.setDemand.length, 1, 'one forward per group');
|
|
assert.deepEqual(g._calls.setDemand[0], [50, '%']);
|
|
assert.equal(g._calls.handleInput.length, 0, 'no raw handleInput — % goes through setDemand');
|
|
assert.equal(g._calls.turnOff, 0);
|
|
}
|
|
});
|
|
|
|
test('inflowLevel does NOT shape the curve — ramp foot = startLevel regardless', async () => {
|
|
// startLevel=2, inflowLevel=3, maxLevel=4. Level=2.5 sits between
|
|
// startLevel and inflowLevel. Pre-fix this was a 0 % "hold zone"; now
|
|
// the ramp is anchored at startLevel so level=2.5 → 25 %.
|
|
const ctx = makeCtx(2.5, { levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4 } });
|
|
ctx.basin = { inflowLevel: 3 };
|
|
const state = { percControl: null };
|
|
await levelBased.run(ctx, state);
|
|
assert.ok(Math.abs(state.percControl - 25) < 1e-9,
|
|
`expected ~25 % (ramp foot at startLevel, NOT inflowLevel); got ${state.percControl}`);
|
|
});
|
|
|
|
test('holdLevel > startLevel opts into a hold band [startLevel, holdLevel] at 0 %', async () => {
|
|
// Same geometry but operator raises holdLevel to 3 so the ramp's 0 %
|
|
// foot moves up. Level=2.5 should now sit in the hold band: pumps are
|
|
// engaged but emit 0 % (= MGC's flow.min, NOT turn-off).
|
|
const ctx = makeCtx(2.5, {
|
|
levelbased: { minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4 },
|
|
});
|
|
const state = { percControl: null };
|
|
await levelBased.run(ctx, state);
|
|
assert.equal(state.percControl, 0, '0 % in the configurable hold band');
|
|
for (const g of Object.values(ctx.machineGroups)) {
|
|
assert.equal(g._calls.turnOff, 0, 'engaged — must not turnOff in hold band');
|
|
assert.deepEqual(g._calls.setDemand[0], [0, '%']);
|
|
}
|
|
});
|
|
|
|
test('falling-edge keep-alive [stopLevel, startLevel] keeps pumps spinning', async () => {
|
|
// stopLevel = 0.5, startLevel = 2. Once armed (level ≥ startLevel), the
|
|
// band [0.5, 2) stays engaged at deadZoneKeepAlivePercent (default 1 %).
|
|
const ctx = makeCtx(1.5, {
|
|
levelbased: { minLevel: 0.1, startLevel: 2, stopLevel: 0.5, maxLevel: 4 },
|
|
});
|
|
// Pre-arm: simulate that level previously crossed startLevel.
|
|
ctx.host = { _stopHystRunning: true };
|
|
const state = { percControl: null };
|
|
await levelBased.run(ctx, state);
|
|
assert.equal(state.percControl, 1, 'keep-alive emits 1 % in the [stop, start) band');
|
|
for (const g of Object.values(ctx.machineGroups)) {
|
|
assert.equal(g._calls.turnOff, 0);
|
|
assert.deepEqual(g._calls.setDemand[0], [1, '%']);
|
|
}
|
|
});
|
|
|
|
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);
|
|
}
|
|
});
|