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>
231 lines
7.7 KiB
JavaScript
231 lines
7.7 KiB
JavaScript
'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);
|
|
});
|