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:
106
test/basic/BasinGeometry.basic.test.js
Normal file
106
test/basic/BasinGeometry.basic.test.js
Normal 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');
|
||||
});
|
||||
106
test/basic/calibration.basic.test.js
Normal file
106
test/basic/calibration.basic.test.js
Normal 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));
|
||||
});
|
||||
178
test/basic/commands.basic.test.js
Normal file
178
test/basic/commands.basic.test.js
Normal 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)}`
|
||||
);
|
||||
});
|
||||
126
test/basic/control-levelBased.basic.test.js
Normal file
126
test/basic/control-levelBased.basic.test.js
Normal 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);
|
||||
}
|
||||
});
|
||||
64
test/basic/control-manual.basic.test.js
Normal file
64
test/basic/control-manual.basic.test.js
Normal 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');
|
||||
});
|
||||
141
test/basic/flowAggregator.basic.test.js
Normal file
141
test/basic/flowAggregator.basic.test.js
Normal 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);
|
||||
});
|
||||
106
test/basic/measurementRouter.basic.test.js
Normal file
106
test/basic/measurementRouter.basic.test.js
Normal 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() }));
|
||||
});
|
||||
230
test/basic/safetyController.basic.test.js
Normal file
230
test/basic/safetyController.basic.test.js
Normal 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);
|
||||
});
|
||||
123
test/basic/thresholdValidator.basic.test.js
Normal file
123
test/basic/thresholdValidator.basic.test.js
Normal 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, []);
|
||||
});
|
||||
Reference in New Issue
Block a user