Merge origin/basin-docs-update: per-mode SVG + stopLevel hysteresis + shifted ramp

Reconciles the 7-commit basin-docs-update feature branch (which never
landed on main before the platform refactor) with the post-refactor
architecture on development. Each basin-docs feature ported into the
relevant concern module:

  control/levelBased.js
    - stopLevel Schmitt-trigger + dead-band keep-alive
    - Shifted ramp (arm % → hold @ 100% → ramp down to shiftLevel)
    - Linear vs log up-curve (curveType + logCurveFactor)

  measurement/flowAggregator.js
    - Predicted-volume overflow clamp + spill flow stream
    - Cumulative overflowVolume + underflowVolume
    - Hard floor at 0 + dry-run-on-transition handling

  basin/thresholdValidator.js
    - computeSafetyPoints exposes dryRunLevel + highVolumeSafetyLevel
    - startLevel ≤ inflowLevel invariant added

  measurement/calibration.js + commands/
    - Manual q_out path (set.outflow / q_out alias)

  safety/safetyController.js
    - Accepts both legacy + new high-volume threshold names

UI:
  pumpingStation.html — restored the side-panel + SVG mode-preview block,
  added defaults for stopLevel/shiftLevel/shiftArmPercent/levelCurveType/
  logCurveFactor/enableShiftedRamp.
  src/editor/* — basin-docs' 7-file modular editor (replaces single
  src/editor.js, which is deleted).
  pumpingStation.js — admin endpoint serves editor/:file.

Tests: 130/130 pass (125 basic + 5 integration). Two basin-docs test
files added: nodeClass-config.test.js, basic-dashboard-flow.test.js,
shifted-ramp-end-to-end.test.js. One pre-refactor control-levelBased
test adapted to match basin-docs canonical "no-shutdown in dead zone"
behaviour.

Human-review items (see commit context):
  - rampFoot = inflowLevel (matches basin-docs test); basin-docs source
    used rampFoot = startLevel. Domain owner: confirm intent.
  - Naming kept dual (overfillLevel + highVolumeSafetyLevel).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-05-11 16:19:55 +02:00
40 changed files with 3035 additions and 555 deletions

View File

@@ -63,15 +63,19 @@ test('level < minLevel → STOP: turnOffAllMachines on every group, percControl
}
});
test('minLevel ≤ level < startLevel → DEAD ZONE: no calls, percControl unchanged', async () => {
// basin-docs behavior: between minLevel and the active ramp foot, demand
// is commanded to 0 % (not "unchanged"). MGC still receives the command;
// only the explicit minLevel hard-stop path skips handleInput.
test('minLevel ≤ level < ramp foot → commands 0 % without shutdown', async () => {
const ctx = makeCtx(1.5);
const state = { percControl: 17 };
await levelBased.run(ctx, state);
assert.equal(state.percControl, 17, 'percControl untouched in dead zone');
assert.equal(state.percControl, 0, 'percControl driven to 0 in the hold zone');
for (const g of Object.values(ctx.machineGroups)) {
assert.equal(g._calls.turnOff, 0);
assert.equal(g._calls.handleInput.length, 0);
assert.equal(g._calls.handleInput.length, 1, 'one demand=0 forward per group');
assert.deepEqual(g._calls.handleInput[0], ['parent', 0]);
}
});

View File

@@ -0,0 +1,74 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const NodeClass = require('../../src/nodeClass');
function loadConfig(uiConfig = {}) {
const ctx = { name: 'pumpingStation' };
NodeClass.prototype._loadConfig.call(ctx, {
name: 'PS Config Test',
basinVolume: 80,
basinHeight: 8,
inflowLevel: 3.2,
outflowLevel: 0.4,
overflowLevel: 7.4,
inletPipeDiameter: 0.5,
outletPipeDiameter: 0.35,
refHeight: 'NAP',
minHeightBasedOn: 'outlet',
basinBottomRef: -1.2,
maxInflowRate: 300,
staticHead: 11,
maxDischargeHead: 22,
pipelineLength: 120,
defaultFluid: 'wastewater',
temperatureReferenceDegC: 16,
controlMode: 'levelbased',
minLevel: 0.8,
startLevel: 2,
maxLevel: 6.5,
levelCurveType: 'log',
logCurveFactor: 7,
enableDryRunProtection: true,
dryRunThresholdPercent: 3,
enableHighVolumeSafety: true,
highVolumeSafetyThresholdPercent: 96,
timeleftToFullOrEmptyThresholdSeconds: 60,
processOutputFormat: 'process',
dbaseOutputFormat: 'influxdb',
...uiConfig,
}, { id: 'node-1' });
return ctx.config;
}
test('nodeClass config mapping — basin, hydraulics, mode and safety fields', () => {
const cfg = loadConfig();
assert.equal(cfg.basin.inletPipeDiameter, 0.5);
assert.equal(cfg.basin.outletPipeDiameter, 0.35);
assert.equal(cfg.hydraulics.maxInflowRate, 300);
assert.equal(cfg.hydraulics.staticHead, 11);
assert.equal(cfg.hydraulics.maxDischargeHead, 22);
assert.equal(cfg.hydraulics.pipelineLength, 120);
assert.equal(cfg.hydraulics.defaultFluid, 'wastewater');
assert.equal(cfg.hydraulics.temperatureReferenceDegC, 16);
assert.equal(cfg.control.mode, 'levelbased');
assert.equal(cfg.control.levelbased.curveType, 'log');
assert.equal(cfg.control.levelbased.logCurveFactor, 7);
assert.equal(cfg.safety.enableHighVolumeSafety, true);
assert.equal(cfg.safety.highVolumeSafetyThresholdPercent, 96);
assert.equal(cfg.output.process, 'process');
assert.equal(cfg.output.dbase, 'influxdb');
});
test('nodeClass config mapping — accepts deprecated overfill UI fields', () => {
const cfg = loadConfig({
enableHighVolumeSafety: undefined,
highVolumeSafetyThresholdPercent: undefined,
enableOverfillProtection: false,
overfillThresholdPercent: 91,
});
assert.equal(cfg.safety.enableHighVolumeSafety, false);
assert.equal(cfg.safety.highVolumeSafetyThresholdPercent, 91);
});

View File

@@ -27,6 +27,8 @@ function makeConfig(overrides = {}) {
inflowLevel: 3,
outflowLevel: 0.2,
overflowLevel: 4.5,
inletPipeDiameter: 0.4,
outletPipeDiameter: 0.3,
},
hydraulics: {
refHeight: 'NAP',
@@ -36,12 +38,13 @@ function makeConfig(overrides = {}) {
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased', 'manual']),
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4 },
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
},
safety: {
enableDryRunProtection: false,
enableOverfillProtection: false,
dryRunThresholdPercent: 2,
highVolumeSafetyThresholdPercent: 98,
overfillThresholdPercent: 98,
timeleftToFullOrEmptyThresholdSeconds: 0,
},
@@ -80,6 +83,10 @@ test('Basin geometry — derived values', async (t) => {
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) => {
@@ -131,6 +138,17 @@ test('Threshold guardrails — _validateThresholdOrdering', async (t) => {
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'startLevel'));
});
await t.test('startLevel > inflowLevel flagged for levelbased rising hold zone', () => {
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'));
});
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 },
@@ -223,20 +241,22 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
assert.equal(turnOffCalls, 1);
});
await t.test('minLevel ≤ level < startLevel → dead zone, percControl unchanged', async () => {
await t.test('minLevel ≤ level < active ramp start → commands 0% without shutdown', async () => {
const ps = new PumpingStation(makeConfig());
ps.percControl = 42; // simulated previous demand
const demands = [];
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => {},
handleInput: async () => { throw new Error('should not be called in dead zone'); },
handleInput: async (_src, d) => { demands.push(d); },
};
ps.calibratePredictedLevel(1.5); // between minLevel=1 and startLevel=2
await ps._controlLevelBased();
assert.equal(ps.percControl, 42); // unchanged
assert.equal(ps.percControl, 0);
assert.equal(demands[0], 0);
});
await t.test('level ≥ startLevel → percControl linearly scaled to [0,100]', async () => {
await t.test('filling: level between startLevel and inflowLevel commands 0%', async () => {
const ps = new PumpingStation(makeConfig());
const demands = [];
ps.machineGroups['mgc1'] = {
@@ -244,14 +264,144 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
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
ps.calibratePredictedLevel(2.5); // startLevel=2, inflowLevel=3
await ps._controlLevelBased('filling');
assert.equal(ps.percControl, 0);
assert.equal(demands[0], 0);
});
await t.test('filling: level ≥ inflowLevel → 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.5); // midpoint of inflowLevel=3 and maxLevel=4
await ps._controlLevelBased('filling');
// lerp(3.5, [3,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('shift disabled (default): foot stays at inflowLevel even after fall', async () => {
const ps = new PumpingStation(makeConfig());
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => {},
handleInput: async () => {},
};
// Climb past inflowLevel and beyond, then fall to a level inside [start..inflow].
ps.calibratePredictedLevel(3.8);
await ps._controlLevelBased();
assert.ok(ps.percControl > 0);
ps.calibratePredictedLevel(2.5); // between startLevel=2 and inflowLevel=3
await ps._controlLevelBased();
// Without shift the foot is inflowLevel → 0% in the hold zone.
assert.equal(ps.percControl, 0);
});
await t.test('shift enabled: arming on % threshold + hold-then-ramp on draining', async () => {
// Geometry: inflow=3, max=4 → 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, maxLevel: 4, curveType: 'linear', logCurveFactor: 9,
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
},
},
}));
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => {},
handleInput: async () => {},
};
// 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: {
minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9,
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
},
},
}));
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => {},
handleInput: async () => {},
};
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']),
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'log', logCurveFactor: 9 },
},
}));
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => {},
handleInput: async () => {},
};
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());
ps.machineGroups['mgc1'] = {
@@ -275,6 +425,10 @@ test('getOutput — flattens basin + state + demand', async (t) => {
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();
@@ -293,3 +447,155 @@ test('Manual inflow — setManualInflow stores predicted inflow', async (t) => {
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
});
});

View File

@@ -0,0 +1,94 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
function loadDashboardFlow() {
const flowPath = path.join(__dirname, '../../examples/basic-dashboard.flow.json');
return JSON.parse(fs.readFileSync(flowPath, 'utf8'));
}
function makeContextStub() {
const store = {};
return {
get(key) {
return store[key];
},
set(key, value) {
store[key] = value;
},
};
}
test('basic dashboard flow contains the pumpingStation node and trend widgets', () => {
const flow = loadDashboardFlow();
const ps = flow.find((n) => n.id === 'ps_node_basic');
const parser = flow.find((n) => n.id === 'ps_parse_output');
const levelChart = flow.find((n) => n.id === 'ps_chart_level');
const demandChart = flow.find((n) => n.id === 'ps_chart_demand');
assert.ok(ps, 'ps_node_basic should exist');
assert.equal(ps.type, 'pumpingStation');
assert.equal(ps.controlMode, 'levelbased');
assert.equal(ps.levelCurveType, 'linear');
assert.equal(ps.inletPipeDiameter, 0.4);
assert.equal(ps.outletPipeDiameter, 0.3);
assert.ok(parser, 'ps_parse_output should exist');
assert.equal(parser.outputs, 6);
assert.equal(levelChart.type, 'ui-chart');
assert.equal(demandChart.type, 'ui-chart');
});
test('basic dashboard parser routes process fields to charts and state text', () => {
const flow = loadDashboardFlow();
const parser = flow.find((n) => n.id === 'ps_parse_output');
assert.ok(parser, 'ps_parse_output should exist');
const func = new Function('msg', 'context', 'node', parser.func);
const context = makeContextStub();
const node = { send() {} };
// Flatten format is `${type}.${variant}.${position}.${childId}`. When the
// runtime writes without an explicit .child(), childId='default'. Mirror
// the real shape here. (See generalFunctions/src/measurements/
// MeasurementContainer.js getFlattenedOutput.)
const out = func({
payload: {
'level.predicted.atequipment.default': 3.25,
'volume.predicted.atequipment.default': 32.5,
'netFlowRate.predicted.atequipment.default': 0.003,
percControl: 25,
direction: 'filling',
safetyState: 'normal',
isOverflowing: false,
timeleft: 400,
},
}, context, node);
assert.ok(Array.isArray(out));
assert.equal(out.length, 6);
assert.equal(out[0].topic, 'level');
assert.equal(out[0].payload, 3.25);
assert.equal(out[1].topic, 'volume');
assert.equal(out[1].payload, 32.5);
assert.equal(out[2].topic, 'demand');
assert.equal(out[2].payload, 25);
assert.equal(out[3].topic, 'net_flow');
assert.equal(out[3].payload, 0.003);
assert.match(out[4].payload, /normal/);
assert.match(out[5].payload, /level=3.25 m/);
});
test('basic dashboard parser keeps previous values when process output sends only changed fields', () => {
const flow = loadDashboardFlow();
const parser = flow.find((n) => n.id === 'ps_parse_output');
const func = new Function('msg', 'context', 'node', parser.func);
const context = makeContextStub();
const node = { send() {} };
func({ payload: { 'level.predicted.atequipment.default': 3.1, percControl: 10 } }, context, node);
const out = func({ payload: { percControl: 20 } }, context, node);
assert.equal(out[0].payload, 3.1);
assert.equal(out[2].payload, 20);
});

View File

@@ -0,0 +1,198 @@
// End-to-end test for the level-armed hysteresis (shifted ramp) cycle.
// Drives a full fill→arm→drain cycle through the same code path the
// dashboard exercises (manual Q_IN / Q_OUT + tick), and asserts the
// hold-then-ramp output behaviour.
//
// Run with: node --test test/integration/shifted-ramp-end-to-end.test.js
const test = require('node:test');
const assert = require('node:assert/strict');
const PumpingStation = require('../../src/specificClass');
const SURFACE_AREA = 10; // basin volume / height = 50/5
const TICK_MS = 1000; // simulate 1 s per tick
function makeConfig() {
return {
general: {
name: 'TestPS',
id: 'ps-e2e',
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,
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
},
},
safety: {
enableDryRunProtection: false, enableOverfillProtection: false,
dryRunThresholdPercent: 2, highVolumeSafetyThresholdPercent: 98,
overfillThresholdPercent: 98, timeleftToFullOrEmptyThresholdSeconds: 0,
},
};
}
// Build a PS with a fake MGC that captures every demand sent to it,
// and a clock we control so _updatePredictedVolume integrates over a
// known dt regardless of wall-clock.
function buildHarness() {
const ps = new PumpingStation(makeConfig());
const demands = [];
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => {},
handleInput: async (_src, d) => { demands.push(d); },
};
// Seed level at startLevel so the run begins idle.
ps.calibratePredictedLevel(2.0);
// Override Date.now via a controllable clock that advances `step()`.
let now = ps._predictedFlowState.lastTimestamp || 0;
ps._fakeNow = () => now;
ps._fakeAdvance = (ms) => { now += ms; };
// Patch global Date.now JUST inside the scope of these tests.
const realNow = Date.now;
Date.now = ps._fakeNow;
// Restore on completion.
ps._restore = () => { Date.now = realNow; };
return { ps, demands };
}
async function step(ps, qIn, qOut) {
// Apply the manual Q_IN / Q_OUT (mirroring the dashboard's q_in / q_out
// topic handlers in nodeClass.js), advance time, then tick once.
if (Number.isFinite(qIn)) ps.setManualInflow(qIn, Date.now(), 'm3/s');
if (Number.isFinite(qOut)) ps.setManualOutflow(qOut, Date.now(), 'm3/s');
ps._fakeAdvance(TICK_MS);
ps.tick();
}
function levelOf(ps) {
return ps.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m');
}
test('shifted ramp e2e: arm → hold → ramp-down → disarm', async () => {
const { ps } = buildHarness();
try {
// ─── PHASE A: fill from start (2.0) up past the arm point ──────────
// Q_IN = 0.05 m3/s, Q_OUT = 0 → net = 0.05 m3/s. Level rises by
// 0.05/SURFACE_AREA = 0.005 m per second.
let armedAt = null;
for (let i = 0; i < 600 && levelOf(ps) < 3.95; i++) {
await step(ps, 0.05, 0);
if (!armedAt && ps._shiftArmed) armedAt = { level: levelOf(ps), pct: ps.percControl };
}
assert.ok(armedAt, 'shift should arm during fill');
// Should arm right around level=3.8 (up curve = 80 %). Allow ±0.05 m
// jitter for time-discretization.
assert.ok(Math.abs(armedAt.level - 3.8) < 0.05,
`expected arm near level=3.8, got ${armedAt.level}`);
assert.ok(armedAt.pct >= 80 - 1e-6,
`at arm point output should be ≥ shiftArmPercent, got ${armedAt.pct}`);
// While still filling and armed, output should track the up curve
// (not jump to 100 %). At level ~ 3.95, up curve = 95 %.
const fillingPct = ps.percControl;
assert.ok(fillingPct < 100 + 1e-6 && fillingPct >= 80 - 1e-6,
`filling-armed output should still be on up curve, got ${fillingPct}`);
// No hold captured yet (still filling).
assert.equal(ps._shiftHoldValue, null);
// ─── PHASE B: flip to draining ─────────────────────────────────────
// First drain tick captures the hold. We need direction='draining' as
// determined by _selectBestNetFlow → so q_in - q_out must be negative
// by more than the dead-band (1e-4).
await step(ps, 0, 0.05); // net = -0.05
assert.equal(ps.state.direction, 'draining');
// Hold captured = up curve at the level when direction flipped. The
// captured value is recorded BEFORE this drain tick lowered the level
// further, so it should match the last filling tick's output (within
// the per-tick step size 0.5 % ~ 0.005 m × 100 / 1 m).
assert.ok(ps._shiftHoldValue >= 80 - 1e-6,
`hold should be at least the arm threshold, got ${ps._shiftHoldValue}`);
const hold = ps._shiftHoldValue;
// ─── PHASE C: drain while level still ≥ shiftLevel — output HELD ───
// Drain until level just above shiftLevel=3.5. Output stays = hold.
let held = true;
for (let i = 0; i < 200 && levelOf(ps) > 3.51; i++) {
await step(ps, 0, 0.05);
if (Math.abs(ps.percControl - hold) > 1e-6) { held = false; break; }
}
assert.ok(held, 'output should HOLD at the captured value while level > shiftLevel');
assert.ok(Math.abs(ps.percControl - hold) < 1e-6,
`still expected hold=${hold}, got ${ps.percControl}`);
// ─── PHASE D: drain past shiftLevel — output ramps hold→0 ──────────
// Drain until clearly below shiftLevel (level ≤ 3.45). Output should drop.
while (levelOf(ps) > 3.45) await step(ps, 0, 0.05);
const justBelow = ps.percControl;
assert.ok(justBelow < hold,
`output should start dropping below shiftLevel, got ${justBelow} vs hold ${hold}`);
// Ramp midpoint: level=2.75 (midway in [2, 3.5]). Output ≈ hold × 0.5.
while (levelOf(ps) > 2.78 && levelOf(ps) > 2.0) await step(ps, 0, 0.05);
const mid = ps.percControl;
assert.ok(Math.abs(mid - hold * 0.5) < hold * 0.05,
`at level≈2.75 expected ≈ hold/2 (${hold * 0.5}), got ${mid}`);
// ─── PHASE E: level drops to startLevel — DISARM, output 0 ─────────
while (levelOf(ps) > 1.95) await step(ps, 0, 0.05);
assert.equal(ps._shiftArmed, false, 'should disarm when level reaches startLevel');
assert.equal(ps._shiftHoldValue, null);
assert.equal(ps.percControl, 0);
} finally {
ps._restore();
}
});
test('shifted ramp e2e: bounce — fill, drain a bit, refill, drain — captures fresh hold', async () => {
const { ps } = buildHarness();
try {
// Fill to arm + some headroom.
while (levelOf(ps) < 3.85) await step(ps, 0.05, 0);
assert.equal(ps._shiftArmed, true);
// First drain transition → hold #1.
await step(ps, 0, 0.05);
const hold1 = ps._shiftHoldValue;
assert.ok(hold1 >= 80 - 1e-6);
// Drain a tiny bit (level still > shiftLevel) → output stays at hold1.
for (let i = 0; i < 5; i++) await step(ps, 0, 0.05);
assert.ok(Math.abs(ps.percControl - hold1) < 1e-6);
// Flip back to filling at higher rate; up curve resumes; hold cleared.
await step(ps, 0.05, 0);
assert.equal(ps._shiftHoldValue, null);
assert.equal(ps._shiftArmed, true, 'should stay armed across the bounce');
// Fill higher than before (output goes higher).
while (levelOf(ps) < 3.95) await step(ps, 0.05, 0);
const fillingPct = ps.percControl;
assert.ok(fillingPct > hold1, `bounce should rise above first hold; got ${fillingPct} vs ${hold1}`);
// Drain again → fresh hold #2 = current up curve %.
await step(ps, 0, 0.05);
const hold2 = ps._shiftHoldValue;
assert.ok(hold2 > hold1, `second hold (${hold2}) should be > first (${hold1})`);
} finally {
ps._restore();
}
});