Files
pumpingStation/test/basic/flowAggregator.basic.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

184 lines
8.0 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 tests for FlowAggregator. Pure node:test, no Node-RED runtime.
const test = require('node:test');
const assert = require('node:assert/strict');
const { MeasurementContainer } = require('generalFunctions');
const FlowAggregator = require('../../src/measurement/flowAggregator');
function makeBasin() {
// Constant-cross-section basin: 50 m3 / 5 m height ⇒ surfaceArea = 10 m2.
const surfaceArea = 10;
return {
surfaceArea,
minVol: 2,
maxVol: 50,
maxVolAtOverflow: 45, // overflow at 4.5 m
minVolAtOutflow: 2,
minVolAtInflow: 30,
overflowLevel: 4.5,
outflowLevel: 0.2,
inflowLevel: 3,
};
}
function makeMeasurements() {
return new MeasurementContainer({
autoConvert: true,
preferredUnits: { flow: 'm3/s', netFlowRate: 'm3/s', level: 'm', volume: 'm3' },
});
}
function makeAggregator(overrides = {}) {
const measurements = overrides.measurements || makeMeasurements();
const basin = overrides.basin || makeBasin();
// Seed predicted volume at minVol so update() has a starting point.
measurements.type('volume').variant('predicted').position('atequipment')
.value(basin.minVol).unit('m3');
const fa = new FlowAggregator({ measurements, basin, flowThreshold: 1e-4 });
return { fa, measurements, basin };
}
test('FlowAggregator.update integrates inflow-outflow over delta-t', async () => {
const { fa, measurements } = makeAggregator();
// Net flow = 0.01 m3/s (in) - 0.005 m3/s (out) = 0.005 m3/s.
const t0 = Date.now() - 10_000; // 10 s ago
measurements.type('flow').variant('predicted').position('in').child('src')
.value(0.01, t0, 'm3/s');
measurements.type('flow').variant('predicted').position('out').child('snk')
.value(0.005, t0, 'm3/s');
// Force the integrator to know we are starting 10 s in the past.
fa._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: t0 };
fa.update();
const vol = measurements.type('volume').variant('predicted').position('atequipment')
.getCurrentValue('m3');
// Expect minVol(2) + 0.005 * ~10 ≈ 2.05 m3. Allow slack for clock jitter.
assert.ok(vol > 2.04 && vol < 2.06, `volume after integration was ${vol}`);
});
test('FlowAggregator.update integrates measured inflow when predicted side is empty', async () => {
// Regression: a real upstream sensor writes `flow.measured.upstream.<id>`
// (the measurement node hard-codes variant='measured'), but the integrator
// used to read variant='predicted' only — so level stayed flat while the
// status row reported +N m³/h. The fix mirrors selectBestNetFlow's
// variant precedence per side.
const { fa, measurements } = makeAggregator();
const t0 = Date.now() - 10_000;
// Measured inflow at 'upstream' (one of the inflow position aliases),
// no outflow side at all.
measurements.type('flow').variant('measured').position('upstream').child('sensor-A')
.value(0.01, t0, 'm3/s');
fa._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: t0 };
fa.update();
const vol = measurements.type('volume').variant('predicted').position('atequipment')
.getCurrentValue('m3');
// Expect minVol(2) + 0.01 × ~10 ≈ 2.10 m3.
assert.ok(vol > 2.09 && vol < 2.11, `measured inflow did not integrate: vol=${vol}`);
});
test('FlowAggregator.update mixes measured inflow with predicted outflow', async () => {
// Realistic mix: real upstream sensor (measured) + pump-curve outflow
// (predicted). The picker resolves each side independently, so the net
// balance uses both.
const { fa, measurements } = makeAggregator();
const t0 = Date.now() - 10_000;
measurements.type('flow').variant('measured').position('upstream').child('sensor-A')
.value(0.01, t0, 'm3/s');
measurements.type('flow').variant('predicted').position('downstream').child('pump-A')
.value(0.004, t0, 'm3/s');
fa._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: t0 };
fa.update();
const vol = measurements.type('volume').variant('predicted').position('atequipment')
.getCurrentValue('m3');
// minVol(2) + (0.01 - 0.004) × ~10 ≈ 2.06 m3.
assert.ok(vol > 2.05 && vol < 2.07, `mixed-variant integration produced vol=${vol}`);
});
test('FlowAggregator.selectBestNetFlow prefers measured over predicted', async () => {
const { fa, measurements } = makeAggregator();
measurements.type('flow').variant('measured').position('in').child('m')
.value(0.02, Date.now(), 'm3/s');
measurements.type('flow').variant('measured').position('out').child('m')
.value(0.01, Date.now(), 'm3/s');
measurements.type('flow').variant('predicted').position('in').child('p')
.value(0.5, Date.now(), 'm3/s');
measurements.type('flow').variant('predicted').position('out').child('p')
.value(0.0, Date.now(), 'm3/s');
const r = fa.selectBestNetFlow();
assert.equal(r.source, 'measured');
assert.ok(Math.abs(r.value - 0.01) < 1e-9);
assert.equal(r.direction, 'filling');
});
test('FlowAggregator.selectBestNetFlow falls back to level rate when no flow', async () => {
const { fa, measurements, basin } = makeAggregator();
// Seed two level samples 2 s apart, rising 0.1 m → rate 0.05 m/s
// → net flow = 0.05 * 10 m2 = 0.5 m3/s (filling).
const t0 = Date.now() - 2_000;
const t1 = Date.now();
measurements.type('level').variant('measured').position('atequipment').child('default')
.value(1.0, t0, 'm');
measurements.type('level').variant('measured').position('atequipment').child('default')
.value(1.1, t1, 'm');
const r = fa.selectBestNetFlow();
assert.ok(r.source.startsWith('level:'), `source was ${r.source}`);
assert.equal(r.direction, 'filling');
assert.ok(Math.abs(r.value - basin.surfaceArea * 0.05) < 1e-3, `net flow was ${r.value}`);
});
test('FlowAggregator.deriveDirection threshold semantics', async () => {
const { fa } = makeAggregator();
assert.equal(fa.deriveDirection(0), 'steady');
assert.equal(fa.deriveDirection(fa.flowThreshold * 2), 'filling');
assert.equal(fa.deriveDirection(-fa.flowThreshold * 2), 'draining');
assert.equal(fa.deriveDirection(fa.flowThreshold * 0.5), 'steady');
assert.equal(fa.deriveDirection(-fa.flowThreshold * 0.5), 'steady');
});
test('FlowAggregator.computeRemainingTime — filling uses overflow ceiling', async () => {
const { fa, measurements, basin } = makeAggregator();
measurements.type('level').variant('predicted').position('atequipment')
.value(2.0, Date.now(), 'm');
// Net 0.05 m3/s upward; remaining height = 4.5 - 2.0 = 2.5 m.
// seconds = 2.5 * 10 / 0.05 = 500 s.
const r = fa.computeRemainingTime({ value: 0.05, source: 'measured', direction: 'filling' });
assert.ok(Math.abs(r.seconds - 500) < 1e-6, `seconds was ${r.seconds}`);
assert.equal(typeof r.source, 'string');
});
test('FlowAggregator.computeRemainingTime — draining uses outflow floor', async () => {
const { fa, measurements } = makeAggregator();
measurements.type('level').variant('predicted').position('atequipment')
.value(1.0, Date.now(), 'm');
// Net -0.05 m3/s; remaining height = 1.0 - 0.2 = 0.8 m.
// seconds = 0.8 * 10 / 0.05 = 160 s.
const r = fa.computeRemainingTime({ value: -0.05, source: 'measured', direction: 'draining' });
assert.ok(Math.abs(r.seconds - 160) < 1e-6, `seconds was ${r.seconds}`);
});
test('FlowAggregator.snapshot exposes the expected shape', async () => {
const { fa, measurements } = makeAggregator();
measurements.type('flow').variant('measured').position('in').child('m')
.value(0.02, Date.now(), 'm3/s');
fa.tick();
const snap = fa.snapshot();
assert.ok(Object.prototype.hasOwnProperty.call(snap, 'direction'));
assert.ok(Object.prototype.hasOwnProperty.call(snap, 'netFlow'));
assert.ok(Object.prototype.hasOwnProperty.call(snap, 'flowSource'));
assert.ok(Object.prototype.hasOwnProperty.call(snap, 'secondsRemaining'));
});
test('FlowAggregator.computeRemainingTime — below threshold returns null seconds', async () => {
const { fa } = makeAggregator();
const r = fa.computeRemainingTime({ value: 0, source: null, direction: 'steady' });
assert.equal(r.seconds, null);
});