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>
184 lines
8.0 KiB
JavaScript
184 lines
8.0 KiB
JavaScript
// Basic tests for FlowAggregator. Pure node:test, no Node-RED runtime.
|
||
|
||
const test = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
|
||
const { MeasurementContainer } = require('generalFunctions');
|
||
const FlowAggregator = require('../../src/measurement/flowAggregator');
|
||
|
||
function makeBasin() {
|
||
// Constant-cross-section basin: 50 m3 / 5 m height ⇒ surfaceArea = 10 m2.
|
||
const surfaceArea = 10;
|
||
return {
|
||
surfaceArea,
|
||
minVol: 2,
|
||
maxVol: 50,
|
||
maxVolAtOverflow: 45, // overflow at 4.5 m
|
||
minVolAtOutflow: 2,
|
||
minVolAtInflow: 30,
|
||
overflowLevel: 4.5,
|
||
outflowLevel: 0.2,
|
||
inflowLevel: 3,
|
||
};
|
||
}
|
||
|
||
function makeMeasurements() {
|
||
return new MeasurementContainer({
|
||
autoConvert: true,
|
||
preferredUnits: { flow: 'm3/s', netFlowRate: 'm3/s', level: 'm', volume: 'm3' },
|
||
});
|
||
}
|
||
|
||
function makeAggregator(overrides = {}) {
|
||
const measurements = overrides.measurements || makeMeasurements();
|
||
const basin = overrides.basin || makeBasin();
|
||
// Seed predicted volume at minVol so update() has a starting point.
|
||
measurements.type('volume').variant('predicted').position('atequipment')
|
||
.value(basin.minVol).unit('m3');
|
||
const fa = new FlowAggregator({ measurements, basin, flowThreshold: 1e-4 });
|
||
return { fa, measurements, basin };
|
||
}
|
||
|
||
test('FlowAggregator.update integrates inflow-outflow over delta-t', async () => {
|
||
const { fa, measurements } = makeAggregator();
|
||
// Net flow = 0.01 m3/s (in) - 0.005 m3/s (out) = 0.005 m3/s.
|
||
const t0 = Date.now() - 10_000; // 10 s ago
|
||
measurements.type('flow').variant('predicted').position('in').child('src')
|
||
.value(0.01, t0, 'm3/s');
|
||
measurements.type('flow').variant('predicted').position('out').child('snk')
|
||
.value(0.005, t0, 'm3/s');
|
||
|
||
// Force the integrator to know we are starting 10 s in the past.
|
||
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.005 * ~10 ≈ 2.05 m3. Allow slack for clock jitter.
|
||
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')
|
||
.value(0.02, Date.now(), 'm3/s');
|
||
measurements.type('flow').variant('measured').position('out').child('m')
|
||
.value(0.01, Date.now(), 'm3/s');
|
||
measurements.type('flow').variant('predicted').position('in').child('p')
|
||
.value(0.5, Date.now(), 'm3/s');
|
||
measurements.type('flow').variant('predicted').position('out').child('p')
|
||
.value(0.0, Date.now(), 'm3/s');
|
||
|
||
const r = fa.selectBestNetFlow();
|
||
assert.equal(r.source, 'measured');
|
||
assert.ok(Math.abs(r.value - 0.01) < 1e-9);
|
||
assert.equal(r.direction, 'filling');
|
||
});
|
||
|
||
test('FlowAggregator.selectBestNetFlow falls back to level rate when no flow', async () => {
|
||
const { fa, measurements, basin } = makeAggregator();
|
||
// Seed two level samples 2 s apart, rising 0.1 m → rate 0.05 m/s
|
||
// → net flow = 0.05 * 10 m2 = 0.5 m3/s (filling).
|
||
const t0 = Date.now() - 2_000;
|
||
const t1 = Date.now();
|
||
measurements.type('level').variant('measured').position('atequipment').child('default')
|
||
.value(1.0, t0, 'm');
|
||
measurements.type('level').variant('measured').position('atequipment').child('default')
|
||
.value(1.1, t1, 'm');
|
||
|
||
const r = fa.selectBestNetFlow();
|
||
assert.ok(r.source.startsWith('level:'), `source was ${r.source}`);
|
||
assert.equal(r.direction, 'filling');
|
||
assert.ok(Math.abs(r.value - basin.surfaceArea * 0.05) < 1e-3, `net flow was ${r.value}`);
|
||
});
|
||
|
||
test('FlowAggregator.deriveDirection threshold semantics', async () => {
|
||
const { fa } = makeAggregator();
|
||
assert.equal(fa.deriveDirection(0), 'steady');
|
||
assert.equal(fa.deriveDirection(fa.flowThreshold * 2), 'filling');
|
||
assert.equal(fa.deriveDirection(-fa.flowThreshold * 2), 'draining');
|
||
assert.equal(fa.deriveDirection(fa.flowThreshold * 0.5), 'steady');
|
||
assert.equal(fa.deriveDirection(-fa.flowThreshold * 0.5), 'steady');
|
||
});
|
||
|
||
test('FlowAggregator.computeRemainingTime — filling uses overflow ceiling', async () => {
|
||
const { fa, measurements, basin } = makeAggregator();
|
||
measurements.type('level').variant('predicted').position('atequipment')
|
||
.value(2.0, Date.now(), 'm');
|
||
// Net 0.05 m3/s upward; remaining height = 4.5 - 2.0 = 2.5 m.
|
||
// seconds = 2.5 * 10 / 0.05 = 500 s.
|
||
const r = fa.computeRemainingTime({ value: 0.05, source: 'measured', direction: 'filling' });
|
||
assert.ok(Math.abs(r.seconds - 500) < 1e-6, `seconds was ${r.seconds}`);
|
||
assert.equal(typeof r.source, 'string');
|
||
});
|
||
|
||
test('FlowAggregator.computeRemainingTime — draining uses outflow floor', async () => {
|
||
const { fa, measurements } = makeAggregator();
|
||
measurements.type('level').variant('predicted').position('atequipment')
|
||
.value(1.0, Date.now(), 'm');
|
||
// Net -0.05 m3/s; remaining height = 1.0 - 0.2 = 0.8 m.
|
||
// seconds = 0.8 * 10 / 0.05 = 160 s.
|
||
const r = fa.computeRemainingTime({ value: -0.05, source: 'measured', direction: 'draining' });
|
||
assert.ok(Math.abs(r.seconds - 160) < 1e-6, `seconds was ${r.seconds}`);
|
||
});
|
||
|
||
test('FlowAggregator.snapshot exposes the expected shape', async () => {
|
||
const { fa, measurements } = makeAggregator();
|
||
measurements.type('flow').variant('measured').position('in').child('m')
|
||
.value(0.02, Date.now(), 'm3/s');
|
||
fa.tick();
|
||
const snap = fa.snapshot();
|
||
assert.ok(Object.prototype.hasOwnProperty.call(snap, 'direction'));
|
||
assert.ok(Object.prototype.hasOwnProperty.call(snap, 'netFlow'));
|
||
assert.ok(Object.prototype.hasOwnProperty.call(snap, 'flowSource'));
|
||
assert.ok(Object.prototype.hasOwnProperty.call(snap, 'secondsRemaining'));
|
||
});
|
||
|
||
test('FlowAggregator.computeRemainingTime — below threshold returns null seconds', async () => {
|
||
const { fa } = makeAggregator();
|
||
const r = fa.computeRemainingTime({ value: 0, source: null, direction: 'steady' });
|
||
assert.equal(r.seconds, null);
|
||
});
|