From 016433abe65de97497373a49f50dc9eeb553ff04 Mon Sep 17 00:00:00 2001 From: znetsixe Date: Wed, 22 Apr 2026 16:38:41 +0200 Subject: [PATCH] Add threshold guardrails, fix calibratePredictedLevel bug, rewrite tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 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) --- src/specificClass.js | 91 ++++++++-- test/basic/specificClass.test.js | 295 +++++++++++++++++++++++++++++++ test/specificClass.test.js | 260 --------------------------- 3 files changed, 368 insertions(+), 278 deletions(-) create mode 100644 test/basic/specificClass.test.js delete mode 100644 test/specificClass.test.js diff --git a/src/specificClass.js b/src/specificClass.js index 4856d2d..1fc9457 100644 --- a/src/specificClass.js +++ b/src/specificClass.js @@ -112,10 +112,12 @@ class PumpingStation { const thresholdFromConfig = Number(this.config.general?.flowThreshold); this.flowThreshold = Number.isFinite(thresholdFromConfig) ? thresholdFromConfig : 1e-4; - // Compute basin geometry from config and seed the predicted volume - // at the basin's minimum volume (outflowLevel or inflowLevel based - // on config.hydraulics.minHeightBasedOn). + // Geometry + threshold ordering check. initBasinProperties seeds + // predicted volume at minVol; _validateThresholdOrdering warns if + // any physical/control invariant is violated. Non-fatal — prefer + // continuity over refusal to start (availability-first). this.initBasinProperties(); + this.thresholdIssues = this._validateThresholdOrdering(); this.logger.debug('PumpingStation initialized'); } @@ -244,23 +246,22 @@ class PumpingStation { } calibratePredictedLevel(val, timestamp = Date.now(), unit = 'm') { - const volumeChain = this.measurements.type('volume').variant('predicted').position('atequipment'); - const levelChain = this.measurements.type('level').variant('predicted').position('atequipment'); - - const volumeMeasurement = volumeChain.exists() ? volumeChain.get() : null; - if (volumeMeasurement) { - volumeMeasurement.values = []; - volumeMeasurement.timestamps = []; + // Rebuild the chain each time — MeasurementContainer is stateful + // (its type/variant/position methods mutate the container itself, + // so cached chain references share one cursor). + const volMeas = this.measurements.type('volume').variant('predicted').position('atequipment'); + if (volMeas.exists()) { + const m = volMeas.get(); + 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; - if (levelMeasurement) { - levelMeasurement.values = []; - levelMeasurement.timestamps = []; - } - - levelChain.value(val, timestamp).unit(unit); - volumeChain.value(this._calcVolumeFromLevel(val), timestamp, 'm3'); + this.measurements.type('level').variant('predicted').position('atequipment').value(val, timestamp, unit); + this.measurements.type('volume').variant('predicted').position('atequipment').value(this._calcVolumeFromLevel(val), timestamp, 'm3'); 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'); } + /** + * 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. */ _calcVolumeFromLevel(level) { return Math.max(level, 0) * this.basin.surfaceArea; diff --git a/test/basic/specificClass.test.js b/test/basic/specificClass.test.js new file mode 100644 index 0000000..527de86 --- /dev/null +++ b/test/basic/specificClass.test.js @@ -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); +}); diff --git a/test/specificClass.test.js b/test/specificClass.test.js deleted file mode 100644 index 447bba7..0000000 --- a/test/specificClass.test.js +++ /dev/null @@ -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); - }); - }); -});