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:
znetsixe
2026-04-22 16:38:41 +02:00
parent a2189457f6
commit 016433abe6
3 changed files with 368 additions and 278 deletions

View File

@@ -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;

View 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);
});

View File

@@ -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);
});
});
});