// 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.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); });