Compare commits
1 Commits
d8490aa949
...
6ab585bcc2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ab585bcc2 |
@@ -5,5 +5,6 @@ Wet-well basin model and pump orchestration node for EVOLV.
|
|||||||
The detailed documentation lives in [`wiki/`](wiki/):
|
The detailed documentation lives in [`wiki/`](wiki/):
|
||||||
|
|
||||||
- [`wiki/functional-description.md`](wiki/functional-description.md) defines the shared basin model, pipe reference semantics, safety points, net-flow selection, and child registration behaviour.
|
- [`wiki/functional-description.md`](wiki/functional-description.md) defines the shared basin model, pipe reference semantics, safety points, net-flow selection, and child registration behaviour.
|
||||||
- [`wiki/modes/`](wiki/modes/) documents control-mode-specific behaviour such as the level-linear `startLevel` demand ramp.
|
- [`wiki/modes/`](wiki/modes/) documents control-mode-specific behaviour. For v1.0 the editor exposes `levelbased` and `manual`; levelbased supports linear and log curves with separate rising/falling ramp semantics.
|
||||||
- [`wiki/diagrams/basin-model.drawio.svg`](wiki/diagrams/basin-model.drawio.svg) is the current source of truth for the generic basin model.
|
- [`wiki/diagrams/basin-model.drawio.svg`](wiki/diagrams/basin-model.drawio.svg) is the current source of truth for the generic basin model.
|
||||||
|
- [`examples/basic-dashboard.flow.json`](examples/basic-dashboard.flow.json) provides a simple Node-RED Dashboard 2 flow with level, volume, demand, net-flow, and safety-state trends.
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ module.exports = {
|
|||||||
| `max_level_bounded` | max level across the run must be `≤ value` |
|
| `max_level_bounded` | max level across the run must be `≤ value` |
|
||||||
| `min_level_bounded` | min level across the run must be `≥ value` |
|
| `min_level_bounded` | min level across the run must be `≥ value` |
|
||||||
| `max_demand_bounded` | max percControl must be `≤ value` |
|
| `max_demand_bounded` | max percControl must be `≤ value` |
|
||||||
|
| `max_demand_gt` | max percControl must be `> value` |
|
||||||
| `safety_trips_eq` | total ticks with `safetyActive` must equal `value` |
|
| `safety_trips_eq` | total ticks with `safetyActive` must equal `value` |
|
||||||
| `safety_trips_gt` | total ticks with `safetyActive` must be `> value` |
|
| `safety_trips_gt` | total ticks with `safetyActive` must be `> value` |
|
||||||
| `end_state_eq` | final record's `field` must equal `value` |
|
| `end_state_eq` | final record's `field` must equal `value` |
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ function evalExpectation(ex, records) {
|
|||||||
const v = Math.max(...demands);
|
const v = Math.max(...demands);
|
||||||
return { ok: v <= ex.value, msg: `max demand = ${v.toFixed(0)} % (bound: ≤ ${ex.value})` };
|
return { ok: v <= ex.value, msg: `max demand = ${v.toFixed(0)} % (bound: ≤ ${ex.value})` };
|
||||||
}
|
}
|
||||||
|
case 'max_demand_gt': {
|
||||||
|
const v = Math.max(...demands);
|
||||||
|
return { ok: v > ex.value, msg: `max demand = ${v.toFixed(0)} % (expected > ${ex.value})` };
|
||||||
|
}
|
||||||
case 'safety_trips_eq': {
|
case 'safety_trips_eq': {
|
||||||
const n = records.filter((r) => r.safetyActive).length;
|
const n = records.filter((r) => r.safetyActive).length;
|
||||||
return { ok: n === ex.value, msg: `${n} ticks with safetyActive (expected ${ex.value})` };
|
return { ok: n === ex.value, msg: `${n} ticks with safetyActive (expected ${ex.value})` };
|
||||||
|
|||||||
@@ -2,30 +2,30 @@
|
|||||||
//
|
//
|
||||||
// Expectation: with a stable inflow of 0.008 m³/s and a pump bank with
|
// Expectation: with a stable inflow of 0.008 m³/s and a pump bank with
|
||||||
// max capacity 0.012 m³/s, the level settles in the RAMP zone (between
|
// max capacity 0.012 m³/s, the level settles in the RAMP zone (between
|
||||||
// startLevel and maxLevel) at roughly the point where demand matches
|
// inflowLevel and maxLevel while filling) at roughly the point where demand matches
|
||||||
// inflow. No safety trips should fire.
|
// inflow. No safety trips should fire.
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
name: 'levelbased-steady',
|
name: 'levelbased-steady',
|
||||||
description: 'Constant sewer inflow below pump capacity; level converges inside the RAMP zone with demand matching inflow.',
|
description: 'Constant sewer inflow below pump capacity; level converges inside the RAMP zone with demand matching inflow.',
|
||||||
durationSec: 1200,
|
durationSec: 3600,
|
||||||
|
|
||||||
config: {
|
config: {
|
||||||
general: { name: 'EvalSteady', id: 'eval-steady', unit: 'm3/h',
|
general: { name: 'EvalSteady', id: 'eval-steady', unit: 'm3/h',
|
||||||
logging: { enabled: false, logLevel: 'error' } },
|
logging: { enabled: false, logLevel: 'error' } },
|
||||||
functionality: { softwareType: 'pumpingStation', role: 'stationcontroller', positionVsParent: 'atEquipment' },
|
functionality: { softwareType: 'pumpingStation', role: 'stationcontroller', positionVsParent: 'atEquipment' },
|
||||||
basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5 },
|
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' },
|
hydraulics: { refHeight: 'NAP', basinBottomRef: 0, minHeightBasedOn: 'outlet' },
|
||||||
control: {
|
control: {
|
||||||
mode: 'levelbased',
|
mode: 'levelbased',
|
||||||
allowedModes: new Set(['levelbased']),
|
allowedModes: new Set(['levelbased']),
|
||||||
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4 },
|
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
|
||||||
},
|
},
|
||||||
safety: {
|
safety: {
|
||||||
enableDryRunProtection: true,
|
enableDryRunProtection: true,
|
||||||
dryRunThresholdPercent: 2,
|
dryRunThresholdPercent: 2,
|
||||||
enableOverfillProtection: true,
|
enableHighVolumeSafety: true,
|
||||||
overfillThresholdPercent: 98,
|
highVolumeSafetyThresholdPercent: 98,
|
||||||
timeleftToFullOrEmptyThresholdSeconds: 0,
|
timeleftToFullOrEmptyThresholdSeconds: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -44,7 +44,7 @@ module.exports = {
|
|||||||
ps.measurements.type('flow').variant('predicted').position('out').child('mgc1').value(outflow, Date.now(), 'm3/s');
|
ps.measurements.type('flow').variant('predicted').position('out').child('mgc1').value(outflow, Date.now(), 'm3/s');
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
ps.calibratePredictedLevel(2.0); // start at the bottom of the RAMP zone
|
ps.calibratePredictedLevel(2.0); // start at the mode start level, below the rising ramp
|
||||||
},
|
},
|
||||||
|
|
||||||
inputs: (t, ps) => {
|
inputs: (t, ps) => {
|
||||||
@@ -55,6 +55,7 @@ module.exports = {
|
|||||||
{ name: 'no safety trips', type: 'safety_trips_eq', value: 0 },
|
{ name: 'no safety trips', type: 'safety_trips_eq', value: 0 },
|
||||||
{ name: 'level stays below overflow', type: 'max_level_bounded', value: 4.5 },
|
{ name: 'level stays below overflow', type: 'max_level_bounded', value: 4.5 },
|
||||||
{ name: 'level stays above outflow', type: 'min_level_bounded', value: 0.2 },
|
{ name: 'level stays above outflow', type: 'min_level_bounded', value: 0.2 },
|
||||||
|
{ name: 'rising ramp engages after inlet level', type: 'max_demand_gt', value: 0 },
|
||||||
{ name: 'no threshold issues on init', type: 'threshold_issues_eq', value: 0 },
|
{ name: 'no threshold issues on init', type: 'threshold_issues_eq', value: 0 },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,31 +1,31 @@
|
|||||||
// Storm surge — inflow triples briefly, pumps should saturate at 100%,
|
// Storm surge — inflow triples briefly, pumps should increase demand as
|
||||||
// level rises toward overflow then recedes.
|
// the level enters the rising ramp.
|
||||||
//
|
//
|
||||||
// Expectation: during the surge (t=300..600), demand reaches 100% and
|
// Expectation: during the surge (t=300..600), demand rises but remains
|
||||||
// level may transiently climb above maxLevel. Overflow safety should
|
// bounded. High-volume safety should fire if the surge overwhelms pump
|
||||||
// fire if the surge overwhelms pump capacity; dry-run should not fire.
|
// capacity; dry-run should not fire.
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
name: 'levelbased-storm',
|
name: 'levelbased-storm',
|
||||||
description: 'Sewer inflow triples from 0.008 → 0.024 m³/s for 5 minutes then returns to baseline. Overfill safety may engage.',
|
description: 'Sewer inflow triples from 0.008 → 0.024 m³/s for 5 minutes then returns to baseline. High-volume safety may engage.',
|
||||||
durationSec: 1500,
|
durationSec: 1500,
|
||||||
|
|
||||||
config: {
|
config: {
|
||||||
general: { name: 'EvalStorm', id: 'eval-storm', unit: 'm3/h',
|
general: { name: 'EvalStorm', id: 'eval-storm', unit: 'm3/h',
|
||||||
logging: { enabled: false, logLevel: 'error' } },
|
logging: { enabled: false, logLevel: 'error' } },
|
||||||
functionality: { softwareType: 'pumpingStation', role: 'stationcontroller', positionVsParent: 'atEquipment' },
|
functionality: { softwareType: 'pumpingStation', role: 'stationcontroller', positionVsParent: 'atEquipment' },
|
||||||
basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5 },
|
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' },
|
hydraulics: { refHeight: 'NAP', basinBottomRef: 0, minHeightBasedOn: 'outlet' },
|
||||||
control: {
|
control: {
|
||||||
mode: 'levelbased',
|
mode: 'levelbased',
|
||||||
allowedModes: new Set(['levelbased']),
|
allowedModes: new Set(['levelbased']),
|
||||||
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4 },
|
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
|
||||||
},
|
},
|
||||||
safety: {
|
safety: {
|
||||||
enableDryRunProtection: true,
|
enableDryRunProtection: true,
|
||||||
dryRunThresholdPercent: 2,
|
dryRunThresholdPercent: 2,
|
||||||
enableOverfillProtection: true,
|
enableHighVolumeSafety: true,
|
||||||
overfillThresholdPercent: 95,
|
highVolumeSafetyThresholdPercent: 95,
|
||||||
timeleftToFullOrEmptyThresholdSeconds: 0,
|
timeleftToFullOrEmptyThresholdSeconds: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -55,6 +55,6 @@ module.exports = {
|
|||||||
{ name: 'dry-run never trips', type: 'end_state_eq', field: 'safetyActive', value: false },
|
{ name: 'dry-run never trips', type: 'end_state_eq', field: 'safetyActive', value: false },
|
||||||
// Level may exceed maxLevel transiently but must stay under basinHeight
|
// Level may exceed maxLevel transiently but must stay under basinHeight
|
||||||
{ name: 'level never breaches physical basin', type: 'max_level_bounded', value: 5.0 },
|
{ name: 'level never breaches physical basin', type: 'max_level_bounded', value: 5.0 },
|
||||||
{ name: 'demand saturates at 100% during surge', type: 'max_demand_bounded', value: 100 },
|
{ name: 'demand remains bounded during surge', type: 'max_demand_bounded', value: 100 },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,18 +12,18 @@ module.exports = {
|
|||||||
general: { name: 'EvalDryRun', id: 'eval-dry-run', unit: 'm3/h',
|
general: { name: 'EvalDryRun', id: 'eval-dry-run', unit: 'm3/h',
|
||||||
logging: { enabled: false, logLevel: 'error' } },
|
logging: { enabled: false, logLevel: 'error' } },
|
||||||
functionality: { softwareType: 'pumpingStation', role: 'stationcontroller', positionVsParent: 'atEquipment' },
|
functionality: { softwareType: 'pumpingStation', role: 'stationcontroller', positionVsParent: 'atEquipment' },
|
||||||
basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5 },
|
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' },
|
hydraulics: { refHeight: 'NAP', basinBottomRef: 0, minHeightBasedOn: 'outlet' },
|
||||||
control: {
|
control: {
|
||||||
mode: 'manual',
|
mode: 'manual',
|
||||||
allowedModes: new Set(['levelbased', 'manual']),
|
allowedModes: new Set(['levelbased', 'manual']),
|
||||||
levelbased: { minLevel: 0.5, startLevel: 2, maxLevel: 4 },
|
levelbased: { minLevel: 0.5, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
|
||||||
},
|
},
|
||||||
safety: {
|
safety: {
|
||||||
enableDryRunProtection: true,
|
enableDryRunProtection: true,
|
||||||
dryRunThresholdPercent: 50,
|
dryRunThresholdPercent: 50,
|
||||||
enableOverfillProtection: false,
|
enableHighVolumeSafety: false,
|
||||||
overfillThresholdPercent: 98,
|
highVolumeSafetyThresholdPercent: 98,
|
||||||
timeleftToFullOrEmptyThresholdSeconds: 0,
|
timeleftToFullOrEmptyThresholdSeconds: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
74
test/basic/nodeClass-config.test.js
Normal file
74
test/basic/nodeClass-config.test.js
Normal 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);
|
||||||
|
});
|
||||||
@@ -51,9 +51,10 @@ Use a descriptive `alt` text; it's the fallback if the SVG fails and it shows up
|
|||||||
| Diagram | Shows |
|
| Diagram | Shows |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `basin-model` | Shared physical basin cross-section — walls, pipe reference heights, derived safety zones, storage/dead volumes |
|
| `basin-model` | Shared physical basin cross-section — walls, pipe reference heights, derived safety zones, storage/dead volumes |
|
||||||
| `modes/basin-mode-level-linear` | Level-linear control mode — `startLevel`, demand ramp, threshold-shift behaviour |
|
| `modes/level-based/basin-mode-level-linear` | Level-based linear control curve — rising ramp starts at inlet level, falling ramp shifts to `startLevel` |
|
||||||
|
| `modes/level-based/basin-mode-level-log` | Level-based logarithmic control curve — fast early response, falling ramp shifts to `startLevel` |
|
||||||
| `control-zones` | Legacy vertical level axis ("thermometer") for `levelbased` mode — STOP / DEAD ZONE / RUN with demand ramp |
|
| `control-zones` | Legacy vertical level axis ("thermometer") for `levelbased` mode — STOP / DEAD ZONE / RUN with demand ramp |
|
||||||
| `safety-rules` | Dry-run vs overfill rule asymmetry — which children stop, which keep running |
|
| `safety-rules` | Dry-run vs high-volume safety rule asymmetry — which children stop, which keep running |
|
||||||
|
|
||||||
## Making a brand-new diagram
|
## Making a brand-new diagram
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 271 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 319 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 256 KiB |
@@ -79,7 +79,7 @@ The current runtime still uses the level fields directly for its volume math. Pi
|
|||||||
| **Time To Empty/Full (s)** | `0` | If > 0, triggers safety when predicted time-to-overflow or time-to-empty falls below this value. `0` disables time-based protection. |
|
| **Time To Empty/Full (s)** | `0` | If > 0, triggers safety when predicted time-to-overflow or time-to-empty falls below this value. `0` disables time-based protection. |
|
||||||
| **Enable Dry-Run Protection** | `true` | If on, pumps are shut down once volume drops below the dry-run threshold while draining. |
|
| **Enable Dry-Run Protection** | `true` | If on, pumps are shut down once volume drops below the dry-run threshold while draining. |
|
||||||
| **Low Volume Threshold (%)** | `2` | Safety margin above the configured minimum volume: `dryRunSafetyVol = minVol × (1 + pct/100)`. This creates `dryRunLevel`; it is derived, not a separately entered basin height. |
|
| **Low Volume Threshold (%)** | `2` | Safety margin above the configured minimum volume: `dryRunSafetyVol = minVol × (1 + pct/100)`. This creates `dryRunLevel`; it is derived, not a separately entered basin height. |
|
||||||
| **Enable Overfill Protection** | `true` | If on, upstream inflows are shut down once volume climbs above the high-volume safety point while filling. |
|
| **Enable High-volume Safety** | `true` | If on, upstream inflows are shut down once volume climbs above the high-volume safety point while filling. |
|
||||||
| **High Volume Threshold (%)** | `98` | Safety margin below physical overflow: `highVolumeSafetyVol = maxVolAtOverflow × pct/100`. Actual overflowing is still the boolean condition `level >= overflowLevel`. |
|
| **High Volume Threshold (%)** | `98` | Safety margin below physical overflow: `highVolumeSafetyVol = maxVolAtOverflow × pct/100`. Actual overflowing is still the boolean condition `level >= overflowLevel`. |
|
||||||
|
|
||||||
### Output formats
|
### Output formats
|
||||||
@@ -152,12 +152,18 @@ Delta-compressed payload (only changed fields per tick). Keys follow the standar
|
|||||||
| `volumePercent.predicted.atequipment.default` | `(vol - minVol) / (maxVolAtOverflow - minVol) × 100` (%). |
|
| `volumePercent.predicted.atequipment.default` | `(vol - minVol) / (maxVolAtOverflow - minVol) × 100` (%). |
|
||||||
| `flow.predicted.in.<childId>` | Inflow contribution from a registered child (m³/s internally; editor unit on output). |
|
| `flow.predicted.in.<childId>` | Inflow contribution from a registered child (m³/s internally; editor unit on output). |
|
||||||
| `flow.predicted.out.<childId>` | Outflow contribution from a registered child. |
|
| `flow.predicted.out.<childId>` | Outflow contribution from a registered child. |
|
||||||
|
| `flow.predicted.overflow.default` | Synthetic spill rate over the weir while predicted volume is pinned at `maxVolAtOverflow` (m³/s). Zero when not spilling. Lives at its own position (not under `out`) so the operational outflow sum stays clean; `_selectBestNetFlow` folds it into the outflow side for net-flow balance, where it reads ~0 while pinned. |
|
||||||
| `flow.measured.<position>.<childId>` | Flow sensor reading. |
|
| `flow.measured.<position>.<childId>` | Flow sensor reading. |
|
||||||
| `netFlowRate.<variant>.atequipment.default` | Net flow used for control (inflow − outflow). |
|
| `netFlowRate.<variant>.atequipment.default` | Net flow used for control (inflow − outflow). |
|
||||||
|
| `overflowVolume.predicted.atequipment.default` | Cumulative predicted spill volume (m³) — for compliance reporting via InfluxDB. Monotonically non-decreasing. |
|
||||||
|
| `underflowVolume.predicted.atequipment.default` | Cumulative volume the integrator tried to drive below 0 m³ (m³). Diagnostic only, NOT compliance — a non-zero value indicates a flow-balance error (over-reported outflow / missing inflow source / pump curve too optimistic). |
|
||||||
| `direction` | `filling` / `draining` / `steady` / `unknown`. |
|
| `direction` | `filling` / `draining` / `steady` / `unknown`. |
|
||||||
| `flowSource` | Which variant drove the current control cycle (`measured`, `predicted`, `level:predicted`, `null`). |
|
| `flowSource` | Which variant drove the current control cycle (`measured`, `predicted`, `level:predicted`, `null`). |
|
||||||
| `timeleft` | Predicted seconds to overflow (while filling) or to dry-run (while draining). |
|
| `timeleft` | Predicted seconds to overflow (while filling) or to dry-run (while draining). |
|
||||||
| `volEmptyBasin`, `inflowLevel`, `overflowLevel`, `maxVol`, `maxVolAtOverflow`, `minVol`, `minVolAtInflow`, `minVolAtOutflow`, `minHeightBasedOn` | Echoes of the basin geometry for dashboards. |
|
| `volEmptyBasin`, `inflowLevel`, `overflowLevel`, `maxVol`, `maxVolAtOverflow`, `minVol`, `minVolAtInflow`, `minVolAtOutflow`, `minHeightBasedOn` | Echoes of the basin geometry for dashboards. |
|
||||||
|
| `predictedOverflowVolume` | Convenience top-level mirror of `overflowVolume.predicted.atequipment.default` (m³). |
|
||||||
|
| `predictedOverflowRate` | Convenience top-level mirror of `flow.predicted.overflow.default` (m³/s). |
|
||||||
|
| `predictedUnderflowVolume` | Convenience top-level mirror of `underflowVolume.predicted.atequipment.default` (m³). |
|
||||||
| `percControl` | Last demand (0–100+ %) forwarded to the machine group during level-based control. |
|
| `percControl` | Last demand (0–100+ %) forwarded to the machine group during level-based control. |
|
||||||
|
|
||||||
Consumers must cache and merge deltas — the example dashboard flows include a reusable function node that does exactly this.
|
Consumers must cache and merge deltas — the example dashboard flows include a reusable function node that does exactly this.
|
||||||
@@ -178,9 +184,9 @@ The basin is modelled as a rectangular prism with constant cross-section. Everyt
|
|||||||
|
|
||||||
*Editable source: [`diagrams/basin-model.drawio.svg`](diagrams/basin-model.drawio.svg) (drag into draw.io; the SVG embeds the editable source). See [`diagrams/README.md`](diagrams/README.md) for the edit-and-export workflow.*
|
*Editable source: [`diagrams/basin-model.drawio.svg`](diagrams/basin-model.drawio.svg) (drag into draw.io; the SVG embeds the editable source). See [`diagrams/README.md`](diagrams/README.md) for the edit-and-export workflow.*
|
||||||
|
|
||||||
**Generic basin ordering** (bottom → top): `outflowLevel ≤ dryRunLevel ≤ minLevel < inflowLevel < maxLevel ≤ highVolumeSafetyLevel < overflowLevel ≤ basinHeight`.
|
**Generic basin ordering** (bottom → top): `outflowLevel ≤ dryRunLevel < inflowLevel ≤ highVolumeSafetyLevel < overflowLevel ≤ basinHeight`.
|
||||||
|
|
||||||
`startLevel` is deliberately not part of this generic basin diagram. It belongs to a control mode. For the current level-linear mode, see [`diagrams/modes/basin-mode-level-linear.drawio.svg`](diagrams/modes/basin-mode-level-linear.drawio.svg).
|
`minLevel`, `startLevel`, and `maxLevel` are deliberately not part of this generic basin diagram. They belong to a control mode. For the current level-based mode variants, see [`diagrams/modes/level-based/`](diagrams/modes/level-based/).
|
||||||
|
|
||||||
The pipe labels are intentional:
|
The pipe labels are intentional:
|
||||||
|
|
||||||
@@ -215,6 +221,23 @@ The high-volume safety point exists so the station can still react before the ba
|
|||||||
|
|
||||||
The rectangular approximation is acceptable for this node's first basin model because operational level is always in metres from the basin floor, while calculated m³ can tolerate small shape errors. A later upgrade can replace `volume = level × surfaceArea` with a level-volume curve for benching, sumps, sediment/dead zones, and irregular wet-well geometry.
|
The rectangular approximation is acceptable for this node's first basin model because operational level is always in metres from the basin floor, while calculated m³ can tolerate small shape errors. A later upgrade can replace `volume = level × surfaceArea` with a level-volume curve for benching, sumps, sediment/dead zones, and irregular wet-well geometry.
|
||||||
|
|
||||||
|
### Predicted-volume bounds
|
||||||
|
|
||||||
|
The predicted-volume integrator is clamped between two physical limits. **Measured** values are never clamped — only a real sensor can show level outside this range (e.g. inflow exceeds pump+weir capacity and the basin pressurises against the ceiling).
|
||||||
|
|
||||||
|
**Upper bound — `maxVolAtOverflow`.** Once the integrator would push past the weir crest, the predicted level pins at `overflowLevel`. The excess is recorded two ways every tick it spills:
|
||||||
|
|
||||||
|
- **Cumulative `overflowVolume.predicted.atequipment.default`** — running total of spill in m³, for compliance reporting via InfluxDB.
|
||||||
|
- **Synthetic `flow.predicted.out.overflow`** — instantaneous spill rate (m³/s) equal to `inflow − real_outflow`. Registered as a predicted outflow contribution so `_selectBestNetFlow` sees a balanced ledger and reports `netFlowRate ≈ 0` while pinned. The integrator subtracts this synthetic flow before integrating so the spill never feeds back into the volume math.
|
||||||
|
|
||||||
|
The `isOverflowing` flag (true when `level >= overflowLevel`) is what tells operators why net flow reads zero even though water is still moving through the basin.
|
||||||
|
|
||||||
|
**Lower bound — `dryRunSafetyVol`.** The integrator can't drain below the dry-run threshold because pumps physically can't pump that low (the safety controller would shut them off, and even with safety disabled the suction loses prime). The clamp only fires on the transition — if the basin starts (or is calibrated) below `dryRunSafetyVol` it's left alone; inflow is what brings it back up.
|
||||||
|
|
||||||
|
### Level-rate fallback during overflow
|
||||||
|
|
||||||
|
When the chosen flow source is `level:measured` or `level:predicted` (priorities 3–4 in the ladder below), `dL/dt × surfaceArea` *is* the net flow. While level is pinned at `overflowLevel`, `dL/dt = 0` collapses the signal even though water is still moving. In that case `_selectBestNetFlow` holds the last known non-zero net flow until level starts dropping again — so dashboards keep a usable "this is roughly what's coming in" reading. The held value is refreshed any tick the level rate is meaningful, so it auto-updates once the basin un-pins.
|
||||||
|
|
||||||
## Net-flow selection
|
## Net-flow selection
|
||||||
|
|
||||||
Every tick, `_selectBestNetFlow()` walks a priority ladder and returns the first net flow that clears the dead-band (`|flow| ≥ flowThreshold`):
|
Every tick, `_selectBestNetFlow()` walks a priority ladder and returns the first net flow that clears the dead-band (`|flow| ≥ flowThreshold`):
|
||||||
@@ -245,7 +268,7 @@ flowPositions = { inflow: ['in', 'upstream'], outflow: ['out', 'downstream'] }
|
|||||||
|
|
||||||
The `pumpingStation` supports multiple control modes. Each mode is a **policy that maps basin state to demand (0-100 %)**. `levelbased` uses `minLevel`, `startLevel`, and `maxLevel`; other modes may use different thresholds or compute them dynamically.
|
The `pumpingStation` supports multiple control modes. Each mode is a **policy that maps basin state to demand (0-100 %)**. `levelbased` uses `minLevel`, `startLevel`, and `maxLevel`; other modes may use different thresholds or compute them dynamically.
|
||||||
|
|
||||||
The basin model owns the shared physical and safety references: pipe elevations, `dryRunLevel`, `highVolumeSafetyLevel`, and `overflowLevel`. `startLevel` is mode-specific and is documented with the mode diagrams, not the generic basin drawing.
|
The basin model owns the shared physical and safety references: pipe elevations, `dryRunLevel`, `highVolumeSafetyLevel`, and `overflowLevel`. `minLevel`, `startLevel`, and `maxLevel` are mode-specific and are documented with the mode diagrams, not the generic basin drawing.
|
||||||
|
|
||||||
Every mode gets its own page under [`modes/`](modes/README.md) with a consistent layout (inputs, threshold policy, demand formula, edge cases) so they can be compared side-by-side. Currently:
|
Every mode gets its own page under [`modes/`](modes/README.md) with a consistent layout (inputs, threshold policy, demand formula, edge cases) so they can be compared side-by-side. Currently:
|
||||||
|
|
||||||
@@ -261,7 +284,7 @@ See [`modes/README.md`](modes/README.md) for the index and page template.
|
|||||||
|
|
||||||
`_safetyController` runs **before** `_controlLogic` every tick. Two rules, deliberately asymmetric — *dry-run protects the pumps from running themselves into air*, *high-volume protection tries to preserve distance to actual overflow*.
|
`_safetyController` runs **before** `_controlLogic` every tick. Two rules, deliberately asymmetric — *dry-run protects the pumps from running themselves into air*, *high-volume protection tries to preserve distance to actual overflow*.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
During high-volume or overflow conditions, level-based control naturally commands >=100 % on the downstream MGC because the level is above `maxLevel`.
|
During high-volume or overflow conditions, level-based control naturally commands >=100 % on the downstream MGC because the level is above `maxLevel`.
|
||||||
|
|
||||||
@@ -319,8 +342,10 @@ The canonical end-to-end demo lives in the EVOLV superproject at [`examples/pump
|
|||||||
| "No volume data available to safe guard system; shutting down all machines." in logs | No measured level, predicted volume not calibrated, and no inflow/outflow samples yet. | Issue `calibratePredictedVolume` (or `calibratePredictedLevel`) once at startup, or wire a level sensor. |
|
| "No volume data available to safe guard system; shutting down all machines." in logs | No measured level, predicted volume not calibrated, and no inflow/outflow samples yet. | Issue `calibratePredictedVolume` (or `calibratePredictedLevel`) once at startup, or wire a level sensor. |
|
||||||
| `flowSource: null` and `direction: 'steady'` forever | Every flow / level signal falls inside the dead-band (default `1e-4 m³/s`). | Confirm flows are non-zero, or lower `config.general.flowThreshold` for a small-scale demo. |
|
| `flowSource: null` and `direction: 'steady'` forever | Every flow / level signal falls inside the dead-band (default `1e-4 m³/s`). | Confirm flows are non-zero, or lower `config.general.flowThreshold` for a small-scale demo. |
|
||||||
| `Qd` ignored | Station is not in `manual` mode. | Send `{ topic: 'changemode', payload: 'manual' }` first, or fall back to level-based control. |
|
| `Qd` ignored | Station is not in `manual` mode. | Send `{ topic: 'changemode', payload: 'manual' }` first, or fall back to level-based control. |
|
||||||
| Pumps keep running during overfill | Intended — overfill safety only stops **upstream** equipment; downstream pumps must drain. | To override, switch to `manual` and set `Qd = 0`, or issue an emergency-stop at the MGC. |
|
| Pumps keep running during high-volume safety | Intended — high-volume safety only stops **upstream** equipment; downstream pumps must drain. | To override, switch to `manual` and set `Qd = 0`, or issue an emergency-stop at the MGC. |
|
||||||
| Predicted volume drifts away from measured | Flow integrator has no reference — flows might have the wrong sign, or `unit` is mis-declared. | Call `calibratePredictedVolume` periodically from a measured level. |
|
| Predicted volume drifts away from measured | Flow integrator has no reference — flows might have the wrong sign, or `unit` is mis-declared. | Call `calibratePredictedVolume` periodically from a measured level. |
|
||||||
|
| Predicted level pinned at `overflowLevel` and `netFlowRate` reads ~0 | Intended while spilling — the synthetic `flow.predicted.out.overflow` balances the ledger so net is 0. Watch `isOverflowing`, `predictedOverflowRate`, and the cumulative `predictedOverflowVolume` instead. | Lower inflow (or raise pump capacity / `maxLevel`) to clear the overflow condition; level un-pins automatically. |
|
||||||
|
| Measured level above `overflowLevel` | Real-world ceiling-pressure case — inflow is exceeding pump *and* weir capacity. | This is the only path to "above overflow" in the model; predicted is clamped. Trust the sensor; treat as an alarmable event. |
|
||||||
|
|
||||||
## Running it locally
|
## Running it locally
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ updated: 2026-04-22
|
|||||||
|
|
||||||
# Level-based mode
|
# Level-based mode
|
||||||
|
|
||||||
The simplest and most widely deployed control strategy. Demand is a direct, *static* piecewise-linear function of basin level — no feedback loop, no predictions beyond the level measurement itself. This page uses the [shared basin model](../functional-description.md#basin-model); see [`modes/README.md`](README.md) for the template other mode pages follow.
|
The simplest and most widely deployed control strategy. Demand is a direct, static function of basin level — no feedback loop, no predictions beyond the level measurement itself. This page uses the [shared basin model](../functional-description.md#basin-model); see [`modes/README.md`](README.md) for the template other mode pages follow.
|
||||||
|
|
||||||
## At a glance
|
## At a glance
|
||||||
|
|
||||||
@@ -20,9 +20,9 @@ The simplest and most widely deployed control strategy. Demand is a direct, *sta
|
|||||||
|
|
||||||
## Diagram
|
## Diagram
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
*Editable source: [`../diagrams/modes/basin-mode-level-linear.drawio.svg`](../diagrams/modes/basin-mode-level-linear.drawio.svg) (drag into [draw.io](https://app.diagrams.net/) — it round-trips).*
|
*Editable sources: [`../diagrams/modes/level-based/basin-mode-level-linear.drawio.svg`](../diagrams/modes/level-based/basin-mode-level-linear.drawio.svg) and [`../diagrams/modes/level-based/basin-mode-level-log.drawio.svg`](../diagrams/modes/level-based/basin-mode-level-log.drawio.svg) (drag into [draw.io](https://app.diagrams.net/) — they round-trip).*
|
||||||
|
|
||||||
## Inputs
|
## Inputs
|
||||||
|
|
||||||
@@ -30,10 +30,11 @@ The simplest and most widely deployed control strategy. Demand is a direct, *sta
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| current level | `measurement` child (`measured`) → predicted from volume integrator (fallback) | X-axis of the transfer function |
|
| current level | `measurement` child (`measured`) → predicted from volume integrator (fallback) | X-axis of the transfer function |
|
||||||
| `config.control.levelbased.minLevel` | editor, static | below → pumps hard OFF |
|
| `config.control.levelbased.minLevel` | editor, static | below → pumps hard OFF |
|
||||||
| `config.control.levelbased.startLevel` | editor, static | where demand-ramp starts |
|
| `config.control.levelbased.startLevel` | editor, static | falling ramp reaches 0 % here; rising demand holds 0 % until the inlet level |
|
||||||
| `config.control.levelbased.maxLevel` | editor, static | where demand saturates at 100 % |
|
| `config.control.levelbased.maxLevel` | editor, static | where demand saturates at 100 % |
|
||||||
|
| `config.control.levelbased.curveType` | editor, static | `linear` or `log`; log is fast early response |
|
||||||
|
|
||||||
The three control thresholds are the **only** mode-specific configuration. Nothing here is recomputed at runtime.
|
The three control thresholds plus curve type are the mode-specific configuration. Nothing here is recomputed at runtime.
|
||||||
|
|
||||||
## Threshold policy
|
## Threshold policy
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ The three control thresholds are the **only** mode-specific configuration. Nothi
|
|||||||
| `minLevel` | `config.control.levelbased.minLevel` | No |
|
| `minLevel` | `config.control.levelbased.minLevel` | No |
|
||||||
| `startLevel` | `config.control.levelbased.startLevel` | No |
|
| `startLevel` | `config.control.levelbased.startLevel` | No |
|
||||||
| `maxLevel` | `config.control.levelbased.maxLevel` | No |
|
| `maxLevel` | `config.control.levelbased.maxLevel` | No |
|
||||||
|
| `curveType` | `config.control.levelbased.curveType` | No |
|
||||||
|
|
||||||
That this policy is trivial (all static) is **the defining simplicity of this mode**. Modes like `powerBased` or future `weather-aware` variants will recompute these thresholds on the fly.
|
That this policy is trivial (all static) is **the defining simplicity of this mode**. Modes like `powerBased` or future `weather-aware` variants will recompute these thresholds on the fly.
|
||||||
|
|
||||||
@@ -51,15 +53,15 @@ That this policy is trivial (all static) is **the defining simplicity of this mo
|
|||||||
if level < minLevel:
|
if level < minLevel:
|
||||||
demand = 0
|
demand = 0
|
||||||
MGC → turnOffAllMachines() # explicit shutdown, not just "0 %"
|
MGC → turnOffAllMachines() # explicit shutdown, not just "0 %"
|
||||||
elif level < startLevel:
|
elif direction == filling:
|
||||||
demand = <previous demand> # dead zone — hold last command (hysteresis)
|
demand = curve(level, [inflowLevel, maxLevel], [0 %, 100 %])
|
||||||
elif level <= maxLevel:
|
elif direction == draining:
|
||||||
demand = lerp(level, [startLevel, maxLevel], [0 %, 100 %])
|
demand = curve(level, [startLevel, maxLevel], [0 %, 100 %])
|
||||||
else:
|
else:
|
||||||
demand = 100 % # saturated; MGC clamps internally if overshoot
|
demand = previous demand
|
||||||
```
|
```
|
||||||
|
|
||||||
Where `lerp` is linear interpolation. The MGC is free to distribute the demand across its pumps however its own policy dictates (equal split, lead-lag, staging — that's the MGC's business).
|
Below the active lower ramp point, demand is 0 %. Above `maxLevel`, demand is 100 %. `curve` is either linear or logarithmic; the log variant rises faster early in the ramp. The MGC is free to distribute the demand across its pumps however its own policy dictates (equal split, lead-lag, staging — that's the MGC's business).
|
||||||
|
|
||||||
## Edge cases
|
## Edge cases
|
||||||
|
|
||||||
|
|||||||
@@ -67,11 +67,11 @@ demandCap = min(100, 100 × maxPowerKW(t) / nominalStationPower)
|
|||||||
demand = min(rawDemand, demandCap)
|
demand = min(rawDemand, demandCap)
|
||||||
```
|
```
|
||||||
|
|
||||||
When `demandCap < rawDemand`, the mode sacrifices drainage rate to stay within power budget. Level may rise — the overfill safety layer still applies as the last line of defence.
|
When `demandCap < rawDemand`, the mode sacrifices drainage rate to stay within power budget. Level may rise — the high-volume safety layer still applies as the last line of defence before physical overflow.
|
||||||
|
|
||||||
## Edge cases
|
## Edge cases
|
||||||
|
|
||||||
- **Peak hour with rising level.** demandCap drops faster than level rises → demand gets clipped; level approaches `overflowLevel`. If overfill safety trips, it overrides the clip (safety wins).
|
- **Peak hour with rising level.** demandCap drops faster than level rises → demand gets clipped; level approaches `overflowLevel`. If high-volume safety trips, it overrides the clip (safety wins).
|
||||||
- **Power signal dropout.** Fall back to static `maxPowerKW` from config; log warning.
|
- **Power signal dropout.** Fall back to static `maxPowerKW` from config; log warning.
|
||||||
- **Grid exit from peak while basin is nearly full.** demandCap jumps back to 100; PID is memoryless so demand rises in one tick to match rawDemand.
|
- **Grid exit from peak while basin is nearly full.** demandCap jumps back to 100; PID is memoryless so demand rises in one tick to match rawDemand.
|
||||||
- **Measured vs predicted pump power.** Cap is enforced on predicted (decisions are made before the pump responds). Reconcile against measured for logging/diagnostics.
|
- **Measured vs predicted pump power.** Cap is enforced on predicted (decisions are made before the pump responds). Reconcile against measured for logging/diagnostics.
|
||||||
|
|||||||
Reference in New Issue
Block a user