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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user