Add threshold guardrails, fix calibratePredictedLevel bug, rewrite tests
### Guardrails (specificClass.js) New _validateThresholdOrdering() runs in the constructor. Checks every ordered pair of basin + control + derived-safety levels and logs a warning for each violation; returns the list as this.thresholdIssues so tests and the eval harness can inspect. Non-fatal — we prefer a running-but-warned station to a refusal-to-start (availability-first). Strict invariants (bottom → top): 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight dryRunLevel ≤ minLevel ≤ startLevel < maxLevel ≤ overfillLevel Uses a list-of-checks pattern rather than a switch — easier to add new invariants without reflowing cases, and the list itself is readable documentation. ### Bug fix (specificClass.js) calibratePredictedLevel was writing the volume value into the LEVEL slot. Root cause: MeasurementContainer is stateful — its type()/ variant()/position() calls mutate the container's own cursor, so caching chain references (const levelChain = ...; const volumeChain = ...) doesn't isolate them. The second cached chain ended up sharing the state of the last type() call. Rebuilt chains fresh each time, matching the calibratePredictedVolume pattern that already worked. ### Tests (test/basic/specificClass.test.js) Ported from Jest to node:test + node:assert — the project's standard per .claude/rules/testing.md. Deleted the stale test/specificClass.test.js (tests referenced methods that no longer exist post-rename). New coverage, 42 passing subtests: - Basin geometry derivations + minHeightBasedOn - Level/volume roundtrip - Threshold guardrails (5 violation cases) - Direction derivation - Mode change accept/reject - Calibration (volume and level paths — catches the bug above) - Levelbased control zones (STOP / DEAD ZONE / RAMP / saturate) - getOutput flattening - setManualInflow Run with: node --test test/basic/*.test.js Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
295
test/basic/specificClass.test.js
Normal file
295
test/basic/specificClass.test.js
Normal file
@@ -0,0 +1,295 @@
|
||||
// Basic unit tests for PumpingStation (domain logic, no Node-RED).
|
||||
// Run with: node --test test/basic/specificClass.test.js
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const PumpingStation = require('../../src/specificClass');
|
||||
|
||||
// Standard config shape. Override any section by passing { section: {...} }.
|
||||
function makeConfig(overrides = {}) {
|
||||
const base = {
|
||||
general: {
|
||||
name: 'TestStation',
|
||||
id: 'ps-test',
|
||||
unit: 'm3/h',
|
||||
logging: { enabled: false, logLevel: 'error' },
|
||||
flowThreshold: 1e-4,
|
||||
},
|
||||
functionality: {
|
||||
softwareType: 'pumpingStation',
|
||||
role: 'stationcontroller',
|
||||
positionVsParent: 'atEquipment',
|
||||
},
|
||||
basin: {
|
||||
volume: 50,
|
||||
height: 5,
|
||||
inflowLevel: 3,
|
||||
outflowLevel: 0.2,
|
||||
overflowLevel: 4.5,
|
||||
},
|
||||
hydraulics: {
|
||||
refHeight: 'NAP',
|
||||
basinBottomRef: 0,
|
||||
minHeightBasedOn: 'outlet',
|
||||
},
|
||||
control: {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased', 'manual']),
|
||||
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4 },
|
||||
},
|
||||
safety: {
|
||||
enableDryRunProtection: false,
|
||||
enableOverfillProtection: false,
|
||||
dryRunThresholdPercent: 2,
|
||||
overfillThresholdPercent: 98,
|
||||
timeleftToFullOrEmptyThresholdSeconds: 0,
|
||||
},
|
||||
};
|
||||
for (const k of Object.keys(overrides)) {
|
||||
base[k] = typeof overrides[k] === 'object' && !Array.isArray(overrides[k])
|
||||
? { ...base[k], ...overrides[k] }
|
||||
: overrides[k];
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
test('Basin geometry — derived values', async (t) => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
|
||||
await t.test('surfaceArea = volume / height', () => {
|
||||
assert.equal(ps.basin.surfaceArea, 10); // 50 / 5
|
||||
});
|
||||
await t.test('maxVol = height × area ≡ volEmptyBasin', () => {
|
||||
assert.equal(ps.basin.maxVol, 50);
|
||||
assert.equal(ps.basin.maxVol, ps.basin.volEmptyBasin);
|
||||
});
|
||||
await t.test('maxVolAtOverflow = overflowLevel × area', () => {
|
||||
assert.equal(ps.basin.maxVolAtOverflow, 45); // 4.5 × 10
|
||||
});
|
||||
await t.test('minVolAtInflow = inflowLevel × area', () => {
|
||||
assert.equal(ps.basin.minVolAtInflow, 30); // 3 × 10
|
||||
});
|
||||
await t.test('minVolAtOutflow = outflowLevel × area', () => {
|
||||
assert.ok(Math.abs(ps.basin.minVolAtOutflow - 2) < 1e-9); // 0.2 × 10
|
||||
});
|
||||
await t.test('minVol honours minHeightBasedOn=outlet', () => {
|
||||
assert.ok(Math.abs(ps.basin.minVol - 2) < 1e-9);
|
||||
});
|
||||
await t.test('minVol honours minHeightBasedOn=inlet', () => {
|
||||
const ps2 = new PumpingStation(makeConfig({ hydraulics: { minHeightBasedOn: 'inlet' } }));
|
||||
assert.equal(ps2.basin.minVol, 30);
|
||||
});
|
||||
});
|
||||
|
||||
test('Level ↔ volume roundtrip', async (t) => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
|
||||
await t.test('_calcVolumeFromLevel multiplies by area', () => {
|
||||
assert.equal(ps._calcVolumeFromLevel(2), 20);
|
||||
});
|
||||
await t.test('_calcVolumeFromLevel clamps negatives to 0', () => {
|
||||
assert.equal(ps._calcVolumeFromLevel(-3), 0);
|
||||
});
|
||||
await t.test('_calcLevelFromVolume divides by area', () => {
|
||||
assert.equal(ps._calcLevelFromVolume(20), 2);
|
||||
});
|
||||
await t.test('_calcLevelFromVolume clamps negatives to 0', () => {
|
||||
assert.equal(ps._calcLevelFromVolume(-10), 0);
|
||||
});
|
||||
await t.test('roundtrip preserves level', () => {
|
||||
const v = ps._calcVolumeFromLevel(2.7);
|
||||
assert.ok(Math.abs(ps._calcLevelFromVolume(v) - 2.7) < 1e-10);
|
||||
});
|
||||
});
|
||||
|
||||
test('Threshold guardrails — _validateThresholdOrdering', async (t) => {
|
||||
await t.test('valid config returns no issues', () => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
assert.equal(ps.thresholdIssues.length, 0);
|
||||
});
|
||||
|
||||
await t.test('minLevel > startLevel flagged', () => {
|
||||
const ps = new PumpingStation(makeConfig({
|
||||
control: {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased']),
|
||||
levelbased: { minLevel: 3, startLevel: 2, maxLevel: 4 },
|
||||
},
|
||||
}));
|
||||
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'minLevel'));
|
||||
});
|
||||
|
||||
await t.test('startLevel == maxLevel flagged (must be strict <)', () => {
|
||||
const ps = new PumpingStation(makeConfig({
|
||||
control: {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased']),
|
||||
levelbased: { minLevel: 1, startLevel: 4, maxLevel: 4 },
|
||||
},
|
||||
}));
|
||||
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'startLevel'));
|
||||
});
|
||||
|
||||
await t.test('outflowLevel >= inflowLevel flagged', () => {
|
||||
const ps = new PumpingStation(makeConfig({
|
||||
basin: { volume: 50, height: 5, inflowLevel: 0.1, outflowLevel: 0.5, overflowLevel: 4.5 },
|
||||
}));
|
||||
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'outflowLevel'));
|
||||
});
|
||||
|
||||
await t.test('overflowLevel > basinHeight flagged', () => {
|
||||
const ps = new PumpingStation(makeConfig({
|
||||
basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 6 },
|
||||
}));
|
||||
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'overflowLevel'));
|
||||
});
|
||||
|
||||
await t.test('dryRunLevel > minLevel flagged (safety band inverted)', () => {
|
||||
// With minHeightBasedOn=inlet, refLowLevel=inflowLevel=3.
|
||||
// dryRunLevel = 3 × (1 + 100/100) = 6; minLevel=1 → 6 ≤ 1 fails.
|
||||
const ps = new PumpingStation(makeConfig({
|
||||
hydraulics: { minHeightBasedOn: 'inlet' },
|
||||
safety: { enableDryRunProtection: true, dryRunThresholdPercent: 100 },
|
||||
}));
|
||||
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'dryRunLevel'));
|
||||
});
|
||||
});
|
||||
|
||||
test('Direction derivation — _deriveDirection', async (t) => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
|
||||
await t.test('positive flow above dead-band → filling', () => {
|
||||
assert.equal(ps._deriveDirection(0.01), 'filling');
|
||||
});
|
||||
await t.test('negative flow below dead-band → draining', () => {
|
||||
assert.equal(ps._deriveDirection(-0.01), 'draining');
|
||||
});
|
||||
await t.test('flow inside dead-band → steady', () => {
|
||||
assert.equal(ps._deriveDirection(0), 'steady');
|
||||
assert.equal(ps._deriveDirection(1e-5), 'steady');
|
||||
assert.equal(ps._deriveDirection(-1e-5), 'steady');
|
||||
});
|
||||
});
|
||||
|
||||
test('Mode change — changeMode', async (t) => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
|
||||
await t.test('valid mode swap updates this.mode', () => {
|
||||
ps.changeMode('manual');
|
||||
assert.equal(ps.mode, 'manual');
|
||||
});
|
||||
await t.test('rejected mode leaves this.mode unchanged', () => {
|
||||
ps.changeMode('manual');
|
||||
ps.changeMode('notamode');
|
||||
assert.equal(ps.mode, 'manual');
|
||||
});
|
||||
});
|
||||
|
||||
test('Calibration — predicted volume and level', async (t) => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
|
||||
await t.test('calibratePredictedVolume rewrites volume series', () => {
|
||||
ps.calibratePredictedVolume(25);
|
||||
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.ok(Math.abs(vol - 25) < 1e-9);
|
||||
});
|
||||
await t.test('calibratePredictedVolume also writes level (= vol / area)', () => {
|
||||
ps.calibratePredictedVolume(30);
|
||||
const lvl = ps.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m');
|
||||
assert.ok(Math.abs(lvl - 3) < 1e-9); // 30 / 10
|
||||
});
|
||||
await t.test('calibratePredictedLevel writes level + volume = level × area', () => {
|
||||
ps.calibratePredictedLevel(2.5);
|
||||
const lvl = ps.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m');
|
||||
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.ok(Math.abs(lvl - 2.5) < 1e-9);
|
||||
assert.ok(Math.abs(vol - 25) < 1e-9); // 2.5 × 10
|
||||
});
|
||||
});
|
||||
|
||||
test('Levelbased control zones — _controlLevelBased', async (t) => {
|
||||
await t.test('level < minLevel → percControl=0 and MGC turnOff called', async () => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
let turnOffCalls = 0;
|
||||
ps.machineGroups['mgc1'] = {
|
||||
config: { general: { name: 'mgc1' } },
|
||||
turnOffAllMachines: () => { turnOffCalls++; },
|
||||
handleInput: async () => {},
|
||||
};
|
||||
ps.calibratePredictedLevel(0.5); // below minLevel=1
|
||||
await ps._controlLevelBased();
|
||||
assert.equal(ps.percControl, 0);
|
||||
assert.equal(turnOffCalls, 1);
|
||||
});
|
||||
|
||||
await t.test('minLevel ≤ level < startLevel → dead zone, percControl unchanged', async () => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
ps.percControl = 42; // simulated previous demand
|
||||
ps.machineGroups['mgc1'] = {
|
||||
config: { general: { name: 'mgc1' } },
|
||||
turnOffAllMachines: () => {},
|
||||
handleInput: async () => { throw new Error('should not be called in dead zone'); },
|
||||
};
|
||||
ps.calibratePredictedLevel(1.5); // between minLevel=1 and startLevel=2
|
||||
await ps._controlLevelBased();
|
||||
assert.equal(ps.percControl, 42); // unchanged
|
||||
});
|
||||
|
||||
await t.test('level ≥ startLevel → percControl linearly scaled to [0,100]', async () => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
const demands = [];
|
||||
ps.machineGroups['mgc1'] = {
|
||||
config: { general: { name: 'mgc1' } },
|
||||
turnOffAllMachines: () => {},
|
||||
handleInput: async (_src, d) => { demands.push(d); },
|
||||
};
|
||||
ps.calibratePredictedLevel(3); // midpoint of startLevel=2 and maxLevel=4
|
||||
await ps._controlLevelBased();
|
||||
// lerp(3, [2,4], [0,100]) = 50
|
||||
assert.ok(Math.abs(ps.percControl - 50) < 1e-9);
|
||||
assert.equal(demands.length, 1);
|
||||
assert.ok(Math.abs(demands[0] - 50) < 1e-9);
|
||||
});
|
||||
|
||||
await t.test('level > maxLevel → percControl ≥ 100 (MGC clamps internally)', async () => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
ps.machineGroups['mgc1'] = {
|
||||
config: { general: { name: 'mgc1' } },
|
||||
turnOffAllMachines: () => {},
|
||||
handleInput: async () => {},
|
||||
};
|
||||
ps.calibratePredictedLevel(4.5); // above maxLevel=4
|
||||
await ps._controlLevelBased();
|
||||
assert.ok(ps.percControl >= 100);
|
||||
});
|
||||
});
|
||||
|
||||
test('getOutput — flattens basin + state + demand', async (t) => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
ps.percControl = 37;
|
||||
|
||||
await t.test('includes basin geometry fields', () => {
|
||||
const out = ps.getOutput();
|
||||
assert.equal(out.volEmptyBasin, 50);
|
||||
assert.equal(out.maxVolAtOverflow, 45);
|
||||
assert.equal(out.minVolAtInflow, 30);
|
||||
assert.ok(Math.abs(out.minVolAtOutflow - 2) < 1e-9);
|
||||
});
|
||||
await t.test('includes state fields (direction, flowSource, timeleft)', () => {
|
||||
const out = ps.getOutput();
|
||||
assert.ok('direction' in out);
|
||||
assert.ok('flowSource' in out);
|
||||
assert.ok('timeleft' in out);
|
||||
});
|
||||
await t.test('includes percControl', () => {
|
||||
assert.equal(ps.getOutput().percControl, 37);
|
||||
});
|
||||
});
|
||||
|
||||
test('Manual inflow — setManualInflow stores predicted inflow', async (t) => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
ps.setManualInflow(0.05, Date.now(), 'm3/s'); // 0.05 m³/s
|
||||
const v = ps.measurements.type('flow').variant('predicted').position('in').child('manual-qin').getCurrentValue('m3/s');
|
||||
assert.ok(Math.abs(v - 0.05) < 1e-9);
|
||||
});
|
||||
Reference in New Issue
Block a user