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>
This commit is contained in:
85
test/basic/_probe_upstream_emit.test.js
Normal file
85
test/basic/_probe_upstream_emit.test.js
Normal file
@@ -0,0 +1,85 @@
|
||||
// Throwaway probe — exercises the exact path:
|
||||
// measurement child writes flow.measured.upstream → pumpingStation parent
|
||||
// subscribes → getOutput() (≡ what Port 0 emits).
|
||||
// Run with: node --test test/basic/_probe_upstream_emit.test.js
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const PumpingStation = require('../../src/specificClass');
|
||||
const { MeasurementContainer, configManager } = require('generalFunctions');
|
||||
const EventEmitter = require('node:events');
|
||||
|
||||
// Minimal PumpingStation config — matches the editor defaults shape.
|
||||
function makePsConfig() {
|
||||
const ui = {
|
||||
name: 'PS', basinVolume: 50, basinHeight: 5,
|
||||
inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5,
|
||||
minHeightBasedOn: 'outlet',
|
||||
controlMode: 'levelbased',
|
||||
minLevel: 1, startLevel: 2, maxLevel: 4,
|
||||
levelCurveType: 'linear',
|
||||
processOutputFormat: 'process', dbaseOutputFormat: 'influxdb',
|
||||
};
|
||||
const cm = new configManager();
|
||||
// Use the same buildConfig pipeline the runtime uses.
|
||||
return cm.buildConfig('pumpingStation', ui, 'ps-probe', {
|
||||
basin: {
|
||||
volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5,
|
||||
},
|
||||
hydraulics: { minHeightBasedOn: 'outlet' },
|
||||
control: {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased']),
|
||||
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear' },
|
||||
},
|
||||
safety: {},
|
||||
});
|
||||
}
|
||||
|
||||
// Fake measurement child that looks exactly like the real one to the router:
|
||||
// - softwareType 'measurement'
|
||||
// - config.asset.type = 'flow'
|
||||
// - config.functionality.positionVsParent = 'upstream'
|
||||
// - .measurements is a real MeasurementContainer with a real emitter
|
||||
function makeMeasurementChild(id = 'meas-probe') {
|
||||
const measurements = new MeasurementContainer({
|
||||
autoConvert: true,
|
||||
preferredUnits: { flow: 'm3/s' },
|
||||
});
|
||||
// Real container ships an emitter; sanity check.
|
||||
assert.ok(measurements.emitter instanceof EventEmitter || typeof measurements.emitter?.on === 'function');
|
||||
return {
|
||||
id,
|
||||
source: {
|
||||
config: {
|
||||
general: { id, name: id },
|
||||
functionality: { softwareType: 'measurement', positionVsParent: 'upstream' },
|
||||
asset: { type: 'flow' },
|
||||
},
|
||||
measurements,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('PROBE: measurement child writes flow.measured.upstream — parent surfaces it on getOutput()', () => {
|
||||
const ps = new PumpingStation(makePsConfig());
|
||||
const child = makeMeasurementChild();
|
||||
|
||||
// Register the child the same way the runtime does.
|
||||
ps.childRegistrationUtils.registerChild(child.source, 'upstream');
|
||||
|
||||
// Drive a value through the child's MeasurementContainer the way Channel
|
||||
// does — type/variant/position chain then .value().
|
||||
child.source.measurements
|
||||
.type('flow').variant('measured').position('upstream')
|
||||
.value(12, Date.now(), 'm3/h'); // 12 m³/h ≈ 0.00333 m³/s
|
||||
|
||||
const out = ps.getOutput();
|
||||
const upstreamKeys = Object.keys(out).filter((k) => k.startsWith('flow.measured.upstream'));
|
||||
console.log('flow.measured.upstream.* keys in Port 0 payload:', upstreamKeys);
|
||||
for (const k of upstreamKeys) console.log(` ${k} = ${out[k]}`);
|
||||
|
||||
// The contract: the parent should surface the upstream measurement.
|
||||
assert.ok(upstreamKeys.length > 0, 'parent must surface flow.measured.upstream.* on Port 0');
|
||||
});
|
||||
@@ -24,9 +24,10 @@ function makeMeasurements(levelMeters) {
|
||||
}
|
||||
|
||||
function makeGroup(name) {
|
||||
const calls = { handleInput: [], turnOff: 0 };
|
||||
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,
|
||||
@@ -59,31 +60,38 @@ test('level < minLevel → STOP: turnOffAllMachines on every group, percControl
|
||||
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');
|
||||
assert.equal(g._calls.setDemand.length, 0, 'no demand sent in stop zone');
|
||||
}
|
||||
});
|
||||
|
||||
// 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 () => {
|
||||
// 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 driven to 0 in the hold zone');
|
||||
assert.equal(state.percControl, 0, 'percControl held at 0 before engagement');
|
||||
for (const g of Object.values(ctx.machineGroups)) {
|
||||
assert.equal(g._calls.turnOff, 0);
|
||||
assert.equal(g._calls.handleInput.length, 1, 'one demand=0 forward per group');
|
||||
assert.deepEqual(g._calls.handleInput[0], ['parent', 0]);
|
||||
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 (lower edge of ramp)', async () => {
|
||||
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 () => {
|
||||
@@ -101,19 +109,65 @@ test('level above maxLevel → percControl clamped at 100 (interpolation limit_i
|
||||
assert.equal(state.percControl, 100);
|
||||
});
|
||||
|
||||
test('percControl forwarded to every group via handleInput("parent", percControl)', async () => {
|
||||
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.handleInput.length, 1, 'one forward per group');
|
||||
assert.deepEqual(g._calls.handleInput[0], ['parent', 50]);
|
||||
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;
|
||||
|
||||
@@ -58,6 +58,48 @@ test('FlowAggregator.update integrates inflow-outflow over delta-t', async () =>
|
||||
assert.ok(vol > 2.04 && vol < 2.06, `volume after integration was ${vol}`);
|
||||
});
|
||||
|
||||
test('FlowAggregator.update integrates measured inflow when predicted side is empty', async () => {
|
||||
// Regression: a real upstream sensor writes `flow.measured.upstream.<id>`
|
||||
// (the measurement node hard-codes variant='measured'), but the integrator
|
||||
// used to read variant='predicted' only — so level stayed flat while the
|
||||
// status row reported +N m³/h. The fix mirrors selectBestNetFlow's
|
||||
// variant precedence per side.
|
||||
const { fa, measurements } = makeAggregator();
|
||||
const t0 = Date.now() - 10_000;
|
||||
// Measured inflow at 'upstream' (one of the inflow position aliases),
|
||||
// no outflow side at all.
|
||||
measurements.type('flow').variant('measured').position('upstream').child('sensor-A')
|
||||
.value(0.01, t0, 'm3/s');
|
||||
|
||||
fa._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: t0 };
|
||||
fa.update();
|
||||
|
||||
const vol = measurements.type('volume').variant('predicted').position('atequipment')
|
||||
.getCurrentValue('m3');
|
||||
// Expect minVol(2) + 0.01 × ~10 ≈ 2.10 m3.
|
||||
assert.ok(vol > 2.09 && vol < 2.11, `measured inflow did not integrate: vol=${vol}`);
|
||||
});
|
||||
|
||||
test('FlowAggregator.update mixes measured inflow with predicted outflow', async () => {
|
||||
// Realistic mix: real upstream sensor (measured) + pump-curve outflow
|
||||
// (predicted). The picker resolves each side independently, so the net
|
||||
// balance uses both.
|
||||
const { fa, measurements } = makeAggregator();
|
||||
const t0 = Date.now() - 10_000;
|
||||
measurements.type('flow').variant('measured').position('upstream').child('sensor-A')
|
||||
.value(0.01, t0, 'm3/s');
|
||||
measurements.type('flow').variant('predicted').position('downstream').child('pump-A')
|
||||
.value(0.004, t0, 'm3/s');
|
||||
|
||||
fa._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: t0 };
|
||||
fa.update();
|
||||
|
||||
const vol = measurements.type('volume').variant('predicted').position('atequipment')
|
||||
.getCurrentValue('m3');
|
||||
// minVol(2) + (0.01 - 0.004) × ~10 ≈ 2.06 m3.
|
||||
assert.ok(vol > 2.05 && vol < 2.07, `mixed-variant integration produced vol=${vol}`);
|
||||
});
|
||||
|
||||
test('FlowAggregator.selectBestNetFlow prefers measured over predicted', async () => {
|
||||
const { fa, measurements } = makeAggregator();
|
||||
measurements.type('flow').variant('measured').position('in').child('m')
|
||||
|
||||
@@ -10,7 +10,7 @@ const PumpingStation = require('../../src/specificClass');
|
||||
// assignment is no longer possible. Tests inject mock groups through the
|
||||
// real registration handshake so the registry remains the source of truth.
|
||||
function registerMockGroup(ps, id, behavior = {}) {
|
||||
const calls = { handleInput: [], turnOff: 0 };
|
||||
const calls = { setDemand: [], handleInput: [], turnOff: 0 };
|
||||
const mock = {
|
||||
config: {
|
||||
general: { id, name: id },
|
||||
@@ -21,6 +21,8 @@ function registerMockGroup(ps, id, behavior = {}) {
|
||||
emitter: { on: () => {} },
|
||||
setChildId: () => {}, setChildName: () => {}, setParentRef: () => {},
|
||||
},
|
||||
setDemand: behavior.setDemand
|
||||
|| (async (value, unit) => { calls.setDemand.push([value, unit]); }),
|
||||
handleInput: behavior.handleInput
|
||||
|| (async (...args) => { calls.handleInput.push(args); }),
|
||||
turnOffAllMachines: behavior.turnOffAllMachines
|
||||
@@ -163,7 +165,10 @@ test('Threshold guardrails — _validateThresholdOrdering', async (t) => {
|
||||
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'startLevel'));
|
||||
});
|
||||
|
||||
await t.test('startLevel > inflowLevel flagged for levelbased rising hold zone', () => {
|
||||
await t.test('startLevel > inflowLevel is allowed (sewer-buffer mode), no issue raised', () => {
|
||||
// Inflow gravity point at 3, startLevel pushed to 3.5 → basin is allowed
|
||||
// to fill past the inlet before pumps engage. levelBased shifts the ramp
|
||||
// foot to startLevel; the validator no longer flags the ordering.
|
||||
const ps = new PumpingStation(makeConfig({
|
||||
control: {
|
||||
mode: 'levelbased',
|
||||
@@ -171,7 +176,8 @@ test('Threshold guardrails — _validateThresholdOrdering', async (t) => {
|
||||
levelbased: { minLevel: 1, startLevel: 3.5, maxLevel: 4, curveType: 'linear' },
|
||||
},
|
||||
}));
|
||||
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'startLevel' && i.bName === 'inflowLevel'));
|
||||
assert.ok(!ps.thresholdIssues.some((i) => i.aName === 'startLevel' && i.bName === 'inflowLevel'),
|
||||
'startLevel vs inflowLevel ordering must not raise an issue');
|
||||
});
|
||||
|
||||
await t.test('outflowLevel >= inflowLevel flagged', () => {
|
||||
@@ -261,51 +267,77 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
|
||||
assert.equal(mock._calls.turnOff, 1);
|
||||
});
|
||||
|
||||
await t.test('minLevel ≤ level < active ramp start → commands 0% without shutdown', async () => {
|
||||
await t.test('minLevel ≤ level < active ramp start → soft turnOff (pct=0 no longer dispatched)', async () => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
ps.percControl = 42; // simulated previous demand
|
||||
const mock = registerMockGroup(ps, 'mgc1');
|
||||
ps.calibratePredictedLevel(1.5); // between minLevel=1 and startLevel=2
|
||||
await ps._controlLevelBased();
|
||||
assert.equal(ps.percControl, 0);
|
||||
assert.equal(mock._calls.handleInput[0][1], 0);
|
||||
// pct=0 → turnOff, no setDemand call (avoids MGC interpolating 0 % to dt.flow.min).
|
||||
assert.equal(mock._calls.turnOff, 1);
|
||||
assert.equal(mock._calls.setDemand.length, 0);
|
||||
});
|
||||
|
||||
await t.test('filling: level between startLevel and inflowLevel commands 0%', async () => {
|
||||
await t.test('filling: level between startLevel and inflowLevel ramps from startLevel (no implicit hold zone)', async () => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
const mock = registerMockGroup(ps, 'mgc1');
|
||||
ps.calibratePredictedLevel(2.5); // startLevel=2, inflowLevel=3
|
||||
ps.calibratePredictedLevel(2.5); // startLevel=2, inflowLevel=3, maxLevel=4
|
||||
await ps._controlLevelBased('filling');
|
||||
// Ramp foot = startLevel (NOT inflowLevel). lerp(2.5, [2, 4], [0, 100]) = 25.
|
||||
assert.ok(Math.abs(ps.percControl - 25) < 1e-9, `expected ~25 %, got ${ps.percControl}`);
|
||||
assert.equal(mock._calls.turnOff, 0, 'engaged — pumps must not be turned off in the ramp');
|
||||
assert.equal(mock._calls.setDemand.length, 1);
|
||||
assert.ok(Math.abs(mock._calls.setDemand[0][0] - 25) < 1e-9);
|
||||
});
|
||||
|
||||
await t.test('filling: level ≥ maxLevel → percControl clamped at 100, routed via setDemand', async () => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
const mock = registerMockGroup(ps, 'mgc1');
|
||||
ps.calibratePredictedLevel(3.5); // 3/4 of the [2,4] ramp → 75 %.
|
||||
await ps._controlLevelBased('filling');
|
||||
assert.ok(Math.abs(ps.percControl - 75) < 1e-9, `expected ~75 %, got ${ps.percControl}`);
|
||||
assert.equal(mock._calls.setDemand.length, 1);
|
||||
assert.equal(mock._calls.setDemand[0][1], '%');
|
||||
assert.ok(Math.abs(mock._calls.setDemand[0][0] - 75) < 1e-9);
|
||||
});
|
||||
|
||||
await t.test('filling: holdLevel raises the ramp foot — explicit hold band [startLevel, holdLevel] sits at 0 %', async () => {
|
||||
const ps = new PumpingStation(makeConfig({
|
||||
control: {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased']),
|
||||
levelbased: { minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
|
||||
},
|
||||
}));
|
||||
const mock = registerMockGroup(ps, 'mgc1');
|
||||
ps.calibratePredictedLevel(2.5); // inside [startLevel, holdLevel]
|
||||
await ps._controlLevelBased('filling');
|
||||
assert.equal(ps.percControl, 0);
|
||||
assert.equal(mock._calls.handleInput[0][1], 0);
|
||||
assert.equal(mock._calls.turnOff, 0, 'engaged — hold band runs at MGC flow.min, not off');
|
||||
assert.deepEqual(mock._calls.setDemand[0], [0, '%']);
|
||||
});
|
||||
|
||||
await t.test('filling: level ≥ inflowLevel → percControl linearly scaled to [0,100]', async () => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
const mock = registerMockGroup(ps, 'mgc1');
|
||||
ps.calibratePredictedLevel(3.5); // midpoint of inflowLevel=3 and maxLevel=4
|
||||
await ps._controlLevelBased('filling');
|
||||
// lerp(3.5, [3,4], [0,100]) = 50
|
||||
assert.ok(Math.abs(ps.percControl - 50) < 1e-9);
|
||||
assert.equal(mock._calls.handleInput.length, 1);
|
||||
assert.ok(Math.abs(mock._calls.handleInput[0][1] - 50) < 1e-9);
|
||||
});
|
||||
|
||||
await t.test('shift disabled (default): foot stays at inflowLevel even after fall', async () => {
|
||||
await t.test('shift disabled (default): foot stays at startLevel — falling levels track the ramp down to startLevel', async () => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
registerMockGroup(ps, 'mgc1');
|
||||
// Climb past inflowLevel and beyond, then fall to a level inside [start..inflow].
|
||||
// Climb above startLevel, then fall to a level inside [start, inflow]. With
|
||||
// the new semantics (ramp foot = startLevel, NOT inflowLevel) the falling
|
||||
// level still produces a positive demand on the way down.
|
||||
ps.calibratePredictedLevel(3.8);
|
||||
await ps._controlLevelBased();
|
||||
assert.ok(ps.percControl > 0);
|
||||
ps.calibratePredictedLevel(2.5); // between startLevel=2 and inflowLevel=3
|
||||
ps.calibratePredictedLevel(2.5); // startLevel=2, maxLevel=4 → 25 %
|
||||
await ps._controlLevelBased();
|
||||
// Without shift the foot is inflowLevel → 0% in the hold zone.
|
||||
assert.equal(ps.percControl, 0);
|
||||
assert.ok(Math.abs(ps.percControl - 25) < 1e-9, `expected 25 % on the down ramp, got ${ps.percControl}`);
|
||||
});
|
||||
|
||||
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.
|
||||
await t.test('shift enabled: arming on % threshold + hold-then-ramp on draining (with holdLevel pinning the foot)', async () => {
|
||||
// The original shifted-ramp test was authored against the legacy ramp
|
||||
// foot = inflowLevel (=3). With the new defaults the foot moves to
|
||||
// startLevel (=2), which changes every percentage in the trace. Pin
|
||||
// the foot back to 3 by setting holdLevel = 3 — that keeps this test's
|
||||
// arithmetic self-consistent: 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({
|
||||
@@ -313,7 +345,7 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased']),
|
||||
levelbased: {
|
||||
minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9,
|
||||
minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4, curveType: 'linear', logCurveFactor: 9,
|
||||
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
|
||||
},
|
||||
},
|
||||
@@ -355,7 +387,9 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased']),
|
||||
levelbased: {
|
||||
minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9,
|
||||
// Pin the ramp foot at 3 via holdLevel — keeps legacy arithmetic
|
||||
// self-consistent with the original test (up curve 0 %@3 → 100 %@4).
|
||||
minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4, curveType: 'linear', logCurveFactor: 9,
|
||||
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
|
||||
},
|
||||
},
|
||||
@@ -381,7 +415,9 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
|
||||
control: {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased']),
|
||||
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'log', logCurveFactor: 9 },
|
||||
// holdLevel=3 keeps ramp foot at 3 so x=0.5 means level=3.5, matching
|
||||
// the legacy assertion bracket.
|
||||
levelbased: { minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4, curveType: 'log', logCurveFactor: 9 },
|
||||
},
|
||||
}));
|
||||
registerMockGroup(ps, 'mgc1');
|
||||
|
||||
@@ -37,7 +37,11 @@ function makeConfig() {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased', 'manual']),
|
||||
levelbased: {
|
||||
minLevel: 1, startLevel: 2, maxLevel: 4,
|
||||
// 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,
|
||||
curveType: 'linear', logCurveFactor: 9,
|
||||
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user