Level-armed shift, derived dryRunLevel, side-panel editor + manual q_out

Runtime (specificClass.js):
- Replace direction-based hysteresis with level-armed _shiftArmed state.
  Arms when level rises past shiftLevel; disarms when level drops below
  startLevel. While armed, ramp foot moves to startLevel and ramp top
  to shiftLevel — both ends shift left, then saturate at 100 % up to
  maxLevel.
- _scaleLevelToFlowPercent now takes (rampStartLevel, rampTopLevel) so
  the saturation point follows the shift state.
- New setManualOutflow mirroring setManualInflow.

Adapter (nodeClass.js):
- Pipe enableShiftedRamp / shiftLevel through to control.levelbased.
- New q_out topic handler.

Editor (pumpingStation.html + new src/editor/ modules):
- Split monolithic <script> into modules: index.js (helpers),
  basin-diagram.js, mode-preview.js, hover-couple.js, oneditprepare.js,
  oneditsave.js — served via /pumpingStation/editor/:file.
- Mode preview redrawn per the SVG diagrams: OFF tier below 0 %, 0 %
  flat from start→inlet, ramp inlet→max, optional shifted-down curve
  start→shift with 100 % saturation past shift.
- Mode preview gains zone bands (dryRun / safetyLow / safe / safetyHigh /
  overflow), level markers (dryRun derived, start, inlet, max, shift,
  overflow), validation ribbon that blocks save on bad ordering.
- Auto-default shiftLevel to 0.9 × maxLevel on enable so the marker is
  always visible.
- All level inputs moved to a side panel left of each diagram, color-
  coded to match line strokes; hover-couple highlights the paired SVG
  line on input focus / mouseover.
- Removed UI for non-static parameters: minHeightBasedOn,
  pipelineLength, maxDischargeHead, staticHead, defaultFluid,
  maxInflowRate, temperatureReferenceDegC,
  timeleftToFullOrEmptyThresholdSeconds, inletPipeDiameter,
  outletPipeDiameter, minLevel (now derived = dryRunLevel).
- foreignObject inputs in basin SVG removed (single source of truth in
  side panel).

Dashboard example (examples/basic-dashboard.flow.json):
- Add manual Q_OUT slider + q_out builder mirroring the existing q_in
  trio so the basin can be exercised end-to-end without a connected
  rotating-machine downstream.

Tests (test/basic/specificClass.test.js):
- Replace direction-shift test with two new cases covering shift-disabled
  hold-zone behaviour and shift-armed/disarmed transitions through
  shiftLevel and startLevel boundaries. 53/53 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rene De Ren
2026-05-05 19:29:34 +02:00
parent da50403c76
commit 8a6ca1baeb
12 changed files with 1877 additions and 487 deletions

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,101 @@ 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 when level crosses shiftLevel; foot moves to startLevel', 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,
},
},
}));
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => {},
handleInput: async () => {},
};
// Below shiftLevel: not yet armed → foot=inflowLevel, level 2.5 is in hold zone → 0%.
ps.calibratePredictedLevel(2.5);
await ps._controlLevelBased();
assert.equal(ps.percControl, 0);
assert.equal(ps._shiftArmed, false);
// Cross the shift trigger going up — at level >= shiftLevel, output saturates at 100%.
ps.calibratePredictedLevel(3.6);
await ps._controlLevelBased();
assert.equal(ps._shiftArmed, true);
assert.ok(ps.percControl >= 100 - 1e-9);
// Drop to midpoint of shifted ramp [start=2 .. shiftLevel=3.5] → x=0.5 → 50%.
ps.calibratePredictedLevel(2.75);
await ps._controlLevelBased();
assert.equal(ps._shiftArmed, true);
assert.ok(Math.abs(ps.percControl - 50) < 1e-9);
// Drop below startLevel → disarm.
ps.calibratePredictedLevel(1.9);
await ps._controlLevelBased();
assert.equal(ps._shiftArmed, false);
});
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 +382,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();