Splits pumpingStation/src/ into focused concern modules. specificClass.js
will be slimmed to an orchestrator in P2.9 (integration); for now both
the inlined logic AND the new modules coexist so tests stay green
throughout.
src/basin/ BasinGeometry + thresholdValidator (pure)
src/measurement/ flowAggregator + measurementRouter + calibration
src/control/ levelBased + flowBased(stub) + manual + index dispatcher
src/safety/ safetyController split into dryRun + overfill rules
src/commands/ registry array + handlers (canonical names from start)
src/editor.js 260 lines of SVG basin-diagram redraw, was inline in .html
examples/standalone-demo.js was if(require.main===module) at bottom of specificClass.js
CONTRACT.md canonical inputs + outputs + emitted events
Modified:
src/specificClass.js removed the 170-line standalone demo block
pumpingStation.html oneditprepare/oneditsave delegate to editor.{init,save}
pumpingStation.js added admin endpoint serving src/editor.js
102 basic tests pass (60 new + 42 existing).
specificClass.js itself is unchanged in behaviour — integration is P2.9.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
142 lines
6.0 KiB
JavaScript
142 lines
6.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.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);
|
|
});
|