Files
pumpingStation/test/basic/specificClass.test.js
znetsixe 2e4ad8d3f1 fix(levelBased): drop hold zone, route through MGC.setDemand, add holdLevel + integrator variant pick; slim npm pack
levelBased ramp + engagement:
- Ramp foot is now max(startLevel, holdLevel) — was max(startLevel,
  inflowLevel). inflowLevel is basin geometry, not a control setpoint;
  the implicit hold zone it created was causing pumps to "start at
  inflowLevel" instead of startLevel.
- New optional `holdLevel` config (defaults to startLevel = no hold band).
  When raised, pumps engage at startLevel and hold at 0 % = MGC flow.min
  across [startLevel, holdLevel], then ramp 0..100 % to maxLevel.
- Engagement decided in run() (not in `_applyMachineGroupLevelControl`):
  rising-edge hysteresis arming gates a clean turnOff early-return.
  Once armed, the helper always forwards setDemand(pct, '%') — 0 %
  legitimately means "engaged at min flow", no more soft-turnOff at
  the boundary.
- Disengagement paths (minLevel hard-stop, stopLevel falling-edge,
  pre-arming idle) now all clear the shifted-ramp hysteresis state too.
- Threshold validator drops the startLevel ≤ inflowLevel rule; adds
  startLevel ≤ holdLevel < maxLevel (only checked when holdLevel is
  explicitly set, so default-null doesn't false-flag).

MGC unit math:
- Replace direct group.handleInput(percent) with group.setDemand(pct, '%')
  in _applyMachineGroupLevelControl. The percent → m³/s resolution now
  lives in MGC.setDemand (committed separately in the MGC submodule).

FlowAggregator variant picking:
- New _pickFlowSum() helper mirrors selectBestNetFlow's variant
  precedence (measured first, then predicted) and resolves each side
  independently. Realistic mixed case — real measured upstream sensor +
  predicted pump outflow — now feeds the predicted-volume integrator.
  Was reading only `flow.predicted.*` so a real upstream sensor
  (which writes `flow.measured.*`) never moved the level.

Editor:
- New `holdLevel` and `deadZoneKeepAlivePercent` defaults + side-panel
  input rows in the levelbased mode preview.
- Add the missing `ps-mode-line-holdLevel` SVG marker (was declared in
  the side-panel coupling but the SVG element didn't exist, so the
  dashed line never rendered).
- Relax stopLevel marker gate so it renders for any non-negative typed
  value — start/stop ordering is the ribbon's job, not the marker's
  (was hiding the line whenever startLevel was momentarily smaller).
- Add holdLevel to the marker loop in mode-preview so changes track.
- Add stopLevel + holdLevel + maxLevel to all three bindRedraw lists
  (basin-diagram, mode-preview, bounds.apply) so the SVG, validation
  ribbon, and HTML5 min/max attrs update on every edit.
- Initialise stopLevel + holdLevel + deadZoneKeepAlivePercent inputs
  in oneditprepare so reopening the editor shows the saved values.
- nodeClass passes holdLevel + deadZoneKeepAlivePercent into the
  domain config.

Tests:
- New test/basic/_probe_upstream_emit.test.js: confirms the parent
  surfaces flow.measured.upstream.* on Port 0 after a measurement
  child write — pins the previously-invisible measured variant flow.
- flowAggregator.basic.test.js: two new regression cases — measured
  inflow when predicted side is empty, and the measured-in /
  predicted-out mixed case.
- control-levelBased.basic.test.js: new cases for the holdLevel hold
  band, the [stopLevel, startLevel] keep-alive, the engagement gate,
  and the "0 % at startLevel = setDemand" contract.
- specificClass.test.js: zone tests adjusted to the new ramp foot.
  Shifted-ramp tests pin holdLevel = 3 explicitly so their legacy
  arithmetic (ramp foot at inflowLevel) stays self-consistent.
- shifted-ramp-end-to-end.test.js: same holdLevel pin for the same
  reason.

Packaging:
- Add .gitignore + .npmignore so the published tarball drops the
  wiki/, simulations/, test/, tools/, .claude/ etc. The pack went
  from 1.5 MB (72 files) to ~57 KB (30 files).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:36:29 +02:00

623 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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');
// machineGroups is a registry-backed getter (declareChildGetter) — direct
// assignment is no longer possible. Tests inject mock groups through the
// real registration handshake so the registry remains the source of truth.
function registerMockGroup(ps, id, behavior = {}) {
const calls = { setDemand: [], handleInput: [], turnOff: 0 };
const mock = {
config: {
general: { id, name: id },
functionality: { softwareType: 'machinegroup', positionVsParent: 'atEquipment' },
asset: { category: 'controller' },
},
measurements: {
emitter: { on: () => {} },
setChildId: () => {}, setChildName: () => {}, setParentRef: () => {},
},
setDemand: behavior.setDemand
|| (async (value, unit) => { calls.setDemand.push([value, unit]); }),
handleInput: behavior.handleInput
|| (async (...args) => { calls.handleInput.push(args); }),
turnOffAllMachines: behavior.turnOffAllMachines
|| (() => { calls.turnOff += 1; }),
_calls: calls,
};
ps.childRegistrationUtils.registerChild(mock, 'atEquipment');
return mock;
}
// 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,
inletPipeDiameter: 0.4,
outletPipeDiameter: 0.3,
},
hydraulics: {
refHeight: 'NAP',
basinBottomRef: 0,
minHeightBasedOn: 'outlet',
},
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased', 'manual']),
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
},
safety: {
enableDryRunProtection: false,
enableOverfillProtection: false,
dryRunThresholdPercent: 2,
highVolumeSafetyThresholdPercent: 98,
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);
});
await t.test('pipe diameters are part of basin contract', () => {
assert.equal(ps.basin.inletPipeDiameter, 0.4);
assert.equal(ps.basin.outletPipeDiameter, 0.3);
});
});
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('startLevel > inflowLevel is allowed (sewer-buffer mode), no issue raised', () => {
// Inflow gravity point at 3, startLevel pushed to 3.5 → basin is allowed
// to fill past the inlet before pumps engage. levelBased shifts the ramp
// foot to startLevel; the validator no longer flags the ordering.
const ps = new PumpingStation(makeConfig({
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased']),
levelbased: { minLevel: 1, startLevel: 3.5, maxLevel: 4, curveType: 'linear' },
},
}));
assert.ok(!ps.thresholdIssues.some((i) => i.aName === 'startLevel' && i.bName === 'inflowLevel'),
'startLevel vs inflowLevel ordering must not raise an issue');
});
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());
const mock = registerMockGroup(ps, 'mgc1');
ps.calibratePredictedLevel(0.5); // below minLevel=1
await ps._controlLevelBased();
assert.equal(ps.percControl, 0);
assert.equal(mock._calls.turnOff, 1);
});
await t.test('minLevel ≤ level < active ramp start → soft turnOff (pct=0 no longer dispatched)', async () => {
const ps = new PumpingStation(makeConfig());
ps.percControl = 42; // simulated previous demand
const mock = registerMockGroup(ps, 'mgc1');
ps.calibratePredictedLevel(1.5); // between minLevel=1 and startLevel=2
await ps._controlLevelBased();
assert.equal(ps.percControl, 0);
// pct=0 → turnOff, no setDemand call (avoids MGC interpolating 0 % to dt.flow.min).
assert.equal(mock._calls.turnOff, 1);
assert.equal(mock._calls.setDemand.length, 0);
});
await t.test('filling: level between startLevel and inflowLevel ramps from startLevel (no implicit hold zone)', async () => {
const ps = new PumpingStation(makeConfig());
const mock = registerMockGroup(ps, 'mgc1');
ps.calibratePredictedLevel(2.5); // startLevel=2, inflowLevel=3, maxLevel=4
await ps._controlLevelBased('filling');
// Ramp foot = startLevel (NOT inflowLevel). lerp(2.5, [2, 4], [0, 100]) = 25.
assert.ok(Math.abs(ps.percControl - 25) < 1e-9, `expected ~25 %, got ${ps.percControl}`);
assert.equal(mock._calls.turnOff, 0, 'engaged — pumps must not be turned off in the ramp');
assert.equal(mock._calls.setDemand.length, 1);
assert.ok(Math.abs(mock._calls.setDemand[0][0] - 25) < 1e-9);
});
await t.test('filling: level ≥ maxLevel → percControl clamped at 100, routed via setDemand', async () => {
const ps = new PumpingStation(makeConfig());
const mock = registerMockGroup(ps, 'mgc1');
ps.calibratePredictedLevel(3.5); // 3/4 of the [2,4] ramp → 75 %.
await ps._controlLevelBased('filling');
assert.ok(Math.abs(ps.percControl - 75) < 1e-9, `expected ~75 %, got ${ps.percControl}`);
assert.equal(mock._calls.setDemand.length, 1);
assert.equal(mock._calls.setDemand[0][1], '%');
assert.ok(Math.abs(mock._calls.setDemand[0][0] - 75) < 1e-9);
});
await t.test('filling: holdLevel raises the ramp foot — explicit hold band [startLevel, holdLevel] sits at 0 %', async () => {
const ps = new PumpingStation(makeConfig({
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased']),
levelbased: { minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
},
}));
const mock = registerMockGroup(ps, 'mgc1');
ps.calibratePredictedLevel(2.5); // inside [startLevel, holdLevel]
await ps._controlLevelBased('filling');
assert.equal(ps.percControl, 0);
assert.equal(mock._calls.turnOff, 0, 'engaged — hold band runs at MGC flow.min, not off');
assert.deepEqual(mock._calls.setDemand[0], [0, '%']);
});
await t.test('shift disabled (default): foot stays at startLevel — falling levels track the ramp down to startLevel', async () => {
const ps = new PumpingStation(makeConfig());
registerMockGroup(ps, 'mgc1');
// Climb above startLevel, then fall to a level inside [start, inflow]. With
// the new semantics (ramp foot = startLevel, NOT inflowLevel) the falling
// level still produces a positive demand on the way down.
ps.calibratePredictedLevel(3.8);
await ps._controlLevelBased();
assert.ok(ps.percControl > 0);
ps.calibratePredictedLevel(2.5); // startLevel=2, maxLevel=4 → 25 %
await ps._controlLevelBased();
assert.ok(Math.abs(ps.percControl - 25) < 1e-9, `expected 25 % on the down ramp, got ${ps.percControl}`);
});
await t.test('shift enabled: arming on % threshold + hold-then-ramp on draining (with holdLevel pinning the foot)', async () => {
// The original shifted-ramp test was authored against the legacy ramp
// foot = inflowLevel (=3). With the new defaults the foot moves to
// startLevel (=2), which changes every percentage in the trace. Pin
// the foot back to 3 by setting holdLevel = 3 — that keeps this test's
// arithmetic self-consistent: up curve goes 0 %@3 to 100 %@4.
// shiftArmPercent=80 ⇒ arms when up curve ≥ 80 % i.e. level ≥ 3.8.
// shiftLevel=3.5 ⇒ held output starts ramping down at this level.
const ps = new PumpingStation(makeConfig({
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased']),
levelbased: {
minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4, curveType: 'linear', logCurveFactor: 9,
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
},
},
}));
registerMockGroup(ps, 'mgc1');
// Filling at level=3.5 ⇒ up curve = 50 %, below arm threshold ⇒ not armed.
ps.calibratePredictedLevel(3.5);
await ps._controlLevelBased('filling');
assert.equal(ps._shiftArmed, false);
assert.ok(Math.abs(ps.percControl - 50) < 1e-9);
// Filling at level=3.85 ⇒ up curve = 85 % ≥ arm threshold ⇒ ARM.
ps.calibratePredictedLevel(3.85);
await ps._controlLevelBased('filling');
assert.equal(ps._shiftArmed, true);
assert.ok(Math.abs(ps.percControl - 85) < 1e-9); // still up curve while filling
// Direction flips to draining at the same level ⇒ capture hold ≈ 85 %.
await ps._controlLevelBased('draining');
assert.ok(Math.abs(ps._shiftHoldValue - 85) < 1e-6);
// While draining and level ≥ shiftLevel ⇒ output stays at hold (≈85 %).
ps.calibratePredictedLevel(3.6);
await ps._controlLevelBased('draining');
assert.ok(Math.abs(ps.percControl - 85) < 1e-6);
// Below shiftLevel: ramp [shift, hold] → [start, 0]. At level=2.75
// (midpoint of [2, 3.5]), x=0.5, output ≈ 85 × 0.5 = 42.5 %.
ps.calibratePredictedLevel(2.75);
await ps._controlLevelBased('draining');
assert.ok(Math.abs(ps.percControl - 42.5) < 1e-6);
// Below startLevel ⇒ output 0 % AND disarm.
ps.calibratePredictedLevel(1.9);
await ps._controlLevelBased('draining');
assert.equal(ps.percControl, 0);
assert.equal(ps._shiftArmed, false);
assert.equal(ps._shiftHoldValue, null);
});
await t.test('shift enabled: returning to filling clears hold; new hold captured on next drain', async () => {
const ps = new PumpingStation(makeConfig({
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased']),
levelbased: {
// Pin the ramp foot at 3 via holdLevel — keeps legacy arithmetic
// self-consistent with the original test (up curve 0 %@3 → 100 %@4).
minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4, curveType: 'linear', logCurveFactor: 9,
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
},
},
}));
registerMockGroup(ps, 'mgc1');
ps.calibratePredictedLevel(3.85);
await ps._controlLevelBased('filling');
await ps._controlLevelBased('draining');
assert.ok(Math.abs(ps._shiftHoldValue - 85) < 1e-6);
// Direction back to filling ⇒ up curve, hold cleared, still armed.
ps.calibratePredictedLevel(3.9);
await ps._controlLevelBased('filling');
assert.equal(ps._shiftHoldValue, null);
assert.equal(ps._shiftArmed, true);
assert.ok(Math.abs(ps.percControl - 90) < 1e-6); // up curve at 3.9 = 90 %
// Flip to draining again at higher level ⇒ new hold ≈ 90 %.
await ps._controlLevelBased('draining');
assert.ok(Math.abs(ps._shiftHoldValue - 90) < 1e-6);
});
await t.test('log curve has fast early response', async () => {
const ps = new PumpingStation(makeConfig({
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased']),
// holdLevel=3 keeps ramp foot at 3 so x=0.5 means level=3.5, matching
// the legacy assertion bracket.
levelbased: { minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4, curveType: 'log', logCurveFactor: 9 },
},
}));
registerMockGroup(ps, 'mgc1');
ps.calibratePredictedLevel(3.5); // x=0.5 on filling ramp [3,4]
await ps._controlLevelBased('filling');
assert.ok(ps.percControl > 50);
assert.ok(ps.percControl < 100);
});
await t.test('level > maxLevel → percControl ≥ 100 (MGC clamps internally)', async () => {
const ps = new PumpingStation(makeConfig());
registerMockGroup(ps, 'mgc1');
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);
assert.equal(out.inletPipeDiameter, 0.4);
assert.equal(out.outletPipeDiameter, 0.3);
assert.ok(Math.abs(out.highVolumeSafetyLevel - 4.41) < 1e-9);
assert.ok(Math.abs(out.dryRunLevel - 0.204) < 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);
});
// _updatePredictedVolume now clamps [dryRunSafetyVol, maxVolAtOverflow] and
// tracks any excess as cumulative `overflowVolume` plus a synthetic
// `flow.predicted.out.overflow` rate so net-flow balance stays at ~0 while
// pinned. We drive ticks manually with monotonic timestamps to keep tests
// deterministic (Date.now() in the integrator can step by 0 ms in fast loops).
test('Predicted volume — overflow clamp and spill tracking', async (t) => {
const ps = new PumpingStation(makeConfig({
safety: { enableDryRunProtection: false, enableHighVolumeSafety: false, dryRunThresholdPercent: 0 },
}));
// Seed predicted volume just below the spill point.
// maxVolAtOverflow = overflowLevel × area = 4.5 × 10 = 45 m³.
const t0 = 1_700_000_000_000;
ps.calibratePredictedVolume(44, t0);
// Heavy inflow, no real outflow (no pumps wired).
ps.setManualInflow(2, t0, 'm3/s'); // 2 m³/s, dt=1s → 2 m³/tick
await t.test('first overflow tick clamps volume and records spill increment', () => {
ps._predictedFlowState = { inflow: 2, outflow: 0, lastTimestamp: t0 };
Date.now = () => t0 + 1000;
ps._updatePredictedVolume();
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
assert.equal(vol, 45); // pinned at overflow
const cumulative = ps.measurements.type('overflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3');
assert.equal(cumulative, 1); // proposed=44+2=46, excess=1 m³ this tick
const spill = ps.measurements.type('flow').variant('predicted').position('overflow').getCurrentValue('m3/s');
assert.equal(spill, 2); // instantaneous balance: inflow outflowReal
});
await t.test('subsequent ticks accumulate full inflow as spill (stable)', () => {
Date.now = () => t0 + 2000;
ps._updatePredictedVolume();
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
assert.equal(vol, 45);
const cumulative = ps.measurements.type('overflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3');
assert.equal(cumulative, 3); // 1 + 2
const spill = ps.measurements.type('flow').variant('predicted').position('overflow').getCurrentValue('m3/s');
assert.equal(spill, 2);
});
await t.test('predicted net flow reads ~0 while pinned at overflow', () => {
const net = ps._selectBestNetFlow();
// inflow=2, outflow_total=2 (synthetic spill), net = 0
assert.ok(Math.abs(net.value) < 1e-9);
assert.equal(net.source, 'predicted');
});
await t.test('once inflow stops, spill flow clears and clamp releases', () => {
ps.setManualInflow(0, t0 + 2000, 'm3/s');
ps._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: t0 + 2000 };
Date.now = () => t0 + 3000;
ps._updatePredictedVolume();
const spill = ps.measurements.type('flow').variant('predicted').position('overflow').getCurrentValue('m3/s');
assert.equal(spill, 0);
// Volume stays at 45 (no draining force) but is no longer "pinned".
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
assert.equal(vol, 45);
});
});
test('Predicted volume — dry-run lower clamp', async (t) => {
const ps = new PumpingStation(makeConfig({
// dryRunSafetyVol = minVolAtOutflow × (1 + 5/100) = 2 × 1.05 = 2.1 m³
safety: { enableDryRunProtection: true, dryRunThresholdPercent: 5 },
}));
const t0 = 1_700_000_000_000;
await t.test('initial seed below dryRunSafetyVol is left alone (no upward bump)', () => {
// Seed defaults to minVol=2 (below dryRunSafetyVol=2.1).
ps._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: t0 };
Date.now = () => t0 + 1000;
ps._updatePredictedVolume();
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
assert.equal(vol, 2); // unchanged — clamp doesn't fire because we started below it
});
await t.test('drain across dryRunSafetyVol clamps at the threshold', () => {
// Calibrate well above, then push outflow that would cross the threshold.
ps.calibratePredictedVolume(3, t0 + 1000);
// outflow=2 m³/s for 1s → would drop to 1; clamp catches at 2.1.
ps.setManualOutflow(2, t0 + 1000, 'm3/s');
ps._predictedFlowState = { inflow: 0, outflow: 2, lastTimestamp: t0 + 1000 };
Date.now = () => t0 + 2000;
ps._updatePredictedVolume();
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
assert.ok(Math.abs(vol - 2.1) < 1e-9);
});
});
test('getOutput — exposes predictedOverflowVolume / predictedOverflowRate', () => {
const ps = new PumpingStation(makeConfig());
// Seed an overflow scenario.
const t0 = 1_700_000_000_000;
ps.calibratePredictedVolume(44, t0);
ps.setManualInflow(2, t0, 'm3/s');
ps._predictedFlowState = { inflow: 2, outflow: 0, lastTimestamp: t0 };
Date.now = () => t0 + 1000;
ps._updatePredictedVolume();
const out = ps.getOutput();
assert.equal(out.predictedOverflowVolume, 1);
assert.equal(out.predictedOverflowRate, 2);
});
// Hard physical floor at 0. The dryRunSafetyVol clamp only fires on transition
// from above, so a basin seeded below + continued outflow used to integrate
// the volume arbitrarily negative. The level helper masked this by flooring
// at 0 in _calcLevelFromVolume — fix is to floor the integrator itself.
test('Predicted volume — physical floor at 0 (underflow track)', async (t) => {
const ps = new PumpingStation(makeConfig({
safety: { enableDryRunProtection: true, dryRunThresholdPercent: 5 },
}));
const t0 = 1_700_000_000_000;
await t.test('seeded below dryRun + continued outflow does NOT go negative', () => {
ps.calibratePredictedVolume(0.5, t0); // below dryRunSafetyVol (2.1)
ps.setManualOutflow(2, t0, 'm3/s'); // 2 m³/s for 1s → would drop to -1.5
ps._predictedFlowState = { inflow: 0, outflow: 2, lastTimestamp: t0 };
Date.now = () => t0 + 1000;
ps._updatePredictedVolume();
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
assert.equal(vol, 0); // floored at 0, not -1.5
const underflow = ps.measurements
.type('underflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3');
assert.equal(underflow, 1.5); // tracked as diagnostic
});
await t.test('subsequent ticks accumulate underflow while outflow continues', () => {
Date.now = () => t0 + 2000;
ps._predictedFlowState = { inflow: 0, outflow: 2, lastTimestamp: t0 + 1000 };
ps._updatePredictedVolume();
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
assert.equal(vol, 0);
const underflow = ps.measurements
.type('underflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3');
assert.equal(underflow, 3.5); // 1.5 + 2.0
});
await t.test('getOutput exposes predictedUnderflowVolume', () => {
const out = ps.getOutput();
assert.equal(out.predictedUnderflowVolume, 3.5);
});
await t.test('inflow returns and basin refills from 0 (no jump to dryRunSafetyVol)', () => {
ps.setManualInflow(1, t0 + 2000, 'm3/s');
ps.setManualOutflow(0, t0 + 2000, 'm3/s');
ps._predictedFlowState = { inflow: 1, outflow: 0, lastTimestamp: t0 + 2000 };
Date.now = () => t0 + 3000;
ps._updatePredictedVolume();
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
assert.ok(Math.abs(vol - 1) < 1e-9); // 0 + 1 = 1, NOT pinned to 2.1
});
});