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:
@@ -112,10 +112,12 @@ class PumpingStation {
|
|||||||
const thresholdFromConfig = Number(this.config.general?.flowThreshold);
|
const thresholdFromConfig = Number(this.config.general?.flowThreshold);
|
||||||
this.flowThreshold = Number.isFinite(thresholdFromConfig) ? thresholdFromConfig : 1e-4;
|
this.flowThreshold = Number.isFinite(thresholdFromConfig) ? thresholdFromConfig : 1e-4;
|
||||||
|
|
||||||
// Compute basin geometry from config and seed the predicted volume
|
// Geometry + threshold ordering check. initBasinProperties seeds
|
||||||
// at the basin's minimum volume (outflowLevel or inflowLevel based
|
// predicted volume at minVol; _validateThresholdOrdering warns if
|
||||||
// on config.hydraulics.minHeightBasedOn).
|
// any physical/control invariant is violated. Non-fatal — prefer
|
||||||
|
// continuity over refusal to start (availability-first).
|
||||||
this.initBasinProperties();
|
this.initBasinProperties();
|
||||||
|
this.thresholdIssues = this._validateThresholdOrdering();
|
||||||
this.logger.debug('PumpingStation initialized');
|
this.logger.debug('PumpingStation initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,23 +246,22 @@ class PumpingStation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
calibratePredictedLevel(val, timestamp = Date.now(), unit = 'm') {
|
calibratePredictedLevel(val, timestamp = Date.now(), unit = 'm') {
|
||||||
const volumeChain = this.measurements.type('volume').variant('predicted').position('atequipment');
|
// Rebuild the chain each time — MeasurementContainer is stateful
|
||||||
const levelChain = this.measurements.type('level').variant('predicted').position('atequipment');
|
// (its type/variant/position methods mutate the container itself,
|
||||||
|
// so cached chain references share one cursor).
|
||||||
const volumeMeasurement = volumeChain.exists() ? volumeChain.get() : null;
|
const volMeas = this.measurements.type('volume').variant('predicted').position('atequipment');
|
||||||
if (volumeMeasurement) {
|
if (volMeas.exists()) {
|
||||||
volumeMeasurement.values = [];
|
const m = volMeas.get();
|
||||||
volumeMeasurement.timestamps = [];
|
m.values = []; m.timestamps = [];
|
||||||
|
}
|
||||||
|
const lvlMeas = this.measurements.type('level').variant('predicted').position('atequipment');
|
||||||
|
if (lvlMeas.exists()) {
|
||||||
|
const m = lvlMeas.get();
|
||||||
|
m.values = []; m.timestamps = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const levelMeasurement = levelChain.exists() ? levelChain.get() : null;
|
this.measurements.type('level').variant('predicted').position('atequipment').value(val, timestamp, unit);
|
||||||
if (levelMeasurement) {
|
this.measurements.type('volume').variant('predicted').position('atequipment').value(this._calcVolumeFromLevel(val), timestamp, 'm3');
|
||||||
levelMeasurement.values = [];
|
|
||||||
levelMeasurement.timestamps = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
levelChain.value(val, timestamp).unit(unit);
|
|
||||||
volumeChain.value(this._calcVolumeFromLevel(val), timestamp, 'm3');
|
|
||||||
|
|
||||||
this._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: timestamp };
|
this._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: timestamp };
|
||||||
}
|
}
|
||||||
@@ -775,6 +776,60 @@ class PumpingStation {
|
|||||||
this.measurements.type('volume').variant('predicted').position('atequipment').value(minVol).unit('m3');
|
this.measurements.type('volume').variant('predicted').position('atequipment').value(minVol).unit('m3');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate basin + control threshold ordering.
|
||||||
|
*
|
||||||
|
* Every pair is a strict physical or control invariant. Violations
|
||||||
|
* don't throw — they log a warning and return the list so callers
|
||||||
|
* (tests, node-status, the eval harness) can surface them. Returning
|
||||||
|
* [] means "all invariants hold".
|
||||||
|
*
|
||||||
|
* Strict invariants (bottom → top):
|
||||||
|
* 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight
|
||||||
|
* dryRunTriggerLevel ≤ minLevel ≤ startLevel < maxLevel ≤ overflowLevel
|
||||||
|
*
|
||||||
|
* dryRunTriggerLevel and the overfill trigger are DERIVED — computed
|
||||||
|
* from minVol × (1 + dryRunThresholdPercent/100) and overflowLevel ×
|
||||||
|
* overfillThresholdPercent/100 in the safety layer. Validating those
|
||||||
|
* catches config that would let minLevel sit below where safety has
|
||||||
|
* already force-stopped the pumps (no-op control band).
|
||||||
|
*/
|
||||||
|
_validateThresholdOrdering() {
|
||||||
|
const basin = this.basin;
|
||||||
|
const lvl = this.config.control?.levelbased || {};
|
||||||
|
const safety = this.config.safety || {};
|
||||||
|
|
||||||
|
// Derived safety trigger levels (level-space equivalents of what
|
||||||
|
// _safetyController does in volume-space).
|
||||||
|
const dryRunPct = Number(safety.dryRunThresholdPercent) || 0;
|
||||||
|
const overfillPct = Number(safety.overfillThresholdPercent) || 100;
|
||||||
|
const refLowLevel = basin.minHeightBasedOn === 'inlet' ? basin.inflowLevel : basin.outflowLevel;
|
||||||
|
const dryRunLevel = refLowLevel * (1 + dryRunPct / 100);
|
||||||
|
const overfillLevel = basin.overflowLevel * (overfillPct / 100);
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
['outflowLevel', basin.outflowLevel, '<', 'inflowLevel', basin.inflowLevel],
|
||||||
|
['inflowLevel', basin.inflowLevel, '<', 'overflowLevel', basin.overflowLevel],
|
||||||
|
['overflowLevel', basin.overflowLevel, '<=', 'basinHeight', basin.heightBasin],
|
||||||
|
['dryRunLevel', dryRunLevel, '<=', 'minLevel', lvl.minLevel],
|
||||||
|
['minLevel', lvl.minLevel, '<=', 'startLevel', lvl.startLevel],
|
||||||
|
['startLevel', lvl.startLevel, '<', 'maxLevel', lvl.maxLevel],
|
||||||
|
['maxLevel', lvl.maxLevel, '<=', 'overfillLevel', overfillLevel],
|
||||||
|
];
|
||||||
|
|
||||||
|
const issues = [];
|
||||||
|
for (const [aName, a, op, bName, b] of checks) {
|
||||||
|
if (!Number.isFinite(a) || !Number.isFinite(b)) continue;
|
||||||
|
const ok = op === '<' ? a < b : a <= b;
|
||||||
|
if (!ok) {
|
||||||
|
const msg = `Threshold invariant violated: ${aName} (${a}) must be ${op} ${bName} (${b})`;
|
||||||
|
issues.push({ aName, a, op, bName, b, msg });
|
||||||
|
this.logger.warn(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
/** Convert level (m from floor) → volume (m3). Clamps to 0. */
|
/** Convert level (m from floor) → volume (m3). Clamps to 0. */
|
||||||
_calcVolumeFromLevel(level) {
|
_calcVolumeFromLevel(level) {
|
||||||
return Math.max(level, 0) * this.basin.surfaceArea;
|
return Math.max(level, 0) * this.basin.surfaceArea;
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tests for pumpingStation specificClass (domain logic).
|
|
||||||
*
|
|
||||||
* The pumpingStation class manages a basin (wet well):
|
|
||||||
* - initBasinProperties: derives surface area, volumes from config
|
|
||||||
* - _calcVolumeFromLevel / _calcLevelFromVolume: linear geometry
|
|
||||||
* - _calcDirection: filling / draining / stable from flow diff
|
|
||||||
* - _callMeasurementHandler: dispatches to type-specific handlers
|
|
||||||
* - getOutput: builds an output snapshot
|
|
||||||
*/
|
|
||||||
|
|
||||||
const PumpingStation = require('../src/specificClass');
|
|
||||||
|
|
||||||
// --------------- helpers ---------------
|
|
||||||
|
|
||||||
function makeConfig(overrides = {}) {
|
|
||||||
const base = {
|
|
||||||
general: {
|
|
||||||
name: 'TestStation',
|
|
||||||
id: 'ps-test-1',
|
|
||||||
unit: 'm3/h',
|
|
||||||
logging: { enabled: false, logLevel: 'error' },
|
|
||||||
},
|
|
||||||
functionality: {
|
|
||||||
softwareType: 'pumpingStation',
|
|
||||||
role: 'stationcontroller',
|
|
||||||
positionVsParent: 'atEquipment',
|
|
||||||
},
|
|
||||||
basin: {
|
|
||||||
volume: 50, // m3 (empty basin volume)
|
|
||||||
height: 5, // m
|
|
||||||
inflowLevel: 0.3, // m
|
|
||||||
outflowLevel: 0.2, // m
|
|
||||||
overflowLevel: 4.0, // m
|
|
||||||
},
|
|
||||||
hydraulics: {
|
|
||||||
refHeight: 'NAP',
|
|
||||||
basinBottomRef: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const key of Object.keys(overrides)) {
|
|
||||||
if (typeof overrides[key] === 'object' && !Array.isArray(overrides[key]) && base[key]) {
|
|
||||||
base[key] = { ...base[key], ...overrides[key] };
|
|
||||||
} else {
|
|
||||||
base[key] = overrides[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return base;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --------------- tests ---------------
|
|
||||||
|
|
||||||
describe('pumpingStation specificClass', () => {
|
|
||||||
|
|
||||||
describe('constructor / initialization', () => {
|
|
||||||
it('should create an instance with the given config', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
expect(ps).toBeDefined();
|
|
||||||
expect(ps.config.general.name).toBe('teststation');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should initialize state object with default values', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
expect(ps.state).toEqual({ direction: '', netDownstream: 0, netUpstream: 0, seconds: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should initialize empty machines, stations, child, parent objects', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
expect(ps.machines).toEqual({});
|
|
||||||
expect(ps.stations).toEqual({});
|
|
||||||
expect(ps.child).toEqual({});
|
|
||||||
expect(ps.parent).toEqual({});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('initBasinProperties()', () => {
|
|
||||||
it('should calculate surfaceArea = volume / height', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
// 50 / 5 = 10 m2
|
|
||||||
expect(ps.basin.surfaceArea).toBe(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should calculate maxVol = height * surfaceArea', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
// 5 * 10 = 50
|
|
||||||
expect(ps.basin.maxVol).toBe(50);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should calculate maxVolAtOverflow = overflowLevel * surfaceArea', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
// 4.0 * 10 = 40
|
|
||||||
expect(ps.basin.maxVolAtOverflow).toBe(40);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should calculate minVol = outflowLevel * surfaceArea', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
// 0.2 * 10 = 2
|
|
||||||
expect(ps.basin.minVol).toBeCloseTo(2, 5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should calculate minVolAtOutflow = inflowLevel * surfaceArea', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
// 0.3 * 10 = 3
|
|
||||||
expect(ps.basin.minVolAtOutflow).toBeCloseTo(3, 5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should store the raw config values on basin', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
expect(ps.basin.volEmptyBasin).toBe(50);
|
|
||||||
expect(ps.basin.heightBasin).toBe(5);
|
|
||||||
expect(ps.basin.inflowLevel).toBe(0.3);
|
|
||||||
expect(ps.basin.outflowLevel).toBe(0.2);
|
|
||||||
expect(ps.basin.overflowLevel).toBe(4.0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('_calcVolumeFromLevel()', () => {
|
|
||||||
let ps;
|
|
||||||
beforeAll(() => { ps = new PumpingStation(makeConfig()); });
|
|
||||||
|
|
||||||
it('should return level * surfaceArea', () => {
|
|
||||||
// surfaceArea = 10, level = 2 => 20
|
|
||||||
expect(ps._calcVolumeFromLevel(2)).toBe(20);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 0 for level = 0', () => {
|
|
||||||
expect(ps._calcVolumeFromLevel(0)).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should clamp negative levels to 0', () => {
|
|
||||||
expect(ps._calcVolumeFromLevel(-3)).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('_calcLevelFromVolume()', () => {
|
|
||||||
let ps;
|
|
||||||
beforeAll(() => { ps = new PumpingStation(makeConfig()); });
|
|
||||||
|
|
||||||
it('should return volume / surfaceArea', () => {
|
|
||||||
// surfaceArea = 10, vol = 20 => 2
|
|
||||||
expect(ps._calcLevelFromVolume(20)).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 0 for volume = 0', () => {
|
|
||||||
expect(ps._calcLevelFromVolume(0)).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should clamp negative volumes to 0', () => {
|
|
||||||
expect(ps._calcLevelFromVolume(-10)).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('volume/level roundtrip', () => {
|
|
||||||
it('should roundtrip level -> volume -> level', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
const level = 2.7;
|
|
||||||
const vol = ps._calcVolumeFromLevel(level);
|
|
||||||
const levelBack = ps._calcLevelFromVolume(vol);
|
|
||||||
expect(levelBack).toBeCloseTo(level, 10);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('_calcDirection()', () => {
|
|
||||||
let ps;
|
|
||||||
beforeAll(() => { ps = new PumpingStation(makeConfig()); });
|
|
||||||
|
|
||||||
it('should return "filling" for positive flow above threshold', () => {
|
|
||||||
expect(ps._calcDirection(0.01)).toBe('filling');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return "draining" for negative flow below negative threshold', () => {
|
|
||||||
expect(ps._calcDirection(-0.01)).toBe('draining');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return "stable" for flow near zero (within threshold)', () => {
|
|
||||||
expect(ps._calcDirection(0.0005)).toBe('stable');
|
|
||||||
expect(ps._calcDirection(-0.0005)).toBe('stable');
|
|
||||||
expect(ps._calcDirection(0)).toBe('stable');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('_callMeasurementHandler()', () => {
|
|
||||||
it('should not throw for flow and temperature measurement types', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
// flow and temperature handlers are empty stubs, safe to call
|
|
||||||
expect(() => ps._callMeasurementHandler('flow', 0.5, 'downstream', {})).not.toThrow();
|
|
||||||
expect(() => ps._callMeasurementHandler('temperature', 15, 'atEquipment', {})).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should dispatch to the correct handler based on measurement type', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
// Verify the switch dispatches by checking it does not warn for known types
|
|
||||||
// pressure handler stores values and attempts coolprop calculation
|
|
||||||
// level handler stores values and computes volume
|
|
||||||
// We verify the dispatch logic by calling with type and checking no unhandled error
|
|
||||||
const spy = jest.spyOn(ps, 'updateMeasuredFlow');
|
|
||||||
ps._callMeasurementHandler('flow', 0.5, 'downstream', {});
|
|
||||||
expect(spy).toHaveBeenCalledWith(0.5, 'downstream', {});
|
|
||||||
spy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getOutput()', () => {
|
|
||||||
it('should return an object containing state and basin', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
const out = ps.getOutput();
|
|
||||||
expect(out).toHaveProperty('state');
|
|
||||||
expect(out).toHaveProperty('basin');
|
|
||||||
expect(out.state).toBe(ps.state);
|
|
||||||
expect(out.basin).toBe(ps.basin);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include measurement keys in the output', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
const out = ps.getOutput();
|
|
||||||
// After initialization the predicted volume is set
|
|
||||||
expect(typeof out).toBe('object');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('_calcRemainingTime()', () => {
|
|
||||||
it('should not throw when called with a level and variant', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
// Should not throw even with no measurement data; it will just find null diffs
|
|
||||||
expect(() => ps._calcRemainingTime(2, 'predicted')).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('tick()', () => {
|
|
||||||
it('should call _updateVolumePrediction and _calcNetFlow', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
const spyVol = jest.spyOn(ps, '_updateVolumePrediction');
|
|
||||||
const spyNet = jest.spyOn(ps, '_calcNetFlow');
|
|
||||||
// stub _calcRemainingTime to avoid needing full measurement data
|
|
||||||
ps._calcRemainingTime = jest.fn();
|
|
||||||
ps.tick();
|
|
||||||
expect(spyVol).toHaveBeenCalledWith('out');
|
|
||||||
expect(spyVol).toHaveBeenCalledWith('in');
|
|
||||||
expect(spyNet).toHaveBeenCalled();
|
|
||||||
spyVol.mockRestore();
|
|
||||||
spyNet.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('edge cases', () => {
|
|
||||||
it('should handle basin with zero height gracefully', () => {
|
|
||||||
// surfaceArea = volume / height => division by 0 gives Infinity
|
|
||||||
const config = makeConfig({ basin: { volume: 50, height: 0, inflowLevel: 0, outflowLevel: 0, overflowLevel: 0 } });
|
|
||||||
const ps = new PumpingStation(config);
|
|
||||||
expect(ps.basin.surfaceArea).toBe(Infinity);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle basin with very small dimensions', () => {
|
|
||||||
const config = makeConfig({ basin: { volume: 0.001, height: 0.001, inflowLevel: 0, outflowLevel: 0, overflowLevel: 0.0005 } });
|
|
||||||
const ps = new PumpingStation(config);
|
|
||||||
expect(ps.basin.surfaceArea).toBeCloseTo(1, 5);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user