P2 wave 1: extract concerns from pumpingStation specificClass

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>
This commit is contained in:
znetsixe
2026-05-10 20:18:49 +02:00
parent da50403c76
commit 7afcd6e54a
27 changed files with 2533 additions and 463 deletions

View File

@@ -0,0 +1,106 @@
// Basic unit tests for BasinGeometry.
// Run with: node --test test/basic/BasinGeometry.basic.test.js
const test = require('node:test');
const assert = require('node:assert/strict');
const BasinGeometry = require('../../src/basin/BasinGeometry');
function makeBasin(overrides = {}) {
const basin = {
volume: 50,
height: 5,
inflowLevel: 3,
outflowLevel: 0.2,
overflowLevel: 4.5,
...overrides.basin,
};
const hydraulics = {
minHeightBasedOn: 'outlet',
...overrides.hydraulics,
};
return new BasinGeometry(basin, hydraulics);
}
test('constructor produces correct surfaceArea = volume / height', () => {
const g = makeBasin();
assert.equal(g.surfaceArea, 10); // 50 / 5
assert.equal(g.heightBasin, 5);
assert.equal(g.volEmptyBasin, 50);
});
test('maxVolAtOverflow equals overflowLevel × surfaceArea', () => {
const g = makeBasin();
assert.equal(g.maxVolAtOverflow, 4.5 * 10); // 45
assert.equal(g.minVolAtInflow, 3 * 10); // 30
assert.equal(g.minVolAtOutflow, 0.2 * 10); // 2
assert.equal(g.maxVol, 50);
});
test("minVol selects outlet-based when minHeightBasedOn = 'outlet'", () => {
const g = makeBasin();
assert.equal(g.minVol, g.minVolAtOutflow);
assert.equal(g.minHeightBasedOn, 'outlet');
});
test("minVol selects inlet-based when minHeightBasedOn = 'inlet'", () => {
const g = makeBasin({ hydraulics: { minHeightBasedOn: 'inlet' } });
assert.equal(g.minVol, g.minVolAtInflow);
assert.equal(g.minHeightBasedOn, 'inlet');
});
test('volumeFromLevel(0) returns 0; negative level clamps to 0', () => {
const g = makeBasin();
assert.equal(g.volumeFromLevel(0), 0);
assert.equal(g.volumeFromLevel(-1), 0);
assert.equal(g.volumeFromLevel(-1e9), 0);
});
test('volumeFromLevel(positive) is level × surfaceArea', () => {
const g = makeBasin();
assert.equal(g.volumeFromLevel(2.5), 25);
assert.equal(g.volumeFromLevel(5), 50);
});
test('levelFromVolume(maxVol) returns heightBasin', () => {
const g = makeBasin();
assert.equal(g.levelFromVolume(g.maxVol), g.heightBasin);
});
test('levelFromVolume(0) returns 0; negative volume clamps to 0', () => {
const g = makeBasin();
assert.equal(g.levelFromVolume(0), 0);
assert.equal(g.levelFromVolume(-10), 0);
});
test('round-trip: volumeFromLevel(levelFromVolume(v)) ≈ v for v in range', () => {
const g = makeBasin();
for (const v of [0, 0.001, 1, 12.34, 25, 49.999, 50]) {
const back = g.volumeFromLevel(g.levelFromVolume(v));
assert.ok(Math.abs(back - v) < 1e-9, `round-trip failed for v=${v}, got ${back}`);
}
});
test('round-trip: levelFromVolume(volumeFromLevel(L)) ≈ L for L in range', () => {
const g = makeBasin();
for (const L of [0, 0.05, 1, 2.5, 4.5, 5]) {
const back = g.levelFromVolume(g.volumeFromLevel(L));
assert.ok(Math.abs(back - L) < 1e-9, `round-trip failed for L=${L}, got ${back}`);
}
});
test('snapshot() exposes legacy this.basin field names', () => {
const g = makeBasin();
const s = g.snapshot();
const expectedKeys = [
'volEmptyBasin', 'heightBasin', 'inflowLevel', 'outflowLevel',
'overflowLevel', 'surfaceArea', 'maxVol', 'maxVolAtOverflow',
'minVolAtInflow', 'minVolAtOutflow', 'minVol', 'minHeightBasedOn',
];
for (const k of expectedKeys) {
assert.ok(k in s, `snapshot missing key: ${k}`);
}
assert.equal(s.volEmptyBasin, 50);
assert.equal(s.surfaceArea, 10);
assert.equal(s.minHeightBasedOn, 'outlet');
});

View File

@@ -0,0 +1,106 @@
// Basic tests for the calibration helpers.
const test = require('node:test');
const assert = require('node:assert/strict');
const { MeasurementContainer } = require('generalFunctions');
const {
calibratePredictedVolume,
calibratePredictedLevel,
setManualInflow,
} = require('../../src/measurement/calibration');
function makeBasin() {
return {
surfaceArea: 10,
minVol: 2,
maxVol: 50,
maxVolAtOverflow: 45,
overflowLevel: 4.5,
outflowLevel: 0.2,
inflowLevel: 3,
};
}
function makeCtx(seedVolume = null) {
const measurements = new MeasurementContainer({
autoConvert: true,
preferredUnits: { flow: 'm3/s', level: 'm', volume: 'm3' },
});
const basin = makeBasin();
if (seedVolume != null) {
measurements.type('volume').variant('predicted').position('atequipment')
.value(seedVolume, Date.now() - 5_000, 'm3').unit('m3');
}
const ctx = { measurements, basin };
return ctx;
}
test('calibratePredictedVolume clears prior series and writes new value', async () => {
const ctx = makeCtx(12);
const before = ctx.measurements.type('volume').variant('predicted').position('atequipment')
.getCurrentValue('m3');
assert.ok(Math.abs(before - 12) < 1e-9);
const ts = Date.now();
calibratePredictedVolume(ctx, 30, ts);
const m = ctx.measurements.type('volume').variant('predicted').position('atequipment').get();
assert.equal(m.values.length, 1, 'series should hold exactly the calibration point');
assert.ok(Math.abs(m.getCurrentValue() - 30) < 1e-9);
// Level was derived: 30 / 10 = 3 m.
const lvl = ctx.measurements.type('level').variant('predicted').position('atequipment')
.getCurrentValue('m');
assert.ok(Math.abs(lvl - 3) < 1e-9, `derived level was ${lvl}`);
assert.equal(ctx._predictedFlowState.lastTimestamp, ts);
assert.equal(ctx._predictedFlowState.inflow, 0);
assert.equal(ctx._predictedFlowState.outflow, 0);
});
test('calibratePredictedLevel writes both level and derived volume', async () => {
const ctx = makeCtx(2);
calibratePredictedLevel(ctx, 4.0, Date.now(), 'm');
const lvl = ctx.measurements.type('level').variant('predicted').position('atequipment')
.getCurrentValue('m');
assert.ok(Math.abs(lvl - 4.0) < 1e-9);
const vol = ctx.measurements.type('volume').variant('predicted').position('atequipment')
.getCurrentValue('m3');
assert.ok(Math.abs(vol - 40) < 1e-9, `derived volume was ${vol}`);
});
test('setManualInflow writes to flow.predicted.in.manual-qin', async () => {
const ctx = makeCtx();
const ts = Date.now();
setManualInflow(ctx, 0.025, ts, 'm3/s');
const series = ctx.measurements.type('flow').variant('predicted').position('in').child('manual-qin');
const val = series.getCurrentValue('m3/s');
assert.ok(Math.abs(val - 0.025) < 1e-9, `manual-qin value was ${val}`);
// It must NOT collide with the default child bucket.
const defaultBucket = ctx.measurements.measurements?.flow?.predicted?.in?.default;
assert.equal(defaultBucket, undefined);
});
test('calibration uses ctx.flowAggregator.resetState when present', async () => {
const ctx = makeCtx(5);
let resetCalled = null;
ctx.flowAggregator = { resetState: (ts) => { resetCalled = ts; } };
const ts = 1234567890;
calibratePredictedVolume(ctx, 20, ts);
assert.equal(resetCalled, ts);
// The plain bag should NOT be touched when the aggregator hook is present.
assert.equal(ctx._predictedFlowState, undefined);
});
test('calibratePredictedVolume rejects bad context', async () => {
assert.throws(() => calibratePredictedVolume({}, 10));
assert.throws(() => calibratePredictedLevel({}, 1.0));
assert.throws(() => setManualInflow({}, 0.01));
});

View File

@@ -0,0 +1,178 @@
// Basic tests for the pumpingStation commands registry.
// Run with: node --test test/basic/commands.basic.test.js
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { createRegistry } = require('generalFunctions');
const commands = require('../../src/commands');
// --- helpers ---------------------------------------------------------------
function makeLogger() {
const calls = { warn: [], error: [], info: [], debug: [] };
return {
calls,
warn: (m) => calls.warn.push(String(m)),
error: (m) => calls.error.push(String(m)),
info: (m) => calls.info.push(String(m)),
debug: (m) => calls.debug.push(String(m)),
};
}
function makeSource({ mode = 'manual' } = {}) {
const calls = {
changeMode: [],
calibratePredictedVolume: [],
calibratePredictedLevel: [],
setManualInflow: [],
forwardDemandToChildren: [],
registerChild: [],
};
const source = {
mode,
logger: makeLogger(),
changeMode: (m) => calls.changeMode.push(m),
calibratePredictedVolume: (v) => calls.calibratePredictedVolume.push(v),
calibratePredictedLevel: (v) => calls.calibratePredictedLevel.push(v),
setManualInflow: (v, ts, u) => calls.setManualInflow.push({ v, ts, u }),
forwardDemandToChildren: async (d) => { calls.forwardDemandToChildren.push(d); },
childRegistrationUtils: {
registerChild: (childSource, position) =>
calls.registerChild.push({ childSource, position }),
},
};
return { source, calls };
}
function makeCtx({ child = null, logger = makeLogger() } = {}) {
return {
logger,
RED: { nodes: { getNode: (id) => (child && child.id === id ? child : undefined) } },
node: {},
send: () => {},
};
}
function makeRegistry(logger) {
return createRegistry(commands, { logger });
}
// --- tests -----------------------------------------------------------------
test('canonical topics dispatch to their handlers', async () => {
const { source, calls } = makeSource();
const reg = makeRegistry(makeLogger());
await reg.dispatch({ topic: 'set.mode', payload: 'levelbased' }, source, makeCtx());
assert.deepEqual(calls.changeMode, ['levelbased']);
await reg.dispatch({ topic: 'cmd.calibrate.volume', payload: '12.5' }, source, makeCtx());
assert.deepEqual(calls.calibratePredictedVolume, [12.5]);
await reg.dispatch({ topic: 'cmd.calibrate.level', payload: 1.25 }, source, makeCtx());
assert.deepEqual(calls.calibratePredictedLevel, [1.25]);
await reg.dispatch({ topic: 'set.inflow', payload: 0.5, unit: 'm3/s' }, source, makeCtx());
assert.equal(calls.setManualInflow.length, 1);
assert.equal(calls.setManualInflow[0].v, 0.5);
assert.equal(calls.setManualInflow[0].u, 'm3/s');
await reg.dispatch({ topic: 'set.demand', payload: 100 }, source, makeCtx());
assert.deepEqual(calls.forwardDemandToChildren, [100]);
});
test('child.register canonical resolves child via RED.nodes.getNode', async () => {
const { source, calls } = makeSource();
const child = { id: 'child-1', source: { tag: 'child-domain' } };
const reg = makeRegistry(makeLogger());
await reg.dispatch(
{ topic: 'child.register', payload: 'child-1', positionVsParent: 'upstream' },
source,
makeCtx({ child })
);
assert.equal(calls.registerChild.length, 1);
assert.equal(calls.registerChild[0].childSource, child.source);
assert.equal(calls.registerChild[0].position, 'upstream');
});
test('aliases dispatch to the same handler and log a one-time deprecation', async () => {
const { source, calls } = makeSource();
const ctxLogger = makeLogger();
const reg = makeRegistry(ctxLogger);
await reg.dispatch({ topic: 'changemode', payload: 'manual' }, source, makeCtx({ logger: ctxLogger }));
await reg.dispatch({ topic: 'changemode', payload: 'manual' }, source, makeCtx({ logger: ctxLogger }));
assert.deepEqual(calls.changeMode, ['manual', 'manual']);
const deprecWarns = ctxLogger.calls.warn.filter((m) => m.includes("'changemode' is deprecated"));
assert.equal(deprecWarns.length, 1, 'deprecation warning should log exactly once');
assert.equal(reg.deprecationStats().changemode, 2);
// q_in alias also routes to setInflow.
await reg.dispatch({ topic: 'q_in', payload: 0.25, unit: 'm3/s' }, source, makeCtx({ logger: ctxLogger }));
assert.equal(calls.setManualInflow.length, 1);
});
test('child.register with unknown child id logs warn and does not throw', async () => {
const { source, calls } = makeSource();
const ctxLogger = makeLogger();
const reg = makeRegistry(makeLogger());
await assert.doesNotReject(() =>
reg.dispatch(
{ topic: 'child.register', payload: 'missing-id', positionVsParent: 'atEquipment' },
source,
makeCtx({ logger: ctxLogger })
)
);
assert.equal(calls.registerChild.length, 0);
assert.ok(
ctxLogger.calls.warn.some((m) => m.includes('registerChild') && m.includes('missing-id')),
`expected warn about missing child, got: ${JSON.stringify(ctxLogger.calls.warn)}`
);
});
test('set.inflow accepts number payload and { value, unit, timestamp } object payload', async () => {
const { source, calls } = makeSource();
const reg = makeRegistry(makeLogger());
await reg.dispatch({ topic: 'set.inflow', payload: 0.5, unit: 'm3/s', timestamp: 1000 }, source, makeCtx());
assert.deepEqual(calls.setManualInflow[0], { v: 0.5, ts: 1000, u: 'm3/s' });
await reg.dispatch(
{ topic: 'set.inflow', payload: { value: 2, unit: 'm3/h', timestamp: 2000 } },
source,
makeCtx()
);
assert.deepEqual(calls.setManualInflow[1], { v: 2, ts: 2000, u: 'm3/h' });
});
test('set.demand in non-manual mode logs debug and does not call forwardDemandToChildren', async () => {
const { source, calls } = makeSource({ mode: 'levelbased' });
const ctxLogger = makeLogger();
const reg = makeRegistry(makeLogger());
await reg.dispatch({ topic: 'set.demand', payload: 50 }, source, makeCtx({ logger: ctxLogger }));
assert.equal(calls.forwardDemandToChildren.length, 0);
assert.ok(
ctxLogger.calls.debug.some((m) => m.includes('set.demand') && m.includes('levelbased')),
`expected debug about ignoring demand, got: ${JSON.stringify(ctxLogger.calls.debug)}`
);
});
test('set.demand with non-numeric payload logs warn and does not call', async () => {
const { source, calls } = makeSource({ mode: 'manual' });
const ctxLogger = makeLogger();
const reg = makeRegistry(makeLogger());
await reg.dispatch({ topic: 'set.demand', payload: 'oops' }, source, makeCtx({ logger: ctxLogger }));
assert.equal(calls.forwardDemandToChildren.length, 0);
assert.ok(
ctxLogger.calls.warn.some((m) => m.includes('set.demand') && m.includes('oops')),
`expected warn about invalid Qd, got: ${JSON.stringify(ctxLogger.calls.warn)}`
);
});

View File

@@ -0,0 +1,126 @@
// Unit tests for the level-based control strategy.
// Run with: node --test test/basic/control-levelBased.basic.test.js
const test = require('node:test');
const assert = require('node:assert/strict');
const levelBased = require('../../src/control/levelBased');
function makeMeasurements(levelMeters) {
// Minimal MeasurementContainer stand-in. The strategy only calls
// getUnit('level') and a chain ending in getCurrentValue(unit).
const chain = {
type() { return chain; },
variant() { return chain; },
position() { return chain; },
getCurrentValue() {
return Number.isFinite(levelMeters) ? levelMeters : null;
},
};
return {
getUnit: () => 'm',
type: () => chain,
};
}
function makeGroup(name) {
const calls = { handleInput: [], turnOff: 0 };
return {
config: { general: { name } },
handleInput: async (...args) => { calls.handleInput.push(args); },
turnOffAllMachines: () => { calls.turnOff += 1; },
_calls: calls,
};
}
function makeCtx(levelMeters, opts = {}) {
const groups = {
a: makeGroup('A'),
b: makeGroup('B'),
c: makeGroup('C'),
};
return {
measurements: makeMeasurements(levelMeters),
config: {
control: { levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, ...(opts.levelbased || {}) } },
},
logger: { warn: () => {}, debug: () => {}, info: () => {}, error: () => {} },
machineGroups: groups,
machines: {},
levelVariants: ['measured', 'predicted'],
};
}
test('level < minLevel → STOP: turnOffAllMachines on every group, percControl = 0', async () => {
const ctx = makeCtx(0.5);
const state = { percControl: 42 };
await levelBased.run(ctx, state);
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');
}
});
test('minLevel ≤ level < startLevel → DEAD ZONE: no calls, percControl unchanged', async () => {
const ctx = makeCtx(1.5);
const state = { percControl: 17 };
await levelBased.run(ctx, state);
assert.equal(state.percControl, 17, 'percControl untouched in dead zone');
for (const g of Object.values(ctx.machineGroups)) {
assert.equal(g._calls.turnOff, 0);
assert.equal(g._calls.handleInput.length, 0);
}
});
test('level == startLevel → percControl == 0 (lower edge of ramp)', async () => {
const ctx = makeCtx(2);
const state = { percControl: null };
await levelBased.run(ctx, state);
assert.equal(state.percControl, 0);
});
test('level == maxLevel → percControl == 100 (upper edge of ramp)', async () => {
const ctx = makeCtx(4);
const state = { percControl: null };
await levelBased.run(ctx, state);
assert.equal(state.percControl, 100);
});
test('level above maxLevel → percControl clamped at 100 (interpolation limit_input behaviour)', async () => {
const ctx = makeCtx(10);
const state = { percControl: null };
await levelBased.run(ctx, state);
// interpolate_lin_single_point clamps via limit_input(o_min, o_max).
assert.equal(state.percControl, 100);
});
test('percControl forwarded to every group via handleInput("parent", percControl)', 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.turnOff, 0);
}
});
test('no valid level → warns and returns without mutating percControl or calling groups', async () => {
const ctx = makeCtx(NaN);
let warned = false;
ctx.logger.warn = () => { warned = true; };
const state = { percControl: 7 };
await levelBased.run(ctx, state);
assert.equal(warned, true);
assert.equal(state.percControl, 7);
for (const g of Object.values(ctx.machineGroups)) {
assert.equal(g._calls.turnOff, 0);
assert.equal(g._calls.handleInput.length, 0);
}
});

View File

@@ -0,0 +1,64 @@
// Unit tests for the manual control strategy.
// Run with: node --test test/basic/control-manual.basic.test.js
const test = require('node:test');
const assert = require('node:assert/strict');
const manual = require('../../src/control/manual');
function makeGroup(name) {
const calls = { handleInput: [] };
return {
config: { general: { name } },
handleInput: async (...args) => { calls.handleInput.push(args); },
_calls: calls,
};
}
function makeMachine(name) {
const calls = { handleInput: [] };
return {
config: { general: { name } },
handleInput: async (...args) => { calls.handleInput.push(args); },
_calls: calls,
};
}
function makeLogger() {
return { info: () => {}, debug: () => {}, warn: () => {}, error: () => {} };
}
test('forwardDemand calls handleInput("parent", demand) on every machine group', async () => {
const groups = { a: makeGroup('A'), b: makeGroup('B'), c: makeGroup('C') };
const ctx = { machineGroups: groups, machines: {}, logger: makeLogger() };
await manual.forwardDemand(ctx, 50);
for (const g of Object.values(groups)) {
assert.equal(g._calls.handleInput.length, 1);
assert.deepEqual(g._calls.handleInput[0], ['parent', 50]);
}
});
test('forwardDemand with no machineGroups but direct machines splits demand evenly', async () => {
const machines = { m1: makeMachine('M1'), m2: makeMachine('M2'), m3: makeMachine('M3'), m4: makeMachine('M4') };
const ctx = { machineGroups: {}, machines, logger: makeLogger() };
await manual.forwardDemand(ctx, 80);
for (const m of Object.values(machines)) {
assert.equal(m._calls.handleInput.length, 1);
assert.deepEqual(m._calls.handleInput[0], ['parent', 'execMovement', 20]);
}
});
test('run() is a no-op (manual mode is event-driven)', async () => {
const groups = { a: makeGroup('A') };
const ctx = { machineGroups: groups, machines: {}, logger: makeLogger() };
await manual.run(ctx, { percControl: 0 });
assert.equal(groups.a._calls.handleInput.length, 0);
});
test('manual exports name === "manual"', () => {
assert.equal(manual.name, 'manual');
});

View File

@@ -0,0 +1,141 @@
// 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);
});

View File

@@ -0,0 +1,106 @@
// Basic tests for MeasurementRouter.
const test = require('node:test');
const assert = require('node:assert/strict');
const { MeasurementContainer, coolprop } = require('generalFunctions');
const MeasurementRouter = require('../../src/measurement/measurementRouter');
// CoolProp is async-init; ensure it's warm before any pressure-conversion
// test runs.
test.before(async () => {
await coolprop.init({ refrigerant: 'Water' });
});
function makeBasin() {
return {
surfaceArea: 10,
minVol: 2,
maxVol: 50,
maxVolAtOverflow: 45,
overflowLevel: 4.5,
outflowLevel: 0.2,
inflowLevel: 3,
};
}
function makeMeasurements() {
return new MeasurementContainer({
autoConvert: true,
preferredUnits: { flow: 'm3/s', level: 'm', volume: 'm3' },
});
}
function fakeLogger() {
const calls = { warn: [], info: [], error: [], debug: [] };
return {
warn: (m) => calls.warn.push(m),
info: (m) => calls.info.push(m),
error: (m) => calls.error.push(m),
debug: (m) => calls.debug.push(m),
_calls: calls,
};
}
test('onLevelMeasurement writes volume + percent', async () => {
const measurements = makeMeasurements();
const basin = makeBasin();
const router = new MeasurementRouter({ measurements, basin });
router.onLevelMeasurement('atequipment', 2.5, { unit: 'm', timestamp: Date.now() });
const lvl = measurements.type('level').variant('measured').position('atequipment').getCurrentValue('m');
assert.ok(Math.abs(lvl - 2.5) < 1e-9);
const vol = measurements.type('volume').variant('measured').position('atequipment').getCurrentValue('m3');
// 2.5 m * 10 m² = 25 m3.
assert.ok(Math.abs(vol - 25) < 1e-9, `volume was ${vol}`);
const pct = measurements.type('volumePercent').variant('measured').position('atequipment').getCurrentValue('%');
// (25 - 2) / (45 - 2) * 100 ≈ 53.488...
assert.ok(pct > 53 && pct < 54, `percent was ${pct}`);
});
test('onPressureMeasurement falls back to assumed temperature and warns', async () => {
const measurements = makeMeasurements();
const basin = makeBasin();
const logger = fakeLogger();
const router = new MeasurementRouter({ measurements, basin, logger });
// No temperature seeded — must fall back to assumed 15C.
measurements.type('pressure').variant('measured').position('atequipment')
.value(20000, Date.now(), 'Pa');
router.onPressureMeasurement('atequipment', 20000, { unit: 'Pa', timestamp: Date.now() });
const warned = logger._calls.warn.some((m) => /assuming 15C|temperature/i.test(m));
assert.ok(warned, 'expected a warn about missing temperature');
const assumedT = measurements.type('temperature').variant('assumed').position('atequipment')
.getCurrentValue('K');
assert.ok(Number.isFinite(assumedT), 'assumed temperature was not stored');
const lvl = measurements.type('level').variant('predicted').position('atequipment')
.getCurrentValue('m');
// 20000 Pa / (~999 kg/m³ * 9.80665) ≈ 2.04 m.
assert.ok(lvl > 1.9 && lvl < 2.2, `derived level was ${lvl}`);
});
test('route() dispatches by measurement type', async () => {
const measurements = makeMeasurements();
const basin = makeBasin();
const router = new MeasurementRouter({ measurements, basin });
const handledLevel = router.route('level', 1.5, 'atequipment', { unit: 'm' });
assert.equal(handledLevel, true);
const lvl = measurements.type('level').variant('measured').position('atequipment').getCurrentValue('m');
assert.ok(Math.abs(lvl - 1.5) < 1e-9);
// Unknown type returns false (no dispatch).
const handledOther = router.route('flow', 0.1, 'in', {});
assert.equal(handledOther, false);
});
test('constructor rejects missing context fields', async () => {
assert.throws(() => new MeasurementRouter({}));
assert.throws(() => new MeasurementRouter({ measurements: makeMeasurements() }));
});

View File

@@ -0,0 +1,230 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const SafetyController = require('../../src/safety/safetyController');
// --------------------------- fakes ---------------------------
function fakeMeasurements(values) {
// values keyed by `${type}.${variant}.${position}` → number|null
return {
getUnit: (_type) => 'm3',
type(t) {
return {
variant(v) {
return {
position(p) {
return {
getCurrentValue() {
const k = `${t}.${v}.${p}`;
return values[k];
},
};
},
};
},
};
},
};
}
function makeMachine(positionVsParent, operational = true) {
const calls = [];
return {
config: { functionality: { positionVsParent } },
_isOperationalState: () => operational,
handleInput: (...args) => calls.push(args),
calls,
};
}
function makeStation() {
const calls = [];
return {
handleInput: (...args) => calls.push(args),
calls,
};
}
function makeGroup() {
const calls = [];
return {
turnOffAllMachines: () => calls.push(['turnOffAllMachines']),
calls,
};
}
function makeLogger() {
const warns = [];
return {
warn: (msg) => warns.push(msg),
info: () => {},
error: () => {},
debug: () => {},
warns,
};
}
function makeCtx({
vol = 50,
basin = { minVol: 10, maxVolAtOverflow: 90 },
safety = {
enableDryRunProtection: true,
enableOverfillProtection: true,
dryRunThresholdPercent: 10,
overfillThresholdPercent: 95,
timeleftToFullOrEmptyThresholdSeconds: 0,
},
machines = {},
stations = {},
machineGroups = {},
} = {}) {
const measurements = fakeMeasurements({
'volume.measured.atequipment': vol,
'volume.predicted.atequipment': vol,
});
const logger = makeLogger();
return {
ctx: { measurements, basin, config: { safety }, logger, machines, stations, machineGroups },
logger,
};
}
// --------------------------- tests ---------------------------
test('normal volume + filling → not blocked, no shutdowns', () => {
const m = makeMachine('downstream');
const { ctx } = makeCtx({ vol: 50, machines: { m } });
const sc = new SafetyController(ctx);
const r = sc.evaluate({ direction: 'filling', secondsRemaining: 1000 });
assert.deepStrictEqual(r, { blocked: false, reason: null, triggered: [] });
assert.strictEqual(m.calls.length, 0);
});
test('dry-run trigger: low volume + draining → blocked, downstream shut down', () => {
const down = makeMachine('downstream');
const at = makeMachine('atequipment');
const up = makeMachine('upstream');
const station = makeStation();
const group = makeGroup();
const { ctx } = makeCtx({
vol: 5, // below 10 * (1 + 10/100) = 11
machines: { down, at, up },
stations: { station },
machineGroups: { group },
});
const sc = new SafetyController(ctx);
const r = sc.evaluate({ direction: 'draining', secondsRemaining: 1000 });
assert.strictEqual(r.blocked, true);
assert.strictEqual(r.reason, 'dry-run');
assert.ok(r.triggered.includes('dry-run-volume'));
assert.deepStrictEqual(down.calls[0], ['parent', 'execSequence', 'shutdown']);
assert.deepStrictEqual(at.calls[0], ['parent', 'execSequence', 'shutdown']);
assert.strictEqual(up.calls.length, 0, 'upstream untouched in dry-run');
assert.deepStrictEqual(station.calls[0], ['parent', 'execSequence', 'shutdown']);
assert.deepStrictEqual(group.calls[0], ['turnOffAllMachines']);
});
test('dry-run does NOT trigger when filling', () => {
const down = makeMachine('downstream');
const { ctx } = makeCtx({ vol: 5, machines: { down } });
const sc = new SafetyController(ctx);
const r = sc.evaluate({ direction: 'filling', secondsRemaining: 1000 });
// Filling at vol=5 (below overfill threshold 85.5) → no trigger at all.
assert.strictEqual(r.blocked, false);
assert.strictEqual(r.reason, null);
assert.strictEqual(down.calls.length, 0);
});
test('overfill trigger: high volume + filling → not blocked, only upstream + station shut down', () => {
const down = makeMachine('downstream');
const at = makeMachine('atequipment');
const up = makeMachine('upstream');
const station = makeStation();
const group = makeGroup();
const { ctx } = makeCtx({
vol: 88, // above 90 * 0.95 = 85.5
machines: { down, at, up },
stations: { station },
machineGroups: { group },
});
const sc = new SafetyController(ctx);
const r = sc.evaluate({ direction: 'filling', secondsRemaining: 1000 });
assert.strictEqual(r.blocked, false, 'overfill must NOT block control');
assert.strictEqual(r.reason, 'overfill');
assert.ok(r.triggered.includes('overfill-volume'));
assert.deepStrictEqual(up.calls[0], ['parent', 'execSequence', 'shutdown']);
assert.strictEqual(down.calls.length, 0, 'downstream must keep running');
assert.strictEqual(at.calls.length, 0, 'atequipment must keep running');
assert.deepStrictEqual(station.calls[0], ['parent', 'execSequence', 'shutdown']);
assert.strictEqual(group.calls.length, 0, 'machine groups must keep draining');
});
test('no volume data → blocked, all machines shut down (panic)', () => {
const a = makeMachine('downstream');
const b = makeMachine('upstream');
const c = makeMachine('atequipment');
// override measurements to return null
const measurements = {
getUnit: () => 'm3',
type: () => ({ variant: () => ({ position: () => ({ getCurrentValue: () => null }) }) }),
};
const ctx = {
measurements,
basin: { minVol: 10, maxVolAtOverflow: 90 },
config: { safety: { enableDryRunProtection: true, enableOverfillProtection: true, dryRunThresholdPercent: 10, overfillThresholdPercent: 95 } },
logger: makeLogger(),
machines: { a, b, c },
stations: {},
machineGroups: {},
};
const sc = new SafetyController(ctx);
const r = sc.evaluate({ direction: 'steady', secondsRemaining: null });
assert.strictEqual(r.blocked, true);
assert.strictEqual(r.reason, 'no-volume-data');
assert.deepStrictEqual(a.calls[0], ['parent', 'execSequence', 'shutdown']);
assert.deepStrictEqual(b.calls[0], ['parent', 'execSequence', 'shutdown']);
assert.deepStrictEqual(c.calls[0], ['parent', 'execSequence', 'shutdown']);
});
test('time-based protection: short remainingTime while draining triggers dry-run shutdowns', () => {
const down = makeMachine('downstream');
const { ctx } = makeCtx({
vol: 50, // well above dry-run vol threshold
safety: {
enableDryRunProtection: false, // volume rule disabled
enableOverfillProtection: false,
dryRunThresholdPercent: 10,
overfillThresholdPercent: 95,
timeleftToFullOrEmptyThresholdSeconds: 60,
},
machines: { down },
});
const sc = new SafetyController(ctx);
const r = sc.evaluate({ direction: 'draining', secondsRemaining: 30 });
assert.strictEqual(r.blocked, true);
assert.strictEqual(r.reason, 'dry-run');
assert.ok(r.triggered.includes('time-remaining'));
assert.deepStrictEqual(down.calls[0], ['parent', 'execSequence', 'shutdown']);
});
test('disabled rules: enableDryRunProtection=false + draining low → no trigger', () => {
const down = makeMachine('downstream');
const { ctx } = makeCtx({
vol: 5, // would normally trigger dry-run
safety: {
enableDryRunProtection: false,
enableOverfillProtection: false,
dryRunThresholdPercent: 10,
overfillThresholdPercent: 95,
timeleftToFullOrEmptyThresholdSeconds: 0,
},
machines: { down },
});
const sc = new SafetyController(ctx);
const r = sc.evaluate({ direction: 'draining', secondsRemaining: 1000 });
assert.strictEqual(r.blocked, false);
assert.strictEqual(r.reason, null);
assert.strictEqual(down.calls.length, 0);
});

View File

@@ -0,0 +1,123 @@
// Basic unit tests for thresholdValidator.
// Run with: node --test test/basic/thresholdValidator.basic.test.js
const test = require('node:test');
const assert = require('node:assert/strict');
const { validateThresholdOrdering } = require('../../src/basin/thresholdValidator');
const BasinGeometry = require('../../src/basin/BasinGeometry');
// A valid baseline: outlet 0.2 < inflow 3 < overflow 4.5 ≤ height 5,
// dryRun = 0.2 * 1.10 = 0.22 ≤ minLevel 1 ≤ start 2 < max 4 ≤ overfill 4.275.
function validBasinAndCfg() {
const basin = new BasinGeometry(
{ volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5 },
{ minHeightBasedOn: 'outlet' }
);
const levelbased = { minLevel: 1, startLevel: 2, maxLevel: 4 };
const safety = { dryRunThresholdPercent: 10, overfillThresholdPercent: 95 };
return { basin, levelbased, safety };
}
test('valid ordering returns empty array', () => {
const { basin, levelbased, safety } = validBasinAndCfg();
const issues = validateThresholdOrdering(basin, levelbased, safety);
assert.deepEqual(issues, []);
});
test('outflowLevel >= inflowLevel triggers issue with correct shape', () => {
const basin = new BasinGeometry(
// outflow 3.5 > inflow 3 — invariant broken.
{ volume: 50, height: 5, inflowLevel: 3, outflowLevel: 3.5, overflowLevel: 4.5 },
{ minHeightBasedOn: 'outlet' }
);
const issues = validateThresholdOrdering(basin, { minLevel: 1, startLevel: 2, maxLevel: 4 }, { dryRunThresholdPercent: 0, overfillThresholdPercent: 100 });
const hit = issues.find((i) => i.aName === 'outflowLevel' && i.bName === 'inflowLevel');
assert.ok(hit, 'expected an outflowLevel < inflowLevel issue');
assert.equal(hit.op, '<');
assert.equal(hit.a, 3.5);
assert.equal(hit.b, 3);
assert.match(hit.msg, /outflowLevel.*<.*inflowLevel/);
});
test('maxLevel >= overfillLevel triggers issue', () => {
const { basin } = validBasinAndCfg();
// overfillLevel = overflowLevel × overfillPct/100 = 4.5 × 0.80 = 3.6.
// maxLevel 4 > 3.6 → expect a `maxLevel <= overfillLevel` issue.
const issues = validateThresholdOrdering(
basin,
{ minLevel: 1, startLevel: 2, maxLevel: 4 },
{ dryRunThresholdPercent: 10, overfillThresholdPercent: 80 }
);
const hit = issues.find((i) => i.aName === 'maxLevel' && i.bName === 'overfillLevel');
assert.ok(hit, 'expected a maxLevel <= overfillLevel issue');
assert.equal(hit.op, '<=');
assert.equal(hit.a, 4);
assert.ok(Math.abs(hit.b - 3.6) < 1e-9);
});
test('NaN / undefined values are skipped, not flagged as issues', () => {
const { basin } = validBasinAndCfg();
const issues = validateThresholdOrdering(
basin,
{ minLevel: undefined, startLevel: NaN, maxLevel: 4 },
{ dryRunThresholdPercent: 10, overfillThresholdPercent: 95 }
);
// dryRunLevel <= minLevel skipped (minLevel undefined → NaN)
// minLevel <= startLevel skipped (both NaN-ish)
// startLevel < maxLevel skipped (startLevel NaN)
// maxLevel <= overfillLevel still checked → 4 ≤ 4.275 OK.
// Geometry checks also OK.
assert.deepEqual(issues, []);
});
test('multiple violations produce multiple issues in stable order', () => {
// Build a basin with two geometry violations.
const basin = new BasinGeometry(
// outflow 4 > inflow 3 (broken) AND overflow 6 > height 5 (broken)
{ volume: 50, height: 5, inflowLevel: 3, outflowLevel: 4, overflowLevel: 6 },
{ minHeightBasedOn: 'outlet' }
);
const issues = validateThresholdOrdering(
basin,
{ minLevel: 1, startLevel: 2, maxLevel: 4 },
{ dryRunThresholdPercent: 0, overfillThresholdPercent: 100 }
);
// Expect at least the two geometry issues, in declaration order:
// outflowLevel < inflowLevel comes before overflowLevel <= basinHeight.
const idxOutflow = issues.findIndex((i) => i.aName === 'outflowLevel');
const idxOverflow = issues.findIndex((i) => i.aName === 'overflowLevel' && i.bName === 'basinHeight');
assert.ok(idxOutflow >= 0, 'expected outflowLevel issue');
assert.ok(idxOverflow >= 0, 'expected overflowLevel <= basinHeight issue');
assert.ok(idxOutflow < idxOverflow, 'issues should be in check-declaration order');
});
test('accepts a plain basin object (duck-typed via getters)', () => {
const plainBasin = {
volEmptyBasin: 50,
heightBasin: 5,
inflowLevel: 3,
outflowLevel: 0.2,
overflowLevel: 4.5,
surfaceArea: 10,
maxVol: 50,
maxVolAtOverflow: 45,
minVolAtInflow: 30,
minVolAtOutflow: 2,
minVol: 2,
minHeightBasedOn: 'outlet',
};
const issues = validateThresholdOrdering(
plainBasin,
{ minLevel: 1, startLevel: 2, maxLevel: 4 },
{ dryRunThresholdPercent: 10, overfillThresholdPercent: 95 }
);
assert.deepEqual(issues, []);
});
test('omitted levelbased / safety objects are tolerated', () => {
const { basin } = validBasinAndCfg();
// No control or safety supplied → only geometry checks run; valid basin geometry → []
const issues = validateThresholdOrdering(basin, undefined, undefined);
assert.deepEqual(issues, []);
});