2026-05-10 20:18:49 +02:00
|
|
|
|
// 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}`);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
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
|
|
|
|
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}`);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-10 20:18:49 +02:00
|
|
|
|
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);
|
|
|
|
|
|
});
|